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
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