diff --git a/i18n/en.json b/i18n/en.json index 1dbd50e294..17bac80a3e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1551,11 +1551,30 @@ "oldest_first": "Oldest first", "on_this_device": "On this device", "onboarding": "Onboarding", + "onboarding_backup_description": "Automatically upload new photos and videos to your server.", + "onboarding_backup_manual_hint": "To upload only some photos, open a local album from the Library tab, select the ones you want, then tap Upload.", + "onboarding_backup_title": "Back up your photos", + "onboarding_continue_anyway": "Continue anyway", + "onboarding_done_description": "Immich is ready. Your library will back up in the background.", + "onboarding_done_title": "You're all set", + "onboarding_get_started": "Get started", + "onboarding_go_to_settings": "Go to settings", "onboarding_locale_description": "Select your preferred language. You can change this later in your settings.", + "onboarding_permission_notifications_description": "Get notified about backup progress and important updates.", + "onboarding_permission_notifications_title": "Enable notifications", + "onboarding_permission_photos_denied_description": "Without photo access nothing will be backed up, and you will only see photos already on your server.", + "onboarding_permission_photos_denied_title": "Photo access denied", + "onboarding_permission_photos_description": "Immich needs access to your gallery to back up and display your photos and videos.", + "onboarding_permission_photos_granted": "Photo access granted", + "onboarding_permission_photos_limited_description": "Immich works best with full access. With limited access, only the photos you select are backed up.", + "onboarding_permission_photos_limited_title": "Limited access granted", + "onboarding_permission_photos_title": "Allow access to your photos", "onboarding_privacy_description": "The following (optional) features rely on external services, and can be disabled at any time in settings.", "onboarding_server_welcome_description": "Let's get your instance set up with some common settings.", "onboarding_theme_description": "Choose a color theme for your instance. You can change this later in your settings.", + "onboarding_theme_title": "Choose your theme", "onboarding_user_welcome_description": "Let's get you started!", + "onboarding_welcome_description": "Back up, browse, and manage the photos and videos on your server.", "onboarding_welcome_user": "Welcome, {user}", "online": "Online", "only_favorites": "Only favorites", diff --git a/mobile/lib/domain/models/config/app_config.dart b/mobile/lib/domain/models/config/app_config.dart index e4e11baf9d..d025c408f9 100644 --- a/mobile/lib/domain/models/config/app_config.dart +++ b/mobile/lib/domain/models/config/app_config.dart @@ -23,6 +23,7 @@ const defaultConfig = AppConfig(); class AppConfig { final LogLevel logLevel; + final bool onboardingComplete; final ThemeConfig theme; final CleanupConfig cleanup; final MapConfig map; @@ -38,6 +39,7 @@ class AppConfig { const AppConfig({ this.logLevel = .info, + this.onboardingComplete = true, this.theme = const .new(), this.cleanup = const .new(), this.map = const .new(), @@ -54,6 +56,7 @@ class AppConfig { AppConfig copyWith({ LogLevel? logLevel, + bool? onboardingComplete, ThemeConfig? theme, CleanupConfig? cleanup, MapConfig? map, @@ -68,6 +71,7 @@ class AppConfig { FeatureMessageConfig? featureMessage, }) => .new( logLevel: logLevel ?? this.logLevel, + onboardingComplete: onboardingComplete ?? this.onboardingComplete, theme: theme ?? this.theme, cleanup: cleanup ?? this.cleanup, map: map ?? this.map, @@ -87,6 +91,7 @@ class AppConfig { identical(this, other) || (other is AppConfig && other.logLevel == logLevel && + other.onboardingComplete == onboardingComplete && other.theme == theme && other.cleanup == cleanup && other.map == map && @@ -103,6 +108,7 @@ class AppConfig { @override int get hashCode => Object.hash( logLevel, + onboardingComplete, theme, cleanup, map, @@ -119,7 +125,7 @@ class AppConfig { @override String toString() => - 'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share, featureMessage: $featureMessage)'; + 'AppConfig(logLevel: $logLevel, onboardingComplete: $onboardingComplete, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share, featureMessage: $featureMessage)'; T read(SettingsKey key) => (switch (key) { @@ -166,6 +172,7 @@ class AppConfig { .shareFileType => share.fileType, .slideshowRepeat => slideshow.repeat, .slideshowDuration => slideshow.duration, + .onboardingComplete => onboardingComplete, .slideshowLook => slideshow.look, .slideshowDirection => slideshow.direction, .featureMessageSeenRelease => featureMessage.seenRelease, @@ -178,6 +185,7 @@ class AppConfig { AppConfig write(SettingsKey key, U value) { return switch (key) { .logLevel => copyWith(logLevel: value as LogLevel), + .onboardingComplete => copyWith(onboardingComplete: value as bool), .themePrimaryColor => copyWith(theme: theme.copyWith(primaryColor: value as ImmichColorPreset)), .themeMode => copyWith(theme: theme.copyWith(mode: value as ThemeMode)), .themeDynamic => copyWith(theme: theme.copyWith(dynamicTheme: value as bool)), diff --git a/mobile/lib/domain/models/settings_key.dart b/mobile/lib/domain/models/settings_key.dart index 85e58ffcf1..ba3f575113 100644 --- a/mobile/lib/domain/models/settings_key.dart +++ b/mobile/lib/domain/models/settings_key.dart @@ -78,7 +78,10 @@ enum SettingsKey { slideshowDirection(codec: EnumCodec(SlideshowDirection.values)), // Feature message - featureMessageSeenRelease(codec: SemVerCodec()); + featureMessageSeenRelease(codec: SemVerCodec()), + + // Onboarding + onboardingComplete(); final ValueCodec? _codecOverride; diff --git a/mobile/lib/domain/services/feature_message.service.dart b/mobile/lib/domain/services/feature_message.service.dart index ff0fdda8f3..660eb75d33 100644 --- a/mobile/lib/domain/services/feature_message.service.dart +++ b/mobile/lib/domain/services/feature_message.service.dart @@ -1,5 +1,4 @@ import 'package:immich_mobile/domain/models/feature_message.model.dart'; -import 'package:immich_mobile/domain/models/settings_key.dart'; import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; class FeatureMessageService { @@ -12,5 +11,5 @@ class FeatureMessageService { return featureMessageHighlights.isNotEmpty && featureMessageRelease > seen; } - Future markSeen() => _settingsRepository.write(SettingsKey.featureMessageSeenRelease, featureMessageRelease); + Future markSeen() => _settingsRepository.write(.featureMessageSeenRelease, featureMessageRelease); } diff --git a/mobile/lib/domain/services/onboarding.service.dart b/mobile/lib/domain/services/onboarding.service.dart new file mode 100644 index 0000000000..4f94dd52f8 --- /dev/null +++ b/mobile/lib/domain/services/onboarding.service.dart @@ -0,0 +1,11 @@ +import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; + +class OnboardingService { + final SettingsRepository _settingsRepository; + + const OnboardingService(this._settingsRepository); + + bool get isComplete => _settingsRepository.appConfig.onboardingComplete; + + Future markComplete([bool complete = true]) => _settingsRepository.write(.onboardingComplete, complete); +} diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index 0d423875cb..9cd6b1ced6 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -16,6 +16,7 @@ import 'package:immich_mobile/infrastructure/repositories/settings.repository.da import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; +import 'package:immich_mobile/providers/feature_message.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; @@ -354,7 +355,11 @@ class SplashScreenPageState extends ConsumerState { } else { log.severe('Missing crucial offline login info - Logging out completely'); unawaited(ref.read(authProvider.notifier).logout()); - unawaited(context.router.replaceAll([const LoginRoute()])); + unawaited( + context.router.replaceAll([ + ref.read(onboardingServiceProvider).isComplete ? const LoginRoute() : const WelcomeRoute(), + ]), + ); return; } diff --git a/mobile/lib/pages/onboarding/backup_step.widget.dart b/mobile/lib/pages/onboarding/backup_step.widget.dart new file mode 100644 index 0000000000..b38e51a72b --- /dev/null +++ b/mobile/lib/pages/onboarding/backup_step.widget.dart @@ -0,0 +1,68 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/pages/onboarding/step_layout.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; +import 'package:immich_mobile/providers/sync_status.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_ui/immich_ui.dart'; + +class OnboardingBackupStep extends ConsumerWidget { + final VoidCallback onNext; + + const OnboardingBackupStep({super.key, required this.onNext}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final enabled = ref.watch(appConfigProvider.select((config) => config.backup.enabled)); + final preparing = ref.watch(syncStatusProvider.select((status) => status.isLocalSyncing)); + + return OnboardingStepLayout( + icon: Icons.backup_outlined, + title: context.t.onboarding_backup_title, + description: context.t.onboarding_backup_description, + body: preparing + ? const Padding( + padding: .symmetric(vertical: ImmichSpacing.xl), + child: Center( + child: SizedBox.square(dimension: ImmichIconSize.lg, child: CircularProgressIndicator()), + ), + ) + : Column( + crossAxisAlignment: .stretch, + spacing: ImmichSpacing.sm, + children: [ + SwitchListTile( + contentPadding: .zero, + title: Text(context.t.enable_backup), + value: enabled, + onChanged: (value) => ref.read(settingsProvider).write(.backupEnabled, value), + ), + if (enabled) ...[ + .new( + labelText: context.t.select_albums, + icon: Icons.photo_album_outlined, + variant: .ghost, + onPressed: () => context.pushRoute(const DriftBackupAlbumSelectionRoute()), + ), + .new( + labelText: context.t.backup_options, + icon: Icons.settings_outlined, + variant: .ghost, + onPressed: () => context.pushRoute(const DriftBackupOptionsRoute()), + ), + ], + Text( + context.t.onboarding_backup_manual_hint, + textAlign: .center, + style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + ], + ), + actions: [ImmichTextButton(labelText: context.t.continue$, onPressed: onNext)], + ); + } +} diff --git a/mobile/lib/pages/onboarding/done_step.widget.dart b/mobile/lib/pages/onboarding/done_step.widget.dart new file mode 100644 index 0000000000..5a286d721d --- /dev/null +++ b/mobile/lib/pages/onboarding/done_step.widget.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/pages/onboarding/step_layout.widget.dart'; +import 'package:immich_ui/immich_ui.dart'; + +class OnboardingDoneStep extends StatelessWidget { + final VoidCallback onFinish; + + const OnboardingDoneStep({super.key, required this.onFinish}); + + @override + Widget build(BuildContext context) { + return OnboardingStepLayout( + icon: Icons.celebration_outlined, + title: context.t.onboarding_done_title, + description: context.t.onboarding_done_description, + actions: [ImmichTextButton(labelText: context.t.done, onPressed: onFinish)], + ); + } +} diff --git a/mobile/lib/pages/onboarding/notification_step.widget.dart b/mobile/lib/pages/onboarding/notification_step.widget.dart new file mode 100644 index 0000000000..894dc6d821 --- /dev/null +++ b/mobile/lib/pages/onboarding/notification_step.widget.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/pages/onboarding/step_layout.widget.dart'; +import 'package:immich_mobile/providers/permission.provider.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:permission_handler/permission_handler.dart'; + +class OnboardingNotificationStep extends ConsumerWidget { + final VoidCallback onNext; + + const OnboardingNotificationStep({super.key, required this.onNext}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final status = ref.watch(notificationPermissionProvider); + + Future request() async { + await ref.read(notificationPermissionProvider.notifier).requestNotificationPermission(); + if (context.mounted) { + onNext(); + } + } + + return switch (status) { + .granted || .provisional => OnboardingStepLayout( + icon: Icons.notifications_active_outlined, + title: context.t.notification_enabled_list_tile_title, + description: context.t.onboarding_permission_notifications_description, + actions: [ImmichTextButton(labelText: context.t.continue$, onPressed: onNext)], + ), + .denied || .limited => OnboardingStepLayout( + icon: Icons.notifications_outlined, + title: context.t.onboarding_permission_notifications_title, + description: context.t.onboarding_permission_notifications_description, + actions: [ + .new(labelText: context.t.grant_permission, onPressed: request), + .new(labelText: context.t.skip, onPressed: onNext, variant: .ghost), + ], + ), + .restricted || .permanentlyDenied => OnboardingStepLayout( + icon: Icons.notifications_off_outlined, + title: context.t.onboarding_permission_notifications_title, + description: context.t.notification_backup_reliability, + actions: [ + .new(labelText: context.t.onboarding_go_to_settings, onPressed: openAppSettings), + .new(labelText: context.t.skip, onPressed: onNext, variant: .ghost), + ], + ), + }; + } +} diff --git a/mobile/lib/pages/onboarding/onboarding.page.dart b/mobile/lib/pages/onboarding/onboarding.page.dart new file mode 100644 index 0000000000..754ad7f61d --- /dev/null +++ b/mobile/lib/pages/onboarding/onboarding.page.dart @@ -0,0 +1,184 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/pages/onboarding/backup_step.widget.dart'; +import 'package:immich_mobile/pages/onboarding/done_step.widget.dart'; +import 'package:immich_mobile/pages/onboarding/notification_step.widget.dart'; +import 'package:immich_mobile/pages/onboarding/photo_step.widget.dart'; +import 'package:immich_mobile/pages/onboarding/step_indicator.widget.dart'; +import 'package:immich_mobile/pages/onboarding/theme_step.widget.dart'; +import 'package:immich_mobile/providers/background_sync.provider.dart'; +import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; +import 'package:immich_mobile/providers/feature_message.provider.dart'; +import 'package:immich_mobile/providers/gallery_permission.provider.dart'; +import 'package:immich_mobile/providers/permission.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:logging/logging.dart'; +import 'package:permission_handler/permission_handler.dart'; + +enum _Step { + photo, + backup(needsGallery: true), + notification(needsGallery: true), + theme, + done; + + const _Step({this.needsGallery = false}); + + final bool needsGallery; +} + +@RoutePage() +class OnboardingPage extends ConsumerStatefulWidget { + const OnboardingPage({super.key}); + + @override + ConsumerState createState() => _OnboardingPageState(); +} + +class _OnboardingPageState extends ConsumerState { + final _log = Logger('OnboardingPage'); + final _controller = PageController(); + int _page = 0; + Future? _prepareAlbumsTask; + late final AppLifecycleListener _lifecycleListener; + + @override + void initState() { + super.initState(); + _refreshPermissions(); + _lifecycleListener = AppLifecycleListener(onResume: _refreshPermissions); + } + + @override + void dispose() { + _lifecycleListener.dispose(); + _controller.dispose(); + super.dispose(); + } + + void _refreshPermissions() { + unawaited(ref.read(galleryPermissionNotifier.notifier).getGalleryPermissionStatus()); + unawaited(ref.read(notificationPermissionProvider.notifier).getNotificationPermission()); + } + + bool get _hasGalleryAccess { + final status = ref.read(galleryPermissionNotifier); + return status.isGranted || status.isLimited; + } + + bool _applies(_Step step) => !step.needsGallery || _hasGalleryAccess; + + void _next() => _move(1); + + void _previous() => _move(-1); + + void _move(int direction) { + for (var i = _page + direction; i >= 0 && i < _Step.values.length; i += direction) { + final step = _Step.values[i]; + if (_applies(step)) { + _goTo(step); + return; + } + } + } + + void _goTo(_Step step) { + if ((step.index - _page).abs() > 1) { + _controller.jumpToPage(step.index); + return; + } + + _controller.animateToPage(step.index, duration: ImmichDuration.moderate, curve: Curves.easeInOut); + } + + Future _finish() async { + await ref.read(onboardingServiceProvider).markComplete(); + if (mounted) { + await context.router.replaceAll([const TabShellRoute()]); + } + } + + void _leavePhotoStep() { + if (_hasGalleryAccess) { + _prepareAlbumsTask ??= _prepareLocalAlbums(); + } + _next(); + } + + Future _prepareLocalAlbums() async { + try { + final sync = ref.read(backgroundSyncProvider); + await sync.syncLocal(full: true); + await ref.read(backupAlbumProvider.notifier).getAll(); + if (!mounted) { + return; + } + + await _preselectCameraRoll(); + } catch (error, stack) { + _log.severe('Failed to prepare local albums for backup', error, stack); + } + } + + Future _preselectCameraRoll() async { + final albums = ref.read(backupAlbumProvider); + if (albums.any((album) => album.backupSelection != .none)) { + return; + } + + final cameraRoll = albums.firstWhereOrNull(_isCameraRoll); + if (cameraRoll == null) { + _log.warning('No camera roll album found, leaving backup selection empty'); + return; + } + + await ref.read(backupAlbumProvider.notifier).selectAlbum(cameraRoll); + } + + bool _isCameraRoll(LocalAlbum album) => + CurrentPlatform.isIOS ? (album.name == 'Recents' || album.name == 'Recent') : album.name == 'Camera'; + + Widget _buildStep(_Step step) => switch (step) { + .photo => OnboardingPhotoStep(onNext: _leavePhotoStep), + .backup => OnboardingBackupStep(onNext: _next), + .notification => OnboardingNotificationStep(onNext: _next), + .theme => OnboardingThemeStep(onNext: _next), + .done => OnboardingDoneStep(onFinish: _finish), + }; + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + child: Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + leading: _page == _Step.photo.index ? null : BackButton(onPressed: _previous), + centerTitle: true, + title: OnboardingStepIndicator(step: _page, stepCount: _Step.values.length), + actions: [ + if (_page != _Step.done.index) + ImmichTextButton(labelText: context.t.skip, onPressed: _finish, variant: .ghost, expanded: false), + ], + ), + body: SafeArea( + child: PageView.builder( + controller: _controller, + physics: const NeverScrollableScrollPhysics(), + itemCount: _Step.values.length, + onPageChanged: (index) => setState(() => _page = index), + itemBuilder: (_, index) => _buildStep(_Step.values[index]), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/pages/onboarding/photo_step.widget.dart b/mobile/lib/pages/onboarding/photo_step.widget.dart new file mode 100644 index 0000000000..1746a135d2 --- /dev/null +++ b/mobile/lib/pages/onboarding/photo_step.widget.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/pages/onboarding/step_layout.widget.dart'; +import 'package:immich_mobile/providers/gallery_permission.provider.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:permission_handler/permission_handler.dart'; + +class OnboardingPhotoStep extends ConsumerWidget { + final VoidCallback onNext; + + const OnboardingPhotoStep({super.key, required this.onNext}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final permission = ref.watch(galleryPermissionNotifier); + + return switch (permission) { + .granted || .provisional => OnboardingStepLayout( + icon: Icons.check_circle_outline, + title: context.t.onboarding_permission_photos_granted, + actions: [ImmichTextButton(labelText: context.t.continue$, onPressed: onNext)], + ), + .limited => OnboardingStepLayout( + icon: Icons.warning_amber_rounded, + title: context.t.onboarding_permission_photos_limited_title, + description: context.t.onboarding_permission_photos_limited_description, + actions: [ + .new(labelText: context.t.onboarding_go_to_settings, onPressed: openAppSettings), + .new(labelText: context.t.continue$, onPressed: onNext, variant: .ghost), + ], + ), + .denied || .restricted || .permanentlyDenied => OnboardingStepLayout( + icon: Icons.block, + title: context.t.onboarding_permission_photos_denied_title, + description: context.t.onboarding_permission_photos_denied_description, + actions: [ + .new(labelText: context.t.onboarding_go_to_settings, onPressed: openAppSettings), + .new(labelText: context.t.onboarding_continue_anyway, onPressed: onNext, variant: .ghost), + ], + ), + }; + } +} diff --git a/mobile/lib/pages/onboarding/step_indicator.widget.dart b/mobile/lib/pages/onboarding/step_indicator.widget.dart new file mode 100644 index 0000000000..1d99259bbf --- /dev/null +++ b/mobile/lib/pages/onboarding/step_indicator.widget.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_ui/immich_ui.dart'; + +class OnboardingStepIndicator extends StatelessWidget { + final int step; + final int stepCount; + + const OnboardingStepIndicator({super.key, required this.step, required this.stepCount}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: .min, + spacing: ImmichSpacing.xs, + children: List.generate( + stepCount, + (index) => AnimatedContainer( + duration: ImmichDuration.normal, + width: index == step ? ImmichSpacing.xl : ImmichSpacing.sm, + height: ImmichSpacing.sm, + decoration: BoxDecoration( + color: index <= step ? context.primaryColor : context.colorScheme.surfaceContainerHighest, + borderRadius: const .all(.circular(ImmichRadius.xs)), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/pages/onboarding/step_layout.widget.dart b/mobile/lib/pages/onboarding/step_layout.widget.dart new file mode 100644 index 0000000000..c94e78a5f0 --- /dev/null +++ b/mobile/lib/pages/onboarding/step_layout.widget.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_ui/immich_ui.dart'; + +class OnboardingStepLayout extends StatelessWidget { + final IconData icon; + final String title; + final String? description; + final Widget? body; + final List actions; + + const OnboardingStepLayout({ + super.key, + required this.icon, + required this.title, + required this.actions, + this.description, + this.body, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + constraints: const .new(maxWidth: 380), + padding: const .fromLTRB(ImmichSpacing.xl, 0, ImmichSpacing.xl, ImmichSpacing.xl), + child: Column( + crossAxisAlignment: .stretch, + spacing: ImmichSpacing.xl, + children: [ + const Spacer(flex: 2), + Icon(icon, size: ImmichIconSize.xxl, color: context.primaryColor), + Column( + crossAxisAlignment: .stretch, + spacing: ImmichSpacing.md, + children: [ + Text(title, textAlign: .center, style: context.textTheme.headlineSmall), + if (description != null) + Text( + description!, + textAlign: .center, + style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + ], + ), + if (body != null) body!, + const Spacer(flex: 3), + Column(crossAxisAlignment: .stretch, spacing: ImmichSpacing.sm, children: actions), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/pages/onboarding/theme_step.widget.dart b/mobile/lib/pages/onboarding/theme_step.widget.dart new file mode 100644 index 0000000000..2a7f5b8540 --- /dev/null +++ b/mobile/lib/pages/onboarding/theme_step.widget.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/pages/onboarding/step_layout.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; +import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart'; +import 'package:immich_ui/immich_ui.dart'; + +class OnboardingThemeStep extends ConsumerWidget { + final VoidCallback onNext; + + const OnboardingThemeStep({super.key, required this.onNext}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mode = ref.watch(appConfigProvider.select((config) => config.theme.mode)); + + void updateTheme(ThemeMode theme) => ref.read(settingsProvider).write(.themeMode, theme); + + return OnboardingStepLayout( + icon: Icons.palette_outlined, + title: context.t.onboarding_theme_title, + body: SettingsRadioListTile( + groupBy: mode, + onRadioChanged: (theme) => theme == null ? null : updateTheme(theme), + groups: >[ + .new(title: context.t.light, value: .light), + .new(title: context.t.dark, value: .dark), + .new(title: context.t.theme_setting_system_theme_switch, value: .system), + ], + ), + actions: [ImmichTextButton(labelText: context.t.continue$, onPressed: onNext)], + ); + } +} diff --git a/mobile/lib/pages/onboarding/welcome.page.dart b/mobile/lib/pages/onboarding/welcome.page.dart new file mode 100644 index 0000000000..ab1ce6bc01 --- /dev/null +++ b/mobile/lib/pages/onboarding/welcome.page.dart @@ -0,0 +1,50 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/immich_logo.dart'; +import 'package:immich_mobile/widgets/common/immich_title_text.dart'; +import 'package:immich_ui/immich_ui.dart'; + +@RoutePage() +class WelcomePage extends StatelessWidget { + const WelcomePage({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + body: SafeArea( + child: Center( + child: Container( + constraints: const .new(maxWidth: 380), + padding: const .fromLTRB(ImmichSpacing.xl, 0, ImmichSpacing.xl, ImmichSpacing.xl), + child: Column( + crossAxisAlignment: .stretch, + spacing: ImmichSpacing.xl, + children: [ + const Spacer(flex: 2), + const Column( + spacing: ImmichSpacing.lg, + children: [ + ImmichLogo(size: 100, heroTag: 'logo'), + ImmichTitleText(), + ], + ), + Text( + context.t.onboarding_welcome_description, + textAlign: .center, + style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + const Spacer(flex: 3), + ImmichTextButton( + labelText: context.t.onboarding_get_started, + onPressed: () => context.pushRoute(const LoginRoute()), + ), + ], + ), + ), + ), + ), + ); +} diff --git a/mobile/lib/providers/feature_message.provider.dart b/mobile/lib/providers/feature_message.provider.dart index 459464524d..608b6a219f 100644 --- a/mobile/lib/providers/feature_message.provider.dart +++ b/mobile/lib/providers/feature_message.provider.dart @@ -1,7 +1,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/feature_message.service.dart'; +import 'package:immich_mobile/domain/services/onboarding.service.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; final featureMessageServiceProvider = Provider( - (ref) => FeatureMessageService(ref.read(settingsProvider)), + (ref) => FeatureMessageService(ref.watch(settingsProvider)), ); + +final onboardingServiceProvider = Provider((ref) => OnboardingService(ref.watch(settingsProvider))); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 34d29be945..34d9e163e8 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -32,6 +32,8 @@ import 'package:immich_mobile/pages/library/shared_link/shared_link.page.dart'; import 'package:immich_mobile/pages/library/shared_link/shared_link_edit.page.dart'; import 'package:immich_mobile/pages/login/change_password.page.dart'; import 'package:immich_mobile/pages/login/login.page.dart'; +import 'package:immich_mobile/pages/onboarding/onboarding.page.dart'; +import 'package:immich_mobile/pages/onboarding/welcome.page.dart'; import 'package:immich_mobile/pages/search/map/map_location_picker.page.dart'; import 'package:immich_mobile/pages/settings/sync_status.page.dart'; import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; @@ -117,7 +119,9 @@ class AppRouter extends RootStackRouter { @override late final List routes = [ AutoRoute(page: SplashScreenRoute.page, initial: true), + AutoRoute(page: WelcomeRoute.page), AutoRoute(page: LoginRoute.page), + AutoRoute(page: OnboardingRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: ChangePasswordRoute.page), AutoRoute( page: TabShellRoute.page, diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 1876a784dc..3a0a23cf1a 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1440,6 +1440,22 @@ class MapLocationPickerRouteArgs { int get hashCode => key.hashCode ^ initialLatLng.hashCode; } +/// generated route for +/// [OnboardingPage] +class OnboardingRoute extends PageRouteInfo { + const OnboardingRoute({List? children}) + : super(OnboardingRoute.name, initialChildren: children); + + static const String name = 'OnboardingRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const OnboardingPage(); + }, + ); +} + /// generated route for /// [PartnerPage] class PartnerRoute extends PageRouteInfo { @@ -1873,6 +1889,22 @@ class TabShellRoute extends PageRouteInfo { ); } +/// generated route for +/// [WelcomePage] +class WelcomeRoute extends PageRouteInfo { + const WelcomeRoute({List? children}) + : super(WelcomeRoute.name, initialChildren: children); + + static const String name = 'WelcomeRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const WelcomePage(); + }, + ); +} + /// generated route for /// [WhatsNewPage] class WhatsNewRoute extends PageRouteInfo { diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 56fccf4610..5a33225ff0 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/domain/models/settings_key.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/feature_message.service.dart'; +import 'package:immich_mobile/domain/services/onboarding.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; @@ -35,6 +36,7 @@ Future migrateDatabaseIfNeeded(Drift drift) async { } if (storedVersion == null) { + await OnboardingService(SettingsRepository.instance).markComplete(false); await FeatureMessageService(SettingsRepository.instance).markSeen(); } diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 79617f8fe4..d87fad625f 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -245,7 +245,11 @@ class LoginForm extends HookConsumerWidget { unawaited(handleSyncFlow()); ref.read(websocketProvider.notifier).connect(); unawaited(ref.read(featureMessageServiceProvider).markSeen()); - unawaited(context.router.replaceAll([const TabShellRoute()])); + unawaited( + context.router.replaceAll([ + ref.read(onboardingServiceProvider).isComplete ? const TabShellRoute() : const OnboardingRoute(), + ]), + ); return; } } catch (error) { @@ -333,7 +337,11 @@ class LoginForm extends HookConsumerWidget { } unawaited(handleSyncFlow()); unawaited(ref.read(featureMessageServiceProvider).markSeen()); - unawaited(context.router.replaceAll([const TabShellRoute()])); + unawaited( + context.router.replaceAll([ + ref.read(onboardingServiceProvider).isComplete ? const TabShellRoute() : const OnboardingRoute(), + ]), + ); return; } } catch (error, stack) {