/// Build, share, and restore a Receipity JSON backup. library; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'backup_format.dart'; import 'product_repository.dart'; class BackupService { BackupService(this.repository); final ProductRepository repository; Future buildBackup() async { final products = await repository.dumpTable('products'); final shops = await repository.dumpTable('shops'); final images = {}; Future addImage(String? path) async { final key = backupImageKey(path); if (key == null || images.containsKey(key)) return; final file = File(path!); if (!file.existsSync()) return; images[key] = base64Encode(await file.readAsBytes()); } final rewrittenProducts = >[]; for (final row in products) { final copy = Map.from(row); final path = copy['image_path'] as String?; await addImage(path); copy['image_path'] = backupImageKey(path); rewrittenProducts.add(copy); } final rewrittenShops = >[]; for (final row in shops) { final copy = Map.from(row); final path = copy['receipt_image_path'] as String?; await addImage(path); copy['receipt_image_path'] = backupImageKey(path); rewrittenShops.add(copy); } return BackupDocument( version: kBackupVersion, exportedAt: DateTime.now().toIso8601String(), darkMode: await repository.getDarkMode(), supermarkets: await repository.dumpTable('supermarkets'), products: rewrittenProducts, shops: rewrittenShops, shopItems: await repository.dumpTable('shop_items'), shoppingLists: await repository.dumpTable('shopping_lists'), shoppingListItems: await repository.dumpTable('shopping_list_items'), images: images, ); } Future exportBackup() async { final document = await buildBackup(); final bytes = Uint8List.fromList(utf8.encode(document.encode())); final stamp = DateTime.now().toIso8601String().split('T').first; final fileName = 'receipity-backup-$stamp.json'; try { final saved = await FilePicker.saveFile( dialogTitle: 'Save Receipity backup', fileName: fileName, bytes: bytes, mimeType: 'application/json', type: FileType.custom, allowedExtensions: const ['json'], ); return saved != null; } catch (_) { // Fall through to the share sheet if save-as is unavailable. } final dir = await getTemporaryDirectory(); final file = File(p.join(dir.path, fileName)); await file.writeAsBytes(bytes, flush: true); final result = await SharePlus.instance.share( ShareParams( files: [XFile(file.path, mimeType: 'application/json')], subject: 'Receipity backup', text: 'Receipity backup $stamp', ), ); return result.status != ShareResultStatus.dismissed; } Future importBackup() async { final picked = await FilePicker.pickFile( dialogTitle: 'Import Receipity backup', type: FileType.custom, allowedExtensions: const ['json'], ); if (picked == null) return false; final bytes = await picked.readAsBytes(); await restoreBackup(utf8.decode(bytes)); return true; } Future restoreBackup(String json) async { final document = BackupDocument.parse(json); await repository.clearImageFolders(); final products = >[]; final now = DateTime.now().toIso8601String(); for (final row in document.products) { final copy = Map.from(row); copy['image_path'] = await _restoreImage( copy['image_path'], document.images, ); copy['created_at'] ??= now; copy['updated_at'] ??= copy['created_at']; if (copy['normalized_name'] == null && copy['name'] is String) { copy['normalized_name'] = ProductRepository.normalizeName( copy['name']! as String, ); } products.add(copy); } final shops = >[]; for (final row in document.shops) { final copy = Map.from(row); copy['receipt_image_path'] = await _restoreImage( copy['receipt_image_path'], document.images, ); copy['created_at'] ??= now; shops.add(copy); } await repository.replaceAllData( supermarkets: document.supermarkets, products: products, shops: shops, shopItems: document.shopItems, shoppingLists: document.shoppingLists, shoppingListItems: document.shoppingListItems, darkMode: document.darkMode, ); } Future _restoreImage(Object? key, Map images) async { if (key is! String || key.isEmpty) return null; final encoded = images[key]; if (encoded == null) return null; try { return await repository.writeImageBytes(key, base64Decode(encoded)); } catch (_) { return null; } } }