diff --git a/mobile/lib/modules/backup/ui/album_info_card.dart b/mobile/lib/modules/backup/ui/album_info_card.dart index 952d4393ed..a8fcde8ee2 100644 --- a/mobile/lib/modules/backup/ui/album_info_card.dart +++ b/mobile/lib/modules/backup/ui/album_info_card.dart @@ -137,6 +137,7 @@ class AlbumInfoCard extends HookConsumerWidget { } }, child: Card( + clipBehavior: Clip.hardEdge, margin: const EdgeInsets.all(1), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), // if you need this @@ -150,20 +151,17 @@ class AlbumInfoCard extends HookConsumerWidget { elevation: 0, borderOnForeground: false, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Stack( - children: [ - Container( - width: 200, - height: 200, - decoration: BoxDecoration( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(12), - topRight: Radius.circular(12), - ), - image: DecorationImage( - colorFilter: buildImageFilter(), + Expanded( + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + ColorFiltered( + colorFilter: buildImageFilter(), + child: Image( + width: double.infinity, + height: double.infinity, image: imageData != null ? MemoryImage(imageData!) : const AssetImage( @@ -172,58 +170,56 @@ class AlbumInfoCard extends HookConsumerWidget { fit: BoxFit.cover, ), ), - child: null, - ), - Positioned( - bottom: 10, - left: 25, - child: buildSelectedTextBox(), - ) - ], + Positioned( + bottom: 10, + right: 25, + child: buildSelectedTextBox(), + ) + ], + ), ), Padding( - padding: const EdgeInsets.only(top: 8.0), + padding: const EdgeInsets.only( + left: 25, + ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - SizedBox( - width: 140, - child: Padding( - padding: const EdgeInsets.only(left: 25.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - albumInfo.name, - style: TextStyle( - fontSize: 14, - color: Theme.of(context).primaryColor, - fontWeight: FontWeight.bold, - ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + albumInfo.name, + style: TextStyle( + fontSize: 14, + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, ), - Padding( - padding: const EdgeInsets.only(top: 2.0), - child: FutureBuilder( - builder: ((context, snapshot) { - if (snapshot.hasData) { - return Text( - snapshot.data.toString() + - (albumInfo.isAll - ? " (${'backup_all'.tr()})" - : ""), - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - ), - ); - } - return const Text("0"); - }), - future: albumInfo.assetCount, - ), - ) - ], - ), + ), + Padding( + padding: const EdgeInsets.only(top: 2.0), + child: FutureBuilder( + builder: ((context, snapshot) { + if (snapshot.hasData) { + return Text( + snapshot.data.toString() + + (albumInfo.isAll + ? " (${'backup_all'.tr()})" + : ""), + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ); + } + return const Text("0"); + }), + future: albumInfo.assetCount, + ), + ) + ], ), ), IconButton( diff --git a/mobile/lib/modules/backup/ui/album_info_list_tile.dart b/mobile/lib/modules/backup/ui/album_info_list_tile.dart new file mode 100644 index 0000000000..c392fed3e0 --- /dev/null +++ b/mobile/lib/modules/backup/ui/album_info_list_tile.dart @@ -0,0 +1,176 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/backup/models/available_album.model.dart'; +import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/ui/immich_toast.dart'; + +class AlbumInfoListTile extends HookConsumerWidget { + final Uint8List? imageData; + final AvailableAlbum albumInfo; + + const AlbumInfoListTile({Key? key, this.imageData, required this.albumInfo}) + : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bool isSelected = + ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo); + final bool isExcluded = + ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo); + + ColorFilter selectedFilter = ColorFilter.mode( + Theme.of(context).primaryColor.withAlpha(100), + BlendMode.darken, + ); + ColorFilter excludedFilter = + ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken); + ColorFilter unselectedFilter = + const ColorFilter.mode(Colors.black, BlendMode.color); + var isDarkTheme = Theme.of(context).brightness == Brightness.dark; + + var assetCount = useState(0); + + useEffect( + () { + albumInfo.assetCount.then((value) => assetCount.value = value); + return null; + }, + [], + ); + + buildImageFilter() { + if (isSelected) { + return selectedFilter; + } else if (isExcluded) { + return excludedFilter; + } else { + return unselectedFilter; + } + } + + buildTileColor() { + if (isSelected) { + return isDarkTheme + ? Theme.of(context).primaryColor.withAlpha(100) + : Theme.of(context).primaryColor.withAlpha(25); + } else if (isExcluded) { + return isDarkTheme + ? Colors.red[300]?.withAlpha(150) + : Colors.red[100]?.withAlpha(150); + } else { + return Colors.transparent; + } + } + + return GestureDetector( + onDoubleTap: () { + HapticFeedback.selectionClick(); + + if (isExcluded) { + // Remove from exclude album list + ref + .watch(backupProvider.notifier) + .removeExcludedAlbumForBackup(albumInfo); + } else { + // Add to exclude album list + if (ref.watch(backupProvider).selectedBackupAlbums.length == 1 && + ref + .watch(backupProvider) + .selectedBackupAlbums + .contains(albumInfo)) { + ImmichToast.show( + context: context, + msg: "backup_err_only_album".tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + return; + } + + if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') { + ImmichToast.show( + context: context, + msg: 'Cannot exclude album contains all assets', + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + return; + } + + ref + .watch(backupProvider.notifier) + .addExcludedAlbumForBackup(albumInfo); + } + }, + child: ListTile( + tileColor: buildTileColor(), + contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + onTap: () { + HapticFeedback.selectionClick(); + if (isSelected) { + if (ref.watch(backupProvider).selectedBackupAlbums.length == 1) { + ImmichToast.show( + context: context, + msg: "backup_err_only_album".tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + return; + } + + ref.watch(backupProvider.notifier).removeAlbumForBackup(albumInfo); + } else { + ref.watch(backupProvider.notifier).addAlbumForBackup(albumInfo); + } + }, + leading: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + height: 80, + width: 80, + child: ColorFiltered( + colorFilter: buildImageFilter(), + child: Image( + width: double.infinity, + height: double.infinity, + image: imageData != null + ? MemoryImage(imageData!) + : const AssetImage( + 'assets/immich-logo-no-outline.png', + ) as ImageProvider, + fit: BoxFit.cover, + ), + ), + ), + ), + title: Text( + albumInfo.name, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + subtitle: Text(assetCount.value.toString()), + trailing: IconButton( + onPressed: () { + AutoRouter.of(context).push( + AlbumPreviewRoute(album: albumInfo.albumEntity), + ); + }, + icon: Icon( + Icons.image_outlined, + color: Theme.of(context).primaryColor, + size: 24, + ), + splashRadius: 25, + ), + ), + ); + } +} diff --git a/mobile/lib/modules/backup/views/backup_album_selection_page.dart b/mobile/lib/modules/backup/views/backup_album_selection_page.dart index 836f192b55..80259d06f7 100644 --- a/mobile/lib/modules/backup/views/backup_album_selection_page.dart +++ b/mobile/lib/modules/backup/views/backup_album_selection_page.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/immich_colors.dart'; import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; import 'package:immich_mobile/modules/backup/ui/album_info_card.dart'; +import 'package:immich_mobile/modules/backup/ui/album_info_list_tile.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart'; @@ -18,7 +19,12 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums; final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums; final isDarkTheme = Theme.of(context).brightness == Brightness.dark; - final albums = ref.watch(backupProvider).availableAlbums; + final allAlbums = ref.watch(backupProvider).availableAlbums; + + // Albums which are displayed to the user + // by filtering out based on search + final filteredAlbums = useState(allAlbums); + final albums = filteredAlbums.value; useEffect( () { @@ -30,27 +36,53 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { buildAlbumSelectionList() { if (albums.isEmpty) { - return const Center( - child: ImmichLoadingIndicator(), + return const SliverToBoxAdapter( + child: Center( + child: ImmichLoadingIndicator(), + ), ); } - return SizedBox( - height: 265, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: albums.length, - physics: const BouncingScrollPhysics(), - itemBuilder: ((context, index) { - var thumbnailData = albums[index].thumbnailData; - return Padding( - padding: index == 0 - ? const EdgeInsets.only(left: 16.00) - : const EdgeInsets.all(0), - child: AlbumInfoCard( + return SliverPadding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + ((context, index) { + var thumbnailData = albums[index].thumbnailData; + return AlbumInfoListTile( imageData: thumbnailData, albumInfo: albums[index], - ), + ); + }), + childCount: albums.length, + ), + ), + ); + } + + buildAlbumSelectionGrid() { + if (albums.isEmpty) { + return const SliverToBoxAdapter( + child: Center( + child: ImmichLoadingIndicator(), + ), + ); + } + + 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) { + var thumbnailData = albums[index].thumbnailData; + return AlbumInfoCard( + imageData: thumbnailData, + albumInfo: albums[index], ); }), ), @@ -139,19 +171,17 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0), child: TextFormField( onChanged: (searchValue) { - var avaialbleAlbums = ref - .watch(backupProvider) - .availableAlbums - .where( - (album) => album.name - .toLowerCase() - .contains(searchValue.toLowerCase()), - ) - .toList(); - - ref - .read(backupProvider.notifier) - .setAvailableAlbums(avaialbleAlbums); + if (searchValue.isEmpty) { + filteredAlbums.value = allAlbums; + } else { + filteredAlbums.value = allAlbums + .where( + (album) => album.name + .toLowerCase() + .contains(searchValue.toLowerCase()), + ) + .toList(); + } }, decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric( @@ -190,143 +220,162 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { ).tr(), elevation: 0, ), - body: ListView( + body: CustomScrollView( physics: const ClampingScrollPhysics(), - children: [ - Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 16.0, - ), - child: const Text( - "backup_album_selection_page_selection_info", - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ).tr(), - ), - // Selected Album Chips - - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Wrap( + slivers: [ + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - ...buildSelectedAlbumNameChip(), - ...buildExcludedAlbumNameChip() + Padding( + padding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 16.0, + ), + child: const Text( + "backup_album_selection_page_selection_info", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ).tr(), + ), + // Selected Album Chips + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Wrap( + children: [ + ...buildSelectedAlbumNameChip(), + ...buildExcludedAlbumNameChip() + ], + ), + ), + + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + child: Card( + margin: const EdgeInsets.all(0), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide( + color: isDarkTheme + ? const Color.fromARGB(255, 0, 0, 0) + : const Color.fromARGB(255, 235, 235, 235), + width: 1, + ), + ), + elevation: 0, + borderOnForeground: false, + child: Column( + children: [ + ListTile( + visualDensity: VisualDensity.compact, + title: const Text( + "backup_album_selection_page_total_assets", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ).tr(), + trailing: Text( + ref + .watch(backupProvider) + .allUniqueAssets + .length + .toString(), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + ), + + ListTile( + title: Text( + "backup_album_selection_page_albums_device".tr( + args: [ + ref + .watch(backupProvider) + .availableAlbums + .length + .toString() + ], + ), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + subtitle: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + "backup_album_selection_page_albums_tap", + style: TextStyle( + fontSize: 12, + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + trailing: IconButton( + splashRadius: 16, + icon: Icon( + Icons.info, + size: 20, + color: Theme.of(context).primaryColor, + ), + onPressed: () { + // show the dialog + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + elevation: 5, + title: Text( + 'backup_album_selection_page_selection_info', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Theme.of(context).primaryColor, + ), + ).tr(), + content: SingleChildScrollView( + child: ListBody( + children: [ + const Text( + 'backup_album_selection_page_assets_scatter', + style: TextStyle( + fontSize: 14, + ), + ).tr(), + ], + ), + ), + ); + }, + ); + }, + ), + ), + + buildSearchBar(), ], ), ), - - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), - child: Card( - margin: const EdgeInsets.all(0), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - side: BorderSide( - color: isDarkTheme - ? const Color.fromARGB(255, 0, 0, 0) - : const Color.fromARGB(255, 235, 235, 235), - width: 1, - ), - ), - elevation: 0, - borderOnForeground: false, - child: Column( - children: [ - ListTile( - visualDensity: VisualDensity.compact, - title: const Text( - "backup_album_selection_page_total_assets", - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ).tr(), - trailing: Text( - ref - .watch(backupProvider) - .allUniqueAssets - .length - .toString(), - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - ], - ), - ), - ), - - ListTile( - title: Text( - "backup_album_selection_page_albums_device".tr( - args: [ - ref.watch(backupProvider).availableAlbums.length.toString() - ], - ), - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), - ), - subtitle: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Text( - "backup_album_selection_page_albums_tap", - style: TextStyle( - fontSize: 12, - color: Theme.of(context).primaryColor, - fontWeight: FontWeight.bold, - ), - ).tr(), - ), - trailing: IconButton( - splashRadius: 16, - icon: Icon( - Icons.info, - size: 20, - color: Theme.of(context).primaryColor, - ), - onPressed: () { - // show the dialog - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - elevation: 5, - title: Text( - 'backup_album_selection_page_selection_info', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Theme.of(context).primaryColor, - ), - ).tr(), - content: SingleChildScrollView( - child: ListBody( - children: [ - const Text( - 'backup_album_selection_page_assets_scatter', - style: TextStyle( - fontSize: 14, - ), - ).tr(), - ], - ), - ), - ); - }, - ); - }, - ), - ), - - buildSearchBar(), - - Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: buildAlbumSelectionList(), + SliverLayoutBuilder( + builder: (context, constraints) { + if (constraints.crossAxisExtent > 600) { + return buildAlbumSelectionGrid(); + } else { + return buildAlbumSelectionList(); + } + }, ), ], ),