From 8b9953ec57f4bb58f4592a3d20259155be9cfbc0 Mon Sep 17 00:00:00 2001 From: Gabriel Sousa Date: Mon, 21 Sep 2026 18:17:42 -0300 Subject: [PATCH 1/9] feat(l10n): add loading, startup stage and download progress strings --- lib/l10n/generated/app_localizations.dart | 36 ++++++++++++++++++++ lib/l10n/generated/app_localizations_de.dart | 20 +++++++++++ lib/l10n/generated/app_localizations_en.dart | 20 +++++++++++ lib/l10n/locales/app_de.arb | 34 +++++++++++++++++- lib/l10n/locales/app_en.arb | 36 ++++++++++++++++++-- 5 files changed, 143 insertions(+), 3 deletions(-) diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 7a1164d..823bfa9 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -883,6 +883,42 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Unfortunately there is no PDF available yet for this worksheet. If you want to help make this change soon, please contact us!'** String get pdfNotAvailable; + + /// Caption under the spinner on the startup screen + /// + /// In en, this message translates to: + /// **'Loading'** + String get loading; + + /// Caption under the spinner while a worksheet is being loaded + /// + /// In en, this message translates to: + /// **'Loading content...'** + String get loadingContent; + + /// Caption under the startup spinner while the app finds out which languages are on the device + /// + /// In en, this message translates to: + /// **'Checking downloaded languages'** + String get startupCheckingLanguages; + + /// Caption under the startup spinner while the app language is being loaded + /// + /// In en, this message translates to: + /// **'Loading app language'** + String get startupLoadingAppLanguage; + + /// Caption under the startup spinner while the language of the worksheet the user last read is being loaded + /// + /// In en, this message translates to: + /// **'Loading your last worksheet'** + String get startupLoadingRecentPage; + + /// Caption next to the progress ring while all languages are being downloaded, e.g. '3 of 34' + /// + /// In en, this message translates to: + /// **'{completed} of {total}'** + String downloadProgress(int completed, int total); } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 4b478f1..c72acc6 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -509,4 +509,24 @@ class AppLocalizationsDe extends AppLocalizations { @override String get pdfNotAvailable => 'Für dieses Arbeitsblatt ist leider noch kein PDF verfügbar. Wenn du mithelfen möchtest, damit sich das bald ändert, dann melde dich bitte!'; + + @override + String get loading => 'Wird geladen'; + + @override + String get loadingContent => 'Inhalt wird geladen...'; + + @override + String get startupCheckingLanguages => 'Überprüfe heruntergeladene Sprachen'; + + @override + String get startupLoadingAppLanguage => 'Lade App-Sprache'; + + @override + String get startupLoadingRecentPage => 'Lade dein letztes Arbeitsblatt'; + + @override + String downloadProgress(int completed, int total) { + return '$completed von $total'; + } } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index c50e29d..7362d02 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -508,4 +508,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get pdfNotAvailable => 'Unfortunately there is no PDF available yet for this worksheet. If you want to help make this change soon, please contact us!'; + + @override + String get loading => 'Loading'; + + @override + String get loadingContent => 'Loading content...'; + + @override + String get startupCheckingLanguages => 'Checking downloaded languages'; + + @override + String get startupLoadingAppLanguage => 'Loading app language'; + + @override + String get startupLoadingRecentPage => 'Loading your last worksheet'; + + @override + String downloadProgress(int completed, int total) { + return '$completed of $total'; + } } diff --git a/lib/l10n/locales/app_de.arb b/lib/l10n/locales/app_de.arb index 2dae244..8c1231e 100644 --- a/lib/l10n/locales/app_de.arb +++ b/lib/l10n/locales/app_de.arb @@ -339,5 +339,37 @@ "openPdf": "PDF öffnen", "openInBrowser": "Im Browser öffnen", "shareLink": "Link teilen", - "pdfNotAvailable": "Für dieses Arbeitsblatt ist leider noch kein PDF verfügbar. Wenn du mithelfen möchtest, damit sich das bald ändert, dann melde dich bitte!" + "pdfNotAvailable": "Für dieses Arbeitsblatt ist leider noch kein PDF verfügbar. Wenn du mithelfen möchtest, damit sich das bald ändert, dann melde dich bitte!", + "loading": "Wird geladen", + "@loading": { + "description": "Caption under the spinner on the startup screen" + }, + "loadingContent": "Inhalt wird geladen...", + "@loadingContent": { + "description": "Caption under the spinner while a worksheet is being loaded" + }, + "startupCheckingLanguages": "Überprüfe heruntergeladene Sprachen", + "@startupCheckingLanguages": { + "description": "Caption under the startup spinner while the app finds out which languages are on the device" + }, + "startupLoadingAppLanguage": "Lade App-Sprache", + "@startupLoadingAppLanguage": { + "description": "Caption under the startup spinner while the app language is being loaded" + }, + "startupLoadingRecentPage": "Lade dein letztes Arbeitsblatt", + "@startupLoadingRecentPage": { + "description": "Caption under the startup spinner while the language of the worksheet the user last read is being loaded" + }, + "downloadProgress": "{completed} von {total}", + "@downloadProgress": { + "description": "Caption next to the progress ring while all languages are being downloaded, e.g. '3 of 34'", + "placeholders": { + "completed": { + "type": "int" + }, + "total": { + "type": "int" + } + } + } } diff --git a/lib/l10n/locales/app_en.arb b/lib/l10n/locales/app_en.arb index e4fa8ad..996b648 100644 --- a/lib/l10n/locales/app_en.arb +++ b/lib/l10n/locales/app_en.arb @@ -339,5 +339,37 @@ "openPdf": "Open PDF", "openInBrowser": "Open in browser", "shareLink": "Share link", - "pdfNotAvailable": "Unfortunately there is no PDF available yet for this worksheet. If you want to help make this change soon, please contact us!" -} \ No newline at end of file + "pdfNotAvailable": "Unfortunately there is no PDF available yet for this worksheet. If you want to help make this change soon, please contact us!", + "loading": "Loading", + "@loading": { + "description": "Caption under the spinner on the startup screen" + }, + "loadingContent": "Loading content...", + "@loadingContent": { + "description": "Caption under the spinner while a worksheet is being loaded" + }, + "startupCheckingLanguages": "Checking downloaded languages", + "@startupCheckingLanguages": { + "description": "Caption under the startup spinner while the app finds out which languages are on the device" + }, + "startupLoadingAppLanguage": "Loading app language", + "@startupLoadingAppLanguage": { + "description": "Caption under the startup spinner while the app language is being loaded" + }, + "startupLoadingRecentPage": "Loading your last worksheet", + "@startupLoadingRecentPage": { + "description": "Caption under the startup spinner while the language of the worksheet the user last read is being loaded" + }, + "downloadProgress": "{completed} of {total}", + "@downloadProgress": { + "description": "Caption next to the progress ring while all languages are being downloaded, e.g. '3 of 34'", + "placeholders": { + "completed": { + "type": "int" + }, + "total": { + "type": "int" + } + } + } +} From 388d011de34738d80ca43d87ce7a5baf5643c169 Mon Sep 17 00:00:00 2001 From: Gabriel Sousa Date: Mon, 21 Sep 2026 18:17:51 -0300 Subject: [PATCH 2/9] feat(startup): localize loading captions and show the current startup stage --- lib/data/startup_stage.dart | 52 ++++++++++++++ lib/routes/startup_page.dart | 108 ++++++++++++++++++----------- lib/routes/view_page.dart | 2 +- lib/widgets/loading_animation.dart | 41 +++++++---- 4 files changed, 149 insertions(+), 54 deletions(-) create mode 100644 lib/data/startup_stage.dart diff --git a/lib/data/startup_stage.dart b/lib/data/startup_stage.dart new file mode 100644 index 0000000..1ceae7e --- /dev/null +++ b/lib/data/startup_stage.dart @@ -0,0 +1,52 @@ +import 'package:app4training/l10n/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Which part of its work `StartupPage.init()` is currently in. +/// +/// Only init() itself reports a stage, right before it starts the work of +/// that stage - so the caption under the startup spinner never claims +/// progress that hasn't been made. See docs/routing.md ("Why the loading +/// is staged") for what each stage does. +enum StartupStage { + /// init() hasn't reported anything yet + starting, + + /// Step 1: one stat() per language to find out which ones are on the device + checkingLanguages, + + /// Step 2: fully loading the app language (the menu needs it) + loadingAppLanguage, + + /// Still step 2: the app language is in, the language of the worksheet + /// we resume isn't yet + loadingRecentPage; + + static String getLocalized(BuildContext context, StartupStage stage) { + switch (stage) { + case StartupStage.starting: + return context.l10n.loading; + case StartupStage.checkingLanguages: + return context.l10n.startupCheckingLanguages; + case StartupStage.loadingAppLanguage: + return context.l10n.startupLoadingAppLanguage; + case StartupStage.loadingRecentPage: + return context.l10n.startupLoadingRecentPage; + } + } +} + +class StartupStageNotifier extends Notifier { + @override + StartupStage build() => StartupStage.starting; + + /// Called by StartupPage.init() whenever it moves on to the next stage + void report(StartupStage stage) { + state = stage; + } +} + +/// The stage StartupPage.init() is in - drives the caption of its spinner +final startupStageProvider = + NotifierProvider( + StartupStageNotifier.new); diff --git a/lib/routes/startup_page.dart b/lib/routes/startup_page.dart index e78a2aa..8823e15 100644 --- a/lib/routes/startup_page.dart +++ b/lib/routes/startup_page.dart @@ -3,6 +3,7 @@ import 'dart:collection'; import 'package:app4training/background/background_scheduler.dart'; import 'package:app4training/data/app_language.dart'; +import 'package:app4training/data/startup_stage.dart'; import 'package:app4training/features/perf/perf_logger.dart'; import 'package:app4training/routes/error_page.dart'; import 'package:flutter/foundation.dart'; @@ -20,10 +21,34 @@ const int _maxParallelLanguageLoads = 3; /// while we're initializing the data in the background. /// In case the user is new: Lead him to the onboarding / resume onboarding /// in case onboarding got interrupted in between -class StartupPage extends ConsumerWidget { +class StartupPage extends ConsumerStatefulWidget { final Function? initFunction; // For testing (is there a better solution?) const StartupPage({super.key, this.initFunction}); + @override + ConsumerState createState() => _StartupPageState(); +} + +class _StartupPageState extends ConsumerState { + /// Completes once we have pushed the next route (or with init()'s error) + late final Future _navigation; + + @override + void initState() { + super.initState(); + // init() reports where it is through startupStageProvider, and a provider + // must not be modified while the widget tree is building. So the first + // frame (spinner with its initial caption) goes out before init() starts. + _navigation = Future.microtask(() { + final Future initResult = + (widget.initFunction != null) ? widget.initFunction!() : init(); + return initResult; + }).then((String navigateTo) { + if (!mounted) return; + unawaited(Navigator.pushReplacementNamed(context, navigateTo)); + }); + } + /// Initialize and return the route where to continue now /// /// This runs while the user is staring at the loading spinner, so it does @@ -38,13 +63,15 @@ class StartupPage extends ConsumerWidget { /// background: nothing on the first screen depends on them, and the /// widgets that do (language selection, drawer translation icons) /// rebuild by themselves once a language arrives. - Future init(WidgetRef ref) async { + Future init() async { if (ref.read(sharedPrefsProvider).getString('appLanguage') == null) { // First app usage: Let's start onboarding return '/onboarding/1'; } + final StartupStageNotifier stage = ref.read(startupStageProvider.notifier); // Step 1: Which languages are on the device? + stage.report(StartupStage.checkingLanguages); final List availableLanguages = ref.read(availableLanguagesProvider); await PerfLogger.span( @@ -78,14 +105,26 @@ class StartupPage extends ConsumerWidget { // Step 2: Load what the first screen needs - the app language for the menu // and, if we resume a recent page, the language that page is written in. + stage.report(StartupStage.loadingAppLanguage); final Set neededNow = {appLangCode, if (resumeRecentPage) lang}; - await PerfLogger.span( - 'startup.initNeededNow', - () => Future.wait([ - for (String languageCode in neededNow) - ref.read(languageProvider(languageCode).notifier).init() - // TODO: look at return value and show snackBar on error - ]), + final Future appLanguageLoaded = + ref.read(languageProvider(appLangCode).notifier).init(); + final List> loads = [appLanguageLoaded]; + if (neededNow.length > 1) { + // Both load in parallel. Once the app language is in, all we're still + // waiting for is the worksheet's language - but only say so if that + // one is in fact still loading. + bool recentLanguageLoaded = false; + loads.add(ref + .read(languageProvider(lang).notifier) + .init() + .whenComplete(() => recentLanguageLoaded = true)); + unawaited(appLanguageLoaded.then((_) { + if (!recentLanguageLoaded) stage.report(StartupStage.loadingRecentPage); + }, onError: (_) {})); // errors surface through the Future.wait below + } + // TODO: look at the return values and show snackBar on error + await PerfLogger.span('startup.initNeededNow', () => Future.wait(loads), data: () => {'languages': neededNow.length}); // Step 3: Everything else may take its time. We hand over the controllers @@ -130,41 +169,32 @@ class StartupPage extends ConsumerWidget { } @override - Widget build(BuildContext context, WidgetRef ref) { - // When we're finished with loading: Go to the recently opened page - Future initResult = - ((initFunction != null) ? initFunction!() : init(ref)); + Widget build(BuildContext context) { return FutureBuilder( - future: initResult.then( - (String navigateTo) => { - if (context.mounted) - {Navigator.pushReplacementNamed(context, navigateTo)}, - }, - ), - initialData: "Loading", - builder: (BuildContext context, AsyncSnapshot snapshot) { + future: _navigation, + builder: (BuildContext context, AsyncSnapshot snapshot) { if (kDebugMode) debugPrint(snapshot.connectionState.toString()); - switch (snapshot.connectionState) { - case ConnectionState.none: - case ConnectionState.waiting: - case ConnectionState.active: - return loadingAnimation('Loading'); - case ConnectionState.done: - if (kDebugMode) { - debugPrint('Done, hasData: ${snapshot.hasData},' - ' Error: ${snapshot.hasError}'); - } - if (snapshot.hasError) { - // TODO do something more helpful for the user ("try again...") - return ErrorPage(snapshot.error.toString()); - } else { - // This is actually never called because as soon - // as we push the new route he's out of here... - return loadingAnimation('Redirecting ...'); - } + if (snapshot.hasError) { + // TODO do something more helpful for the user ("try again...") + return ErrorPage(snapshot.error.toString()); } + // Once init() is done we have already pushed the next route; this + // page just stays as it is while that route animates in. + return const LoadingAnimation(caption: _StartupCaption()); }, ); } } + +/// Names the stage init() is in. Watching the stage here, and only here, +/// keeps a stage change from rebuilding anything but this text. +class _StartupCaption extends ConsumerWidget { + const _StartupCaption(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Text( + StartupStage.getLocalized(context, ref.watch(startupStageProvider))); + } +} diff --git a/lib/routes/view_page.dart b/lib/routes/view_page.dart index 8801e6b..45ebe2a 100644 --- a/lib/routes/view_page.dart +++ b/lib/routes/view_page.dart @@ -61,7 +61,7 @@ class ViewPage extends ConsumerWidget { case ConnectionState.none: case ConnectionState.waiting: case ConnectionState.active: - return loadingAnimation("Loading content..."); + return loadingAnimation(context.l10n.loadingContent); case ConnectionState.done: if (kDebugMode) { debugPrint('Done, hasData: ${snapshot.hasData},' diff --git a/lib/widgets/loading_animation.dart b/lib/widgets/loading_animation.dart index c76f013..ad5efc3 100644 --- a/lib/widgets/loading_animation.dart +++ b/lib/widgets/loading_animation.dart @@ -1,17 +1,30 @@ import 'package:flutter/material.dart'; -Widget loadingAnimation(String msg) { - return Scaffold( - body: Center( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Spacer(flex: 10), - const Expanded(child: CircularProgressIndicator()), - const Spacer(), - Expanded(child: Text(msg)), - const Spacer(flex: 10) - ], - ), - )); +/// Full-screen spinner with a [caption] underneath it. +/// +/// The caption is a widget so that it can rebuild on its own (e.g. a +/// Consumer watching a provider) without touching the spinner. +class LoadingAnimation extends StatelessWidget { + final Widget caption; + const LoadingAnimation({required this.caption, super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Spacer(flex: 10), + const Expanded(child: CircularProgressIndicator()), + const Spacer(), + Expanded(child: caption), + const Spacer(flex: 10) + ], + ), + )); + } } + +/// [LoadingAnimation] with a fixed text as its caption +Widget loadingAnimation(String msg) => LoadingAnimation(caption: Text(msg)); From 88046403a0b77b06a43c6f3feffd52d53d1d6da0 Mon Sep 17 00:00:00 2001 From: Gabriel Sousa Date: Mon, 21 Sep 2026 18:18:23 -0300 Subject: [PATCH 3/9] test(startup): cover localized captions and startup stages --- .../background_interaction_test.dart | 2 +- test/startup_page_test.dart | 209 +++++++++++++++++- test/view_page_test.dart | 2 +- 3 files changed, 208 insertions(+), 5 deletions(-) diff --git a/integration_test/background_interaction_test.dart b/integration_test/background_interaction_test.dart index be34779..b5fe3a3 100644 --- a/integration_test/background_interaction_test.dart +++ b/integration_test/background_interaction_test.dart @@ -74,7 +74,7 @@ void main() async { languageDownloaderProvider .overrideWithValue(FakeLanguageDownloader(fileSystem: fileSystem)), ], child: const App4Training())); - expect(find.text('Loading'), findsOneWidget); + expect(find.text('Wird geladen'), findsOneWidget); await tester.pumpAndSettle(); // The languageStatusProvider haven't been loaded into memory yet - diff --git a/test/startup_page_test.dart b/test/startup_page_test.dart index cc5ba78..e0a8be0 100644 --- a/test/startup_page_test.dart +++ b/test/startup_page_test.dart @@ -3,7 +3,9 @@ import 'dart:async'; import 'package:app4training/background/background_scheduler.dart'; import 'package:app4training/data/globals.dart'; import 'package:app4training/data/languages.dart'; +import 'package:app4training/data/startup_stage.dart'; import 'package:app4training/l10n/generated/app_localizations.dart'; +import 'package:app4training/l10n/generated/app_localizations_en.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -17,18 +19,27 @@ import 'languages_test.dart'; /// test decide when each of them finishes loading class GatedLanguageController extends TestLanguageController { GatedLanguageController(this.initCalls, this.gates, - {super.downloadedLanguages}) + {super.downloadedLanguages, this.lazyInitGate}) : super(initReturns: true); final List initCalls; final Map> gates; + /// If set, every lazyInit() waits for this before answering + final Completer? lazyInitGate; + @override Future init() async { initCalls.add(languageCode); await gates[languageCode]!.future; return super.init(); } + + @override + Future lazyInit() async { + if (lazyInitGate != null) await lazyInitGate!.future; + return super.lazyInit(); + } } void main() { @@ -68,14 +79,17 @@ void main() { UncontrolledProviderScope( container: ref, child: MaterialApp( + locale: const Locale('de'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, home: const StartupPage(), onGenerateRoute: generateRoutes, ), ), ); - // First there should be the loading animation + // First there should be the loading animation, in the app language expect(find.byType(CircularProgressIndicator), findsOneWidget); - expect(find.text('Loading'), findsOneWidget); + expect(find.text('Wird geladen'), findsOneWidget); await tester.pump(); expect(route, equals('/home')); // Now we went on to this route expect(ref.read(backgroundSchedulerProvider), true); @@ -102,6 +116,9 @@ void main() { UncontrolledProviderScope( container: ref, child: MaterialApp( + locale: const Locale('de'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, home: const StartupPage(), onGenerateRoute: generateRoutes, ), @@ -168,6 +185,9 @@ void main() { UncontrolledProviderScope( container: ref, child: MaterialApp( + locale: const Locale('de'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, home: const StartupPage(), onGenerateRoute: generateRoutes, ), @@ -194,6 +214,183 @@ void main() { expect(initCalls.length, 3); }); + testWidgets('The caption names the stage init() is in', ( + WidgetTester tester, + ) async { + SharedPreferences.setMockInitialValues({ + 'appLanguage': 'en', + 'checkFrequency': 'weekly', + 'recentPage': 'Healing', + 'recentLang': 'de', + }); + final prefs = await SharedPreferences.getInstance(); + route = null; + final l10n = AppLocalizationsEn(); + final initCalls = []; + final lazyInitGate = Completer(); + final gates = { + for (final languageCode in ['en', 'de']) + languageCode: Completer() + }; + final ref = ProviderContainer( + overrides: [ + languageProvider.overrideWith2( + (languageCode) => GatedLanguageController( + initCalls, + gates, + downloadedLanguages: ['en', 'de'], + lazyInitGate: lazyInitGate, + ), + ), + backgroundSchedulerProvider.overrideWith( + () => TestBackgroundScheduler(), + ), + sharedPrefsProvider.overrideWithValue(prefs), + ], + ); + await tester.pumpWidget( + UncontrolledProviderScope( + container: ref, + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const StartupPage(), + onGenerateRoute: generateRoutes, + ), + ), + ); + + // Stage 1: finding out which languages are on the device + await tester.pump(); + expect(find.text(l10n.startupCheckingLanguages), findsOneWidget); + expect(initCalls, isEmpty); + + // Stage 2: loading the app language + lazyInitGate.complete(); + await tester.pump(); + expect(find.text(l10n.startupLoadingAppLanguage), findsOneWidget); + expect(initCalls.toSet(), equals({'en', 'de'})); + expect(route, isNull); + + // Stage 3: the app language is in, the worksheet's language isn't yet + gates['en']!.complete(); + await tester.pump(); + expect(find.text(l10n.startupLoadingRecentPage), findsOneWidget); + expect(route, isNull); + + gates['de']!.complete(); + await tester.pump(); + expect(route, equals('/view/Healing/de')); + }); + + testWidgets('The recent-page stage is not claimed once nothing is left', ( + WidgetTester tester, + ) async { + SharedPreferences.setMockInitialValues({ + 'appLanguage': 'en', + 'checkFrequency': 'weekly', + 'recentPage': 'Healing', + 'recentLang': 'de', + }); + final prefs = await SharedPreferences.getInstance(); + route = null; + final l10n = AppLocalizationsEn(); + final gates = { + for (final languageCode in ['en', 'de']) + languageCode: Completer() + }; + final ref = ProviderContainer( + overrides: [ + languageProvider.overrideWith2( + (languageCode) => GatedLanguageController( + [], + gates, + downloadedLanguages: ['en', 'de'], + ), + ), + backgroundSchedulerProvider.overrideWith( + () => TestBackgroundScheduler(), + ), + sharedPrefsProvider.overrideWithValue(prefs), + ], + ); + await tester.pumpWidget( + UncontrolledProviderScope( + container: ref, + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const StartupPage(), + onGenerateRoute: generateRoutes, + ), + ), + ); + await tester.pump(); + expect(find.text(l10n.startupLoadingAppLanguage), findsOneWidget); + + // The worksheet's language lands first: we're still on the app language + gates['de']!.complete(); + await tester.pump(); + expect(find.text(l10n.startupLoadingAppLanguage), findsOneWidget); + expect(route, isNull); + + // ... and when that arrives there is nothing left to wait for + gates['en']!.complete(); + await tester.pump(); + expect(route, equals('/view/Healing/de')); + expect(find.text(l10n.startupLoadingRecentPage), findsNothing); + }); + + testWidgets('Reporting a stage changes the caption but does not restart init', + (WidgetTester tester) async { + SharedPreferences.setMockInitialValues({'appLanguage': 'de'}); + final prefs = await SharedPreferences.getInstance(); + route = null; + final l10n = AppLocalizationsEn(); + int initCalls = 0; + final Completer gate = Completer(); + Future countingInit() { + initCalls++; + return gate.future; + } + + final ref = ProviderContainer( + overrides: [sharedPrefsProvider.overrideWithValue(prefs)], + ); + await tester.pumpWidget( + UncontrolledProviderScope( + container: ref, + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: StartupPage(initFunction: countingInit), + onGenerateRoute: generateRoutes, + ), + ), + ); + await tester.pump(); + expect(find.text(l10n.loading), findsOneWidget); + expect(initCalls, 1); + + ref + .read(startupStageProvider.notifier) + .report(StartupStage.loadingAppLanguage); + await tester.pump(); + expect(find.text(l10n.startupLoadingAppLanguage), findsOneWidget); + expect(find.text(l10n.loading), findsNothing); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(initCalls, 1); + expect(route, isNull); + + gate.complete('/home'); + await tester.pump(); + expect(route, equals('/home')); + expect(initCalls, 1); + }); + testWidgets('Test failing initFunction', (WidgetTester tester) async { SharedPreferences.setMockInitialValues({'appLanguage': 'de'}); final prefs = await SharedPreferences.getInstance(); @@ -248,6 +445,9 @@ void main() { UncontrolledProviderScope( container: ref, child: MaterialApp( + locale: const Locale('de'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, home: const StartupPage(), onGenerateRoute: generateRoutes, ), @@ -282,6 +482,9 @@ void main() { UncontrolledProviderScope( container: ref, child: MaterialApp( + locale: const Locale('de'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, home: const StartupPage(), onGenerateRoute: generateRoutes, ), diff --git a/test/view_page_test.dart b/test/view_page_test.dart index 5faa959..c4a7a48 100644 --- a/test/view_page_test.dart +++ b/test/view_page_test.dart @@ -43,7 +43,7 @@ void main() { expect(prefs.getString('recentLang'), isNull); // First there should be the loading animation expect(find.byType(CircularProgressIndicator), findsOneWidget); - expect(find.text('Loading content...'), findsOneWidget); + expect(find.text('Inhalt wird geladen...'), findsOneWidget); // In Riverpod v3, FutureProvider needs additional frames to settle await tester.pumpAndSettle(); From 5f38a3a1740efe3b5e904cf0a73a315398f32454 Mon Sep 17 00:00:00 2001 From: Gabriel Sousa Date: Mon, 21 Sep 2026 18:18:29 -0300 Subject: [PATCH 4/9] feat(languages): show n of m progress while downloading all languages --- lib/data/bulk_language_download.dart | 32 ++++++++++++++++ lib/widgets/download_language_button.dart | 46 +++++++++++++++++------ lib/widgets/languages_table.dart | 6 +-- 3 files changed, 70 insertions(+), 14 deletions(-) diff --git a/lib/data/bulk_language_download.dart b/lib/data/bulk_language_download.dart index c168e4a..3b2e468 100644 --- a/lib/data/bulk_language_download.dart +++ b/lib/data/bulk_language_download.dart @@ -4,6 +4,26 @@ const kMaxParallelLanguageDownloads = 4; typedef LanguageDownloadFn = Future Function(String languageCode); +/// One completed language of a running bulk download +class BulkDownloadProgress { + /// How many of [total] languages are finished (successfully or not) + final int completed; + final int total; + + /// The language that just finished, and whether it succeeded + final String languageCode; + final bool success; + + const BulkDownloadProgress({ + required this.completed, + required this.total, + required this.languageCode, + required this.success, + }); +} + +typedef BulkDownloadProgressFn = void Function(BulkDownloadProgress progress); + class BulkDownloadResult { final int successCount; final int errorCount; @@ -19,13 +39,19 @@ class BulkDownloadResult { /// Download or update [languageCodes] keeping at most [maxConcurrent] in /// flight at any time. Uses a worker pool: as soon as one download finishes a /// new one is started, so a slow language never blocks idle slots. +/// +/// [onProgress] is called once per language as soon as it has finished, +/// whether it succeeded or not, so a caller can show "n of m" while the +/// batch is still running. Future downloadLanguagesInParallel( Iterable languageCodes, { required LanguageDownloadFn download, int maxConcurrent = kMaxParallelLanguageDownloads, + BulkDownloadProgressFn? onProgress, }) async { assert(maxConcurrent > 0); final queue = Queue.of(languageCodes); + final total = queue.length; var successCount = 0; var errorCount = 0; var lastSuccessCode = ''; @@ -48,6 +74,12 @@ Future downloadLanguagesInParallel( } else { errorCount++; } + onProgress?.call(BulkDownloadProgress( + completed: successCount + errorCount, + total: total, + languageCode: code, + success: success, + )); } } diff --git a/lib/widgets/download_language_button.dart b/lib/widgets/download_language_button.dart index c0810ac..7af050a 100644 --- a/lib/widgets/download_language_button.dart +++ b/lib/widgets/download_language_button.dart @@ -70,8 +70,10 @@ class _DownloadLanguageButtonState } } -/// Button to download all languages. Shows a CircularProgressIndicator while -/// the download is in progress (that's why the class is stateful) +/// Button to download all languages. While the batch runs it turns into a +/// determinate progress ring with an "n of m" caption (that's why the class +/// is stateful): 34 languages on a slow connection can take minutes, and a +/// bare spinner gives no hint whether anything is happening. class DownloadAllLanguagesButton extends ConsumerStatefulWidget { const DownloadAllLanguagesButton({super.key}); @@ -84,17 +86,30 @@ class _DownloadAllLanguagesButtonState extends ConsumerState { bool _isLoading = false; + /// How many of [_total] languages of the running batch are finished + int _completed = 0; + int _total = 0; + @override Widget build(BuildContext context) { - return _isLoading - ? const Center( - child: SizedBox( - height: 24, width: 24, child: CircularProgressIndicator())) - : IconButton( + if (_isLoading) { + return Row(mainAxisSize: MainAxisSize.min, children: [ + SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + value: _total == 0 ? null : _completed / _total)), + const SizedBox(width: 4), + Text(context.l10n.downloadProgress(_completed, _total), + style: Theme.of(context).textTheme.bodyMedium), + ]); + } + // Same footprint as the other header buttons while idle - the table cell + // deliberately has no fixed width so that the row above can grow + return SizedBox( + width: 32, + child: IconButton( onPressed: () async { - setState(() { - _isLoading = true; - }); // Get l10n now as we can't access context after async gap later final l10n = context.l10n; final codesToDownload = [ @@ -102,10 +117,19 @@ class _DownloadAllLanguagesButtonState if (!ref.read(languageProvider(languageCode)).downloaded) languageCode, ]; + setState(() { + _isLoading = true; + _completed = 0; + _total = codesToDownload.length; + }); final result = await downloadLanguagesInParallel( codesToDownload, download: (code) => ref.read(languageProvider(code).notifier).download(), + onProgress: (progress) { + if (!mounted) return; + setState(() => _completed = progress.completed); + }, ); if (result.successCount > 0) { // Show info message in snackbar @@ -128,6 +152,6 @@ class _DownloadAllLanguagesButtonState }); }, icon: const Icon(Icons.download), - padding: EdgeInsets.zero); + padding: EdgeInsets.zero)); } } diff --git a/lib/widgets/languages_table.dart b/lib/widgets/languages_table.dart index 6db05d6..a0fc4b3 100644 --- a/lib/widgets/languages_table.dart +++ b/lib/widgets/languages_table.dart @@ -96,10 +96,10 @@ class LanguagesTable extends ConsumerWidget { style: const TextStyle(fontWeight: FontWeight.bold))), const SizedBox( height: 32, width: 32, child: UpdateAllLanguagesButton()), + // No fixed width: while a bulk download runs this shows a + // progress ring with an "n of m" caption next to it const SizedBox( - height: 32, - width: 32, - child: DownloadAllLanguagesButton()), + height: 32, child: DownloadAllLanguagesButton()), const SizedBox( height: 32, width: 32, child: DeleteAllLanguagesButton()), ]) From 52c3ec722ac1294c1b8af8de159c692d78b6b10b Mon Sep 17 00:00:00 2001 From: Gabriel Sousa Date: Mon, 21 Sep 2026 18:19:41 -0300 Subject: [PATCH 5/9] test(languages): cover bulk download progress reporting --- test/bulk_language_download_test.dart | 34 ++++++++++++ test/download_language_button_test.dart | 69 +++++++++++++++++++++++++ test/download_languages_page_test.dart | 52 +++++++++++++++++++ 3 files changed, 155 insertions(+) diff --git a/test/bulk_language_download_test.dart b/test/bulk_language_download_test.dart index 76abc70..a83ab4e 100644 --- a/test/bulk_language_download_test.dart +++ b/test/bulk_language_download_test.dart @@ -105,6 +105,40 @@ void main() { expect(result.successCount, 4); }); + test('reports each language as it completes, failures included', () async { + final reports = []; + final result = await downloadLanguagesInParallel( + ['de', 'en', 'fr'], + maxConcurrent: 1, // one at a time, so the order is deterministic + download: (code) async => code != 'en', + onProgress: reports.add, + ); + + expect( + [for (final r in reports) (r.completed, r.total, r.languageCode, r.success)], + [(1, 3, 'de', true), (2, 3, 'en', false), (3, 3, 'fr', true)], + ); + expect(result.successCount, 2); + expect(result.errorCount, 1); + }); + + test('a throwing download advances the counter instead of stalling it', + () async { + final reports = []; + await downloadLanguagesInParallel( + ['de', 'en', 'fr'], + maxConcurrent: 1, + download: (code) async { + if (code == 'en') throw Exception('boom'); + return true; + }, + onProgress: reports.add, + ); + + expect([for (final r in reports) r.completed], [1, 2, 3]); + expect([for (final r in reports) r.success], [true, false, true]); + }); + test('uses default maxConcurrent constant', () async { expect(kMaxParallelLanguageDownloads, 4); }); diff --git a/test/download_language_button_test.dart b/test/download_language_button_test.dart index d6c357d..d048848 100644 --- a/test/download_language_button_test.dart +++ b/test/download_language_button_test.dart @@ -1,8 +1,11 @@ +import 'dart:async'; + import 'package:app4training/background/background_test.dart'; import 'package:app4training/data/app_language.dart'; import 'package:app4training/data/globals.dart'; import 'package:app4training/data/languages.dart'; import 'package:app4training/l10n/generated/app_localizations.dart'; +import 'package:app4training/l10n/generated/app_localizations_de.dart'; import 'package:app4training/widgets/download_language_button.dart'; import 'package:file/memory.dart'; import 'package:flutter/material.dart'; @@ -12,6 +15,18 @@ import 'package:flutter_test/flutter_test.dart'; import 'app_language_test.dart'; import 'languages_test.dart'; +/// Lets the test decide when the download of each language finishes +class GatedDownloadLanguageController extends TestLanguageController { + GatedDownloadLanguageController(this.gates) : super(downloadedLanguages: []); + final Map> gates; + + @override + Future download() async { + await gates[languageCode]!.future; + return super.download(); + } +} + class TestDownloadLanguageButton extends ConsumerWidget { final String languageCode; final bool highlight; @@ -81,6 +96,60 @@ void main() { expect(find.text('34 Sprachen heruntergeladen'), findsOneWidget); }); + testWidgets('DownloadAllLanguagesButton shows how far the batch is', + (WidgetTester tester) async { + final gates = { + for (final code in ['de', 'en', 'fr']) code: Completer() + }; + final ref = ProviderContainer(overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + availableLanguagesProvider.overrideWithValue(['de', 'en', 'fr']), + languageProvider + .overrideWith2((langCode) => GatedDownloadLanguageController(gates)), + ]); + final l10n = AppLocalizationsDe(); + + await tester.pumpWidget(UncontrolledProviderScope( + container: ref, + child: MaterialApp( + locale: ref.read(appLanguageProvider).locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), + home: const Scaffold(body: DownloadAllLanguagesButton())))); + + CircularProgressIndicator indicator() => tester + .widget( + find.byType(CircularProgressIndicator)); + + // Let a released download finish (first pump) and draw the caption that + // its setState() asked for (second pump) + Future finish(String code) async { + gates[code]!.complete(); + await tester.pump(); + await tester.pump(); + } + + await tester.tap(find.byType(DownloadAllLanguagesButton)); + await tester.pump(); + expect(find.text(l10n.downloadProgress(0, 3)), findsOneWidget); + expect(indicator().value, 0); + + await finish('de'); + expect(find.text(l10n.downloadProgress(1, 3)), findsOneWidget); + expect(indicator().value, closeTo(1 / 3, 0.001)); + + await finish('en'); + expect(find.text(l10n.downloadProgress(2, 3)), findsOneWidget); + expect(indicator().value, closeTo(2 / 3, 0.001)); + + // Once the last one is in, the button is back and the summary shows + await finish('fr'); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.byIcon(Icons.download), findsOneWidget); + expect(find.text('3 Sprachen heruntergeladen'), findsOneWidget); + }); + testWidgets('Test highlighted DownloadLanguageButton', (WidgetTester tester) async { final ref = ProviderContainer(overrides: [ diff --git a/test/download_languages_page_test.dart b/test/download_languages_page_test.dart index c4e379e..3d821ae 100644 --- a/test/download_languages_page_test.dart +++ b/test/download_languages_page_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:app4training/data/app_language.dart'; import 'package:app4training/data/globals.dart'; import 'package:app4training/data/languages.dart'; @@ -7,6 +9,7 @@ import 'package:app4training/l10n/generated/app_localizations_de.dart'; import 'package:app4training/l10n/generated/app_localizations_en.dart'; import 'package:app4training/routes/onboarding/download_languages_page.dart'; import 'package:app4training/routes/routes.dart'; +import 'package:app4training/widgets/download_language_button.dart'; import 'package:app4training/widgets/languages_table.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -15,6 +18,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'app_language_test.dart'; +import 'download_language_button_test.dart'; import 'languages_test.dart'; import 'routes_test.dart'; import 'updates_test.dart'; @@ -33,6 +37,7 @@ class TestDownloadLanguagesPage extends ConsumerWidget { supportedLocales: AppLocalizations.supportedLocales, onGenerateRoute: (settings) => generateRoutes(settings), navigatorObservers: [navigatorObserver], + scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), home: const DownloadLanguagesPage(), ); } @@ -127,6 +132,53 @@ void main() { expect(listEquals(testObserver.replacedRoutes, ['/onboarding/1']), isTrue); }); + testWidgets('Download-all progress is shown on the onboarding screen', ( + WidgetTester tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final gates = { + for (final code in ['de', 'en', 'fr']) code: Completer() + }; + final ref = ProviderContainer( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('en')), + availableLanguagesProvider.overrideWithValue(['de', 'en', 'fr']), + languageProvider.overrideWith2( + (languageCode) => GatedDownloadLanguageController(gates), + ), + languageStatusProvider.overrideWith2( + (languageCode) => TestLanguageStatus(), + ), + sharedPrefsProvider.overrideWith((ref) => prefs), + ], + ); + await tester.pumpWidget( + UncontrolledProviderScope( + container: ref, + child: TestDownloadLanguagesPage(TestObserver()), + ), + ); + final l10n = AppLocalizationsEn(); + + await tester.tap(find.byType(DownloadAllLanguagesButton)); + await tester.pump(); + expect(find.text(l10n.downloadProgress(0, 3)), findsOneWidget); + + // One pump lets the download finish, the next draws the new caption + gates['de']!.complete(); + await tester.pump(); + await tester.pump(); + expect(find.text(l10n.downloadProgress(1, 3)), findsOneWidget); + + gates['en']!.complete(); + gates['fr']!.complete(); + await tester.pump(); + await tester.pump(); + expect(find.text(l10n.downloadedNLanguages(3)), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + testWidgets('Test skipping third onboarding step', ( WidgetTester tester, ) async { From 413207666a30129b704f790f48e1cda23cdd3aa3 Mon Sep 17 00:00:00 2001 From: Gabriel Sousa Date: Mon, 21 Sep 2026 18:19:47 -0300 Subject: [PATCH 6/9] feat(android): show the logo on the native splash screen --- .../main/res/drawable-nodpi/splash_logo.png | Bin 0 -> 34994 bytes .../res/drawable-v21/launch_background.xml | 12 ----------- .../src/main/res/drawable-v31/splash_icon.xml | 10 +++++++++ .../main/res/drawable/launch_background.xml | 19 +++++++++--------- .../src/main/res/values-night-v31/styles.xml | 9 +++++++++ .../app/src/main/res/values-night/colors.xml | 6 ++++++ .../app/src/main/res/values-night/styles.xml | 6 +++++- .../app/src/main/res/values-v31/styles.xml | 12 +++++++++++ android/app/src/main/res/values/colors.xml | 8 ++++++++ android/app/src/main/res/values/dimens.xml | 9 +++++++++ android/app/src/main/res/values/styles.xml | 6 +++++- 11 files changed, 74 insertions(+), 23 deletions(-) create mode 100644 android/app/src/main/res/drawable-nodpi/splash_logo.png delete mode 100644 android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 android/app/src/main/res/drawable-v31/splash_icon.xml create mode 100644 android/app/src/main/res/values-night-v31/styles.xml create mode 100644 android/app/src/main/res/values-night/colors.xml create mode 100644 android/app/src/main/res/values-v31/styles.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/dimens.xml diff --git a/android/app/src/main/res/drawable-nodpi/splash_logo.png b/android/app/src/main/res/drawable-nodpi/splash_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d7bf856ed1b5dd12e6097a2add785606cb41c63e GIT binary patch literal 34994 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 - - - - - - - diff --git a/android/app/src/main/res/drawable-v31/splash_icon.xml b/android/app/src/main/res/drawable-v31/splash_icon.xml new file mode 100644 index 0000000..9c5fb34 --- /dev/null +++ b/android/app/src/main/res/drawable-v31/splash_icon.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 304732f..fb0df78 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -1,12 +1,13 @@ - + - - - - + + diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 0000000..48f8561 --- /dev/null +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..faacd32 --- /dev/null +++ b/android/app/src/main/res/values-night/colors.xml @@ -0,0 +1,6 @@ + + + + #FF010101 + diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index 06952be..5bac2ea 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -13,6 +13,10 @@ This Theme is only used starting with V2 of Flutter's Android embedding. --> diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..0003ee2 --- /dev/null +++ b/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..68e8e89 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,8 @@ + + + + #FFFFFFFF + diff --git a/android/app/src/main/res/values/dimens.xml b/android/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..fe9257e --- /dev/null +++ b/android/app/src/main/res/values/dimens.xml @@ -0,0 +1,9 @@ + + + + 127dp + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index cb1ef88..f626cad 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -13,6 +13,10 @@ This Theme is only used starting with V2 of Flutter's Android embedding. --> From b99c63135aca16cfa3a1c910e9ba7d989e5ac789 Mon Sep 17 00:00:00 2001 From: Gabriel Sousa Date: Mon, 21 Sep 2026 18:19:55 -0300 Subject: [PATCH 7/9] feat(ios): show the logo on the launch screen --- .../LaunchBackground.colorset/Contents.json | 38 ++++++++++++++++++ .../LaunchImage.imageset/LaunchImage.png | Bin 68 -> 6186 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 68 -> 14500 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 68 -> 23820 bytes .../LaunchImage.imageset/README.md | 10 ++++- ios/Runner/Base.lproj/LaunchScreen.storyboard | 7 +++- 6 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json b/ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json new file mode 100644 index 0000000..a16059b --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.004", + "green" : "0.004", + "red" : "0.004" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png index 9da19eacad3b03bb08bbddbbf4ac48dd78b3d838..0bb5cee5bf5a58f690a0e6c7b282f75826aca267 100644 GIT binary patch literal 6186 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 wM-@QR=CkYF2C}tmtQv}U<-#I1^@s6 literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png index 9da19eacad3b03bb08bbddbbf4ac48dd78b3d838..43404496bce3f4fbf7e102d36fcb724f2cdbc49b 100644 GIT binary patch literal 14500 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 U+5E{@{~@TZtgTe8VEOL<0;QcL7ytkO literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png index 9da19eacad3b03bb08bbddbbf4ac48dd78b3d838..7d26c0cc30344bba60e4b10cbc9ed42a1fab8c91 100644 GIT binary patch literal 23820 zcmeFZS3F!%`!-C55z$HX5Q!RPw5Wq55+b5U4MvGFL3A-yH3m^uQMm z4k!&52Lyb_z5ZR=t^fWN7nuh7e}2CHAbGlm77or+9EBIp-n!y$)xy8rZ+B*Qs~ND| zAx#DbK3cf@MYj_+!Og$bjOafCdj zf;|maWaO*Y(j} zMBM+Ah4H;)MTJ zG(V706gFlVu$buguzE0H019j?DcQDh5@r7SCKMNVo*a2_%YfPMaVsjx!nhXMtrtbb zf3Hr6E>z|pP+ZCM(>P~bA=4At$+32{1EK-sJ#drc-(npeVpw+CbO9saDMos5i0v0g zTKwyyh9s?bnGq3d>tjL6@CxOgHIFdMwy+l-tpbV6cDF5{Pz3E~s+$;u;(h`fMK9Cf z1RyJlTd%S+UqsZYI5pm9lg_RjNPRB6G)Ip04a0gcz83CzF1q`E>i*4TzxW?~rK~vm zsX~tRxO#rz5VNO-*d=4DbdOle_(X-ZW*n$rt-^8=E-rz~yxTl)o)1af5ae&sOL};h zG~VM}1SQf*dQ_AA;^NVR)FqL#E78x#LV-+61mz);)gN0nm2QH<4?dvt+_f;BX*oI~ zMVY&8^Q4DuQ|^||ZbnY5LD=b!KsUC_;a+MdGcJWWX!`}*E+gt4AY z^1k5Eq5v+jj_g?Ti1pB$RzBGO8^|>Ofj-UizgL{ndd`>yeCF&6zZfil!pzwQ?$j+Y zTHA}9!B}cgOaoD-RS)<7b&WM@Mcv%P7;J=@a20h(x~hxVd7n@jG`pmz>_6ABc18I4 zgmDxROmbNp+gv_n@t+`j{StaWog#Jldwhq?q`6PyY=~`SZ9lQRnRe%U$qN|2PpWdx zGCvrYa#|Acd?sJY%zn_z7X^%qtClLie$ZjtY$>siWXiC0eom1=WxsC%`qmrQ^+KEE zYKHOaM2EkJMkG^^+*MH!%2!u{c#>0wEwW)V<$vOFO6Q4Op{Y(7PrKSBPS-}f?%NgP zH3_=}y{J@PC}(#4T4pSw$Pq66`{PYA3(!_cBs=0wZJv%%NtODosYjcCmAg_#(Rc5D zhwbS1`&FQn@7lQZ01yiGS`ff#?jv)tC^oiToxl9WNkVaFg9A!))t(U~ZEAmk8Xm{L ziNT#7oaa)|p*#NW~IlC0YZEnIBhdb^la+ZoE9pI_vkgNCdxxDh9TN4zxz7GI%3NU%Ku?6ZvI31!G zQyDDUE?8AqDYbRjm#11mb_p3LdAEpEq z@1So_MQ3d8i2j>FpEhfkf5`ndN_`W{P%lHU8^<~^E&mei>NM43(A8;6Pv}g9|35)8 z1{d~rGeRBzP1oC6>y|WyqDVefJh{m@T>k!BGM{u`Cno@>2(Q9kWuY^q_4t3F?%d#+|4WxZshs^LN=C$S1C{?X!hHcFx%zO4 zsf3%7?n#HdiGmUFFa^l`;BQW8LGI1G{_h3H3OMb>}TR&MTQ!GNJ#Jn66!8`f5N zH%o1Pa065TBmak8@a=tC0JpqgE)S5f++Fn}ABl&dpqc5Wt|Wh@xTnq8Zv>iQ7$(sS z7>2ZXBg{+hJq{CcY(&^);9<@A_Ekz<`Fq0TM2T-ZDh)pzh=nx^Km42kVDupjSKtUl zK$5=xX-+A`V~;8U8{gOeMJ>Z}=9gKSdk=k07=u)B6x~(lK^+~obLV3@J?Kd*4zbu^ zAj8Kr-MJGm?`5e>M((HSXJKK?U@AkziE&N;ghGJW+@FLGNarVQp(y9WShyAOU4Pu& z4dc=F83!U>BUza%$+_Yj5*A{vymMo1-0Pk**D(@WGuDq8^Rp2a* z8-NI5w2}o>3y9|%c|s5Vz=te*$Lf_s>dy@l+|Rd$zYL}k6%>>mOjy7hM%@2+L=W9n zZWfEjSWtFynjdt%s?anee#-#j|HFS2Mnd9*EvOu9+V)8OczIL_KUnJMRnqM%bvgPt z{mR7J=%95fwQJS{|D7ikYeYKHec41~DZp^9dpxVyBO74; z!AM1gto9-VrfQ*0^906Am=Upxv#S*nS!!~I8fn5JoeNagJz;>TOZXq!Bd!_zX#BT{f zNEXJ?Pn0U9Ej|;~u+uwXNn!)P@r$Gm@0J^&$Io#@iNVbtzErnfu51NNaLrfF7$vU>3fF!kdv=n-LX_cYpGqrN$X=0amPK@TI|6PvgCq4%k@0qv@I;Ddx}ix zZ9<>K!zIoUj+C8)t>?7<`FhM++E_OrCdIrHrpo z945j<;W&W~X&_C~L(9nuqHLmTD7DMl+q+F9rsYBP`S?*`eI4tWi(%cHBItqYMkx+0 z)FCH?1pqN@dn z<-gdv7qac)yxH_l)>kOE#JL6~K|cP2XN?xLScN38qhNC%YxJwgv&F=gp8qMebbmhY{l7{nC60S1yQm@IW`F6xy9Y-WyCgzq&s?1P zde;8#!7baK>e70uMU(ed0kTHAUO+M;R-RUr0XMQ!;qyBNUQG4r-Zpzje?a+N+beLL z@hmFtel4&_hgHI?rfEyW4dcg!cH-~+Tl!_HxTyi97GfasBF5)(TCljL>fB-mx&_m#U}3I)(ED2&htkjdpe(-Xq4iTC01L zlWxC$;?hQ3rw*O??aW>9`EX>`*=6rTKt1lU-Z5IN_j`>r8X)T#lAG%NVZI(|ml42bcOB6SMbEN0<2Zcz?*9DwG$H zGDG6=0~VBb*%X<%w5(JCVB>&|`i(YA>MmY{5A<2c%4}P~G6poV_r7g>)r8c&I#N;WG^9aKCH1iu~#KK7(^uYoijOC+g$o>E+@lmtJO(sXAa;}2jf6l&n0=K zauvO@+qUpX+yzFo*On16=to3Ti17Stwb@9-Pd+8nNSK(DSlP$Q8Ayl3rzB~j4Kx9+ z_7V4WTwIlwuLL9}IKTn?*hZaF)1ta48R`@mv(HeaQ>xM2A9DGO$Mu{_>^rH@MU(1f z1|9ZdT35mJK_o6A#>h|6*N*y^g^WF>=?{7YWq)ex0q7E@9;~3&+jAc<^XjU&@vJnH zO(GC*XlrO4hnLngEe=o_8YK>+{-L9&_Z`|C#eId`FHREJaxyY}VxYUe!2!&|2Wq~? zXYA&Cah1%i$`w}T2c^4a)nQ&%v|q@3$!9HGL?98xYXLGp))^~pKeT1EPsS)+?6^$j zX?`WMGtf&hU-R;UE^MVPIYRLTY*Q6>fK+E%(j7Q6!(n0kqhpBKV>g^}pa`&I-$6)q zbfJ5d32Z|G5eK?0t>Yj_9GO2;3G(7&<3;@`)YO6K|YqAM85uGmDJC6*@b0 zEma4&jjx@5IGwCx-KmmZOJcCfViht|gp2FeVVA2#erdLHrgTG?&_bg~)zUN6#gJ4B zoRNc;k7%EtP`o7+MLL-OYD);m=awm%&ovQ92%yO=R#2cjj^};cWdroT=+%|xbLB{3 z(-aq((ECS6J!=7U4(HZ#b734^njFLpUmh4nR38jUpDO6J{2lE(y;y~F%H%w$ZfZ!- zHvB%5DDqOe!C7F54-r`vGme^+NoEaotFw@+Px(AIIop2y>@W|ho|PwmY4)2ehW=Ig z(4X+?MUm01{ri`tJ<+wjLZb0PpU$C&qe@NQ=s|@?FjbbFo^`6iAD{^fQJPO!1pu4@ zBM`o<^qShxfXJddl&*Z})pE?{#4kUQx?O)D@96!d$MNPH`&SN2Rrne@mK{zj)iKQ+||Zldf<439pc_j!uV%HtU>l%rp@SA-bpf zr0;aES*&)ye#>>;jykyYdokBfp0yB#*f1N@UPs|#8`1<>Z!h_f8WqR<~o|Q<$2<|9>)0zx&9$f=sEA=TcF7`N1hD{ z2OcLs>F9lGVZp%*6uW2kPx>1L?N<@S{q6E%y4Et`BWr)n4>AxXKQ%jXh^5fyYC;x+ zN{mgqEkvm^c2hRgO}m?YyxiJ@E6ZKU%=0yGW83=)?AaEOVReT=NujmjZ*N zzIK7TNCu2sN;hB&fL;IBNAR=Qx?zSQ`P&u`io#6O+ZzTz1eQIDki(E=i-KoEoJ7>& z7UIbmU;YTh`J3m#aRVwWe?HPg;Ob%=hVylWW5=3wFRB z>~&Nj;)ZP`5TettX-@R#C_(NY&B%GtAw^SowEbw;h%h^MVucWNrriapjVwuS<3D~p zkA`V%xuMQo#y)Si_V(1X3wgW}>c?Ctq?Q_C#+Y5ko?Fkc#sfg@xEPe*rm;8>!W}|eH$q=@B*v^!}C(Ul7AK5CFEf7ybG%Ibs+c4Em^o|-R?j`_G zi3MD$FNj~=&d$vZ%9?RhDf(U?v^q}qn-pdmJ%n(dAHR!)z|4fwIQ(1zhc%xSD)uH5I}=D5qEqIs~-8%*&r@* zq?qcO4v8r;OUg7ML30xO;$ya^{t+}eT>%CDGHLu9rQib=#_r5im+jgz&$+ndu30QO zO4Ul85j=`MdwhSk{O8!`aKS=^FfReA$$8*kQv)Vils(VM@X}*o3O1b4ez@+ZU;LGl zUWo$zuPpAZ>Sk}kK|t)OByxuHBkqP6hs{vBgb~^pLD1A#K}MKu-2}~K2T^LPD#yszdjucxwC03u zWfC-n&IZuqnq*+3O-XU%mtE_}Gx8zghBB*;sTqxEeQ$+bI@d#h2Nu-U&Vlr4dP?cn z#NNOdUC&(=$K>=q{Cn1pS6d2~-s8Z8Y27!4B#)=&{u!KD56a8D4y)+1R+j66ZQ?;H zcY3!`u^nB<09v&^7y}iahiNlgGB~RBDBkxcz-!`)O>a0s-TSe9%C>z(ROBhTl+s_Wa8ls}rdWIx;8HcyL@okx{ zfL=@1Rv3slYx2EsO814FRI3V&>6L^_Gn}2GxkvseM45Drhi#b7hqW_jcR>wG#nWm+ zj7i?BKnw7wH(K5>wH(RY3EIvEsA z`3>)}MGYMf!MdJWVLZ2>I-HM6khDlDz)k``r2V2RmXWQpO@w))U;N-50XtRVF= zS7o5#na@U2{2Bc6Yz&r-Wp@8kWsag`&}*Y}j=n=w#UZY=Bpj+C_4(ZV0D&`{@#;1K z&{&p7cxh0plETKbXty8fg;Uc*GfKA53x4C;8~Dt^9s-7?keZ3wc8Tp=w6CX z#?J@u1O8m2a+sAm>~?l_eAcux%?zM;7ywFS&7;LEnkx@>`8fV``w~Oe`#qV{Cn2`d z!IY)KcD7FHPmiSG&%7j{AOEsX?U6%K*glrtI-0Ucoygm09ppS;iBcKF4{&x7CK1+4 z4~oU_LL&h+c*=ec?;b#boQY_TKC1rm9BZa)|J<8E3o@GL=^NEOoOVPG31{eZn5brS z5)|FTy}7DUK$mJ2;B4${Loxtu=zd4miyKMbg4#%V+9_7Q6r(-5(g6Vk|f#jsna|&pyg9ya6A~04+?soRqY$M>l#$X8uEKA$t za^H&2icZs$`o5ycxoc1HEHNN1to)JANb({k8Qr4ahgy0KLUz`_z!sxNYXs&_e2kulbCJ z3`vR7>%3x7?u%_imT|s}A7jZf~LOH#gdj;Vrw&j6JIjWjl=S%(;U~$;eX9k-0-QW^~Z*m`7kc z7b^Y9sTv>1)Esd~OTNzsA&B$Fzci3Khk;9a5SxinM-l2}!D=6Daao@3S?WglPMKYQ zj4;o%?W!+LMV3RJ^UV+r;xC?yfy8`;;PPCyGWqBi2^G8=7XQC(*dJSoujPTkjB{?F91St3dn# z@-uuxex}c=VJEjp7({!iCW0A2r*z}J8p4%zSQq2jh7tb$CVzJ7EZfdEOz1O9Iham` zsm!&gce-#*Xth6UML)V1^MtAY{guY~S2LQ<-iASrlr#|(7tySh)_`E1o>+Q4yCE$A zz4DE%M9oOG=-t~8oHi!G7+Eo*@qSZ;uf6E3>vB5w3HVM!L+&H_HJjP{++8$M)_BMS zH5;9Nni8x=61#ogOIw*P9iuyM&VaBJ3=$hs8#f-;&oMO737Us~H6^|q?p5h(T)r4U zCi(ePPfm}Bz&D0Ladz;fs>_3HJ)sNp1A#n7V9AI`Qt*xbHZZ2nssq$vz`90GAfT^DM-7hPljH`r&%+}E%>5M7bjc!{^U!0bC#>&TC z71$1p(+NFvm|`EA38<7rlt$-35aWMNEe(5g>}wV!OaQu{S3Zs!cHLROO5o&X zVFb{cEXtwVvg^hZ#Xl~>l^b+f5G#l79TJvwefI)weK%T6mZp2rq0rB#rZeoD7W2PH z6(0UWFO`fcU~R(ud}ou1<&vZi9jc@1@A$(7LtGC7KO0^RL=3uzkMx~Sj#+k>!Q)Hv z0Uz=%IA$L-9V29MhB!qWc75Q9+Taq^nmtn`EkSlKHM&&_zKc-#skbq?mO+&mK(?Li z=R;8HDCRo^y(?)B)ZCG-rwow1BkFChMCXj?9O~64aW5MN2k(SmgT@Xe z6&INBx}JmnahyqhRGcA{*za!*DRWqCn?-b^e~Xb|oVN&eo8M6ZPUwwbgk5aS4K{8~ z34PAukJFPYoz}_Bf&9T2QIof>PpC!u{<7FB?W{RGsKySLS&$X19*hFLMin)&aTYJM zE;pNChuc>VYv^KDS%oLa@=SyB-sJgu0UCB7#;og%X+A6WzOXeMG4#*M+AZxIg=YAzsCh4jvn?sI z6F>eAW8atYGk-@JKOxBV7}O;e<8e7Ho-Jw@MQkv7^o`-?^r077%aMSs%$Awx67MhJ z3t1bbXFV-Xz@(^(f<>nk4Z8;&`AlyXTRhq*Oxp)p4NX_gFY)b&AXy^E5to7NZ<1ew zTwf{yfw@hbBxUb?36kXCAY}|Bk;QATD(YQsd;AG$j54A!w{NNl9-*BI;5|RMx=1~| zKl_Yhq4gg6Lv6B#Dt|-qKQQG|)A~@E&B)`)ru9>gGY)#k8E4_e(Rc7mgHMP4J|CC1 zNZTjmRVrVR{h%jZ;sn{v7NM)0{+FA*-XBad1o+vau5ekRmnYe=^L zG-Ig_)i2iHtEzYgBBCv(a8<0-t3~dxi4d`DOrRwWsZ29fC?6bO!@Y}~3GdPquu zNp1LI-0W65#ccXC(Q9iPE&DDOvVFK`&|VxudJ{NZanIU@1gPDru(R`kEVs%G1Cl%_ zyoKRYfLKsvUv@m+72f->D5lwzfBh9UWvN4yhRb%hWiFCi{%-ayui}PW8+%Kl9@#rG z_=!vjK_*>E4LB9D*7Z)gw#4l3m1;BL-PiN^@fi8Uhgyq~v~ckh=>O1lbAmNsc-j6^J+8wTngXGPl;iVJg^Y@<|!WKRf0y*IRppqya%4>9z)Y2 zVn7r)TH@zbnd*_ET-w-9*??$qX8djR+o*cUK9>klR{U%Jfh~7-X%fF*=_DFiBAf4q zBnp+DZG9PM!I53{jQmq_h!_z+fAe#5-K51_m+f7UF_g9q;x&N&dS-oeNi&1|v@MIF zyNDdOOyPZRLhMUtxms%*i`|QjmvRrYXB=+D?_*OT7h2aR;)alJ;%m(__HaSu)X!fd z`a)5j22QM_z5bOE$#w)l{X0;^?20# ziY7#-V-3;Oj&NECov{hAwd*3uWH}1CiN4_UPj54ND;;aar^AQP-+0c%%L8L{hfz$~ zVtfN?MwYe`2Uba`0RBavxO4Q${tms#W!$@8r2NC&G9$ZLI}|8ITB}^1&ntBFO+r39 zk>mP2_Zqiv&nsbaFA!g9xsrZ+@-oxMj|D zw;$TOa)w&qhMeBVh2CvTYyzFqcq`Zld9ulh)E();;`pca!ggs|oEjZFs9N4gj5Q55 zc@;k6Ndg*EIF;de9`y|&(o!`#V5=arl^O47bXQnKbhcppN_KQF3DdeWN3|yR|NSnD z8X~&7=o1b$z0#oO`;o{m741RTy^O!qrqm?a&R*WuAecfr-DDfwRrI_J0yT7>+VozH zY}X{?<>5^dRyJOvm}2fwC{-SQ_qc>Bk)k_&pWkQIKzS=kUvlUPvusu{k=9!#;^zvt zslwvLN{*F`c^CuO9%c0<%9|o8z9={g^>_X@!O9z6t^fHqgZ!SHv-_p;+s$(QEpo}9 znC?&id@f=pWGoD9Cj2gS&G{XmG3fcGrv0T*5RsN16Ybf2X&^O9!J1=c=?qJ}0RSZ-Kv5*_@>0rUrkZ!y@8Hq)~aRtO~ zyw2)3cYI%yq`+pzBZ|A?O}Rcm5y$I0jj61fuU-mLXFXBDA@ft&xx|$huf-#$-O+;Wm;E@XgvuZ1(XtGQJa)F4#XZ^0JHRbG~*hdhy}72n}&8^jY^0M<8a5 zYa2d(zbLPFM$+2>+LzJ(2v3e#2r<94z0B(KJlwtG-A%E4{IDKp=MBzaYta_-$vkMX zYyB{QX=8?e#h*^dQu>Iz>ew)$*@{3ZNqUCq4($6+evjP?b0L+H1MFT+C~qvZuw__~ znpkQS@Ko#979>9g&q_nEEmQ4pQWTSf!39MAicDkvY(Q_^`MzU@8Xze3c5!e6Y;Hd} zhfNPb8BkTc2O-bOF=)k(7y<1ge%<6W(zUm2F&`GdoFy{5p)u;#85}+80Y5Gs)`sX< za;$7NF9u)&wm9uPFKOe#v^Q&8BdVn3R5-k7A10#g6k$ z0JT2=I}~vp8V6(S%jYD@ZywSD-Kt9uQHzZAT8ZR%Uj9chL8lRhn?v07GXL}PJ-x2n zco-!^kC0EXKHluzl=jcJ-zi_3b!(3k^r?xAlB+EHn%}l07ZIoaqt~xKGk=WB!!%)(ss$G zO1pjucii~Q*$oNmS@O8Gzgxk7MH$y*dxxwdY`F+Cy4su>PO8_W$k~1vVU#^1FC7c@ zUUe958fqH&nDNsvjyfnFT1@xyp3oVo6sS3((rI^ym#<=5f>VEAJSX$4wI`2e?@>p{ zvsyC0Q{4t>WkpxG=d2Xo_`W=?B`$yfDZHI8t`GWX1v}as$0&> zARXzNl4qq)3W>EW>O>I@g6-fm-C}M`QLsOjR-UqO*frOG|=(^!NCqquxqVFC@M?c|Nb( zNQ=S=4O_NtJI7@tO(-|Q4O_OrV;>-9oGcHygk6pdcXAgmFz0u<+I3JFyu148Kav{t zgL^GQJZXN5hONTZc8UE^r=>Qn(j#!214$V9@8wet!^aP`n39)bJuk|vF-kI{`-202 z#Yo=UdfY7{JJ^MwzFyw9eQX-iddN)Kg-A{wvfe4-dz-Mdo+|dHIoLGo>hOW~!j|a~ zYyfZWWfU>-Jg>n8nzVhR9}_9q@Y4KV()@$q?ln8L>oW2f1en6EE&BUlL;92M@3LjS zq9AcJ)w}UHJi02oe~i2y7rVoBl7sS%zYx9n1oE(4=lrUp#^Q$+9$r!4h&n7ekFA`{ zAGuC(VkuN%*dnVOOhPI%XK3r8srZuy7L8#VoUt9E`G+zD<24SMpw~z0*V6!MWzpLN;3(W}xUIBGnY740x9l#%LV)4fC}^QH@T zNV~-h=g>@9e4vSvkrBy8Gzy*-99)tG*PLqitEZ6onX38@&6lHBy_cuPyrZ-ujKmxF zMhN3F8}4k-epO4=y@QTBQqAJA`k1qapIy9iFW%i;$&b*QRydACS)t8+$Q$kv=*Y z7evpYw*{(zP@ff8mLPjd?zVa;88+YL;SsUB&oQ>HgCH@!>*c*H`Jmh`Edxx=KPg@q zQI2{O*t_$KW*d6tzS*UtD>k^soU2m2n1jJ@((nF^_EZnxk+lm|IZz?!`q8K^0#CuL;a*1CJE1&oAN! z)Y~Vc$hTyNt^}Ljy(Ey4S{l$gMb^7S_#95m)HrnAx(z3Fya_;+7F&7}h{q$qo%k5Ymb9I4$>4@#ZBb?{HR*wJQ)E1}aEsea8rbXHOpj`vA_#So z2(L>ANQci+G>8baw>eQ-_Kn0ts*B*K)<8w&T<_h;=##7G&yg`bu?PALiXbvtda=Pev<_#$I4bkn zQdv_V`#OGU+4kpbO$}^1`;7I26$Yp1woc$*=E<@k(yeh8rTm|ZQz|k_9IgTRIgo6v zD@#n8X>ed>u10Z~G>AfirNU#9;Kg_A;VN~{0?6$zVt!FwJVZT>oI3Q@G=xMoN%eMK z(mEy2GvJzj_Fq?*Ai&`ZOv#TC8Q_u119cK4;N@ygk&n>G3;)*D*VkL>qpeda36+rh z|K26fTN1}TG{JYC+}Ze9=cH+HdK}A_iA585tuRzQQ$`YUNyONms~Y|c^QIalEWy6+ zof6Gp{`WO0^%*O0n&ta4>z5FW;=@}^pLU2-O3_JnwXeuVCx8=J8&0mCf14DN2JE>Q zdNp3~3~-5kY;yaJ;=|WcpV6`Wp$@no*dHk!b}DGBfIhfXHKjvmsU8<*KO-xR@^*~a}F29}gCB2?4 zGo>gIc7A@24+#q?J4e{77T!EsnPHC|^Q<%!3l&1BD$U~h{xaqg)Y2KN>a%Q)VN=Wr z3G*mWO>Gp|DSL5UNw)?U6hQ<)tnxXm3EjV8^I|(9$&xhfp$U8qFLX49dQxlgH{AJ?^8f>x zbX-kuI5=C7DI}cDZAI!sxeAf|Bt=W}DVy?gIG`J1XH}sOvmy?UGEB|~*Xc*#CeN7X z;epiQYkR0LNJnw9N00l?=)02d$q$c9wP02iY8o7LK6UL?M;HmfEcW279dde-k=mvj zeh!eq8sPLoyR#eVNnPqsM5ljOpkW_JJ^X={n$nE37^Ep~3hY<-NTs=kyOwn??cNVm zRRpViE$;kI1zsz$nOXxJxM&UZbdw}{)BEBv4?#A~8zbhNy z7KWlE)1}XykeSrco%|YIsaG95$S_7e+qg%(e0>n4wB>|oF9U+x3x;8#q}_>k*) z@G=_RiLqjsTV zrC*r|f8T*#GZx4YQYRjKt+!L9hE%nYoz+WU_x=#sG8;1`#i|2`#Uc&b{2QCzq_C!! z5{I)oP9culMWNszLo2X)l-w&85FaUg(L9IcGI;K4RP8X z%n$AdbtaO?0iZKE7ku#EbE!4j!TV8_gH)ed@m@4ZEEhgCctb?wEOtVu5MQ^K%&u>D zkcbb~puUsaXiRbVxKcSYhin$bMZ;uI=SaVpQ7ABvX$AxXQ<(f$J&=-}-N83?Q{yy~ z#eYKKUx1n7IO;JMcmZqwGYB|t= zXB2r~cir1D5YkR7U@@vT-Ugi&H@?oj-sM?46}(L>CDL9s`<;*Oz1Z1nu3~rR=LnkG zCd53Qsc6Ce=C1b7O$^DL=zfU#$ua?9y31Jl zh-fhxrgs7~F_Pns1S;CLnTkzmZd>-}0h{QEpyu2O%ijplP$ytVmw zav{nd`nKC^X~3$zlg<2PTDF6#|LOXPXLk>I_toNKq6#B3E~;2<05^6A_pYs^LN`kj zmX-{usP@o0E0xE~QSs2`qJ>k^4?VcxZ^Ab&9bT^drIKb1YjfV*Mb@VywQ10_kz_`1 zslz=#ma*VK&5y~Q2B~)24!$4xdv}id?3*lj1u}|S--8*)#!>V?R-S%=Jk?9)J2oiU zTO;$SwwRHSXbp+hjMA@jkB^Z$()kCmWhzWRvENF}`Gv8%)*|VtMB01^Jjhl~*uA#q z1gHW0_4_J-3WId@bSYLE(b(@v3nu6G&c;t)1$QmF8Q3-XbeFsHr*g+w)je?8VFVrV z1SnJ#7~V(gpX^`zfxop1UMD9qTL(h4)pQ!I5PdI;(94-g)TBFjMD1es9aP#P?zQ?l zIr39Cy|_ov-Tymt3q0M_AOS?Ic_+p6Fla*m8eMg_h<7SWI%D(&-&olol_yR7sQrQMol3g-wnY`=d3dZ2~;9IU1?6*D{6${sI{W*}tGBw!&eiej3 zx%1#KcY)H&(V19+?y0fBE%~r!VZ*IDO3-Z$yjrX}vCf>Ae^#)e!nG*|r^>IosPeeH zIL*_E#AQjo7XcmYo6jy6DBQyQ{SShu!?TnXbkaYmr|{TNqIt{~_JC6ms?Afj%S%(h zyBqekn*M(&ry}-__TNH4-Jev`K5FxH#LCav#pmmcEB##iwH_wwX2D?mv963vo_V0j zi!`%g51tr(uXf3Ccz{jiZFP>7w_iq)rSVIQr7I9hofhcBam(K3l3?9U_=a3O}e9-7q77+W6FK=_M z=G*-GVl6TAE{03Lh&tw41{CT<_EkzpLmtM2C>K-MIW{o}epf0qk(>LLOSvKoY}+j@ zY;`p>+!Xp$TO=n$r|!&j-tgb%53)Px;k{rOHn?{e@)c?`vY-&P|9b6hkVKn1(qR!MwpP>Xb`im5W{@Ep$3AmR@6*)LFxRi}Yy>kGrB9R0QSK+{k~zBB{9 z8-njrZNw6xC{2?R9J-wDAr?^t?7_-(Tlz_7JQ9fF*xc&!967mlQ-w2g?&>tw7^@!d zMG|uyZ4nvrwVY1QU8RujMyqRQ!0r@!u0L8H&7D3p!^k;xsk1*WnxBu;Rvm2zz>yk@ zSa#q)^ATt127PU`U)H#+_l^?z!wS1hkmkwtV-vHlG2N*smajGKNg(5fH0aU{o+MNJUTD32&zeuI_if@U zJY{2BnIbnnA0Dy5z0pU@*FLmo>G7#C{OkKMf0DAWPbaVSo=;@j>QDXf`{s8nJac2h zk32dK31yQwV{G1PUKLSB0B1-Cj@Jp7e=Tf1>h{@(KJ)?o7xMf(A@GI?I5j$SP>syySRMM_8$pc+La&K7rwF$z$^9U5vgcBT z{&UVdiqdl{*L~oj4#J+_Q^k5tnc&mrAENJi|CTV#2faleGonfwli3~LAX;orkrGe- z@I8!K^UWx#GGSrXzyE{hFk#?$i)DF_|KRp^!%jA^kIAU;_*Dvz&qGp`;)Q>|4QUuj zlKDNS#=GY9zT_ruqjzp4n>3Tv3QrO!`$3Y<0|Cln*BA*E`w%#PkMQT9M5EmMWFQ4# zrg&VYX^)H#N-i9tF`wux0fdi?wmB4t@-iP#P8>{Hebw*4Ixs?ZwZd(88LITau#iu@ zjCO0+rL0^Ak4T<`0t&l!DKUUc8MtM!3sfSXLvKRhG$Pi>8U_Z3+b=|N6k4CSJu$|3 zafxt>e_nc3MQ4V+Xart1iAsgAN;N2H-FI-)-0Dtmhy>f=_oWVRhFI<5$c+$ghC0JL*IZaS#PFIs-v$W(0e~i*ea-cZ)hFATEipa+Loj5DWkD3 z>;}Q|^lW-!jTOryg!_kNdx~o9kBeDl-&t|fs zbrd!a>N(sG>xZuHY~qhjkx2w%9lu7IQfl@Ve)|f%16kL#A6!HGqAt?xLDop7AAXXf zMc<&ytYqc%9$z1$L`xTEdqHW&gq#sPFhrRT@oOzTQ*vXtY0%m z5kz37#jD@$E!KrGz=>?9IERGGCv*Hjw{FbvNL?qMF&Eb5C=vE$`)UrLXWrpA>c?!E z-s=^6BXssgc>rG;kkh8(hsPY}R?K4)SjRRPu0J?@I4$g+UqGX~!?xjMrCG?^cYBNK z;mfdjQE#R2u1P#H{EP5HaOx39G@Siv*y z9s5>p@7hIbd7Q31U(M+DowR!?=z7cyj=ZPy7_voS|EQLua_|>wQcIp&qvkvvs{S)& zqqPT=_g7C(?aAyK;_7j&djm-6Vk)HuDXY}S7{g-qgXqxYjul0 z?lwzBL_8Fp{4c$R_(3chjtKU~2cZKN&$1QKte{x}ouhdlWlRPCr{(1ko?RRq#t+y3 z7r;v1^r7Dr!?CU+Cf(7-D@7(iQOe#)EYyyo8vTdQjCz-re$ z`DJsH=SOkpyrti8o8{c^u5@ph=KT>tXZjxtI}KoO6Eh0o>%a!seMK+)H?ZBt!0V}> zAHBx!RGF73rDe$B^qI6ESI`Iv_iHrVV55tXKB6^Pu%Q?_;StqdE>vlIF{!2q0ZP%& zpnENfix0QzVhkIC_JBdM51%&vvp765!+{HD1nQ(yg+Hv-5S^fES8vb!vPeMM?++Z? z+GQ?U%T%i;iUEvh1Aw@`NN&hp^UH{^!~~Lh(<}oLo+c)5GcX>iFk9zTBUc(6`0WQ3 z_z*DN)^E$a=k?2XTcML^?IUi2F%1v`Wf9GKy%)RebNrvN;$sZ;#0Ddbq3&&HWR;9q zfN%TOx1UFq{ltNlG704^9u5@icI*1pDXTd_erNcQ2);%^KCwSOG-WQ)MjPgmeI2k~ z+j(%k7@q|G)E6f{9kI3`#gwXFYGe424a3nJX53$Y?LK9eQ-mNZ?Yw6^@!J1=<36f< zO#8GQ-}IdJ1%0|WEq<4FB~*-dUS~t+-Rx?Z`qBQos*`VQZl|LxVsNN}^xT=MVe#OG zQ?4SQ*RylBh&Gm+tn`?)>%J6yi@GLTLvg%oNY|5Q;MIlLt9j|XPMp2QFTeD3q{RaN zZZFN0>8Y3!sNKiy<7Pre14zHSmc;@WYkH<9t!;qU_ryb8`bLiH0S*;=pIi5E{?6tcGoji129D|@+A>q>VVD<1~SSLYaMtSUnejax(n>;1}hp2N;59L z*lU^Tb1(o3I`ASz=9{;M1KihP0KgNe-2d`gwCT!gd+jyv-?f#Yol?Y&3RP&R!qktRxEU`)F1RZ9&V<3K9K(gobubWP&t$ZovT*WIITz0Az3o?B2KoYyoX*XtIgIC zGZo-+xz@P@o*kNPfm;Og9cJu2DBGj(S@NK3#E?`(lb()uRMzj1 z@bYlYuq`2#IkP2dD47HWpa5%uGDM#T9J`lJ2XLid+(4w&bdX#VL{QlUydv=@Ox@eP zYHca+Gr8#cba^i;aW#j|p7^%fwRBH6)qHo}05FX?L++;t;P`b3U*JB?VOQ3(Z?w4D zJUb=ub@j2$uct2g28zIeiU^Xesx?*waN$FfdukjQU8kr%M8yAA)W41J_u~e}jB0Uc z*WLocFQKMk@XP$u{noCK5lLsq!w(;9t-1=D-l<3{Y%vm%>@TE80WfR@$m*njnY(fL z&|dotO`-4(9(Q3O&sbO3Jl&+12}pQ|cNQgG`dO3_ zOWh@=Wqdn0W11WtvDv+V?FE#p4Z@$Z)sc1K3yqGIuiO%IM0h}W06luy{GouqyYhFez1tkt`9^yTr0J5P%=fA2gy2T0X zhUSD098fWz|JBA<$2GaW|Ld8;Q9x1#ND0bBT6&a7i3o^69VDbXcL-@DB#$7X$QXi@ zw9+wBP!NHUk|PGA2MlE7_ul;c@%!WV_g+uzdG71F-*r9LY*>nUgYWDO{S%&+E^#%& zl0_S4V5B44%4mkN7A#M$O*=^5{#ex&AIzcl#E&O)Sx(OsxhmPxMs&3Lj-{XP3ffp( zzUsG8>SeQ}7qBVYy!<}zT^0Icz`ZbBk252J=~9)?An-JBotR?%9?`p}V-;s;@4E1; zZmT!tu4ZS`-r@|(Wid8EwB7E?tWNV+;)a@R6L6Z+5PO#h&kN}DR`gcufFnw ztfH2C5E(@{2UH0Z(`mn&ewW73{AoDuURdf-r`M>xrpnla^uL$*qx}!tbIS`n1IwfFw28j@{%^&CA8kspGn2t?2YEt7@O#uU}1Og z@6nML$>7b*=;F%#sk6Rb;d#tz9_3GW<+##Vxty%=kLQ0ddQ}0)OuW}l=@V4ch8?j= z(;&Ss-ho?TqN10EG@IPMh zk3R5tH`Dm^=jz%E#N|fkTT4e-WNKi?!l>iZHee)Gkrju%z6{_t;H;Ufh76z*{+s1c zj;?4(WQMC~hez-WT8t}!Am@B+>WGY`RSOD-OZMG1Ra9-&6l^dmUEA4yx=Q=314&jM zZ};(8f8BbEtG7EeBVmdGx4#I^_vY2(ul}|IeGG1DV-sm%VgHjV8!WXmh3kDK_7?ly zOx?HO3S69I zg|_Nodamm~Bw>|AgpI(7Rrj2aIJr-Z_0&?wm*L;ciXi6cwF}zqEm*2mK(Y zDzx4iKyCsd_a=qEjBJKSGW2N&Gp?S?uM`WTF;92nU~7d-ZBbgpPtc1R(3Tbg$h13R zamz3X^PJxd;|_}~!Js(rC%X@xhdK7wFMQ9A#@4-2J*gE71?-AHd_Ll;EkJ)TtRq-t z4l4_>xrNWm^?){zXKrBZsF#?uVOZTl+d_z|nQ2{O;{@A?2?)a+gylx&Cs4L+({(i^^0C<|tlS$ z1|{PjM&zK}ZRH(eWC>iUZ9?U%plCHpJ+9}B_xF^?TKky-nPgpM+?4#t7P?dOHcAnE z7RirIVLQZ>fc(zQmo+K^)(R31gH)y9dQRtsO7x(L6i`_VfWP-FzhDxcyo;OHRopY+ z5f4&-1jE0A_Qj+1#YXO$upO&r5cGMq&8Xf1vAWjw1lBbxFq43(EfyjDfIEsuGejKtV7C8Vmj*FLP$^9m!}TCmfYpalQKTu* zQwrhr6*I?cjIy(UzI-(Msdew$F#gSWOUr%k7ydRL*Q&bzcbxV4U820!6>0TaObfHH zZc}&U6ZI(#SOWTNQh5GXM}{uD&2zsBcD#Qq$oDQYkR*d&Y#Bp>jr zpRR1b8-R|ijX4uj?6z%kzae<*u@uY8URPK|k<0`JI>JT6(>|vCf#8$^DPb{g`N79J z{k_c)xsW<`V(aEUayAv9v|P&1PndOR%3|Q!&Gr9JRhb3}uaETi#G}g{yUeK=Cc5HG zW7c^#o03notaddS~yf$o#KH3|)#r?4*C!tZz3S8w8%eQu2b@W8d4Nl@mt3 zfJ3;z8-dI`0B1ldTU4zg2L5uIqyDSaTfiXK$CA#v6ppoB0Fe{a3(trO)L)mNPFzLl z6GrM&(ug*~P{&}~z4n14QH4Ma5uGY=UN9fOl1G;<>MR|Vl$rKY73zJ>Ju zX4iDxp|szDG(&E_JH%Y*I@|X%&kpV75gOBohJsyN0INncIxs_pOs+#iKr5rkX9~>L zgMJ^^+#x$vWsNXQ!Bvp*X)J~iq(DR5QIpIltvAys?~ zlCuLbGZXfr_9H(X*&h8iMTkVKA^VuY@6YB!d~fpfusIoxaT~be!Q4I#F^ZrNgDZNZ z@9Uel>BXV-Z!;D9-vNhXh7ub(+`dXeK*uT=EuZ}5i|dv~>GYDH-MBRvw8;tD=a!}8 zzMmtYFO#!*OHQOOHLXfK1;#ls%Jv`D2+&gF;Yqa92-ke8M(miTE|LM`$RW8n_wAje ztgOr>o{hGbxI$|x>~-4NYZXDXn%d|}zzmWo%*3cv3Xul)Bwp5S0>u0xl1hBpK9Td+ z)|P(o_D;gQ$|>mw{c@l}Z?_Q)0=${))jXs3*oWl)|J7>5pDzke3;+F~NolPXA*)xr zEFgJs)o#r?gsKC?Tx&cspV-`X4kQ`WU~Px_Z#G+!R`Md5T~K9urN+a>&mW^Sp9(#@ z(o@JhIvg=1XL}N;tTqhhSm5Bw5vbTwzKC%$VQ$5+l~C4|@AdMJKd((c1@UgOnEX(^ z_}=o1021lXHiiZq&IAD>Ci|GSpUH+}qCWZM5gxb+59L3}2aPq6P&lmI z3ee`wTxJ>dO*U~$mDQQ)efQIz5dYFI`AfCBW53sb9tb9l1!vS)#EO-6LN$onUoma1 z4q%7`w8mr281qt6%695sG;fvp;H0_R(T&DW6B2w%>qsI|wUghjSfQ2@%4LCKy*zRt zC^UWYx7+8Ay`4)PH%9*ST46tf^}YZRI-QMqU`#H>U6L9NVVtX#!s@83iRs9f{On7T#v z;wN7`cB!gSp?m*xEyChMx&^iesT?=4k4U*VZ9f~lQSTHfjz*_dY+HRw9u78inv zPTvft+1s-eurQ=oc!pXOuFWli1|gR;Rzr?U8#U(W#usV|3NMn-o2$<1&SUtpTkaMG z71kUO0wLNnc6)D-%9`U}La<p+CG;lgs~_7-YyHJQBSHpc?>2JR~)}^dFN&e7Ih0*Q+WKB z&I)mu3rD^SDFmh?d2g1yk|M6NnfN$y$|u9M5>}Kahj+gOxw>kF?^;+BQH#*3J*H!> zD<^}jix)69OWL%S;`IRVi`#g>a5@bzHWM_j=i7AS0yVyHa^(>KnOTX7(i|?OtM+cIAVG$X>zpN`h59upWm-Q;%{x-}4Kx`UA-w}-;6C$I+Adc7q1E2aj(l2QwQYU9yYkb#L z9k#fw9=x}=Ia}jHJ>EWbV`a_sEwBUDgc-_kERKYQo~ZxHpDa(VhyBUeL1{mConRET@1>u0`TPLDw?ku3{gF0n?Z%wdD?y zBfF#_O0T8g2_^7G#>mulsO^IH@Ys-g?_fT2v&|u4oCZ4GaV+<8muO%K6YCRy=`h&e z+1U`aM~wd>uFsk9?^CvxtSDHkWJ{wux{||!Nmp3v9wC|IhQHLV{--{ixu6fm1)Q6P zN8A6?%c4%ZOT~%og9xX6LYI+QEy1@WO>|4hXe-%)Xy76_5cX0@X(CEmYEq)|!z^6# zXv=0CDMX7^q7QCRkf}y>qy+vVN0Efi8zx&N14+eZcnVq+0}qSHUd4^;dy=|`(Nr?~ zz03mr%dhxRN4UN9kant5n+!cKrC~h(JZtupEWtX!PGRPP4$>3LK``|Un=F)rOkJoe}-Vn9Jq|IN?QIaLoSBFIfMBJQZ9 z{Zn?fQ8Rd+PSywM;1sYOiBt#)DE3u7U&FZMLOe^|9ulPA$9icuFeByb{*+v4kD2y# za9uNRDHk?7ZNc;F{ce~s*lR6Gb6J6m6|u!BcBi>kv%$>I_vun#7RtjzM7;O&-zTqS zZ+UGKY+Y<_9o*>nkg2q`WgV1B>P<*^L`@`08P{szE3N+vE3dKW5ECUL1^_`VU)NrW zO6~Yr^*i>*a8>%^;ljA`Li_vb-lMrEt68cOzL^TYEl>*n+dTV7w&_L1_ck&Iq4r6q z;#ZE-sP>4JbOQCR57Z#&8G+6gY1)`G_m2xS z%?+*2M0<~S#>;zTec1^qmJdEM$c*c|dUTZBGf4k_r5 z$$e()GqmTd4}&6pg>fvTF8rq?5r_RayqPXm@wrHr(%ucpLCuTRTTZeI@W*%ZomUp3 zEBzkOSkK4@2X-y1Mg%wPEqredE~?sg;?U<%38NI4Q)m&+@zo z7Cy?k<2@W|`QH{Tq-{mt6C%11kg54+b_ev6cd?rxzAxy#owiCNA-&|tPVjj(f~IRb zeqd;7%x~Gud3AB8J^D1**LhXad18Ej%W+d7v58c9a`)KOZxl+Cu0L7i=v`B*17D#H31!Yyj69S-`lTgH zrd}pK{cS=Vm*m8elbUxp^~;@O{49?@QKv2t0X&54>5^-jUJZXCgHh4e=0y>B3=zMh^DVA8#EPs-QKdOn|{z9E{PUE1lO zB@Sx8r4m*+G$vQR%A|k$R-rcd4jN%Ds6)l*`9!`vfg&?dUIKrB^DOA-WdQZ-z}SxL zWVVskoojCH;QvcGJb>vpxHu>U)|R+pTj;3$um9*-U)Q2%M9#h3b6M@-PhaoL@!Sb& zeB6KPN5Y*dIkmkgtImX{QnzxGE~rBS$+*Vtl5B#p}P!tw7@-_97G{n?B6_DQGI_<*^-P!7gli_@% literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md index 89c2725..ea7cfa7 100644 --- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -1,5 +1,11 @@ # Launch Screen Assets -You can customize the launch screen with your own desired assets by replacing the image files in this directory. +The three `LaunchImage*.png` files are the app logo at 127pt (1x/2x/3x), +generated from `../AppIcon.appiconset/4training logo_1024x1024.png` with the +white corners made transparent (so it also works on the dark launch +background, see `../LaunchBackground.colorset`). 127pt matches the logo size +of the Android splash screen (`android/app/src/main/res/values/dimens.xml`). -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file +To regenerate after changing the logo, resize the transparent-corner source +to 127, 254 and 381 px and replace the files here; `Contents.json` needs no +change as long as the file names stay the same. diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard index f2e259c..180dbb6 100644 --- a/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -19,7 +19,7 @@ - + @@ -32,6 +32,9 @@ - + + + + From 3e9ced73b93d5a91e98acd43ed1788fb439e7cfe Mon Sep 17 00:00:00 2001 From: Gabriel Sousa Date: Mon, 21 Sep 2026 18:20:02 -0300 Subject: [PATCH 8/9] docs: document startup stages, bulk download progress and native splash --- docs/architecture.md | 7 +++++++ docs/data-layer.md | 4 ++++ docs/features.md | 8 ++++---- docs/onboarding-flow.md | 2 +- docs/routing.md | 12 ++++++++++++ docs/state-management.md | 8 ++++++++ docs/testing.md | 2 +- 7 files changed, 37 insertions(+), 6 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 97510f2..18a18ec 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -66,6 +66,13 @@ which is mitigated by overriding `fileSystemProvider` and `httpClientProvider` i ## Lifecycle +0. **Native splash screen** (no Dart code running yet) + + - Covers Android process start, Flutter engine initialization and the `await`s at the top of `main()` - the single longest blank stretch of a cold start, and the one no Dart change can shorten. It shows the app logo on the same background colour as the first Flutter frame, so the hand-over doesn't flash (`values/colors.xml`: `#FFFFFF` light / `#010101` dark, i.e. the scaffold colours of the two themes in `lib/design/theme.dart`). + - Android: `drawable/launch_background.xml` is the `windowBackground` of both `LaunchTheme` (the system's starting window) and `NormalTheme` (the activity's own window - `FlutterActivity` switches to it in `onCreate()`, well before the first Flutter frame, so a plain colour there would drop the logo for the longest part of the wait); it centers `drawable-nodpi/splash_logo.png` (one 508 px bitmap, scaled by dp) at `@dimen/splash_logo_size` (127dp). On Android 12+ the system draws its own splash first: `values-v31/styles.xml` gives it the same colour and `drawable-v31/splash_icon.xml`, the logo inset by 28% so it sits inside the system's circular mask at the same 127dp - without that the system would upscale the launcher icon and cut its corners off. + - iOS: `LaunchScreen.storyboard` centers `LaunchImage` (127pt, 1x/2x/3x) on the `LaunchBackground` colour set (light/dark). + - All resources are hand-maintained, no splash generator package; the bitmaps derive from the 1024 px icon in `ios/Runner/Assets.xcassets/AppIcon.appiconset` with the white corners made transparent. + 1. **`main()`** (`lib/main.dart`) - `WidgetsFlutterBinding.ensureInitialized()` diff --git a/docs/data-layer.md b/docs/data-layer.md index d8f4f7e..69744cf 100644 --- a/docs/data-layer.md +++ b/docs/data-layer.md @@ -116,6 +116,10 @@ The branch name (`main`) is hardcoded; switching branches would require a code c `init()` calls `_load()` only — no network. `lazyInit()` only checks for `contents.json` existence and returns a sparse `Language(languageCode, {}, [], {}, path, timestamp)` without parsing — used by the background isolate, and by `StartupPage` for all languages before the first frame. Both `lazyInit()` and `_load()` read the path from `ref.read(languageDownloaderProvider).pathFor(languageCode)`. +### Bulk downloads (`lib/data/bulk_language_download.dart`) + +`downloadLanguagesInParallel(codes, download: ..., maxConcurrent: kMaxParallelLanguageDownloads, onProgress: ...)` runs a worker pool over the given language codes: at most `maxConcurrent` (4) downloads are in flight, and as soon as one finishes the next one starts, so a slow language never blocks idle slots. A download that returns `false` or throws counts as an error and the batch carries on. It returns a `BulkDownloadResult` (success/error counts, last successful code) for the summary snackbar - and, if `onProgress` is given, reports a `BulkDownloadProgress` (completed, total, language code, success) as each language finishes, which is what the "n of m" caption of `DownloadAllLanguagesButton` renders. Both "download all" and "update all" buttons use it. + ### Inside `LanguageDownloaderImpl` (`lib/data/language_downloader.dart`) The downloader owns the atomicity, concurrency, and crash-recovery guarantees so callers don't need to reason about partial state. One `download(langCode)` call performs: diff --git a/docs/features.md b/docs/features.md index 4187f07..55ea9ee 100644 --- a/docs/features.md +++ b/docs/features.md @@ -5,7 +5,7 @@ What's on each screen, what each widget does, and where to find it. ## Screens (`lib/routes/`) ### `StartupPage` -Initial loading screen. Computes `init()` (see [routing.md](routing.md)), shows a `loadingAnimation('Loading')` spinner while the future is pending, navigates with `pushReplacementNamed` once decided. The `initFunction` constructor parameter is exposed only for tests so that `StartupPage` can drive `Navigator` against a `Completer`. +Initial loading screen. Runs `init()` (see [routing.md](routing.md)) and shows a `LoadingAnimation` while it is pending, then navigates with `pushReplacementNamed` once decided. The caption under the spinner names the stage `init()` is in (`startupStageProvider`, localized `startupCheckingLanguages` / `startupLoadingAppLanguage` / `startupLoadingRecentPage`; `loading` until the first stage is reported). The `initFunction` constructor parameter is exposed only for tests so that `StartupPage` can drive `Navigator` against a `Completer`; with it, the caption stays at `loading`. ### `HomePage` (`/home`) Minimal — an `AppBar(title: '4training')`, the `MainDrawer`, and a `TableOfContent` body with the localized "What this app is" intro at the top. @@ -76,7 +76,7 @@ Below the table: `diskUsage` total (calculated asynchronously — a `…` placeh ### Language buttons - **`DownloadLanguageButton`** (`ConsumerStatefulWidget`): icon with internal `_isLoading` flag — swaps to `CircularProgressIndicator` during `LanguageController.download()`. Optional `highlight` flag wraps it in a tinted rounded box (used during onboarding). -- **`DownloadAllLanguagesButton`**: same idea, iterates `availableLanguagesProvider`. +- **`DownloadAllLanguagesButton`**: iterates `availableLanguagesProvider` through `downloadLanguagesInParallel()`. While the batch runs the icon is replaced by a *determinate* progress ring plus an "n of m" caption (`downloadProgress`), fed by the helper's `onProgress` callback; a failed language advances the counter like a successful one. The header cell of `LanguagesTable` has no fixed width for this reason - the control is 32 px wide while idle and grows while downloading. The end-of-batch snackbars are unchanged. - **`DeleteLanguageButton`**: deleting the *current app language* is "discouraged" — the icon turns `inversePrimary` and clicking shows a `ConfirmDeletionDialog`. - **`DeleteAllLanguagesButton`**: skips the current app language without prompting. - **`UpdateLanguageButton`**: same loading pattern, calls `download(force: true)`. @@ -109,8 +109,8 @@ The wrapper `ShareService` (and `shareProvider`) exists *purely* for testability ### `ErrorMessage` A reusable card with an icon, title, and message. Used by `ErrorPage` and by `ViewPage`'s error states. -### `loadingAnimation(msg)` -Function (not a class) returning a `Scaffold` with a centered `CircularProgressIndicator` + label. Used during startup and when fetching page content. +### `LoadingAnimation` +A `Scaffold` with a centered `CircularProgressIndicator` and a `caption` *widget* underneath - a widget rather than a string so that the caption can rebuild on its own (`StartupPage` passes a `Consumer` watching `startupStageProvider`). `loadingAnimation(msg)` is the shorthand for a fixed, already localized text; `ViewPage` uses it with `loadingContent`. ## Sharing assets diff --git a/docs/onboarding-flow.md b/docs/onboarding-flow.md index 6d80b31..ab80097 100644 --- a/docs/onboarding-flow.md +++ b/docs/onboarding-flow.md @@ -20,7 +20,7 @@ The page is wrapped in `LayoutBuilder + SingleChildScrollView + ConstrainedBox(m File: `lib/routes/onboarding/download_languages_page.dart`. -- Renders the same `LanguagesTable` widget used on the settings page, but with `highlightLang: appLanguage.languageCode` so the user's app-language download button is visually highlighted. +- Renders the same `LanguagesTable` widget used on the settings page, but with `highlightLang: appLanguage.languageCode` so the user's app-language download button is visually highlighted. Because it is the same table, "download all" shows its "n of m" progress here too - this is the first-run path where a user is most likely to download everything. - "Continue" is disabled-looking until the app language is downloaded: - Implementation note: we don't set `onPressed: null` (which would also disable click handling). We pass a manually-greyed `ButtonStyle` (`onSurface.withOpacity(0.12)` background, `0.38` foreground) so the button stays clickable. Clicking it while not yet downloaded shows a `MissingAppLanguageDialog` warning. - Once the app language is downloaded, "Continue" routes to `getNextRoute(ref)`. Currently that always returns `/home` — for v0.9 this will branch to `/onboarding/3` if `checkFrequency` isn't set yet. diff --git a/docs/routing.md b/docs/routing.md index af84373..5bb97d8 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -32,6 +32,7 @@ StartupPage.init(): return '/onboarding/1' # first time # step 1: which languages are on the device? one stat() each, in parallel + stage.report(checkingLanguages) await Future.wait(languageProvider(code).notifier.lazyInit() for all codes) if app language is not yet downloaded: @@ -45,8 +46,11 @@ StartupPage.init(): navigateTo = '/home' # step 2: fully load only what the first screen renders + stage.report(loadingAppLanguage) await Future.wait(languageProvider(code).notifier.init() for code in {appLanguage, recentLang?}) + # once the app language is in and recentLang is still loading: + # stage.report(loadingRecentPage) # step 3: the remaining downloaded languages, unawaited, 3 at a time unawaited(_loadRemainingLanguages(...)) @@ -65,6 +69,14 @@ Only the app language (for the menu) and the language of the resumed worksheet a Step 3 gets the `LanguageController`s handed to it rather than the `WidgetRef`: `StartupPage` is disposed by `pushReplacementNamed` while that work is still running, and a disposed `WidgetRef` must not be used. +### Telling the user which stage we're in + +On a slow device a single stage can take seconds, and a static caption then looks like a hang. So `init()` reports a `StartupStage` (`lib/data/startup_stage.dart`, exposed as `startupStageProvider`) right before it starts each stage, and the caption under the spinner renders the localized name of that stage. The rule is: only report a stage the code is actually in. That is why `loadingRecentPage` is reported from a continuation of the app language's `init()` and only if the worksheet's language is still loading at that moment - if it landed first there is nothing left to wait for and the caption stays on `loadingAppLanguage`. + +Two consequences for the widget: +- Only the caption (a small `Consumer` inside `LoadingAnimation`) watches the provider, so a stage change rebuilds nothing but that text - in particular it never rebuilds `StartupPage` itself, which would restart `init()`. +- Riverpod forbids modifying a provider while the widget tree is building, so `StartupPage` is a `ConsumerStatefulWidget` that starts `init()` from a microtask in `initState`: the first frame (spinner, generic `loading` caption) goes out, then `init()` runs. + ## Navigation primitives - **`Navigator.pushNamed`** for normal in-app navigation. diff --git a/docs/state-management.md b/docs/state-management.md index 41ac456..877a6f9 100644 --- a/docs/state-management.md +++ b/docs/state-management.md @@ -32,6 +32,14 @@ This page is the index of every provider in the app — what it holds, what it d `Globals` (a static-only class in the same file) holds the constant URLs (`https://github.com/4training/html-/archive/refs/heads/main.zip`) and folder names (`html--main`). +### Startup progress (`lib/data/startup_stage.dart`) + +| Provider | Type | Purpose | +| --- | --- | --- | +| `startupStageProvider` | `NotifierProvider` | Which stage `StartupPage.init()` is in (`starting` / `checkingLanguages` / `loadingAppLanguage` / `loadingRecentPage`). Drives the caption under the startup spinner; only `init()` calls `report()` | + +`StartupStage.getLocalized(context, stage)` maps a stage to its localized caption, following the `AutomaticUpdates.getLocalized` pattern. See [routing.md](routing.md), "Telling the user which stage we're in". + ### App language (`lib/data/app_language.dart`) | Provider | Type | Purpose | diff --git a/docs/testing.md b/docs/testing.md index 1524bbc..0e60af5 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -128,7 +128,7 @@ Almost every test: Uses a `TestObserver extends NavigatorObserver` to record `didPush` and `didReplace` calls. The asserts then check that, given a starting state, the right `pushReplacementNamed` was invoked. This is the cleanest way to verify `StartupPage`'s decision matrix end-to-end. -`startup_page_test.dart` additionally pins the staged loading described in [routing.md](routing.md): a `GatedLanguageController` holds each `init()` open until the test releases it, so the test can assert that navigation happens once the app language and the recent page's language are loaded — while another downloaded language is still loading in the background. +`startup_page_test.dart` additionally pins the staged loading described in [routing.md](routing.md): a `GatedLanguageController` holds each `init()` open until the test releases it, so the test can assert that navigation happens once the app language and the recent page's language are loaded — while another downloaded language is still loading in the background. The same controller can also hold `lazyInit()` open (`lazyInitGate`), which is how the stage-caption tests walk `init()` through its stages one at a time and check the caption at each of them. ## Integration test From 190f06c1adeec8cc4586706b1545418f74ac19f0 Mon Sep 17 00:00:00 2001 From: Gabriel Sousa Date: Mon, 21 Sep 2026 18:20:14 -0300 Subject: [PATCH 9/9] build(ios): update auto generated iOS build files --- ios/Flutter/AppFrameworkInfo.plist | 2 ++ ios/Podfile | 5 ++- ios/Podfile.lock | 48 ++++++++++++++++++++++++++-- ios/Runner.xcodeproj/project.pbxproj | 6 ++-- 4 files changed, 55 insertions(+), 6 deletions(-) diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 391a902..e784354 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -2,6 +2,8 @@ + MinimumOSVersion + 15.0 CFBundleDevelopmentRegion en CFBundleExecutable diff --git a/ios/Podfile b/ios/Podfile index 16e3165..5f86caf 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '14.0' +platform :ios, '15.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -39,5 +39,8 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' + end end end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 823c7ec..26042d6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,28 +1,72 @@ PODS: + - device_info_plus (0.0.1): + - Flutter - Flutter (1.0.0) + - integration_test (0.0.1): + - Flutter - open_filex (0.0.2): - Flutter + - package_info_plus (0.4.5): + - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - share_plus (0.0.1): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - url_launcher_ios (0.0.1): + - Flutter - workmanager_apple (0.0.1): - Flutter DEPENDENCIES: + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - Flutter (from `Flutter`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) - open_filex (from `.symlinks/plugins/open_filex/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`) EXTERNAL SOURCES: + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" Flutter: :path: Flutter + integration_test: + :path: ".symlinks/plugins/integration_test/ios" open_filex: :path: ".symlinks/plugins/open_filex/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" workmanager_apple: :path: ".symlinks/plugins/workmanager_apple/ios" SPEC CHECKSUMS: + device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778 -PODFILE CHECKSUM: ec1ad48fafee7989ae02b93f14e4d03d1f0e05bb +PODFILE CHECKSUM: ec7febc55a194e213ced0d0eaf1ba6793163cfd3 -COCOAPODS: 1.16.2 +COCOAPODS: 1.17.0 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 1f9431b..62881ff 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -356,7 +356,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -442,7 +442,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -491,7 +491,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos;