From 71413f380154041cc88dfb91e5c3cbcc9d90fe45 Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 10 Jul 2026 17:34:08 +0200 Subject: [PATCH] perf(startup): paint a splash immediately, run init off the first frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main() awaited all of configureDependencies() (open the encrypted DB, derive the social key, seed/scan the species catalog) before the first runApp, so nothing painted until init finished — startup jank visible as skipped frames. Now main() runs runApp immediately with a Bootstrap widget that shows a native-splash-matching green splash while the heavy work runs, then swaps in TaneApp. On failure it shows a localized error with a retry (configureDependencies is idempotent, so retry is safe). Adds a bootstrap i18n group (en/es/pt/ast) and a widget test for the splash/error frame. --- apps/app_seeds/lib/bootstrap.dart | 162 ++++++++++++++++++ apps/app_seeds/lib/i18n/ast.i18n.json | 4 + apps/app_seeds/lib/i18n/en.i18n.json | 4 + apps/app_seeds/lib/i18n/es.i18n.json | 4 + apps/app_seeds/lib/i18n/pt.i18n.json | 4 + apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_ast.g.dart | 24 +++ apps/app_seeds/lib/i18n/strings_en.g.dart | 18 ++ apps/app_seeds/lib/i18n/strings_es.g.dart | 14 ++ apps/app_seeds/lib/i18n/strings_pt.g.dart | 14 ++ apps/app_seeds/lib/main.dart | 57 +----- .../test/ui/bootstrap_splash_test.dart | 33 ++++ 12 files changed, 289 insertions(+), 53 deletions(-) create mode 100644 apps/app_seeds/lib/bootstrap.dart create mode 100644 apps/app_seeds/test/ui/bootstrap_splash_test.dart diff --git a/apps/app_seeds/lib/bootstrap.dart b/apps/app_seeds/lib/bootstrap.dart new file mode 100644 index 0000000..34b58db --- /dev/null +++ b/apps/app_seeds/lib/bootstrap.dart @@ -0,0 +1,162 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; + +import 'app.dart'; +import 'data/species_repository.dart'; +import 'data/variety_repository.dart'; +import 'di/injector.dart'; +import 'i18n/strings.g.dart'; +import 'services/auto_backup_service.dart'; +import 'services/coarse_location.dart'; +import 'services/inbox_service.dart'; +import 'services/locale_store.dart'; +import 'services/message_store.dart'; +import 'services/offer_outbox.dart'; +import 'services/onboarding_store.dart'; +import 'services/profile_cache.dart'; +import 'services/profile_store.dart'; +import 'services/social_service.dart'; +import 'services/social_settings.dart'; +import 'ui/theme.dart'; + +/// Boots the app WITHOUT blocking the first frame: paints a splash immediately, +/// then wires the encrypted DB + services and swaps in [TaneApp] once ready. +/// +/// Before this, `main` awaited the whole of `configureDependencies()` (open the +/// encrypted DB, derive the social key, seed/scan the species catalog…) before +/// the first `runApp`, so nothing painted until init finished — the startup jank +/// visible as skipped frames. Now `main` runs `runApp` immediately with this +/// widget; the heavy work runs while the splash is already on screen. +class Bootstrap extends StatefulWidget { + const Bootstrap({super.key}); + + @override + State createState() => _BootstrapState(); +} + +class _BootstrapState extends State { + late Future _app = _boot(); + + Future _boot() async { + await configureDependencies(); + + // The saved language lives in the keystore, so it can only be applied once + // DI is up. Until now the splash showed in the device language (it has no + // text), so there is no visible flip. + final savedLocale = await getIt().saved(); + if (savedLocale != null) LocaleSettings.setLocaleSync(savedLocale); + + final onboarding = getIt(); + final inbox = + getIt.isRegistered() ? getIt() : null; + // Listen for incoming private messages app-wide (foreground) so they arrive + // and fill the inbox even before their chat is opened. + if (inbox != null) unawaited(inbox.start()); + + return TaneApp( + repository: getIt(), + species: getIt(), + onboarding: onboarding, + social: + getIt.isRegistered() ? getIt() : null, + socialSettings: getIt(), + location: const GeolocatorCoarseLocation(), + outbox: getIt(), + messageStore: getIt(), + profileStore: getIt(), + profileCache: getIt(), + inbox: inbox, + showIntro: !await onboarding.introSeen(), + autoBackup: getIt.isRegistered() + ? getIt() + : null, + ); + } + + // `configureDependencies` is idempotent (and self-heals a half-wired + // container), so retrying after a failed boot is safe. + void _retry() => setState(() => _app = _boot()); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _app, + builder: (context, snapshot) { + if (snapshot.hasData) return snapshot.data!; + return BootstrapSplash( + error: snapshot.hasError, + onRetry: snapshot.hasError ? _retry : null, + ); + }, + ); + } +} + +/// The pre-init frame. A minimal [MaterialApp] so its text has a +/// `Directionality`, theme and localizations. Its background matches the native +/// splash colour ([seedGreen]) for a seamless handoff — one continuous green +/// screen from launch until the app is ready. Shows a spinner while booting and, +/// on [error], the failure message with a [onRetry] action. +class BootstrapSplash extends StatelessWidget { + const BootstrapSplash({required this.error, this.onRetry, super.key}); + + final bool error; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: buildTaneTheme(), + locale: TranslationProvider.of(context).flutterLocale, + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: Scaffold( + backgroundColor: seedGreen, + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('assets/logo.png', width: 120, height: 120), + const SizedBox(height: 32), + if (error) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + context.t.bootstrap.failed, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white, fontSize: 16), + ), + ), + const SizedBox(height: 16), + if (onRetry != null) + FilledButton( + onPressed: onRetry, + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: seedGreen, + ), + child: Text(context.t.bootstrap.retry), + ), + ] else + const SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index 107d18d..5752372 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -2,6 +2,10 @@ "app": { "title": "Tanemaki" }, + "bootstrap": { + "failed": "Tanemaki nun pudo aniciar", + "retry": "Reintentar" + }, "common": { "save": "Guardar", "cancel": "Encaboxar", diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index c0bd30b..df03960 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -2,6 +2,10 @@ "app": { "title": "Tanemaki" }, + "bootstrap": { + "failed": "Tanemaki couldn't start", + "retry": "Try again" + }, "common": { "save": "Save", "cancel": "Cancel", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 37c64cd..b7763f4 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -2,6 +2,10 @@ "app": { "title": "Tanemaki" }, + "bootstrap": { + "failed": "Tanemaki no pudo arrancar", + "retry": "Reintentar" + }, "common": { "save": "Guardar", "cancel": "Cancelar", diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 149e92a..6fc765c 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -2,6 +2,10 @@ "app": { "title": "Tanemaki" }, + "bootstrap": { + "failed": "O Tanemaki não conseguiu iniciar", + "retry": "Tentar de novo" + }, "common": { "save": "Guardar", "cancel": "Cancelar", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 058a3c4..af3f751 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 4 -/// Strings: 1464 (366 per locale) +/// Strings: 1472 (368 per locale) /// -/// Built on 2026-07-10 at 13:52 UTC +/// Built on 2026-07-10 at 15:00 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index e3f2d4a..ab1d5e4 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -40,6 +40,7 @@ class TranslationsAst extends Translations with BaseTranslations 'Tanemaki'; } +// Path: bootstrap +class _Translations$bootstrap$ast extends Translations$bootstrap$en { + _Translations$bootstrap$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get failed => 'Tanemaki nun pudo aniciar'; + @override String get retry => 'Reintentar'; +} + // Path: common class _Translations$common$ast extends Translations$common$en { _Translations$common$ast._(TranslationsAst root) : this._root = root, super.internal(root); @@ -645,6 +657,11 @@ class _Translations$market$ast extends Translations$market$en { @override String get useLocation => 'Usar el mio allugamientu averáu'; @override String get locationFailed => 'Nun se pudo consiguir el to allugamientu — comprueba que l\'allugamientu ta activáu y el permisu concedíu'; @override String get queued => 'Guardáu — compartirémosles cuando tengas conexón'; + @override String get shareFailed => 'Nun se pudo coneutar colos sirvidores — les tos granes nun se compartieron. Vuelvi a intentalo nun momentu.'; + @override String get rangeLabel => 'Hasta ónde guetar'; + @override String get rangeNear => 'Bien cerca'; + @override String get rangeArea => 'Pela mio zona'; + @override String get rangeRegion => 'La mio rexón'; } // Path: profile @@ -1052,6 +1069,8 @@ extension on TranslationsAst { dynamic _flatMapFunction(String path) { return switch (path) { 'app.title' => 'Tanemaki', + 'bootstrap.failed' => 'Tanemaki nun pudo aniciar', + 'bootstrap.retry' => 'Reintentar', 'common.save' => 'Guardar', 'common.cancel' => 'Encaboxar', 'common.delete' => 'Desaniciar', @@ -1384,6 +1403,11 @@ extension on TranslationsAst { 'market.useLocation' => 'Usar el mio allugamientu averáu', 'market.locationFailed' => 'Nun se pudo consiguir el to allugamientu — comprueba que l\'allugamientu ta activáu y el permisu concedíu', 'market.queued' => 'Guardáu — compartirémosles cuando tengas conexón', + 'market.shareFailed' => 'Nun se pudo coneutar colos sirvidores — les tos granes nun se compartieron. Vuelvi a intentalo nun momentu.', + 'market.rangeLabel' => 'Hasta ónde guetar', + 'market.rangeNear' => 'Bien cerca', + 'market.rangeArea' => 'Pela mio zona', + 'market.rangeRegion' => 'La mio rexón', 'profile.title' => 'El to perfil', 'profile.name' => 'Nome', 'profile.nameHint' => 'Cómo te ven los demás', diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index adb1071..a39e358 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -41,6 +41,7 @@ class Translations with BaseTranslations { // Translations late final Translations$app$en app = Translations$app$en.internal(_root); + late final Translations$bootstrap$en bootstrap = Translations$bootstrap$en.internal(_root); late final Translations$common$en common = Translations$common$en.internal(_root); late final Translations$home$en home = Translations$home$en.internal(_root); late final Translations$photo$en photo = Translations$photo$en.internal(_root); @@ -88,6 +89,21 @@ class Translations$app$en { String get title => 'Tanemaki'; } +// Path: bootstrap +class Translations$bootstrap$en { + Translations$bootstrap$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Tanemaki couldn't start' + String get failed => 'Tanemaki couldn\'t start'; + + /// en: 'Try again' + String get retry => 'Try again'; +} + // Path: common class Translations$common$en { Translations$common$en.internal(this._root); @@ -1776,6 +1792,8 @@ extension on Translations { dynamic _flatMapFunction(String path) { return switch (path) { 'app.title' => 'Tanemaki', + 'bootstrap.failed' => 'Tanemaki couldn\'t start', + 'bootstrap.retry' => 'Try again', 'common.save' => 'Save', 'common.cancel' => 'Cancel', 'common.delete' => 'Delete', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index e01e6bd..e65d887 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -40,6 +40,7 @@ class TranslationsEs extends Translations with BaseTranslations 'Tanemaki'; } +// Path: bootstrap +class _Translations$bootstrap$es extends Translations$bootstrap$en { + _Translations$bootstrap$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get failed => 'Tanemaki no pudo arrancar'; + @override String get retry => 'Reintentar'; +} + // Path: common class _Translations$common$es extends Translations$common$en { _Translations$common$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -1059,6 +1071,8 @@ extension on TranslationsEs { dynamic _flatMapFunction(String path) { return switch (path) { 'app.title' => 'Tanemaki', + 'bootstrap.failed' => 'Tanemaki no pudo arrancar', + 'bootstrap.retry' => 'Reintentar', 'common.save' => 'Guardar', 'common.cancel' => 'Cancelar', 'common.delete' => 'Eliminar', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 772857f..ddb71ec 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -40,6 +40,7 @@ class TranslationsPt extends Translations with BaseTranslations 'Tanemaki'; } +// Path: bootstrap +class _Translations$bootstrap$pt extends Translations$bootstrap$en { + _Translations$bootstrap$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get failed => 'O Tanemaki não conseguiu iniciar'; + @override String get retry => 'Tentar de novo'; +} + // Path: common class _Translations$common$pt extends Translations$common$en { _Translations$common$pt._(TranslationsPt root) : this._root = root, super.internal(root); @@ -1056,6 +1068,8 @@ extension on TranslationsPt { dynamic _flatMapFunction(String path) { return switch (path) { 'app.title' => 'Tanemaki', + 'bootstrap.failed' => 'O Tanemaki não conseguiu iniciar', + 'bootstrap.retry' => 'Tentar de novo', 'common.save' => 'Guardar', 'common.cancel' => 'Cancelar', 'common.delete' => 'Eliminar', diff --git a/apps/app_seeds/lib/main.dart b/apps/app_seeds/lib/main.dart index 15c9b2b..e74b392 100644 --- a/apps/app_seeds/lib/main.dart +++ b/apps/app_seeds/lib/main.dart @@ -1,60 +1,15 @@ -import 'dart:async'; - import 'package:flutter/widgets.dart'; -import 'app.dart'; -import 'data/species_repository.dart'; -import 'data/variety_repository.dart'; -import 'di/injector.dart'; +import 'bootstrap.dart'; import 'i18n/strings.g.dart'; -import 'services/auto_backup_service.dart'; -import 'services/coarse_location.dart'; -import 'services/inbox_service.dart'; -import 'services/locale_store.dart'; -import 'services/message_store.dart'; -import 'services/offer_outbox.dart'; -import 'services/onboarding_store.dart'; -import 'services/profile_cache.dart'; -import 'services/profile_store.dart'; -import 'services/social_service.dart'; -import 'services/social_settings.dart'; -Future main() async { +void main() { WidgetsFlutterBinding.ensureInitialized(); // Start from the device language, then let an explicit in-app choice win — // it's the only reliable way to reach languages the OS picker doesn't offer - // (e.g. Asturian). Restored after DI, since the store lives in the keystore. + // (e.g. Asturian). The saved choice is restored inside [Bootstrap], after DI. LocaleSettings.useDeviceLocaleSync(); - await configureDependencies(); - final savedLocale = await getIt().saved(); - if (savedLocale != null) LocaleSettings.setLocaleSync(savedLocale); - final onboarding = getIt(); - final inbox = - getIt.isRegistered() ? getIt() : null; - // Start listening for incoming private messages app-wide (foreground), so - // they arrive and populate the inbox list even before their chat is opened. - if (inbox != null) unawaited(inbox.start()); - runApp( - TranslationProvider( - child: TaneApp( - repository: getIt(), - species: getIt(), - onboarding: onboarding, - social: getIt.isRegistered() - ? getIt() - : null, - socialSettings: getIt(), - location: const GeolocatorCoarseLocation(), - outbox: getIt(), - messageStore: getIt(), - profileStore: getIt(), - profileCache: getIt(), - inbox: inbox, - showIntro: !await onboarding.introSeen(), - autoBackup: getIt.isRegistered() - ? getIt() - : null, - ), - ), - ); + // Paint immediately: [Bootstrap] shows a splash and runs the heavy init off + // the first frame, so the app window appears at once instead of after DI. + runApp(TranslationProvider(child: const Bootstrap())); } diff --git a/apps/app_seeds/test/ui/bootstrap_splash_test.dart b/apps/app_seeds/test/ui/bootstrap_splash_test.dart new file mode 100644 index 0000000..a204bae --- /dev/null +++ b/apps/app_seeds/test/ui/bootstrap_splash_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/bootstrap.dart'; +import 'package:tane/i18n/strings.g.dart'; + +void main() { + setUp(() => LocaleSettings.setLocaleSync(AppLocale.en)); + + testWidgets('shows a spinner and no error while booting', (tester) async { + await tester.pumpWidget( + TranslationProvider(child: const BootstrapSplash(error: false)), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text(t.bootstrap.failed), findsNothing); + expect(find.text(t.bootstrap.retry), findsNothing); + }); + + testWidgets('on error shows the message and a working retry', (tester) async { + var retries = 0; + await tester.pumpWidget( + TranslationProvider( + child: BootstrapSplash(error: true, onRetry: () => retries++), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text(t.bootstrap.failed), findsOneWidget); + + await tester.tap(find.text(t.bootstrap.retry)); + expect(retries, 1); + }); +}