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
76
apps/app_seeds/lib/app.dart
Normal file
76
apps/app_seeds/lib/app.dart
Normal 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
38
apps/app_seeds/lib/db/database.dart
Normal file
38
apps/app_seeds/lib/db/database.dart
Normal 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.
|
||||
);
|
||||
}
|
||||
10194
apps/app_seeds/lib/db/database.g.dart
Normal file
10194
apps/app_seeds/lib/db/database.g.dart
Normal file
File diff suppressed because it is too large
Load diff
61
apps/app_seeds/lib/db/encrypted_executor.dart
Normal file
61
apps/app_seeds/lib/db/encrypted_executor.dart
Normal 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
27
apps/app_seeds/lib/db/enums.dart
Normal file
27
apps/app_seeds/lib/db/enums.dart
Normal 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 }
|
||||
30
apps/app_seeds/lib/db/sync_columns.dart
Normal file
30
apps/app_seeds/lib/db/sync_columns.dart
Normal 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};
|
||||
}
|
||||
107
apps/app_seeds/lib/db/tables.dart
Normal file
107
apps/app_seeds/lib/db/tables.dart
Normal 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()();
|
||||
}
|
||||
45
apps/app_seeds/lib/di/injector.dart
Normal file
45
apps/app_seeds/lib/di/injector.dart
Normal 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'));
|
||||
}
|
||||
41
apps/app_seeds/lib/i18n/en.i18n.json
Normal file
41
apps/app_seeds/lib/i18n/en.i18n.json
Normal 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"
|
||||
}
|
||||
}
|
||||
41
apps/app_seeds/lib/i18n/es.i18n.json
Normal file
41
apps/app_seeds/lib/i18n/es.i18n.json
Normal 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"
|
||||
}
|
||||
}
|
||||
173
apps/app_seeds/lib/i18n/strings.g.dart
Normal file
173
apps/app_seeds/lib/i18n/strings.g.dart
Normal 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;
|
||||
}
|
||||
220
apps/app_seeds/lib/i18n/strings_en.g.dart
Normal file
220
apps/app_seeds/lib/i18n/strings_en.g.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
157
apps/app_seeds/lib/i18n/strings_es.g.dart
Normal file
157
apps/app_seeds/lib/i18n/strings_es.g.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
15
apps/app_seeds/lib/main.dart
Normal file
15
apps/app_seeds/lib/main.dart
Normal 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>())),
|
||||
);
|
||||
}
|
||||
27
apps/app_seeds/lib/security/secret_store.dart
Normal file
27
apps/app_seeds/lib/security/secret_store.dart
Normal 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);
|
||||
}
|
||||
45
apps/app_seeds/lib/security/secure_key_store.dart
Normal file
45
apps/app_seeds/lib/security/secure_key_store.dart
Normal 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();
|
||||
}
|
||||
62
apps/app_seeds/lib/state/inventory_cubit.dart
Normal file
62
apps/app_seeds/lib/state/inventory_cubit.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
97
apps/app_seeds/lib/state/quick_add_cubit.dart
Normal file
97
apps/app_seeds/lib/state/quick_add_cubit.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
128
apps/app_seeds/lib/ui/inventory_list_screen.dart
Normal file
128
apps/app_seeds/lib/ui/inventory_list_screen.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
47
apps/app_seeds/lib/ui/quantity_kind_l10n.dart
Normal file
47
apps/app_seeds/lib/ui/quantity_kind_l10n.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
184
apps/app_seeds/lib/ui/quick_add_sheet.dart
Normal file
184
apps/app_seeds/lib/ui/quick_add_sheet.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue