From 6866a89278a4d47f14f8892999a00a3a15d7cb3a Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 8 Jan 2026 14:47:22 -0600 Subject: [PATCH] better upload detail page --- .../backup/drift_upload_detail.page.dart | 578 +++++++++++++----- 1 file changed, 410 insertions(+), 168 deletions(-) diff --git a/mobile/lib/pages/backup/drift_upload_detail.page.dart b/mobile/lib/pages/backup/drift_upload_detail.page.dart index 135da2eef6..020be1a7e5 100644 --- a/mobile/lib/pages/backup/drift_upload_detail.page.dart +++ b/mobile/lib/pages/backup/drift_upload_detail.page.dart @@ -11,24 +11,63 @@ import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:path/path.dart' as path; @RoutePage() -class DriftUploadDetailPage extends ConsumerWidget { +class DriftUploadDetailPage extends ConsumerStatefulWidget { const DriftUploadDetailPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _DriftUploadDetailPageState(); +} + +class _DriftUploadDetailPageState extends ConsumerState { + final List _completedItems = []; + final Set _seenTaskIds = {}; + + @override + Widget build(BuildContext context) { final uploadItems = ref.watch(driftBackupProvider.select((state) => state.uploadItems)); final iCloudProgress = ref.watch(driftBackupProvider.select((state) => state.iCloudDownloadProgress)); + // Watch for items with progress >= 1.0 (completed) before they get removed + for (final item in uploadItems.values) { + if (item.progress >= 1.0 && item.isFailed != true) { + if (!_seenTaskIds.contains(item.taskId)) { + _seenTaskIds.add(item.taskId); + _completedItems.insert(0, item); + if (_completedItems.length > 50) { + _completedItems.removeLast(); + } + } + } + } + + // Get current uploading items (progress < 1.0 or failed) + final currentUploads = uploadItems.values.where((item) => item.progress < 1.0 || item.isFailed == true).toList(); + + final hasContent = currentUploads.isNotEmpty || iCloudProgress.isNotEmpty || _completedItems.isNotEmpty; + return Scaffold( appBar: AppBar( title: Text("upload_details".t(context: context)), backgroundColor: context.colorScheme.surface, elevation: 0, scrolledUnderElevation: 1, + actions: [ + if (_completedItems.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear_all), + tooltip: "Clear completed", + onPressed: () { + setState(() { + _completedItems.clear(); + _seenTaskIds.clear(); + }); + }, + ), + ], ), - body: uploadItems.isEmpty && iCloudProgress.isEmpty + body: !hasContent ? _buildEmptyState(context) - : _buildUploadList(uploadItems, iCloudProgress), + : _buildTwoSectionLayout(context, currentUploads, iCloudProgress, _completedItems), ); } @@ -48,24 +87,112 @@ class DriftUploadDetailPage extends ConsumerWidget { ); } - Widget _buildUploadList(Map uploadItems, Map iCloudProgress) { - final totalItems = uploadItems.length + iCloudProgress.length; + Widget _buildTwoSectionLayout( + BuildContext context, + List currentUploads, + Map iCloudProgress, + List completedItems, + ) { + return CustomScrollView( + slivers: [ + // iCloud Downloads Section + if (iCloudProgress.isNotEmpty) ...[ + SliverToBoxAdapter( + child: _buildSectionHeader( + context, + title: "Downloading from iCloud", + count: iCloudProgress.length, + color: context.colorScheme.tertiary, + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final entry = iCloudProgress.entries.elementAt(index); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _buildICloudDownloadCard(context, entry.key, entry.value), + ); + }, childCount: iCloudProgress.length), + ), + ), + ], - return ListView.separated( - addAutomaticKeepAlives: true, - padding: const EdgeInsets.all(16), - itemCount: totalItems, - separatorBuilder: (context, index) => const SizedBox(height: 4), - itemBuilder: (context, index) { - if (index < iCloudProgress.length) { - final entry = iCloudProgress.entries.elementAt(index); - return _buildICloudDownloadCard(context, entry.key, entry.value); - } + SliverToBoxAdapter( + child: _buildSectionHeader( + context, + title: "uploading".t(context: context), + color: context.colorScheme.primary, + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + if (index < currentUploads.length) { + final item = currentUploads[index]; + return _buildCurrentUploadCard(context, item); + } else { + return _buildPlaceholderCard(context); + } + }, childCount: 3), + ), + ), - final uploadIndex = index - iCloudProgress.length; - final item = uploadItems.values.elementAt(uploadIndex); - return _buildUploadCard(context, item); - }, + // Completed Section + if (completedItems.isNotEmpty) ...[ + SliverToBoxAdapter( + child: _buildSectionHeader( + context, + title: "completed".t(context: context), + count: completedItems.length, + color: Colors.green, + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final item = completedItems[index]; + return Padding(padding: const EdgeInsets.only(bottom: 4), child: _buildCompletedCard(context, item)); + }, childCount: completedItems.length), + ), + ), + ], + + // Bottom padding + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ], + ); + } + + Widget _buildSectionHeader(BuildContext context, {required String title, int? count, required Color color}) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600, color: color), + ), + const SizedBox(width: 8), + count != null + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + child: Text( + count.toString(), + style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.bold, color: color), + ), + ) + : const SizedBox.shrink(), + ], + ), ); } @@ -74,140 +201,194 @@ class DriftUploadDetailPage extends ConsumerWidget { return Card( elevation: 0, - color: context.colorScheme.surfaceContainerHighest, + color: context.colorScheme.tertiaryContainer.withValues(alpha: 0.5), shape: RoundedRectangleBorder( - borderRadius: const BorderRadius.all(Radius.circular(16)), - side: BorderSide(color: context.colorScheme.outline.withValues(alpha: 0.1), width: 1), + borderRadius: const BorderRadius.all(Radius.circular(12)), + side: BorderSide(color: context.colorScheme.tertiary.withValues(alpha: 0.3), width: 1), ), child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + padding: const EdgeInsets.all(12), + child: Row( children: [ - Row( + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: context.colorScheme.tertiary.withValues(alpha: 0.2), + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + child: Icon(Icons.cloud_download_rounded, size: 24, color: context.colorScheme.tertiary), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Downloading from iCloud...", + style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + assetId, + style: context.textTheme.bodySmall?.copyWith( + color: context.colorScheme.onSurface.withValues(alpha: 0.6), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: LinearProgressIndicator( + value: progress, + backgroundColor: context.colorScheme.tertiary.withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation(context.colorScheme.tertiary), + minHeight: 4, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 48, + child: Text( + "${progressPercentage.toStringAsFixed(0)}%", + textAlign: TextAlign.right, + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: context.colorScheme.tertiary, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildCurrentUploadCard(BuildContext context, DriftUploadStatus item) { + final double progressPercentage = (item.progress * 100).clamp(0, 100); + final isFailed = item.isFailed == true; + + return Card( + elevation: 0, + color: isFailed + ? context.colorScheme.errorContainer + : context.colorScheme.primaryContainer.withValues(alpha: 0.5), + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(12)), + side: BorderSide( + color: isFailed + ? context.colorScheme.error.withValues(alpha: 0.3) + : context.colorScheme.primary.withValues(alpha: 0.3), + width: 1, + ), + ), + child: InkWell( + onTap: () => _showFileDetailDialog(context, item), + borderRadius: const BorderRadius.all(Radius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(12), + child: SizedBox( + height: 56, + child: Row( children: [ - Icon(Icons.cloud_download_rounded, size: 20, color: context.colorScheme.primary), - const SizedBox(width: 8), + _CurrentUploadThumbnail(taskId: item.taskId), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - spacing: 4, children: [ Text( - "Downloading from iCloud", - style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + path.basename(item.filename), + style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), maxLines: 1, overflow: TextOverflow.ellipsis, ), + const SizedBox(height: 2), Text( - assetId, + isFailed + ? item.error ?? "Upload failed" + : "${formatHumanReadableBytes(item.fileSize, 1)} • ${item.networkSpeedAsString}", style: context.textTheme.bodySmall?.copyWith( - color: context.colorScheme.onSurface.withValues(alpha: 0.6), + color: isFailed + ? context.colorScheme.error + : context.colorScheme.onSurface.withValues(alpha: 0.6), ), maxLines: 1, overflow: TextOverflow.ellipsis, ), + if (!isFailed) ...[ + const SizedBox(height: 8), + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: LinearProgressIndicator( + value: item.progress, + backgroundColor: context.colorScheme.primary.withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation(context.colorScheme.primary), + minHeight: 4, + ), + ), + ], ], ), ), - _buildICloudProgressIndicator(context, progress, progressPercentage), + const SizedBox(width: 12), + SizedBox( + width: 48, + child: isFailed + ? Icon(Icons.error_rounded, color: context.colorScheme.error, size: 28) + : Text( + "${progressPercentage.toStringAsFixed(0)}%", + textAlign: TextAlign.right, + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: context.colorScheme.primary, + ), + ), + ), ], ), - ], + ), ), ), ); } - Widget _buildICloudProgressIndicator(BuildContext context, double progress, double percentage) { - return Column( - children: [ - Stack( - alignment: AlignmentDirectional.center, - children: [ - SizedBox( - width: 36, - height: 36, - child: TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: progress), - duration: const Duration(milliseconds: 300), - builder: (context, value, _) => CircularProgressIndicator( - backgroundColor: context.colorScheme.outline.withValues(alpha: 0.2), - strokeWidth: 3, - value: value, - color: context.colorScheme.primary, - ), - ), - ), - Text( - percentage.toStringAsFixed(0), - style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.bold, fontSize: 10), - ), - ], - ), - Text("iCloud", style: context.textTheme.labelSmall?.copyWith(color: context.colorScheme.primary, fontSize: 10)), - ], - ); - } - - Widget _buildUploadCard(BuildContext context, DriftUploadStatus item) { - final isCompleted = item.progress >= 1.0; - final double progressPercentage = (item.progress * 100).clamp(0, 100); - + Widget _buildCompletedCard(BuildContext context, DriftUploadStatus item) { return Card( elevation: 0, - color: item.isFailed != null ? context.colorScheme.errorContainer : context.colorScheme.surfaceContainer, + color: context.colorScheme.surfaceContainerLow, shape: RoundedRectangleBorder( - borderRadius: const BorderRadius.all(Radius.circular(16)), + borderRadius: const BorderRadius.all(Radius.circular(8)), side: BorderSide(color: context.colorScheme.outline.withValues(alpha: 0.1), width: 1), ), child: InkWell( onTap: () => _showFileDetailDialog(context, item), - borderRadius: const BorderRadius.all(Radius.circular(16)), + borderRadius: const BorderRadius.all(Radius.circular(8)), child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 4, - children: [ - Text( - path.basename(item.filename), - style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (item.error != null) - Text( - item.error!, - style: context.textTheme.bodySmall?.copyWith( - color: context.colorScheme.onErrorContainer.withValues(alpha: 0.6), - ), - ), - Text( - "backup_upload_details_page_more_details".t(context: context), - style: context.textTheme.bodySmall?.copyWith( - color: context.colorScheme.onSurface.withValues(alpha: 0.6), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - _buildProgressIndicator( - context, - item.progress, - progressPercentage, - isCompleted, - item.networkSpeedAsString, - ), - ], + Icon(Icons.check_circle, size: 20, color: Colors.green.shade600), + const SizedBox(width: 12), + Expanded( + child: Text( + path.basename(item.filename), + style: context.textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + formatHumanReadableBytes(item.fileSize, 1), + style: context.textTheme.bodySmall?.copyWith( + color: context.colorScheme.onSurface.withValues(alpha: 0.5), + ), ), ], ), @@ -216,49 +397,84 @@ class DriftUploadDetailPage extends ConsumerWidget { ); } - Widget _buildProgressIndicator( - BuildContext context, - double progress, - double percentage, - bool isCompleted, - String networkSpeedAsString, - ) { - return Column( - children: [ - Stack( - alignment: AlignmentDirectional.center, - children: [ - SizedBox( - width: 36, - height: 36, - child: TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: progress), - duration: const Duration(milliseconds: 300), - builder: (context, value, _) => CircularProgressIndicator( - backgroundColor: context.colorScheme.outline.withValues(alpha: 0.2), - strokeWidth: 3, - value: value, - color: isCompleted ? context.colorScheme.primary : context.colorScheme.secondary, + Widget _buildPlaceholderCard(BuildContext context) { + return Card( + elevation: 0, + color: context.colorScheme.surfaceContainerLow.withValues(alpha: 0.5), + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(12)), + side: BorderSide(color: context.colorScheme.outline.withValues(alpha: 0.1), width: 1, style: BorderStyle.solid), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: SizedBox( + height: 56, + child: Row( + children: [ + SizedBox( + width: 48, + height: 48, + child: Container( + decoration: BoxDecoration( + color: context.colorScheme.outline.withValues(alpha: 0.1), + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + child: Icon( + Icons.hourglass_empty_rounded, + size: 24, + color: context.colorScheme.outline.withValues(alpha: 0.3), + ), ), ), - ), - if (isCompleted) - Icon(Icons.check_circle_rounded, size: 28, color: context.colorScheme.primary) - else - Text( - percentage.toStringAsFixed(0), - style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.bold, fontSize: 10), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 14, + width: 120, + decoration: BoxDecoration( + color: context.colorScheme.outline.withValues(alpha: 0.1), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + ), + const SizedBox(height: 6), + Container( + height: 10, + width: 80, + decoration: BoxDecoration( + color: context.colorScheme.outline.withValues(alpha: 0.08), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + ), + const SizedBox(height: 8), + Container( + height: 4, + decoration: BoxDecoration( + color: context.colorScheme.outline.withValues(alpha: 0.1), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + ), + ], + ), ), - ], - ), - Text( - networkSpeedAsString, - style: context.textTheme.labelSmall?.copyWith( - color: context.colorScheme.onSurface.withValues(alpha: 0.6), - fontSize: 10, + const SizedBox(width: 12), + SizedBox( + width: 48, + child: Text( + "0%", + textAlign: TextAlign.right, + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: context.colorScheme.outline.withValues(alpha: 0.3), + ), + ), + ), + ], ), ), - ], + ), ); } @@ -270,9 +486,44 @@ class DriftUploadDetailPage extends ConsumerWidget { } } +class _CurrentUploadThumbnail extends ConsumerWidget { + final String taskId; + const _CurrentUploadThumbnail({required this.taskId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return FutureBuilder( + future: _getAsset(ref), + builder: (context, snapshot) { + return SizedBox( + width: 48, + height: 48, + child: Container( + decoration: BoxDecoration( + color: context.colorScheme.primary.withValues(alpha: 0.2), + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + clipBehavior: Clip.antiAlias, + child: snapshot.data != null + ? Thumbnail.fromAsset(asset: snapshot.data!, size: const Size(48, 48), fit: BoxFit.cover) + : Icon(Icons.image, size: 24, color: context.colorScheme.primary), + ), + ); + }, + ); + } + + Future _getAsset(WidgetRef ref) async { + try { + return await ref.read(localAssetRepository).getById(taskId); + } catch (e) { + return null; + } + } +} + class FileDetailDialog extends ConsumerWidget { final DriftUploadStatus uploadStatus; - const FileDetailDialog({super.key, required this.uploadStatus}); @override @@ -304,14 +555,12 @@ class FileDetailDialog extends ConsumerWidget { if (snapshot.connectionState == ConnectionState.waiting) { return const SizedBox(height: 200, child: Center(child: CircularProgressIndicator())); } - final asset = snapshot.data; return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - // Thumbnail at the top center Center( child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(12)), @@ -329,7 +578,7 @@ class FileDetailDialog extends ConsumerWidget { ), ), const SizedBox(height: 24), - if (asset != null) ...[ + if (asset != null) _buildInfoSection(context, [ _buildInfoRow(context, "filename".t(context: context), path.basename(uploadStatus.filename)), _buildInfoRow(context, "local_id".t(context: context), asset.id), @@ -346,7 +595,6 @@ class FileDetailDialog extends ConsumerWidget { if (asset.checksum != null) _buildInfoRow(context, "checksum".t(context: context), asset.checksum!), ]), - ], ], ), ); @@ -374,7 +622,7 @@ class FileDetailDialog extends ConsumerWidget { borderRadius: const BorderRadius.all(Radius.circular(12)), border: Border.all(color: context.colorScheme.outline.withValues(alpha: 0.1), width: 1), ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [...children]), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children), ); } @@ -395,12 +643,7 @@ class FileDetailDialog extends ConsumerWidget { ), ), Expanded( - child: Text( - value, - style: context.textTheme.labelMedium?.copyWith(), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), + child: Text(value, style: context.textTheme.labelMedium, maxLines: 3, overflow: TextOverflow.ellipsis), ), ], ), @@ -409,8 +652,7 @@ class FileDetailDialog extends ConsumerWidget { Future _getAssetDetails(WidgetRef ref, String localAssetId) async { try { - final repository = ref.read(localAssetRepository); - return await repository.getById(localAssetId); + return await ref.read(localAssetRepository).getById(localAssetId); } catch (e) { return null; }