From d3711e39b0329fc49844fc2bdf8dfa32fdf58a25 Mon Sep 17 00:00:00 2001 From: vjrj Date: Thu, 9 Jul 2026 14:45:48 +0200 Subject: [PATCH] feat(quick-add): save & add another for rapid manual entry The quick-add sheet no longer closes on every save: a new 'Save & add another' action saves the seed, clears the form (keeping the chosen lot type), refocuses the name field and shows an 'N added' running count, so a whole shelf can be entered without reopening the sheet each time. Plain Save still closes it. Adds a dedicated label field with its own controller, submitAndAddAnother() on the cubit, and cubit + widget tests. --- apps/app_seeds/lib/i18n/en.i18n.json | 2 + apps/app_seeds/lib/i18n/es.i18n.json | 2 + apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_en.g.dart | 8 ++ apps/app_seeds/lib/i18n/strings_es.g.dart | 4 + apps/app_seeds/lib/state/quick_add_cubit.dart | 27 ++++++ apps/app_seeds/lib/ui/quick_add_sheet.dart | 86 +++++++++++++++---- .../test/state/quick_add_cubit_test.dart | 67 +++++++++++++++ .../test/ui/quick_add_flow_test.dart | 58 +++++++++++++ 9 files changed, 240 insertions(+), 18 deletions(-) create mode 100644 apps/app_seeds/test/state/quick_add_cubit_test.dart diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 36b7a89..d1369a3 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -122,6 +122,8 @@ "quantity": "How much?", "more": "Add more…", "save": "Save", + "saveAndAddAnother": "Save & add another", + "addedCount": "{n} added", "cancel": "Cancel" }, "detail": { diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 3217e18..4a0570d 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -122,6 +122,8 @@ "quantity": "¿Cuánta?", "more": "Añadir más…", "save": "Guardar", + "saveAndAddAnother": "Guardar y añadir otra", + "addedCount": "{n} añadidas", "cancel": "Cancelar" }, "detail": { diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index c49afe9..32c5872 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 2 -/// Strings: 404 (202 per locale) +/// Strings: 408 (204 per locale) /// -/// Built on 2026-07-09 at 12:34 UTC +/// Built on 2026-07-09 at 12:44 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index cd9cca7..ec54931 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -401,6 +401,12 @@ class Translations$quickAdd$en { /// en: 'Save' String get save => 'Save'; + /// en: 'Save & add another' + String get saveAndAddAnother => 'Save & add another'; + + /// en: '{n} added' + String addedCount({required Object n}) => '${n} added'; + /// en: 'Cancel' String get cancel => 'Cancel'; } @@ -1217,6 +1223,8 @@ extension on Translations { 'quickAdd.quantity' => 'How much?', 'quickAdd.more' => 'Add more…', 'quickAdd.save' => 'Save', + 'quickAdd.saveAndAddAnother' => 'Save & add another', + 'quickAdd.addedCount' => ({required Object n}) => '${n} added', 'quickAdd.cancel' => 'Cancel', 'detail.notFound' => 'This seed is no longer here.', 'detail.lots' => 'Lots', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index adf8235..3c5b2f8 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -238,6 +238,8 @@ class _Translations$quickAdd$es extends Translations$quickAdd$en { @override String get quantity => '¿Cuánta?'; @override String get more => 'Añadir más…'; @override String get save => 'Guardar'; + @override String get saveAndAddAnother => 'Guardar y añadir otra'; + @override String addedCount({required Object n}) => '${n} añadidas'; @override String get cancel => 'Cancelar'; } @@ -833,6 +835,8 @@ extension on TranslationsEs { 'quickAdd.quantity' => '¿Cuánta?', 'quickAdd.more' => 'Añadir más…', 'quickAdd.save' => 'Guardar', + 'quickAdd.saveAndAddAnother' => 'Guardar y añadir otra', + 'quickAdd.addedCount' => ({required Object n}) => '${n} añadidas', 'quickAdd.cancel' => 'Cancelar', 'detail.notFound' => 'Esta semilla ya no está aquí.', 'detail.lots' => 'Lotes', diff --git a/apps/app_seeds/lib/state/quick_add_cubit.dart b/apps/app_seeds/lib/state/quick_add_cubit.dart index 180b404..3a6fb82 100644 --- a/apps/app_seeds/lib/state/quick_add_cubit.dart +++ b/apps/app_seeds/lib/state/quick_add_cubit.dart @@ -21,6 +21,7 @@ class QuickAddState extends Equatable { this.submitting = false, this.submitted = false, this.showLabelError = false, + this.addedCount = 0, }); final String label; @@ -32,6 +33,10 @@ class QuickAddState extends Equatable { final bool submitted; final bool showLabelError; + /// How many seeds this sheet has saved via "save & add another" (it stays + /// open); drives the "N added" feedback and resets of the label field. + final int addedCount; + bool get hasValidLabel => label.trim().isNotEmpty; QuickAddState copyWith({ @@ -43,6 +48,7 @@ class QuickAddState extends Equatable { bool? submitting, bool? submitted, bool? showLabelError, + int? addedCount, }) { return QuickAddState( label: label ?? this.label, @@ -55,6 +61,7 @@ class QuickAddState extends Equatable { submitting: submitting ?? this.submitting, submitted: submitted ?? this.submitted, showLabelError: showLabelError ?? this.showLabelError, + addedCount: addedCount ?? this.addedCount, ); } @@ -68,6 +75,7 @@ class QuickAddState extends Equatable { submitting, submitted, showLabelError, + addedCount, ]; } @@ -105,4 +113,23 @@ class QuickAddCubit extends Cubit { ); emit(state.copyWith(submitting: false, submitted: true)); } + + /// Saves and keeps the sheet open for the next seed: resets the form to a + /// clean state (keeping the chosen [lotType]) and bumps [addedCount]. Lets a + /// user rip through a whole shelf without reopening the sheet each time. + Future submitAndAddAnother() 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(), + lotType: state.lotType, + quantity: state.quantity, + photoBytes: state.photoBytes, + ); + emit(QuickAddState(lotType: state.lotType, addedCount: state.addedCount + 1)); + } } diff --git a/apps/app_seeds/lib/ui/quick_add_sheet.dart b/apps/app_seeds/lib/ui/quick_add_sheet.dart index 7e78182..f4dc1c2 100644 --- a/apps/app_seeds/lib/ui/quick_add_sheet.dart +++ b/apps/app_seeds/lib/ui/quick_add_sheet.dart @@ -49,22 +49,7 @@ class QuickAddSheet extends StatelessWidget { 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( - borderRadius: BorderRadius.all(Radius.circular(8)), - ), - ), - onChanged: cubit.labelChanged, - onSubmitted: (_) => cubit.submit(), - ), + const _LabelField(), const SizedBox(height: 16), LotTypeSelector( value: state.lotType, @@ -104,7 +89,22 @@ class QuickAddSheet extends StatelessWidget { onPressed: () => Navigator.of(context).pop(), child: Text(t.quickAdd.cancel), ), + if (state.addedCount > 0) ...[ + const SizedBox(width: 8), + Text( + t.quickAdd.addedCount(n: state.addedCount), + style: Theme.of(context).textTheme.bodySmall, + ), + ], const Spacer(), + TextButton( + key: const Key('quickAdd.saveAndAddAnother'), + onPressed: state.submitting + ? null + : cubit.submitAndAddAnother, + child: Text(t.quickAdd.saveAndAddAnother), + ), + const SizedBox(width: 8), FilledButton( key: const Key('quickAdd.save'), onPressed: state.submitting ? null : cubit.submit, @@ -121,6 +121,60 @@ class QuickAddSheet extends StatelessWidget { } } +/// The name field, with its own controller so "save & add another" can clear +/// it and refocus without tearing down the sheet. +class _LabelField extends StatefulWidget { + const _LabelField(); + + @override + State<_LabelField> createState() => _LabelFieldState(); +} + +class _LabelFieldState extends State<_LabelField> { + final _controller = TextEditingController(); + final _focusNode = FocusNode(); + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + final cubit = context.read(); + return BlocListener( + listenWhen: (prev, curr) => prev.addedCount != curr.addedCount, + listener: (context, _) { + _controller.clear(); + _focusNode.requestFocus(); + }, + child: BlocBuilder( + buildWhen: (prev, curr) => prev.showLabelError != curr.showLabelError, + builder: (context, state) => TextField( + key: const Key('quickAdd.labelField'), + controller: _controller, + focusNode: _focusNode, + autofocus: true, + textInputAction: TextInputAction.done, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + labelText: t.quickAdd.labelField, + errorText: state.showLabelError ? t.quickAdd.labelRequired : null, + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + ), + onChanged: cubit.labelChanged, + onSubmitted: (_) => cubit.submit(), + ), + ), + ); + } +} + class _MoreSection extends StatelessWidget { const _MoreSection(); diff --git a/apps/app_seeds/test/state/quick_add_cubit_test.dart b/apps/app_seeds/test/state/quick_add_cubit_test.dart new file mode 100644 index 0000000..be2549d --- /dev/null +++ b/apps/app_seeds/test/state/quick_add_cubit_test.dart @@ -0,0 +1,67 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; +import 'package:tane/state/quick_add_cubit.dart'; + +import '../support/test_support.dart'; + +void main() { + late AppDatabase db; + late QuickAddCubit cubit; + + setUp(() { + db = newTestDatabase(); + cubit = QuickAddCubit(newTestRepository(db)); + }); + tearDown(() async { + await cubit.close(); + await db.close(); + }); + + test('submitAndAddAnother with an empty label errors and saves nothing', + () async { + await cubit.submitAndAddAnother(); + expect(cubit.state.showLabelError, isTrue); + expect(cubit.state.addedCount, 0); + expect(await db.select(db.varieties).get(), isEmpty); + }); + + test('submitAndAddAnother saves, resets the form and keeps the lot type', + () async { + cubit + ..setLotType(LotType.plant) + ..labelChanged('Basil') + ..setQuantity(const Quantity(kind: QuantityKind.plant, count: 3)); + + await cubit.submitAndAddAnother(); + + // Saved to the DB. + final varieties = await db.select(db.varieties).get(); + expect(varieties.single.label, 'Basil'); + + // Form reset for the next one, but the chosen lot type is kept and the + // sheet is NOT told to close (submitted stays false). + expect(cubit.state.label, ''); + expect(cubit.state.quantity, isNull); + expect(cubit.state.lotType, LotType.plant); + expect(cubit.state.submitted, isFalse); + expect(cubit.state.addedCount, 1); + }); + + test('addedCount accumulates across several saves', () async { + cubit.labelChanged('One'); + await cubit.submitAndAddAnother(); + cubit.labelChanged('Two'); + await cubit.submitAndAddAnother(); + + expect(cubit.state.addedCount, 2); + expect(await db.select(db.varieties).get(), hasLength(2)); + }); + + test('submit still closes the sheet (submitted = true)', () async { + cubit.labelChanged('Once'); + await cubit.submit(); + expect(cubit.state.submitted, isTrue); + }); +} diff --git a/apps/app_seeds/test/ui/quick_add_flow_test.dart b/apps/app_seeds/test/ui/quick_add_flow_test.dart index d9856fd..8eec0c9 100644 --- a/apps/app_seeds/test/ui/quick_add_flow_test.dart +++ b/apps/app_seeds/test/ui/quick_add_flow_test.dart @@ -19,6 +19,7 @@ void main() { child: TaneApp( repository: repo, species: newTestSpeciesRepository(db), + onboarding: newTestOnboardingStore(), ), ), ); @@ -57,4 +58,61 @@ void main() { await disposeTree(tester); }, ); + + testWidgets( + 'save & add another keeps the sheet open and accumulates a count', + (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final db = newTestDatabase(); + addTearDown(db.close); + final repo = newTestRepository(db); + + await tester.pumpWidget( + TranslationProvider( + child: TaneApp( + repository: repo, + species: newTestSpeciesRepository(db), + onboarding: newTestOnboardingStore(), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('home.inventory'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('inventory.addFab'))); + await tester.pumpAndSettle(); + + // First seed via "save & add another": the sheet stays open. + await tester.enterText( + find.byKey(const Key('quickAdd.labelField')), + 'Alpha', + ); + await tester.tap(find.byKey(const Key('quickAdd.saveAndAddAnother'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('quickAdd.labelField')), findsOneWidget); + expect(find.text('1 added'), findsOneWidget); + + // The field was cleared; add a second one. + await tester.enterText( + find.byKey(const Key('quickAdd.labelField')), + 'Beta', + ); + await tester.tap(find.byKey(const Key('quickAdd.saveAndAddAnother'))); + await tester.pumpAndSettle(); + expect(find.text('2 added'), findsOneWidget); + + // A third one via plain Save closes the sheet. + await tester.enterText( + find.byKey(const Key('quickAdd.labelField')), + 'Gamma', + ); + await tester.tap(find.byKey(const Key('quickAdd.save'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('quickAdd.labelField')), findsNothing); + + // All three landed in the inventory. + expect(await db.select(db.varieties).get(), hasLength(3)); + await disposeTree(tester); + }, + ); }