From 177d1c9a30e2e032aa849a48eb7eb08c3b3a489a Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 26 Feb 2026 22:17:28 +0530 Subject: [PATCH] feat: splash screen error page (#26460) * feat: splash screen error page * Update mobile/lib/pages/common/splash_screen.page.dart Co-authored-by: Alex * add clear data action --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex --- i18n/en.json | 6 +- mobile/lib/main.dart | 45 ++-- .../lib/pages/common/splash_screen.page.dart | 254 ++++++++++++++++++ .../sync_status_and_actions.dart | 28 +- 4 files changed, 300 insertions(+), 33 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 808fbeb695..97cff2c69c 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1883,7 +1883,10 @@ "reset_pin_code_success": "Successfully reset PIN code", "reset_pin_code_with_password": "You can always reset your PIN code with your password", "reset_sqlite": "Reset SQLite Database", - "reset_sqlite_confirmation": "Are you sure you want to reset the SQLite database? You will need to log out and log in again to resync the data", + "reset_sqlite_clear_app_data": "Clear Data", + "reset_sqlite_confirmation": "Are you sure you want to clear the app data? This will remove all settings and sign you out.", + "reset_sqlite_confirmation_note": "Note: You will need to restart the app after clearing.", + "reset_sqlite_done": "App data has been cleared. Please restart Immich and log in again.", "reset_sqlite_success": "Successfully reset the SQLite database", "reset_to_default": "Reset to default", "resolution": "Resolution", @@ -1911,6 +1914,7 @@ "saved_settings": "Saved settings", "say_something": "Say something", "scaffold_body_error_occurred": "Error occurred", + "scaffold_body_error_unrecoverable": "An unrecoverable error has occurred. Please share the error and stack trace on Discord or GitHub so we can help. If advised, you can clear the app data below.", "scan": "Scan", "scan_all_libraries": "Scan All Libraries", "scan_library": "Scan", diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 1316e66273..c35c27e141 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -20,6 +20,7 @@ import 'package:immich_mobile/extensions/translate_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/network.repository.dart'; +import 'package:immich_mobile/pages/common/splash_screen.page.dart'; import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; @@ -49,30 +50,34 @@ import 'package:logging/logging.dart'; import 'package:timezone/data/latest.dart'; void main() async { - ImmichWidgetsBinding(); - unawaited(BackgroundWorkerLockService(BackgroundWorkerLockApi()).lock()); - final (isar, drift, logDb) = await Bootstrap.initDB(); - await Bootstrap.initDomain(isar, drift, logDb); - await initApp(); - // Warm-up isolate pool for worker manager - await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5)); - await migrateDatabaseIfNeeded(isar, drift); - HttpSSLOptions.apply(); + try { + ImmichWidgetsBinding(); + unawaited(BackgroundWorkerLockService(BackgroundWorkerLockApi()).lock()); + await EasyLocalization.ensureInitialized(); + final (isar, drift, logDb) = await Bootstrap.initDB(); + await Bootstrap.initDomain(isar, drift, logDb); + await initApp(); + // Warm-up isolate pool for worker manager + await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5)); + await migrateDatabaseIfNeeded(isar, drift); + HttpSSLOptions.apply(); - runApp( - ProviderScope( - overrides: [ - dbProvider.overrideWithValue(isar), - isarProvider.overrideWithValue(isar), - driftProvider.overrideWith(driftOverride(drift)), - ], - child: const MainWidget(), - ), - ); + runApp( + ProviderScope( + overrides: [ + dbProvider.overrideWithValue(isar), + isarProvider.overrideWithValue(isar), + driftProvider.overrideWith(driftOverride(drift)), + ], + child: const MainWidget(), + ), + ); + } catch (error, stack) { + runApp(BootstrapErrorWidget(error: error.toString(), stack: stack.toString())); + } } Future initApp() async { - await EasyLocalization.ensureInitialized(); await initializeDateFormatting(); if (Platform.isAndroid) { diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index 0d728422d1..99998918e4 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -1,10 +1,17 @@ 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/colors.dart'; +import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/generated/codegen_loader.g.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.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'; @@ -13,7 +20,254 @@ import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/theme/color_scheme.dart'; +import 'package:immich_mobile/theme/theme_data.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 launchUrl, LaunchMode; + +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 = defaultColorPreset.themeOfPreset; + + return EasyLocalization( + supportedLocales: locales.values.toList(), + path: translationsPath, + useFallbackTranslations: true, + fallbackLocale: locales.values.first, + assetLoader: const CodegenLoader(), + child: Builder( + builder: (lCtx) => MaterialApp( + title: 'Immich', + debugShowCheckedModeBanner: true, + localizationsDelegates: lCtx.localizationDelegates, + 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), + const SafeArea( + top: false, + child: Padding(padding: EdgeInsets.fromLTRB(24, 16, 24, 16), child: _BottomPanel()), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _BottomPanel extends StatefulWidget { + const _BottomPanel(); + + @override + State<_BottomPanel> createState() => _BottomPanelState(); +} + +class _BottomPanelState extends State<_BottomPanel> { + bool _cleared = false; + + Future _clearDatabase() async { + final confirmed = await showDialog( + 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; + } + + final db = Drift(); + try { + await db.reset(); + } finally { + await db.close(); + } + + if (mounted) { + 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, + ), + Row( + mainAxisAlignment: MainAxisAlignment.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, + ), + ), + 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 { diff --git a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart index ef571fb30a..92787077a1 100644 --- a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart +++ b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -5,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; @@ -87,25 +89,27 @@ class SyncStatusAndActions extends HookConsumerWidget { context: context, builder: (context) { return AlertDialog( - title: Text("reset_sqlite".t(context: context)), - content: Text("reset_sqlite_confirmation".t(context: context)), + title: Text(context.t.reset_sqlite), + content: Text(context.t.reset_sqlite_confirmation), actions: [ - TextButton( - onPressed: () => context.pop(), - child: Text("cancel".t(context: context)), - ), + TextButton(onPressed: () => context.pop(), child: Text(context.t.cancel)), TextButton( onPressed: () async { await ref.read(driftProvider).reset(); context.pop(); - context.scaffoldMessenger.showSnackBar( - SnackBar(content: Text("reset_sqlite_success".t(context: context))), + unawaited( + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + title: Text(context.t.reset_sqlite_success), + content: Text(context.t.reset_sqlite_done), + actions: [TextButton(onPressed: () => ctx.pop(), child: Text(context.t.ok))], + ), + ), ); }, - child: Text( - "confirm".t(context: context), - style: TextStyle(color: context.colorScheme.error), - ), + child: Text(context.t.confirm, style: TextStyle(color: context.colorScheme.error)), ), ], );