added categories, import export, settings
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user