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'; /// The backup actions shown in Settings. The primary pair — save/restore a /// 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, 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(); RecoverySheetService get _sheet => sheet ?? getIt(); AutoBackupService? get _autoBackup => autoBackup ?? (getIt.isRegistered() ? getIt() : 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), subtitle: Text(t.backup.exportJsonSubtitle), 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: () => _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( leading: const Icon(Icons.table_view_outlined), title: Text(t.backup.exportCsv), subtitle: Text(t.backup.exportCsvSubtitle), onTap: () => _runExport(context, () => _service.exportCsv()), ), ListTile( leading: const Icon(Icons.playlist_add_outlined), title: Text(t.backup.importCsv), subtitle: Text(t.backup.importCsvSubtitle), onTap: () => _runImportCsv(context), ), ], ); } Future _runExport( BuildContext context, Future Function() export, ) async { final t = context.t; final messenger = ScaffoldMessenger.of(context); try { final saved = await export(); _show(messenger, saved ? t.backup.exportSaved : t.backup.cancelled); } on Object { _show(messenger, t.backup.failed); } } /// Shows this device's recovery code and offers to save the printable /// sheet (QR + code + instructions). Future _showRecoverySheet(BuildContext context) async { final t = context.t; final messenger = ScaffoldMessenger.of(context); final code = await _service.recoveryCode(); if (!context.mounted) return; final save = await showDialog( context: context, builder: (dialogContext) => AlertDialog( 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( 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( key: const Key('backup.recoverySave'), onPressed: () => Navigator.pop(dialogContext, true), child: Text(t.backup.recoverySave), ), ], ), ); if (save != true) return; try { final saved = await _sheet.saveSheet( title: t.backup.recoverySheetTitle, intro: t.backup.recoveryIntro, code: code, suggestedName: 'tanemaki-recovery.pdf', ); _show(messenger, saved ? t.backup.exportSaved : t.backup.cancelled); } on Object { _show(messenger, t.backup.failed); } } Future _runRestore(BuildContext context) async { final t = context.t; final messenger = ScaffoldMessenger.of(context); 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 _askRecoveryCode(BuildContext context) { final t = context.t; final controller = TextEditingController(); return showDialog( context: context, builder: (dialogContext) => AlertDialog( 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.pop(dialogContext), child: Text(t.common.cancel), ), FilledButton( key: const Key('backup.recoveryConfirm'), onPressed: () => Navigator.pop(dialogContext, controller.text.trim()), child: Text(t.backup.importAction), ), ], ), ); } Future _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(); _show( messenger, summary == null ? t.backup.cancelled : t.backup.importCsvDone(count: summary.inserted), ); } on FormatException { _show(messenger, t.backup.importFailed); } on Object { _show(messenger, t.backup.failed); } } Future _confirm( BuildContext context, { required String title, required String body, }) { final t = context.t; return showDialog( 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))); } } /// The automatic-copies line: the app keeps sealed copies on its own, and this /// says when the last one was made. Tapping it makes one right now (handy after /// a big change, and so the entry visibly *does* something). Hidden entirely /// when there is no automatic backup on this platform. class _AutoBackupTile extends StatefulWidget { const _AutoBackupTile({required this.service}); final AutoBackupService? service; @override State<_AutoBackupTile> createState() => _AutoBackupTileState(); } class _AutoBackupTileState extends State<_AutoBackupTile> { late Future _lastBackup = _read(); bool _busy = false; Future _read() async => widget.service?.lastBackupAt(); Future _backupNow() async { final service = widget.service; if (service == null || _busy) return; final t = context.t; final messenger = ScaffoldMessenger.of(context); setState(() => _busy = true); final ok = await service.backupNow(); if (!mounted) return; setState(() { _busy = false; _lastBackup = _read(); }); messenger.showSnackBar( SnackBar(content: Text(ok ? t.backup.exportSaved : t.backup.failed)), ); } @override Widget build(BuildContext context) { if (widget.service == null) return const SizedBox.shrink(); final t = context.t; return FutureBuilder( future: _lastBackup, builder: (context, snapshot) { final last = snapshot.data; final subtitle = last == null ? t.backup.autoBackupNone : t.backup.autoBackupLast( // Format via the Localizations locale, not the raw app locale: // Asturian (`ast`) has no `intl` date symbols and would throw, // but it's mapped to Spanish for the framework (see app.dart). date: DateFormat.yMMMd( Localizations.localeOf(context).languageCode, ).format(last), ); return ListTile( leading: const Icon(Icons.shield_outlined), title: Text(t.backup.autoBackupTitle), subtitle: Text(subtitle), trailing: _busy ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.backup_outlined), onTap: _busy ? null : _backupNow, ); }, ); } }