import 'package:background_downloader/background_downloader.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../controllers/upload_controller.dart'; import '../core/app_theme.dart'; class UploadHistoryScreen extends StatefulWidget { const UploadHistoryScreen({super.key}); @override State createState() => _UploadHistoryScreenState(); } class _UploadHistoryScreenState extends State { @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) context.read().refreshHistory(); }); } @override Widget build(BuildContext context) => Scaffold( appBar: AppBar( title: const Text( 'Upload history', style: TextStyle(fontWeight: FontWeight.w800), ), actions: [ Consumer( builder: (context, controller, _) => controller.uploads.any((item) => item.isFinished) ? TextButton( onPressed: () => _clearFinished(controller), child: const Text('Clear finished'), ) : const SizedBox.shrink(), ), const SizedBox(width: 6), ], ), body: Consumer( builder: (context, controller, _) { final uploads = controller.uploads; if (uploads.isEmpty) { return const _HistoryEmptyState(); } final active = uploads.where((item) => !item.isFinished).length; final complete = uploads .where((item) => item.status == TaskStatus.complete) .length; final failed = uploads .where( (item) => item.status == TaskStatus.failed || item.status == TaskStatus.notFound, ) .length; return RefreshIndicator( onRefresh: controller.refreshHistory, child: ListView( padding: const EdgeInsets.fromLTRB(16, 14, 16, 28), children: [ _Summary(active: active, complete: complete, failed: failed), const SizedBox(height: 18), for (final item in uploads) ...[ _UploadCard( item: item, onOpen: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => UploadDetailScreen(taskId: item.taskId), ), ), onCancel: () => controller.cancel(item.taskId), onRetry: () => _retry(controller, item.taskId), onRemove: () => controller.removeHistory(item.taskId), ), const SizedBox(height: 10), ], ], ), ); }, ), ); Future _retry(UploadController controller, String taskId) async { try { await controller.retry(taskId); } catch (error) { if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(_cleanError(error)))); } } } Future _clearFinished(UploadController controller) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Clear finished uploads?'), content: const Text( 'This removes completed, failed, and canceled entries from local history. Files already in Google Drive are not deleted.', ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Keep'), ), FilledButton( onPressed: () => Navigator.pop(context, true), child: const Text('Clear'), ), ], ), ); if (confirmed == true) await controller.clearFinished(); } } class UploadDetailScreen extends StatelessWidget { const UploadDetailScreen({super.key, required this.taskId}); final String taskId; @override Widget build(BuildContext context) => Consumer( builder: (context, controller, _) { final matches = controller.uploads.where((item) => item.taskId == taskId); if (matches.isEmpty) { return Scaffold( appBar: AppBar(title: const Text('Upload details')), body: const Center(child: Text('This history entry was removed.')), ); } final item = matches.first; final visual = _statusVisual(item.status); return Scaffold( appBar: AppBar( title: const Text( 'Upload details', style: TextStyle(fontWeight: FontWeight.w800), ), ), body: ListView( padding: const EdgeInsets.all(20), children: [ Card( child: Padding( padding: const EdgeInsets.all(22), child: Column( children: [ CircleAvatar( radius: 31, backgroundColor: visual.color.withValues(alpha: .12), child: Icon(visual.icon, color: visual.color, size: 32), ), const SizedBox(height: 14), Text( item.name, textAlign: TextAlign.center, style: const TextStyle( fontSize: 19, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 5), Text( visual.label, style: TextStyle( color: visual.color, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 18), LinearProgressIndicator( value: item.progress.clamp(0, 1), minHeight: 8, borderRadius: BorderRadius.circular(8), color: visual.color, backgroundColor: visual.color.withValues(alpha: .12), ), const SizedBox(height: 8), Text('${(item.progress.clamp(0, 1) * 100).round()}%'), ], ), ), ), const SizedBox(height: 14), Card( child: Padding( padding: const EdgeInsets.all(18), child: Column( children: [ _DetailRow( label: 'Started', value: _dateTime(item.createdAt), ), _DetailRow( label: 'File size', value: _size(item.sizeBytes), ), _DetailRow(label: 'Status', value: visual.label), _DetailRow(label: 'Task ID', value: item.taskId), if (item.errorMessage != null) _DetailRow(label: 'Error', value: item.errorMessage!), ], ), ), ), const SizedBox(height: 18), if (!item.isFinished) OutlinedButton.icon( onPressed: () => controller.cancel(item.taskId), icon: const Icon(Icons.close_rounded), label: const Text('Cancel upload'), ), if (item.canRetry) FilledButton.icon( onPressed: () async { try { await controller.retry(item.taskId); } catch (error) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(_cleanError(error))), ); } } }, icon: const Icon(Icons.refresh_rounded), label: const Text('Retry upload'), ), ], ), ); }, ); } class _Summary extends StatelessWidget { const _Summary({ required this.active, required this.complete, required this.failed, }); final int active; final int complete; final int failed; @override Widget build(BuildContext context) => Row( children: [ Expanded( child: _SummaryItem( value: active, label: 'Active', color: AppTheme.blue, ), ), const SizedBox(width: 8), Expanded( child: _SummaryItem( value: complete, label: 'Done', color: const Color(0xFF15805A), ), ), const SizedBox(width: 8), Expanded( child: _SummaryItem( value: failed, label: 'Failed', color: const Color(0xFFC43B3B), ), ), ], ); } class _SummaryItem extends StatelessWidget { const _SummaryItem({ required this.value, required this.label, required this.color, }); final int value; final String label; final Color color; @override Widget build(BuildContext context) => Card( child: Padding( padding: const EdgeInsets.symmetric(vertical: 15), child: Column( children: [ Text( '$value', style: TextStyle( fontSize: 22, fontWeight: FontWeight.w800, color: color, ), ), Text( label, style: const TextStyle(fontSize: 12, color: Colors.black54), ), ], ), ), ); } class _UploadCard extends StatelessWidget { const _UploadCard({ required this.item, required this.onOpen, required this.onCancel, required this.onRetry, required this.onRemove, }); final UploadRecord item; final VoidCallback onOpen; final VoidCallback onCancel; final VoidCallback onRetry; final VoidCallback onRemove; @override Widget build(BuildContext context) { final visual = _statusVisual(item.status); return Card( clipBehavior: Clip.antiAlias, child: InkWell( onTap: onOpen, child: Padding( padding: const EdgeInsets.fromLTRB(14, 14, 8, 14), child: Row( children: [ CircleAvatar( backgroundColor: visual.color.withValues(alpha: .12), child: Icon(visual.icon, color: visual.color), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w700), ), const SizedBox(height: 3), Text( '${visual.label} • ${_dateTime(item.createdAt)}', style: TextStyle(fontSize: 11, color: visual.color), ), const SizedBox(height: 8), LinearProgressIndicator( value: item.progress.clamp(0, 1), minHeight: 5, borderRadius: BorderRadius.circular(5), color: visual.color, backgroundColor: visual.color.withValues(alpha: .12), ), ], ), ), PopupMenuButton( onSelected: (value) { if (value == 'cancel') onCancel(); if (value == 'retry') onRetry(); if (value == 'remove') onRemove(); }, itemBuilder: (_) => [ if (!item.isFinished) const PopupMenuItem(value: 'cancel', child: Text('Cancel')), if (item.canRetry) const PopupMenuItem(value: 'retry', child: Text('Retry')), if (item.isFinished) const PopupMenuItem( value: 'remove', child: Text('Remove history'), ), ], ), ], ), ), ), ); } } class _DetailRow extends StatelessWidget { const _DetailRow({required this.label, required this.value}); final String label; final String value; @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 82, child: Text(label, style: const TextStyle(color: Colors.black54)), ), Expanded( child: SelectableText( value, style: const TextStyle(fontWeight: FontWeight.w600), ), ), ], ), ); } class _HistoryEmptyState extends StatelessWidget { const _HistoryEmptyState(); @override Widget build(BuildContext context) => const Center( child: Padding( padding: EdgeInsets.all(36), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.history_rounded, size: 64, color: AppTheme.blue), SizedBox(height: 16), Text( 'No uploads yet', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800), ), SizedBox(height: 6), Text( 'Uploads and their progress will appear here.', textAlign: TextAlign.center, style: TextStyle(color: Colors.black54), ), ], ), ), ); } ({String label, IconData icon, Color color}) _statusVisual(TaskStatus status) => switch (status) { TaskStatus.enqueued => ( label: 'Queued', icon: Icons.schedule_rounded, color: AppTheme.blue, ), TaskStatus.running => ( label: 'Uploading', icon: Icons.cloud_upload_rounded, color: AppTheme.blue, ), TaskStatus.complete => ( label: 'Uploaded', icon: Icons.check_circle_rounded, color: const Color(0xFF15805A), ), TaskStatus.failed => ( label: 'Failed', icon: Icons.error_rounded, color: const Color(0xFFC43B3B), ), TaskStatus.notFound => ( label: 'Not found', icon: Icons.error_rounded, color: const Color(0xFFC43B3B), ), TaskStatus.canceled => ( label: 'Canceled', icon: Icons.cancel_rounded, color: Colors.blueGrey, ), TaskStatus.waitingToRetry => ( label: 'Waiting to retry', icon: Icons.refresh_rounded, color: const Color(0xFFB36B00), ), TaskStatus.paused => ( label: 'Paused', icon: Icons.pause_circle_rounded, color: const Color(0xFFB36B00), ), }; String _dateTime(DateTime value) { final local = value.toLocal(); String two(int number) => number.toString().padLeft(2, '0'); return '${two(local.day)}/${two(local.month)}/${local.year} ${two(local.hour)}:${two(local.minute)}'; } String _size(int bytes) { if (bytes < 0) return 'Calculating'; if (bytes < 1024) return '$bytes B'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; if (bytes < 1024 * 1024 * 1024) { return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB'; } return '${(bytes / 1024 / 1024 / 1024).toStringAsFixed(2)} GB'; } String _cleanError(Object error) => error .toString() .replaceFirst('Bad state: ', '') .replaceFirst('Exception: ', '');