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.
This commit is contained in:
vjrj 2026-07-09 14:45:48 +02:00
parent 89b61bc9e4
commit d3711e39b0
9 changed files with 240 additions and 18 deletions

View file

@ -122,6 +122,8 @@
"quantity": "How much?", "quantity": "How much?",
"more": "Add more…", "more": "Add more…",
"save": "Save", "save": "Save",
"saveAndAddAnother": "Save & add another",
"addedCount": "{n} added",
"cancel": "Cancel" "cancel": "Cancel"
}, },
"detail": { "detail": {

View file

@ -122,6 +122,8 @@
"quantity": "¿Cuánta?", "quantity": "¿Cuánta?",
"more": "Añadir más…", "more": "Añadir más…",
"save": "Guardar", "save": "Guardar",
"saveAndAddAnother": "Guardar y añadir otra",
"addedCount": "{n} añadidas",
"cancel": "Cancelar" "cancel": "Cancelar"
}, },
"detail": { "detail": {

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 2 /// 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 // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import

View file

@ -401,6 +401,12 @@ class Translations$quickAdd$en {
/// en: 'Save' /// en: 'Save'
String get save => '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' /// en: 'Cancel'
String get cancel => 'Cancel'; String get cancel => 'Cancel';
} }
@ -1217,6 +1223,8 @@ extension on Translations {
'quickAdd.quantity' => 'How much?', 'quickAdd.quantity' => 'How much?',
'quickAdd.more' => 'Add more…', 'quickAdd.more' => 'Add more…',
'quickAdd.save' => 'Save', 'quickAdd.save' => 'Save',
'quickAdd.saveAndAddAnother' => 'Save & add another',
'quickAdd.addedCount' => ({required Object n}) => '${n} added',
'quickAdd.cancel' => 'Cancel', 'quickAdd.cancel' => 'Cancel',
'detail.notFound' => 'This seed is no longer here.', 'detail.notFound' => 'This seed is no longer here.',
'detail.lots' => 'Lots', 'detail.lots' => 'Lots',

View file

@ -238,6 +238,8 @@ class _Translations$quickAdd$es extends Translations$quickAdd$en {
@override String get quantity => '¿Cuánta?'; @override String get quantity => '¿Cuánta?';
@override String get more => 'Añadir más…'; @override String get more => 'Añadir más…';
@override String get save => 'Guardar'; @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'; @override String get cancel => 'Cancelar';
} }
@ -833,6 +835,8 @@ extension on TranslationsEs {
'quickAdd.quantity' => '¿Cuánta?', 'quickAdd.quantity' => '¿Cuánta?',
'quickAdd.more' => 'Añadir más…', 'quickAdd.more' => 'Añadir más…',
'quickAdd.save' => 'Guardar', 'quickAdd.save' => 'Guardar',
'quickAdd.saveAndAddAnother' => 'Guardar y añadir otra',
'quickAdd.addedCount' => ({required Object n}) => '${n} añadidas',
'quickAdd.cancel' => 'Cancelar', 'quickAdd.cancel' => 'Cancelar',
'detail.notFound' => 'Esta semilla ya no está aquí.', 'detail.notFound' => 'Esta semilla ya no está aquí.',
'detail.lots' => 'Lotes', 'detail.lots' => 'Lotes',

View file

@ -21,6 +21,7 @@ class QuickAddState extends Equatable {
this.submitting = false, this.submitting = false,
this.submitted = false, this.submitted = false,
this.showLabelError = false, this.showLabelError = false,
this.addedCount = 0,
}); });
final String label; final String label;
@ -32,6 +33,10 @@ class QuickAddState extends Equatable {
final bool submitted; final bool submitted;
final bool showLabelError; 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; bool get hasValidLabel => label.trim().isNotEmpty;
QuickAddState copyWith({ QuickAddState copyWith({
@ -43,6 +48,7 @@ class QuickAddState extends Equatable {
bool? submitting, bool? submitting,
bool? submitted, bool? submitted,
bool? showLabelError, bool? showLabelError,
int? addedCount,
}) { }) {
return QuickAddState( return QuickAddState(
label: label ?? this.label, label: label ?? this.label,
@ -55,6 +61,7 @@ class QuickAddState extends Equatable {
submitting: submitting ?? this.submitting, submitting: submitting ?? this.submitting,
submitted: submitted ?? this.submitted, submitted: submitted ?? this.submitted,
showLabelError: showLabelError ?? this.showLabelError, showLabelError: showLabelError ?? this.showLabelError,
addedCount: addedCount ?? this.addedCount,
); );
} }
@ -68,6 +75,7 @@ class QuickAddState extends Equatable {
submitting, submitting,
submitted, submitted,
showLabelError, showLabelError,
addedCount,
]; ];
} }
@ -105,4 +113,23 @@ class QuickAddCubit extends Cubit<QuickAddState> {
); );
emit(state.copyWith(submitting: false, submitted: true)); 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<void> 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));
}
} }

View file

@ -49,22 +49,7 @@ class QuickAddSheet extends StatelessWidget {
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( const _LabelField(),
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 SizedBox(height: 16), const SizedBox(height: 16),
LotTypeSelector( LotTypeSelector(
value: state.lotType, value: state.lotType,
@ -104,7 +89,22 @@ class QuickAddSheet extends StatelessWidget {
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
child: Text(t.quickAdd.cancel), 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(), const Spacer(),
TextButton(
key: const Key('quickAdd.saveAndAddAnother'),
onPressed: state.submitting
? null
: cubit.submitAndAddAnother,
child: Text(t.quickAdd.saveAndAddAnother),
),
const SizedBox(width: 8),
FilledButton( FilledButton(
key: const Key('quickAdd.save'), key: const Key('quickAdd.save'),
onPressed: state.submitting ? null : cubit.submit, 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<QuickAddCubit>();
return BlocListener<QuickAddCubit, QuickAddState>(
listenWhen: (prev, curr) => prev.addedCount != curr.addedCount,
listener: (context, _) {
_controller.clear();
_focusNode.requestFocus();
},
child: BlocBuilder<QuickAddCubit, QuickAddState>(
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 { class _MoreSection extends StatelessWidget {
const _MoreSection(); const _MoreSection();

View file

@ -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);
});
}

View file

@ -19,6 +19,7 @@ void main() {
child: TaneApp( child: TaneApp(
repository: repo, repository: repo,
species: newTestSpeciesRepository(db), species: newTestSpeciesRepository(db),
onboarding: newTestOnboardingStore(),
), ),
), ),
); );
@ -57,4 +58,61 @@ void main() {
await disposeTree(tester); 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);
},
);
} }