1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

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 <marty@fuhry.farm>
This commit is contained in:
martyfuhry 2023-02-28 11:22:18 -05:00 committed by GitHub
parent df1710f4cc
commit 12217bde8a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 510 additions and 55 deletions

View File

@ -229,5 +229,14 @@
"version_announcement_overlay_text_1": "Hi friend, there is a new release of", "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_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_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" "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"
} }

View File

@ -72,7 +72,7 @@ post_install do |installer|
# 'PERMISSION_SPEECH_RECOGNIZER=1', # 'PERMISSION_SPEECH_RECOGNIZER=1',
## dart: PermissionGroup.photos ## dart: PermissionGroup.photos
# 'PERMISSION_PHOTOS=1', 'PERMISSION_PHOTOS=1',
## dart: [PermissionGroup.location, PermissionGroup.locationAlways, PermissionGroup.locationWhenInUse] ## dart: [PermissionGroup.location, PermissionGroup.locationAlways, PermissionGroup.locationWhenInUse]
# 'PERMISSION_LOCATION=1', # 'PERMISSION_LOCATION=1',

View File

@ -1,4 +1,6 @@
PODS: PODS:
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_native_splash (0.0.1): - flutter_native_splash (0.0.1):
- Flutter - Flutter
@ -49,6 +51,7 @@ PODS:
- Flutter - Flutter
DEPENDENCIES: DEPENDENCIES:
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
@ -76,6 +79,8 @@ SPEC REPOS:
- Toast - Toast
EXTERNAL SOURCES: EXTERNAL SOURCES:
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
flutter_native_splash: flutter_native_splash:
@ -116,6 +121,7 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/wakelock/ios" :path: ".symlinks/plugins/wakelock/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c
@ -139,6 +145,6 @@ SPEC CHECKSUMS:
video_player_avfoundation: 6d971a232d72e6ee25368378d48a079dea01f1cf video_player_avfoundation: 6d971a232d72e6ee25368378d48a079dea01f1cf
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
PODFILE CHECKSUM: 4a7e0475ea85ab7bf89955bc4c7ea9d18b54dfd8 PODFILE CHECKSUM: 0606648e8a9ecd5a59eafa5ab3187b45a9004a28
COCOAPODS: 1.11.3 COCOAPODS: 1.11.3

View File

@ -4,6 +4,7 @@ import Flutter
import BackgroundTasks import BackgroundTasks
import path_provider_ios import path_provider_ios
import photo_manager import photo_manager
import permission_handler_apple
@UIApplicationMain @UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate {
@ -30,6 +31,10 @@ import photo_manager
if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") { if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") {
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "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) return super.application(application, didFinishLaunchingWithOptions: launchOptions)

View File

@ -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/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/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.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/router.dart';
import 'package:immich_mobile/routing/tab_navigation_observer.dart'; import 'package:immich_mobile/routing/tab_navigation_observer.dart';
import 'package:immich_mobile/shared/models/immich_logger_message.model.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:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'constants/hive_box.dart'; import 'constants/hive_box.dart';
void main() async { void main() async {
@ -129,8 +131,10 @@ class ImmichAppState extends ConsumerState<ImmichApp>
ref.watch(appStateProvider.notifier).state = AppStateEnum.resumed; ref.watch(appStateProvider.notifier).state = AppStateEnum.resumed;
var isAuthenticated = ref.watch(authenticationProvider).isAuthenticated; 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(backupProvider.notifier).resumeBackup();
ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
ref.watch(assetProvider.notifier).getAllAsset(); ref.watch(assetProvider.notifier).getAllAsset();
@ -143,6 +147,8 @@ class ImmichAppState extends ConsumerState<ImmichApp>
ref.watch(notificationPermissionProvider.notifier) ref.watch(notificationPermissionProvider.notifier)
.getNotificationPermission(); .getNotificationPermission();
ref.watch(galleryPermissionNotifier.notifier)
.getGalleryPermissionStatus();
ref.read(iOSBackgroundSettingsProvider.notifier).refresh(); ref.read(iOSBackgroundSettingsProvider.notifier).refresh();

View File

@ -560,6 +560,9 @@ class BackgroundService {
} }
Future<DateTime?> getIOSBackupLastRun(IosBackgroundTask task) async { Future<DateTime?> getIOSBackupLastRun(IosBackgroundTask task) async {
if (!Platform.isIOS) {
return null;
}
// Seconds since last run // Seconds since last run
final double? lastRun = task == IosBackgroundTask.fetch final double? lastRun = task == IosBackgroundTask.fetch
? await _foregroundChannel.invokeMethod('lastBackgroundFetchTime') ? await _foregroundChannel.invokeMethod('lastBackgroundFetchTime')
@ -572,10 +575,16 @@ class BackgroundService {
} }
Future<int> getIOSBackupNumberOfProcesses() async { Future<int> getIOSBackupNumberOfProcesses() async {
if (!Platform.isIOS) {
return 0;
}
return await _foregroundChannel.invokeMethod('numberOfBackgroundProcesses'); return await _foregroundChannel.invokeMethod('numberOfBackgroundProcesses');
} }
Future<bool> getIOSBackgroundAppRefreshEnabled() async { Future<bool> getIOSBackgroundAppRefreshEnabled() async {
if (!Platform.isIOS) {
return false;
}
return await _foregroundChannel.invokeMethod('backgroundAppRefreshEnabled'); return await _foregroundChannel.invokeMethod('backgroundAppRefreshEnabled');
} }
} }

View File

@ -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/backup/services/backup.service.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.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/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/providers/app_state.provider.dart';
import 'package:immich_mobile/shared/services/server_info.service.dart'; import 'package:immich_mobile/shared/services/server_info.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
class BackupNotifier extends StateNotifier<BackUpState> { class BackupNotifier extends StateNotifier<BackUpState> {
@ -26,6 +28,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
this._serverInfoService, this._serverInfoService,
this._authState, this._authState,
this._backgroundService, this._backgroundService,
this._galleryPermissionNotifier,
this.ref, this.ref,
) : super( ) : super(
BackUpState( BackUpState(
@ -65,6 +68,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final ServerInfoService _serverInfoService; final ServerInfoService _serverInfoService;
final AuthenticationState _authState; final AuthenticationState _authState;
final BackgroundService _backgroundService; final BackgroundService _backgroundService;
final GalleryPermissionNotifier _galleryPermissionNotifier;
final Ref ref; final Ref ref;
/// ///
@ -431,8 +435,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
await getBackupInfo(); await getBackupInfo();
var authResult = await PhotoManager.requestPermissionExtend(); final hasPermission = _galleryPermissionNotifier.hasPermission;
if (authResult.isAuth) { if (hasPermission) {
await PhotoManager.clearFileCache(); await PhotoManager.clearFileCache();
if (state.allUniqueAssets.isEmpty) { if (state.allUniqueAssets.isEmpty) {
@ -463,7 +467,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
); );
await _notifyBackgroundServiceCanRun(); await _notifyBackgroundServiceCanRun();
} else { } else {
PhotoManager.openSetting(); openAppSettings();
} }
} }
@ -704,6 +708,7 @@ final backupProvider =
ref.watch(serverInfoServiceProvider), ref.watch(serverInfoServiceProvider),
ref.watch(authenticationProvider), ref.watch(authenticationProvider),
ref.watch(backgroundServiceProvider), ref.watch(backgroundServiceProvider),
ref.watch(galleryPermissionNotifier.notifier),
ref, ref,
); );
}); });

View File

@ -7,14 +7,18 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.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/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/login/providers/oauth.provider.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/routing/router.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/asset.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/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/backup/providers/backup.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/shared/ui/immich_toast.dart';
import 'package:immich_mobile/utils/url_helper.dart'; import 'package:immich_mobile/utils/url_helper.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:permission_handler/permission_handler.dart';
class LoginForm extends HookConsumerWidget { class LoginForm extends HookConsumerWidget {
const LoginForm({Key? key}) : super(key: key); const LoginForm({Key? key}) : super(key: key);
@ -105,22 +109,12 @@ class LoginForm extends HookConsumerWidget {
onDoubleTap: () => populateTestLoginInfo(), onDoubleTap: () => populateTestLoginInfo(),
child: RotationTransition( child: RotationTransition(
turns: logoAnimationController, turns: logoAnimationController,
child: const Image( child: const ImmichLogo(
image: AssetImage('assets/immich-logo-no-outline.png'), heroTag: 'logo',
width: 100,
filterQuality: FilterQuality.high,
), ),
), ),
), ),
Text( const ImmichTitleText(),
'IMMICH',
style: TextStyle(
fontFamily: 'SnowburstOne',
fontWeight: FontWeight.bold,
fontSize: 48,
color: Theme.of(context).primaryColor,
),
),
EmailInput(controller: usernameController), EmailInput(controller: usernameController),
PasswordInput(controller: passwordController), PasswordInput(controller: passwordController),
ServerEndpointInput( ServerEndpointInput(
@ -164,7 +158,10 @@ class LoginForm extends HookConsumerWidget {
isLoading: isLoading, isLoading: isLoading,
onLoginSuccess: () { onLoginSuccess: () {
isLoading.value = false; 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( AutoRouter.of(context).replace(
const TabControllerRoute(), const TabControllerRoute(),
); );
@ -313,7 +310,13 @@ class LoginButton extends ConsumerWidget {
!ref.read(authenticationProvider).isAdmin) { !ref.read(authenticationProvider).isAdmin) {
AutoRouter.of(context).push(const ChangePasswordRoute()); AutoRouter.of(context).push(const ChangePasswordRoute());
} else { } 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()); AutoRouter.of(context).replace(const TabControllerRoute());
} }
} else { } else {

View File

@ -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<PermissionStatus> {
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<PermissionStatus> 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<PermissionStatus> 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<GalleryPermissionNotifier, PermissionStatus>
((ref) => GalleryPermissionNotifier());

View File

@ -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(),
);
},
),
],
),
),
),
),
);
}
}

View File

@ -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<PermissionStatus> requestNotificationPermission() {
return Permission.notification.request();
}
/// Whether the user has the permission or not
/// Note: In Android, this is always true
Future<bool> hasNotificationPermission() {
return Permission.notification.isGranted;
}
/// Either the permission was granted already or else ask for the permission
Future<bool> hasOrAskForNotificationPermission() {
return requestNotificationPermission().then((p) => p.isGranted);
}
}

View File

@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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/services/app_settings.service.dart';
import 'package:immich_mobile/modules/settings/ui/settings_switch_list_tile.dart'; import 'package:immich_mobile/modules/settings/ui/settings_switch_list_tile.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';

View File

@ -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()]);
}
}
}

View File

@ -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/home/views/home_page.dart';
import 'package:immich_mobile/modules/login/views/change_password_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/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_page.dart';
import 'package:immich_mobile/modules/search/views/search_result_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/modules/settings/views/settings_page.dart';
import 'package:immich_mobile/routing/auth_guard.dart'; import 'package:immich_mobile/routing/auth_guard.dart';
import 'package:immich_mobile/routing/duplicate_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/asset.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
@ -39,6 +42,7 @@ part 'router.gr.dart';
replaceInRouteName: 'Page,Route', replaceInRouteName: 'Page,Route',
routes: <AutoRoute>[ routes: <AutoRoute>[
AutoRoute(page: SplashScreenPage, initial: true), AutoRoute(page: SplashScreenPage, initial: true),
AutoRoute(page: PermissionOnboardingPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: LoginPage, AutoRoute(page: LoginPage,
guards: [ guards: [
DuplicateGuard, DuplicateGuard,
@ -47,7 +51,7 @@ part 'router.gr.dart';
AutoRoute(page: ChangePasswordPage), AutoRoute(page: ChangePasswordPage),
CustomRoute( CustomRoute(
page: TabControllerPage, page: TabControllerPage,
guards: [AuthGuard, DuplicateGuard], guards: [AuthGuard, DuplicateGuard, GalleryPermissionGuard],
children: [ children: [
AutoRoute(page: HomePage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: HomePage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: SearchPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: SearchPage, guards: [AuthGuard, DuplicateGuard]),
@ -56,7 +60,7 @@ part 'router.gr.dart';
], ],
transitionsBuilder: TransitionsBuilders.fadeIn, transitionsBuilder: TransitionsBuilders.fadeIn,
), ),
AutoRoute(page: GalleryViewerPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: GalleryViewerPage, guards: [AuthGuard, DuplicateGuard, GalleryPermissionGuard]),
AutoRoute(page: VideoViewerPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: VideoViewerPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: BackupControllerPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: BackupControllerPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: SearchResultPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: SearchResultPage, guards: [AuthGuard, DuplicateGuard]),
@ -101,12 +105,15 @@ class AppRouter extends _$AppRouter {
// ignore: unused_field // ignore: unused_field
final ApiService _apiService; final ApiService _apiService;
AppRouter(this._apiService) AppRouter(
: super( this._apiService,
GalleryPermissionNotifier galleryPermissionNotifier,
) : super(
authGuard: AuthGuard(_apiService), authGuard: AuthGuard(_apiService),
duplicateGuard: DuplicateGuard(), duplicateGuard: DuplicateGuard(),
galleryPermissionGuard: GalleryPermissionGuard(galleryPermissionNotifier),
); );
} }
final appRouterProvider = final appRouterProvider =
Provider((ref) => AppRouter(ref.watch(apiServiceProvider))); Provider((ref) => AppRouter(ref.watch(apiServiceProvider), ref.watch(galleryPermissionNotifier.notifier)));

View File

@ -15,13 +15,16 @@ part of 'router.dart';
class _$AppRouter extends RootStackRouter { class _$AppRouter extends RootStackRouter {
_$AppRouter({ _$AppRouter({
GlobalKey<NavigatorState>? navigatorKey, GlobalKey<NavigatorState>? navigatorKey,
required this.duplicateGuard,
required this.authGuard, required this.authGuard,
required this.duplicateGuard,
required this.galleryPermissionGuard,
}) : super(navigatorKey); }) : super(navigatorKey);
final AuthGuard authGuard;
final DuplicateGuard duplicateGuard; final DuplicateGuard duplicateGuard;
final AuthGuard authGuard; final GalleryPermissionGuard galleryPermissionGuard;
@override @override
final Map<String, PageFactory> pagesMap = { final Map<String, PageFactory> pagesMap = {
@ -31,6 +34,12 @@ class _$AppRouter extends RootStackRouter {
child: const SplashScreenPage(), child: const SplashScreenPage(),
); );
}, },
PermissionOnboardingRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const PermissionOnboardingPage(),
);
},
LoginRoute.name: (routeData) { LoginRoute.name: (routeData) {
return MaterialPageX<dynamic>( return MaterialPageX<dynamic>(
routeData: routeData, routeData: routeData,
@ -225,6 +234,14 @@ class _$AppRouter extends RootStackRouter {
SplashScreenRoute.name, SplashScreenRoute.name,
path: '/', path: '/',
), ),
RouteConfig(
PermissionOnboardingRoute.name,
path: '/permission-onboarding-page',
guards: [
authGuard,
duplicateGuard,
],
),
RouteConfig( RouteConfig(
LoginRoute.name, LoginRoute.name,
path: '/login-page', path: '/login-page',
@ -240,6 +257,7 @@ class _$AppRouter extends RootStackRouter {
guards: [ guards: [
authGuard, authGuard,
duplicateGuard, duplicateGuard,
galleryPermissionGuard,
], ],
children: [ children: [
RouteConfig( RouteConfig(
@ -286,6 +304,7 @@ class _$AppRouter extends RootStackRouter {
guards: [ guards: [
authGuard, authGuard,
duplicateGuard, duplicateGuard,
galleryPermissionGuard,
], ],
), ),
RouteConfig( RouteConfig(
@ -411,6 +430,18 @@ class SplashScreenRoute extends PageRouteInfo<void> {
static const String name = 'SplashScreenRoute'; static const String name = 'SplashScreenRoute';
} }
/// generated route for
/// [PermissionOnboardingPage]
class PermissionOnboardingRoute extends PageRouteInfo<void> {
const PermissionOnboardingRoute()
: super(
PermissionOnboardingRoute.name,
path: '/permission-onboarding-page',
);
static const String name = 'PermissionOnboardingRoute';
}
/// generated route for /// generated route for
/// [LoginPage] /// [LoginPage]
class LoginRoute extends PageRouteInfo<void> { class LoginRoute extends PageRouteInfo<void> {

View File

@ -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,
),
);
}
}

View File

@ -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,
),
);
}
}

View File

@ -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/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.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/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/routing/router.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
@ -32,8 +33,13 @@ class SplashScreenPage extends HookConsumerWidget {
serverUrl: loginInfo.serverUrl, serverUrl: loginInfo.serverUrl,
); );
if (isSuccess) { if (isSuccess) {
// Resume backup (if enable) then navigate final hasPermission = await ref
ref.watch(backupProvider.notifier).resumeBackup(); .read(galleryPermissionNotifier.notifier)
.hasPermission;
if (hasPermission) {
// Resume backup (if enable) then navigate
ref.watch(backupProvider.notifier).resumeBackup();
}
AutoRouter.of(context).replace(const TabControllerRoute()); AutoRouter.of(context).replace(const TabControllerRoute());
} else { } else {
AutoRouter.of(context).replace(const LoginRoute()); AutoRouter.of(context).replace(const LoginRoute());

View File

@ -281,6 +281,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" 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: easy_image_viewer:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -46,6 +46,7 @@ dependencies:
isar: *isar_version isar: *isar_version
isar_flutter_libs: *isar_version # contains Isar Core isar_flutter_libs: *isar_version # contains Isar Core
permission_handler: ^10.2.0 permission_handler: ^10.2.0
device_info_plus: ^8.1.0
openapi: openapi:
path: openapi path: openapi