feat: mobile onboarding page

This commit is contained in:
shenlong-tanwen
2026-07-28 21:23:25 +05:30
parent 1094b69946
commit 81455ad387
20 changed files with 640 additions and 8 deletions
+19
View File
@@ -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",
@@ -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<T>(SettingsKey<T> 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<T, U extends T>(SettingsKey<T> 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)),
+4 -1
View File
@@ -78,7 +78,10 @@ enum SettingsKey<T> {
slideshowDirection<SlideshowDirection>(codec: EnumCodec(SlideshowDirection.values)),
// Feature message
featureMessageSeenRelease<SemVer>(codec: SemVerCodec());
featureMessageSeenRelease<SemVer>(codec: SemVerCodec()),
// Onboarding
onboardingComplete<bool>();
final ValueCodec<T>? _codecOverride;
@@ -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<void> markSeen() => _settingsRepository.write(SettingsKey.featureMessageSeenRelease, featureMessageRelease);
Future<void> markSeen() => _settingsRepository.write(.featureMessageSeenRelease, featureMessageRelease);
}
@@ -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<void> markComplete([bool complete = true]) => _settingsRepository.write(.onboardingComplete, complete);
}
@@ -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<SplashScreenPage> {
} 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;
}
@@ -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) ...<ImmichTextButton>[
.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)],
);
}
}
@@ -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)],
);
}
}
@@ -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<void> 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: <ImmichTextButton>[
.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: <ImmichTextButton>[
.new(labelText: context.t.onboarding_go_to_settings, onPressed: openAppSettings),
.new(labelText: context.t.skip, onPressed: onNext, variant: .ghost),
],
),
};
}
}
@@ -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<OnboardingPage> createState() => _OnboardingPageState();
}
class _OnboardingPageState extends ConsumerState<OnboardingPage> {
final _log = Logger('OnboardingPage');
final _controller = PageController();
int _page = 0;
Future<void>? _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<void> _finish() async {
await ref.read(onboardingServiceProvider).markComplete();
if (mounted) {
await context.router.replaceAll([const TabShellRoute()]);
}
}
void _leavePhotoStep() {
if (_hasGalleryAccess) {
_prepareAlbumsTask ??= _prepareLocalAlbums();
}
_next();
}
Future<void> _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<void> _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]),
),
),
),
);
}
}
@@ -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: <ImmichTextButton>[
.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: <ImmichTextButton>[
.new(labelText: context.t.onboarding_go_to_settings, onPressed: openAppSettings),
.new(labelText: context.t.onboarding_continue_anyway, onPressed: onNext, variant: .ghost),
],
),
};
}
}
@@ -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)),
),
),
),
);
}
}
@@ -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<Widget> 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),
],
),
),
);
}
}
@@ -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<ThemeMode>(
groupBy: mode,
onRadioChanged: (theme) => theme == null ? null : updateTheme(theme),
groups: <SettingsRadioGroup<ThemeMode>>[
.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)],
);
}
}
@@ -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()),
),
],
),
),
),
),
);
}
@@ -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<FeatureMessageService>(
(ref) => FeatureMessageService(ref.read(settingsProvider)),
(ref) => FeatureMessageService(ref.watch(settingsProvider)),
);
final onboardingServiceProvider = Provider<OnboardingService>((ref) => OnboardingService(ref.watch(settingsProvider)));
+4
View File
@@ -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<AutoRoute> 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,
+32
View File
@@ -1440,6 +1440,22 @@ class MapLocationPickerRouteArgs {
int get hashCode => key.hashCode ^ initialLatLng.hashCode;
}
/// generated route for
/// [OnboardingPage]
class OnboardingRoute extends PageRouteInfo<void> {
const OnboardingRoute({List<PageRouteInfo>? 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<void> {
@@ -1873,6 +1889,22 @@ class TabShellRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [WelcomePage]
class WelcomeRoute extends PageRouteInfo<void> {
const WelcomeRoute({List<PageRouteInfo>? 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<void> {
+2
View File
@@ -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<void> migrateDatabaseIfNeeded(Drift drift) async {
}
if (storedVersion == null) {
await OnboardingService(SettingsRepository.instance).markComplete(false);
await FeatureMessageService(SettingsRepository.instance).markSeen();
}
+10 -2
View File
@@ -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) {