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:
vjrj 2026-07-07 15:16:14 +02:00
parent 57c0eeadaf
commit 040f15a898
131 changed files with 19855 additions and 20 deletions

View file

@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:go_router/go_router.dart';
import 'data/variety_repository.dart';
import 'i18n/strings.g.dart';
import 'state/inventory_cubit.dart';
import 'ui/inventory_list_screen.dart';
/// Root widget. Provides the repository + inventory cubit to the tree and wires
/// go_router. The list is `/`; `/variety/:id` is a placeholder detail route
/// (the full item screen is a follow-on story).
class TaneApp extends StatelessWidget {
TaneApp({required this.repository, super.key})
: _router = _buildRouter(repository);
final VarietyRepository repository;
final GoRouter _router;
static GoRouter _buildRouter(VarietyRepository repository) {
return GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => BlocProvider(
create: (_) => InventoryCubit(repository),
child: const InventoryListScreen(),
),
),
GoRoute(
path: '/variety/:id',
builder: (context, state) =>
_VarietyDetailPlaceholder(id: state.pathParameters['id']!),
),
],
);
}
@override
Widget build(BuildContext context) {
return RepositoryProvider.value(
value: repository,
child: MaterialApp.router(
onGenerateTitle: (context) => context.t.app.title,
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: const Color(0xFF4C7A34), // seed-green
useMaterial3: true,
),
locale: TranslationProvider.of(context).flutterLocale,
supportedLocales: AppLocaleUtils.supportedLocales,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
routerConfig: _router,
),
);
}
}
class _VarietyDetailPlaceholder extends StatelessWidget {
const _VarietyDetailPlaceholder({required this.id});
final String id;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Center(child: Text(id)),
);
}
}

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

View file

@ -0,0 +1,38 @@
import 'package:drift/drift.dart';
import 'enums.dart';
import 'tables.dart';
part 'database.g.dart';
/// The encrypted local inventory database (SQLCipher via an injected executor).
///
/// The executor is passed in so production wires SQLCipher while tests can use
/// an in-memory database the schema and queries are identical either way.
@DriftDatabase(
tables: [
Varieties,
VarietyVernacularNames,
Species,
SpeciesCommonNames,
Lots,
GerminationTests,
Movements,
Parties,
Attachments,
ExternalLinks,
],
)
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
@override
int get schemaVersion => 1;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) async => m.createAll(),
// Step-by-step upgrades (from1To2, ) are generated into
// schema_versions.dart when schemaVersion is bumped. See data-model §5.
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,61 @@
import 'dart:ffi';
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart';
import 'package:sqlite3/open.dart';
import 'package:sqlite3/sqlite3.dart';
/// Routes package:sqlite3 to the **SQLCipher** build instead of plain SQLite.
///
/// - Android: the bundled SQLCipher `.so` (from sqlcipher_flutter_libs).
/// - Linux: the system `libsqlcipher.so` (dev machines / CI install it).
/// - iOS & macOS: SQLCipher is linked into the app binary no override needed.
void useSqlCipher() {
open
..overrideFor(OperatingSystem.android, openCipherOnAndroid)
..overrideFor(OperatingSystem.linux, _openLinuxCipher);
}
DynamicLibrary _openLinuxCipher() {
// The dev package ships `libsqlcipher.so`; the runtime package only the
// versioned `libsqlcipher.so.0`. Accept either.
try {
return DynamicLibrary.open('libsqlcipher.so');
} on ArgumentError {
return DynamicLibrary.open('libsqlcipher.so.0');
}
}
/// Opens [file] as an encrypted database using the raw 256-bit [keyHex].
///
/// Verifies SQLCipher is actually linked (`PRAGMA cipher_version`) and refuses
/// to fall back to plaintext enforcing "no plaintext at rest, ever".
QueryExecutor openEncryptedExecutor(File file, String keyHex) {
return LazyDatabase(() async {
if (Platform.isAndroid) {
await applyWorkaroundToOpenSqlCipherOnOldAndroidVersions();
}
return NativeDatabase.createInBackground(
file,
isolateSetup: useSqlCipher,
setup: (db) => applyKeyAndVerify(db, keyHex),
);
});
}
/// Applies the SQLCipher key to [db] and asserts encryption is active.
///
/// `x''` passes the key as raw bytes, skipping the KDF (our key is already a
/// random 256-bit value from the OS keystore). Exposed for the security test.
void applyKeyAndVerify(Database db, String keyHex) {
db.execute('PRAGMA key = "x\'$keyHex\'";');
final cipher = db.select('PRAGMA cipher_version;');
if (cipher.isEmpty) {
throw StateError(
'SQLCipher is not linked: PRAGMA cipher_version is empty. Encryption at '
'rest is mandatory — refusing to open a plaintext database.',
);
}
}

View file

@ -0,0 +1,27 @@
// Domain enums stored by **name** (never by index) via Drift `textEnum`, so
// the stored keys are stable across migrations. Per data-model §5.2, values
// may only be appended, never renumbered or reused; when sync arrives, readers
// must tolerate unknown values (map to a safe default). No sync yet in Block 1.
/// Per-lot visibility (data-model §2.3). Used by the future sharing layer.
enum OfferStatus { private, shared, exchange, sell }
/// Append-only event kinds on a Lot (data-model §2.4).
enum MovementType {
received,
given,
sown,
harvested,
germinationTest,
split,
discarded,
}
/// A counterparty is a person or a collective (data-model §2.5).
enum PartyKind { person, collective }
/// Attachment media kind (data-model §2.1).
enum AttachmentKind { photo, doc }
/// Polymorphic parent of an Attachment / ExternalLink.
enum ParentType { variety, lot, movement }

View file

@ -0,0 +1,30 @@
import 'package:drift/drift.dart';
/// Common columns on every *mutable* row (data-model §1). Carries the metadata
/// needed to merge across devices later (CRDT), even before sync exists.
///
/// `updatedAt` stores a packed [Hlc] string; `isDeleted` is a tombstone
/// rows are never physically deleted, so they can merge correctly.
mixin SyncColumns on Table {
TextColumn get id => text()();
IntColumn get createdAt => integer()();
TextColumn get updatedAt => text()(); // packed HLC
TextColumn get lastAuthor => text()(); // public key / device id
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
IntColumn get schemaRowVersion => integer().withDefault(const Constant(1))();
@override
Set<Column> get primaryKey => {id};
}
/// Common columns on *append-only* rows (only `Movement` for now). Immutable
/// events: no `updatedAt`/`isDeleted` a correction is a new compensating row.
mixin AppendOnlyColumns on Table {
TextColumn get id => text()();
IntColumn get createdAt => integer()();
TextColumn get lastAuthor => text()();
IntColumn get schemaRowVersion => integer().withDefault(const Constant(1))();
@override
Set<Column> get primaryKey => {id};
}

View file

@ -0,0 +1,107 @@
import 'package:drift/drift.dart';
import 'enums.dart';
import 'sync_columns.dart';
/// The identity/accession one row per distinct thing in the inventory.
/// Only [label] is mandatory (progressive disclosure).
class Varieties extends Table with SyncColumns {
TextColumn get label => text()();
TextColumn get speciesId => text().nullable()(); // Species
TextColumn get cultivarName => text().nullable()();
TextColumn get category =>
text().nullable()(); // free text, prefilled from family
TextColumn get notes => text().nullable()();
}
/// The multiple common names of a Variety (separate table so concurrent adds
/// merge as a set).
class VarietyVernacularNames extends Table with SyncColumns {
TextColumn get varietyId => text()();
TextColumn get name => text()();
TextColumn get language => text().nullable()();
TextColumn get region => text().nullable()();
}
/// Bundled, mostly read-only name catalog (Wikidata CC0 + GBIF CC-BY).
class Species extends Table with SyncColumns {
TextColumn get scientificName => text()();
TextColumn get wikidataQid => text().nullable()();
IntColumn get gbifKey => integer().nullable()();
TextColumn get family => text().nullable()();
BoolColumn get isBundled => boolean().withDefault(const Constant(false))();
}
/// Localized common names for the catalog (bundled).
class SpeciesCommonNames extends Table with SyncColumns {
TextColumn get speciesId => text()();
TextColumn get name => text()();
TextColumn get language => text().nullable()();
}
/// A homogeneous batch held for a Variety its own year and its own unit.
/// Quantity (commons_core value type) is flattened into columns here.
class Lots extends Table with SyncColumns {
TextColumn get varietyId => text()();
IntColumn get harvestYear => integer().nullable()();
TextColumn get quantityKind => text().nullable()(); // QuantityKind.name
RealColumn get quantityPrecise => real().nullable()();
TextColumn get quantityLabel => text().nullable()();
TextColumn get storageLocation => text().nullable()();
TextColumn get offerStatus =>
textEnum<OfferStatus>().withDefault(const Constant('private'))();
TextColumn get seedbankId => text().nullable()();
}
/// Optional germination history for a Lot; percent is derived in code.
class GerminationTests extends Table with SyncColumns {
TextColumn get lotId => text()();
IntColumn get testedOn => integer().nullable()(); // date, ms since epoch
IntColumn get sampleSize => integer().nullable()();
IntColumn get germinatedCount => integer().nullable()();
TextColumn get notes => text().nullable()();
}
/// The append-only event log on a Lot history + provenance DAG.
class Movements extends Table with AppendOnlyColumns {
TextColumn get lotId => text()();
TextColumn get type => textEnum<MovementType>()();
IntColumn get occurredOn => integer().nullable()(); // date, ms since epoch
TextColumn get counterpartyId => text().nullable()(); // Party
TextColumn get quantityKind => text().nullable()();
RealColumn get quantityPrecise => real().nullable()();
TextColumn get quantityLabel => text().nullable()();
TextColumn get parentMovementId => text().nullable()(); // provenance DAG
TextColumn get plantareId => text().nullable()(); // reserved (social layer)
TextColumn get notes => text().nullable()();
}
/// A person or collective you exchange with.
class Parties extends Table with SyncColumns {
TextColumn get displayName => text()();
TextColumn get publicKey => text().nullable()();
TextColumn get kind =>
textEnum<PartyKind>().withDefault(const Constant('person'))();
TextColumn get note => text().nullable()();
}
/// Photos/docs. Polymorphic parent. Photo bytes are stored in-DB (encrypted at
/// rest by SQLCipher) via [bytes]; external files use [uri]. Storing bytes here
/// keeps the "no plaintext at rest" rule for photos in Block 1; an external
/// encrypted file store is a later optimization.
class Attachments extends Table with SyncColumns {
TextColumn get parentType => textEnum<ParentType>()();
TextColumn get parentId => text()();
TextColumn get kind => textEnum<AttachmentKind>()();
TextColumn get uri => text().nullable()();
BlobColumn get bytes => blob().nullable()();
TextColumn get mimeType => text().nullable()();
}
/// Any pasted URL (Wikipedia, forum). Polymorphic parent.
class ExternalLinks extends Table with SyncColumns {
TextColumn get parentType => textEnum<ParentType>()();
TextColumn get parentId => text()();
TextColumn get url => text()();
TextColumn get title => text().nullable()();
}

View file

@ -0,0 +1,45 @@
import 'dart:io';
import 'package:commons_core/commons_core.dart';
import 'package:get_it/get_it.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../data/variety_repository.dart';
import '../db/database.dart';
import '../db/encrypted_executor.dart';
import '../security/secret_store.dart';
import '../security/secure_key_store.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
/// in the tree.
final GetIt getIt = GetIt.instance;
/// Wires the encrypted DB, keystore and repositories. Call once from `main`
/// before `runApp`; the DB key must exist before the DB opens.
Future<void> configureDependencies() async {
final keyStore = SecureKeyStore(store: FlutterSecretStore());
final dbKeyHex = await keyStore.databaseKeyHex();
final rootSeedHex = await keyStore.rootSeedHex();
final database = AppDatabase(
openEncryptedExecutor(await _databaseFile(), dbKeyHex),
);
// Until real key derivation lands, the node/author id is a stable per-install
// slice of the root seed. It becomes the user's public key in the social layer.
final nodeId = rootSeedHex.substring(0, 16);
getIt
..registerSingleton<SecureKeyStore>(keyStore)
..registerSingleton<AppDatabase>(database)
..registerSingleton<VarietyRepository>(
VarietyRepository(database, idGen: IdGen(), nodeId: nodeId),
);
}
Future<File> _databaseFile() async {
final dir = await getApplicationDocumentsDirectory();
return File(p.join(dir.path, 'tane_inventory.sqlite'));
}

View file

@ -0,0 +1,41 @@
{
"app": {
"title": "Tanemaki"
},
"inventory": {
"title": "Inventory",
"searchHint": "Search seeds",
"empty": "No seeds yet. Tap + to add your first.",
"uncategorized": "Uncategorized"
},
"quickAdd": {
"title": "Add a seed",
"labelField": "Name",
"labelRequired": "Give it a name",
"addPhoto": "Add photo",
"quantity": "How much?",
"more": "Add more…",
"save": "Save",
"cancel": "Cancel"
},
"quantityKind": {
"aFew": "a few",
"some": "some",
"plenty": "plenty",
"handful": "a handful",
"pinch": "a pinch",
"jar": "a jar",
"packet": "a packet",
"cob": "a cob",
"head": "a head",
"pod": "a pod",
"ear": "an ear",
"fruit": "a fruit",
"bulb": "a bulb",
"tuber": "a tuber",
"seedHead": "a seed head",
"bunch": "a bunch",
"grams": "grams",
"count": "count"
}
}

View file

@ -0,0 +1,41 @@
{
"app": {
"title": "Tanemaki"
},
"inventory": {
"title": "Inventario",
"searchHint": "Buscar semillas",
"empty": "Aún no hay semillas. Toca + para añadir la primera.",
"uncategorized": "Sin categoría"
},
"quickAdd": {
"title": "Añadir una semilla",
"labelField": "Nombre",
"labelRequired": "Ponle un nombre",
"addPhoto": "Añadir foto",
"quantity": "¿Cuánta?",
"more": "Añadir más…",
"save": "Guardar",
"cancel": "Cancelar"
},
"quantityKind": {
"aFew": "unas pocas",
"some": "algunas",
"plenty": "muchas",
"handful": "un puñado",
"pinch": "una pizca",
"jar": "un tarro",
"packet": "un sobre",
"cob": "una mazorca",
"head": "una cabezuela",
"pod": "una vaina",
"ear": "una espiga",
"fruit": "un fruto",
"bulb": "un bulbo",
"tuber": "un tubérculo",
"seedHead": "una cabeza de semillas",
"bunch": "un manojo",
"grams": "gramos",
"count": "unidades"
}
}

View file

@ -0,0 +1,173 @@
/// Generated file. Do not edit.
///
/// Source: lib/i18n
/// To regenerate, run: `dart run slang`
///
/// Locales: 2
/// Strings: 62 (31 per locale)
///
/// Built on 2026-07-07 at 13:12 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
// dart format off
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
import 'package:slang/generated.dart';
import 'package:slang_flutter/slang_flutter.dart';
export 'package:slang_flutter/slang_flutter.dart';
import 'strings_es.g.dart' as l_es;
part 'strings_en.g.dart';
/// Supported locales.
///
/// Usage:
/// - LocaleSettings.setLocale(AppLocale.en) // set locale
/// - Locale locale = AppLocale.en.flutterLocale // get flutter locale from enum
/// - if (LocaleSettings.currentLocale == AppLocale.en) // locale check
enum AppLocale with BaseAppLocale<AppLocale, Translations> {
en(languageCode: 'en'),
es(languageCode: 'es');
const AppLocale({
required this.languageCode,
this.scriptCode, // ignore: unused_element, unused_element_parameter
this.countryCode, // ignore: unused_element, unused_element_parameter
});
@override final String languageCode;
@override final String? scriptCode;
@override final String? countryCode;
@override
Future<Translations> build({
Map<String, Node>? overrides,
PluralResolver? cardinalResolver,
PluralResolver? ordinalResolver,
}) async {
return buildSync(
overrides: overrides,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
}
@override
Translations buildSync({
Map<String, Node>? overrides,
PluralResolver? cardinalResolver,
PluralResolver? ordinalResolver,
}) {
switch (this) {
case AppLocale.en:
return TranslationsEn(
overrides: overrides,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
case AppLocale.es:
return l_es.TranslationsEs(
overrides: overrides,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
}
}
/// Gets current instance managed by [LocaleSettings].
Translations get translations => LocaleSettings.instance.getTranslations(this);
}
/// Method A: Simple
///
/// No rebuild after locale change.
/// Translation happens during initialization of the widget (call of t).
/// Configurable via 'translate_var'.
///
/// Usage:
/// String a = t.someKey.anotherKey;
/// String b = t['someKey.anotherKey']; // Only for edge cases!
Translations get t => LocaleSettings.instance.currentTranslations;
/// Method B: Advanced
///
/// All widgets using this method will trigger a rebuild when locale changes.
/// Use this if you have e.g. a settings page where the user can select the locale during runtime.
///
/// Step 1:
/// wrap your App with
/// TranslationProvider(
/// child: MyApp()
/// );
///
/// Step 2:
/// final t = Translations.of(context); // Get t variable.
/// String a = t.someKey.anotherKey; // Use t variable.
/// String b = t['someKey.anotherKey']; // Only for edge cases!
class TranslationProvider extends BaseTranslationProvider<AppLocale, Translations> {
TranslationProvider({required super.child}) : super(settings: LocaleSettings.instance);
static InheritedLocaleData<AppLocale, Translations> of(BuildContext context) => InheritedLocaleData.of<AppLocale, Translations>(context);
}
/// Method B shorthand via [BuildContext] extension method.
/// Configurable via 'translate_var'.
///
/// Usage (e.g. in a widget's build method):
/// context.t.someKey.anotherKey
extension BuildContextTranslationsExtension on BuildContext {
Translations get t => TranslationProvider.of(this).translations;
}
/// Manages all translation instances and the current locale
class LocaleSettings extends BaseFlutterLocaleSettings<AppLocale, Translations> {
LocaleSettings._() : super(
utils: AppLocaleUtils.instance,
lazy: false,
);
static final instance = LocaleSettings._();
// static aliases (checkout base methods for documentation)
static AppLocale get currentLocale => instance.currentLocale;
static Stream<AppLocale> getLocaleStream() => instance.getLocaleStream();
static Future<AppLocale> setLocale(AppLocale locale, {bool? listenToDeviceLocale = false}) => instance.setLocale(locale, listenToDeviceLocale: listenToDeviceLocale);
static Future<AppLocale> setLocaleRaw(String rawLocale, {bool? listenToDeviceLocale = false}) => instance.setLocaleRaw(rawLocale, listenToDeviceLocale: listenToDeviceLocale);
static Future<AppLocale> useDeviceLocale() => instance.useDeviceLocale();
static Future<void> setPluralResolver({String? language, AppLocale? locale, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) => instance.setPluralResolver(
language: language,
locale: locale,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
// synchronous versions
static AppLocale setLocaleSync(AppLocale locale, {bool? listenToDeviceLocale = false}) => instance.setLocaleSync(locale, listenToDeviceLocale: listenToDeviceLocale);
static AppLocale setLocaleRawSync(String rawLocale, {bool? listenToDeviceLocale = false}) => instance.setLocaleRawSync(rawLocale, listenToDeviceLocale: listenToDeviceLocale);
static AppLocale useDeviceLocaleSync() => instance.useDeviceLocaleSync();
static void setPluralResolverSync({String? language, AppLocale? locale, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) => instance.setPluralResolverSync(
language: language,
locale: locale,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
}
/// Provides utility functions without any side effects.
class AppLocaleUtils extends BaseAppLocaleUtils<AppLocale, Translations> {
AppLocaleUtils._() : super(
baseLocale: AppLocale.en,
locales: AppLocale.values,
);
static final instance = AppLocaleUtils._();
// static aliases (checkout base methods for documentation)
static AppLocale parse(String rawLocale) => instance.parse(rawLocale);
static AppLocale parseLocaleParts({required String languageCode, String? scriptCode, String? countryCode}) => instance.parseLocaleParts(languageCode: languageCode, scriptCode: scriptCode, countryCode: countryCode);
static AppLocale findDeviceLocale() => instance.findDeviceLocale();
static List<Locale> get supportedLocales => instance.supportedLocales;
static List<String> get supportedLocalesRaw => instance.supportedLocalesRaw;
}

View file

@ -0,0 +1,220 @@
///
/// Generated file. Do not edit.
///
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
// dart format off
part of 'strings.g.dart';
// Path: <root>
typedef TranslationsEn = Translations; // ignore: unused_element
class Translations with BaseTranslations<AppLocale, Translations> {
/// Returns the current translations of the given [context].
///
/// Usage:
/// final t = Translations.of(context);
static Translations of(BuildContext context) => InheritedLocaleData.of<AppLocale, Translations>(context).translations;
/// You can call this constructor and build your own translation instance of this locale.
/// Constructing via the enum [AppLocale.build] is preferred.
Translations({Map<String, Node>? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata<AppLocale, Translations>? meta})
: assert(overrides == null, 'Set "translation_overrides: true" in order to enable this feature.'),
$meta = meta ?? TranslationMetadata(
locale: AppLocale.en,
overrides: overrides ?? {},
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
) {
$meta.setFlatMapFunction(_flatMapFunction);
}
/// Metadata for the translations of <en>.
@override final TranslationMetadata<AppLocale, Translations> $meta;
/// Access flat map
dynamic operator[](String key) => $meta.getTranslation(key);
late final Translations _root = this; // ignore: unused_field
Translations $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => Translations(meta: meta ?? this.$meta);
// Translations
late final Translations$app$en app = Translations$app$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$quantityKind$en quantityKind = Translations$quantityKind$en.internal(_root);
}
// Path: app
class Translations$app$en {
Translations$app$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Tanemaki'
String get title => 'Tanemaki';
}
// Path: inventory
class Translations$inventory$en {
Translations$inventory$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Inventory'
String get title => 'Inventory';
/// en: 'Search seeds'
String get searchHint => 'Search seeds';
/// en: 'No seeds yet. Tap + to add your first.'
String get empty => 'No seeds yet. Tap + to add your first.';
/// en: 'Uncategorized'
String get uncategorized => 'Uncategorized';
}
// Path: quickAdd
class Translations$quickAdd$en {
Translations$quickAdd$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Add a seed'
String get title => 'Add a seed';
/// en: 'Name'
String get labelField => 'Name';
/// en: 'Give it a name'
String get labelRequired => 'Give it a name';
/// en: 'Add photo'
String get addPhoto => 'Add photo';
/// en: 'How much?'
String get quantity => 'How much?';
/// en: 'Add more…'
String get more => 'Add more…';
/// en: 'Save'
String get save => 'Save';
/// en: 'Cancel'
String get cancel => 'Cancel';
}
// Path: quantityKind
class Translations$quantityKind$en {
Translations$quantityKind$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'a few'
String get aFew => 'a few';
/// en: 'some'
String get some => 'some';
/// en: 'plenty'
String get plenty => 'plenty';
/// en: 'a handful'
String get handful => 'a handful';
/// en: 'a pinch'
String get pinch => 'a pinch';
/// en: 'a jar'
String get jar => 'a jar';
/// en: 'a packet'
String get packet => 'a packet';
/// en: 'a cob'
String get cob => 'a cob';
/// en: 'a head'
String get head => 'a head';
/// en: 'a pod'
String get pod => 'a pod';
/// en: 'an ear'
String get ear => 'an ear';
/// en: 'a fruit'
String get fruit => 'a fruit';
/// en: 'a bulb'
String get bulb => 'a bulb';
/// en: 'a tuber'
String get tuber => 'a tuber';
/// en: 'a seed head'
String get seedHead => 'a seed head';
/// en: 'a bunch'
String get bunch => 'a bunch';
/// en: 'grams'
String get grams => 'grams';
/// en: 'count'
String get count => 'count';
}
/// The flat map containing all translations for locale <en>.
/// Only for edge cases! For simple maps, use the map function of this library.
///
/// The Dart AOT compiler has issues with very large switch statements,
/// so the map is split into smaller functions (512 entries each).
extension on Translations {
dynamic _flatMapFunction(String path) {
return switch (path) {
'app.title' => 'Tanemaki',
'inventory.title' => 'Inventory',
'inventory.searchHint' => 'Search seeds',
'inventory.empty' => 'No seeds yet. Tap + to add your first.',
'inventory.uncategorized' => 'Uncategorized',
'quickAdd.title' => 'Add a seed',
'quickAdd.labelField' => 'Name',
'quickAdd.labelRequired' => 'Give it a name',
'quickAdd.addPhoto' => 'Add photo',
'quickAdd.quantity' => 'How much?',
'quickAdd.more' => 'Add more…',
'quickAdd.save' => 'Save',
'quickAdd.cancel' => 'Cancel',
'quantityKind.aFew' => 'a few',
'quantityKind.some' => 'some',
'quantityKind.plenty' => 'plenty',
'quantityKind.handful' => 'a handful',
'quantityKind.pinch' => 'a pinch',
'quantityKind.jar' => 'a jar',
'quantityKind.packet' => 'a packet',
'quantityKind.cob' => 'a cob',
'quantityKind.head' => 'a head',
'quantityKind.pod' => 'a pod',
'quantityKind.ear' => 'an ear',
'quantityKind.fruit' => 'a fruit',
'quantityKind.bulb' => 'a bulb',
'quantityKind.tuber' => 'a tuber',
'quantityKind.seedHead' => 'a seed head',
'quantityKind.bunch' => 'a bunch',
'quantityKind.grams' => 'grams',
'quantityKind.count' => 'count',
_ => null,
};
}
}

View file

@ -0,0 +1,157 @@
///
/// Generated file. Do not edit.
///
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
// dart format off
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
import 'package:slang/generated.dart';
import 'strings.g.dart';
// Path: <root>
class TranslationsEs extends Translations with BaseTranslations<AppLocale, Translations> {
/// You can call this constructor and build your own translation instance of this locale.
/// Constructing via the enum [AppLocale.build] is preferred.
TranslationsEs({Map<String, Node>? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata<AppLocale, Translations>? meta})
: assert(overrides == null, 'Set "translation_overrides: true" in order to enable this feature.'),
$meta = meta ?? TranslationMetadata(
locale: AppLocale.es,
overrides: overrides ?? {},
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
),
super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver) {
super.$meta.setFlatMapFunction($meta.getTranslation); // copy base translations to super.$meta
$meta.setFlatMapFunction(_flatMapFunction);
}
/// Metadata for the translations of <es>.
@override final TranslationMetadata<AppLocale, Translations> $meta;
/// Access flat map
@override dynamic operator[](String key) => $meta.getTranslation(key) ?? super.$meta.getTranslation(key);
late final TranslationsEs _root = this; // ignore: unused_field
@override
TranslationsEs $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => TranslationsEs(meta: meta ?? this.$meta);
// Translations
@override late final _Translations$app$es app = _Translations$app$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$quantityKind$es quantityKind = _Translations$quantityKind$es._(_root);
}
// Path: app
class _Translations$app$es extends Translations$app$en {
_Translations$app$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get title => 'Tanemaki';
}
// Path: inventory
class _Translations$inventory$es extends Translations$inventory$en {
_Translations$inventory$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get title => 'Inventario';
@override String get searchHint => 'Buscar semillas';
@override String get empty => 'Aún no hay semillas. Toca + para añadir la primera.';
@override String get uncategorized => 'Sin categoría';
}
// Path: quickAdd
class _Translations$quickAdd$es extends Translations$quickAdd$en {
_Translations$quickAdd$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get title => 'Añadir una semilla';
@override String get labelField => 'Nombre';
@override String get labelRequired => 'Ponle un nombre';
@override String get addPhoto => 'Añadir foto';
@override String get quantity => '¿Cuánta?';
@override String get more => 'Añadir más…';
@override String get save => 'Guardar';
@override String get cancel => 'Cancelar';
}
// Path: quantityKind
class _Translations$quantityKind$es extends Translations$quantityKind$en {
_Translations$quantityKind$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get aFew => 'unas pocas';
@override String get some => 'algunas';
@override String get plenty => 'muchas';
@override String get handful => 'un puñado';
@override String get pinch => 'una pizca';
@override String get jar => 'un tarro';
@override String get packet => 'un sobre';
@override String get cob => 'una mazorca';
@override String get head => 'una cabezuela';
@override String get pod => 'una vaina';
@override String get ear => 'una espiga';
@override String get fruit => 'un fruto';
@override String get bulb => 'un bulbo';
@override String get tuber => 'un tubérculo';
@override String get seedHead => 'una cabeza de semillas';
@override String get bunch => 'un manojo';
@override String get grams => 'gramos';
@override String get count => 'unidades';
}
/// The flat map containing all translations for locale <es>.
/// Only for edge cases! For simple maps, use the map function of this library.
///
/// The Dart AOT compiler has issues with very large switch statements,
/// so the map is split into smaller functions (512 entries each).
extension on TranslationsEs {
dynamic _flatMapFunction(String path) {
return switch (path) {
'app.title' => 'Tanemaki',
'inventory.title' => 'Inventario',
'inventory.searchHint' => 'Buscar semillas',
'inventory.empty' => 'Aún no hay semillas. Toca + para añadir la primera.',
'inventory.uncategorized' => 'Sin categoría',
'quickAdd.title' => 'Añadir una semilla',
'quickAdd.labelField' => 'Nombre',
'quickAdd.labelRequired' => 'Ponle un nombre',
'quickAdd.addPhoto' => 'Añadir foto',
'quickAdd.quantity' => '¿Cuánta?',
'quickAdd.more' => 'Añadir más…',
'quickAdd.save' => 'Guardar',
'quickAdd.cancel' => 'Cancelar',
'quantityKind.aFew' => 'unas pocas',
'quantityKind.some' => 'algunas',
'quantityKind.plenty' => 'muchas',
'quantityKind.handful' => 'un puñado',
'quantityKind.pinch' => 'una pizca',
'quantityKind.jar' => 'un tarro',
'quantityKind.packet' => 'un sobre',
'quantityKind.cob' => 'una mazorca',
'quantityKind.head' => 'una cabezuela',
'quantityKind.pod' => 'una vaina',
'quantityKind.ear' => 'una espiga',
'quantityKind.fruit' => 'un fruto',
'quantityKind.bulb' => 'un bulbo',
'quantityKind.tuber' => 'un tubérculo',
'quantityKind.seedHead' => 'una cabeza de semillas',
'quantityKind.bunch' => 'un manojo',
'quantityKind.grams' => 'gramos',
'quantityKind.count' => 'unidades',
_ => null,
};
}
}

View file

@ -0,0 +1,15 @@
import 'package:flutter/widgets.dart';
import 'app.dart';
import 'data/variety_repository.dart';
import 'di/injector.dart';
import 'i18n/strings.g.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
LocaleSettings.useDeviceLocaleSync();
await configureDependencies();
runApp(
TranslationProvider(child: TaneApp(repository: getIt<VarietyRepository>())),
);
}

View file

@ -0,0 +1,27 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Minimal secret key/value store, backed by the OS keystore in production and
/// trivially fakeable in tests. Keeps [SecureKeyStore] free of plugin calls.
abstract class SecretStore {
Future<String?> read(String key);
Future<void> write(String key, String value);
}
/// OS-keystore-backed implementation (Android Keystore / iOS Keychain / etc.).
class FlutterSecretStore implements SecretStore {
FlutterSecretStore([FlutterSecureStorage? storage])
: _storage =
storage ??
const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
final FlutterSecureStorage _storage;
@override
Future<String?> read(String key) => _storage.read(key: key);
@override
Future<void> write(String key, String value) =>
_storage.write(key: key, value: value);
}

View file

@ -0,0 +1,45 @@
import 'package:commons_core/commons_core.dart';
import 'secret_store.dart';
/// Owns the app's secrets in the OS keystore and creates them on first run:
///
/// - the **DB key**: a random 256-bit symmetric key for SQLCipher (NOT derived
/// from any user password see CLAUDE.md identity section);
/// - the **root seed**: the Duniter/Ğ1-style identity seed (stub for now).
///
/// Both are stored as lowercase hex strings.
class SecureKeyStore {
SecureKeyStore({required SecretStore store, IdentityService? identity})
: _store = store,
_identity = identity ?? IdentityService();
final SecretStore _store;
final IdentityService _identity;
static const dbKeyName = 'tane.db_key';
static const rootSeedName = 'tane.root_seed';
static const _dbKeyLengthBytes = 32;
/// The SQLCipher database key as hex, created and persisted on first access.
Future<String> databaseKeyHex() =>
_readOrCreate(dbKeyName, () => randomBytes(_dbKeyLengthBytes));
/// The root identity seed as hex, created and persisted on first access.
Future<String> rootSeedHex() =>
_readOrCreate(rootSeedName, _identity.generateRootSeed);
Future<String> _readOrCreate(
String key,
List<int> Function() generate,
) async {
final existing = await _store.read(key);
if (existing != null) return existing;
final hex = _toHex(generate());
await _store.write(key, hex);
return hex;
}
static String _toHex(List<int> bytes) =>
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}

View file

@ -0,0 +1,62 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../data/variety_repository.dart';
/// Inventory list state: all items from the DB plus the current search query.
/// [visibleItems] applies the query; grouping by category is done in the UI.
class InventoryState extends Equatable {
const InventoryState({
this.items = const [],
this.query = '',
this.loading = true,
});
final List<VarietyListItem> items;
final String query;
final bool loading;
List<VarietyListItem> get visibleItems {
if (query.trim().isEmpty) return items;
final q = query.toLowerCase();
return items.where((i) => i.label.toLowerCase().contains(q)).toList();
}
InventoryState copyWith({
List<VarietyListItem>? items,
String? query,
bool? loading,
}) {
return InventoryState(
items: items ?? this.items,
query: query ?? this.query,
loading: loading ?? this.loading,
);
}
@override
List<Object?> get props => [items, query, loading];
}
/// Subscribes to the repository's reactive inventory stream. The list updates
/// automatically after a quick-add no manual refresh.
class InventoryCubit extends Cubit<InventoryState> {
InventoryCubit(this._repo) : super(const InventoryState()) {
_sub = _repo.watchInventory().listen(
(items) => emit(state.copyWith(items: items, loading: false)),
);
}
final VarietyRepository _repo;
late final StreamSubscription<List<VarietyListItem>> _sub;
void search(String query) => emit(state.copyWith(query: query));
@override
Future<void> close() async {
await _sub.cancel();
return super.close();
}
}

View file

@ -0,0 +1,97 @@
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));
}
}

View file

@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../data/variety_repository.dart';
import '../i18n/strings.g.dart';
import '../state/inventory_cubit.dart';
import 'quick_add_sheet.dart';
/// The inventory home: a searchable list of seeds grouped by category, with a
/// quick-add FAB. Driven by [InventoryCubit] over the encrypted DB stream.
class InventoryListScreen extends StatelessWidget {
const InventoryListScreen({super.key});
@override
Widget build(BuildContext context) {
final t = context.t;
return Scaffold(
appBar: AppBar(title: Text(t.inventory.title)),
floatingActionButton: FloatingActionButton(
key: const Key('inventory.addFab'),
tooltip: t.quickAdd.title,
onPressed: () => showQuickAddSheet(
context,
repository: context.read<VarietyRepository>(),
),
child: const Icon(Icons.add),
),
body: BlocBuilder<InventoryCubit, InventoryState>(
builder: (context, state) {
if (state.loading) {
return const Center(child: CircularProgressIndicator());
}
return Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: TextField(
key: const Key('inventory.search'),
decoration: InputDecoration(
hintText: t.inventory.searchHint,
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
),
onChanged: context.read<InventoryCubit>().search,
),
),
Expanded(child: _InventoryBody(items: state.visibleItems)),
],
);
},
),
);
}
}
class _InventoryBody extends StatelessWidget {
const _InventoryBody({required this.items});
final List<VarietyListItem> items;
@override
Widget build(BuildContext context) {
final t = context.t;
if (items.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
t.inventory.empty,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
),
);
}
// Items arrive ordered by category then label; insert a header whenever the
// category changes.
final rows = <Widget>[];
String? currentCategory;
for (final item in items) {
final category = item.category ?? t.inventory.uncategorized;
if (category != currentCategory) {
currentCategory = category;
rows.add(_CategoryHeader(title: category));
}
rows.add(_VarietyTile(item: item));
}
return ListView(children: rows);
}
}
class _CategoryHeader extends StatelessWidget {
const _CategoryHeader({required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
);
}
}
class _VarietyTile extends StatelessWidget {
const _VarietyTile({required this.item});
final VarietyListItem item;
@override
Widget build(BuildContext context) {
final trimmed = item.label.trim();
final initial = trimmed.isEmpty
? '?'
: trimmed.substring(0, 1).toUpperCase();
return ListTile(
leading: CircleAvatar(child: Text(initial)),
title: Text(item.label),
);
}
}

View file

@ -0,0 +1,47 @@
import 'package:commons_core/commons_core.dart';
import '../i18n/strings.g.dart';
/// Localized display label for a [QuantityKind]. The enum name is the stable
/// storage key; the label is always resolved through i18n (never hardcoded).
String quantityKindLabel(Translations t, QuantityKind kind) {
final q = t.quantityKind;
switch (kind) {
case QuantityKind.aFew:
return q.aFew;
case QuantityKind.some:
return q.some;
case QuantityKind.plenty:
return q.plenty;
case QuantityKind.handful:
return q.handful;
case QuantityKind.pinch:
return q.pinch;
case QuantityKind.jar:
return q.jar;
case QuantityKind.packet:
return q.packet;
case QuantityKind.cob:
return q.cob;
case QuantityKind.head:
return q.head;
case QuantityKind.pod:
return q.pod;
case QuantityKind.ear:
return q.ear;
case QuantityKind.fruit:
return q.fruit;
case QuantityKind.bulb:
return q.bulb;
case QuantityKind.tuber:
return q.tuber;
case QuantityKind.seedHead:
return q.seedHead;
case QuantityKind.bunch:
return q.bunch;
case QuantityKind.grams:
return q.grams;
case QuantityKind.count:
return q.count;
}
}

View file

@ -0,0 +1,184 @@
import 'dart:typed_data';
import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
import '../data/variety_repository.dart';
import '../i18n/strings.g.dart';
import '../state/quick_add_cubit.dart';
import 'quantity_kind_l10n.dart';
/// Picks a photo and returns its bytes (or null if cancelled). Injected so
/// widget tests can supply a fake instead of the camera plugin.
typedef PhotoPicker = Future<Uint8List?> Function();
/// A short, high-frequency subset of quantity units shown up front to keep the
/// add flow fast; the rest live behind "Add more…".
const _quickKinds = <QuantityKind>[
QuantityKind.aFew,
QuantityKind.handful,
QuantityKind.packet,
QuantityKind.pod,
QuantityKind.cob,
QuantityKind.grams,
];
Future<void> showQuickAddSheet(
BuildContext context, {
required VarietyRepository repository,
PhotoPicker? photoPicker,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => BlocProvider(
create: (_) => QuickAddCubit(repository),
child: QuickAddSheet(photoPicker: photoPicker ?? _cameraPhotoPicker),
),
);
}
Future<Uint8List?> _cameraPhotoPicker() async {
final file = await ImagePicker().pickImage(
source: ImageSource.camera,
maxWidth: 1280,
imageQuality: 80,
);
return file?.readAsBytes();
}
class QuickAddSheet extends StatelessWidget {
const QuickAddSheet({required this.photoPicker, super.key});
final PhotoPicker photoPicker;
@override
Widget build(BuildContext context) {
final t = context.t;
return BlocConsumer<QuickAddCubit, QuickAddState>(
listenWhen: (prev, curr) => !prev.submitted && curr.submitted,
listener: (context, state) => Navigator.of(context).pop(),
builder: (context, state) {
final cubit = context.read<QuickAddCubit>();
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
t.quickAdd.title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
TextField(
key: const Key('quickAdd.labelField'),
autofocus: true,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: t.quickAdd.labelField,
errorText: state.showLabelError
? t.quickAdd.labelRequired
: null,
border: const OutlineInputBorder(),
),
onChanged: cubit.labelChanged,
onSubmitted: (_) => cubit.submit(),
),
const SizedBox(height: 16),
Text(
t.quickAdd.quantity,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
for (final kind in _quickKinds)
ChoiceChip(
label: Text(quantityKindLabel(t, kind)),
selected: state.quantityKind == kind,
onSelected: (_) => cubit.selectQuantity(kind),
),
],
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: cubit.toggleExpanded,
icon: Icon(
state.expanded ? Icons.expand_less : Icons.expand_more,
),
label: Text(t.quickAdd.more),
),
),
if (state.expanded) _MoreSection(photoPicker: photoPicker),
const SizedBox(height: 8),
Row(
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(t.quickAdd.cancel),
),
const Spacer(),
FilledButton(
key: const Key('quickAdd.save'),
onPressed: state.submitting ? null : cubit.submit,
child: Text(t.quickAdd.save),
),
],
),
],
),
);
},
);
}
}
class _MoreSection extends StatelessWidget {
const _MoreSection({required this.photoPicker});
final PhotoPicker photoPicker;
@override
Widget build(BuildContext context) {
final t = context.t;
final state = context.watch<QuickAddCubit>().state;
final cubit = context.read<QuickAddCubit>();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
if (state.photoBytes != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.memory(
state.photoBytes!,
height: 120,
fit: BoxFit.cover,
),
),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
onPressed: () async {
final bytes = await photoPicker();
if (bytes != null) cubit.photoPicked(bytes);
},
icon: const Icon(Icons.photo_camera_outlined),
label: Text(t.quickAdd.addPhoto),
),
),
],
);
}
}