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