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:
parent
89b61bc9e4
commit
d3711e39b0
9 changed files with 240 additions and 18 deletions
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
67
apps/app_seeds/test/state/quick_add_cubit_test.dart
Normal file
67
apps/app_seeds/test/state/quick_add_cubit_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue