Merge branch 'main' into claude/trusting-shannon-ff64dd
# Conflicts: # apps/app_seeds/lib/i18n/strings.g.dart
This commit is contained in:
commit
6eb1517ffb
37 changed files with 2998 additions and 258 deletions
|
|
@ -1,22 +1,29 @@
|
|||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../data/export_import/inventory_snapshot.dart';
|
||||
import '../di/injector.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/export_import_service.dart';
|
||||
import '../services/recovery_sheet_service.dart';
|
||||
|
||||
/// The backup actions shown in Settings. The primary pair — save/restore a
|
||||
/// full copy — comes first; the spreadsheet list export/import is a secondary
|
||||
/// convenience below. File formats (JSON/CSV) are an implementation detail and
|
||||
/// never surface in the UI. Restore/import asks for confirmation first (it
|
||||
/// merges into the live inventory), then reports what happened in a SnackBar.
|
||||
/// full copy — comes first, then the recovery sheet, then the spreadsheet
|
||||
/// list export/import as a secondary convenience. File formats and crypto are
|
||||
/// implementation details and never surface in the UI. Restore/import asks
|
||||
/// for confirmation first (it merges into the live inventory), then reports
|
||||
/// what happened in a SnackBar.
|
||||
class BackupSection extends StatelessWidget {
|
||||
const BackupSection({this.service, super.key});
|
||||
const BackupSection({this.service, this.sheet, super.key});
|
||||
|
||||
/// Injectable for widget tests; the app resolves it 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.
|
||||
final ExportImportService? service;
|
||||
final RecoverySheetService? sheet;
|
||||
|
||||
ExportImportService get _service => service ?? getIt<ExportImportService>();
|
||||
RecoverySheetService get _sheet => sheet ?? getIt<RecoverySheetService>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -27,13 +34,20 @@ class BackupSection extends StatelessWidget {
|
|||
leading: const Icon(Icons.save_alt_outlined),
|
||||
title: Text(t.backup.exportJson),
|
||||
subtitle: Text(t.backup.exportJsonSubtitle),
|
||||
onTap: () => _runExport(context, () => _service.exportJson()),
|
||||
onTap: () => _runExport(context, () => _service.exportBackup()),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings_backup_restore_outlined),
|
||||
title: Text(t.backup.importJson),
|
||||
subtitle: Text(t.backup.importJsonSubtitle),
|
||||
onTap: () => _runImport(context),
|
||||
onTap: () => _runRestore(context),
|
||||
),
|
||||
ListTile(
|
||||
key: const Key('backup.recovery'),
|
||||
leading: const Icon(Icons.qr_code_2_outlined),
|
||||
title: Text(t.backup.recoveryTitle),
|
||||
subtitle: Text(t.backup.recoverySubtitle),
|
||||
onTap: () => _showRecoverySheet(context),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
|
|
@ -66,65 +80,169 @@ class BackupSection extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _runImport(BuildContext context) async {
|
||||
/// Shows this device's recovery code and offers to save the printable
|
||||
/// sheet (QR + code + instructions).
|
||||
Future<void> _showRecoverySheet(BuildContext context) async {
|
||||
final t = context.t;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
final code = await _service.recoveryCode();
|
||||
if (!context.mounted) return;
|
||||
final save = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(t.backup.importConfirmTitle),
|
||||
content: Text(t.backup.importConfirmBody),
|
||||
title: Text(t.backup.recoveryTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(t.backup.recoveryIntro),
|
||||
const SizedBox(height: 12),
|
||||
SelectableText(
|
||||
code,
|
||||
key: const Key('backup.recoveryCode'),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(t.common.cancel),
|
||||
key: const Key('backup.recoveryCopy'),
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: code));
|
||||
if (dialogContext.mounted) Navigator.pop(dialogContext, false);
|
||||
},
|
||||
child: Text(t.backup.recoveryCopy),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text(t.backup.importAction),
|
||||
key: const Key('backup.recoverySave'),
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: Text(t.backup.recoverySave),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
if (save != true) return;
|
||||
try {
|
||||
final summary = await _service.importJson();
|
||||
_show(
|
||||
messenger,
|
||||
summary == null
|
||||
? t.backup.cancelled
|
||||
: t.backup.importDone(
|
||||
added: summary.inserted,
|
||||
updated: summary.updated,
|
||||
),
|
||||
final saved = await _sheet.saveSheet(
|
||||
title: t.backup.recoverySheetTitle,
|
||||
intro: t.backup.recoveryIntro,
|
||||
code: code,
|
||||
suggestedName: 'tanemaki-recovery.pdf',
|
||||
);
|
||||
} on FormatException {
|
||||
_show(messenger, t.backup.importFailed);
|
||||
_show(messenger, saved ? t.backup.exportSaved : t.backup.cancelled);
|
||||
} on Object {
|
||||
_show(messenger, t.backup.failed);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runImportCsv(BuildContext context) async {
|
||||
Future<void> _runRestore(BuildContext context) async {
|
||||
final t = context.t;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
final confirmed = await _confirm(
|
||||
context,
|
||||
title: t.backup.importConfirmTitle,
|
||||
body: t.backup.importConfirmBody,
|
||||
);
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
|
||||
final PendingBackup? pending;
|
||||
try {
|
||||
pending = await _service.pickBackup();
|
||||
} on Object {
|
||||
_show(messenger, t.backup.failed);
|
||||
return;
|
||||
}
|
||||
if (pending == null) {
|
||||
_show(messenger, t.backup.cancelled);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
_report(messenger, t, await _service.restoreBackup(pending));
|
||||
return;
|
||||
} on BackupAuthException {
|
||||
// Sealed under another identity: ask for the printed code below.
|
||||
} on FormatException {
|
||||
_show(messenger, t.backup.importFailed);
|
||||
return;
|
||||
} on Object {
|
||||
_show(messenger, t.backup.failed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
final code = await _askRecoveryCode(context);
|
||||
if (code == null) {
|
||||
_show(messenger, t.backup.cancelled);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
_report(
|
||||
messenger,
|
||||
t,
|
||||
await _service.restoreBackup(pending, recoveryCode: code),
|
||||
);
|
||||
} on BackupAuthException {
|
||||
_show(messenger, t.backup.recoveryWrongCode);
|
||||
} on FormatException {
|
||||
_show(messenger, t.backup.recoveryWrongCode);
|
||||
} on Object {
|
||||
_show(messenger, t.backup.failed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks for the code printed on the recovery sheet.
|
||||
Future<String?> _askRecoveryCode(BuildContext context) {
|
||||
final t = context.t;
|
||||
final controller = TextEditingController();
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(t.backup.importCsvConfirmTitle),
|
||||
content: Text(t.backup.importCsvConfirmBody),
|
||||
title: Text(t.backup.recoveryPromptTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(t.backup.recoveryPromptBody),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
key: const Key('backup.recoveryField'),
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.backup.recoveryTitle,
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
key: const Key('backup.recoveryConfirm'),
|
||||
onPressed: () =>
|
||||
Navigator.pop(dialogContext, controller.text.trim()),
|
||||
child: Text(t.backup.importAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _runImportCsv(BuildContext context) async {
|
||||
final t = context.t;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final confirmed = await _confirm(
|
||||
context,
|
||||
title: t.backup.importCsvConfirmTitle,
|
||||
body: t.backup.importCsvConfirmBody,
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
final summary = await _service.importCsv();
|
||||
|
|
@ -141,6 +259,42 @@ class BackupSection extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
Future<bool?> _confirm(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String body,
|
||||
}) {
|
||||
final t = context.t;
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(body),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text(t.backup.importAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _report(
|
||||
ScaffoldMessengerState messenger,
|
||||
Translations t,
|
||||
ImportSummary summary,
|
||||
) {
|
||||
_show(
|
||||
messenger,
|
||||
t.backup.importDone(added: summary.inserted, updated: summary.updated),
|
||||
);
|
||||
}
|
||||
|
||||
void _show(ScaffoldMessengerState messenger, String message) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ class SettingsScreen extends StatelessWidget {
|
|||
selected: current == 'en',
|
||||
onTap: () => LocaleSettings.setLocale(AppLocale.en),
|
||||
),
|
||||
_LanguageTile(
|
||||
label: t.settings.langPt,
|
||||
selected: current == 'pt',
|
||||
onTap: () => LocaleSettings.setLocale(AppLocale.pt),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.smartphone_outlined),
|
||||
title: Text(t.settings.systemLanguage),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue