162 lines
4.9 KiB
Dart
162 lines
4.9 KiB
Dart
/// 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<BackupDocument> buildBackup() async {
|
|
final products = await repository.dumpTable('products');
|
|
final shops = await repository.dumpTable('shops');
|
|
final images = <String, String>{};
|
|
|
|
Future<void> 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 = <Map<String, Object?>>[];
|
|
for (final row in products) {
|
|
final copy = Map<String, Object?>.from(row);
|
|
final path = copy['image_path'] as String?;
|
|
await addImage(path);
|
|
copy['image_path'] = backupImageKey(path);
|
|
rewrittenProducts.add(copy);
|
|
}
|
|
|
|
final rewrittenShops = <Map<String, Object?>>[];
|
|
for (final row in shops) {
|
|
final copy = Map<String, Object?>.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'),
|
|
images: images,
|
|
);
|
|
}
|
|
|
|
Future<bool> 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<bool> 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<void> restoreBackup(String json) async {
|
|
final document = BackupDocument.parse(json);
|
|
await repository.clearImageFolders();
|
|
|
|
final products = <Map<String, Object?>>[];
|
|
final now = DateTime.now().toIso8601String();
|
|
for (final row in document.products) {
|
|
final copy = Map<String, Object?>.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 = <Map<String, Object?>>[];
|
|
for (final row in document.shops) {
|
|
final copy = Map<String, Object?>.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,
|
|
darkMode: document.darkMode,
|
|
);
|
|
}
|
|
|
|
Future<String?> _restoreImage(
|
|
Object? key,
|
|
Map<String, String> 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;
|
|
}
|
|
}
|
|
}
|