feat(block1): inventory walking skeleton + first quick-add slice
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.
This commit is contained in:
parent
57c0eeadaf
commit
040f15a898
131 changed files with 19855 additions and 20 deletions
129
apps/app_seeds/lib/data/variety_repository.dart
Normal file
129
apps/app_seeds/lib/data/variety_repository.dart
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue