feat: new upload (cont) (#20029)

* new upload button

* wip

* pr feedback

* fix: updateAll override album selection value

* feat: status box

* feat: handle upload resume

* re-enable websocket event

* fix: update state condition and upload status

* Better backup detail page

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
Alex
2025-07-21 15:30:51 -05:00
committed by GitHub
parent 1dc62fce5f
commit 4d27f187ea
26 changed files with 1558 additions and 413 deletions
+52 -73
View File
@@ -1,83 +1,61 @@
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/domain/models/album/local_album.model.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/presentation/widgets/backup/backup_toggle_button.widget.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/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
@RoutePage()
class DriftBackupPage extends HookConsumerWidget {
class DriftBackupPage extends ConsumerStatefulWidget {
const DriftBackupPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
useEffect(
() {
ref.read(driftBackupProvider.notifier).getBackupStatus();
return null;
},
[],
);
ConsumerState<DriftBackupPage> createState() => _DriftBackupPageState();
}
Widget buildControlButtons() {
return Padding(
padding: const EdgeInsets.only(
top: 24,
),
child: Column(
children: [
ElevatedButton(
onPressed: () => ref.read(driftBackupProvider.notifier).backup(),
child: const Text(
"backup_controller_page_start_backup",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
).tr(),
),
OutlinedButton(
onPressed: () => ref.read(driftBackupProvider.notifier).cancel(),
child: const Text(
"cancel",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
).tr(),
),
OutlinedButton(
onPressed: () =>
ref.read(driftBackupProvider.notifier).getDataInfo(),
child: const Text(
"Get database info",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
).tr(),
),
],
),
);
}
class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
@override
void initState() {
super.initState();
ref.read(driftBackupProvider.notifier).getBackupStatus();
}
Future<void> startBackup() async {
await ref.read(driftBackupProvider.notifier).getBackupStatus();
await ref.read(driftBackupProvider.notifier).backup();
}
Future<void> stopBackup() async {
await ref.read(driftBackupProvider.notifier).cancel();
}
@override
Widget build(BuildContext context) {
final selectedAlbum = ref
.watch(backupAlbumProvider)
.where(
(album) => album.backupSelection == BackupSelection.selected,
)
.toList();
final uploadItems = ref.watch(
driftBackupProvider.select((state) => state.uploadItems),
);
return Scaffold(
appBar: AppBar(
elevation: 0,
title: const Text(
"Backup (Experimental)",
title: Text(
"backup_controller_page_backup".t(),
),
leading: IconButton(
onPressed: () {
ref.watch(websocketProvider.notifier).listenUploadEvent();
context.maybePop(true);
},
splashRadius: 24,
@@ -85,18 +63,6 @@ class DriftBackupPage extends HookConsumerWidget {
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: [
@@ -110,11 +76,24 @@ class DriftBackupPage extends HookConsumerWidget {
children: [
const SizedBox(height: 8),
const _BackupAlbumSelectionCard(),
const _TotalCard(),
const _BackupCard(),
const _RemainderCard(),
const Divider(),
buildControlButtons(),
if (selectedAlbum.isNotEmpty) ...[
const _TotalCard(),
const _BackupCard(),
const _RemainderCard(),
const Divider(),
BackupToggleButton(
onStart: () async => await startBackup(),
onStop: () async => await stopBackup(),
),
if (uploadItems.isNotEmpty)
TextButton.icon(
icon: const Icon(Icons.info_outline_rounded),
onPressed: () => context.pushRoute(
const DriftUploadDetailRoute(),
),
label: Text("view_details".t(context: context)),
),
],
],
),
),
@@ -1,25 +1,67 @@
import 'dart:io';
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/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.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/backup_album.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/providers/app_settings.provider.dart';
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
@RoutePage()
class DriftBackupAlbumSelectionPage extends HookConsumerWidget {
class DriftBackupAlbumSelectionPage extends ConsumerStatefulWidget {
const DriftBackupAlbumSelectionPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<DriftBackupAlbumSelectionPage> createState() =>
_DriftBackupAlbumSelectionPageState();
}
class _DriftBackupAlbumSelectionPageState
extends ConsumerState<DriftBackupAlbumSelectionPage> {
String _searchQuery = '';
bool _isSearchMode = false;
late ValueNotifier<bool> _enableSyncUploadAlbum;
late TextEditingController _searchController;
late FocusNode _searchFocusNode;
@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();
}
@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)
@@ -27,133 +69,6 @@ class DriftBackupAlbumSelectionPage extends HookConsumerWidget {
final excludedBackupAlbums = albums
.where((album) => album.backupSelection == BackupSelection.excluded)
.toList();
final enableSyncUploadAlbum =
useAppSettingsState(AppSettingsEnum.syncAlbums);
final isDarkTheme = context.isDarkTheme;
useEffect(
() {
ref.watch(backupProvider.notifier).getBackupInfo();
ref.watch(backupAlbumProvider.notifier).getAll();
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 DriftAlbumInfoListTile(
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 DriftAlbumInfoListTile(
album: albums[index],
);
}),
),
);
}
buildSelectedAlbumNameChip() {
return selectedBackupAlbums.map((album) {
void removeSelection() {
ref.read(backupAlbumProvider.notifier).deselectAlbum(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.read(backupAlbumProvider.notifier).deselectAlbum(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) {
@@ -170,138 +85,439 @@ class DriftBackupAlbumSelectionPage extends HookConsumerWidget {
onPressed: () => context.maybePop(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
title: const Text(
"backup_album_selection_page_select_albums",
).tr(),
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: SafeArea(
child: 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(),
body: CustomScrollView(
physics: const ClampingScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
// Selected Album Chips
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: [
...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(),
},
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap(
children: [
_SelectedAlbumNameChips(
selectedBackupAlbums: selectedBackupAlbums,
),
style: context.textTheme.titleSmall,
_ExcludedAlbumNameChips(
excludedBackupAlbums: excludedBackupAlbums,
),
],
),
),
SettingsSwitchListTile(
valueNotifier: _enableSyncUploadAlbum,
title: "sync_albums".t(context: context),
subtitle:
"sync_upload_album_setting_subtitle".t(context: context),
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(
"albums_on_device_count".t(
context: context,
args: {'count': albumCount.toString()},
),
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,
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,
),
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(),
],
),
),
);
},
);
},
),
).t(context: context),
),
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,
),
).t(context: context),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text(
'backup_album_selection_page_assets_scatter',
style: TextStyle(
fontSize: 14,
),
).t(context: context),
],
),
),
);
},
);
},
),
),
// buildSearchBar(),
],
),
if (Platform.isAndroid)
_SelectAllButton(
filteredAlbums: filteredAlbums,
selectedBackupAlbums: selectedBackupAlbums,
),
],
),
SliverLayoutBuilder(
builder: (context, constraints) {
if (constraints.crossAxisExtent > 600) {
return buildAlbumSelectionGrid();
} else {
return buildAlbumSelectionList();
}
},
),
],
),
SliverLayoutBuilder(
builder: (context, constraints) {
if (constraints.crossAxisExtent > 600) {
return _AlbumSelectionGrid(
filteredAlbums: filteredAlbums,
searchQuery: _searchQuery,
);
} else {
return _AlbumSelectionList(
filteredAlbums: filteredAlbums,
searchQuery: _searchQuery,
);
}
},
),
],
),
);
}
}
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),
),
),
),
],
),
);
}
}
@@ -0,0 +1,428 @@
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/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:path/path.dart' as path;
@RoutePage()
class DriftUploadDetailPage extends ConsumerWidget {
const DriftUploadDetailPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final uploadItems = ref.watch(
driftBackupProvider.select((state) => state.uploadItems),
);
return Scaffold(
appBar: AppBar(
title: Text("upload_details".t(context: context)),
backgroundColor: context.colorScheme.surface,
elevation: 0,
scrolledUnderElevation: 1,
),
body: uploadItems.isEmpty
? _buildEmptyState(context)
: _buildUploadList(uploadItems),
);
}
Widget _buildEmptyState(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_upload_outlined,
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 _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);
return Card(
elevation: 0,
color: context.colorScheme.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(
Radius.circular(16),
),
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),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
path.basename(item.filename),
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'Tap for more details',
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,
),
],
),
],
),
),
),
);
}
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,
),
),
),
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,
),
),
],
),
Text(
networkSpeedAsString,
style: context.textTheme.labelSmall?.copyWith(
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
fontSize: 10,
),
),
],
);
}
Future<void> _showFileDetailDialog(
BuildContext context,
DriftUploadStatus item,
) async {
showDialog(
context: context,
builder: (context) => FileDetailDialog(uploadStatus: item),
);
}
}
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: [
// Thumbnail at the top center
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(
asset: asset,
size: const Size(512, 512),
fit: BoxFit.cover,
)
: null,
),
),
),
const SizedBox(height: 24),
if (asset != null) ...[
_buildInfoSection(context, [
_buildInfoRow(
context,
"Filename",
path.basename(uploadStatus.filename),
),
_buildInfoRow(
context,
"Local ID",
asset.id,
),
_buildInfoRow(
context,
"File Size",
formatHumanReadableBytes(uploadStatus.fileSize, 2),
),
if (asset.width != null)
_buildInfoRow(context, "Width", "${asset.width}px"),
if (asset.height != null)
_buildInfoRow(
context,
"Height",
"${asset.height}px",
),
_buildInfoRow(
context,
"Created At",
asset.createdAt.toString(),
),
_buildInfoRow(
context,
"Updated At",
asset.updatedAt.toString(),
),
if (asset.checksum != null)
_buildInfoRow(
context,
"Checksum",
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?.copyWith(),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
Future<LocalAsset?> _getAssetDetails(
WidgetRef ref,
String localAssetId,
) async {
try {
final repository = ref.read(localAssetRepository);
return await repository.getById(localAssetId);
} catch (e) {
return null;
}
}
}