Files
Immich/mobile/lib/pages/common/splash_screen.page.dart
T
YarosandCodex e708a80474 fix(mobile): don't block startup when offline (#32652)
* fix(mobile): don't block startup when offline

* chore: implement suggestions

---------

Co-authored-by: Santo Shakil <santoshakil@users.noreply.github.com>
(cherry picked from commit 32b137ab3713545324873f552adb51b9ef2aecde)
2026-10-31 07:22:45 +03:00

422 lines
15 KiB
Dart

import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/data/db/main/database.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.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';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/immich_logger.service.dart';
import 'package:immich_mobile/theme/color_scheme.dart';
import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/utils/fallback_localizations.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart';
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
import 'package:logging/logging.dart';
import 'package:url_launcher/url_launcher.dart' show LaunchMode, launchUrl;
class BootstrapErrorWidget extends StatelessWidget {
final String error;
final String stack;
const BootstrapErrorWidget({super.key, required this.error, required this.stack});
@override
Widget build(BuildContext _) {
final immichTheme = defaultConfig.theme.primaryColor.themeOfPreset;
return EasyLocalization(
supportedLocales: locales.values.toList(),
startLocale: defaultAppLocale,
path: translationsPath,
useFallbackTranslations: true,
fallbackLocale: locales.values.first,
assetLoader: const CodegenLoader(),
child: Builder(
builder: (lCtx) => MaterialApp(
title: 'Immich',
debugShowCheckedModeBanner: true,
localizationsDelegates: [...lCtx.localizationDelegates, ...fallbackLocalizationDelegates],
supportedLocales: lCtx.supportedLocales,
locale: lCtx.locale,
themeMode: ThemeMode.system,
darkTheme: getThemeData(colorScheme: immichTheme.dark, locale: lCtx.locale),
theme: getThemeData(colorScheme: immichTheme.light, locale: lCtx.locale),
home: Builder(
builder: (ctx) => Scaffold(
body: Column(
children: [
const SafeArea(
bottom: false,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [ImmichLogo(size: 48), SizedBox(width: 12), ImmichTitleText(fontSize: 24)],
),
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: _ErrorCard(error: error, stack: stack),
),
),
const Divider(height: 1),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 16),
child: _BottomPanel(startupError: '$error\n\n$stack'),
),
),
],
),
),
),
),
),
);
}
}
class _BottomPanel extends StatefulWidget {
final String startupError;
const _BottomPanel({required this.startupError});
@override
State<_BottomPanel> createState() => _BottomPanelState();
}
class _BottomPanelState extends State<_BottomPanel> {
bool _cleared = false;
Future<void> _clearDatabase() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogCtx) => AlertDialog(
title: Text(context.t.reset_sqlite_clear_app_data),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.t.reset_sqlite_confirmation),
const SizedBox(height: 12),
Text(
context.t.reset_sqlite_confirmation_note,
style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.of(dialogCtx).pop(false), child: Text(context.t.cancel)),
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(true),
child: Text(context.t.confirm, style: TextStyle(color: Theme.of(context).colorScheme.error)),
),
],
),
);
if (confirmed != true || !mounted) {
return;
}
try {
await deleteSqliteDatabase(name: 'immich');
} catch (_) {
return;
}
if (!mounted) {
return;
}
setState(() => _cleared = true);
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
Text(
_cleared ? context.t.reset_sqlite_done : context.t.scaffold_body_error_unrecoverable,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
Wrap(
alignment: WrapAlignment.center,
children: [
_ActionLink(
icon: Icons.chat_bubble_outline,
label: context.t.discord,
onTap: () => launchUrl(Uri.parse('https://discord.immich.app/'), mode: LaunchMode.externalApplication),
),
_ActionLink(
icon: Icons.bug_report_outlined,
label: context.t.profile_drawer_github,
onTap: () => launchUrl(
Uri.parse('https://github.com/immich-app/immich/issues'),
mode: LaunchMode.externalApplication,
),
),
_ActionLink(
icon: Icons.share_outlined,
label: context.t.share_logs,
onTap: () => ImmichLogger.shareLogs(context, startupError: widget.startupError),
),
if (!_cleared)
_ActionLink(
icon: Icons.delete_outline,
label: context.t.reset_sqlite_clear_app_data,
onTap: _clearDatabase,
),
],
),
],
);
}
}
class _ActionLink extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _ActionLink({required this.icon, required this.label, required this.onTap});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 24),
const SizedBox(height: 4),
Text(label, style: const TextStyle(fontSize: 12)),
],
),
),
);
}
}
class _ErrorCard extends StatelessWidget {
final String error;
final String stack;
const _ErrorCard({required this.error, required this.stack});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ColoredBox(
color: scheme.error,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 8, 8),
child: Row(
children: [
Expanded(
child: Text(
context.t.scaffold_body_error_occurred,
style: textTheme.titleSmall?.copyWith(color: scheme.onError),
),
),
IconButton(
tooltip: context.t.copy_error,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
icon: Icon(Icons.copy_outlined, size: 16, color: scheme.onError),
onPressed: () => Clipboard.setData(ClipboardData(text: '$error\n\n$stack')),
),
],
),
),
),
Padding(
padding: const EdgeInsets.all(12),
child: Text(error, style: textTheme.bodyMedium),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.t.stacktrace, style: textTheme.labelMedium),
const SizedBox(height: 4),
SelectableText(stack, style: textTheme.bodySmall?.copyWith(fontFamily: 'GoogleSansCode')),
],
),
),
],
),
);
}
}
@RoutePage()
class SplashScreenPage extends StatefulHookConsumerWidget {
const SplashScreenPage({super.key});
@override
SplashScreenPageState createState() => SplashScreenPageState();
}
class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
final log = Logger("SplashScreenPage");
@override
void initState() {
super.initState();
unawaited(resumeSession());
}
Future<void> resumeSession() async {
final serverUrl = Store.tryGet(StoreKey.serverUrl);
final endpoint = Store.tryGet(StoreKey.serverEndpoint);
final accessToken = Store.tryGet(StoreKey.accessToken);
if (accessToken != null && serverUrl != null && endpoint != null) {
final infoProvider = ref.read(serverInfoProvider.notifier);
final wsProvider = ref.read(websocketProvider.notifier);
final backgroundManager = ref.read(backgroundSyncProvider);
final backupNotifier = ref.read(backupProvider.notifier);
final lifeCycle = ref.read(appStateProvider.notifier);
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
final authNotifier = ref.read(authProvider.notifier);
unawaited(
authNotifier
.setOpenApiServiceEndpoint()
.catchError((Object error, StackTrace stackTrace) {
log.warning('Failed to resolve server endpoint', error, stackTrace);
return null;
})
.then((_) async {
if (Store.tryGet(StoreKey.accessToken) != accessToken) {
return false;
}
await authNotifier.saveAuthInfo(accessToken: accessToken);
return true;
})
.then(
(sessionResumed) async {
if (!sessionResumed) {
return;
}
try {
wsProvider.connect();
unawaited(infoProvider.getServerInfo());
bool syncSuccess = false;
await Future.wait([
backgroundManager.syncLocal(full: true),
backgroundManager.syncRemote().then((success) => syncSuccess = success),
]);
await viewIntentHandler.flushDeferredViewIntent();
if (syncSuccess) {
await Future.wait([
backgroundManager.hashAssets().then((_) {
unawaited(_resumeBackup(backupNotifier, lifeCycle));
}),
_resumeBackup(backupNotifier, lifeCycle),
backgroundManager.syncCloudIds(),
]);
} else {
await backgroundManager.hashAssets();
}
if (SettingsRepository.instance.appConfig.backup.syncAlbums) {
await backgroundManager.syncLinkedAlbum();
}
} catch (e) {
log.severe('Failed establishing connection to the server: $e');
}
},
onError: (exception) {
log.severe('Failed to update auth info', exception);
if (!mounted) {
return;
}
unawaited(ref.read(authProvider.notifier).logout());
unawaited(context.router.replaceAll([const LoginRoute()]));
},
),
);
} else {
log.severe('Missing crucial offline login info - Logging out completely');
unawaited(ref.read(authProvider.notifier).logout());
unawaited(context.router.replaceAll([const LoginRoute()]));
return;
}
// clean install - change the default of the flag
// current install not using beta timeline
if (context.router.current.name == SplashScreenRoute.name) {
unawaited(context.replaceRoute(const TabShellRoute()));
}
}
Future<void> _resumeBackup(BackupNotifier notifier, AppLifeCycleNotifier lifeCycle) async {
final isEnableBackup = SettingsRepository.instance.appConfig.backup.enabled;
if (isEnableBackup) {
final currentUser = Store.tryGet(StoreKey.currentUser);
if (currentUser != null) {
// TODO(rewrite): Remove this check and requestFullResume once the splash no longer runs in background launches
// iOS also runs the splash in background launches, where the app is never resumed.
// there the foreground backup waits for the first resume instead of uploading off screen
if (CurrentPlatform.isIOS && WidgetsBinding.instance.lifecycleState != AppLifecycleState.resumed) {
lifeCycle.requestFullResume();
return;
}
unawaited(notifier.startForegroundBackup(currentUser.id));
}
}
}
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Image(image: AssetImage('assets/immich-logo.png'), width: 80, filterQuality: FilterQuality.high),
),
);
}
}