import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:url_launcher/url_launcher.dart'; import '../backend/backend.dart'; import '../core/utils/result.dart'; import 'router.dart'; import 'theme.dart'; class GelRuleApp extends ConsumerWidget { const GelRuleApp({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final database = ref.watch(appDatabaseProvider); final settings = ref.watch(appSettingsProvider); final router = ref.watch(appRouterProvider); return database.when( loading: () => const MaterialApp( debugShowCheckedModeBanner: false, home: _StartupLoadingScreen(), ), error: (error, _) => MaterialApp( debugShowCheckedModeBanner: false, theme: buildTheme(Brightness.light), darkTheme: buildTheme(Brightness.dark), themeMode: ThemeMode.dark, home: _StartupErrorScreen(error: error), ), data: (_) => MaterialApp.router( title: 'RuleGel', debugShowCheckedModeBanner: false, theme: buildTheme(Brightness.light), darkTheme: buildTheme(Brightness.dark), themeMode: settings.maybeWhen( data: (value) => parseThemeMode(value.themeMode), orElse: () => ThemeMode.dark, ), routerConfig: router, builder: (context, child) => _AppOverlay(child: child), ), ); } } class _AppOverlay extends ConsumerStatefulWidget { const _AppOverlay({required this.child}); final Widget? child; @override ConsumerState<_AppOverlay> createState() => _AppOverlayState(); } class _AppOverlayState extends ConsumerState<_AppOverlay> { bool _checkedUpdates = false; @override void didChangeDependencies() { super.didChangeDependencies(); if (_checkedUpdates) return; _checkedUpdates = true; WidgetsBinding.instance.addPostFrameCallback((_) => _checkUpdates()); } Future _checkUpdates() async { final result = await ref.read(updateServiceProvider).checkForUpdates(); if (!mounted || result is! Success || result.data == null) { return; } await _showUpdateDialog(result.data!); } Future _showUpdateDialog(AppUpdateInfo info) async { await showDialog( context: context, builder: (context) => AlertDialog( title: Text('RuleGel ${info.version} is available'), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(info.name), if (info.body.trim().isNotEmpty) ...[ const SizedBox(height: 12), Text( info.body.trim(), maxLines: 12, overflow: TextOverflow.ellipsis, ), ], ], ), ), ), actions: [ TextButton( onPressed: () async { await ref.read(updateServiceProvider).skipVersion(info); if (context.mounted) Navigator.pop(context); }, child: const Text('Skip this version'), ), TextButton( onPressed: () async { await ref.read(updateServiceProvider).remindLater(); if (context.mounted) Navigator.pop(context); }, child: const Text('Later'), ), FilledButton.icon( onPressed: () async { final url = Uri.tryParse(info.htmlUrl); if (url != null) { await launchUrl(url, mode: LaunchMode.externalApplication); } if (context.mounted) Navigator.pop(context); }, icon: const Icon(Icons.open_in_new_rounded), label: const Text('Update'), ), ], ), ); ref.invalidate(appSettingsProvider); } @override Widget build(BuildContext context) { return _DownloadOverlay(child: widget.child); } } class _DownloadOverlay extends ConsumerWidget { const _DownloadOverlay({required this.child}); final Widget? child; @override Widget build(BuildContext context, WidgetRef ref) { final tasks = ref.watch(downloadTasksProvider).value ?? const []; final visible = tasks .where((task) => task.status == DownloadTaskStatus.running || task.status == DownloadTaskStatus.queued || task.status == DownloadTaskStatus.failed || task.status == DownloadTaskStatus.completed) .toList(); return Stack( children: [ if (child != null) child!, if (visible.isNotEmpty) Positioned( right: 16, bottom: 16, child: SafeArea( child: _DownloadPanel(tasks: visible.take(3).toList()), ), ), ], ); } } class _DownloadPanel extends ConsumerWidget { const _DownloadPanel({required this.tasks}); final List tasks; @override Widget build(BuildContext context, WidgetRef ref) { final scheme = Theme.of(context).colorScheme; return Material( elevation: 12, borderRadius: BorderRadius.circular(16), color: scheme.surface, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 360), child: Padding( padding: const EdgeInsets.all(12), child: Column( mainAxisSize: MainAxisSize.min, children: [ for (final task in tasks) ...[ Row( children: [ Icon(_icon(task.status), size: 20), const SizedBox(width: 8), Expanded( child: Text( task.fileName, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), if (task.status == DownloadTaskStatus.failed) IconButton( tooltip: 'Retry', onPressed: () => ref .read(downloadManagerServiceProvider) .retry(task.id), icon: const Icon(Icons.refresh_rounded), ), ], ), const SizedBox(height: 6), LinearProgressIndicator( value: task.status == DownloadTaskStatus.running ? task.progress.clamp(0, 1) : task.status == DownloadTaskStatus.completed ? 1 : null, ), if (task.error != null) Padding( padding: const EdgeInsets.only(top: 4), child: Text( task.error!, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(color: scheme.error), ), ), if (task != tasks.last) const SizedBox(height: 10), ], ], ), ), ), ); } IconData _icon(DownloadTaskStatus status) { return switch (status) { DownloadTaskStatus.completed => Icons.download_done_rounded, DownloadTaskStatus.failed => Icons.error_outline_rounded, DownloadTaskStatus.canceled => Icons.cancel_rounded, _ => Icons.download_rounded, }; } } final appSettingsProvider = FutureProvider((ref) { return ref.watch(settingsServiceProvider).getSettings().then( (result) => result.fold( onSuccess: (settings) => settings, onError: (_) => AppSettings.defaults, ), ); }); class _StartupLoadingScreen extends StatelessWidget { const _StartupLoadingScreen(); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFF101114), body: Center( child: CircularProgressIndicator( color: ColorScheme.fromSeed( seedColor: const Color(0xFFE84D8A), brightness: Brightness.dark, ).primary, ), ), ); } } class _StartupErrorScreen extends ConsumerWidget { const _StartupErrorScreen({required this.error}); final Object error; @override Widget build(BuildContext context, WidgetRef ref) { return Scaffold( body: SafeArea( child: Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.storage_rounded, size: 48), const SizedBox(height: 16), Text( 'Could not start local database', style: Theme.of(context).textTheme.titleLarge, textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( '$error', textAlign: TextAlign.center, maxLines: 5, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 18), FilledButton.icon( onPressed: () => ref.invalidate(appDatabaseProvider), icon: const Icon(Icons.refresh_rounded), label: const Text('Retry'), ), ], ), ), ), ), ); } }