tane/apps/app_seeds/lib/state/quick_add_cubit.dart
vjrj 040f15a898 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.
2026-07-07 15:16:14 +02:00

97 lines
2.7 KiB
Dart

import 'dart:typed_data';
import 'package:commons_core/commons_core.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../data/variety_repository.dart';
/// Form state for the quick-add sheet. Only [label] is required; everything
/// else is progressive disclosure behind [expanded].
class QuickAddState extends Equatable {
const QuickAddState({
this.label = '',
this.quantityKind,
this.photoBytes,
this.expanded = false,
this.submitting = false,
this.submitted = false,
this.showLabelError = false,
});
final String label;
final QuantityKind? quantityKind;
final Uint8List? photoBytes;
final bool expanded;
final bool submitting;
final bool submitted;
final bool showLabelError;
bool get hasValidLabel => label.trim().isNotEmpty;
QuickAddState copyWith({
String? label,
QuantityKind? quantityKind,
Uint8List? photoBytes,
bool? expanded,
bool? submitting,
bool? submitted,
bool? showLabelError,
}) {
return QuickAddState(
label: label ?? this.label,
quantityKind: quantityKind ?? this.quantityKind,
photoBytes: photoBytes ?? this.photoBytes,
expanded: expanded ?? this.expanded,
submitting: submitting ?? this.submitting,
submitted: submitted ?? this.submitted,
showLabelError: showLabelError ?? this.showLabelError,
);
}
@override
List<Object?> get props => [
label,
quantityKind,
photoBytes,
expanded,
submitting,
submitted,
showLabelError,
];
}
/// Drives the 20-second quick-add flow and persists via [VarietyRepository].
class QuickAddCubit extends Cubit<QuickAddState> {
QuickAddCubit(this._repo) : super(const QuickAddState());
final VarietyRepository _repo;
void labelChanged(String value) =>
emit(state.copyWith(label: value, showLabelError: false));
void selectQuantity(QuantityKind kind) =>
emit(state.copyWith(quantityKind: kind));
void photoPicked(Uint8List bytes) => emit(state.copyWith(photoBytes: bytes));
void toggleExpanded() => emit(state.copyWith(expanded: !state.expanded));
/// Validates and saves. No-op (sets [showLabelError]) if the label is empty.
Future<void> submit() async {
if (!state.hasValidLabel) {
emit(state.copyWith(showLabelError: true));
return;
}
if (state.submitting) return;
emit(state.copyWith(submitting: true));
await _repo.addQuickVariety(
label: state.label.trim(),
quantity: state.quantityKind == null
? null
: Quantity(kind: state.quantityKind!),
photoBytes: state.photoBytes,
);
emit(state.copyWith(submitting: false, submitted: true));
}
}