mirror of
https://github.com/immich-app/immich.git
synced 2026-07-28 14:47:30 -07:00
feat: mobile onboarding page
This commit is contained in:
@@ -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)),
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user