Merge branch 'main' into spike/block2-derisking
# Conflicts: # apps/app_seeds/lib/app.dart # apps/app_seeds/lib/i18n/strings.g.dart # apps/app_seeds/lib/main.dart
This commit is contained in:
commit
cf99b7ff87
23 changed files with 607 additions and 25 deletions
|
|
@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
|
||||||
import 'data/species_repository.dart';
|
import 'data/species_repository.dart';
|
||||||
import 'data/variety_repository.dart';
|
import 'data/variety_repository.dart';
|
||||||
import 'i18n/strings.g.dart';
|
import 'i18n/strings.g.dart';
|
||||||
|
import 'services/auto_backup_service.dart';
|
||||||
import 'services/coarse_location.dart';
|
import 'services/coarse_location.dart';
|
||||||
import 'services/offer_outbox.dart';
|
import 'services/offer_outbox.dart';
|
||||||
import 'services/onboarding_store.dart';
|
import 'services/onboarding_store.dart';
|
||||||
|
|
@ -15,6 +16,7 @@ import 'services/social_settings.dart';
|
||||||
import 'state/inventory_cubit.dart';
|
import 'state/inventory_cubit.dart';
|
||||||
import 'state/variety_detail_cubit.dart';
|
import 'state/variety_detail_cubit.dart';
|
||||||
import 'ui/about_screen.dart';
|
import 'ui/about_screen.dart';
|
||||||
|
import 'ui/auto_backup_gate.dart';
|
||||||
import 'ui/home_screen.dart';
|
import 'ui/home_screen.dart';
|
||||||
import 'ui/intro_screen.dart';
|
import 'ui/intro_screen.dart';
|
||||||
import 'ui/inventory_list_screen.dart';
|
import 'ui/inventory_list_screen.dart';
|
||||||
|
|
@ -36,6 +38,7 @@ class TaneApp extends StatelessWidget {
|
||||||
this.location,
|
this.location,
|
||||||
this.outbox,
|
this.outbox,
|
||||||
this.showIntro = false,
|
this.showIntro = false,
|
||||||
|
this.autoBackup,
|
||||||
super.key,
|
super.key,
|
||||||
}) : _router = _buildRouter(
|
}) : _router = _buildRouter(
|
||||||
repository,
|
repository,
|
||||||
|
|
@ -62,6 +65,10 @@ class TaneApp extends StatelessWidget {
|
||||||
/// Optional offline outbox for the market's "share my seeds".
|
/// Optional offline outbox for the market's "share my seeds".
|
||||||
final OfferOutbox? outbox;
|
final OfferOutbox? outbox;
|
||||||
final bool showIntro;
|
final bool showIntro;
|
||||||
|
|
||||||
|
/// Drives silent periodic backups off the app lifecycle. Null in widget tests
|
||||||
|
/// and on platforms without file storage (web) — the gate then does nothing.
|
||||||
|
final AutoBackupService? autoBackup;
|
||||||
final GoRouter _router;
|
final GoRouter _router;
|
||||||
|
|
||||||
static GoRouter _buildRouter(
|
static GoRouter _buildRouter(
|
||||||
|
|
@ -134,22 +141,25 @@ class TaneApp extends StatelessWidget {
|
||||||
RepositoryProvider.value(value: repository),
|
RepositoryProvider.value(value: repository),
|
||||||
RepositoryProvider.value(value: species),
|
RepositoryProvider.value(value: species),
|
||||||
],
|
],
|
||||||
child: MaterialApp.router(
|
child: AutoBackupGate(
|
||||||
onGenerateTitle: (context) => context.t.app.title,
|
service: autoBackup,
|
||||||
debugShowCheckedModeBanner: false,
|
child: MaterialApp.router(
|
||||||
theme: buildTaneTheme(),
|
onGenerateTitle: (context) => context.t.app.title,
|
||||||
// Let scrollables & PageViews be dragged with a mouse/trackpad on
|
debugShowCheckedModeBanner: false,
|
||||||
// desktop (Flutter disables that by default), so the photo carousel and
|
theme: buildTaneTheme(),
|
||||||
// lists swipe there too.
|
// Let scrollables & PageViews be dragged with a mouse/trackpad on
|
||||||
scrollBehavior: const _DragScrollBehavior(),
|
// desktop (Flutter disables that by default), so the photo carousel and
|
||||||
locale: TranslationProvider.of(context).flutterLocale,
|
// lists swipe there too.
|
||||||
supportedLocales: AppLocaleUtils.supportedLocales,
|
scrollBehavior: const _DragScrollBehavior(),
|
||||||
localizationsDelegates: const [
|
locale: TranslationProvider.of(context).flutterLocale,
|
||||||
GlobalMaterialLocalizations.delegate,
|
supportedLocales: AppLocaleUtils.supportedLocales,
|
||||||
GlobalWidgetsLocalizations.delegate,
|
localizationsDelegates: const [
|
||||||
GlobalCupertinoLocalizations.delegate,
|
GlobalMaterialLocalizations.delegate,
|
||||||
],
|
GlobalWidgetsLocalizations.delegate,
|
||||||
routerConfig: _router,
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
routerConfig: _router,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:commons_core/commons_core.dart';
|
import 'package:commons_core/commons_core.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:flutter/services.dart' show rootBundle;
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
|
@ -15,6 +16,8 @@ import '../db/encrypted_executor.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../security/secret_store.dart';
|
import '../security/secret_store.dart';
|
||||||
import '../security/secure_key_store.dart';
|
import '../security/secure_key_store.dart';
|
||||||
|
import '../services/auto_backup_service.dart';
|
||||||
|
import '../services/auto_backup_store.dart';
|
||||||
import '../services/export_import_service.dart';
|
import '../services/export_import_service.dart';
|
||||||
import '../services/file_picker_file_service.dart';
|
import '../services/file_picker_file_service.dart';
|
||||||
import '../services/file_service.dart';
|
import '../services/file_service.dart';
|
||||||
|
|
@ -100,6 +103,23 @@ Future<void> configureDependencies() async {
|
||||||
..registerSingleton<RecoverySheetService>(
|
..registerSingleton<RecoverySheetService>(
|
||||||
RecoverySheetService(files: fileService),
|
RecoverySheetService(files: fileService),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Automatic silent backups need real file storage; the web build has none, so
|
||||||
|
// it simply goes without (the manual "save a copy" still works there).
|
||||||
|
if (!kIsWeb) {
|
||||||
|
getIt.registerSingleton<AutoBackupService>(
|
||||||
|
AutoBackupService(
|
||||||
|
exporter: getIt<ExportImportService>(),
|
||||||
|
store: AutoBackupStore(secretStore),
|
||||||
|
directory: _backupsDir,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Directory> _backupsDir() async {
|
||||||
|
final dir = await getApplicationSupportDirectory();
|
||||||
|
return Directory(p.join(dir.path, 'backups'));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<File> _databaseFile() async {
|
Future<File> _databaseFile() async {
|
||||||
|
|
|
||||||
|
|
@ -46,12 +46,12 @@ String? matchSpeciesInLabel(String label, List<SpeciesNameEntry> names) {
|
||||||
if (!_containsSequence(haystack, needle)) continue;
|
if (!_containsSequence(haystack, needle)) continue;
|
||||||
final chars = needle.fold<int>(0, (sum, t) => sum + t.length);
|
final chars = needle.fold<int>(0, (sum, t) => sum + t.length);
|
||||||
final score = (tokens: needle.length, chars: chars);
|
final score = (tokens: needle.length, chars: chars);
|
||||||
if (best == null || _isBetter(score, best!)) {
|
if (best == null || _isBetter(score, best)) {
|
||||||
best = score;
|
best = score;
|
||||||
winners
|
winners
|
||||||
..clear()
|
..clear()
|
||||||
..add(entry.speciesId);
|
..add(entry.speciesId);
|
||||||
} else if (score.tokens == best!.tokens && score.chars == best!.chars) {
|
} else if (score.tokens == best.tokens && score.chars == best.chars) {
|
||||||
winners.add(entry.speciesId);
|
winners.add(entry.speciesId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@
|
||||||
},
|
},
|
||||||
"backup": {
|
"backup": {
|
||||||
"title": "Backup & restore",
|
"title": "Backup & restore",
|
||||||
|
"autoBackupTitle": "Automatic backups",
|
||||||
|
"autoBackupLast": "Last copy saved {date}",
|
||||||
|
"autoBackupNone": "A copy is kept automatically in the background",
|
||||||
"exportJson": "Save a backup",
|
"exportJson": "Save a backup",
|
||||||
"exportJsonSubtitle": "A complete copy to keep safe, restore later or move to another device",
|
"exportJsonSubtitle": "A complete copy to keep safe, restore later or move to another device",
|
||||||
"importJson": "Restore a backup",
|
"importJson": "Restore a backup",
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@
|
||||||
},
|
},
|
||||||
"backup": {
|
"backup": {
|
||||||
"title": "Copia de seguridad",
|
"title": "Copia de seguridad",
|
||||||
|
"autoBackupTitle": "Copias automáticas",
|
||||||
|
"autoBackupLast": "Última copia guardada el {date}",
|
||||||
|
"autoBackupNone": "Se guarda una copia automáticamente en segundo plano",
|
||||||
"exportJson": "Guardar una copia de seguridad",
|
"exportJson": "Guardar una copia de seguridad",
|
||||||
"exportJsonSubtitle": "Una copia completa para guardar a salvo, restaurar luego o pasar a otro dispositivo",
|
"exportJsonSubtitle": "Una copia completa para guardar a salvo, restaurar luego o pasar a otro dispositivo",
|
||||||
"importJson": "Restaurar una copia",
|
"importJson": "Restaurar una copia",
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@
|
||||||
},
|
},
|
||||||
"backup": {
|
"backup": {
|
||||||
"title": "Cópia de segurança",
|
"title": "Cópia de segurança",
|
||||||
|
"autoBackupTitle": "Cópias automáticas",
|
||||||
|
"autoBackupLast": "Última cópia guardada em {date}",
|
||||||
|
"autoBackupNone": "Uma cópia é guardada automaticamente em segundo plano",
|
||||||
"exportJson": "Guardar uma cópia de segurança",
|
"exportJson": "Guardar uma cópia de segurança",
|
||||||
"exportJsonSubtitle": "Uma cópia completa para guardar em segurança, restaurar depois ou levar para outro aparelho",
|
"exportJsonSubtitle": "Uma cópia completa para guardar em segurança, restaurar depois ou levar para outro aparelho",
|
||||||
"importJson": "Restaurar uma cópia",
|
"importJson": "Restaurar uma cópia",
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 3
|
/// Locales: 3
|
||||||
/// Strings: 947 (315 per locale)
|
/// Strings: 956 (318 per locale)
|
||||||
///
|
///
|
||||||
/// Built on 2026-07-10 at 08:44 UTC
|
/// Built on 2026-07-10 at 08:49 UTC
|
||||||
|
|
||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,15 @@ class Translations$backup$en {
|
||||||
/// en: 'Backup & restore'
|
/// en: 'Backup & restore'
|
||||||
String get title => 'Backup & restore';
|
String get title => 'Backup & restore';
|
||||||
|
|
||||||
|
/// en: 'Automatic backups'
|
||||||
|
String get autoBackupTitle => 'Automatic backups';
|
||||||
|
|
||||||
|
/// en: 'Last copy saved {date}'
|
||||||
|
String autoBackupLast({required Object date}) => 'Last copy saved ${date}';
|
||||||
|
|
||||||
|
/// en: 'A copy is kept automatically in the background'
|
||||||
|
String get autoBackupNone => 'A copy is kept automatically in the background';
|
||||||
|
|
||||||
/// en: 'Save a backup'
|
/// en: 'Save a backup'
|
||||||
String get exportJson => 'Save a backup';
|
String get exportJson => 'Save a backup';
|
||||||
|
|
||||||
|
|
@ -1616,6 +1625,9 @@ extension on Translations {
|
||||||
'settings.aboutText' => 'Local-first, encrypted inventory for traditional seeds. AGPL-3.0.',
|
'settings.aboutText' => 'Local-first, encrypted inventory for traditional seeds. AGPL-3.0.',
|
||||||
'settings.aboutOpen' => 'About Tanemaki',
|
'settings.aboutOpen' => 'About Tanemaki',
|
||||||
'backup.title' => 'Backup & restore',
|
'backup.title' => 'Backup & restore',
|
||||||
|
'backup.autoBackupTitle' => 'Automatic backups',
|
||||||
|
'backup.autoBackupLast' => ({required Object date}) => 'Last copy saved ${date}',
|
||||||
|
'backup.autoBackupNone' => 'A copy is kept automatically in the background',
|
||||||
'backup.exportJson' => 'Save a backup',
|
'backup.exportJson' => 'Save a backup',
|
||||||
'backup.exportJsonSubtitle' => 'A complete copy to keep safe, restore later or move to another device',
|
'backup.exportJsonSubtitle' => 'A complete copy to keep safe, restore later or move to another device',
|
||||||
'backup.importJson' => 'Restore a backup',
|
'backup.importJson' => 'Restore a backup',
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,9 @@ class _Translations$backup$es extends Translations$backup$en {
|
||||||
|
|
||||||
// Translations
|
// Translations
|
||||||
@override String get title => 'Copia de seguridad';
|
@override String get title => 'Copia de seguridad';
|
||||||
|
@override String get autoBackupTitle => 'Copias automáticas';
|
||||||
|
@override String autoBackupLast({required Object date}) => 'Última copia guardada el ${date}';
|
||||||
|
@override String get autoBackupNone => 'Se guarda una copia automáticamente en segundo plano';
|
||||||
@override String get exportJson => 'Guardar una copia de seguridad';
|
@override String get exportJson => 'Guardar una copia de seguridad';
|
||||||
@override String get exportJsonSubtitle => 'Una copia completa para guardar a salvo, restaurar luego o pasar a otro dispositivo';
|
@override String get exportJsonSubtitle => 'Una copia completa para guardar a salvo, restaurar luego o pasar a otro dispositivo';
|
||||||
@override String get importJson => 'Restaurar una copia';
|
@override String get importJson => 'Restaurar una copia';
|
||||||
|
|
@ -1002,6 +1005,9 @@ extension on TranslationsEs {
|
||||||
'settings.aboutText' => 'Inventario local y cifrado para semillas tradicionales. AGPL-3.0.',
|
'settings.aboutText' => 'Inventario local y cifrado para semillas tradicionales. AGPL-3.0.',
|
||||||
'settings.aboutOpen' => 'Acerca de Tanemaki',
|
'settings.aboutOpen' => 'Acerca de Tanemaki',
|
||||||
'backup.title' => 'Copia de seguridad',
|
'backup.title' => 'Copia de seguridad',
|
||||||
|
'backup.autoBackupTitle' => 'Copias automáticas',
|
||||||
|
'backup.autoBackupLast' => ({required Object date}) => 'Última copia guardada el ${date}',
|
||||||
|
'backup.autoBackupNone' => 'Se guarda una copia automáticamente en segundo plano',
|
||||||
'backup.exportJson' => 'Guardar una copia de seguridad',
|
'backup.exportJson' => 'Guardar una copia de seguridad',
|
||||||
'backup.exportJsonSubtitle' => 'Una copia completa para guardar a salvo, restaurar luego o pasar a otro dispositivo',
|
'backup.exportJsonSubtitle' => 'Una copia completa para guardar a salvo, restaurar luego o pasar a otro dispositivo',
|
||||||
'backup.importJson' => 'Restaurar una copia',
|
'backup.importJson' => 'Restaurar una copia',
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,9 @@ class _Translations$backup$pt extends Translations$backup$en {
|
||||||
|
|
||||||
// Translations
|
// Translations
|
||||||
@override String get title => 'Cópia de segurança';
|
@override String get title => 'Cópia de segurança';
|
||||||
|
@override String get autoBackupTitle => 'Cópias automáticas';
|
||||||
|
@override String autoBackupLast({required Object date}) => 'Última cópia guardada em ${date}';
|
||||||
|
@override String get autoBackupNone => 'Uma cópia é guardada automaticamente em segundo plano';
|
||||||
@override String get exportJson => 'Guardar uma cópia de segurança';
|
@override String get exportJson => 'Guardar uma cópia de segurança';
|
||||||
@override String get exportJsonSubtitle => 'Uma cópia completa para guardar em segurança, restaurar depois ou levar para outro aparelho';
|
@override String get exportJsonSubtitle => 'Uma cópia completa para guardar em segurança, restaurar depois ou levar para outro aparelho';
|
||||||
@override String get importJson => 'Restaurar uma cópia';
|
@override String get importJson => 'Restaurar uma cópia';
|
||||||
|
|
@ -998,6 +1001,9 @@ extension on TranslationsPt {
|
||||||
'settings.aboutText' => 'Inventário local e cifrado para sementes tradicionais. AGPL-3.0.',
|
'settings.aboutText' => 'Inventário local e cifrado para sementes tradicionais. AGPL-3.0.',
|
||||||
'settings.aboutOpen' => 'Acerca do Tanemaki',
|
'settings.aboutOpen' => 'Acerca do Tanemaki',
|
||||||
'backup.title' => 'Cópia de segurança',
|
'backup.title' => 'Cópia de segurança',
|
||||||
|
'backup.autoBackupTitle' => 'Cópias automáticas',
|
||||||
|
'backup.autoBackupLast' => ({required Object date}) => 'Última cópia guardada em ${date}',
|
||||||
|
'backup.autoBackupNone' => 'Uma cópia é guardada automaticamente em segundo plano',
|
||||||
'backup.exportJson' => 'Guardar uma cópia de segurança',
|
'backup.exportJson' => 'Guardar uma cópia de segurança',
|
||||||
'backup.exportJsonSubtitle' => 'Uma cópia completa para guardar em segurança, restaurar depois ou levar para outro aparelho',
|
'backup.exportJsonSubtitle' => 'Uma cópia completa para guardar em segurança, restaurar depois ou levar para outro aparelho',
|
||||||
'backup.importJson' => 'Restaurar uma cópia',
|
'backup.importJson' => 'Restaurar uma cópia',
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import 'data/species_repository.dart';
|
||||||
import 'data/variety_repository.dart';
|
import 'data/variety_repository.dart';
|
||||||
import 'di/injector.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/coarse_location.dart';
|
||||||
import 'services/offer_outbox.dart';
|
import 'services/offer_outbox.dart';
|
||||||
import 'services/onboarding_store.dart';
|
import 'services/onboarding_store.dart';
|
||||||
|
|
@ -27,6 +28,9 @@ Future<void> main() async {
|
||||||
location: const GeolocatorCoarseLocation(),
|
location: const GeolocatorCoarseLocation(),
|
||||||
outbox: getIt<OfferOutbox>(),
|
outbox: getIt<OfferOutbox>(),
|
||||||
showIntro: !await onboarding.introSeen(),
|
showIntro: !await onboarding.introSeen(),
|
||||||
|
autoBackup: getIt.isRegistered<AutoBackupService>()
|
||||||
|
? getIt<AutoBackupService>()
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
83
apps/app_seeds/lib/services/auto_backup_service.dart
Normal file
83
apps/app_seeds/lib/services/auto_backup_service.dart
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'auto_backup_store.dart';
|
||||||
|
import 'export_import_service.dart';
|
||||||
|
|
||||||
|
/// Makes a silent, encrypted safety copy of the inventory on a schedule — a net
|
||||||
|
/// against in-app data loss or DB corruption, with zero friction. Triggered
|
||||||
|
/// from the app lifecycle (see [AutoBackupGate]), it writes into the app's
|
||||||
|
/// private storage; the manual "save a copy" stays the way to move one off the
|
||||||
|
/// device.
|
||||||
|
///
|
||||||
|
/// Copies are **sealed** (same [ExportImportService.buildSealedBackup] bytes as
|
||||||
|
/// a manual backup): they reveal nothing at rest and open with the recovery
|
||||||
|
/// code. The newest few are kept and older ones rotated out.
|
||||||
|
class AutoBackupService {
|
||||||
|
AutoBackupService({
|
||||||
|
required ExportImportService exporter,
|
||||||
|
required AutoBackupStore store,
|
||||||
|
required Future<Directory> Function() directory,
|
||||||
|
DateTime Function() now = DateTime.now,
|
||||||
|
}) : _exporter = exporter,
|
||||||
|
_store = store,
|
||||||
|
_directory = directory,
|
||||||
|
_now = now;
|
||||||
|
|
||||||
|
final ExportImportService _exporter;
|
||||||
|
final AutoBackupStore _store;
|
||||||
|
final Future<Directory> Function() _directory;
|
||||||
|
final DateTime Function() _now;
|
||||||
|
|
||||||
|
/// Filename prefix so rotation only ever touches automatic copies, never a
|
||||||
|
/// backup the user saved by hand.
|
||||||
|
static const _prefix = 'tanemaki-auto-';
|
||||||
|
|
||||||
|
/// How many automatic copies to keep. A rotating window (not a single file)
|
||||||
|
/// so a corrupt copy can't overwrite the only backup.
|
||||||
|
static const _keep = 3;
|
||||||
|
|
||||||
|
/// When the last automatic copy was made, or null if none has run yet. For a
|
||||||
|
/// reassurance line in Settings.
|
||||||
|
Future<DateTime?> lastBackupAt() => _store.lastBackupAt();
|
||||||
|
|
||||||
|
/// Makes a fresh copy when at least [interval] has passed since the last one.
|
||||||
|
/// Returns true when it wrote a copy, false when not due (or on any failure —
|
||||||
|
/// a backup must never crash startup or the move to the background).
|
||||||
|
Future<bool> runIfDue({Duration interval = const Duration(days: 7)}) async {
|
||||||
|
try {
|
||||||
|
final now = _now();
|
||||||
|
final last = await _store.lastBackupAt();
|
||||||
|
if (last != null && now.difference(last) < interval) return false;
|
||||||
|
|
||||||
|
final dir = await _directory();
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
final sealed = await _exporter.buildSealedBackup();
|
||||||
|
final date = now.toIso8601String().substring(0, 10);
|
||||||
|
final file = File('${dir.path}/$_prefix$date.tanemaki');
|
||||||
|
await file.writeAsBytes(sealed, flush: true);
|
||||||
|
|
||||||
|
await _rotate(dir);
|
||||||
|
await _store.markBackupAt(now);
|
||||||
|
return true;
|
||||||
|
} on Object {
|
||||||
|
// Silent net: a failed copy is logged nowhere sensitive and never thrown.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the newest [_keep] automatic copies; deletes the rest. Filenames end
|
||||||
|
/// in an ISO date, so a lexical sort is chronological.
|
||||||
|
Future<void> _rotate(Directory dir) async {
|
||||||
|
final copies =
|
||||||
|
(await dir.list().toList())
|
||||||
|
.whereType<File>()
|
||||||
|
.where((f) => f.uri.pathSegments.last.startsWith(_prefix))
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => a.path.compareTo(b.path));
|
||||||
|
final excess = copies.length - _keep;
|
||||||
|
if (excess <= 0) return;
|
||||||
|
for (final stale in copies.take(excess)) {
|
||||||
|
await stale.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/app_seeds/lib/services/auto_backup_store.dart
Normal file
24
apps/app_seeds/lib/services/auto_backup_store.dart
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import '../security/secret_store.dart';
|
||||||
|
|
||||||
|
/// Remembers when the last automatic backup ran, so the app only makes a fresh
|
||||||
|
/// copy once the interval has elapsed. Backed by the OS keystore (via
|
||||||
|
/// [SecretStore]) to honour the "no plaintext at rest" rule — mirrors
|
||||||
|
/// [OnboardingStore]; there is no shared_preferences here.
|
||||||
|
class AutoBackupStore {
|
||||||
|
AutoBackupStore(this._store);
|
||||||
|
|
||||||
|
final SecretStore _store;
|
||||||
|
|
||||||
|
static const _lastKey = 'tane.auto_backup.last';
|
||||||
|
|
||||||
|
/// When the last automatic backup succeeded, or null if none has run yet.
|
||||||
|
Future<DateTime?> lastBackupAt() async {
|
||||||
|
final raw = await _store.read(_lastKey);
|
||||||
|
if (raw == null) return null;
|
||||||
|
return DateTime.tryParse(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records that an automatic backup succeeded at [when].
|
||||||
|
Future<void> markBackupAt(DateTime when) =>
|
||||||
|
_store.write(_lastKey, when.toIso8601String());
|
||||||
|
}
|
||||||
|
|
@ -49,15 +49,22 @@ class ExportImportService {
|
||||||
/// The backup file extension. The content is the sealed interchange JSON.
|
/// The backup file extension. The content is the sealed interchange JSON.
|
||||||
static const backupExtension = 'tanemaki';
|
static const backupExtension = 'tanemaki';
|
||||||
|
|
||||||
/// Exports the full inventory as a sealed backup file. Returns true when
|
/// Builds the full inventory as a sealed (encrypted) backup — the same bytes
|
||||||
/// saved, false when the user cancelled the save dialog.
|
/// [exportBackup] writes to a file. Shared with the automatic backup so both
|
||||||
Future<bool> exportBackup() async {
|
/// paths seal identically.
|
||||||
|
Future<Uint8List> buildSealedBackup() async {
|
||||||
final snapshot = await _repository.exportInventory();
|
final snapshot = await _repository.exportInventory();
|
||||||
final plain = utf8.encode(_jsonCodec.encode(snapshot));
|
final plain = utf8.encode(_jsonCodec.encode(snapshot));
|
||||||
final sealed = await BackupBox().seal(
|
return BackupBox().seal(
|
||||||
plain,
|
plain,
|
||||||
rootSeed: _hexToBytes(await _keys.rootSeedHex()),
|
rootSeed: _hexToBytes(await _keys.rootSeedHex()),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exports the full inventory as a sealed backup file. Returns true when
|
||||||
|
/// saved, false when the user cancelled the save dialog.
|
||||||
|
Future<bool> exportBackup() async {
|
||||||
|
final sealed = await buildSealedBackup();
|
||||||
final path = await _files.saveFile(
|
final path = await _files.saveFile(
|
||||||
suggestedName: _fileName(backupExtension),
|
suggestedName: _fileName(backupExtension),
|
||||||
bytes: sealed,
|
bytes: sealed,
|
||||||
|
|
|
||||||
42
apps/app_seeds/lib/ui/auto_backup_gate.dart
Normal file
42
apps/app_seeds/lib/ui/auto_backup_gate.dart
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import '../services/auto_backup_service.dart';
|
||||||
|
|
||||||
|
/// Drives the automatic backup off the app lifecycle: once on startup and again
|
||||||
|
/// each time the app becomes hidden (backgrounded on mobile, window hidden on
|
||||||
|
/// desktop). The [AutoBackupService] decides whether a copy is actually due, so
|
||||||
|
/// these are cheap no-ops most of the time.
|
||||||
|
///
|
||||||
|
/// When [service] is null (widget tests, or platforms without file storage such
|
||||||
|
/// as web) it wraps [child] untouched and does nothing.
|
||||||
|
class AutoBackupGate extends StatefulWidget {
|
||||||
|
const AutoBackupGate({required this.child, this.service, super.key});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final AutoBackupService? service;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AutoBackupGate> createState() => _AutoBackupGateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AutoBackupGateState extends State<AutoBackupGate> {
|
||||||
|
AppLifecycleListener? _listener;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final service = widget.service;
|
||||||
|
if (service == null) return;
|
||||||
|
_listener = AppLifecycleListener(onHide: () => service.runIfDue());
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => service.runIfDue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_listener?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => widget.child;
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import 'package:commons_core/commons_core.dart';
|
import 'package:commons_core/commons_core.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import '../data/export_import/inventory_snapshot.dart';
|
import '../data/export_import/inventory_snapshot.dart';
|
||||||
import '../di/injector.dart';
|
import '../di/injector.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
|
import '../services/auto_backup_service.dart';
|
||||||
import '../services/export_import_service.dart';
|
import '../services/export_import_service.dart';
|
||||||
import '../services/recovery_sheet_service.dart';
|
import '../services/recovery_sheet_service.dart';
|
||||||
|
|
||||||
|
|
@ -15,21 +17,32 @@ import '../services/recovery_sheet_service.dart';
|
||||||
/// for confirmation first (it merges into the live inventory), then reports
|
/// for confirmation first (it merges into the live inventory), then reports
|
||||||
/// what happened in a SnackBar.
|
/// what happened in a SnackBar.
|
||||||
class BackupSection extends StatelessWidget {
|
class BackupSection extends StatelessWidget {
|
||||||
const BackupSection({this.service, this.sheet, super.key});
|
const BackupSection({this.service, this.sheet, this.autoBackup, super.key});
|
||||||
|
|
||||||
/// Injectable for widget tests; the app resolves them lazily (on tap) from
|
/// Injectable for widget tests; the app resolves them lazily (on tap) from
|
||||||
/// the service locator so merely mounting Settings needs no DI setup.
|
/// the service locator so merely mounting Settings needs no DI setup.
|
||||||
final ExportImportService? service;
|
final ExportImportService? service;
|
||||||
final RecoverySheetService? sheet;
|
final RecoverySheetService? sheet;
|
||||||
|
|
||||||
|
/// Drives the reassurance line about automatic copies. When null (and none is
|
||||||
|
/// registered — e.g. web) the line is simply hidden.
|
||||||
|
final AutoBackupService? autoBackup;
|
||||||
|
|
||||||
ExportImportService get _service => service ?? getIt<ExportImportService>();
|
ExportImportService get _service => service ?? getIt<ExportImportService>();
|
||||||
RecoverySheetService get _sheet => sheet ?? getIt<RecoverySheetService>();
|
RecoverySheetService get _sheet => sheet ?? getIt<RecoverySheetService>();
|
||||||
|
|
||||||
|
AutoBackupService? get _autoBackup =>
|
||||||
|
autoBackup ??
|
||||||
|
(getIt.isRegistered<AutoBackupService>()
|
||||||
|
? getIt<AutoBackupService>()
|
||||||
|
: null);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
|
_AutoBackupTile(service: _autoBackup),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.save_alt_outlined),
|
leading: const Icon(Icons.save_alt_outlined),
|
||||||
title: Text(t.backup.exportJson),
|
title: Text(t.backup.exportJson),
|
||||||
|
|
@ -299,3 +312,37 @@ class BackupSection extends StatelessWidget {
|
||||||
messenger.showSnackBar(SnackBar(content: Text(message)));
|
messenger.showSnackBar(SnackBar(content: Text(message)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A quiet reassurance line: the app already keeps copies on its own, and this
|
||||||
|
/// says when the last one was made. Hidden entirely when there is no automatic
|
||||||
|
/// backup on this platform.
|
||||||
|
class _AutoBackupTile extends StatelessWidget {
|
||||||
|
const _AutoBackupTile({required this.service});
|
||||||
|
|
||||||
|
final AutoBackupService? service;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final service = this.service;
|
||||||
|
if (service == null) return const SizedBox.shrink();
|
||||||
|
final t = context.t;
|
||||||
|
return FutureBuilder<DateTime?>(
|
||||||
|
future: service.lastBackupAt(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final last = snapshot.data;
|
||||||
|
final subtitle = last == null
|
||||||
|
? t.backup.autoBackupNone
|
||||||
|
: t.backup.autoBackupLast(
|
||||||
|
date: DateFormat.yMMMd(
|
||||||
|
LocaleSettings.currentLocale.languageCode,
|
||||||
|
).format(last),
|
||||||
|
);
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Icons.shield_outlined),
|
||||||
|
title: Text(t.backup.autoBackupTitle),
|
||||||
|
subtitle: Text(subtitle),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ dependencies:
|
||||||
# i18n (Weblate-friendly JSON per locale).
|
# i18n (Weblate-friendly JSON per locale).
|
||||||
slang: ^4.7.0
|
slang: ^4.7.0
|
||||||
slang_flutter: ^4.7.0
|
slang_flutter: ^4.7.0
|
||||||
|
# ICU date/number formatting (locale-aware, never ad-hoc).
|
||||||
|
intl: ^0.20.2
|
||||||
|
|
||||||
# Secure key storage (OS keystore).
|
# Secure key storage (OS keystore).
|
||||||
flutter_secure_storage: ^9.2.4
|
flutter_secure_storage: ^9.2.4
|
||||||
|
|
|
||||||
152
apps/app_seeds/test/services/auto_backup_service_test.dart
Normal file
152
apps/app_seeds/test/services/auto_backup_service_test.dart
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:commons_core/commons_core.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/db/database.dart';
|
||||||
|
import 'package:tane/security/secure_key_store.dart';
|
||||||
|
import 'package:tane/services/auto_backup_service.dart';
|
||||||
|
import 'package:tane/services/auto_backup_store.dart';
|
||||||
|
import 'package:tane/services/export_import_service.dart';
|
||||||
|
import 'package:tane/services/file_service.dart';
|
||||||
|
|
||||||
|
import '../support/test_support.dart';
|
||||||
|
|
||||||
|
/// [FileService] the exporter never actually calls for [buildSealedBackup]; the
|
||||||
|
/// automatic path writes files itself.
|
||||||
|
class _UnusedFiles implements FileService {
|
||||||
|
@override
|
||||||
|
Future<String?> saveFile({
|
||||||
|
required String suggestedName,
|
||||||
|
required Uint8List bytes,
|
||||||
|
}) async => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List?> pickFileBytes({List<String>? allowedExtensions}) async =>
|
||||||
|
null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An exporter whose backup build always fails, to prove failures stay silent.
|
||||||
|
class _ThrowingExporter extends ExportImportService {
|
||||||
|
_ThrowingExporter(AppDatabase db)
|
||||||
|
: super(
|
||||||
|
repository: newTestRepository(db),
|
||||||
|
files: _UnusedFiles(),
|
||||||
|
keys: SecureKeyStore(store: InMemorySecretStore()),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List> buildSealedBackup() => throw Exception('disk full');
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
late Directory dir;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
db = newTestDatabase();
|
||||||
|
dir = await Directory.systemTemp.createTemp('tane_auto_backup_test');
|
||||||
|
});
|
||||||
|
tearDown(() async {
|
||||||
|
await db.close();
|
||||||
|
if (dir.existsSync()) await dir.delete(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
ExportImportService newExporter() => ExportImportService(
|
||||||
|
repository: newTestRepository(db),
|
||||||
|
files: _UnusedFiles(),
|
||||||
|
keys: SecureKeyStore(store: InMemorySecretStore()),
|
||||||
|
);
|
||||||
|
|
||||||
|
AutoBackupService newService(
|
||||||
|
ExportImportService exporter, {
|
||||||
|
required AutoBackupStore store,
|
||||||
|
required DateTime now,
|
||||||
|
}) => AutoBackupService(
|
||||||
|
exporter: exporter,
|
||||||
|
store: store,
|
||||||
|
directory: () async => dir,
|
||||||
|
now: () => now,
|
||||||
|
);
|
||||||
|
|
||||||
|
List<String> autoCopies() =>
|
||||||
|
dir
|
||||||
|
.listSync()
|
||||||
|
.whereType<File>()
|
||||||
|
.map((f) => f.uri.pathSegments.last)
|
||||||
|
.where((n) => n.startsWith('tanemaki-auto-'))
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
|
||||||
|
test('first run writes one sealed copy', () async {
|
||||||
|
await newTestRepository(db).addQuickVariety(label: 'Maize');
|
||||||
|
final service = newService(
|
||||||
|
newExporter(),
|
||||||
|
store: AutoBackupStore(InMemorySecretStore()),
|
||||||
|
now: DateTime.utc(2026, 7, 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await service.runIfDue(), isTrue);
|
||||||
|
|
||||||
|
final copies = autoCopies();
|
||||||
|
expect(copies, ['tanemaki-auto-2026-07-10.tanemaki']);
|
||||||
|
final bytes = File('${dir.path}/${copies.single}').readAsBytesSync();
|
||||||
|
expect(BackupBox.looksSealed(bytes), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a second run within the interval is a no-op', () async {
|
||||||
|
final store = AutoBackupStore(InMemorySecretStore());
|
||||||
|
await store.markBackupAt(DateTime.utc(2026, 7, 8));
|
||||||
|
final service = newService(
|
||||||
|
newExporter(),
|
||||||
|
store: store,
|
||||||
|
now: DateTime.utc(2026, 7, 10), // 2 days later < 7-day interval
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await service.runIfDue(), isFalse);
|
||||||
|
expect(autoCopies(), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a run after the interval writes again', () async {
|
||||||
|
final store = AutoBackupStore(InMemorySecretStore());
|
||||||
|
await store.markBackupAt(DateTime.utc(2026, 7, 1));
|
||||||
|
final service = newService(
|
||||||
|
newExporter(),
|
||||||
|
store: store,
|
||||||
|
now: DateTime.utc(2026, 7, 10), // 9 days later >= 7
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await service.runIfDue(), isTrue);
|
||||||
|
expect(autoCopies(), ['tanemaki-auto-2026-07-10.tanemaki']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rotation keeps only the newest three copies', () async {
|
||||||
|
for (final d in ['2020-01-01', '2020-01-02', '2020-01-03', '2020-01-04']) {
|
||||||
|
File('${dir.path}/tanemaki-auto-$d.tanemaki').writeAsBytesSync([0]);
|
||||||
|
}
|
||||||
|
final service = newService(
|
||||||
|
newExporter(),
|
||||||
|
store: AutoBackupStore(InMemorySecretStore()),
|
||||||
|
now: DateTime.utc(2026, 7, 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.runIfDue();
|
||||||
|
|
||||||
|
expect(autoCopies(), [
|
||||||
|
'tanemaki-auto-2020-01-03.tanemaki',
|
||||||
|
'tanemaki-auto-2020-01-04.tanemaki',
|
||||||
|
'tanemaki-auto-2026-07-10.tanemaki',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed backup returns false and never throws', () async {
|
||||||
|
final service = newService(
|
||||||
|
_ThrowingExporter(db),
|
||||||
|
store: AutoBackupStore(InMemorySecretStore()),
|
||||||
|
now: DateTime.utc(2026, 7, 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await service.runIfDue(), isFalse);
|
||||||
|
expect(autoCopies(), isEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
20
apps/app_seeds/test/services/auto_backup_store_test.dart
Normal file
20
apps/app_seeds/test/services/auto_backup_store_test.dart
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/services/auto_backup_store.dart';
|
||||||
|
|
||||||
|
import '../support/test_support.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('lastBackupAt is null until the first backup is marked', () async {
|
||||||
|
final store = AutoBackupStore(InMemorySecretStore());
|
||||||
|
expect(await store.lastBackupAt(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('markBackupAt round-trips to the second', () async {
|
||||||
|
final store = AutoBackupStore(InMemorySecretStore());
|
||||||
|
final when = DateTime.utc(2026, 7, 10, 8, 30);
|
||||||
|
|
||||||
|
await store.markBackupAt(when);
|
||||||
|
|
||||||
|
expect(await store.lastBackupAt(), when);
|
||||||
|
});
|
||||||
|
}
|
||||||
86
apps/app_seeds/test/ui/auto_backup_gate_test.dart
Normal file
86
apps/app_seeds/test/ui/auto_backup_gate_test.dart
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/db/database.dart';
|
||||||
|
import 'package:tane/security/secure_key_store.dart';
|
||||||
|
import 'package:tane/services/auto_backup_service.dart';
|
||||||
|
import 'package:tane/services/auto_backup_store.dart';
|
||||||
|
import 'package:tane/services/export_import_service.dart';
|
||||||
|
import 'package:tane/services/file_service.dart';
|
||||||
|
import 'package:tane/ui/auto_backup_gate.dart';
|
||||||
|
|
||||||
|
import '../support/test_support.dart';
|
||||||
|
|
||||||
|
class _NoFiles implements FileService {
|
||||||
|
@override
|
||||||
|
Future<String?> saveFile({
|
||||||
|
required String suggestedName,
|
||||||
|
required Uint8List bytes,
|
||||||
|
}) async => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List?> pickFileBytes({List<String>? allowedExtensions}) async =>
|
||||||
|
null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts how many times the lifecycle asked for a backup.
|
||||||
|
class _SpyAutoBackup extends AutoBackupService {
|
||||||
|
_SpyAutoBackup(AppDatabase db)
|
||||||
|
: super(
|
||||||
|
exporter: ExportImportService(
|
||||||
|
repository: newTestRepository(db),
|
||||||
|
files: _NoFiles(),
|
||||||
|
keys: SecureKeyStore(store: InMemorySecretStore()),
|
||||||
|
),
|
||||||
|
store: AutoBackupStore(InMemorySecretStore()),
|
||||||
|
directory: () async => throw UnimplementedError(),
|
||||||
|
);
|
||||||
|
|
||||||
|
int calls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> runIfDue({Duration interval = const Duration(days: 7)}) async {
|
||||||
|
calls++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
|
||||||
|
setUp(() => db = newTestDatabase());
|
||||||
|
tearDown(() => db.close());
|
||||||
|
|
||||||
|
testWidgets('runs a backup on startup', (tester) async {
|
||||||
|
final spy = _SpyAutoBackup(db);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
AutoBackupGate(service: spy, child: const SizedBox()),
|
||||||
|
);
|
||||||
|
await tester.pump(); // let the post-frame callback fire
|
||||||
|
|
||||||
|
expect(spy.calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('runs a backup again when the app is hidden', (tester) async {
|
||||||
|
final spy = _SpyAutoBackup(db);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
AutoBackupGate(service: spy, child: const SizedBox()),
|
||||||
|
);
|
||||||
|
await tester.pump(); // startup backup
|
||||||
|
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
|
||||||
|
|
||||||
|
expect(spy.calls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('does nothing when no service is provided', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const AutoBackupGate(child: Text('ok', textDirection: TextDirection.ltr)),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('ok'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
#include <file_selector_windows/file_selector_windows.h>
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
|
#include <geolocator_windows/geolocator_windows.h>
|
||||||
#include <sqlcipher_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
#include <sqlcipher_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
|
|
@ -16,6 +17,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
|
GeolocatorWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
||||||
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
|
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
|
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
file_selector_windows
|
file_selector_windows
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
|
geolocator_windows
|
||||||
sqlcipher_flutter_libs
|
sqlcipher_flutter_libs
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
|
||||||
48
pubspec.lock
48
pubspec.lock
|
|
@ -519,6 +519,54 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
geolocator:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geolocator
|
||||||
|
sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "13.0.4"
|
||||||
|
geolocator_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geolocator_android
|
||||||
|
sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.6.2"
|
||||||
|
geolocator_apple:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geolocator_apple
|
||||||
|
sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.14"
|
||||||
|
geolocator_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geolocator_platform_interface
|
||||||
|
sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.2.8"
|
||||||
|
geolocator_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geolocator_web
|
||||||
|
sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.4"
|
||||||
|
geolocator_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geolocator_windows
|
||||||
|
sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.5"
|
||||||
get_it:
|
get_it:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue