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/database.dart';
|
||||||
import '../db/enums.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).
|
/// A lightweight row for the inventory list (only what the list renders).
|
||||||
class VarietyListItem extends Equatable {
|
class VarietyListItem extends Equatable {
|
||||||
|
|
@ -826,6 +828,223 @@ class VarietyRepository {
|
||||||
return id;
|
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) {
|
VarietyLot _toLot(Lot l, List<GerminationEntry> germinationTests) {
|
||||||
final hasQuantity =
|
final hasQuantity =
|
||||||
l.quantityKind != null ||
|
l.quantityKind != null ||
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,12 @@ part 'database.g.dart';
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
AppDatabase(super.e);
|
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
|
@override
|
||||||
int get schemaVersion => 5;
|
int get schemaVersion => currentSchemaVersion;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ import '../db/database.dart';
|
||||||
import '../db/encrypted_executor.dart';
|
import '../db/encrypted_executor.dart';
|
||||||
import '../security/secret_store.dart';
|
import '../security/secret_store.dart';
|
||||||
import '../security/secure_key_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
|
/// 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
|
/// 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());
|
final speciesRepository = SpeciesRepository(database, idGen: IdGen());
|
||||||
await speciesRepository.seedBundled(await loadBundledSpecies());
|
await speciesRepository.seedBundled(await loadBundledSpecies());
|
||||||
|
|
||||||
|
final varietyRepository = VarietyRepository(
|
||||||
|
database,
|
||||||
|
idGen: IdGen(),
|
||||||
|
nodeId: nodeId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileService = FilePickerFileService();
|
||||||
|
|
||||||
getIt
|
getIt
|
||||||
..registerSingleton<SecureKeyStore>(keyStore)
|
..registerSingleton<SecureKeyStore>(keyStore)
|
||||||
..registerSingleton<AppDatabase>(database)
|
..registerSingleton<AppDatabase>(database)
|
||||||
..registerSingleton<SpeciesRepository>(speciesRepository)
|
..registerSingleton<SpeciesRepository>(speciesRepository)
|
||||||
..registerSingleton<VarietyRepository>(
|
..registerSingleton<VarietyRepository>(varietyRepository)
|
||||||
VarietyRepository(database, idGen: IdGen(), nodeId: nodeId),
|
..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.",
|
"aboutText": "Local-first, encrypted inventory for traditional seeds. AGPL-3.0.",
|
||||||
"aboutOpen": "About Tanemaki"
|
"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": {
|
"about": {
|
||||||
"title": "About",
|
"title": "About",
|
||||||
"kanji": "種まき",
|
"kanji": "種まき",
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,23 @@
|
||||||
"aboutText": "Inventario local y cifrado para semillas tradicionales. AGPL-3.0.",
|
"aboutText": "Inventario local y cifrado para semillas tradicionales. AGPL-3.0.",
|
||||||
"aboutOpen": "Acerca de Tanemaki"
|
"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": {
|
"about": {
|
||||||
"title": "Acerca de",
|
"title": "Acerca de",
|
||||||
"kanji": "種まき",
|
"kanji": "種まき",
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 2
|
/// 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
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// 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$photo$en photo = Translations$photo$en.internal(_root);
|
||||||
late final Translations$menu$en menu = Translations$menu$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$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$about$en about = Translations$about$en.internal(_root);
|
||||||
late final Translations$inventory$en inventory = Translations$inventory$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);
|
late final Translations$quickAdd$en quickAdd = Translations$quickAdd$en.internal(_root);
|
||||||
|
|
@ -209,6 +210,60 @@ class Translations$settings$en {
|
||||||
String get aboutOpen => 'About Tanemaki';
|
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
|
// Path: about
|
||||||
class Translations$about$en {
|
class Translations$about$en {
|
||||||
Translations$about$en.internal(this._root);
|
Translations$about$en.internal(this._root);
|
||||||
|
|
@ -976,6 +1031,21 @@ extension on Translations {
|
||||||
'settings.about' => 'About',
|
'settings.about' => 'About',
|
||||||
'settings.aboutText' => 'Local-first, encrypted inventory for traditional seeds. AGPL-3.0.',
|
'settings.aboutText' => 'Local-first, encrypted inventory for traditional seeds. AGPL-3.0.',
|
||||||
'settings.aboutOpen' => 'About Tanemaki',
|
'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.title' => 'About',
|
||||||
'about.kanji' => '種まき',
|
'about.kanji' => '種まき',
|
||||||
'about.tagline' => 'A local-first, decentralized app for managing and sharing traditional seeds and seedlings.',
|
'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$photo$es photo = _Translations$photo$es._(_root);
|
||||||
@override late final _Translations$menu$es menu = _Translations$menu$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$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$about$es about = _Translations$about$es._(_root);
|
||||||
@override late final _Translations$inventory$es inventory = _Translations$inventory$es._(_root);
|
@override late final _Translations$inventory$es inventory = _Translations$inventory$es._(_root);
|
||||||
@override late final _Translations$quickAdd$es quickAdd = _Translations$quickAdd$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';
|
@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
|
// Path: about
|
||||||
class _Translations$about$es extends Translations$about$en {
|
class _Translations$about$es extends Translations$about$en {
|
||||||
_Translations$about$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
_Translations$about$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||||
|
|
@ -661,6 +686,21 @@ extension on TranslationsEs {
|
||||||
'settings.about' => 'Acerca de',
|
'settings.about' => 'Acerca de',
|
||||||
'settings.aboutText' => 'Inventario local y cifrado para semillas tradicionales. AGPL-3.0.',
|
'settings.aboutText' => 'Inventario local y cifrado para semillas tradicionales. AGPL-3.0.',
|
||||||
'settings.aboutOpen' => 'Acerca de Tanemaki',
|
'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.title' => 'Acerca de',
|
||||||
'about.kanji' => '種まき',
|
'about.kanji' => '種まき',
|
||||||
'about.tagline' => 'Una app local-first y descentralizada para gestionar y compartir semillas y plantones tradicionales.',
|
'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 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
|
import '../services/export_import_service.dart';
|
||||||
|
import 'backup_section.dart';
|
||||||
import 'theme.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 {
|
class SettingsScreen extends StatelessWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({this.exportImport, super.key});
|
||||||
|
|
||||||
|
final ExportImportService? exportImport;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -33,6 +39,9 @@ class SettingsScreen extends StatelessWidget {
|
||||||
onTap: () => LocaleSettings.useDeviceLocale(),
|
onTap: () => LocaleSettings.useDeviceLocale(),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
_SectionHeader(t.backup.title),
|
||||||
|
BackupSection(service: exportImport),
|
||||||
|
const Divider(),
|
||||||
_SectionHeader(t.settings.about),
|
_SectionHeader(t.settings.about),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Image.asset('assets/logo.png', width: 32),
|
leading: Image.asset('assets/logo.png', width: 32),
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ dependencies:
|
||||||
# Routing + media + paths.
|
# Routing + media + paths.
|
||||||
go_router: ^14.6.2
|
go_router: ^14.6.2
|
||||||
image_picker: ^1.1.2
|
image_picker: ^1.1.2
|
||||||
|
# Save/open dialogs for the inventory export/import (MIT license).
|
||||||
|
file_picker: ^10.1.2
|
||||||
path: ^1.9.0
|
path: ^1.9.0
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import 'package:commons_core/commons_core.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/data/export_import/import_reconciler.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const reconciler = ImportReconciler();
|
||||||
|
|
||||||
|
String stamp(int millis, {int counter = 0, String node = 'node-a'}) =>
|
||||||
|
Hlc(millis: millis, counter: counter, nodeId: node).pack();
|
||||||
|
|
||||||
|
group('reconcileMutable (LWW by packed HLC)', () {
|
||||||
|
test('unknown id inserts', () {
|
||||||
|
expect(
|
||||||
|
reconciler.reconcileMutable(
|
||||||
|
existingUpdatedAt: null,
|
||||||
|
incomingUpdatedAt: stamp(1000),
|
||||||
|
),
|
||||||
|
ReconcileAction.insert,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('newer incoming stamp updates', () {
|
||||||
|
expect(
|
||||||
|
reconciler.reconcileMutable(
|
||||||
|
existingUpdatedAt: stamp(1000),
|
||||||
|
incomingUpdatedAt: stamp(2000),
|
||||||
|
),
|
||||||
|
ReconcileAction.update,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('older incoming stamp skips', () {
|
||||||
|
expect(
|
||||||
|
reconciler.reconcileMutable(
|
||||||
|
existingUpdatedAt: stamp(2000),
|
||||||
|
incomingUpdatedAt: stamp(1000),
|
||||||
|
),
|
||||||
|
ReconcileAction.skip,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('identical stamp skips (re-import is a no-op)', () {
|
||||||
|
expect(
|
||||||
|
reconciler.reconcileMutable(
|
||||||
|
existingUpdatedAt: stamp(1000),
|
||||||
|
incomingUpdatedAt: stamp(1000),
|
||||||
|
),
|
||||||
|
ReconcileAction.skip,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same millis resolves by counter then node id', () {
|
||||||
|
expect(
|
||||||
|
reconciler.reconcileMutable(
|
||||||
|
existingUpdatedAt: stamp(1000, counter: 1),
|
||||||
|
incomingUpdatedAt: stamp(1000, counter: 2),
|
||||||
|
),
|
||||||
|
ReconcileAction.update,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
reconciler.reconcileMutable(
|
||||||
|
existingUpdatedAt: stamp(1000, node: 'node-a'),
|
||||||
|
incomingUpdatedAt: stamp(1000, node: 'node-b'),
|
||||||
|
),
|
||||||
|
ReconcileAction.update,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('reconcileAppendOnly', () {
|
||||||
|
test('unknown id inserts, known id skips (never updates)', () {
|
||||||
|
expect(
|
||||||
|
reconciler.reconcileAppendOnly(exists: false),
|
||||||
|
ReconcileAction.insert,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
reconciler.reconcileAppendOnly(exists: true),
|
||||||
|
ReconcileAction.skip,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('maxHlc', () {
|
||||||
|
test('returns the newest stamp', () {
|
||||||
|
final max = reconciler.maxHlc([
|
||||||
|
stamp(1000),
|
||||||
|
stamp(3000, counter: 2),
|
||||||
|
stamp(3000, counter: 1),
|
||||||
|
stamp(2000),
|
||||||
|
]);
|
||||||
|
expect(max, Hlc(millis: 3000, counter: 2, nodeId: 'node-a'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when empty', () {
|
||||||
|
expect(reconciler.maxHlc(const []), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/data/export_import/inventory_csv_codec.dart';
|
||||||
|
import 'package:tane/data/export_import/inventory_snapshot.dart';
|
||||||
|
import 'package:tane/db/database.dart';
|
||||||
|
import 'package:tane/db/enums.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const codec = InventoryCsvCodec();
|
||||||
|
|
||||||
|
Variety variety({
|
||||||
|
String id = 'var-1',
|
||||||
|
String label = 'Grandma tomato',
|
||||||
|
String? notes,
|
||||||
|
}) => Variety(
|
||||||
|
id: id,
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: '000000000001000:00000:node-a',
|
||||||
|
lastAuthor: 'node-a',
|
||||||
|
isDeleted: false,
|
||||||
|
schemaRowVersion: 1,
|
||||||
|
label: label,
|
||||||
|
speciesId: 'sp-1',
|
||||||
|
cultivarName: null,
|
||||||
|
category: 'Solanaceae',
|
||||||
|
notes: notes,
|
||||||
|
);
|
||||||
|
|
||||||
|
Lot lot({String id = 'lot-1', String varietyId = 'var-1'}) => Lot(
|
||||||
|
id: id,
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: '000000000002000:00000:node-a',
|
||||||
|
lastAuthor: 'node-a',
|
||||||
|
isDeleted: false,
|
||||||
|
schemaRowVersion: 1,
|
||||||
|
varietyId: varietyId,
|
||||||
|
type: LotType.seed,
|
||||||
|
harvestYear: 2024,
|
||||||
|
harvestMonth: null,
|
||||||
|
quantityKind: 'cob',
|
||||||
|
quantityPrecise: 2,
|
||||||
|
quantityLabel: null,
|
||||||
|
presentation: null,
|
||||||
|
storageLocation: null,
|
||||||
|
offerStatus: OfferStatus.private,
|
||||||
|
seedbankId: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
List<String> rowsOf(String csv) =>
|
||||||
|
csv.trimRight().split('\r\n');
|
||||||
|
|
||||||
|
test('emits a header and one row per lot', () {
|
||||||
|
final csv = codec.encode(
|
||||||
|
InventorySnapshot(
|
||||||
|
varieties: [variety()],
|
||||||
|
lots: [lot(), lot(id: 'lot-2')],
|
||||||
|
speciesNamesById: const {'sp-1': 'Solanum lycopersicum'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final rows = rowsOf(csv);
|
||||||
|
expect(rows, hasLength(3));
|
||||||
|
expect(rows.first, startsWith('variety_id,variety_label,category'));
|
||||||
|
expect(rows[1], contains('lot-1'));
|
||||||
|
expect(rows[1], contains('Solanum lycopersicum'));
|
||||||
|
expect(rows[1], contains('cob'));
|
||||||
|
expect(rows[2], contains('lot-2'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a variety without lots still gets one row', () {
|
||||||
|
final csv = codec.encode(InventorySnapshot(varieties: [variety()]));
|
||||||
|
final rows = rowsOf(csv);
|
||||||
|
expect(rows, hasLength(2));
|
||||||
|
expect(rows[1], startsWith('var-1,Grandma tomato,Solanaceae'));
|
||||||
|
// Same cell count as the header even with empty lot columns.
|
||||||
|
expect(rows[1].split(',').length, rows.first.split(',').length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('escapes commas, quotes and newlines per RFC 4180', () {
|
||||||
|
final csv = codec.encode(
|
||||||
|
InventorySnapshot(
|
||||||
|
varieties: [
|
||||||
|
variety(label: 'Bean, "the best"', notes: 'line one\nline two'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(csv, contains('"Bean, ""the best"""'));
|
||||||
|
expect(csv, contains('"line one\nline two"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('joins vernacular names and links; never includes photo bytes', () {
|
||||||
|
final snapshot = InventorySnapshot(
|
||||||
|
varieties: [variety()],
|
||||||
|
vernacularNames: const [
|
||||||
|
VarietyVernacularName(
|
||||||
|
id: 'n1',
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: '000000000001000:00000:node-a',
|
||||||
|
lastAuthor: 'node-a',
|
||||||
|
isDeleted: false,
|
||||||
|
schemaRowVersion: 1,
|
||||||
|
varietyId: 'var-1',
|
||||||
|
name: 'tomate de la abuela',
|
||||||
|
language: 'es',
|
||||||
|
region: null,
|
||||||
|
),
|
||||||
|
VarietyVernacularName(
|
||||||
|
id: 'n2',
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: '000000000001000:00001:node-a',
|
||||||
|
lastAuthor: 'node-a',
|
||||||
|
isDeleted: false,
|
||||||
|
schemaRowVersion: 1,
|
||||||
|
varietyId: 'var-1',
|
||||||
|
name: 'granny tomato',
|
||||||
|
language: null,
|
||||||
|
region: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
externalLinks: const [
|
||||||
|
ExternalLink(
|
||||||
|
id: 'l1',
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: '000000000001000:00002:node-a',
|
||||||
|
lastAuthor: 'node-a',
|
||||||
|
isDeleted: false,
|
||||||
|
schemaRowVersion: 1,
|
||||||
|
parentType: ParentType.variety,
|
||||||
|
parentId: 'var-1',
|
||||||
|
url: 'https://example.org',
|
||||||
|
title: 'Wiki',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
attachments: [
|
||||||
|
Attachment(
|
||||||
|
id: 'a1',
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: '000000000001000:00003:node-a',
|
||||||
|
lastAuthor: 'node-a',
|
||||||
|
isDeleted: false,
|
||||||
|
schemaRowVersion: 1,
|
||||||
|
parentType: ParentType.variety,
|
||||||
|
parentId: 'var-1',
|
||||||
|
kind: AttachmentKind.photo,
|
||||||
|
uri: null,
|
||||||
|
bytes: Uint8List.fromList([9, 9, 9]),
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
sortOrder: 0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final csv = codec.encode(snapshot);
|
||||||
|
expect(csv, contains('tomate de la abuela (es); granny tomato'));
|
||||||
|
expect(csv, contains('Wiki|https://example.org'));
|
||||||
|
expect(csv, isNot(contains('9,9,9')));
|
||||||
|
expect(csv.toLowerCase(), isNot(contains('base64')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports the latest germination rate per lot', () {
|
||||||
|
final csv = codec.encode(
|
||||||
|
InventorySnapshot(
|
||||||
|
varieties: [variety()],
|
||||||
|
lots: [lot()],
|
||||||
|
germinationTests: const [
|
||||||
|
GerminationTest(
|
||||||
|
id: 'g-old',
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: '000000000001000:00000:node-a',
|
||||||
|
lastAuthor: 'node-a',
|
||||||
|
isDeleted: false,
|
||||||
|
schemaRowVersion: 1,
|
||||||
|
lotId: 'lot-1',
|
||||||
|
testedOn: 100,
|
||||||
|
sampleSize: 10,
|
||||||
|
germinatedCount: 2,
|
||||||
|
notes: null,
|
||||||
|
),
|
||||||
|
GerminationTest(
|
||||||
|
id: 'g-new',
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: '000000000001000:00001:node-a',
|
||||||
|
lastAuthor: 'node-a',
|
||||||
|
isDeleted: false,
|
||||||
|
schemaRowVersion: 1,
|
||||||
|
lotId: 'lot-1',
|
||||||
|
testedOn: 200,
|
||||||
|
sampleSize: 10,
|
||||||
|
germinatedCount: 8,
|
||||||
|
notes: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(csv, contains('0.80'));
|
||||||
|
expect(csv, isNot(contains('0.20')));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/data/export_import/inventory_json_codec.dart';
|
||||||
|
import 'package:tane/data/export_import/inventory_snapshot.dart';
|
||||||
|
import 'package:tane/db/database.dart';
|
||||||
|
import 'package:tane/db/enums.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const codec = InventoryJsonCodec();
|
||||||
|
|
||||||
|
const variety = Variety(
|
||||||
|
id: 'var-1',
|
||||||
|
createdAt: 1000,
|
||||||
|
updatedAt: '000000000001000:00000:node-a',
|
||||||
|
lastAuthor: 'node-a',
|
||||||
|
isDeleted: false,
|
||||||
|
schemaRowVersion: 1,
|
||||||
|
label: 'Grandma tomato',
|
||||||
|
speciesId: 'sp-1',
|
||||||
|
cultivarName: 'Marmande',
|
||||||
|
category: 'Solanaceae',
|
||||||
|
notes: 'Keeps well',
|
||||||
|
);
|
||||||
|
const lot = Lot(
|
||||||
|
id: 'lot-1',
|
||||||
|
createdAt: 1001,
|
||||||
|
updatedAt: '000000000001001:00000:node-a',
|
||||||
|
lastAuthor: 'node-a',
|
||||||
|
isDeleted: false,
|
||||||
|
schemaRowVersion: 1,
|
||||||
|
varietyId: 'var-1',
|
||||||
|
type: LotType.seed,
|
||||||
|
harvestYear: 2024,
|
||||||
|
harvestMonth: 6,
|
||||||
|
quantityKind: 'cob',
|
||||||
|
quantityPrecise: 2,
|
||||||
|
quantityLabel: null,
|
||||||
|
presentation: null,
|
||||||
|
storageLocation: 'fridge',
|
||||||
|
offerStatus: OfferStatus.private,
|
||||||
|
seedbankId: null,
|
||||||
|
);
|
||||||
|
const name = VarietyVernacularName(
|
||||||
|
id: 'name-1',
|
||||||
|
createdAt: 1002,
|
||||||
|
updatedAt: '000000000001002:00000:node-a',
|
||||||
|
lastAuthor: 'node-a',
|
||||||
|
isDeleted: false,
|
||||||
|
schemaRowVersion: 1,
|
||||||
|
varietyId: 'var-1',
|
||||||
|
name: 'tomate de la abuela',
|
||||||
|
language: 'es',
|
||||||
|
region: 'Madrid',
|
||||||
|
);
|
||||||
|
const link = ExternalLink(
|
||||||
|
id: 'link-1',
|
||||||
|
createdAt: 1003,
|
||||||
|
updatedAt: '000000000001003:00000:node-a',
|
||||||
|
lastAuthor: 'node-a',
|
||||||
|
isDeleted: false,
|
||||||
|
schemaRowVersion: 1,
|
||||||
|
parentType: ParentType.variety,
|
||||||
|
parentId: 'var-1',
|
||||||
|
url: 'https://example.org/tomato',
|
||||||
|
title: 'Wiki',
|
||||||
|
);
|
||||||
|
const germination = GerminationTest(
|
||||||
|
id: 'germ-1',
|
||||||
|
createdAt: 1004,
|
||||||
|
updatedAt: '000000000001004:00000:node-a',
|
||||||
|
lastAuthor: 'node-a',
|
||||||
|
isDeleted: false,
|
||||||
|
schemaRowVersion: 1,
|
||||||
|
lotId: 'lot-1',
|
||||||
|
testedOn: 1700000000000,
|
||||||
|
sampleSize: 10,
|
||||||
|
germinatedCount: 8,
|
||||||
|
notes: null,
|
||||||
|
);
|
||||||
|
const movement = Movement(
|
||||||
|
id: 'mov-1',
|
||||||
|
createdAt: 1005,
|
||||||
|
lastAuthor: 'node-a',
|
||||||
|
schemaRowVersion: 1,
|
||||||
|
lotId: 'lot-1',
|
||||||
|
type: MovementType.received,
|
||||||
|
occurredOn: 1690000000000,
|
||||||
|
counterpartyId: 'party-1',
|
||||||
|
quantityKind: 'handful',
|
||||||
|
quantityPrecise: null,
|
||||||
|
quantityLabel: 'a handful',
|
||||||
|
parentMovementId: null,
|
||||||
|
plantareId: null,
|
||||||
|
notes: 'from the fair',
|
||||||
|
);
|
||||||
|
const party = Party(
|
||||||
|
id: 'party-1',
|
||||||
|
createdAt: 1006,
|
||||||
|
updatedAt: '000000000001006:00000:node-a',
|
||||||
|
lastAuthor: 'node-a',
|
||||||
|
isDeleted: false,
|
||||||
|
schemaRowVersion: 1,
|
||||||
|
displayName: 'Village bank',
|
||||||
|
publicKey: null,
|
||||||
|
kind: PartyKind.collective,
|
||||||
|
note: null,
|
||||||
|
);
|
||||||
|
final photo = Attachment(
|
||||||
|
id: 'att-1',
|
||||||
|
createdAt: 1007,
|
||||||
|
updatedAt: '000000000001007:00000:node-a',
|
||||||
|
lastAuthor: 'node-a',
|
||||||
|
isDeleted: false,
|
||||||
|
schemaRowVersion: 1,
|
||||||
|
parentType: ParentType.variety,
|
||||||
|
parentId: 'var-1',
|
||||||
|
kind: AttachmentKind.photo,
|
||||||
|
uri: null,
|
||||||
|
bytes: Uint8List.fromList([1, 2, 3, 255]),
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
sortOrder: -1,
|
||||||
|
);
|
||||||
|
|
||||||
|
final snapshot = InventorySnapshot(
|
||||||
|
varieties: const [variety],
|
||||||
|
lots: const [lot],
|
||||||
|
vernacularNames: const [name],
|
||||||
|
externalLinks: const [link],
|
||||||
|
germinationTests: const [germination],
|
||||||
|
movements: const [movement],
|
||||||
|
parties: const [party],
|
||||||
|
attachments: [photo],
|
||||||
|
speciesNamesById: const {'sp-1': 'Solanum lycopersicum'},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('encode → decode round-trips every table and field', () {
|
||||||
|
final decoded = codec.decode(codec.encode(snapshot));
|
||||||
|
|
||||||
|
expect(decoded.varieties, [variety]);
|
||||||
|
expect(decoded.lots, [lot]);
|
||||||
|
expect(decoded.vernacularNames, [name]);
|
||||||
|
expect(decoded.externalLinks, [link]);
|
||||||
|
expect(decoded.germinationTests, [germination]);
|
||||||
|
expect(decoded.movements, [movement]);
|
||||||
|
expect(decoded.parties, [party]);
|
||||||
|
expect(decoded.attachments, [photo]);
|
||||||
|
expect(decoded.speciesNamesById, {'sp-1': 'Solanum lycopersicum'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('photo bytes travel as base64', () {
|
||||||
|
final root = jsonDecode(codec.encode(snapshot)) as Map<String, dynamic>;
|
||||||
|
final attachment =
|
||||||
|
(root['attachments'] as List).single as Map<String, dynamic>;
|
||||||
|
expect(attachment['bytesBase64'], base64Encode([1, 2, 3, 255]));
|
||||||
|
expect(attachment.containsKey('bytes'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('envelope carries format/schema versions and app id', () {
|
||||||
|
final root = jsonDecode(codec.encode(snapshot)) as Map<String, dynamic>;
|
||||||
|
expect(root['formatVersion'], inventoryFormatVersion);
|
||||||
|
expect(root['schemaVersion'], AppDatabase.currentSchemaVersion);
|
||||||
|
expect(root['appId'], inventoryAppId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown fields are ignored (forward compatibility, §5.2)', () {
|
||||||
|
final root = jsonDecode(codec.encode(snapshot)) as Map<String, dynamic>;
|
||||||
|
root['someFutureSection'] = {'x': 1};
|
||||||
|
((root['varieties'] as List).single as Map<String, dynamic>)['futureField'] =
|
||||||
|
'ignored';
|
||||||
|
final decoded = codec.decode(jsonEncode(root));
|
||||||
|
expect(decoded.varieties, [variety]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown enum values fall back or drop the row (§5.2)', () {
|
||||||
|
final root = jsonDecode(codec.encode(snapshot)) as Map<String, dynamic>;
|
||||||
|
((root['lots'] as List).single as Map<String, dynamic>)['type'] =
|
||||||
|
'hologram';
|
||||||
|
((root['movements'] as List).single as Map<String, dynamic>)['type'] =
|
||||||
|
'teleported';
|
||||||
|
final decoded = codec.decode(jsonEncode(root));
|
||||||
|
expect(decoded.lots.single.type, LotType.seed); // safe default
|
||||||
|
expect(decoded.movements, isEmpty); // no safe default → dropped
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a broken row is skipped without aborting the import', () {
|
||||||
|
final root = jsonDecode(codec.encode(snapshot)) as Map<String, dynamic>;
|
||||||
|
(root['varieties'] as List).add({'id': 'var-2'}); // missing fields
|
||||||
|
final decoded = codec.decode(jsonEncode(root));
|
||||||
|
expect(decoded.varieties, [variety]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a newer formatVersion is rejected with a clear error', () {
|
||||||
|
final root = jsonDecode(codec.encode(snapshot)) as Map<String, dynamic>;
|
||||||
|
root['formatVersion'] = inventoryFormatVersion + 1;
|
||||||
|
expect(() => codec.decode(jsonEncode(root)), throwsFormatException);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed input is rejected', () {
|
||||||
|
expect(() => codec.decode('not json'), throwsFormatException);
|
||||||
|
expect(() => codec.decode('[1,2]'), throwsFormatException);
|
||||||
|
expect(() => codec.decode('{"foo": 1}'), throwsFormatException);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,231 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:commons_core/commons_core.dart';
|
||||||
|
import 'package:drift/drift.dart' show Value;
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/data/export_import/inventory_json_codec.dart';
|
||||||
|
import 'package:tane/data/species_repository.dart';
|
||||||
|
import 'package:tane/data/variety_repository.dart';
|
||||||
|
import 'package:tane/db/database.dart';
|
||||||
|
|
||||||
|
import '../../support/test_support.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const codec = InventoryJsonCodec();
|
||||||
|
const tomato = SpeciesSeed(
|
||||||
|
scientificName: 'Solanum lycopersicum',
|
||||||
|
family: 'Solanaceae',
|
||||||
|
);
|
||||||
|
|
||||||
|
late AppDatabase sourceDb;
|
||||||
|
late VarietyRepository source;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
sourceDb = newTestDatabase();
|
||||||
|
source = newTestRepository(sourceDb, nodeId: 'node-source');
|
||||||
|
});
|
||||||
|
tearDown(() => sourceDb.close());
|
||||||
|
|
||||||
|
/// Populates the source DB with one of everything and returns the ids.
|
||||||
|
Future<({String varietyId, String lotId})> populate() async {
|
||||||
|
await newTestSpeciesRepository(sourceDb).seedBundled(const [tomato]);
|
||||||
|
final varietyId = await source.addQuickVariety(
|
||||||
|
label: 'Grandma tomato',
|
||||||
|
category: 'Solanaceae',
|
||||||
|
photoBytes: Uint8List.fromList([10, 20, 30]),
|
||||||
|
);
|
||||||
|
final species = await sourceDb.select(sourceDb.species).getSingle();
|
||||||
|
await source.linkSpecies(varietyId, species.id);
|
||||||
|
await source.updateVariety(
|
||||||
|
id: varietyId,
|
||||||
|
label: 'Grandma tomato',
|
||||||
|
category: 'Solanaceae',
|
||||||
|
notes: 'Sow in March',
|
||||||
|
);
|
||||||
|
final lotId = await source.addLot(
|
||||||
|
varietyId: varietyId,
|
||||||
|
harvestYear: 2024,
|
||||||
|
harvestMonth: 6,
|
||||||
|
quantity: const Quantity(kind: QuantityKind.cob, count: 2),
|
||||||
|
storageLocation: 'fridge',
|
||||||
|
);
|
||||||
|
await source.addVernacularName(
|
||||||
|
varietyId,
|
||||||
|
'tomate de la abuela',
|
||||||
|
language: 'es',
|
||||||
|
);
|
||||||
|
await source.addExternalLink(
|
||||||
|
varietyId,
|
||||||
|
'https://example.org/tomato',
|
||||||
|
title: 'Wiki',
|
||||||
|
);
|
||||||
|
await source.addGerminationTest(
|
||||||
|
lotId: lotId,
|
||||||
|
testedOn: 1700000000000,
|
||||||
|
sampleSize: 10,
|
||||||
|
germinatedCount: 8,
|
||||||
|
);
|
||||||
|
// A second photo, then promote it to cover so sortOrder must survive.
|
||||||
|
final cover = await source.addPhoto(
|
||||||
|
varietyId,
|
||||||
|
Uint8List.fromList([40, 50, 60]),
|
||||||
|
);
|
||||||
|
await source.setPreferredPhoto(varietyId, cover);
|
||||||
|
return (varietyId: varietyId, lotId: lotId);
|
||||||
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
'export → import into a fresh database restores the identical state',
|
||||||
|
() async {
|
||||||
|
await populate();
|
||||||
|
final json = codec.encode(await source.exportInventory());
|
||||||
|
|
||||||
|
final targetDb = newTestDatabase();
|
||||||
|
addTearDown(targetDb.close);
|
||||||
|
// The target install has its own bundled catalog (different species id).
|
||||||
|
await newTestSpeciesRepository(targetDb).seedBundled(const [tomato]);
|
||||||
|
final target = newTestRepository(targetDb, nodeId: 'node-target');
|
||||||
|
|
||||||
|
final summary = await target.importInventory(codec.decode(json));
|
||||||
|
expect(summary.inserted, greaterThan(0));
|
||||||
|
expect(summary.updated, 0);
|
||||||
|
|
||||||
|
// Everything round-trips identically, except speciesId which is
|
||||||
|
// re-resolved against the target's own catalog by scientific name.
|
||||||
|
final sourceRows = await source.exportInventory();
|
||||||
|
final targetRows = await target.exportInventory();
|
||||||
|
expect(
|
||||||
|
targetRows.varieties.map((v) => v.copyWith(speciesId: const Value(null))),
|
||||||
|
sourceRows.varieties.map((v) => v.copyWith(speciesId: const Value(null))),
|
||||||
|
);
|
||||||
|
expect(targetRows.lots, sourceRows.lots);
|
||||||
|
expect(targetRows.vernacularNames, sourceRows.vernacularNames);
|
||||||
|
expect(targetRows.externalLinks, sourceRows.externalLinks);
|
||||||
|
expect(targetRows.germinationTests, sourceRows.germinationTests);
|
||||||
|
expect(targetRows.movements, sourceRows.movements);
|
||||||
|
expect(targetRows.attachments, sourceRows.attachments);
|
||||||
|
|
||||||
|
// The species link points at the target's own catalog row.
|
||||||
|
final targetSpecies = await targetDb.select(targetDb.species).getSingle();
|
||||||
|
expect(targetRows.varieties.single.speciesId, targetSpecies.id);
|
||||||
|
|
||||||
|
// Photo bytes and cover order survived.
|
||||||
|
final detail = await target.watchVariety(
|
||||||
|
targetRows.varieties.single.id,
|
||||||
|
).first;
|
||||||
|
expect(detail!.photos.first.bytes, [40, 50, 60]);
|
||||||
|
expect(detail.photos.last.bytes, [10, 20, 30]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('re-importing the same file is a no-op (idempotent)', () async {
|
||||||
|
await populate();
|
||||||
|
final json = codec.encode(await source.exportInventory());
|
||||||
|
final snapshot = codec.decode(json);
|
||||||
|
|
||||||
|
final targetDb = newTestDatabase();
|
||||||
|
addTearDown(targetDb.close);
|
||||||
|
final target = newTestRepository(targetDb, nodeId: 'node-target');
|
||||||
|
|
||||||
|
await target.importInventory(snapshot);
|
||||||
|
final again = await target.importInventory(codec.decode(json));
|
||||||
|
expect(again.inserted, 0);
|
||||||
|
expect(again.updated, 0);
|
||||||
|
expect(again.skipped, greaterThan(0));
|
||||||
|
|
||||||
|
final varieties = await targetDb.select(targetDb.varieties).get();
|
||||||
|
expect(varieties, hasLength(1)); // no duplicates
|
||||||
|
});
|
||||||
|
|
||||||
|
test('import does not overwrite local edits that are newer', () async {
|
||||||
|
final ids = await populate();
|
||||||
|
final json = codec.encode(await source.exportInventory());
|
||||||
|
|
||||||
|
// The local edit happens after the export was taken.
|
||||||
|
await source.updateVariety(
|
||||||
|
id: ids.varietyId,
|
||||||
|
label: 'Renamed after export',
|
||||||
|
category: 'Solanaceae',
|
||||||
|
notes: 'Sow in March',
|
||||||
|
);
|
||||||
|
|
||||||
|
final summary = await source.importInventory(codec.decode(json));
|
||||||
|
expect(summary.updated, 0);
|
||||||
|
|
||||||
|
final variety = await (sourceDb.select(
|
||||||
|
sourceDb.varieties,
|
||||||
|
)..where((v) => v.id.equals(ids.varietyId))).getSingle();
|
||||||
|
expect(variety.label, 'Renamed after export');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('import overwrites when the incoming row is newer (LWW)', () async {
|
||||||
|
final ids = await populate();
|
||||||
|
|
||||||
|
// Fork: a second device imports this inventory, edits the variety…
|
||||||
|
final json = codec.encode(await source.exportInventory());
|
||||||
|
final otherDb = newTestDatabase();
|
||||||
|
addTearDown(otherDb.close);
|
||||||
|
final other = newTestRepository(otherDb, nodeId: 'node-other');
|
||||||
|
await other.importInventory(codec.decode(json));
|
||||||
|
await other.updateVariety(
|
||||||
|
id: ids.varietyId,
|
||||||
|
label: 'Edited elsewhere',
|
||||||
|
category: 'Solanaceae',
|
||||||
|
notes: 'Sow in March',
|
||||||
|
);
|
||||||
|
|
||||||
|
// …and its export comes back: the newer edit wins here.
|
||||||
|
final back = codec.encode(await other.exportInventory());
|
||||||
|
final summary = await source.importInventory(codec.decode(back));
|
||||||
|
expect(summary.updated, greaterThan(0));
|
||||||
|
|
||||||
|
final variety = await (sourceDb.select(
|
||||||
|
sourceDb.varieties,
|
||||||
|
)..where((v) => v.id.equals(ids.varietyId))).getSingle();
|
||||||
|
expect(variety.label, 'Edited elsewhere');
|
||||||
|
expect(variety.lastAuthor, 'node-other');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('after an import the local clock never stamps behind it', () async {
|
||||||
|
await populate();
|
||||||
|
final json = codec.encode(await source.exportInventory());
|
||||||
|
final snapshot = codec.decode(json);
|
||||||
|
|
||||||
|
// A target whose wall clock is frozen in the past.
|
||||||
|
final targetDb = newTestDatabase();
|
||||||
|
addTearDown(targetDb.close);
|
||||||
|
final target = VarietyRepository(
|
||||||
|
targetDb,
|
||||||
|
idGen: IdGen(),
|
||||||
|
nodeId: 'node-past',
|
||||||
|
nowMillis: () => 1000,
|
||||||
|
);
|
||||||
|
await target.importInventory(snapshot);
|
||||||
|
await target.addQuickVariety(label: 'Written after import');
|
||||||
|
|
||||||
|
final maxImported = snapshot.varieties
|
||||||
|
.map((v) => Hlc.parse(v.updatedAt))
|
||||||
|
.reduce((a, b) => a.compareTo(b) > 0 ? a : b);
|
||||||
|
final written = await (targetDb.select(
|
||||||
|
targetDb.varieties,
|
||||||
|
)..where((v) => v.label.equals('Written after import'))).getSingle();
|
||||||
|
expect(
|
||||||
|
Hlc.parse(written.updatedAt).compareTo(maxImported),
|
||||||
|
greaterThan(0),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unmatched species leaves speciesId null instead of dangling', () async {
|
||||||
|
await populate();
|
||||||
|
final json = codec.encode(await source.exportInventory());
|
||||||
|
|
||||||
|
final targetDb = newTestDatabase();
|
||||||
|
addTearDown(targetDb.close);
|
||||||
|
// No bundled catalog in the target: the name cannot be resolved.
|
||||||
|
final target = newTestRepository(targetDb, nodeId: 'node-target');
|
||||||
|
await target.importInventory(codec.decode(json));
|
||||||
|
|
||||||
|
final variety = await targetDb.select(targetDb.varieties).getSingle();
|
||||||
|
expect(variety.speciesId, isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
150
apps/app_seeds/test/ui/backup_section_test.dart
Normal file
150
apps/app_seeds/test/ui/backup_section_test.dart
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/data/export_import/inventory_json_codec.dart';
|
||||||
|
import 'package:tane/data/export_import/inventory_snapshot.dart';
|
||||||
|
import 'package:tane/db/database.dart';
|
||||||
|
import 'package:tane/i18n/strings.g.dart';
|
||||||
|
import 'package:tane/services/export_import_service.dart';
|
||||||
|
import 'package:tane/services/file_service.dart';
|
||||||
|
import 'package:tane/ui/backup_section.dart';
|
||||||
|
import 'package:tane/ui/settings_screen.dart';
|
||||||
|
|
||||||
|
import '../support/test_support.dart';
|
||||||
|
|
||||||
|
/// Records calls instead of opening real dialogs.
|
||||||
|
class FakeFileService implements FileService {
|
||||||
|
String? savedName;
|
||||||
|
Uint8List? savedBytes;
|
||||||
|
Uint8List? bytesToPick;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> saveFile({
|
||||||
|
required String suggestedName,
|
||||||
|
required Uint8List bytes,
|
||||||
|
}) async {
|
||||||
|
savedName = suggestedName;
|
||||||
|
savedBytes = bytes;
|
||||||
|
return '/picked/$suggestedName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List?> pickFileBytes({List<String>? allowedExtensions}) async =>
|
||||||
|
bytesToPick;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
late FakeFileService files;
|
||||||
|
late ExportImportService service;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
db = newTestDatabase();
|
||||||
|
files = FakeFileService();
|
||||||
|
service = ExportImportService(
|
||||||
|
repository: newTestRepository(db),
|
||||||
|
files: files,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
tearDown(() => db.close());
|
||||||
|
|
||||||
|
Widget wrap(Widget child) {
|
||||||
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
|
return TranslationProvider(
|
||||||
|
child: MaterialApp(
|
||||||
|
supportedLocales: AppLocaleUtils.supportedLocales,
|
||||||
|
localizationsDelegates: const [
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
home: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('settings shows the backup section with its three actions', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(800, 1400));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(wrap(SettingsScreen(exportImport: service)));
|
||||||
|
|
||||||
|
expect(find.text('Backup'), findsOneWidget);
|
||||||
|
expect(find.text('Export as CSV'), findsOneWidget);
|
||||||
|
expect(find.text('Export as JSON'), findsOneWidget);
|
||||||
|
expect(find.text('Import from JSON'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('export CSV saves a .csv file and confirms', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(Scaffold(body: BackupSection(service: service))),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Export as CSV'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(files.savedName, endsWith('.csv'));
|
||||||
|
expect(utf8.decode(files.savedBytes!), startsWith('variety_id,'));
|
||||||
|
expect(find.text('Copy saved'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('export JSON saves a versioned .json file', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(Scaffold(body: BackupSection(service: service))),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Export as JSON'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(files.savedName, endsWith('.json'));
|
||||||
|
final root =
|
||||||
|
jsonDecode(utf8.decode(files.savedBytes!)) as Map<String, dynamic>;
|
||||||
|
expect(root['formatVersion'], inventoryFormatVersion);
|
||||||
|
expect(find.text('Copy saved'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('import asks for confirmation, imports and reports counts', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
// A valid one-variety export produced by another repository.
|
||||||
|
final otherDb = newTestDatabase();
|
||||||
|
addTearDown(otherDb.close);
|
||||||
|
final other = newTestRepository(otherDb, nodeId: 'node-other');
|
||||||
|
await other.addQuickVariety(label: 'Imported bean');
|
||||||
|
files.bytesToPick = utf8.encode(
|
||||||
|
const InventoryJsonCodec().encode(await other.exportInventory()),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(Scaffold(body: BackupSection(service: service))),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Import from JSON'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Import a saved copy?'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Import'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Imported: 1 new, 0 updated'), findsOneWidget);
|
||||||
|
final varieties = await db.select(db.varieties).get();
|
||||||
|
expect(varieties.single.label, 'Imported bean');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('an unreadable file reports a friendly error', (tester) async {
|
||||||
|
files.bytesToPick = utf8.encode('this is not json');
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(Scaffold(body: BackupSection(service: service))),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Import from JSON'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Import'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text('This file could not be read as a Tanemaki copy'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -226,3 +226,20 @@ Together: Drift handles the *local database* migration; the compatibility rules
|
||||||
- Is `offer_status` on `Lot` enough, or does a `Variety`-level default help? (Lean: Lot only.)
|
- Is `offer_status` on `Lot` enough, or does a `Variety`-level default help? (Lean: Lot only.)
|
||||||
- Do we need `Variety`-level provenance, or is per-Lot/Movement provenance sufficient? (Lean: Movement DAG is enough.)
|
- Do we need `Variety`-level provenance, or is per-Lot/Movement provenance sufficient? (Lean: Movement DAG is enough.)
|
||||||
- `category` as free enum vs linked to `Species.family` when a species is set. (Lean: free, prefilled from family when available.)
|
- `category` as free enum vs linked to `Species.family` when a species is set. (Lean: free, prefilled from family when available.)
|
||||||
|
|
||||||
|
## 7. Interchange export/import (Phase 1) — decided & implemented
|
||||||
|
|
||||||
|
The user-triggered **interchange export** (Settings → Backup). Distinct from the future encrypted full backup (`.tanemaki`, [backup-and-recovery.md](backup-and-recovery.md) mechanism 1): the interchange files are the *only* plaintext the app ever writes, and only because the user explicitly asks for them. Import reads the picked file straight into memory — no temp copies.
|
||||||
|
|
||||||
|
Two formats, one canonical:
|
||||||
|
|
||||||
|
- **JSON** — canonical, versioned, importable; a precursor of the future sync payload. Envelope: `formatVersion` (currently 1), `schemaVersion` (the writer's DB generation), `appId: "tane"`, then one array per table: `varieties`, `lots`, `vernacularNames`, `externalLinks`, `germinationTests`, `movements`, `parties`, `attachments`. Every row carries its sync metadata verbatim (`id`, `createdAt`, packed-HLC `updatedAt`, `lastAuthor`, `schemaRowVersion`); enums serialize by name. **Photos/docs are embedded as base64** (`bytesBase64`) — the JSON export is the complete copy of the data (~33% size overhead accepted). **Tombstones are excluded**: this is a user-facing export of the live inventory, not the sync wire format (real sync, Block 2, will carry tombstones on its own channel).
|
||||||
|
- **CSV** — export-only, for spreadsheets. One row per Lot (a variety with no lots gets one row); vernacular names and links joined into single columns; latest germination rate derived; ISO-8601 dates; never any photo bytes. Not importable — the JSON is.
|
||||||
|
|
||||||
|
**Species are not exported.** The bundled catalog rows get per-install ids, so `species_id` is not portable. Each exported variety carries a denormalized `speciesScientificName`; the importer re-resolves it against its own catalog by scientific name (no match → `species_id` stays null).
|
||||||
|
|
||||||
|
**Import = merge by id (UUIDv7), never duplicating.** Mutable rows: unknown id → insert preserving the original metadata (no re-stamping); known id → last-writer-wins on the packed HLC `updatedAt` (§4), incoming wins only if strictly newer — so re-importing the same file is a no-op, and a locally-deleted row only resurrects if the incoming version is newer than the tombstone. `movements` are append-only: insert-if-unknown, never updated. Everything runs in one transaction; afterwards the local HLC clock `receiveEvent`s the newest imported stamp so the next local write out-stamps everything imported.
|
||||||
|
|
||||||
|
**Reader compatibility (per §5.2):** unknown JSON fields are ignored; unknown enum values map to a safe default (`lot.type → seed`, `offer_status → private`) or drop the row when none exists (an unknown `movement.type`); a `formatVersion` newer than the reader supports fails with a clear "update the app" error. `formatVersion` changes must be additive and documented here.
|
||||||
|
|
||||||
|
Implementation: `apps/app_seeds/lib/data/export_import/` (codecs + reconciler), `VarietyRepository.exportInventory()/importInventory()`, `lib/services/export_import_service.dart`.
|
||||||
|
|
|
||||||
16
pubspec.lock
16
pubspec.lock
|
|
@ -233,6 +233,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.7"
|
version: "3.1.7"
|
||||||
|
dbus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dbus
|
||||||
|
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.14"
|
||||||
drift:
|
drift:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -281,6 +289,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
file_picker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_picker
|
||||||
|
sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "10.3.10"
|
||||||
file_selector_linux:
|
file_selector_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue