perf(startup): paint a splash immediately, run init off the first frame
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.
This commit is contained in:
parent
33d8b2a4d7
commit
71413f3801
12 changed files with 289 additions and 53 deletions
162
apps/app_seeds/lib/bootstrap.dart
Normal file
162
apps/app_seeds/lib/bootstrap.dart
Normal file
|
|
@ -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<Bootstrap> createState() => _BootstrapState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BootstrapState extends State<Bootstrap> {
|
||||||
|
late Future<TaneApp> _app = _boot();
|
||||||
|
|
||||||
|
Future<TaneApp> _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<LocaleStore>().saved();
|
||||||
|
if (savedLocale != null) LocaleSettings.setLocaleSync(savedLocale);
|
||||||
|
|
||||||
|
final onboarding = getIt<OnboardingStore>();
|
||||||
|
final inbox =
|
||||||
|
getIt.isRegistered<InboxService>() ? getIt<InboxService>() : 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<VarietyRepository>(),
|
||||||
|
species: getIt<SpeciesRepository>(),
|
||||||
|
onboarding: onboarding,
|
||||||
|
social:
|
||||||
|
getIt.isRegistered<SocialService>() ? getIt<SocialService>() : null,
|
||||||
|
socialSettings: getIt<SocialSettings>(),
|
||||||
|
location: const GeolocatorCoarseLocation(),
|
||||||
|
outbox: getIt<OfferOutbox>(),
|
||||||
|
messageStore: getIt<MessageStore>(),
|
||||||
|
profileStore: getIt<ProfileStore>(),
|
||||||
|
profileCache: getIt<ProfileCache>(),
|
||||||
|
inbox: inbox,
|
||||||
|
showIntro: !await onboarding.introSeen(),
|
||||||
|
autoBackup: getIt.isRegistered<AutoBackupService>()
|
||||||
|
? getIt<AutoBackupService>()
|
||||||
|
: 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<TaneApp>(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
"app": {
|
"app": {
|
||||||
"title": "Tanemaki"
|
"title": "Tanemaki"
|
||||||
},
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"failed": "Tanemaki nun pudo aniciar",
|
||||||
|
"retry": "Reintentar"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
"cancel": "Encaboxar",
|
"cancel": "Encaboxar",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
"app": {
|
"app": {
|
||||||
"title": "Tanemaki"
|
"title": "Tanemaki"
|
||||||
},
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"failed": "Tanemaki couldn't start",
|
||||||
|
"retry": "Try again"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
"app": {
|
"app": {
|
||||||
"title": "Tanemaki"
|
"title": "Tanemaki"
|
||||||
},
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"failed": "Tanemaki no pudo arrancar",
|
||||||
|
"retry": "Reintentar"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
"app": {
|
"app": {
|
||||||
"title": "Tanemaki"
|
"title": "Tanemaki"
|
||||||
},
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"failed": "O Tanemaki não conseguiu iniciar",
|
||||||
|
"retry": "Tentar de novo"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 4
|
/// 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
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ class TranslationsAst extends Translations with BaseTranslations<AppLocale, Tran
|
||||||
|
|
||||||
// Translations
|
// Translations
|
||||||
@override late final _Translations$app$ast app = _Translations$app$ast._(_root);
|
@override late final _Translations$app$ast app = _Translations$app$ast._(_root);
|
||||||
|
@override late final _Translations$bootstrap$ast bootstrap = _Translations$bootstrap$ast._(_root);
|
||||||
@override late final _Translations$common$ast common = _Translations$common$ast._(_root);
|
@override late final _Translations$common$ast common = _Translations$common$ast._(_root);
|
||||||
@override late final _Translations$home$ast home = _Translations$home$ast._(_root);
|
@override late final _Translations$home$ast home = _Translations$home$ast._(_root);
|
||||||
@override late final _Translations$photo$ast photo = _Translations$photo$ast._(_root);
|
@override late final _Translations$photo$ast photo = _Translations$photo$ast._(_root);
|
||||||
|
|
@ -85,6 +86,17 @@ class _Translations$app$ast extends Translations$app$en {
|
||||||
@override String get title => 'Tanemaki';
|
@override String get title => '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
|
// Path: common
|
||||||
class _Translations$common$ast extends Translations$common$en {
|
class _Translations$common$ast extends Translations$common$en {
|
||||||
_Translations$common$ast._(TranslationsAst root) : this._root = root, super.internal(root);
|
_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 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 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 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
|
// Path: profile
|
||||||
|
|
@ -1052,6 +1069,8 @@ extension on TranslationsAst {
|
||||||
dynamic _flatMapFunction(String path) {
|
dynamic _flatMapFunction(String path) {
|
||||||
return switch (path) {
|
return switch (path) {
|
||||||
'app.title' => 'Tanemaki',
|
'app.title' => 'Tanemaki',
|
||||||
|
'bootstrap.failed' => 'Tanemaki nun pudo aniciar',
|
||||||
|
'bootstrap.retry' => 'Reintentar',
|
||||||
'common.save' => 'Guardar',
|
'common.save' => 'Guardar',
|
||||||
'common.cancel' => 'Encaboxar',
|
'common.cancel' => 'Encaboxar',
|
||||||
'common.delete' => 'Desaniciar',
|
'common.delete' => 'Desaniciar',
|
||||||
|
|
@ -1384,6 +1403,11 @@ extension on TranslationsAst {
|
||||||
'market.useLocation' => 'Usar el mio allugamientu averáu',
|
'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.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.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.title' => 'El to perfil',
|
||||||
'profile.name' => 'Nome',
|
'profile.name' => 'Nome',
|
||||||
'profile.nameHint' => 'Cómo te ven los demás',
|
'profile.nameHint' => 'Cómo te ven los demás',
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
||||||
|
|
||||||
// Translations
|
// Translations
|
||||||
late final Translations$app$en app = Translations$app$en.internal(_root);
|
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$common$en common = Translations$common$en.internal(_root);
|
||||||
late final Translations$home$en home = Translations$home$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);
|
late final Translations$photo$en photo = Translations$photo$en.internal(_root);
|
||||||
|
|
@ -88,6 +89,21 @@ class Translations$app$en {
|
||||||
String get title => 'Tanemaki';
|
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
|
// Path: common
|
||||||
class Translations$common$en {
|
class Translations$common$en {
|
||||||
Translations$common$en.internal(this._root);
|
Translations$common$en.internal(this._root);
|
||||||
|
|
@ -1776,6 +1792,8 @@ extension on Translations {
|
||||||
dynamic _flatMapFunction(String path) {
|
dynamic _flatMapFunction(String path) {
|
||||||
return switch (path) {
|
return switch (path) {
|
||||||
'app.title' => 'Tanemaki',
|
'app.title' => 'Tanemaki',
|
||||||
|
'bootstrap.failed' => 'Tanemaki couldn\'t start',
|
||||||
|
'bootstrap.retry' => 'Try again',
|
||||||
'common.save' => 'Save',
|
'common.save' => 'Save',
|
||||||
'common.cancel' => 'Cancel',
|
'common.cancel' => 'Cancel',
|
||||||
'common.delete' => 'Delete',
|
'common.delete' => 'Delete',
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
|
||||||
|
|
||||||
// Translations
|
// Translations
|
||||||
@override late final _Translations$app$es app = _Translations$app$es._(_root);
|
@override late final _Translations$app$es app = _Translations$app$es._(_root);
|
||||||
|
@override late final _Translations$bootstrap$es bootstrap = _Translations$bootstrap$es._(_root);
|
||||||
@override late final _Translations$common$es common = _Translations$common$es._(_root);
|
@override late final _Translations$common$es common = _Translations$common$es._(_root);
|
||||||
@override late final _Translations$home$es home = _Translations$home$es._(_root);
|
@override late final _Translations$home$es home = _Translations$home$es._(_root);
|
||||||
@override late final _Translations$photo$es photo = _Translations$photo$es._(_root);
|
@override late final _Translations$photo$es photo = _Translations$photo$es._(_root);
|
||||||
|
|
@ -85,6 +86,17 @@ class _Translations$app$es extends Translations$app$en {
|
||||||
@override String get title => 'Tanemaki';
|
@override String get title => '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
|
// Path: common
|
||||||
class _Translations$common$es extends Translations$common$en {
|
class _Translations$common$es extends Translations$common$en {
|
||||||
_Translations$common$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
_Translations$common$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||||
|
|
@ -1059,6 +1071,8 @@ extension on TranslationsEs {
|
||||||
dynamic _flatMapFunction(String path) {
|
dynamic _flatMapFunction(String path) {
|
||||||
return switch (path) {
|
return switch (path) {
|
||||||
'app.title' => 'Tanemaki',
|
'app.title' => 'Tanemaki',
|
||||||
|
'bootstrap.failed' => 'Tanemaki no pudo arrancar',
|
||||||
|
'bootstrap.retry' => 'Reintentar',
|
||||||
'common.save' => 'Guardar',
|
'common.save' => 'Guardar',
|
||||||
'common.cancel' => 'Cancelar',
|
'common.cancel' => 'Cancelar',
|
||||||
'common.delete' => 'Eliminar',
|
'common.delete' => 'Eliminar',
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ class TranslationsPt extends Translations with BaseTranslations<AppLocale, Trans
|
||||||
|
|
||||||
// Translations
|
// Translations
|
||||||
@override late final _Translations$app$pt app = _Translations$app$pt._(_root);
|
@override late final _Translations$app$pt app = _Translations$app$pt._(_root);
|
||||||
|
@override late final _Translations$bootstrap$pt bootstrap = _Translations$bootstrap$pt._(_root);
|
||||||
@override late final _Translations$common$pt common = _Translations$common$pt._(_root);
|
@override late final _Translations$common$pt common = _Translations$common$pt._(_root);
|
||||||
@override late final _Translations$home$pt home = _Translations$home$pt._(_root);
|
@override late final _Translations$home$pt home = _Translations$home$pt._(_root);
|
||||||
@override late final _Translations$photo$pt photo = _Translations$photo$pt._(_root);
|
@override late final _Translations$photo$pt photo = _Translations$photo$pt._(_root);
|
||||||
|
|
@ -85,6 +86,17 @@ class _Translations$app$pt extends Translations$app$en {
|
||||||
@override String get title => 'Tanemaki';
|
@override String get title => '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
|
// Path: common
|
||||||
class _Translations$common$pt extends Translations$common$en {
|
class _Translations$common$pt extends Translations$common$en {
|
||||||
_Translations$common$pt._(TranslationsPt root) : this._root = root, super.internal(root);
|
_Translations$common$pt._(TranslationsPt root) : this._root = root, super.internal(root);
|
||||||
|
|
@ -1056,6 +1068,8 @@ extension on TranslationsPt {
|
||||||
dynamic _flatMapFunction(String path) {
|
dynamic _flatMapFunction(String path) {
|
||||||
return switch (path) {
|
return switch (path) {
|
||||||
'app.title' => 'Tanemaki',
|
'app.title' => 'Tanemaki',
|
||||||
|
'bootstrap.failed' => 'O Tanemaki não conseguiu iniciar',
|
||||||
|
'bootstrap.retry' => 'Tentar de novo',
|
||||||
'common.save' => 'Guardar',
|
'common.save' => 'Guardar',
|
||||||
'common.cancel' => 'Cancelar',
|
'common.cancel' => 'Cancelar',
|
||||||
'common.delete' => 'Eliminar',
|
'common.delete' => 'Eliminar',
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,15 @@
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import 'app.dart';
|
import 'bootstrap.dart';
|
||||||
import 'data/species_repository.dart';
|
|
||||||
import 'data/variety_repository.dart';
|
|
||||||
import 'di/injector.dart';
|
|
||||||
import 'i18n/strings.g.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<void> main() async {
|
void main() {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
// Start from the device language, then let an explicit in-app choice win —
|
// 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
|
// 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();
|
LocaleSettings.useDeviceLocaleSync();
|
||||||
await configureDependencies();
|
// Paint immediately: [Bootstrap] shows a splash and runs the heavy init off
|
||||||
final savedLocale = await getIt<LocaleStore>().saved();
|
// the first frame, so the app window appears at once instead of after DI.
|
||||||
if (savedLocale != null) LocaleSettings.setLocaleSync(savedLocale);
|
runApp(TranslationProvider(child: const Bootstrap()));
|
||||||
final onboarding = getIt<OnboardingStore>();
|
|
||||||
final inbox =
|
|
||||||
getIt.isRegistered<InboxService>() ? getIt<InboxService>() : 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<VarietyRepository>(),
|
|
||||||
species: getIt<SpeciesRepository>(),
|
|
||||||
onboarding: onboarding,
|
|
||||||
social: getIt.isRegistered<SocialService>()
|
|
||||||
? getIt<SocialService>()
|
|
||||||
: null,
|
|
||||||
socialSettings: getIt<SocialSettings>(),
|
|
||||||
location: const GeolocatorCoarseLocation(),
|
|
||||||
outbox: getIt<OfferOutbox>(),
|
|
||||||
messageStore: getIt<MessageStore>(),
|
|
||||||
profileStore: getIt<ProfileStore>(),
|
|
||||||
profileCache: getIt<ProfileCache>(),
|
|
||||||
inbox: inbox,
|
|
||||||
showIntro: !await onboarding.introSeen(),
|
|
||||||
autoBackup: getIt.isRegistered<AutoBackupService>()
|
|
||||||
? getIt<AutoBackupService>()
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
33
apps/app_seeds/test/ui/bootstrap_splash_test.dart
Normal file
33
apps/app_seeds/test/ui/bootstrap_splash_test.dart
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue