import 'dart:async'; import 'dart:io'; 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/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/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:path/path.dart' as path; import 'package:path_provider/path_provider.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; } try { final dir = await getApplicationDocumentsDirectory(); for (final suffix in ['', '-wal', '-shm']) { final file = File(path.join(dir.path, 'immich.sqlite$suffix')); if (await file.exists()) { await file.delete(); } } } catch (_) { return; } 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 { const SplashScreenPage({super.key}); @override SplashScreenPageState createState() => SplashScreenPageState(); } class SplashScreenPageState extends ConsumerState { final log = Logger("SplashScreenPage"); @override void initState() { super.initState(); ref .read(authProvider.notifier) .setOpenApiServiceEndpoint() .then(logConnectionInfo) .whenComplete(() => resumeSession()); } void logConnectionInfo(String? endpoint) { if (endpoint == null) { return; } log.info("Resuming session at $endpoint"); } 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 backupProvider = ref.read(driftBackupProvider.notifier); unawaited( ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then( (_) async { try { wsProvider.connect(); unawaited(infoProvider.getServerInfo()); bool syncSuccess = false; await Future.wait([ backgroundManager.syncLocal(full: true), backgroundManager.syncRemote().then((success) => syncSuccess = success), ]); if (syncSuccess) { await Future.wait([ backgroundManager.hashAssets().then((_) { _resumeBackup(backupProvider); }), _resumeBackup(backupProvider), // TODO: Bring back when the soft freeze issue is addressed // backgroundManager.syncCloudIds(), ]); } else { await backgroundManager.hashAssets(); } if (Store.get(StoreKey.syncAlbums, false)) { await backgroundManager.syncLinkedAlbum(); } } catch (e) { log.severe('Failed establishing connection to the server: $e'); } }, onError: (exception) => { log.severe('Failed to update auth info with access token: $accessToken'), ref.read(authProvider.notifier).logout(), context.replaceRoute(const LoginRoute()), }, ), ); } else { log.severe('Missing crucial offline login info - Logging out completely'); unawaited(ref.read(authProvider.notifier).logout()); unawaited(context.replaceRoute(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 _resumeBackup(DriftBackupNotifier notifier) async { final isEnableBackup = Store.get(StoreKey.enableBackup, false); if (isEnableBackup) { final currentUser = Store.tryGet(StoreKey.currentUser); if (currentUser != null) { 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), ), ); } }