From 12217bde8a14c45103cc23f42c70cc2364c6acc7 Mon Sep 17 00:00:00 2001 From: martyfuhry Date: Tue, 28 Feb 2023 11:22:18 -0500 Subject: [PATCH 1/2] feat(mobile): Adds onboarding for permissions (#1865) * adds onboarding * fixed error where login was taking you to permission page * fixed a bad rebase and added more checks to not start backup service on login if no gallery permission * forgot the permission handler import in AppDelegate * reverts album selection page * change to ref watch * added device_info_plus to podspec * removed unused import --------- Co-authored-by: Marty Fuhry --- mobile/assets/i18n/en-US.json | 13 +- mobile/ios/Podfile | 2 +- mobile/ios/Podfile.lock | 8 +- mobile/ios/Runner/AppDelegate.swift | 5 + mobile/lib/main.dart | 10 +- .../background.service.dart | 9 + .../backup/providers/backup.provider.dart | 11 +- mobile/lib/modules/login/ui/login_form.dart | 33 +-- .../gallery_permission.provider.dart | 101 +++++++++ .../views/permission_onboarding_page.dart | 201 ++++++++++++++++++ ... => notification_permission.provider.dart} | 0 .../settings/services/permission.service.dart | 21 -- .../notification_setting.dart | 2 +- .../lib/routing/gallery_permission_guard.dart | 19 ++ mobile/lib/routing/router.dart | 17 +- mobile/lib/routing/router.gr.dart | 35 ++- mobile/lib/shared/ui/immich_logo.dart | 25 +++ mobile/lib/shared/ui/immich_title_text.dart | 26 +++ mobile/lib/shared/views/splash_screen.dart | 10 +- mobile/pubspec.lock | 16 ++ mobile/pubspec.yaml | 1 + 21 files changed, 510 insertions(+), 55 deletions(-) create mode 100644 mobile/lib/modules/onboarding/providers/gallery_permission.provider.dart create mode 100644 mobile/lib/modules/onboarding/views/permission_onboarding_page.dart rename mobile/lib/modules/settings/providers/{permission.provider.dart => notification_permission.provider.dart} (100%) delete mode 100644 mobile/lib/modules/settings/services/permission.service.dart create mode 100644 mobile/lib/routing/gallery_permission_guard.dart create mode 100644 mobile/lib/shared/ui/immich_logo.dart create mode 100644 mobile/lib/shared/ui/immich_title_text.dart diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 9567f24d0f..f9851b7460 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -229,5 +229,14 @@ "version_announcement_overlay_text_1": "Hi friend, there is a new release of", "version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", - "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89" -} \ No newline at end of file + "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", + "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "permission_onboarding_grant_permission": "Grant permission", + "permission_onboarding_permission_granted": "Permission granted! You are all set.", + "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.", + "permission_onboarding_get_started": "Get started", + "permission_onboarding_go_to_settings": "Go to settings", + "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", + "permission_onboarding_continue_anyway": "Continue anyway", + "permission_onboarding_log_out": "Log out" +} diff --git a/mobile/ios/Podfile b/mobile/ios/Podfile index e9e52b5d07..30c25cfc21 100644 --- a/mobile/ios/Podfile +++ b/mobile/ios/Podfile @@ -72,7 +72,7 @@ post_install do |installer| # 'PERMISSION_SPEECH_RECOGNIZER=1', ## dart: PermissionGroup.photos - # 'PERMISSION_PHOTOS=1', + 'PERMISSION_PHOTOS=1', ## dart: [PermissionGroup.location, PermissionGroup.locationAlways, PermissionGroup.locationWhenInUse] # 'PERMISSION_LOCATION=1', diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index f03d82a69a..c2438952d8 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - device_info_plus (0.0.1): + - Flutter - Flutter (1.0.0) - flutter_native_splash (0.0.1): - Flutter @@ -49,6 +51,7 @@ PODS: - Flutter DEPENDENCIES: + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - Flutter (from `Flutter`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) @@ -76,6 +79,8 @@ SPEC REPOS: - Toast EXTERNAL SOURCES: + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" Flutter: :path: Flutter flutter_native_splash: @@ -116,6 +121,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/wakelock/ios" SPEC CHECKSUMS: + device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c @@ -139,6 +145,6 @@ SPEC CHECKSUMS: video_player_avfoundation: 6d971a232d72e6ee25368378d48a079dea01f1cf wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f -PODFILE CHECKSUM: 4a7e0475ea85ab7bf89955bc4c7ea9d18b54dfd8 +PODFILE CHECKSUM: 0606648e8a9ecd5a59eafa5ab3187b45a9004a28 COCOAPODS: 1.11.3 diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index ca3cf4564f..c3302d18bd 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -4,6 +4,7 @@ import Flutter import BackgroundTasks import path_provider_ios import photo_manager +import permission_handler_apple @UIApplicationMain @objc class AppDelegate: FlutterAppDelegate { @@ -30,6 +31,10 @@ import photo_manager if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!) } + + if !registry.hasPlugin("org.cocoapods.permission-handler-apple") { + PermissionHandlerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.permission-handler-apple")!) + } } return super.application(application, didFinishLaunchingWithOptions: launchOptions) diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 62fea4aa6c..9fcab0d881 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -15,7 +15,8 @@ import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart'; import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; -import 'package:immich_mobile/modules/settings/providers/permission.provider.dart'; +import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart'; +import 'package:immich_mobile/modules/settings/providers/notification_permission.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/tab_navigation_observer.dart'; import 'package:immich_mobile/shared/models/immich_logger_message.model.dart'; @@ -34,6 +35,7 @@ import 'package:immich_mobile/utils/migration.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'constants/hive_box.dart'; void main() async { @@ -129,8 +131,10 @@ class ImmichAppState extends ConsumerState ref.watch(appStateProvider.notifier).state = AppStateEnum.resumed; var isAuthenticated = ref.watch(authenticationProvider).isAuthenticated; + final permission = ref.watch(galleryPermissionNotifier); - if (isAuthenticated) { + // Needs to be logged in and have gallery permissions + if (isAuthenticated && (permission.isGranted || permission.isLimited)) { ref.read(backupProvider.notifier).resumeBackup(); ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); ref.watch(assetProvider.notifier).getAllAsset(); @@ -143,6 +147,8 @@ class ImmichAppState extends ConsumerState ref.watch(notificationPermissionProvider.notifier) .getNotificationPermission(); + ref.watch(galleryPermissionNotifier.notifier) + .getGalleryPermissionStatus(); ref.read(iOSBackgroundSettingsProvider.notifier).refresh(); diff --git a/mobile/lib/modules/backup/background_service/background.service.dart b/mobile/lib/modules/backup/background_service/background.service.dart index 3502455b1e..7f67147bcc 100644 --- a/mobile/lib/modules/backup/background_service/background.service.dart +++ b/mobile/lib/modules/backup/background_service/background.service.dart @@ -560,6 +560,9 @@ class BackgroundService { } Future getIOSBackupLastRun(IosBackgroundTask task) async { + if (!Platform.isIOS) { + return null; + } // Seconds since last run final double? lastRun = task == IosBackgroundTask.fetch ? await _foregroundChannel.invokeMethod('lastBackgroundFetchTime') @@ -572,10 +575,16 @@ class BackgroundService { } Future getIOSBackupNumberOfProcesses() async { + if (!Platform.isIOS) { + return 0; + } return await _foregroundChannel.invokeMethod('numberOfBackgroundProcesses'); } Future getIOSBackgroundAppRefreshEnabled() async { + if (!Platform.isIOS) { + return false; + } return await _foregroundChannel.invokeMethod('backgroundAppRefreshEnabled'); } } diff --git a/mobile/lib/modules/backup/providers/backup.provider.dart b/mobile/lib/modules/backup/providers/backup.provider.dart index b23b197dcf..ddb45a4ecb 100644 --- a/mobile/lib/modules/backup/providers/backup.provider.dart +++ b/mobile/lib/modules/backup/providers/backup.provider.dart @@ -14,10 +14,12 @@ import 'package:immich_mobile/modules/backup/background_service/background.servi import 'package:immich_mobile/modules/backup/services/backup.service.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; +import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/shared/providers/app_state.provider.dart'; import 'package:immich_mobile/shared/services/server_info.service.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:photo_manager/photo_manager.dart'; class BackupNotifier extends StateNotifier { @@ -26,6 +28,7 @@ class BackupNotifier extends StateNotifier { this._serverInfoService, this._authState, this._backgroundService, + this._galleryPermissionNotifier, this.ref, ) : super( BackUpState( @@ -65,6 +68,7 @@ class BackupNotifier extends StateNotifier { final ServerInfoService _serverInfoService; final AuthenticationState _authState; final BackgroundService _backgroundService; + final GalleryPermissionNotifier _galleryPermissionNotifier; final Ref ref; /// @@ -431,8 +435,8 @@ class BackupNotifier extends StateNotifier { await getBackupInfo(); - var authResult = await PhotoManager.requestPermissionExtend(); - if (authResult.isAuth) { + final hasPermission = _galleryPermissionNotifier.hasPermission; + if (hasPermission) { await PhotoManager.clearFileCache(); if (state.allUniqueAssets.isEmpty) { @@ -463,7 +467,7 @@ class BackupNotifier extends StateNotifier { ); await _notifyBackgroundServiceCanRun(); } else { - PhotoManager.openSetting(); + openAppSettings(); } } @@ -704,6 +708,7 @@ final backupProvider = ref.watch(serverInfoServiceProvider), ref.watch(authenticationProvider), ref.watch(backgroundServiceProvider), + ref.watch(galleryPermissionNotifier.notifier), ref, ); }); diff --git a/mobile/lib/modules/login/ui/login_form.dart b/mobile/lib/modules/login/ui/login_form.dart index 2c6b2a4a85..a324ce17b2 100644 --- a/mobile/lib/modules/login/ui/login_form.dart +++ b/mobile/lib/modules/login/ui/login_form.dart @@ -7,14 +7,18 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; import 'package:immich_mobile/modules/login/providers/oauth.provider.dart'; +import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; +import 'package:immich_mobile/shared/ui/immich_logo.dart'; +import 'package:immich_mobile/shared/ui/immich_title_text.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/utils/url_helper.dart'; import 'package:openapi/api.dart'; +import 'package:permission_handler/permission_handler.dart'; class LoginForm extends HookConsumerWidget { const LoginForm({Key? key}) : super(key: key); @@ -105,22 +109,12 @@ class LoginForm extends HookConsumerWidget { onDoubleTap: () => populateTestLoginInfo(), child: RotationTransition( turns: logoAnimationController, - child: const Image( - image: AssetImage('assets/immich-logo-no-outline.png'), - width: 100, - filterQuality: FilterQuality.high, + child: const ImmichLogo( + heroTag: 'logo', ), ), ), - Text( - 'IMMICH', - style: TextStyle( - fontFamily: 'SnowburstOne', - fontWeight: FontWeight.bold, - fontSize: 48, - color: Theme.of(context).primaryColor, - ), - ), + const ImmichTitleText(), EmailInput(controller: usernameController), PasswordInput(controller: passwordController), ServerEndpointInput( @@ -164,7 +158,10 @@ class LoginForm extends HookConsumerWidget { isLoading: isLoading, onLoginSuccess: () { isLoading.value = false; - ref.watch(backupProvider.notifier).resumeBackup(); + final permission = ref.watch(galleryPermissionNotifier); + if (permission.isGranted || permission.isLimited) { + ref.watch(backupProvider.notifier).resumeBackup(); + } AutoRouter.of(context).replace( const TabControllerRoute(), ); @@ -313,7 +310,13 @@ class LoginButton extends ConsumerWidget { !ref.read(authenticationProvider).isAdmin) { AutoRouter.of(context).push(const ChangePasswordRoute()); } else { - ref.read(backupProvider.notifier).resumeBackup(); + final hasPermission = await ref + .read(galleryPermissionNotifier.notifier) + .hasPermission; + if (hasPermission) { + // Don't resume the backup until we have gallery permission + ref.read(backupProvider.notifier).resumeBackup(); + } AutoRouter.of(context).replace(const TabControllerRoute()); } } else { diff --git a/mobile/lib/modules/onboarding/providers/gallery_permission.provider.dart b/mobile/lib/modules/onboarding/providers/gallery_permission.provider.dart new file mode 100644 index 0000000000..61ea75e5bf --- /dev/null +++ b/mobile/lib/modules/onboarding/providers/gallery_permission.provider.dart @@ -0,0 +1,101 @@ +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:permission_handler/permission_handler.dart'; + +class GalleryPermissionNotifier extends StateNotifier { + GalleryPermissionNotifier() + : super(PermissionStatus.denied) // Denied is the intitial state + { + // Sets the initial state + getGalleryPermissionStatus(); + } + + get hasPermission => state.isGranted || state.isLimited; + + /// Requests the gallery permission + Future requestGalleryPermission() async { + // Android 32 and below uses Permission.storage + if (Platform.isAndroid) { + final androidInfo = await DeviceInfoPlugin().androidInfo; + if (androidInfo.version.sdkInt <= 32) { + // Android 32 and below need storage + final permission = await Permission.storage.request(); + state = permission; + return permission; + } else { + // Android 33 need photo & video + final photos = await Permission.photos.request(); + if (!photos.isGranted) { + // Don't ask twice for the same permission + return photos; + } + final videos = await Permission.videos.request(); + + // Return the joint result of those two permissions + final PermissionStatus status; + if (photos.isGranted && videos.isGranted) { + status = PermissionStatus.granted; + } else if (photos.isDenied || videos.isDenied) { + status = PermissionStatus.denied; + } else if (photos.isPermanentlyDenied || videos.isPermanentlyDenied) { + status = PermissionStatus.permanentlyDenied; + } else { + status = PermissionStatus.denied; + } + + state = status; + return status; + } + } else { + // iOS can use photos + final photos = await Permission.photos.request(); + state = photos; + return photos; + } + } + + /// Checks the current state of the gallery permissions without + /// requesting them again + Future getGalleryPermissionStatus() async { + // Android 32 and below uses Permission.storage + if (Platform.isAndroid) { + final androidInfo = await DeviceInfoPlugin().androidInfo; + if (androidInfo.version.sdkInt <= 32) { + // Android 32 and below need storage + final permission = await Permission.storage.status; + state = permission; + return permission; + } else { + // Android 33 needs photo & video + final photos = await Permission.photos.status; + final videos = await Permission.videos.status; + + // Return the joint result of those two permissions + final PermissionStatus status; + if (photos.isGranted && videos.isGranted) { + status = PermissionStatus.granted; + } else if (photos.isDenied || videos.isDenied) { + status = PermissionStatus.denied; + } else if (photos.isPermanentlyDenied || videos.isPermanentlyDenied) { + status = PermissionStatus.permanentlyDenied; + } else { + status = PermissionStatus.denied; + } + + state = status; + return status; + } + } else { + // iOS can use photos + final photos = await Permission.photos.status; + state = photos; + return photos; + } + } +} + +final galleryPermissionNotifier + = StateNotifierProvider + ((ref) => GalleryPermissionNotifier()); diff --git a/mobile/lib/modules/onboarding/views/permission_onboarding_page.dart b/mobile/lib/modules/onboarding/views/permission_onboarding_page.dart new file mode 100644 index 0000000000..cab16d82d0 --- /dev/null +++ b/mobile/lib/modules/onboarding/views/permission_onboarding_page.dart @@ -0,0 +1,201 @@ +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/modules/backup/providers/backup.provider.dart'; +import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; +import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/ui/immich_logo.dart'; +import 'package:immich_mobile/shared/ui/immich_title_text.dart'; +import 'package:permission_handler/permission_handler.dart'; + +class PermissionOnboardingPage extends HookConsumerWidget { + + const PermissionOnboardingPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final PermissionStatus permission = ref.watch(galleryPermissionNotifier); + + // Navigate to the main Tab Controller when permission is granted + void goToHome() { + // Resume backup (if enable) then navigate + ref.watch(backupProvider.notifier).resumeBackup() + .catchError((error) { + debugPrint('PermissionOnboardingPage error: $error'); + }); + AutoRouter.of(context).replace( + const TabControllerRoute(), + ); + } + + // When the permission is denied, we show a request permission page + buildRequestPermission() { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'permission_onboarding_request', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ).tr(), + const SizedBox(height: 18), + ElevatedButton( + onPressed: () => ref + .read(galleryPermissionNotifier.notifier) + .requestGalleryPermission() + .then((permission) async { + if (permission.isGranted) { + // If permission is limited, we will show the limited + // permission page + goToHome(); + } + }), + child: const Text( + 'permission_onboarding_grant_permission', + ).tr(), + ), + ], + ); + } + + // When permission is granted from outside the app, this will show to + // let them continue on to the main timeline + buildPermissionGranted() { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'permission_onboarding_permission_granted', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ).tr(), + const SizedBox(height: 18), + ElevatedButton( + onPressed: () => goToHome(), + child: const Text('permission_onboarding_get_started').tr(), + ), + ], + ); + } + + // iOS 14+ has limited permission options, which let someone just share + // a few photos with the app. If someone only has limited permissions, we + // inform that Immich works best when given full permission + buildPermissionLimited() { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.warning_outlined, + color: Colors.yellow, + size: 48, + ), + const SizedBox(height: 8), + Text( + 'permission_onboarding_permission_limited', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ).tr(), + const SizedBox(height: 18), + ElevatedButton( + onPressed: () => openAppSettings(), + child: const Text( + 'permission_onboarding_go_to_settings', + ).tr(), + ), + const SizedBox(height: 8.0), + TextButton( + onPressed: () => goToHome(), + child: const Text( + 'permission_onboarding_continue_anyway', + ).tr(), + ), + ], + ); + } + + buildPermissionDenied() { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.warning_outlined, + color: Colors.red, + size: 48, + ), + const SizedBox(height: 8), + Text( + 'permission_onboarding_permission_denied', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ).tr(), + const SizedBox(height: 18), + ElevatedButton( + onPressed: () => openAppSettings(), + child: const Text( + 'permission_onboarding_go_to_settings', + ).tr(), + ), + ], + ); + } + + final Widget child; + switch (permission) { + case PermissionStatus.limited: + child = buildPermissionLimited(); + break; + case PermissionStatus.denied: + child = buildRequestPermission(); + break; + case PermissionStatus.granted: + child = buildPermissionGranted(); + break; + case PermissionStatus.restricted: + case PermissionStatus.permanentlyDenied: + child = buildPermissionDenied(); + break; + } + + return Scaffold( + body: SafeArea( + child: Center( + child: SizedBox( + width: 380, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const ImmichLogo( + heroTag: 'logo', + ), + const ImmichTitleText(), + AnimatedSwitcher( + duration: const Duration(milliseconds: 500), + child: Padding( + padding: const EdgeInsets.all(18.0), + child: child, + ), + ), + TextButton( + child: const Text('permission_onboarding_log_out').tr(), + onPressed: () { + ref.read(authenticationProvider.notifier).logout(); + AutoRouter.of(context).replace( + const LoginRoute(), + ); + }, + ), + ], + + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/modules/settings/providers/permission.provider.dart b/mobile/lib/modules/settings/providers/notification_permission.provider.dart similarity index 100% rename from mobile/lib/modules/settings/providers/permission.provider.dart rename to mobile/lib/modules/settings/providers/notification_permission.provider.dart diff --git a/mobile/lib/modules/settings/services/permission.service.dart b/mobile/lib/modules/settings/services/permission.service.dart deleted file mode 100644 index e2e8309af1..0000000000 --- a/mobile/lib/modules/settings/services/permission.service.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:permission_handler/permission_handler.dart'; - -/// This class is for requesting permissions in the app -class PermissionService { - /// Requests the notification permission - /// Note: In Android, this is always granted - Future requestNotificationPermission() { - return Permission.notification.request(); - } - - /// Whether the user has the permission or not - /// Note: In Android, this is always true - Future hasNotificationPermission() { - return Permission.notification.isGranted; - } - - /// Either the permission was granted already or else ask for the permission - Future hasOrAskForNotificationPermission() { - return requestNotificationPermission().then((p) => p.isGranted); - } -} diff --git a/mobile/lib/modules/settings/ui/notification_setting/notification_setting.dart b/mobile/lib/modules/settings/ui/notification_setting/notification_setting.dart index 1028d4e6a0..e7f21dde22 100644 --- a/mobile/lib/modules/settings/ui/notification_setting/notification_setting.dart +++ b/mobile/lib/modules/settings/ui/notification_setting/notification_setting.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; -import 'package:immich_mobile/modules/settings/providers/permission.provider.dart'; +import 'package:immich_mobile/modules/settings/providers/notification_permission.provider.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/ui/settings_switch_list_tile.dart'; import 'package:permission_handler/permission_handler.dart'; diff --git a/mobile/lib/routing/gallery_permission_guard.dart b/mobile/lib/routing/gallery_permission_guard.dart new file mode 100644 index 0000000000..65455584bc --- /dev/null +++ b/mobile/lib/routing/gallery_permission_guard.dart @@ -0,0 +1,19 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class GalleryPermissionGuard extends AutoRouteGuard { + final GalleryPermissionNotifier _permission; + + GalleryPermissionGuard(this._permission); + + @override + void onNavigation(NavigationResolver resolver, StackRouter router) async { + final p = _permission.hasPermission; + if (p) { + resolver.next(true); + } else { + router.replaceAll([const PermissionOnboardingRoute()]); + } + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 61484abaca..c76f99304e 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -19,11 +19,14 @@ import 'package:immich_mobile/modules/favorite/views/favorites_page.dart'; import 'package:immich_mobile/modules/home/views/home_page.dart'; import 'package:immich_mobile/modules/login/views/change_password_page.dart'; import 'package:immich_mobile/modules/login/views/login_page.dart'; +import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart'; +import 'package:immich_mobile/modules/onboarding/views/permission_onboarding_page.dart'; import 'package:immich_mobile/modules/search/views/search_page.dart'; import 'package:immich_mobile/modules/search/views/search_result_page.dart'; import 'package:immich_mobile/modules/settings/views/settings_page.dart'; import 'package:immich_mobile/routing/auth_guard.dart'; import 'package:immich_mobile/routing/duplicate_guard.dart'; +import 'package:immich_mobile/routing/gallery_permission_guard.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart'; @@ -39,6 +42,7 @@ part 'router.gr.dart'; replaceInRouteName: 'Page,Route', routes: [ AutoRoute(page: SplashScreenPage, initial: true), + AutoRoute(page: PermissionOnboardingPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: LoginPage, guards: [ DuplicateGuard, @@ -47,7 +51,7 @@ part 'router.gr.dart'; AutoRoute(page: ChangePasswordPage), CustomRoute( page: TabControllerPage, - guards: [AuthGuard, DuplicateGuard], + guards: [AuthGuard, DuplicateGuard, GalleryPermissionGuard], children: [ AutoRoute(page: HomePage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: SearchPage, guards: [AuthGuard, DuplicateGuard]), @@ -56,7 +60,7 @@ part 'router.gr.dart'; ], transitionsBuilder: TransitionsBuilders.fadeIn, ), - AutoRoute(page: GalleryViewerPage, guards: [AuthGuard, DuplicateGuard]), + AutoRoute(page: GalleryViewerPage, guards: [AuthGuard, DuplicateGuard, GalleryPermissionGuard]), AutoRoute(page: VideoViewerPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: BackupControllerPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: SearchResultPage, guards: [AuthGuard, DuplicateGuard]), @@ -101,12 +105,15 @@ class AppRouter extends _$AppRouter { // ignore: unused_field final ApiService _apiService; - AppRouter(this._apiService) - : super( + AppRouter( + this._apiService, + GalleryPermissionNotifier galleryPermissionNotifier, + ) : super( authGuard: AuthGuard(_apiService), duplicateGuard: DuplicateGuard(), + galleryPermissionGuard: GalleryPermissionGuard(galleryPermissionNotifier), ); } final appRouterProvider = - Provider((ref) => AppRouter(ref.watch(apiServiceProvider))); + Provider((ref) => AppRouter(ref.watch(apiServiceProvider), ref.watch(galleryPermissionNotifier.notifier))); diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 7fc34a52d0..158a302edd 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -15,13 +15,16 @@ part of 'router.dart'; class _$AppRouter extends RootStackRouter { _$AppRouter({ GlobalKey? navigatorKey, - required this.duplicateGuard, required this.authGuard, + required this.duplicateGuard, + required this.galleryPermissionGuard, }) : super(navigatorKey); + final AuthGuard authGuard; + final DuplicateGuard duplicateGuard; - final AuthGuard authGuard; + final GalleryPermissionGuard galleryPermissionGuard; @override final Map pagesMap = { @@ -31,6 +34,12 @@ class _$AppRouter extends RootStackRouter { child: const SplashScreenPage(), ); }, + PermissionOnboardingRoute.name: (routeData) { + return MaterialPageX( + routeData: routeData, + child: const PermissionOnboardingPage(), + ); + }, LoginRoute.name: (routeData) { return MaterialPageX( routeData: routeData, @@ -225,6 +234,14 @@ class _$AppRouter extends RootStackRouter { SplashScreenRoute.name, path: '/', ), + RouteConfig( + PermissionOnboardingRoute.name, + path: '/permission-onboarding-page', + guards: [ + authGuard, + duplicateGuard, + ], + ), RouteConfig( LoginRoute.name, path: '/login-page', @@ -240,6 +257,7 @@ class _$AppRouter extends RootStackRouter { guards: [ authGuard, duplicateGuard, + galleryPermissionGuard, ], children: [ RouteConfig( @@ -286,6 +304,7 @@ class _$AppRouter extends RootStackRouter { guards: [ authGuard, duplicateGuard, + galleryPermissionGuard, ], ), RouteConfig( @@ -411,6 +430,18 @@ class SplashScreenRoute extends PageRouteInfo { static const String name = 'SplashScreenRoute'; } +/// generated route for +/// [PermissionOnboardingPage] +class PermissionOnboardingRoute extends PageRouteInfo { + const PermissionOnboardingRoute() + : super( + PermissionOnboardingRoute.name, + path: '/permission-onboarding-page', + ); + + static const String name = 'PermissionOnboardingRoute'; +} + /// generated route for /// [LoginPage] class LoginRoute extends PageRouteInfo { diff --git a/mobile/lib/shared/ui/immich_logo.dart b/mobile/lib/shared/ui/immich_logo.dart new file mode 100644 index 0000000000..6c560d14cf --- /dev/null +++ b/mobile/lib/shared/ui/immich_logo.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +class ImmichLogo extends StatelessWidget { + final double size; + final dynamic heroTag; + + const ImmichLogo({ + super.key, + this.size = 100, + this.heroTag, + }); + + @override + Widget build(BuildContext context) { + return Hero( + tag: heroTag, + child: Image( + image: const AssetImage('assets/immich-logo-no-outline.png'), + width: size, + filterQuality: FilterQuality.high, + ), + ); + } + +} diff --git a/mobile/lib/shared/ui/immich_title_text.dart b/mobile/lib/shared/ui/immich_title_text.dart new file mode 100644 index 0000000000..7f633a0e6f --- /dev/null +++ b/mobile/lib/shared/ui/immich_title_text.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +class ImmichTitleText extends StatelessWidget { + final double fontSize; + final Color? color; + + const ImmichTitleText({ + super.key, + this.fontSize = 48, + this.color, + }); + + @override + Widget build(BuildContext context) { + return Text( + 'IMMICH', + style: TextStyle( + fontFamily: 'SnowburstOne', + fontWeight: FontWeight.bold, + fontSize: fontSize, + color: color ?? Theme.of(context).primaryColor, + ), + ); + } + +} diff --git a/mobile/lib/shared/views/splash_screen.dart b/mobile/lib/shared/views/splash_screen.dart index abef3b3395..a26e9d0a2c 100644 --- a/mobile/lib/shared/views/splash_screen.dart +++ b/mobile/lib/shared/views/splash_screen.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; +import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart'; @@ -32,8 +33,13 @@ class SplashScreenPage extends HookConsumerWidget { serverUrl: loginInfo.serverUrl, ); if (isSuccess) { - // Resume backup (if enable) then navigate - ref.watch(backupProvider.notifier).resumeBackup(); + final hasPermission = await ref + .read(galleryPermissionNotifier.notifier) + .hasPermission; + if (hasPermission) { + // Resume backup (if enable) then navigate + ref.watch(backupProvider.notifier).resumeBackup(); + } AutoRouter.of(context).replace(const TabControllerRoute()); } else { AutoRouter.of(context).replace(const LoginRoute()); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 9f895fadd9..d2e3427e68 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -281,6 +281,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: "1d6e5a61674ba3a68fb048a7c7b4ff4bebfed8d7379dbe8f2b718231be9a7c95" + url: "https://pub.dev" + source: hosted + version: "8.1.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: d3b01d5868b50ae571cd1dc6e502fc94d956b665756180f7b16ead09e836fd64 + url: "https://pub.dev" + source: hosted + version: "7.0.0" easy_image_viewer: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 7d28923d46..5a5cac7668 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -46,6 +46,7 @@ dependencies: isar: *isar_version isar_flutter_libs: *isar_version # contains Isar Core permission_handler: ^10.2.0 + device_info_plus: ^8.1.0 openapi: path: openapi From 9d570392745a50b0ccfa2a7c234c057eb9ac2e37 Mon Sep 17 00:00:00 2001 From: martyfuhry Date: Tue, 28 Feb 2023 22:10:53 -0500 Subject: [PATCH 2/2] feat(mobile): Responsive list and grid view of backup album selection and fixes search filter (#1895) * rebuilding gridview * adds listview, gridview and responsive display to backup album selection * aligned selection info title and chips to the left * fixed search * style: album tile --------- Co-authored-by: Alex Tran --- .../modules/backup/ui/album_info_card.dart | 114 +++--- .../backup/ui/album_info_list_tile.dart | 176 +++++++++ .../views/backup_album_selection_page.dart | 373 ++++++++++-------- 3 files changed, 442 insertions(+), 221 deletions(-) create mode 100644 mobile/lib/modules/backup/ui/album_info_list_tile.dart 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(); + } + }, ), ], ),