Stand up the Tanemaki monorepo and the first end-to-end vertical slice of Block 1 (offline, encrypted inventory): add a seed → see it in a categorized, searchable list → it persists → reopen and it's still there. Architecture (state management like G1nkgo, adapted to Tane's reality): - flutter_bloc (Cubit-first), but the encrypted Drift DB is the single source of truth; cubits stream from repositories (no hydrated_bloc/Hive, which would write plaintext at rest). - get_it composition root; go_router; slang i18n (ES/EN, Weblate-friendly JSON). Workspace & core: - pub workspace: packages/commons_core (pure Dart) + apps/app_seeds (Flutter). - commons_core primitives: UUIDv7 IdGen, Hybrid Logical Clock, Quantity value type (+ plant-aware QuantityKind), IdentityService root-seed stub. Data & security: - Drift schemaVersion=1 with all 10 Block-1 tables + common CRDT columns (HLC updated_at, last_author, tombstones); Movement append-only. - SQLCipher via an injectable executor that refuses to open a plaintext DB; DB key + root seed in the OS keystore (separate secrets). - Exported drift_schema_v1.json + migration scaffold. Tests (near-TDD; nothing merges without tests): - commons_core units (24), Drift migration test, "no plaintext at rest" security guard (runs where SQLCipher is present, skips otherwise), repository, widget, full quick-add flow, file-reopen persistence, and a no-hardcoded-strings i18n guard. GitLab CI: format + analyze + test + coverage. Follow-on Block-1 stories (item detail/edit, species catalog, germination UI) and all of Block 2 remain out of scope.
129 lines
3.9 KiB
Dart
129 lines
3.9 KiB
Dart
import 'package:commons_core/commons_core.dart';
|
|
import 'package:drift/drift.dart';
|
|
import 'package:equatable/equatable.dart';
|
|
|
|
import '../db/database.dart';
|
|
import '../db/enums.dart';
|
|
|
|
/// A lightweight row for the inventory list (only what the list renders).
|
|
class VarietyListItem extends Equatable {
|
|
const VarietyListItem({required this.id, required this.label, this.category});
|
|
|
|
final String id;
|
|
final String label;
|
|
final String? category;
|
|
|
|
@override
|
|
List<Object?> get props => [id, label, category];
|
|
}
|
|
|
|
/// Reads and writes the inventory. The encrypted Drift DB is the single source
|
|
/// of truth; [watchInventory] exposes a reactive stream the UI subscribes to.
|
|
///
|
|
/// Every write stamps the row with the local [Hlc] and [nodeId] so the CRDT
|
|
/// metadata is correct from day one, even though sync does not exist yet.
|
|
class VarietyRepository {
|
|
VarietyRepository(
|
|
this._db, {
|
|
required this.idGen,
|
|
required this.nodeId,
|
|
int Function()? nowMillis,
|
|
}) : _now = nowMillis ?? (() => DateTime.now().millisecondsSinceEpoch),
|
|
_clock = Hlc.zero(nodeId);
|
|
|
|
final AppDatabase _db;
|
|
final IdGen idGen;
|
|
final String nodeId;
|
|
final int Function() _now;
|
|
Hlc _clock;
|
|
|
|
/// Emits the non-deleted inventory, ordered by category then label.
|
|
Stream<List<VarietyListItem>> watchInventory() {
|
|
final query = _db.select(_db.varieties)
|
|
..where((v) => v.isDeleted.equals(false))
|
|
..orderBy([
|
|
(v) => OrderingTerm(expression: v.category),
|
|
(v) => OrderingTerm(expression: v.label),
|
|
]);
|
|
return query.watch().map(
|
|
(rows) => rows
|
|
.map(
|
|
(v) =>
|
|
VarietyListItem(id: v.id, label: v.label, category: v.category),
|
|
)
|
|
.toList(),
|
|
);
|
|
}
|
|
|
|
/// The 20-second quick-add: a [label] (required) plus an optional [category],
|
|
/// an optional [quantity] (creates a Lot) and an optional [photoBytes]
|
|
/// (stored as an encrypted BLOB Attachment). Returns the new Variety id.
|
|
Future<String> addQuickVariety({
|
|
required String label,
|
|
String? category,
|
|
Quantity? quantity,
|
|
Uint8List? photoBytes,
|
|
}) async {
|
|
final varietyId = idGen.newId();
|
|
await _db.transaction(() async {
|
|
final (created, updated) = _stamp();
|
|
await _db
|
|
.into(_db.varieties)
|
|
.insert(
|
|
VarietiesCompanion.insert(
|
|
id: varietyId,
|
|
label: label,
|
|
createdAt: created,
|
|
updatedAt: updated,
|
|
lastAuthor: nodeId,
|
|
category: Value(category),
|
|
),
|
|
);
|
|
|
|
if (quantity != null) {
|
|
final (created, updated) = _stamp();
|
|
await _db
|
|
.into(_db.lots)
|
|
.insert(
|
|
LotsCompanion.insert(
|
|
id: idGen.newId(),
|
|
varietyId: varietyId,
|
|
createdAt: created,
|
|
updatedAt: updated,
|
|
lastAuthor: nodeId,
|
|
quantityKind: Value(quantity.kind.name),
|
|
quantityPrecise: Value(quantity.precise),
|
|
quantityLabel: Value(quantity.label),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (photoBytes != null) {
|
|
final (created, updated) = _stamp();
|
|
await _db
|
|
.into(_db.attachments)
|
|
.insert(
|
|
AttachmentsCompanion.insert(
|
|
id: idGen.newId(),
|
|
createdAt: created,
|
|
updatedAt: updated,
|
|
lastAuthor: nodeId,
|
|
parentType: ParentType.variety,
|
|
parentId: varietyId,
|
|
kind: AttachmentKind.photo,
|
|
bytes: Value(photoBytes),
|
|
mimeType: const Value('image/jpeg'),
|
|
),
|
|
);
|
|
}
|
|
});
|
|
return varietyId;
|
|
}
|
|
|
|
/// Advances the local clock and returns `(createdAtMillis, packedHlc)`.
|
|
(int, String) _stamp() {
|
|
final now = _now();
|
|
_clock = _clock.localEvent(now);
|
|
return (now, _clock.pack());
|
|
}
|
|
}
|