merge: remote-tracking branch 'origin/main' into feat/integrity-checks-izzy

This commit is contained in:
izzy
2026-01-16 09:43:30 +00:00
parent 28443a090d
commit e9dafefb02
46 changed files with 2234 additions and 1225 deletions

View File

@@ -11,12 +11,70 @@ 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<DriftUploadDetailPage> createState() => _DriftUploadDetailPageState();
}
class _DriftUploadDetailPageState extends ConsumerState<DriftUploadDetailPage> {
final Set<String> _seenTaskIds = {};
final Set<String> _failedTaskIds = {};
final Map<String, int> _taskSlotAssignments = {};
static const int _maxSlots = 3;
/// Assigns uploading items to fixed slots to prevent jumping when items complete
List<DriftUploadStatus?> _assignItemsToSlots(List<DriftUploadStatus> uploadingItems) {
final slots = List<DriftUploadStatus?>.filled(_maxSlots, null);
final currentTaskIds = uploadingItems.map((e) => e.taskId).toSet();
_taskSlotAssignments.removeWhere((taskId, _) => !currentTaskIds.contains(taskId));
for (final item in uploadingItems) {
final existingSlot = _taskSlotAssignments[item.taskId];
if (existingSlot != null && existingSlot < _maxSlots) {
slots[existingSlot] = item;
}
}
for (final item in uploadingItems) {
if (_taskSlotAssignments.containsKey(item.taskId)) continue;
for (int i = 0; i < _maxSlots; i++) {
if (slots[i] == null) {
slots[i] = item;
_taskSlotAssignments[item.taskId] = i;
break;
}
}
}
return slots;
}
@override
Widget build(BuildContext context) {
final uploadItems = ref.watch(driftBackupProvider.select((state) => state.uploadItems));
final iCloudProgress = ref.watch(driftBackupProvider.select((state) => state.iCloudDownloadProgress));
for (final item in uploadItems.values) {
if (item.isFailed == true) {
_failedTaskIds.add(item.taskId);
}
}
for (final item in uploadItems.values) {
if (item.progress >= 1.0 && item.isFailed != true && !_failedTaskIds.contains(item.taskId)) {
if (!_seenTaskIds.contains(item.taskId)) {
_seenTaskIds.add(item.taskId);
}
}
}
final uploadingItems = uploadItems.values.where((item) => item.progress < 1.0 && item.isFailed != true).toList();
final failedItems = uploadItems.values.where((item) => item.isFailed == true).toList();
return Scaffold(
appBar: AppBar(
@@ -25,98 +83,326 @@ class DriftUploadDetailPage extends ConsumerWidget {
elevation: 0,
scrolledUnderElevation: 1,
),
body: uploadItems.isEmpty ? _buildEmptyState(context) : _buildUploadList(uploadItems),
body: _buildTwoSectionLayout(context, uploadingItems, failedItems, iCloudProgress),
);
}
Widget _buildEmptyState(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.cloud_off_rounded, size: 80, color: context.colorScheme.onSurface.withValues(alpha: 0.3)),
const SizedBox(height: 16),
Text(
"no_uploads_in_progress".t(context: context),
style: context.textTheme.titleMedium?.copyWith(color: context.colorScheme.onSurface.withValues(alpha: 0.6)),
Widget _buildTwoSectionLayout(
BuildContext context,
List<DriftUploadStatus> uploadingItems,
List<DriftUploadStatus> failedItems,
Map<String, double> iCloudProgress,
) {
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),
),
),
],
// Uploading Section
SliverToBoxAdapter(
child: _buildSectionHeader(
context,
title: "uploading".t(context: context),
count: uploadingItems.length,
color: context.colorScheme.primary,
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
// Use slot-based assignment to prevent items from jumping
final slots = _assignItemsToSlots(uploadingItems);
final item = slots[index];
if (item != null) {
return _buildCurrentUploadCard(context, item);
} else {
return _buildPlaceholderCard(context);
}
}, childCount: 3),
),
),
// Errors Section
if (failedItems.isNotEmpty) ...[
SliverToBoxAdapter(
child: _buildSectionHeader(
context,
title: "errors_text".t(context: context),
count: failedItems.length,
color: context.colorScheme.error,
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final item = failedItems[index];
return Padding(padding: const EdgeInsets.only(bottom: 8), child: _buildErrorCard(context, item));
}, childCount: failedItems.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(),
],
),
);
}
Widget _buildUploadList(Map<String, DriftUploadStatus> uploadItems) {
return ListView.separated(
addAutomaticKeepAlives: true,
padding: const EdgeInsets.all(16),
itemCount: uploadItems.length,
separatorBuilder: (context, index) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final item = uploadItems.values.elementAt(index);
return _buildUploadCard(context, item);
},
);
}
Widget _buildUploadCard(BuildContext context, DriftUploadStatus item) {
final isCompleted = item.progress >= 1.0;
final double progressPercentage = (item.progress * 100).clamp(0, 100);
Widget _buildICloudDownloadCard(BuildContext context, String assetId, double progress) {
final double progressPercentage = (progress * 100).clamp(0, 100);
return Card(
elevation: 0,
color: item.isFailed != null ? context.colorScheme.errorContainer : context.colorScheme.surfaceContainer,
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: InkWell(
onTap: () => _showFileDetailDialog(context, item),
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
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: [
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,
),
],
),
Text(
"downloading_from_icloud".t(context: context),
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
_buildProgressIndicator(
context,
item.progress,
progressPercentage,
isCompleted,
item.networkSpeedAsString,
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: 64,
child: Row(
children: [
_CurrentUploadThumbnail(taskId: item.taskId),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
path.basename(item.filename),
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
isFailed
? item.error ?? "unable_to_upload_file".t(context: context)
: "${formatHumanReadableBytes(item.fileSize, 1)}${item.networkSpeedAsString}",
style: context.textTheme.labelLarge?.copyWith(
color: isFailed
? context.colorScheme.error
: context.colorScheme.onSurface.withValues(alpha: 0.6),
),
maxLines: 2,
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,
),
),
],
],
),
),
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 _buildErrorCard(BuildContext context, DriftUploadStatus item) {
return Card(
elevation: 0,
color: context.colorScheme.errorContainer,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
side: BorderSide(color: context.colorScheme.error.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: Row(
children: [
_CurrentUploadThumbnail(taskId: item.taskId),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
path.basename(item.filename),
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
item.error ?? "unable_to_upload_file".t(context: context),
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.error),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 12),
Icon(Icons.error_rounded, color: context.colorScheme.error, size: 28),
],
),
),
@@ -124,49 +410,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<double>(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: 64,
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),
),
),
),
],
),
),
],
),
);
}
@@ -178,9 +499,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<LocalAsset?>(
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<LocalAsset?> _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
@@ -212,14 +568,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)),
@@ -237,7 +591,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),
@@ -254,7 +608,6 @@ class FileDetailDialog extends ConsumerWidget {
if (asset.checksum != null)
_buildInfoRow(context, "checksum".t(context: context), asset.checksum!),
]),
],
],
),
);
@@ -282,7 +635,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),
);
}
@@ -303,12 +656,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),
),
],
),
@@ -317,8 +665,7 @@ class FileDetailDialog extends ConsumerWidget {
Future<LocalAsset?> _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;
}