feat(inventory): CSV/JSON export and JSON import with LWW reconciliation
Interchange export/import for Phase 1 (data-model §7): - JSON: canonical, versioned envelope (formatVersion 1) with all sync metadata verbatim, photos embedded as base64, tombstones excluded. Species are re-resolved on import by scientific name (catalog ids are per-install). Reader tolerates unknown fields/enum values (§5.2) and rejects newer format versions with a clear error. - CSV: export-only spreadsheet flatten, one row per lot, RFC 4180 escaping, never any photo bytes. - Import merges by UUIDv7 id in one transaction: insert-if-unknown preserving original stamps, last-writer-wins by packed HLC for known mutable rows, append-only movements; afterwards the local clock receiveEvent()s the newest imported stamp so it never runs behind. - Settings gains a Backup section (export CSV/JSON, import JSON with confirmation); file dialogs behind a FileService interface backed by file_picker (MIT). - Tests: codec round-trip and tolerance, reconciler LWW, repository export→import round-trip (fresh DB, idempotent re-import, newer-local wins, clock monotonicity, species re-resolution), backup widget flows.
This commit is contained in:
parent
136ed701a7
commit
2812c99280
25 changed files with 2207 additions and 7 deletions
42
apps/app_seeds/lib/data/export_import/import_reconciler.dart
Normal file
42
apps/app_seeds/lib/data/export_import/import_reconciler.dart
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import 'package:commons_core/commons_core.dart';
|
||||
|
||||
/// What to do with one incoming row when importing (data-model §7).
|
||||
enum ReconcileAction { insert, update, skip }
|
||||
|
||||
/// Pure reconciliation rules for the JSON import. No database access — the
|
||||
/// repository queries existing rows and applies the returned action.
|
||||
class ImportReconciler {
|
||||
const ImportReconciler();
|
||||
|
||||
/// Last-writer-wins for mutable rows, keyed by the packed HLC `updatedAt`
|
||||
/// (data-model §4): unknown id → insert; known id → the incoming row wins
|
||||
/// only if its stamp is strictly newer, otherwise skip. Re-importing the
|
||||
/// same file is therefore a no-op (idempotent).
|
||||
ReconcileAction reconcileMutable({
|
||||
required String? existingUpdatedAt,
|
||||
required String incomingUpdatedAt,
|
||||
}) {
|
||||
if (existingUpdatedAt == null) return ReconcileAction.insert;
|
||||
final incoming = Hlc.parse(incomingUpdatedAt);
|
||||
final existing = Hlc.parse(existingUpdatedAt);
|
||||
return incoming.compareTo(existing) > 0
|
||||
? ReconcileAction.update
|
||||
: ReconcileAction.skip;
|
||||
}
|
||||
|
||||
/// Append-only rows (movements): insert when unknown, never update.
|
||||
ReconcileAction reconcileAppendOnly({required bool exists}) =>
|
||||
exists ? ReconcileAction.skip : ReconcileAction.insert;
|
||||
|
||||
/// The newest stamp among [packedHlcs], or null when empty. After an import
|
||||
/// the local clock must `receiveEvent` this so it never falls behind the
|
||||
/// imported rows (the next local write must out-stamp all of them).
|
||||
Hlc? maxHlc(Iterable<String> packedHlcs) {
|
||||
Hlc? max;
|
||||
for (final packed in packedHlcs) {
|
||||
final hlc = Hlc.parse(packed);
|
||||
if (max == null || hlc.compareTo(max) > 0) max = hlc;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
}
|
||||
144
apps/app_seeds/lib/data/export_import/inventory_csv_codec.dart
Normal file
144
apps/app_seeds/lib/data/export_import/inventory_csv_codec.dart
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import '../../db/database.dart';
|
||||
import '../../db/enums.dart';
|
||||
import 'inventory_snapshot.dart';
|
||||
|
||||
/// Flattens an [InventorySnapshot] into a spreadsheet-friendly CSV — one row
|
||||
/// per lot, one row for a variety without lots. Export-only: the JSON format
|
||||
/// is the canonical one for import (data-model §7). Never includes photo
|
||||
/// bytes. Pure serialization — no I/O, no database.
|
||||
class InventoryCsvCodec {
|
||||
const InventoryCsvCodec();
|
||||
|
||||
static const List<String> _header = [
|
||||
'variety_id',
|
||||
'variety_label',
|
||||
'category',
|
||||
'species_scientific_name',
|
||||
'cultivar_name',
|
||||
'variety_notes',
|
||||
'vernacular_names',
|
||||
'external_links',
|
||||
'lot_id',
|
||||
'lot_type',
|
||||
'harvest_year',
|
||||
'harvest_month',
|
||||
'quantity_kind',
|
||||
'quantity_precise',
|
||||
'quantity_label',
|
||||
'presentation',
|
||||
'storage_location',
|
||||
'offer_status',
|
||||
'latest_germination_rate',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
String encode(InventorySnapshot snapshot) {
|
||||
final lotsByVariety = <String, List<Lot>>{};
|
||||
for (final lot in snapshot.lots) {
|
||||
lotsByVariety.putIfAbsent(lot.varietyId, () => []).add(lot);
|
||||
}
|
||||
final namesByVariety = <String, List<String>>{};
|
||||
for (final n in snapshot.vernacularNames) {
|
||||
namesByVariety
|
||||
.putIfAbsent(n.varietyId, () => [])
|
||||
.add(n.language == null ? n.name : '${n.name} (${n.language})');
|
||||
}
|
||||
final linksByVariety = <String, List<String>>{};
|
||||
for (final e in snapshot.externalLinks) {
|
||||
if (e.parentType != ParentType.variety) continue;
|
||||
linksByVariety
|
||||
.putIfAbsent(e.parentId, () => [])
|
||||
.add(e.title == null ? e.url : '${e.title}|${e.url}');
|
||||
}
|
||||
final latestRateByLot = _latestGerminationRates(snapshot.germinationTests);
|
||||
|
||||
final lines = <String>[_header.map(_escape).join(',')];
|
||||
for (final v in snapshot.varieties) {
|
||||
final varietyCells = [
|
||||
v.id,
|
||||
v.label,
|
||||
v.category ?? '',
|
||||
snapshot.speciesNamesById[v.speciesId] ?? '',
|
||||
v.cultivarName ?? '',
|
||||
v.notes ?? '',
|
||||
(namesByVariety[v.id] ?? const []).join('; '),
|
||||
(linksByVariety[v.id] ?? const []).join('; '),
|
||||
];
|
||||
final lots = lotsByVariety[v.id] ?? const <Lot>[];
|
||||
if (lots.isEmpty) {
|
||||
lines.add(
|
||||
[
|
||||
...varietyCells,
|
||||
...List.filled(11, ''),
|
||||
_isoDate(v.createdAt),
|
||||
_hlcMillisIso(v.updatedAt),
|
||||
].map(_escape).join(','),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (final l in lots) {
|
||||
lines.add(
|
||||
[
|
||||
...varietyCells,
|
||||
l.id,
|
||||
l.type.name,
|
||||
l.harvestYear?.toString() ?? '',
|
||||
l.harvestMonth?.toString() ?? '',
|
||||
l.quantityKind ?? '',
|
||||
l.quantityPrecise?.toString() ?? '',
|
||||
l.quantityLabel ?? '',
|
||||
l.presentation?.name ?? '',
|
||||
l.storageLocation ?? '',
|
||||
l.offerStatus.name,
|
||||
latestRateByLot[l.id]?.toStringAsFixed(2) ?? '',
|
||||
_isoDate(l.createdAt),
|
||||
_hlcMillisIso(l.updatedAt),
|
||||
].map(_escape).join(','),
|
||||
);
|
||||
}
|
||||
}
|
||||
return '${lines.join('\r\n')}\r\n';
|
||||
}
|
||||
|
||||
/// lotId → latest germination rate (0..1), from the most recent test that
|
||||
/// has a computable rate.
|
||||
Map<String, double> _latestGerminationRates(List<GerminationTest> tests) {
|
||||
final latest = <String, GerminationTest>{};
|
||||
for (final t in tests) {
|
||||
final sample = t.sampleSize;
|
||||
if (sample == null || sample <= 0 || t.germinatedCount == null) continue;
|
||||
final current = latest[t.lotId];
|
||||
if (current == null || (t.testedOn ?? 0) > (current.testedOn ?? 0)) {
|
||||
latest[t.lotId] = t;
|
||||
}
|
||||
}
|
||||
return latest.map(
|
||||
(lotId, t) => MapEntry(lotId, t.germinatedCount! / t.sampleSize!),
|
||||
);
|
||||
}
|
||||
|
||||
String _isoDate(int millisSinceEpoch) => DateTime.fromMillisecondsSinceEpoch(
|
||||
millisSinceEpoch,
|
||||
isUtc: true,
|
||||
).toIso8601String();
|
||||
|
||||
/// Renders the physical-time part of a packed HLC as an ISO date; the raw
|
||||
/// stamp stays in the JSON export, the CSV favours readability.
|
||||
String _hlcMillisIso(String packedHlc) {
|
||||
final millis = int.tryParse(packedHlc.split(':').first);
|
||||
return millis == null ? '' : _isoDate(millis);
|
||||
}
|
||||
|
||||
/// RFC 4180: quote fields containing separators/quotes/newlines, doubling
|
||||
/// embedded quotes.
|
||||
String _escape(String cell) {
|
||||
if (!cell.contains(',') &&
|
||||
!cell.contains('"') &&
|
||||
!cell.contains('\n') &&
|
||||
!cell.contains('\r')) {
|
||||
return cell;
|
||||
}
|
||||
return '"${cell.replaceAll('"', '""')}"';
|
||||
}
|
||||
}
|
||||
411
apps/app_seeds/lib/data/export_import/inventory_json_codec.dart
Normal file
411
apps/app_seeds/lib/data/export_import/inventory_json_codec.dart
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart' show Uint8List;
|
||||
|
||||
import '../../db/database.dart';
|
||||
import '../../db/enums.dart';
|
||||
import 'inventory_snapshot.dart';
|
||||
|
||||
/// Encodes/decodes an [InventorySnapshot] to/from the versioned interchange
|
||||
/// JSON (data-model §7). Pure serialization — no I/O, no database.
|
||||
///
|
||||
/// Reader tolerance follows data-model §5.2: unknown JSON fields are ignored;
|
||||
/// unknown enum values fall back to a safe default, or drop the row when no
|
||||
/// default makes sense (e.g. an unknown movement type). Photo/doc bytes are
|
||||
/// embedded as base64 — the JSON export is the complete interchange backup.
|
||||
///
|
||||
/// Throws [FormatException] on malformed input or a `formatVersion` newer
|
||||
/// than [inventoryFormatVersion].
|
||||
class InventoryJsonCodec {
|
||||
const InventoryJsonCodec();
|
||||
|
||||
String encode(InventorySnapshot snapshot) {
|
||||
return const JsonEncoder.withIndent(' ').convert({
|
||||
'formatVersion': inventoryFormatVersion,
|
||||
'schemaVersion': AppDatabase.currentSchemaVersion,
|
||||
'appId': inventoryAppId,
|
||||
'varieties': [
|
||||
for (final v in snapshot.varieties)
|
||||
{
|
||||
..._syncMeta(
|
||||
id: v.id,
|
||||
createdAt: v.createdAt,
|
||||
updatedAt: v.updatedAt,
|
||||
lastAuthor: v.lastAuthor,
|
||||
schemaRowVersion: v.schemaRowVersion,
|
||||
),
|
||||
'label': v.label,
|
||||
'speciesId': v.speciesId,
|
||||
// Species ids are per-install; the scientific name is what an
|
||||
// importer can re-resolve against its own bundled catalog.
|
||||
'speciesScientificName': snapshot.speciesNamesById[v.speciesId],
|
||||
'cultivarName': v.cultivarName,
|
||||
'category': v.category,
|
||||
'notes': v.notes,
|
||||
},
|
||||
],
|
||||
'lots': [
|
||||
for (final l in snapshot.lots)
|
||||
{
|
||||
..._syncMeta(
|
||||
id: l.id,
|
||||
createdAt: l.createdAt,
|
||||
updatedAt: l.updatedAt,
|
||||
lastAuthor: l.lastAuthor,
|
||||
schemaRowVersion: l.schemaRowVersion,
|
||||
),
|
||||
'varietyId': l.varietyId,
|
||||
'type': l.type.name,
|
||||
'harvestYear': l.harvestYear,
|
||||
'harvestMonth': l.harvestMonth,
|
||||
'quantityKind': l.quantityKind,
|
||||
'quantityPrecise': l.quantityPrecise,
|
||||
'quantityLabel': l.quantityLabel,
|
||||
'presentation': l.presentation?.name,
|
||||
'storageLocation': l.storageLocation,
|
||||
'offerStatus': l.offerStatus.name,
|
||||
'seedbankId': l.seedbankId,
|
||||
},
|
||||
],
|
||||
'vernacularNames': [
|
||||
for (final n in snapshot.vernacularNames)
|
||||
{
|
||||
..._syncMeta(
|
||||
id: n.id,
|
||||
createdAt: n.createdAt,
|
||||
updatedAt: n.updatedAt,
|
||||
lastAuthor: n.lastAuthor,
|
||||
schemaRowVersion: n.schemaRowVersion,
|
||||
),
|
||||
'varietyId': n.varietyId,
|
||||
'name': n.name,
|
||||
'language': n.language,
|
||||
'region': n.region,
|
||||
},
|
||||
],
|
||||
'externalLinks': [
|
||||
for (final e in snapshot.externalLinks)
|
||||
{
|
||||
..._syncMeta(
|
||||
id: e.id,
|
||||
createdAt: e.createdAt,
|
||||
updatedAt: e.updatedAt,
|
||||
lastAuthor: e.lastAuthor,
|
||||
schemaRowVersion: e.schemaRowVersion,
|
||||
),
|
||||
'parentType': e.parentType.name,
|
||||
'parentId': e.parentId,
|
||||
'url': e.url,
|
||||
'title': e.title,
|
||||
},
|
||||
],
|
||||
'germinationTests': [
|
||||
for (final g in snapshot.germinationTests)
|
||||
{
|
||||
..._syncMeta(
|
||||
id: g.id,
|
||||
createdAt: g.createdAt,
|
||||
updatedAt: g.updatedAt,
|
||||
lastAuthor: g.lastAuthor,
|
||||
schemaRowVersion: g.schemaRowVersion,
|
||||
),
|
||||
'lotId': g.lotId,
|
||||
'testedOn': g.testedOn,
|
||||
'sampleSize': g.sampleSize,
|
||||
'germinatedCount': g.germinatedCount,
|
||||
'notes': g.notes,
|
||||
},
|
||||
],
|
||||
'movements': [
|
||||
for (final m in snapshot.movements)
|
||||
{
|
||||
// Append-only rows: no updatedAt/isDeleted.
|
||||
'id': m.id,
|
||||
'createdAt': m.createdAt,
|
||||
'lastAuthor': m.lastAuthor,
|
||||
'schemaRowVersion': m.schemaRowVersion,
|
||||
'lotId': m.lotId,
|
||||
'type': m.type.name,
|
||||
'occurredOn': m.occurredOn,
|
||||
'counterpartyId': m.counterpartyId,
|
||||
'quantityKind': m.quantityKind,
|
||||
'quantityPrecise': m.quantityPrecise,
|
||||
'quantityLabel': m.quantityLabel,
|
||||
'parentMovementId': m.parentMovementId,
|
||||
'plantareId': m.plantareId,
|
||||
'notes': m.notes,
|
||||
},
|
||||
],
|
||||
'parties': [
|
||||
for (final p in snapshot.parties)
|
||||
{
|
||||
..._syncMeta(
|
||||
id: p.id,
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
lastAuthor: p.lastAuthor,
|
||||
schemaRowVersion: p.schemaRowVersion,
|
||||
),
|
||||
'displayName': p.displayName,
|
||||
'publicKey': p.publicKey,
|
||||
'kind': p.kind.name,
|
||||
'note': p.note,
|
||||
},
|
||||
],
|
||||
'attachments': [
|
||||
for (final a in snapshot.attachments)
|
||||
{
|
||||
..._syncMeta(
|
||||
id: a.id,
|
||||
createdAt: a.createdAt,
|
||||
updatedAt: a.updatedAt,
|
||||
lastAuthor: a.lastAuthor,
|
||||
schemaRowVersion: a.schemaRowVersion,
|
||||
),
|
||||
'parentType': a.parentType.name,
|
||||
'parentId': a.parentId,
|
||||
'kind': a.kind.name,
|
||||
'uri': a.uri,
|
||||
'bytesBase64': a.bytes == null ? null : base64Encode(a.bytes!),
|
||||
'mimeType': a.mimeType,
|
||||
'sortOrder': a.sortOrder,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
InventorySnapshot decode(String source) {
|
||||
final Object? root;
|
||||
try {
|
||||
root = jsonDecode(source);
|
||||
} on FormatException {
|
||||
throw const FormatException('Not a valid JSON file.');
|
||||
}
|
||||
if (root is! Map<String, dynamic>) {
|
||||
throw const FormatException('Not a Tanemaki inventory file.');
|
||||
}
|
||||
final version = root['formatVersion'];
|
||||
if (version is! int) {
|
||||
throw const FormatException('Missing formatVersion.');
|
||||
}
|
||||
if (version > inventoryFormatVersion) {
|
||||
throw FormatException(
|
||||
'This file uses format v$version; this app reads up to '
|
||||
'v$inventoryFormatVersion. Update the app to import it.',
|
||||
);
|
||||
}
|
||||
|
||||
final speciesNamesById = <String, String>{};
|
||||
final varieties = _rows(root, 'varieties', (m) {
|
||||
final speciesId = m['speciesId'] as String?;
|
||||
final scientificName = m['speciesScientificName'] as String?;
|
||||
if (speciesId != null && scientificName != null) {
|
||||
speciesNamesById[speciesId] = scientificName;
|
||||
}
|
||||
return Variety(
|
||||
id: _string(m, 'id'),
|
||||
createdAt: _int(m, 'createdAt'),
|
||||
updatedAt: _string(m, 'updatedAt'),
|
||||
lastAuthor: _string(m, 'lastAuthor'),
|
||||
isDeleted: false,
|
||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||
label: _string(m, 'label'),
|
||||
speciesId: speciesId,
|
||||
cultivarName: m['cultivarName'] as String?,
|
||||
category: m['category'] as String?,
|
||||
notes: m['notes'] as String?,
|
||||
);
|
||||
});
|
||||
|
||||
return InventorySnapshot(
|
||||
varieties: varieties,
|
||||
speciesNamesById: speciesNamesById,
|
||||
lots: _rows(root, 'lots', (m) {
|
||||
return Lot(
|
||||
id: _string(m, 'id'),
|
||||
createdAt: _int(m, 'createdAt'),
|
||||
updatedAt: _string(m, 'updatedAt'),
|
||||
lastAuthor: _string(m, 'lastAuthor'),
|
||||
isDeleted: false,
|
||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||
varietyId: _string(m, 'varietyId'),
|
||||
// Unknown enum values → safe defaults (data-model §5.2).
|
||||
type: _enumOr(LotType.values, m['type'], LotType.seed),
|
||||
harvestYear: m['harvestYear'] as int?,
|
||||
harvestMonth: m['harvestMonth'] as int?,
|
||||
quantityKind: m['quantityKind'] as String?,
|
||||
quantityPrecise: (m['quantityPrecise'] as num?)?.toDouble(),
|
||||
quantityLabel: m['quantityLabel'] as String?,
|
||||
presentation: _enumOrNull(Presentation.values, m['presentation']),
|
||||
storageLocation: m['storageLocation'] as String?,
|
||||
offerStatus: _enumOr(
|
||||
OfferStatus.values,
|
||||
m['offerStatus'],
|
||||
OfferStatus.private,
|
||||
),
|
||||
seedbankId: m['seedbankId'] as String?,
|
||||
);
|
||||
}),
|
||||
vernacularNames: _rows(root, 'vernacularNames', (m) {
|
||||
return VarietyVernacularName(
|
||||
id: _string(m, 'id'),
|
||||
createdAt: _int(m, 'createdAt'),
|
||||
updatedAt: _string(m, 'updatedAt'),
|
||||
lastAuthor: _string(m, 'lastAuthor'),
|
||||
isDeleted: false,
|
||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||
varietyId: _string(m, 'varietyId'),
|
||||
name: _string(m, 'name'),
|
||||
language: m['language'] as String?,
|
||||
region: m['region'] as String?,
|
||||
);
|
||||
}),
|
||||
externalLinks: _rows(root, 'externalLinks', (m) {
|
||||
final parentType = _enumOrNull(ParentType.values, m['parentType']);
|
||||
if (parentType == null) return null; // no safe default → drop row
|
||||
return ExternalLink(
|
||||
id: _string(m, 'id'),
|
||||
createdAt: _int(m, 'createdAt'),
|
||||
updatedAt: _string(m, 'updatedAt'),
|
||||
lastAuthor: _string(m, 'lastAuthor'),
|
||||
isDeleted: false,
|
||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||
parentType: parentType,
|
||||
parentId: _string(m, 'parentId'),
|
||||
url: _string(m, 'url'),
|
||||
title: m['title'] as String?,
|
||||
);
|
||||
}),
|
||||
germinationTests: _rows(root, 'germinationTests', (m) {
|
||||
return GerminationTest(
|
||||
id: _string(m, 'id'),
|
||||
createdAt: _int(m, 'createdAt'),
|
||||
updatedAt: _string(m, 'updatedAt'),
|
||||
lastAuthor: _string(m, 'lastAuthor'),
|
||||
isDeleted: false,
|
||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||
lotId: _string(m, 'lotId'),
|
||||
testedOn: m['testedOn'] as int?,
|
||||
sampleSize: m['sampleSize'] as int?,
|
||||
germinatedCount: m['germinatedCount'] as int?,
|
||||
notes: m['notes'] as String?,
|
||||
);
|
||||
}),
|
||||
movements: _rows(root, 'movements', (m) {
|
||||
final type = _enumOrNull(MovementType.values, m['type']);
|
||||
if (type == null) return null; // no safe default → drop row
|
||||
return Movement(
|
||||
id: _string(m, 'id'),
|
||||
createdAt: _int(m, 'createdAt'),
|
||||
lastAuthor: _string(m, 'lastAuthor'),
|
||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||
lotId: _string(m, 'lotId'),
|
||||
type: type,
|
||||
occurredOn: m['occurredOn'] as int?,
|
||||
counterpartyId: m['counterpartyId'] as String?,
|
||||
quantityKind: m['quantityKind'] as String?,
|
||||
quantityPrecise: (m['quantityPrecise'] as num?)?.toDouble(),
|
||||
quantityLabel: m['quantityLabel'] as String?,
|
||||
parentMovementId: m['parentMovementId'] as String?,
|
||||
plantareId: m['plantareId'] as String?,
|
||||
notes: m['notes'] as String?,
|
||||
);
|
||||
}),
|
||||
parties: _rows(root, 'parties', (m) {
|
||||
return Party(
|
||||
id: _string(m, 'id'),
|
||||
createdAt: _int(m, 'createdAt'),
|
||||
updatedAt: _string(m, 'updatedAt'),
|
||||
lastAuthor: _string(m, 'lastAuthor'),
|
||||
isDeleted: false,
|
||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||
displayName: _string(m, 'displayName'),
|
||||
publicKey: m['publicKey'] as String?,
|
||||
kind: _enumOr(PartyKind.values, m['kind'], PartyKind.person),
|
||||
note: m['note'] as String?,
|
||||
);
|
||||
}),
|
||||
attachments: _rows(root, 'attachments', (m) {
|
||||
final parentType = _enumOrNull(ParentType.values, m['parentType']);
|
||||
final kind = _enumOrNull(AttachmentKind.values, m['kind']);
|
||||
if (parentType == null || kind == null) return null; // drop row
|
||||
final bytesBase64 = m['bytesBase64'] as String?;
|
||||
return Attachment(
|
||||
id: _string(m, 'id'),
|
||||
createdAt: _int(m, 'createdAt'),
|
||||
updatedAt: _string(m, 'updatedAt'),
|
||||
lastAuthor: _string(m, 'lastAuthor'),
|
||||
isDeleted: false,
|
||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||
parentType: parentType,
|
||||
parentId: _string(m, 'parentId'),
|
||||
kind: kind,
|
||||
uri: m['uri'] as String?,
|
||||
bytes: bytesBase64 == null
|
||||
? null
|
||||
: Uint8List.fromList(base64Decode(bytesBase64)),
|
||||
mimeType: m['mimeType'] as String?,
|
||||
sortOrder: _int(m, 'sortOrder', fallback: 0),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> _syncMeta({
|
||||
required String id,
|
||||
required int createdAt,
|
||||
required String updatedAt,
|
||||
required String lastAuthor,
|
||||
required int schemaRowVersion,
|
||||
}) => {
|
||||
'id': id,
|
||||
'createdAt': createdAt,
|
||||
'updatedAt': updatedAt,
|
||||
'lastAuthor': lastAuthor,
|
||||
'schemaRowVersion': schemaRowVersion,
|
||||
};
|
||||
|
||||
/// Maps the JSON list under [key] through [decodeRow], skipping entries
|
||||
/// that are not objects, fail to decode, or return null (unknown enums).
|
||||
List<T> _rows<T>(
|
||||
Map<String, dynamic> root,
|
||||
String key,
|
||||
T? Function(Map<String, dynamic>) decodeRow,
|
||||
) {
|
||||
final raw = root[key];
|
||||
if (raw is! List) return const [];
|
||||
final rows = <T>[];
|
||||
for (final entry in raw) {
|
||||
if (entry is! Map<String, dynamic>) continue;
|
||||
final T? row;
|
||||
try {
|
||||
row = decodeRow(entry);
|
||||
} on FormatException {
|
||||
continue; // a broken row must not abort the whole import
|
||||
}
|
||||
if (row != null) rows.add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
String _string(Map<String, dynamic> m, String key) {
|
||||
final value = m[key];
|
||||
if (value is! String) throw FormatException('Missing "$key"');
|
||||
return value;
|
||||
}
|
||||
|
||||
int _int(Map<String, dynamic> m, String key, {int? fallback}) {
|
||||
final value = m[key];
|
||||
if (value is int) return value;
|
||||
if (fallback != null) return fallback;
|
||||
throw FormatException('Missing "$key"');
|
||||
}
|
||||
|
||||
T _enumOr<T extends Enum>(List<T> values, Object? name, T fallback) =>
|
||||
_enumOrNull(values, name) ?? fallback;
|
||||
|
||||
T? _enumOrNull<T extends Enum>(List<T> values, Object? name) {
|
||||
if (name is! String) return null;
|
||||
return values.asNameMap()[name];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import '../../db/database.dart';
|
||||
|
||||
/// The current version of the interchange JSON format (envelope field
|
||||
/// `formatVersion`). Bump only with additive, documented changes — readers
|
||||
/// must reject files with a *newer* version and tolerate unknown fields in
|
||||
/// the same version (data-model §5.2, §7).
|
||||
const int inventoryFormatVersion = 1;
|
||||
|
||||
/// Identifies files produced by this app (envelope field `appId`).
|
||||
const String inventoryAppId = 'tane';
|
||||
|
||||
/// An in-memory snapshot of the user's inventory, decoupled from any file
|
||||
/// format. Produced by `VarietyRepository.exportInventory()` and consumed by
|
||||
/// `importInventory()`; the JSON/CSV codecs turn it into bytes and back.
|
||||
///
|
||||
/// Rows are the plain Drift data classes (immutable). Tombstones
|
||||
/// (`isDeleted = true`) are never part of a snapshot: this is a user-facing
|
||||
/// export, not the future sync wire format. The bundled species catalog is
|
||||
/// not included either — [speciesNamesById] carries just the scientific
|
||||
/// names needed to re-link varieties on import (species ids are generated
|
||||
/// per install and are not portable).
|
||||
class InventorySnapshot {
|
||||
const InventorySnapshot({
|
||||
this.varieties = const [],
|
||||
this.lots = const [],
|
||||
this.vernacularNames = const [],
|
||||
this.externalLinks = const [],
|
||||
this.germinationTests = const [],
|
||||
this.movements = const [],
|
||||
this.parties = const [],
|
||||
this.attachments = const [],
|
||||
this.speciesNamesById = const {},
|
||||
});
|
||||
|
||||
final List<Variety> varieties;
|
||||
final List<Lot> lots;
|
||||
final List<VarietyVernacularName> vernacularNames;
|
||||
final List<ExternalLink> externalLinks;
|
||||
final List<GerminationTest> germinationTests;
|
||||
final List<Movement> movements;
|
||||
final List<Party> parties;
|
||||
final List<Attachment> attachments;
|
||||
|
||||
/// speciesId → scientificName, for the species referenced by [varieties].
|
||||
final Map<String, String> speciesNamesById;
|
||||
}
|
||||
|
||||
/// What an import did, for user feedback: rows added, rows overwritten by a
|
||||
/// newer incoming version (LWW), and rows skipped (already present and at
|
||||
/// least as recent, or not decodable).
|
||||
class ImportSummary {
|
||||
const ImportSummary({
|
||||
this.inserted = 0,
|
||||
this.updated = 0,
|
||||
this.skipped = 0,
|
||||
});
|
||||
|
||||
final int inserted;
|
||||
final int updated;
|
||||
final int skipped;
|
||||
|
||||
ImportSummary operator +(ImportSummary other) => ImportSummary(
|
||||
inserted: inserted + other.inserted,
|
||||
updated: updated + other.updated,
|
||||
skipped: skipped + other.skipped,
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ import 'package:equatable/equatable.dart';
|
|||
|
||||
import '../db/database.dart';
|
||||
import '../db/enums.dart';
|
||||
import 'export_import/import_reconciler.dart';
|
||||
import 'export_import/inventory_snapshot.dart';
|
||||
|
||||
/// A lightweight row for the inventory list (only what the list renders).
|
||||
class VarietyListItem extends Equatable {
|
||||
|
|
@ -826,6 +828,223 @@ class VarietyRepository {
|
|||
return id;
|
||||
}
|
||||
|
||||
/// Snapshots the live inventory (tombstones excluded) for the interchange
|
||||
/// export — data-model §7. Includes photo bytes; the JSON codec embeds them
|
||||
/// as base64 and the CSV codec ignores them.
|
||||
Future<InventorySnapshot> exportInventory() async {
|
||||
final varieties = await (_db.select(
|
||||
_db.varieties,
|
||||
)..where((v) => v.isDeleted.equals(false))).get();
|
||||
final speciesNamesById = await _scientificNamesFor(
|
||||
varieties.map((v) => v.speciesId).whereType<String>().toSet(),
|
||||
);
|
||||
return InventorySnapshot(
|
||||
varieties: varieties,
|
||||
speciesNamesById: speciesNamesById,
|
||||
lots: await (_db.select(
|
||||
_db.lots,
|
||||
)..where((l) => l.isDeleted.equals(false))).get(),
|
||||
vernacularNames: await (_db.select(
|
||||
_db.varietyVernacularNames,
|
||||
)..where((n) => n.isDeleted.equals(false))).get(),
|
||||
externalLinks: await (_db.select(
|
||||
_db.externalLinks,
|
||||
)..where((e) => e.isDeleted.equals(false))).get(),
|
||||
germinationTests: await (_db.select(
|
||||
_db.germinationTests,
|
||||
)..where((g) => g.isDeleted.equals(false))).get(),
|
||||
movements: await _db.select(_db.movements).get(),
|
||||
parties: await (_db.select(
|
||||
_db.parties,
|
||||
)..where((p) => p.isDeleted.equals(false))).get(),
|
||||
attachments: await (_db.select(
|
||||
_db.attachments,
|
||||
)..where((a) => a.isDeleted.equals(false))).get(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Imports a snapshot, reconciling by row id (UUIDv7) so nothing duplicates:
|
||||
/// unknown id → insert preserving the original sync metadata; known mutable
|
||||
/// id → last-writer-wins on the packed HLC `updatedAt`; movements are
|
||||
/// append-only (insert-if-unknown). Runs in one transaction; afterwards the
|
||||
/// local clock absorbs the newest imported stamp so it never runs behind.
|
||||
///
|
||||
/// Species ids are per-install, so incoming `speciesId`s are re-resolved
|
||||
/// against the local catalog by scientific name (unmatched → null).
|
||||
Future<ImportSummary> importInventory(InventorySnapshot snapshot) async {
|
||||
const reconciler = ImportReconciler();
|
||||
final localSpeciesIdByIncoming = await _resolveSpecies(
|
||||
snapshot.speciesNamesById,
|
||||
);
|
||||
final varieties = [
|
||||
for (final v in snapshot.varieties)
|
||||
v.speciesId == null
|
||||
? v
|
||||
: v.copyWith(
|
||||
speciesId: Value(localSpeciesIdByIncoming[v.speciesId]),
|
||||
),
|
||||
];
|
||||
|
||||
var summary = const ImportSummary();
|
||||
await _db.transaction(() async {
|
||||
summary += await _importMutableRows(
|
||||
table: _db.varieties,
|
||||
idColumn: _db.varieties.id,
|
||||
rows: varieties,
|
||||
idOf: (r) => r.id,
|
||||
updatedAtOf: (r) => r.updatedAt,
|
||||
reconciler: reconciler,
|
||||
);
|
||||
summary += await _importMutableRows(
|
||||
table: _db.lots,
|
||||
idColumn: _db.lots.id,
|
||||
rows: snapshot.lots,
|
||||
idOf: (r) => r.id,
|
||||
updatedAtOf: (r) => r.updatedAt,
|
||||
reconciler: reconciler,
|
||||
);
|
||||
summary += await _importMutableRows(
|
||||
table: _db.varietyVernacularNames,
|
||||
idColumn: _db.varietyVernacularNames.id,
|
||||
rows: snapshot.vernacularNames,
|
||||
idOf: (r) => r.id,
|
||||
updatedAtOf: (r) => r.updatedAt,
|
||||
reconciler: reconciler,
|
||||
);
|
||||
summary += await _importMutableRows(
|
||||
table: _db.externalLinks,
|
||||
idColumn: _db.externalLinks.id,
|
||||
rows: snapshot.externalLinks,
|
||||
idOf: (r) => r.id,
|
||||
updatedAtOf: (r) => r.updatedAt,
|
||||
reconciler: reconciler,
|
||||
);
|
||||
summary += await _importMutableRows(
|
||||
table: _db.germinationTests,
|
||||
idColumn: _db.germinationTests.id,
|
||||
rows: snapshot.germinationTests,
|
||||
idOf: (r) => r.id,
|
||||
updatedAtOf: (r) => r.updatedAt,
|
||||
reconciler: reconciler,
|
||||
);
|
||||
summary += await _importMutableRows(
|
||||
table: _db.parties,
|
||||
idColumn: _db.parties.id,
|
||||
rows: snapshot.parties,
|
||||
idOf: (r) => r.id,
|
||||
updatedAtOf: (r) => r.updatedAt,
|
||||
reconciler: reconciler,
|
||||
);
|
||||
summary += await _importMutableRows(
|
||||
table: _db.attachments,
|
||||
idColumn: _db.attachments.id,
|
||||
rows: snapshot.attachments,
|
||||
idOf: (r) => r.id,
|
||||
updatedAtOf: (r) => r.updatedAt,
|
||||
reconciler: reconciler,
|
||||
);
|
||||
summary += await _importMovements(snapshot.movements, reconciler);
|
||||
});
|
||||
|
||||
final maxIncoming = reconciler.maxHlc([
|
||||
for (final v in snapshot.varieties) v.updatedAt,
|
||||
for (final l in snapshot.lots) l.updatedAt,
|
||||
for (final n in snapshot.vernacularNames) n.updatedAt,
|
||||
for (final e in snapshot.externalLinks) e.updatedAt,
|
||||
for (final g in snapshot.germinationTests) g.updatedAt,
|
||||
for (final p in snapshot.parties) p.updatedAt,
|
||||
for (final a in snapshot.attachments) a.updatedAt,
|
||||
]);
|
||||
if (maxIncoming != null) {
|
||||
_clock = _clock.receiveEvent(maxIncoming, _now());
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
/// Maps incoming species ids to local catalog ids by scientific name.
|
||||
Future<Map<String, String?>> _resolveSpecies(
|
||||
Map<String, String> speciesNamesById,
|
||||
) async {
|
||||
final resolved = <String, String?>{};
|
||||
for (final entry in speciesNamesById.entries) {
|
||||
final local =
|
||||
await (_db.select(_db.species)
|
||||
..where(
|
||||
(s) =>
|
||||
s.scientificName.equals(entry.value) &
|
||||
s.isDeleted.equals(false),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
resolved[entry.key] = local?.id;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// LWW import of one mutable table. Existing rows are looked up including
|
||||
/// tombstones, so a locally-deleted row only resurrects when the incoming
|
||||
/// version is strictly newer — plain last-writer-wins.
|
||||
Future<ImportSummary> _importMutableRows<T extends Table, R>({
|
||||
required TableInfo<T, R> table,
|
||||
required GeneratedColumn<String> idColumn,
|
||||
required List<R> rows,
|
||||
required String Function(R) idOf,
|
||||
required String Function(R) updatedAtOf,
|
||||
required ImportReconciler reconciler,
|
||||
}) async {
|
||||
if (rows.isEmpty) return const ImportSummary();
|
||||
final byId = {for (final r in rows) idOf(r): r};
|
||||
final existing = await (_db.select(
|
||||
table,
|
||||
)..where((_) => idColumn.isIn(byId.keys))).get();
|
||||
final existingUpdatedAt = {for (final r in existing) idOf(r): updatedAtOf(r)};
|
||||
|
||||
var inserted = 0, updated = 0, skipped = 0;
|
||||
for (final row in byId.values) {
|
||||
final action = reconciler.reconcileMutable(
|
||||
existingUpdatedAt: existingUpdatedAt[idOf(row)],
|
||||
incomingUpdatedAt: updatedAtOf(row),
|
||||
);
|
||||
switch (action) {
|
||||
case ReconcileAction.insert:
|
||||
await _db.into(table).insert(row as Insertable<R>);
|
||||
inserted++;
|
||||
case ReconcileAction.update:
|
||||
await _db.update(table).replace(row as Insertable<R>);
|
||||
updated++;
|
||||
case ReconcileAction.skip:
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
return ImportSummary(inserted: inserted, updated: updated, skipped: skipped);
|
||||
}
|
||||
|
||||
/// Movements are append-only: insert unknown ids, never touch known ones.
|
||||
Future<ImportSummary> _importMovements(
|
||||
List<Movement> movements,
|
||||
ImportReconciler reconciler,
|
||||
) async {
|
||||
if (movements.isEmpty) return const ImportSummary();
|
||||
final ids = movements.map((m) => m.id).toList();
|
||||
final existing = await (_db.select(
|
||||
_db.movements,
|
||||
)..where((m) => m.id.isIn(ids))).get();
|
||||
final existingIds = existing.map((m) => m.id).toSet();
|
||||
|
||||
var inserted = 0, skipped = 0;
|
||||
for (final movement in movements) {
|
||||
final action = reconciler.reconcileAppendOnly(
|
||||
exists: existingIds.contains(movement.id),
|
||||
);
|
||||
if (action == ReconcileAction.insert) {
|
||||
await _db.into(_db.movements).insert(movement);
|
||||
inserted++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
return ImportSummary(inserted: inserted, skipped: skipped);
|
||||
}
|
||||
|
||||
VarietyLot _toLot(Lot l, List<GerminationEntry> germinationTests) {
|
||||
final hasQuantity =
|
||||
l.quantityKind != null ||
|
||||
|
|
|
|||
|
|
@ -26,8 +26,12 @@ part 'database.g.dart';
|
|||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase(super.e);
|
||||
|
||||
/// Current schema version; also stamped into interchange exports so an
|
||||
/// importer knows which app generation wrote the file (data-model §7).
|
||||
static const int currentSchemaVersion = 5;
|
||||
|
||||
@override
|
||||
int get schemaVersion => 5;
|
||||
int get schemaVersion => currentSchemaVersion;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ import '../db/database.dart';
|
|||
import '../db/encrypted_executor.dart';
|
||||
import '../security/secret_store.dart';
|
||||
import '../security/secure_key_store.dart';
|
||||
import '../services/export_import_service.dart';
|
||||
import '../services/file_picker_file_service.dart';
|
||||
import '../services/file_service.dart';
|
||||
|
||||
/// The app's service locator. Kept to the composition root — widgets get their
|
||||
/// repositories from here (or via BlocProvider), never by reaching into it deep
|
||||
|
|
@ -37,12 +40,22 @@ Future<void> configureDependencies() async {
|
|||
final speciesRepository = SpeciesRepository(database, idGen: IdGen());
|
||||
await speciesRepository.seedBundled(await loadBundledSpecies());
|
||||
|
||||
final varietyRepository = VarietyRepository(
|
||||
database,
|
||||
idGen: IdGen(),
|
||||
nodeId: nodeId,
|
||||
);
|
||||
|
||||
const fileService = FilePickerFileService();
|
||||
|
||||
getIt
|
||||
..registerSingleton<SecureKeyStore>(keyStore)
|
||||
..registerSingleton<AppDatabase>(database)
|
||||
..registerSingleton<SpeciesRepository>(speciesRepository)
|
||||
..registerSingleton<VarietyRepository>(
|
||||
VarietyRepository(database, idGen: IdGen(), nodeId: nodeId),
|
||||
..registerSingleton<VarietyRepository>(varietyRepository)
|
||||
..registerSingleton<FileService>(fileService)
|
||||
..registerSingleton<ExportImportService>(
|
||||
ExportImportService(repository: varietyRepository, files: fileService),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,23 @@
|
|||
"aboutText": "Local-first, encrypted inventory for traditional seeds. AGPL-3.0.",
|
||||
"aboutOpen": "About Tanemaki"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup",
|
||||
"exportCsv": "Export as CSV",
|
||||
"exportCsvSubtitle": "For spreadsheets — no photos",
|
||||
"exportJson": "Export as JSON",
|
||||
"exportJsonSubtitle": "Complete copy, can be imported back",
|
||||
"importJson": "Import from JSON",
|
||||
"importJsonSubtitle": "Merge a saved copy into this inventory",
|
||||
"importConfirmTitle": "Import a saved copy?",
|
||||
"importConfirmBody": "Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.",
|
||||
"importAction": "Import",
|
||||
"exportSaved": "Copy saved",
|
||||
"cancelled": "Cancelled",
|
||||
"importDone": "Imported: {added} new, {updated} updated",
|
||||
"importFailed": "This file could not be read as a Tanemaki copy",
|
||||
"failed": "Something went wrong"
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"kanji": "種まき",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,23 @@
|
|||
"aboutText": "Inventario local y cifrado para semillas tradicionales. AGPL-3.0.",
|
||||
"aboutOpen": "Acerca de Tanemaki"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Copia de seguridad",
|
||||
"exportCsv": "Exportar a CSV",
|
||||
"exportCsvSubtitle": "Para hojas de cálculo — sin fotos",
|
||||
"exportJson": "Exportar a JSON",
|
||||
"exportJsonSubtitle": "Copia completa, se puede volver a importar",
|
||||
"importJson": "Importar desde JSON",
|
||||
"importJsonSubtitle": "Fusiona una copia guardada con este inventario",
|
||||
"importConfirmTitle": "¿Importar una copia guardada?",
|
||||
"importConfirmBody": "Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.",
|
||||
"importAction": "Importar",
|
||||
"exportSaved": "Copia guardada",
|
||||
"cancelled": "Cancelado",
|
||||
"importDone": "Importado: {added} nuevas, {updated} actualizadas",
|
||||
"importFailed": "Este fichero no se pudo leer como una copia de Tanemaki",
|
||||
"failed": "Algo ha salido mal"
|
||||
},
|
||||
"about": {
|
||||
"title": "Acerca de",
|
||||
"kanji": "種まき",
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 2
|
||||
/// Strings: 336 (168 per locale)
|
||||
/// Strings: 366 (183 per locale)
|
||||
///
|
||||
/// Built on 2026-07-09 at 09:55 UTC
|
||||
/// Built on 2026-07-09 at 10:41 UTC
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
|||
late final Translations$photo$en photo = Translations$photo$en.internal(_root);
|
||||
late final Translations$menu$en menu = Translations$menu$en.internal(_root);
|
||||
late final Translations$settings$en settings = Translations$settings$en.internal(_root);
|
||||
late final Translations$backup$en backup = Translations$backup$en.internal(_root);
|
||||
late final Translations$about$en about = Translations$about$en.internal(_root);
|
||||
late final Translations$inventory$en inventory = Translations$inventory$en.internal(_root);
|
||||
late final Translations$quickAdd$en quickAdd = Translations$quickAdd$en.internal(_root);
|
||||
|
|
@ -209,6 +210,60 @@ class Translations$settings$en {
|
|||
String get aboutOpen => 'About Tanemaki';
|
||||
}
|
||||
|
||||
// Path: backup
|
||||
class Translations$backup$en {
|
||||
Translations$backup$en.internal(this._root);
|
||||
|
||||
final Translations _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
|
||||
/// en: 'Backup'
|
||||
String get title => 'Backup';
|
||||
|
||||
/// en: 'Export as CSV'
|
||||
String get exportCsv => 'Export as CSV';
|
||||
|
||||
/// en: 'For spreadsheets — no photos'
|
||||
String get exportCsvSubtitle => 'For spreadsheets — no photos';
|
||||
|
||||
/// en: 'Export as JSON'
|
||||
String get exportJson => 'Export as JSON';
|
||||
|
||||
/// en: 'Complete copy, can be imported back'
|
||||
String get exportJsonSubtitle => 'Complete copy, can be imported back';
|
||||
|
||||
/// en: 'Import from JSON'
|
||||
String get importJson => 'Import from JSON';
|
||||
|
||||
/// en: 'Merge a saved copy into this inventory'
|
||||
String get importJsonSubtitle => 'Merge a saved copy into this inventory';
|
||||
|
||||
/// en: 'Import a saved copy?'
|
||||
String get importConfirmTitle => 'Import a saved copy?';
|
||||
|
||||
/// en: 'Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.'
|
||||
String get importConfirmBody => 'Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.';
|
||||
|
||||
/// en: 'Import'
|
||||
String get importAction => 'Import';
|
||||
|
||||
/// en: 'Copy saved'
|
||||
String get exportSaved => 'Copy saved';
|
||||
|
||||
/// en: 'Cancelled'
|
||||
String get cancelled => 'Cancelled';
|
||||
|
||||
/// en: 'Imported: {added} new, {updated} updated'
|
||||
String importDone({required Object added, required Object updated}) => 'Imported: ${added} new, ${updated} updated';
|
||||
|
||||
/// en: 'This file could not be read as a Tanemaki copy'
|
||||
String get importFailed => 'This file could not be read as a Tanemaki copy';
|
||||
|
||||
/// en: 'Something went wrong'
|
||||
String get failed => 'Something went wrong';
|
||||
}
|
||||
|
||||
// Path: about
|
||||
class Translations$about$en {
|
||||
Translations$about$en.internal(this._root);
|
||||
|
|
@ -976,6 +1031,21 @@ extension on Translations {
|
|||
'settings.about' => 'About',
|
||||
'settings.aboutText' => 'Local-first, encrypted inventory for traditional seeds. AGPL-3.0.',
|
||||
'settings.aboutOpen' => 'About Tanemaki',
|
||||
'backup.title' => 'Backup',
|
||||
'backup.exportCsv' => 'Export as CSV',
|
||||
'backup.exportCsvSubtitle' => 'For spreadsheets — no photos',
|
||||
'backup.exportJson' => 'Export as JSON',
|
||||
'backup.exportJsonSubtitle' => 'Complete copy, can be imported back',
|
||||
'backup.importJson' => 'Import from JSON',
|
||||
'backup.importJsonSubtitle' => 'Merge a saved copy into this inventory',
|
||||
'backup.importConfirmTitle' => 'Import a saved copy?',
|
||||
'backup.importConfirmBody' => 'Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.',
|
||||
'backup.importAction' => 'Import',
|
||||
'backup.exportSaved' => 'Copy saved',
|
||||
'backup.cancelled' => 'Cancelled',
|
||||
'backup.importDone' => ({required Object added, required Object updated}) => 'Imported: ${added} new, ${updated} updated',
|
||||
'backup.importFailed' => 'This file could not be read as a Tanemaki copy',
|
||||
'backup.failed' => 'Something went wrong',
|
||||
'about.title' => 'About',
|
||||
'about.kanji' => '種まき',
|
||||
'about.tagline' => 'A local-first, decentralized app for managing and sharing traditional seeds and seedlings.',
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
|
|||
@override late final _Translations$photo$es photo = _Translations$photo$es._(_root);
|
||||
@override late final _Translations$menu$es menu = _Translations$menu$es._(_root);
|
||||
@override late final _Translations$settings$es settings = _Translations$settings$es._(_root);
|
||||
@override late final _Translations$backup$es backup = _Translations$backup$es._(_root);
|
||||
@override late final _Translations$about$es about = _Translations$about$es._(_root);
|
||||
@override late final _Translations$inventory$es inventory = _Translations$inventory$es._(_root);
|
||||
@override late final _Translations$quickAdd$es quickAdd = _Translations$quickAdd$es._(_root);
|
||||
|
|
@ -144,6 +145,30 @@ class _Translations$settings$es extends Translations$settings$en {
|
|||
@override String get aboutOpen => 'Acerca de Tanemaki';
|
||||
}
|
||||
|
||||
// Path: backup
|
||||
class _Translations$backup$es extends Translations$backup$en {
|
||||
_Translations$backup$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||
|
||||
final TranslationsEs _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get title => 'Copia de seguridad';
|
||||
@override String get exportCsv => 'Exportar a CSV';
|
||||
@override String get exportCsvSubtitle => 'Para hojas de cálculo — sin fotos';
|
||||
@override String get exportJson => 'Exportar a JSON';
|
||||
@override String get exportJsonSubtitle => 'Copia completa, se puede volver a importar';
|
||||
@override String get importJson => 'Importar desde JSON';
|
||||
@override String get importJsonSubtitle => 'Fusiona una copia guardada con este inventario';
|
||||
@override String get importConfirmTitle => '¿Importar una copia guardada?';
|
||||
@override String get importConfirmBody => 'Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.';
|
||||
@override String get importAction => 'Importar';
|
||||
@override String get exportSaved => 'Copia guardada';
|
||||
@override String get cancelled => 'Cancelado';
|
||||
@override String importDone({required Object added, required Object updated}) => 'Importado: ${added} nuevas, ${updated} actualizadas';
|
||||
@override String get importFailed => 'Este fichero no se pudo leer como una copia de Tanemaki';
|
||||
@override String get failed => 'Algo ha salido mal';
|
||||
}
|
||||
|
||||
// Path: about
|
||||
class _Translations$about$es extends Translations$about$en {
|
||||
_Translations$about$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||
|
|
@ -661,6 +686,21 @@ extension on TranslationsEs {
|
|||
'settings.about' => 'Acerca de',
|
||||
'settings.aboutText' => 'Inventario local y cifrado para semillas tradicionales. AGPL-3.0.',
|
||||
'settings.aboutOpen' => 'Acerca de Tanemaki',
|
||||
'backup.title' => 'Copia de seguridad',
|
||||
'backup.exportCsv' => 'Exportar a CSV',
|
||||
'backup.exportCsvSubtitle' => 'Para hojas de cálculo — sin fotos',
|
||||
'backup.exportJson' => 'Exportar a JSON',
|
||||
'backup.exportJsonSubtitle' => 'Copia completa, se puede volver a importar',
|
||||
'backup.importJson' => 'Importar desde JSON',
|
||||
'backup.importJsonSubtitle' => 'Fusiona una copia guardada con este inventario',
|
||||
'backup.importConfirmTitle' => '¿Importar una copia guardada?',
|
||||
'backup.importConfirmBody' => 'Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.',
|
||||
'backup.importAction' => 'Importar',
|
||||
'backup.exportSaved' => 'Copia guardada',
|
||||
'backup.cancelled' => 'Cancelado',
|
||||
'backup.importDone' => ({required Object added, required Object updated}) => 'Importado: ${added} nuevas, ${updated} actualizadas',
|
||||
'backup.importFailed' => 'Este fichero no se pudo leer como una copia de Tanemaki',
|
||||
'backup.failed' => 'Algo ha salido mal',
|
||||
'about.title' => 'Acerca de',
|
||||
'about.kanji' => '種まき',
|
||||
'about.tagline' => 'Una app local-first y descentralizada para gestionar y compartir semillas y plantones tradicionales.',
|
||||
|
|
|
|||
63
apps/app_seeds/lib/services/export_import_service.dart
Normal file
63
apps/app_seeds/lib/services/export_import_service.dart
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import '../data/export_import/inventory_csv_codec.dart';
|
||||
import '../data/export_import/inventory_json_codec.dart';
|
||||
import '../data/export_import/inventory_snapshot.dart';
|
||||
import '../data/variety_repository.dart';
|
||||
import 'file_service.dart';
|
||||
|
||||
/// End-to-end export/import flows: repository snapshot ↔ codec ↔ the file the
|
||||
/// user picked. UI-free — screens call these and render the outcome.
|
||||
class ExportImportService {
|
||||
ExportImportService({
|
||||
required VarietyRepository repository,
|
||||
required FileService files,
|
||||
DateTime Function()? now,
|
||||
}) : _repository = repository,
|
||||
_files = files,
|
||||
_now = now ?? DateTime.now;
|
||||
|
||||
final VarietyRepository _repository;
|
||||
final FileService _files;
|
||||
final DateTime Function() _now;
|
||||
|
||||
static const _jsonCodec = InventoryJsonCodec();
|
||||
static const _csvCodec = InventoryCsvCodec();
|
||||
|
||||
/// Exports the inventory as interchange JSON (data-model §7). Returns true
|
||||
/// when saved, false when the user cancelled the save dialog.
|
||||
Future<bool> exportJson() async {
|
||||
final snapshot = await _repository.exportInventory();
|
||||
final path = await _files.saveFile(
|
||||
suggestedName: _fileName('json'),
|
||||
bytes: utf8.encode(_jsonCodec.encode(snapshot)),
|
||||
);
|
||||
return path != null;
|
||||
}
|
||||
|
||||
/// Exports the inventory as flat CSV (export-only). Returns true when
|
||||
/// saved, false when the user cancelled the save dialog.
|
||||
Future<bool> exportCsv() async {
|
||||
final snapshot = await _repository.exportInventory();
|
||||
final path = await _files.saveFile(
|
||||
suggestedName: _fileName('csv'),
|
||||
bytes: utf8.encode(_csvCodec.encode(snapshot)),
|
||||
);
|
||||
return path != null;
|
||||
}
|
||||
|
||||
/// Imports an interchange JSON file, merging by id (LWW). Returns the
|
||||
/// summary, or null when the user cancelled the file dialog. Throws
|
||||
/// [FormatException] when the file is not a readable inventory export.
|
||||
Future<ImportSummary?> importJson() async {
|
||||
final bytes = await _files.pickFileBytes(allowedExtensions: ['json']);
|
||||
if (bytes == null) return null;
|
||||
final snapshot = _jsonCodec.decode(utf8.decode(bytes));
|
||||
return _repository.importInventory(snapshot);
|
||||
}
|
||||
|
||||
String _fileName(String extension) {
|
||||
final date = _now().toIso8601String().substring(0, 10);
|
||||
return 'tanemaki-inventory-$date.$extension';
|
||||
}
|
||||
}
|
||||
47
apps/app_seeds/lib/services/file_picker_file_service.dart
Normal file
47
apps/app_seeds/lib/services/file_picker_file_service.dart
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
|
||||
import 'file_service.dart';
|
||||
|
||||
/// [FileService] backed by the `file_picker` plugin (one plugin covers the
|
||||
/// save and open dialogs on Android, iOS and desktop).
|
||||
class FilePickerFileService implements FileService {
|
||||
const FilePickerFileService();
|
||||
|
||||
@override
|
||||
Future<String?> saveFile({
|
||||
required String suggestedName,
|
||||
required Uint8List bytes,
|
||||
}) async {
|
||||
final path = await FilePicker.platform.saveFile(
|
||||
fileName: suggestedName,
|
||||
bytes: bytes,
|
||||
);
|
||||
if (path == null) return null;
|
||||
// On mobile the plugin writes [bytes] itself; on desktop it only returns
|
||||
// the chosen path, so write them here.
|
||||
if (!Platform.isAndroid && !Platform.isIOS) {
|
||||
await File(path).writeAsBytes(bytes, flush: true);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Uint8List?> pickFileBytes({List<String>? allowedExtensions}) async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: allowedExtensions == null ? FileType.any : FileType.custom,
|
||||
allowedExtensions: allowedExtensions,
|
||||
withData: true,
|
||||
);
|
||||
final file = result?.files.singleOrNull;
|
||||
if (file == null) return null;
|
||||
// withData should populate bytes; fall back to the path just in case.
|
||||
final bytes = file.bytes;
|
||||
if (bytes != null) return bytes;
|
||||
final path = file.path;
|
||||
if (path == null) return null;
|
||||
return File(path).readAsBytes();
|
||||
}
|
||||
}
|
||||
19
apps/app_seeds/lib/services/file_service.dart
Normal file
19
apps/app_seeds/lib/services/file_service.dart
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
/// Cross-platform "save a file / open a file" boundary, so everything above
|
||||
/// it (services, tests) stays free of platform channels. Implementations must
|
||||
/// never leave plaintext temp copies behind — bytes go straight between
|
||||
/// memory and the destination the user picked (security-privacy.md).
|
||||
abstract class FileService {
|
||||
/// Asks the user where to save [bytes] (suggesting [suggestedName]) and
|
||||
/// writes them there. Returns the chosen path, or null if they cancelled.
|
||||
Future<String?> saveFile({
|
||||
required String suggestedName,
|
||||
required Uint8List bytes,
|
||||
});
|
||||
|
||||
/// Asks the user to pick a file (optionally restricted to
|
||||
/// [allowedExtensions], without dots) and returns its bytes, or null if
|
||||
/// they cancelled.
|
||||
Future<Uint8List?> pickFileBytes({List<String>? allowedExtensions});
|
||||
}
|
||||
102
apps/app_seeds/lib/ui/backup_section.dart
Normal file
102
apps/app_seeds/lib/ui/backup_section.dart
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../di/injector.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/export_import_service.dart';
|
||||
|
||||
/// The three backup actions shown in Settings: export CSV, export JSON and
|
||||
/// import JSON. 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});
|
||||
|
||||
/// Injectable for widget tests; the app resolves it lazily (on tap) from
|
||||
/// the service locator so merely mounting Settings needs no DI setup.
|
||||
final ExportImportService? service;
|
||||
|
||||
ExportImportService get _service => service ?? getIt<ExportImportService>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
return Column(
|
||||
children: [
|
||||
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.file_download_outlined),
|
||||
title: Text(t.backup.exportJson),
|
||||
subtitle: Text(t.backup.exportJsonSubtitle),
|
||||
onTap: () => _runExport(context, () => _service.exportJson()),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.file_upload_outlined),
|
||||
title: Text(t.backup.importJson),
|
||||
subtitle: Text(t.backup.importJsonSubtitle),
|
||||
onTap: () => _runImport(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _runExport(
|
||||
BuildContext context,
|
||||
Future<bool> 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);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runImport(BuildContext context) async {
|
||||
final t = context.t;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(t.backup.importConfirmTitle),
|
||||
content: Text(t.backup.importConfirmBody),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
final summary = await _service.importJson();
|
||||
_show(
|
||||
messenger,
|
||||
summary == null
|
||||
? t.backup.cancelled
|
||||
: t.backup.importDone(
|
||||
added: summary.inserted,
|
||||
updated: summary.updated,
|
||||
),
|
||||
);
|
||||
} on FormatException {
|
||||
_show(messenger, t.backup.importFailed);
|
||||
} on Object {
|
||||
_show(messenger, t.backup.failed);
|
||||
}
|
||||
}
|
||||
|
||||
void _show(ScaffoldMessengerState messenger, String message) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
}
|
||||
|
|
@ -2,11 +2,17 @@ import 'package:flutter/material.dart';
|
|||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/export_import_service.dart';
|
||||
import 'backup_section.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// Basic settings: app language and an "about" section.
|
||||
/// Basic settings: app language, backup (export/import) and an "about"
|
||||
/// section. [exportImport] is injectable so widget tests can pass a fake;
|
||||
/// the app resolves it lazily from the service locator.
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
const SettingsScreen({this.exportImport, super.key});
|
||||
|
||||
final ExportImportService? exportImport;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -33,6 +39,9 @@ class SettingsScreen extends StatelessWidget {
|
|||
onTap: () => LocaleSettings.useDeviceLocale(),
|
||||
),
|
||||
const Divider(),
|
||||
_SectionHeader(t.backup.title),
|
||||
BackupSection(service: exportImport),
|
||||
const Divider(),
|
||||
_SectionHeader(t.settings.about),
|
||||
ListTile(
|
||||
leading: Image.asset('assets/logo.png', width: 32),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue