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:
vjrj 2026-07-09 12:46:53 +02:00
parent 136ed701a7
commit 2812c99280
25 changed files with 2207 additions and 7 deletions

View 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;
}
}

View 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('"', '""')}"';
}
}

View 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];
}
}

View file

@ -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,
);
}

View file

@ -5,6 +5,8 @@ import 'package:equatable/equatable.dart';
import '../db/database.dart';
import '../db/enums.dart';
import 'export_import/import_reconciler.dart';
import 'export_import/inventory_snapshot.dart';
/// A lightweight row for the inventory list (only what the list renders).
class VarietyListItem extends Equatable {
@ -826,6 +828,223 @@ class VarietyRepository {
return id;
}
/// Snapshots the live inventory (tombstones excluded) for the interchange
/// export data-model §7. Includes photo bytes; the JSON codec embeds them
/// as base64 and the CSV codec ignores them.
Future<InventorySnapshot> exportInventory() async {
final varieties = await (_db.select(
_db.varieties,
)..where((v) => v.isDeleted.equals(false))).get();
final speciesNamesById = await _scientificNamesFor(
varieties.map((v) => v.speciesId).whereType<String>().toSet(),
);
return InventorySnapshot(
varieties: varieties,
speciesNamesById: speciesNamesById,
lots: await (_db.select(
_db.lots,
)..where((l) => l.isDeleted.equals(false))).get(),
vernacularNames: await (_db.select(
_db.varietyVernacularNames,
)..where((n) => n.isDeleted.equals(false))).get(),
externalLinks: await (_db.select(
_db.externalLinks,
)..where((e) => e.isDeleted.equals(false))).get(),
germinationTests: await (_db.select(
_db.germinationTests,
)..where((g) => g.isDeleted.equals(false))).get(),
movements: await _db.select(_db.movements).get(),
parties: await (_db.select(
_db.parties,
)..where((p) => p.isDeleted.equals(false))).get(),
attachments: await (_db.select(
_db.attachments,
)..where((a) => a.isDeleted.equals(false))).get(),
);
}
/// Imports a snapshot, reconciling by row id (UUIDv7) so nothing duplicates:
/// unknown id insert preserving the original sync metadata; known mutable
/// id last-writer-wins on the packed HLC `updatedAt`; movements are
/// append-only (insert-if-unknown). Runs in one transaction; afterwards the
/// local clock absorbs the newest imported stamp so it never runs behind.
///
/// Species ids are per-install, so incoming `speciesId`s are re-resolved
/// against the local catalog by scientific name (unmatched null).
Future<ImportSummary> importInventory(InventorySnapshot snapshot) async {
const reconciler = ImportReconciler();
final localSpeciesIdByIncoming = await _resolveSpecies(
snapshot.speciesNamesById,
);
final varieties = [
for (final v in snapshot.varieties)
v.speciesId == null
? v
: v.copyWith(
speciesId: Value(localSpeciesIdByIncoming[v.speciesId]),
),
];
var summary = const ImportSummary();
await _db.transaction(() async {
summary += await _importMutableRows(
table: _db.varieties,
idColumn: _db.varieties.id,
rows: varieties,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows(
table: _db.lots,
idColumn: _db.lots.id,
rows: snapshot.lots,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows(
table: _db.varietyVernacularNames,
idColumn: _db.varietyVernacularNames.id,
rows: snapshot.vernacularNames,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows(
table: _db.externalLinks,
idColumn: _db.externalLinks.id,
rows: snapshot.externalLinks,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows(
table: _db.germinationTests,
idColumn: _db.germinationTests.id,
rows: snapshot.germinationTests,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows(
table: _db.parties,
idColumn: _db.parties.id,
rows: snapshot.parties,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows(
table: _db.attachments,
idColumn: _db.attachments.id,
rows: snapshot.attachments,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMovements(snapshot.movements, reconciler);
});
final maxIncoming = reconciler.maxHlc([
for (final v in snapshot.varieties) v.updatedAt,
for (final l in snapshot.lots) l.updatedAt,
for (final n in snapshot.vernacularNames) n.updatedAt,
for (final e in snapshot.externalLinks) e.updatedAt,
for (final g in snapshot.germinationTests) g.updatedAt,
for (final p in snapshot.parties) p.updatedAt,
for (final a in snapshot.attachments) a.updatedAt,
]);
if (maxIncoming != null) {
_clock = _clock.receiveEvent(maxIncoming, _now());
}
return summary;
}
/// Maps incoming species ids to local catalog ids by scientific name.
Future<Map<String, String?>> _resolveSpecies(
Map<String, String> speciesNamesById,
) async {
final resolved = <String, String?>{};
for (final entry in speciesNamesById.entries) {
final local =
await (_db.select(_db.species)
..where(
(s) =>
s.scientificName.equals(entry.value) &
s.isDeleted.equals(false),
))
.getSingleOrNull();
resolved[entry.key] = local?.id;
}
return resolved;
}
/// LWW import of one mutable table. Existing rows are looked up including
/// tombstones, so a locally-deleted row only resurrects when the incoming
/// version is strictly newer plain last-writer-wins.
Future<ImportSummary> _importMutableRows<T extends Table, R>({
required TableInfo<T, R> table,
required GeneratedColumn<String> idColumn,
required List<R> rows,
required String Function(R) idOf,
required String Function(R) updatedAtOf,
required ImportReconciler reconciler,
}) async {
if (rows.isEmpty) return const ImportSummary();
final byId = {for (final r in rows) idOf(r): r};
final existing = await (_db.select(
table,
)..where((_) => idColumn.isIn(byId.keys))).get();
final existingUpdatedAt = {for (final r in existing) idOf(r): updatedAtOf(r)};
var inserted = 0, updated = 0, skipped = 0;
for (final row in byId.values) {
final action = reconciler.reconcileMutable(
existingUpdatedAt: existingUpdatedAt[idOf(row)],
incomingUpdatedAt: updatedAtOf(row),
);
switch (action) {
case ReconcileAction.insert:
await _db.into(table).insert(row as Insertable<R>);
inserted++;
case ReconcileAction.update:
await _db.update(table).replace(row as Insertable<R>);
updated++;
case ReconcileAction.skip:
skipped++;
}
}
return ImportSummary(inserted: inserted, updated: updated, skipped: skipped);
}
/// Movements are append-only: insert unknown ids, never touch known ones.
Future<ImportSummary> _importMovements(
List<Movement> movements,
ImportReconciler reconciler,
) async {
if (movements.isEmpty) return const ImportSummary();
final ids = movements.map((m) => m.id).toList();
final existing = await (_db.select(
_db.movements,
)..where((m) => m.id.isIn(ids))).get();
final existingIds = existing.map((m) => m.id).toSet();
var inserted = 0, skipped = 0;
for (final movement in movements) {
final action = reconciler.reconcileAppendOnly(
exists: existingIds.contains(movement.id),
);
if (action == ReconcileAction.insert) {
await _db.into(_db.movements).insert(movement);
inserted++;
} else {
skipped++;
}
}
return ImportSummary(inserted: inserted, skipped: skipped);
}
VarietyLot _toLot(Lot l, List<GerminationEntry> germinationTests) {
final hasQuantity =
l.quantityKind != null ||

View file

@ -26,8 +26,12 @@ part 'database.g.dart';
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
/// Current schema version; also stamped into interchange exports so an
/// importer knows which app generation wrote the file (data-model §7).
static const int currentSchemaVersion = 5;
@override
int get schemaVersion => 5;
int get schemaVersion => currentSchemaVersion;
@override
MigrationStrategy get migration => MigrationStrategy(

View file

@ -12,6 +12,9 @@ import '../db/database.dart';
import '../db/encrypted_executor.dart';
import '../security/secret_store.dart';
import '../security/secure_key_store.dart';
import '../services/export_import_service.dart';
import '../services/file_picker_file_service.dart';
import '../services/file_service.dart';
/// The app's service locator. Kept to the composition root — widgets get their
/// repositories from here (or via BlocProvider), never by reaching into it deep
@ -37,12 +40,22 @@ Future<void> configureDependencies() async {
final speciesRepository = SpeciesRepository(database, idGen: IdGen());
await speciesRepository.seedBundled(await loadBundledSpecies());
final varietyRepository = VarietyRepository(
database,
idGen: IdGen(),
nodeId: nodeId,
);
const fileService = FilePickerFileService();
getIt
..registerSingleton<SecureKeyStore>(keyStore)
..registerSingleton<AppDatabase>(database)
..registerSingleton<SpeciesRepository>(speciesRepository)
..registerSingleton<VarietyRepository>(
VarietyRepository(database, idGen: IdGen(), nodeId: nodeId),
..registerSingleton<VarietyRepository>(varietyRepository)
..registerSingleton<FileService>(fileService)
..registerSingleton<ExportImportService>(
ExportImportService(repository: varietyRepository, files: fileService),
);
}

View file

@ -43,6 +43,23 @@
"aboutText": "Local-first, encrypted inventory for traditional seeds. AGPL-3.0.",
"aboutOpen": "About Tanemaki"
},
"backup": {
"title": "Backup",
"exportCsv": "Export as CSV",
"exportCsvSubtitle": "For spreadsheets — no photos",
"exportJson": "Export as JSON",
"exportJsonSubtitle": "Complete copy, can be imported back",
"importJson": "Import from JSON",
"importJsonSubtitle": "Merge a saved copy into this inventory",
"importConfirmTitle": "Import a saved copy?",
"importConfirmBody": "Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.",
"importAction": "Import",
"exportSaved": "Copy saved",
"cancelled": "Cancelled",
"importDone": "Imported: {added} new, {updated} updated",
"importFailed": "This file could not be read as a Tanemaki copy",
"failed": "Something went wrong"
},
"about": {
"title": "About",
"kanji": "種まき",

View file

@ -43,6 +43,23 @@
"aboutText": "Inventario local y cifrado para semillas tradicionales. AGPL-3.0.",
"aboutOpen": "Acerca de Tanemaki"
},
"backup": {
"title": "Copia de seguridad",
"exportCsv": "Exportar a CSV",
"exportCsvSubtitle": "Para hojas de cálculo — sin fotos",
"exportJson": "Exportar a JSON",
"exportJsonSubtitle": "Copia completa, se puede volver a importar",
"importJson": "Importar desde JSON",
"importJsonSubtitle": "Fusiona una copia guardada con este inventario",
"importConfirmTitle": "¿Importar una copia guardada?",
"importConfirmBody": "Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.",
"importAction": "Importar",
"exportSaved": "Copia guardada",
"cancelled": "Cancelado",
"importDone": "Importado: {added} nuevas, {updated} actualizadas",
"importFailed": "Este fichero no se pudo leer como una copia de Tanemaki",
"failed": "Algo ha salido mal"
},
"about": {
"title": "Acerca de",
"kanji": "種まき",

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 2
/// Strings: 336 (168 per locale)
/// Strings: 366 (183 per locale)
///
/// Built on 2026-07-09 at 09:55 UTC
/// Built on 2026-07-09 at 10:41 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View file

@ -46,6 +46,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
late final Translations$photo$en photo = Translations$photo$en.internal(_root);
late final Translations$menu$en menu = Translations$menu$en.internal(_root);
late final Translations$settings$en settings = Translations$settings$en.internal(_root);
late final Translations$backup$en backup = Translations$backup$en.internal(_root);
late final Translations$about$en about = Translations$about$en.internal(_root);
late final Translations$inventory$en inventory = Translations$inventory$en.internal(_root);
late final Translations$quickAdd$en quickAdd = Translations$quickAdd$en.internal(_root);
@ -209,6 +210,60 @@ class Translations$settings$en {
String get aboutOpen => 'About Tanemaki';
}
// Path: backup
class Translations$backup$en {
Translations$backup$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Backup'
String get title => 'Backup';
/// en: 'Export as CSV'
String get exportCsv => 'Export as CSV';
/// en: 'For spreadsheets — no photos'
String get exportCsvSubtitle => 'For spreadsheets — no photos';
/// en: 'Export as JSON'
String get exportJson => 'Export as JSON';
/// en: 'Complete copy, can be imported back'
String get exportJsonSubtitle => 'Complete copy, can be imported back';
/// en: 'Import from JSON'
String get importJson => 'Import from JSON';
/// en: 'Merge a saved copy into this inventory'
String get importJsonSubtitle => 'Merge a saved copy into this inventory';
/// en: 'Import a saved copy?'
String get importConfirmTitle => 'Import a saved copy?';
/// en: 'Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.'
String get importConfirmBody => 'Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.';
/// en: 'Import'
String get importAction => 'Import';
/// en: 'Copy saved'
String get exportSaved => 'Copy saved';
/// en: 'Cancelled'
String get cancelled => 'Cancelled';
/// en: 'Imported: {added} new, {updated} updated'
String importDone({required Object added, required Object updated}) => 'Imported: ${added} new, ${updated} updated';
/// en: 'This file could not be read as a Tanemaki copy'
String get importFailed => 'This file could not be read as a Tanemaki copy';
/// en: 'Something went wrong'
String get failed => 'Something went wrong';
}
// Path: about
class Translations$about$en {
Translations$about$en.internal(this._root);
@ -976,6 +1031,21 @@ extension on Translations {
'settings.about' => 'About',
'settings.aboutText' => 'Local-first, encrypted inventory for traditional seeds. AGPL-3.0.',
'settings.aboutOpen' => 'About Tanemaki',
'backup.title' => 'Backup',
'backup.exportCsv' => 'Export as CSV',
'backup.exportCsvSubtitle' => 'For spreadsheets — no photos',
'backup.exportJson' => 'Export as JSON',
'backup.exportJsonSubtitle' => 'Complete copy, can be imported back',
'backup.importJson' => 'Import from JSON',
'backup.importJsonSubtitle' => 'Merge a saved copy into this inventory',
'backup.importConfirmTitle' => 'Import a saved copy?',
'backup.importConfirmBody' => 'Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.',
'backup.importAction' => 'Import',
'backup.exportSaved' => 'Copy saved',
'backup.cancelled' => 'Cancelled',
'backup.importDone' => ({required Object added, required Object updated}) => 'Imported: ${added} new, ${updated} updated',
'backup.importFailed' => 'This file could not be read as a Tanemaki copy',
'backup.failed' => 'Something went wrong',
'about.title' => 'About',
'about.kanji' => '種まき',
'about.tagline' => 'A local-first, decentralized app for managing and sharing traditional seeds and seedlings.',

View file

@ -45,6 +45,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
@override late final _Translations$photo$es photo = _Translations$photo$es._(_root);
@override late final _Translations$menu$es menu = _Translations$menu$es._(_root);
@override late final _Translations$settings$es settings = _Translations$settings$es._(_root);
@override late final _Translations$backup$es backup = _Translations$backup$es._(_root);
@override late final _Translations$about$es about = _Translations$about$es._(_root);
@override late final _Translations$inventory$es inventory = _Translations$inventory$es._(_root);
@override late final _Translations$quickAdd$es quickAdd = _Translations$quickAdd$es._(_root);
@ -144,6 +145,30 @@ class _Translations$settings$es extends Translations$settings$en {
@override String get aboutOpen => 'Acerca de Tanemaki';
}
// Path: backup
class _Translations$backup$es extends Translations$backup$en {
_Translations$backup$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get title => 'Copia de seguridad';
@override String get exportCsv => 'Exportar a CSV';
@override String get exportCsvSubtitle => 'Para hojas de cálculo — sin fotos';
@override String get exportJson => 'Exportar a JSON';
@override String get exportJsonSubtitle => 'Copia completa, se puede volver a importar';
@override String get importJson => 'Importar desde JSON';
@override String get importJsonSubtitle => 'Fusiona una copia guardada con este inventario';
@override String get importConfirmTitle => '¿Importar una copia guardada?';
@override String get importConfirmBody => 'Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.';
@override String get importAction => 'Importar';
@override String get exportSaved => 'Copia guardada';
@override String get cancelled => 'Cancelado';
@override String importDone({required Object added, required Object updated}) => 'Importado: ${added} nuevas, ${updated} actualizadas';
@override String get importFailed => 'Este fichero no se pudo leer como una copia de Tanemaki';
@override String get failed => 'Algo ha salido mal';
}
// Path: about
class _Translations$about$es extends Translations$about$en {
_Translations$about$es._(TranslationsEs root) : this._root = root, super.internal(root);
@ -661,6 +686,21 @@ extension on TranslationsEs {
'settings.about' => 'Acerca de',
'settings.aboutText' => 'Inventario local y cifrado para semillas tradicionales. AGPL-3.0.',
'settings.aboutOpen' => 'Acerca de Tanemaki',
'backup.title' => 'Copia de seguridad',
'backup.exportCsv' => 'Exportar a CSV',
'backup.exportCsvSubtitle' => 'Para hojas de cálculo — sin fotos',
'backup.exportJson' => 'Exportar a JSON',
'backup.exportJsonSubtitle' => 'Copia completa, se puede volver a importar',
'backup.importJson' => 'Importar desde JSON',
'backup.importJsonSubtitle' => 'Fusiona una copia guardada con este inventario',
'backup.importConfirmTitle' => '¿Importar una copia guardada?',
'backup.importConfirmBody' => 'Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.',
'backup.importAction' => 'Importar',
'backup.exportSaved' => 'Copia guardada',
'backup.cancelled' => 'Cancelado',
'backup.importDone' => ({required Object added, required Object updated}) => 'Importado: ${added} nuevas, ${updated} actualizadas',
'backup.importFailed' => 'Este fichero no se pudo leer como una copia de Tanemaki',
'backup.failed' => 'Algo ha salido mal',
'about.title' => 'Acerca de',
'about.kanji' => '種まき',
'about.tagline' => 'Una app local-first y descentralizada para gestionar y compartir semillas y plantones tradicionales.',

View 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';
}
}

View 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();
}
}

View 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});
}

View 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)));
}
}

View file

@ -2,11 +2,17 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../i18n/strings.g.dart';
import '../services/export_import_service.dart';
import 'backup_section.dart';
import 'theme.dart';
/// Basic settings: app language and an "about" section.
/// Basic settings: app language, backup (export/import) and an "about"
/// section. [exportImport] is injectable so widget tests can pass a fake;
/// the app resolves it lazily from the service locator.
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
const SettingsScreen({this.exportImport, super.key});
final ExportImportService? exportImport;
@override
Widget build(BuildContext context) {
@ -33,6 +39,9 @@ class SettingsScreen extends StatelessWidget {
onTap: () => LocaleSettings.useDeviceLocale(),
),
const Divider(),
_SectionHeader(t.backup.title),
BackupSection(service: exportImport),
const Divider(),
_SectionHeader(t.settings.about),
ListTile(
leading: Image.asset('assets/logo.png', width: 32),

View file

@ -44,6 +44,8 @@ dependencies:
# Routing + media + paths.
go_router: ^14.6.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_provider: ^2.1.5

View file

@ -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);
});
});
}

View file

@ -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')));
});
}

View file

@ -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);
});
}

View file

@ -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);
});
}

View 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,
);
});
}