Source Code added

This commit is contained in:
Fr4nz D13trich 2026-02-02 15:06:40 +01:00
parent 800376eafd
commit 9efa9bc6dd
3912 changed files with 754770 additions and 2 deletions

View file

@ -0,0 +1,64 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
@RoutePage()
class AlbumPreviewPage extends HookConsumerWidget {
final Album album;
const AlbumPreviewPage({super.key, required this.album});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assets = useState<List<Asset>>([]);
getAssetsInAlbum() async {
assets.value = await ref.read(albumMediaRepositoryProvider).getAssets(album.localId!);
}
useEffect(() {
getAssetsInAlbum();
return null;
}, []);
return Scaffold(
appBar: AppBar(
elevation: 0,
title: Column(
children: [
Text(album.name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
"ID ${album.id}",
style: TextStyle(
fontSize: 10,
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
),
),
),
],
),
leading: IconButton(onPressed: () => context.maybePop(), icon: const Icon(Icons.arrow_back_ios_new_rounded)),
),
body: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
crossAxisSpacing: 2,
mainAxisSpacing: 2,
),
itemCount: assets.value.length,
itemBuilder: (context, index) {
return ImmichThumbnail(asset: assets.value[index], width: 100, height: 100);
},
),
);
}
}

View file

@ -0,0 +1,225 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/backup/album_info_card.dart';
import 'package:immich_mobile/widgets/backup/album_info_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
@RoutePage()
class BackupAlbumSelectionPage extends HookConsumerWidget {
const BackupAlbumSelectionPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
final enableSyncUploadAlbum = useAppSettingsState(AppSettingsEnum.syncAlbums);
final isDarkTheme = context.isDarkTheme;
final albums = ref.watch(backupProvider).availableAlbums;
useEffect(() {
ref.watch(backupProvider.notifier).getBackupInfo();
return null;
}, []);
buildAlbumSelectionList() {
if (albums.isEmpty) {
return const SliverToBoxAdapter(child: Center(child: CircularProgressIndicator()));
}
return SliverPadding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(((context, index) {
return AlbumInfoListTile(album: albums[index]);
}), childCount: albums.length),
),
);
}
buildAlbumSelectionGrid() {
if (albums.isEmpty) {
return const SliverToBoxAdapter(child: Center(child: CircularProgressIndicator()));
}
return SliverPadding(
padding: const EdgeInsets.all(12.0),
sliver: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 300,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
),
itemCount: albums.length,
itemBuilder: ((context, index) {
return AlbumInfoCard(album: albums[index]);
}),
),
);
}
buildSelectedAlbumNameChip() {
return selectedBackupAlbums.map((album) {
void removeSelection() => ref.read(backupProvider.notifier).removeAlbumForBackup(album);
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: GestureDetector(
onTap: removeSelection,
child: Chip(
label: Text(
album.name,
style: TextStyle(
fontSize: 12,
color: isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
),
backgroundColor: context.primaryColor,
deleteIconColor: isDarkTheme ? Colors.black : Colors.white,
deleteIcon: const Icon(Icons.cancel_rounded, size: 15),
onDeleted: removeSelection,
),
),
);
}).toSet();
}
buildExcludedAlbumNameChip() {
return excludedBackupAlbums.map((album) {
void removeSelection() {
ref.watch(backupProvider.notifier).removeExcludedAlbumForBackup(album);
}
return GestureDetector(
onTap: removeSelection,
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Chip(
label: Text(
album.name,
style: TextStyle(fontSize: 12, color: context.scaffoldBackgroundColor, fontWeight: FontWeight.bold),
),
backgroundColor: Colors.red[300],
deleteIconColor: context.scaffoldBackgroundColor,
deleteIcon: const Icon(Icons.cancel_rounded, size: 15),
onDeleted: removeSelection,
),
),
);
}).toSet();
}
handleSyncAlbumToggle(bool isEnable) async {
if (isEnable) {
await ref.read(albumProvider.notifier).refreshRemoteAlbums();
for (final album in selectedBackupAlbums) {
await ref.read(albumProvider.notifier).createSyncAlbum(album.name);
}
}
}
return Scaffold(
appBar: AppBar(
leading: IconButton(onPressed: () => context.maybePop(), icon: const Icon(Icons.arrow_back_ios_rounded)),
title: const Text("backup_album_selection_page_select_albums").tr(),
elevation: 0,
),
body: CustomScrollView(
physics: const ClampingScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
child: Text("backup_album_selection_page_selection_info", style: context.textTheme.titleSmall).tr(),
),
// Selected Album Chips
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap(children: [...buildSelectedAlbumNameChip(), ...buildExcludedAlbumNameChip()]),
),
SettingsSwitchListTile(
valueNotifier: enableSyncUploadAlbum,
title: "sync_albums".tr(),
subtitle: "sync_upload_album_setting_subtitle".tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
titleStyle: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold),
subtitleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.primary),
onChanged: handleSyncAlbumToggle,
),
ListTile(
title: Text(
"backup_album_selection_page_albums_device".tr(
namedArgs: {'count': ref.watch(backupProvider).availableAlbums.length.toString()},
),
style: context.textTheme.titleSmall,
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
"backup_album_selection_page_albums_tap",
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
).tr(),
),
trailing: IconButton(
splashRadius: 16,
icon: Icon(Icons.info, size: 20, color: context.primaryColor),
onPressed: () {
// show the dialog
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
elevation: 5,
title: Text(
'backup_album_selection_page_selection_info',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: context.primaryColor),
).tr(),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text(
'backup_album_selection_page_assets_scatter',
style: TextStyle(fontSize: 14),
).tr(),
],
),
),
);
},
);
},
),
),
// buildSearchBar(),
],
),
),
SliverLayoutBuilder(
builder: (context, constraints) {
if (constraints.crossAxisExtent > 600) {
return buildAlbumSelectionGrid();
} else {
return buildAlbumSelectionList();
}
},
),
],
),
);
}
}

View file

@ -0,0 +1,286 @@
import 'dart:io';
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
import 'package:immich_mobile/widgets/backup/current_backup_asset_info_box.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@RoutePage()
class BackupControllerPage extends HookConsumerWidget {
const BackupControllerPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
BackUpState backupState = ref.watch(backupProvider);
final hasAnyAlbum = backupState.selectedBackupAlbums.isNotEmpty;
final didGetBackupInfo = useState(false);
bool hasExclusiveAccess = backupState.backupProgress != BackUpProgressEnum.inBackground;
bool shouldBackup =
backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length == 0 ||
!hasExclusiveAccess
? false
: true;
useEffect(() {
// Update the background settings information just to make sure we
// have the latest, since the platform channel will not update
// automatically
if (Platform.isIOS) {
ref.watch(iOSBackgroundSettingsProvider.notifier).refresh();
}
ref.watch(websocketProvider.notifier).stopListenToEvent('on_upload_success');
return () {
WakelockPlus.disable();
};
}, []);
useEffect(() {
if (backupState.backupProgress == BackUpProgressEnum.idle && !didGetBackupInfo.value) {
ref.watch(backupProvider.notifier).getBackupInfo();
didGetBackupInfo.value = true;
}
return null;
}, [backupState.backupProgress]);
useEffect(() {
if (backupState.backupProgress == BackUpProgressEnum.inProgress) {
WakelockPlus.enable();
} else {
WakelockPlus.disable();
}
return null;
}, [backupState.backupProgress]);
Widget buildSelectedAlbumName() {
var text = "backup_controller_page_backup_selected".tr();
var albums = ref.watch(backupProvider).selectedBackupAlbums;
if (albums.isNotEmpty) {
for (var album in albums) {
if (album.name == "Recent" || album.name == "Recents") {
text += "${album.name} (${'all'.tr()}), ";
} else {
text += "${album.name}, ";
}
}
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
text.trim().substring(0, text.length - 2),
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
),
);
} else {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
"backup_controller_page_none_selected".tr(),
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
),
);
}
}
Widget buildExcludedAlbumName() {
var text = "backup_controller_page_excluded".tr();
var albums = ref.watch(backupProvider).excludedBackupAlbums;
if (albums.isNotEmpty) {
for (var album in albums) {
text += "${album.name}, ";
}
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
text.trim().substring(0, text.length - 2),
style: context.textTheme.labelLarge?.copyWith(color: Colors.red[300]),
),
);
} else {
return const SizedBox();
}
}
buildFolderSelectionTile() {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Card(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(20)),
side: BorderSide(color: context.colorScheme.outlineVariant, width: 1),
),
elevation: 0,
borderOnForeground: false,
child: ListTile(
minVerticalPadding: 18,
title: Text("backup_controller_page_albums", style: context.textTheme.titleMedium).tr(),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"backup_controller_page_to_backup",
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
).tr(),
buildSelectedAlbumName(),
buildExcludedAlbumName(),
],
),
),
trailing: ElevatedButton(
onPressed: () async {
await context.pushRoute(const BackupAlbumSelectionRoute());
// waited until returning from selection
await ref.read(backupProvider.notifier).backupAlbumSelectionDone();
// waited until backup albums are stored in DB
await ref.read(albumProvider.notifier).refreshDeviceAlbums();
},
child: const Text("select", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
),
),
),
);
}
void startBackup() {
ref.watch(errorBackupListProvider.notifier).empty();
if (ref.watch(backupProvider).backupProgress != BackUpProgressEnum.inBackground) {
ref.watch(backupProvider.notifier).startBackupProcess();
}
}
Widget buildBackupButton() {
return Padding(
padding: const EdgeInsets.only(top: 24),
child: Container(
child:
backupState.backupProgress == BackUpProgressEnum.inProgress ||
backupState.backupProgress == BackUpProgressEnum.manualInProgress
? ElevatedButton(
style: ElevatedButton.styleFrom(
foregroundColor: Colors.grey[50],
backgroundColor: Colors.red[300],
// padding: const EdgeInsets.all(14),
),
onPressed: () {
if (backupState.backupProgress == BackUpProgressEnum.manualInProgress) {
ref.read(manualUploadProvider.notifier).cancelBackup();
} else {
ref.read(backupProvider.notifier).cancelBackup();
}
},
child: const Text("cancel", style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
)
: ElevatedButton(
onPressed: shouldBackup ? startBackup : null,
child: const Text(
"backup_controller_page_start_backup",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
).tr(),
),
),
);
}
buildBackgroundBackupInfo() {
return ListTile(
leading: const Icon(Icons.info_outline_rounded),
title: Text('background_backup_running_error'.tr()),
);
}
buildLoadingIndicator() {
return const Padding(
padding: EdgeInsets.only(top: 42.0),
child: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
elevation: 0,
title: const Text("backup_controller_page_backup").tr(),
leading: IconButton(
onPressed: () {
ref.watch(websocketProvider.notifier).listenUploadEvent();
context.maybePop(true);
},
splashRadius: 24,
icon: const Icon(Icons.arrow_back_ios_rounded),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: IconButton(
onPressed: () => context.pushRoute(const BackupOptionsRoute()),
splashRadius: 24,
icon: const Icon(Icons.settings_outlined),
),
),
],
),
body: Stack(
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 32),
child: ListView(
// crossAxisAlignment: CrossAxisAlignment.start,
children: hasAnyAlbum
? [
buildFolderSelectionTile(),
BackupInfoCard(
title: "total".tr(),
subtitle: "backup_controller_page_total_sub".tr(),
info: ref.watch(backupProvider).availableAlbums.isEmpty
? "..."
: "${backupState.allUniqueAssets.length}",
),
BackupInfoCard(
title: "backup_controller_page_backup".tr(),
subtitle: "backup_controller_page_backup_sub".tr(),
info: ref.watch(backupProvider).availableAlbums.isEmpty
? "..."
: "${backupState.selectedAlbumsBackupAssetsIds.length}",
),
BackupInfoCard(
title: "backup_controller_page_remainder".tr(),
subtitle: "backup_controller_page_remainder_sub".tr(),
info: ref.watch(backupProvider).availableAlbums.isEmpty
? "..."
: "${max(0, backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length)}",
),
const Divider(),
const CurrentUploadingAssetInfoBox(),
if (!hasExclusiveAccess) buildBackgroundBackupInfo(),
buildBackupButton(),
]
: [buildFolderSelectionTile(), if (!didGetBackupInfo.value) buildLoadingIndicator()],
),
),
],
),
);
}
}

View file

@ -0,0 +1,24 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/backup_settings.dart';
@RoutePage()
class BackupOptionsPage extends StatelessWidget {
const BackupOptionsPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
elevation: 0,
title: const Text("backup_options_page_title").tr(),
leading: IconButton(
onPressed: () => context.maybePop(true),
splashRadius: 24,
icon: const Icon(Icons.arrow_back_ios_rounded),
),
),
body: const BackupSettings(),
);
}
}

View file

@ -0,0 +1,523 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/intl_keys.g.dart';
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
import 'package:logging/logging.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@RoutePage()
class DriftBackupPage extends ConsumerStatefulWidget {
const DriftBackupPage({super.key});
@override
ConsumerState<DriftBackupPage> createState() => _DriftBackupPageState();
}
class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
bool? syncSuccess;
@override
void initState() {
super.initState();
WakelockPlus.enable();
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) async {
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
ref.read(driftBackupProvider.notifier).updateSyncing(true);
syncSuccess = await ref.read(backgroundSyncProvider).syncRemote();
ref.read(driftBackupProvider.notifier).updateSyncing(false);
if (mounted) {
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
}
});
}
@override
dispose() {
super.dispose();
WakelockPlus.disable();
}
@override
Widget build(BuildContext context) {
final selectedAlbum = ref
.watch(backupAlbumProvider)
.where((album) => album.backupSelection == BackupSelection.selected)
.toList();
final error = ref.watch(driftBackupProvider.select((p) => p.error));
final backupNotifier = ref.read(driftBackupProvider.notifier);
final backupSyncManager = ref.read(backgroundSyncProvider);
Future<void> startBackup() async {
final currentUser = Store.tryGet(StoreKey.currentUser);
if (currentUser == null) {
return;
}
if (syncSuccess == null) {
ref.read(driftBackupProvider.notifier).updateSyncing(true);
syncSuccess = await backupSyncManager.syncRemote();
ref.read(driftBackupProvider.notifier).updateSyncing(false);
}
await backupNotifier.getBackupStatus(currentUser.id);
if (syncSuccess == false) {
Logger("DriftBackupPage").warning("Remote sync did not complete successfully, skipping backup");
return;
}
await backupNotifier.startForegroundBackup(currentUser.id);
}
Future<void> stopBackup() async {
await backupNotifier.stopForegroundBackup();
}
return Scaffold(
appBar: AppBar(
elevation: 0,
title: Text("backup_controller_page_backup".t()),
leading: IconButton(
onPressed: () {
context.maybePop(true);
},
splashRadius: 24,
icon: const Icon(Icons.arrow_back_ios_rounded),
),
actions: [
IconButton(
onPressed: () {
context.pushRoute(const DriftBackupOptionsRoute());
},
icon: const Icon(Icons.settings_outlined),
tooltip: "backup_options".t(context: context),
),
],
),
body: Stack(
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 32),
child: ListView(
children: [
const SizedBox(height: 8),
const _BackupAlbumSelectionCard(),
if (selectedAlbum.isNotEmpty) ...[
const _TotalCard(),
const _BackupCard(),
const _RemainderCard(),
const Divider(),
BackupToggleButton(
onStart: () async => await startBackup(),
onStop: () async {
syncSuccess = null;
await stopBackup();
},
),
switch (error) {
BackupError.none => const SizedBox.shrink(),
BackupError.syncFailed => Padding(
padding: const EdgeInsets.only(top: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
Icon(Icons.warning_rounded, color: context.colorScheme.error, fill: 1),
const SizedBox(width: 8),
Text(
IntlKeys.backup_error_sync_failed.t(),
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error),
textAlign: TextAlign.center,
),
],
),
),
},
TextButton.icon(
icon: const Icon(Icons.info_outline_rounded),
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
label: Text("view_details".t(context: context)),
),
],
],
),
),
],
),
);
}
}
class _BackupAlbumSelectionCard extends ConsumerWidget {
const _BackupAlbumSelectionCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget buildSelectedAlbumName() {
String text = "backup_controller_page_backup_selected".tr();
final albums = ref
.watch(backupAlbumProvider)
.where((album) => album.backupSelection == BackupSelection.selected)
.toList();
if (albums.isNotEmpty) {
for (var album in albums) {
if (album.name == "Recent" || album.name == "Recents") {
text += "${album.name} (${'all'.tr()}), ";
} else {
text += "${album.name}, ";
}
}
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
text.trim().substring(0, text.length - 2),
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
),
);
} else {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
"backup_controller_page_none_selected".tr(),
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
),
);
}
}
Widget buildExcludedAlbumName() {
String text = "backup_controller_page_excluded".tr();
final albums = ref
.watch(backupAlbumProvider)
.where((album) => album.backupSelection == BackupSelection.excluded)
.toList();
if (albums.isNotEmpty) {
for (var album in albums) {
text += "${album.name}, ";
}
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
text.trim().substring(0, text.length - 2),
style: context.textTheme.labelLarge?.copyWith(color: Colors.red[300]),
),
);
} else {
return const SizedBox();
}
}
return Card(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(20)),
side: BorderSide(color: context.colorScheme.outlineVariant, width: 1),
),
elevation: 0,
borderOnForeground: false,
child: ListTile(
minVerticalPadding: 18,
title: Text("backup_controller_page_albums", style: context.textTheme.titleMedium).tr(),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"backup_controller_page_to_backup",
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
).tr(),
buildSelectedAlbumName(),
buildExcludedAlbumName(),
],
),
),
trailing: ElevatedButton(
onPressed: () async {
await context.pushRoute(const DriftBackupAlbumSelectionRoute());
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
unawaited(ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id));
},
child: const Text("select", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
),
),
);
}
}
class _TotalCard extends ConsumerWidget {
const _TotalCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final totalCount = ref.watch(driftBackupProvider.select((p) => p.totalCount));
return BackupInfoCard(
title: "total".tr(),
subtitle: "backup_controller_page_total_sub".tr(),
info: totalCount.toString(),
);
}
}
class _BackupCard extends ConsumerWidget {
const _BackupCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final backupCount = ref.watch(driftBackupProvider.select((p) => p.backupCount));
final syncStatus = ref.watch(syncStatusProvider);
return BackupInfoCard(
title: "backup_controller_page_backup".tr(),
subtitle: "backup_controller_page_backup_sub".tr(),
info: backupCount.toString(),
isLoading: syncStatus.isRemoteSyncing,
);
}
}
class _RemainderCard extends ConsumerWidget {
const _RemainderCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final remainderCount = ref.watch(driftBackupProvider.select((p) => p.remainderCount));
final syncStatus = ref.watch(syncStatusProvider);
return Card(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(20)),
side: BorderSide(color: context.colorScheme.outlineVariant, width: 1),
),
elevation: 0,
borderOnForeground: false,
child: Column(
children: [
ListTile(
minVerticalPadding: 18,
isThreeLine: true,
title: Text("backup_controller_page_remainder".t(context: context), style: context.textTheme.titleMedium),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4.0, right: 18.0),
child: Text(
"backup_controller_page_remainder_sub".t(context: context),
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Stack(
children: [
Text(
remainderCount.toString(),
style: context.textTheme.titleLarge?.copyWith(
color: context.colorScheme.onSurface.withAlpha(syncStatus.isRemoteSyncing ? 50 : 255),
),
),
if (syncStatus.isRemoteSyncing)
Positioned.fill(
child: Align(
alignment: Alignment.center,
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: context.colorScheme.onSurface.withAlpha(150),
),
),
),
),
],
),
Text(
"backup_info_card_assets",
style: context.textTheme.labelLarge?.copyWith(
color: context.colorScheme.onSurface.withAlpha(syncStatus.isRemoteSyncing ? 50 : 255),
),
).tr(),
],
),
),
const Divider(height: 0),
const _PreparingStatus(),
const Divider(height: 0),
ListTile(
enableFeedback: true,
visualDensity: VisualDensity.compact,
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 0.0),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(bottomLeft: Radius.circular(20), bottomRight: Radius.circular(20)),
),
onTap: () => context.pushRoute(const DriftBackupAssetDetailRoute()),
title: Text(
"view_details".t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurface.withAlpha(200)),
),
trailing: Icon(Icons.arrow_forward_ios, size: 16, color: context.colorScheme.onSurfaceVariant),
),
],
),
);
}
}
class _PreparingStatus extends ConsumerStatefulWidget {
const _PreparingStatus();
@override
_PreparingStatusState createState() => _PreparingStatusState();
}
class _PreparingStatusState extends ConsumerState {
Timer? _pollingTimer;
@override
void dispose() {
_pollingTimer?.cancel();
super.dispose();
}
void _startPollingIfNeeded() {
if (_pollingTimer != null) return;
_pollingTimer = Timer.periodic(const Duration(seconds: 3), (timer) async {
final currentUser = ref.read(currentUserProvider);
if (currentUser != null && mounted) {
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
// Stop polling if processing count reaches 0
final updatedProcessingCount = ref.read(driftBackupProvider.select((p) => p.processingCount));
if (updatedProcessingCount == 0) {
timer.cancel();
_pollingTimer = null;
}
} else {
timer.cancel();
_pollingTimer = null;
}
});
}
@override
Widget build(BuildContext context) {
final syncStatus = ref.watch(syncStatusProvider);
final remainderCount = ref.watch(driftBackupProvider.select((p) => p.remainderCount));
final processingCount = ref.watch(driftBackupProvider.select((p) => p.processingCount));
final readyForUploadCount = remainderCount - processingCount;
ref.listen<int>(driftBackupProvider.select((p) => p.processingCount), (previous, next) {
if (next > 0 && _pollingTimer == null) {
_startPollingIfNeeded();
} else if (next == 0 && _pollingTimer != null) {
_pollingTimer?.cancel();
_pollingTimer = null;
}
});
if (!syncStatus.isHashing) {
return const SizedBox.shrink();
}
return Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 1.0),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerHigh.withValues(alpha: 0.5),
shape: BoxShape.rectangle,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
"preparing".t(context: context),
style: context.textTheme.labelLarge?.copyWith(
color: context.colorScheme.onSurface.withAlpha(200),
),
),
const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 1.5)),
],
),
const SizedBox(height: 2),
Text(
processingCount.toString(),
style: context.textTheme.titleMedium?.copyWith(
color: context.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
decoration: BoxDecoration(color: context.colorScheme.primary.withValues(alpha: 0.1)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
"ready_for_upload".t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurface.withAlpha(200)),
),
const SizedBox(height: 2),
Text(
readyForUploadCount.toString(),
style: context.textTheme.titleMedium?.copyWith(
color: context.primaryColor,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
],
);
}
}

View file

@ -0,0 +1,503 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:logging/logging.dart';
@RoutePage()
class DriftBackupAlbumSelectionPage extends ConsumerStatefulWidget {
const DriftBackupAlbumSelectionPage({super.key});
@override
ConsumerState<DriftBackupAlbumSelectionPage> createState() => _DriftBackupAlbumSelectionPageState();
}
class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbumSelectionPage> {
String _searchQuery = '';
bool _isSearchMode = false;
int _initialTotalAssetCount = 0;
late ValueNotifier<bool> _enableSyncUploadAlbum;
late TextEditingController _searchController;
late FocusNode _searchFocusNode;
Future? _handleLinkedAlbumFuture;
@override
void initState() {
super.initState();
_enableSyncUploadAlbum = ValueNotifier<bool>(false);
_searchController = TextEditingController();
_searchFocusNode = FocusNode();
_enableSyncUploadAlbum.value = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
ref.read(backupAlbumProvider.notifier).getAll();
_initialTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
}
Future<void> _handlePagePopped() async {
final user = ref.read(currentUserProvider);
if (user == null) {
return;
}
final enableSyncUploadAlbum = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
final selectedAlbums = ref
.read(backupAlbumProvider)
.where((a) => a.backupSelection == BackupSelection.selected)
.toList();
if (enableSyncUploadAlbum && selectedAlbums.isNotEmpty) {
setState(() {
_handleLinkedAlbumFuture = ref.read(syncLinkedAlbumServiceProvider).manageLinkedAlbums(selectedAlbums, user.id);
});
await _handleLinkedAlbumFuture;
}
}
@override
void dispose() {
_enableSyncUploadAlbum.dispose();
_searchController.dispose();
_searchFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final albums = ref.watch(backupAlbumProvider);
final albumCount = albums.length;
// Filter albums based on search query
final filteredAlbums = albums.where((album) {
if (_searchQuery.isEmpty) return true;
return album.name.toLowerCase().contains(_searchQuery.toLowerCase());
}).toList();
final selectedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.selected).toList();
final excludedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.excluded).toList();
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) async {
if (!didPop) {
await _handlePagePopped();
final user = ref.read(currentUserProvider);
if (user == null) {
return;
}
final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
await ref.read(driftBackupProvider.notifier).getBackupStatus(user.id);
final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
final backupNotifier = ref.read(driftBackupProvider.notifier);
final backgroundSync = ref.read(backgroundSyncProvider);
final nativeSync = ref.read(nativeSyncApiProvider);
if (totalChanged) {
// Waits for hashing to be cancelled before starting a new one
unawaited(nativeSync.cancelHashing().whenComplete(() => backgroundSync.hashAssets()));
if (isBackupEnabled) {
unawaited(
backupNotifier.stopForegroundBackup().whenComplete(
() => backgroundSync.syncRemote().then((success) {
if (success) {
return backupNotifier.startForegroundBackup(user.id);
} else {
Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup');
}
}),
),
);
}
}
Navigator.of(context).pop();
}
},
child: Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () async => await context.maybePop(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
title: _isSearchMode
? SearchField(
hintText: 'search_albums'.t(context: context),
autofocus: true,
controller: _searchController,
focusNode: _searchFocusNode,
onChanged: (value) => setState(() => _searchQuery = value.trim()),
)
: const Text("backup_album_selection_page_select_albums").t(context: context),
actions: [
if (!_isSearchMode)
IconButton(
icon: const Icon(Icons.search),
onPressed: () => setState(() {
_isSearchMode = true;
_searchQuery = '';
}),
)
else
IconButton(
icon: const Icon(Icons.close),
onPressed: () => setState(() {
_isSearchMode = false;
_searchQuery = '';
_searchController.clear();
}),
),
],
elevation: 0,
),
body: Stack(
children: [
CustomScrollView(
physics: const ClampingScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
child: Text(
"backup_album_selection_page_selection_info",
style: context.textTheme.titleSmall,
).t(context: context),
),
// Selected Album Chips
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap(
children: [
_SelectedAlbumNameChips(selectedBackupAlbums: selectedBackupAlbums),
_ExcludedAlbumNameChips(excludedBackupAlbums: excludedBackupAlbums),
],
),
),
ListTile(
title: Text(
"albums_on_device_count".t(context: context, args: {'count': albumCount.toString()}),
style: context.textTheme.titleSmall,
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
"backup_album_selection_page_albums_tap",
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
).t(context: context),
),
trailing: IconButton(
splashRadius: 16,
icon: Icon(Icons.info, size: 20, color: context.primaryColor),
onPressed: () {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
),
elevation: 5,
title: Text(
'backup_album_selection_page_selection_info',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: context.primaryColor,
),
).t(context: context),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text(
'backup_album_selection_page_assets_scatter',
style: TextStyle(fontSize: 14),
).t(context: context),
],
),
),
);
},
);
},
),
),
_SelectAllButton(filteredAlbums: filteredAlbums, selectedBackupAlbums: selectedBackupAlbums),
],
),
),
SliverLayoutBuilder(
builder: (context, constraints) {
if (constraints.crossAxisExtent > 600) {
return _AlbumSelectionGrid(filteredAlbums: filteredAlbums, searchQuery: _searchQuery);
} else {
return _AlbumSelectionList(filteredAlbums: filteredAlbums, searchQuery: _searchQuery);
}
},
),
],
),
if (_handleLinkedAlbumFuture != null)
FutureBuilder(
future: _handleLinkedAlbumFuture,
builder: (context, snapshot) {
return SizedBox(
height: double.infinity,
width: double.infinity,
child: Container(
color: context.scaffoldBackgroundColor.withValues(alpha: 0.8),
child: Center(
child: Column(
spacing: 16,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
const CircularProgressIndicator(strokeWidth: 4),
Text('creating_linked_albums'.tr(), style: context.textTheme.labelLarge),
],
),
),
),
);
},
),
],
),
),
);
}
}
class _AlbumSelectionList extends StatelessWidget {
final List<LocalAlbum> filteredAlbums;
final String searchQuery;
const _AlbumSelectionList({required this.filteredAlbums, required this.searchQuery});
@override
Widget build(BuildContext context) {
if (filteredAlbums.isEmpty && searchQuery.isNotEmpty) {
return SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text('album_search_not_found'.t(context: context)),
),
),
);
}
if (filteredAlbums.isEmpty) {
return const SliverToBoxAdapter(child: Center(child: CircularProgressIndicator()));
}
return SliverPadding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(((context, index) {
return DriftAlbumInfoListTile(album: filteredAlbums[index]);
}), childCount: filteredAlbums.length),
),
);
}
}
class _AlbumSelectionGrid extends StatelessWidget {
final List<LocalAlbum> filteredAlbums;
final String searchQuery;
const _AlbumSelectionGrid({required this.filteredAlbums, required this.searchQuery});
@override
Widget build(BuildContext context) {
if (filteredAlbums.isEmpty && searchQuery.isNotEmpty) {
return SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text('album_search_not_found'.t(context: context)),
),
),
);
}
if (filteredAlbums.isEmpty) {
return const SliverToBoxAdapter(child: Center(child: CircularProgressIndicator()));
}
return SliverPadding(
padding: const EdgeInsets.all(12.0),
sliver: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 300,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
),
itemCount: filteredAlbums.length,
itemBuilder: ((context, index) {
return DriftAlbumInfoListTile(album: filteredAlbums[index]);
}),
),
);
}
}
class _SelectedAlbumNameChips extends ConsumerWidget {
final List<LocalAlbum> selectedBackupAlbums;
const _SelectedAlbumNameChips({required this.selectedBackupAlbums});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Wrap(
children: selectedBackupAlbums.asMap().entries.map((entry) {
final album = entry.value;
void removeSelection() {
ref.read(backupAlbumProvider.notifier).deselectAlbum(album);
}
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: GestureDetector(
onTap: removeSelection,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
child: Chip(
label: Text(
album.name,
style: TextStyle(
fontSize: 12,
color: context.isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
),
backgroundColor: context.primaryColor,
deleteIconColor: context.isDarkTheme ? Colors.black : Colors.white,
deleteIcon: const Icon(Icons.cancel_rounded, size: 15),
onDeleted: removeSelection,
),
),
),
);
}).toList(),
);
}
}
class _ExcludedAlbumNameChips extends ConsumerWidget {
final List<LocalAlbum> excludedBackupAlbums;
const _ExcludedAlbumNameChips({required this.excludedBackupAlbums});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Wrap(
children: excludedBackupAlbums.asMap().entries.map((entry) {
final album = entry.value;
void removeSelection() {
ref.read(backupAlbumProvider.notifier).deselectAlbum(album);
}
return GestureDetector(
onTap: removeSelection,
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
child: Chip(
label: Text(
album.name,
style: TextStyle(fontSize: 12, color: context.scaffoldBackgroundColor, fontWeight: FontWeight.bold),
),
backgroundColor: Colors.red[300],
deleteIconColor: context.scaffoldBackgroundColor,
deleteIcon: const Icon(Icons.cancel_rounded, size: 15),
onDeleted: removeSelection,
),
),
),
);
}).toList(),
);
}
}
class _SelectAllButton extends ConsumerWidget {
final List<LocalAlbum> filteredAlbums;
final List<LocalAlbum> selectedBackupAlbums;
const _SelectAllButton({required this.filteredAlbums, required this.selectedBackupAlbums});
@override
Widget build(BuildContext context, WidgetRef ref) {
final canSelectAll = filteredAlbums.where((album) => album.backupSelection != BackupSelection.selected).isNotEmpty;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: canSelectAll
? () {
for (final album in filteredAlbums) {
if (album.backupSelection != BackupSelection.selected) {
ref.read(backupAlbumProvider.notifier).selectAlbum(album);
}
}
}
: null,
icon: const Icon(Icons.select_all),
label: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text("select_all".t(context: context)),
),
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12.0)),
),
),
const SizedBox(width: 8.0),
Expanded(
child: OutlinedButton.icon(
onPressed: selectedBackupAlbums.isNotEmpty
? () {
for (final album in filteredAlbums) {
if (album.backupSelection == BackupSelection.selected) {
ref.read(backupAlbumProvider.notifier).deselectAlbum(album);
}
}
}
: null,
icon: const Icon(Icons.deselect),
label: Text('deselect_all'.t(context: context)),
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12.0)),
),
),
],
),
);
}
}

View file

@ -0,0 +1,109 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/routing/router.dart';
@RoutePage()
class DriftBackupAssetDetailPage extends ConsumerWidget {
const DriftBackupAssetDetailPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
AsyncValue<List<LocalAsset>> result = ref.watch(driftBackupCandidateProvider);
return Scaffold(
appBar: AppBar(title: Text('backup_controller_page_remainder'.t(context: context))),
body: result.when(
data: (List<LocalAsset> candidates) {
return ListView.separated(
padding: const EdgeInsets.only(top: 16.0),
separatorBuilder: (context, index) => Divider(color: context.colorScheme.outlineVariant),
itemCount: candidates.length,
itemBuilder: (context, index) {
final asset = candidates[index];
final albumsAsyncValue = ref.watch(driftCandidateBackupAlbumInfoProvider(asset.id));
final assetMediaRepository = ref.watch(assetMediaRepositoryProvider);
return FutureBuilder<String?>(
future: assetMediaRepository.getOriginalFilename(asset.id),
builder: (context, snapshot) {
final displayName = snapshot.data ?? asset.name;
return LargeLeadingTile(
title: Text(
displayName,
style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w500, fontSize: 16),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
asset.createdAt.toString(),
style: TextStyle(fontSize: 13.0, color: context.colorScheme.onSurfaceSecondary),
),
Text(
asset.checksum ?? "N/A",
style: TextStyle(fontSize: 13.0, color: context.colorScheme.onSurfaceSecondary),
overflow: TextOverflow.ellipsis,
),
albumsAsyncValue.when(
data: (albums) {
if (albums.isEmpty) {
return const SizedBox.shrink();
}
return Text(
albums.map((a) => a.name).join(', '),
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
overflow: TextOverflow.ellipsis,
);
},
error: (error, stackTrace) => Text(
'error_saving_image'.tr(args: [error.toString()]),
style: TextStyle(color: context.colorScheme.error),
),
loading: () =>
const SizedBox(height: 16, width: 16, child: CircularProgressIndicator.adaptive()),
),
],
),
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: SizedBox(
width: 64,
height: 64,
child: Thumbnail.fromAsset(asset: asset, size: const Size(64, 64), fit: BoxFit.cover),
),
),
trailing: const Padding(
padding: EdgeInsets.only(right: 24, left: 8),
child: Icon(Icons.image_search),
),
onTap: () async {
await context.maybePop();
await context.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
EventStream.shared.emit(ScrollToDateEvent(asset.createdAt));
},
);
},
);
},
);
},
error: (Object error, StackTrace stackTrace) {
return Center(child: Text('error_saving_image'.tr(args: [error.toString()])));
},
loading: () {
return const SizedBox(height: 48, width: 48, child: Center(child: CircularProgressIndicator.adaptive()));
},
),
);
}
}

View file

@ -0,0 +1,81 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/drift_backup_settings.dart';
import 'package:logging/logging.dart';
@RoutePage()
class DriftBackupOptionsPage extends ConsumerWidget {
const DriftBackupOptionsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
bool hasPopped = false;
final previousWifiReqForVideos = Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false;
final previousWifiReqForPhotos = Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false;
return PopScope(
onPopInvokedWithResult: (didPop, result) async {
// There is an issue with Flutter where the pop event
// can be triggered multiple times, so we guard it with _hasPopped
final currentWifiReqForVideos = Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false;
final currentWifiReqForPhotos = Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false;
if (currentWifiReqForVideos == previousWifiReqForVideos &&
currentWifiReqForPhotos == previousWifiReqForPhotos) {
return;
}
if (didPop && !hasPopped) {
hasPopped = true;
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
if (!isBackupEnabled) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("network_requirements_updated".t(context: context)),
duration: const Duration(seconds: 4),
),
);
final backupNotifier = ref.read(driftBackupProvider.notifier);
final backgroundSync = ref.read(backgroundSyncProvider);
unawaited(
backupNotifier.stopForegroundBackup().whenComplete(
() => backgroundSync.syncRemote().then((success) {
if (success) {
return backupNotifier.startForegroundBackup(currentUser.id);
} else {
Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup');
}
}),
),
);
}
},
child: Scaffold(
appBar: AppBar(title: Text("backup_options".t(context: context))),
body: const DriftBackupSettings(),
),
);
}
}

View file

@ -0,0 +1,673 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:path/path.dart' as path;
@RoutePage()
class DriftUploadDetailPage extends ConsumerStatefulWidget {
const DriftUploadDetailPage({super.key});
@override
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(
title: Text("upload_details".t(context: context)),
backgroundColor: context.colorScheme.surface,
elevation: 0,
scrolledUnderElevation: 1,
),
body: _buildTwoSectionLayout(context, uploadingItems, failedItems, iCloudProgress),
);
}
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 _buildICloudDownloadCard(BuildContext context, String assetId, double progress) {
final double progressPercentage = (progress * 100).clamp(0, 100);
return Card(
elevation: 0,
color: context.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(
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(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: [
Text(
"downloading_from_icloud".t(context: context),
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: 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),
],
),
),
),
);
}
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),
),
),
),
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)),
),
),
],
),
),
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),
),
),
),
],
),
),
),
);
}
Future<void> _showFileDetailDialog(BuildContext context, DriftUploadStatus item) {
return showDialog(
context: context,
builder: (context) => FileDetailDialog(uploadStatus: item),
);
}
}
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
Widget build(BuildContext context, WidgetRef ref) {
return AlertDialog(
insetPadding: const EdgeInsets.all(20),
backgroundColor: context.colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(16)),
side: BorderSide(color: context.colorScheme.outline.withValues(alpha: 0.2), width: 1),
),
title: Row(
children: [
Icon(Icons.info_outline, color: context.primaryColor, size: 24),
const SizedBox(width: 8),
Expanded(
child: Text(
"details".t(context: context),
style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: context.primaryColor),
),
),
],
),
content: SizedBox(
width: double.maxFinite,
child: FutureBuilder<LocalAsset?>(
future: _getAssetDetails(ref, uploadStatus.taskId),
builder: (context, snapshot) {
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: [
Center(
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Container(
width: 128,
height: 128,
decoration: BoxDecoration(
border: Border.all(color: context.colorScheme.outline.withValues(alpha: 0.2), width: 1),
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child: asset != null
? Thumbnail.fromAsset(asset: asset, size: const Size(128, 128), fit: BoxFit.cover)
: null,
),
),
),
const SizedBox(height: 24),
if (asset != null)
_buildInfoSection(context, [
_buildInfoRow(context, "filename".t(context: context), path.basename(uploadStatus.filename)),
_buildInfoRow(context, "local_id".t(context: context), asset.id),
_buildInfoRow(
context,
"file_size".t(context: context),
formatHumanReadableBytes(uploadStatus.fileSize, 2),
),
if (asset.width != null) _buildInfoRow(context, "width".t(context: context), "${asset.width}px"),
if (asset.height != null)
_buildInfoRow(context, "height".t(context: context), "${asset.height}px"),
_buildInfoRow(context, "created_at".t(context: context), asset.createdAt.toString()),
_buildInfoRow(context, "updated_at".t(context: context), asset.updatedAt.toString()),
if (asset.checksum != null)
_buildInfoRow(context, "checksum".t(context: context), asset.checksum!),
]),
],
),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
"close".t(),
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
),
),
],
);
}
Widget _buildInfoSection(BuildContext context, List<Widget> children) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainer,
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),
);
}
Widget _buildInfoRow(BuildContext context, String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
"$label:",
style: context.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w500,
color: context.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
),
Expanded(
child: Text(value, style: context.textTheme.labelMedium, maxLines: 3, overflow: TextOverflow.ellipsis),
),
],
),
);
}
Future<LocalAsset?> _getAssetDetails(WidgetRef ref, String localAssetId) async {
try {
return await ref.read(localAssetRepository).getById(localAssetId);
} catch (e) {
return null;
}
}
}

View file

@ -0,0 +1,115 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
import 'package:intl/intl.dart';
@RoutePage()
class FailedBackupStatusPage extends HookConsumerWidget {
const FailedBackupStatusPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final errorBackupList = ref.watch(errorBackupListProvider);
return Scaffold(
appBar: AppBar(
elevation: 0,
title: Text(
"Failed Backup (${errorBackupList.length})",
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
leading: IconButton(
onPressed: () {
context.maybePop(true);
},
splashRadius: 24,
icon: const Icon(Icons.arrow_back_ios_rounded),
),
),
body: ListView.builder(
shrinkWrap: true,
itemCount: errorBackupList.length,
itemBuilder: ((context, index) {
var errorAsset = errorBackupList.elementAt(index);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4),
child: Card(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(15), // if you need this
),
side: BorderSide(color: Colors.black12, width: 1),
),
elevation: 0,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 100, minHeight: 100, maxWidth: 100, maxHeight: 150),
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(15),
topLeft: Radius.circular(15),
),
clipBehavior: Clip.hardEdge,
child: Image(
fit: BoxFit.cover,
image: ImmichLocalThumbnailProvider(asset: errorAsset.asset, height: 512, width: 512),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
DateFormat.yMMMMd().format(
DateTime.parse(errorAsset.fileCreatedAt.toString()).toLocal(),
),
style: TextStyle(
fontWeight: FontWeight.w600,
color: context.isDarkTheme ? Colors.white70 : Colors.grey[800],
),
),
Icon(Icons.error, color: Colors.red.withAlpha(200), size: 18),
],
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
errorAsset.fileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontWeight: FontWeight.bold, color: context.primaryColor),
),
),
Text(
errorAsset.errorMessage,
style: TextStyle(
fontWeight: FontWeight.w500,
color: context.isDarkTheme ? Colors.white70 : Colors.grey[800],
),
),
],
),
),
),
],
),
),
);
}),
),
);
}
}