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:
vjrj 2026-07-10 10:50:16 +02:00
commit cf99b7ff87
23 changed files with 607 additions and 25 deletions

View 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;
}

View file

@ -1,10 +1,12 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import '../data/export_import/inventory_snapshot.dart';
import '../di/injector.dart';
import '../i18n/strings.g.dart';
import '../services/auto_backup_service.dart';
import '../services/export_import_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
/// what happened in a SnackBar.
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
/// the service locator so merely mounting Settings needs no DI setup.
final ExportImportService? service;
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>();
RecoverySheetService get _sheet => sheet ?? getIt<RecoverySheetService>();
AutoBackupService? get _autoBackup =>
autoBackup ??
(getIt.isRegistered<AutoBackupService>()
? getIt<AutoBackupService>()
: null);
@override
Widget build(BuildContext context) {
final t = context.t;
return Column(
children: [
_AutoBackupTile(service: _autoBackup),
ListTile(
leading: const Icon(Icons.save_alt_outlined),
title: Text(t.backup.exportJson),
@ -299,3 +312,37 @@ class BackupSection extends StatelessWidget {
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),
);
},
);
}
}