Merge branch 'worktree-print-labels': print seed labels with QR
# Conflicts: # apps/app_seeds/lib/i18n/strings.g.dart # apps/app_seeds/lib/ui/inventory_list_screen.dart
This commit is contained in:
commit
63f48db5c2
21 changed files with 1522 additions and 115 deletions
|
|
@ -10,6 +10,7 @@ import '../i18n/strings.g.dart';
|
|||
import '../services/share_catalog_service.dart';
|
||||
import '../state/inventory_cubit.dart';
|
||||
import 'draft_triage.dart';
|
||||
import 'label_print_sheet.dart';
|
||||
import 'quantity_kind_l10n.dart';
|
||||
import 'quantity_picker.dart';
|
||||
import 'quick_add_sheet.dart';
|
||||
|
|
@ -25,88 +26,162 @@ class InventoryListScreen extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(t.inventory.title),
|
||||
actions: [
|
||||
// The paper bridge: only offered once something is marked to share.
|
||||
BlocSelector<InventoryCubit, InventoryState, bool>(
|
||||
selector: (state) => state.hasShared,
|
||||
builder: (context, hasShared) => hasShared
|
||||
? IconButton(
|
||||
key: const Key('inventory.printCatalog'),
|
||||
icon: const Icon(Icons.print_outlined),
|
||||
tooltip: t.share.printCatalog,
|
||||
onPressed: () => _printCatalog(context),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('inventory.captureBurst'),
|
||||
icon: const Icon(Icons.add_a_photo_outlined),
|
||||
tooltip: t.draft.capture,
|
||||
onPressed: () => _captureBurst(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
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: [
|
||||
if (state.drafts.isNotEmpty)
|
||||
_TriageBanner(count: state.drafts.length),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||
child: TextField(
|
||||
key: const Key('inventory.search'),
|
||||
decoration: InputDecoration(
|
||||
hintText: t.inventory.searchHint,
|
||||
prefixIcon: const Icon(Icons.search, color: seedMuted),
|
||||
hintStyle: const TextStyle(color: seedMuted),
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
return BlocBuilder<InventoryCubit, InventoryState>(
|
||||
builder: (context, state) {
|
||||
return Scaffold(
|
||||
appBar: state.selectionMode
|
||||
? _selectionAppBar(context, state)
|
||||
: _defaultAppBar(context, state),
|
||||
// Inventory is a spoke off the home hub (no drawer — the app bar's
|
||||
// back arrow returns there). In selection mode the bars are
|
||||
// contextual and the add FAB steps aside.
|
||||
floatingActionButton: state.selectionMode
|
||||
? null
|
||||
: FloatingActionButton(
|
||||
key: const Key('inventory.addFab'),
|
||||
tooltip: t.quickAdd.title,
|
||||
onPressed: () => showQuickAddSheet(
|
||||
context,
|
||||
repository: context.read<VarietyRepository>(),
|
||||
),
|
||||
onChanged: context.read<InventoryCubit>().search,
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
),
|
||||
_FilterBar(state: state),
|
||||
Expanded(
|
||||
child: _InventoryBody(
|
||||
items: state.visibleItems,
|
||||
// Distinguish "no seeds at all" from "filters hid them all".
|
||||
filtered:
|
||||
state.categoryFilter.isNotEmpty ||
|
||||
state.typeFilter.isNotEmpty ||
|
||||
state.organicOnly ||
|
||||
state.needsReproductionOnly ||
|
||||
state.sharingOnly,
|
||||
body: state.loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Column(
|
||||
children: [
|
||||
if (state.drafts.isNotEmpty && !state.selectionMode)
|
||||
_TriageBanner(count: state.drafts.length),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||
child: TextField(
|
||||
key: const Key('inventory.search'),
|
||||
decoration: InputDecoration(
|
||||
hintText: t.inventory.searchHint,
|
||||
prefixIcon: const Icon(Icons.search, color: seedMuted),
|
||||
hintStyle: const TextStyle(color: seedMuted),
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 14,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
onChanged: context.read<InventoryCubit>().search,
|
||||
),
|
||||
),
|
||||
_FilterBar(state: state),
|
||||
Expanded(
|
||||
child: _InventoryBody(
|
||||
items: state.visibleItems,
|
||||
selectionMode: state.selectionMode,
|
||||
selectedIds: state.selectedIds,
|
||||
// Distinguish "no seeds at all" from "filters hid them".
|
||||
filtered:
|
||||
state.categoryFilter.isNotEmpty ||
|
||||
state.typeFilter.isNotEmpty ||
|
||||
state.organicOnly ||
|
||||
state.needsReproductionOnly ||
|
||||
state.sharingOnly,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The normal top bar: print-what-I-share, enter label selection, capture.
|
||||
PreferredSizeWidget _defaultAppBar(
|
||||
BuildContext context,
|
||||
InventoryState state,
|
||||
) {
|
||||
final t = context.t;
|
||||
return AppBar(
|
||||
title: Text(t.inventory.title),
|
||||
actions: [
|
||||
// The paper bridge: only offered once something is marked to share.
|
||||
if (state.hasShared)
|
||||
IconButton(
|
||||
key: const Key('inventory.printCatalog'),
|
||||
icon: const Icon(Icons.print_outlined),
|
||||
tooltip: t.share.printCatalog,
|
||||
onPressed: () => _printCatalog(context),
|
||||
),
|
||||
// Enter multi-select to print labels — only once there's something to
|
||||
// label.
|
||||
if (state.items.isNotEmpty)
|
||||
IconButton(
|
||||
key: const Key('inventory.selectLabels'),
|
||||
icon: const Icon(Icons.label_outline),
|
||||
tooltip: t.printLabels.action,
|
||||
onPressed: context.read<InventoryCubit>().startSelection,
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('inventory.captureBurst'),
|
||||
icon: const Icon(Icons.add_a_photo_outlined),
|
||||
tooltip: t.draft.capture,
|
||||
onPressed: () => _captureBurst(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The contextual bar shown while picking seeds to print labels for.
|
||||
PreferredSizeWidget _selectionAppBar(
|
||||
BuildContext context,
|
||||
InventoryState state,
|
||||
) {
|
||||
final t = context.t;
|
||||
final cubit = context.read<InventoryCubit>();
|
||||
return AppBar(
|
||||
leading: IconButton(
|
||||
key: const Key('inventory.selection.close'),
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: t.common.cancel,
|
||||
onPressed: cubit.exitSelection,
|
||||
),
|
||||
title: Text(t.printLabels.selected(n: state.selectedIds.length)),
|
||||
actions: [
|
||||
TextButton(
|
||||
key: const Key('inventory.selection.selectAll'),
|
||||
onPressed: cubit.selectAllVisible,
|
||||
child: Text(t.printLabels.selectAll),
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('inventory.selection.print'),
|
||||
icon: const Icon(Icons.label_outline),
|
||||
tooltip: t.printLabels.action,
|
||||
onPressed: state.selectedIds.isEmpty
|
||||
? null
|
||||
: () => _printLabels(context, state.selectedIds),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Gathers the label rows for the selection and opens the print sheet.
|
||||
Future<void> _printLabels(BuildContext context, Set<String> ids) async {
|
||||
final t = context.t;
|
||||
final repository = context.read<VarietyRepository>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final entries = await repository.labelRows(
|
||||
ids,
|
||||
languageCode: LocaleSettings.currentLocale.languageCode,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
if (entries.isEmpty) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(t.printLabels.none)));
|
||||
return;
|
||||
}
|
||||
await showLabelPrintSheet(context, entries);
|
||||
}
|
||||
|
||||
/// Assembles the localized rows of the "what I share" catalog and saves it
|
||||
/// as a PDF wherever the user chooses.
|
||||
Future<void> _printCatalog(BuildContext context) async {
|
||||
|
|
@ -301,7 +376,12 @@ class _FilterBar extends StatelessWidget {
|
|||
}
|
||||
|
||||
class _InventoryBody extends StatelessWidget {
|
||||
const _InventoryBody({required this.items, this.filtered = false});
|
||||
const _InventoryBody({
|
||||
required this.items,
|
||||
this.filtered = false,
|
||||
this.selectionMode = false,
|
||||
this.selectedIds = const {},
|
||||
});
|
||||
|
||||
final List<VarietyListItem> items;
|
||||
|
||||
|
|
@ -309,6 +389,12 @@ class _InventoryBody extends StatelessWidget {
|
|||
/// than "no seeds yet".
|
||||
final bool filtered;
|
||||
|
||||
/// Whether the list is in multi-select mode (tiles show a checkbox).
|
||||
final bool selectionMode;
|
||||
|
||||
/// Ids currently selected, so each tile can reflect its checkbox state.
|
||||
final Set<String> selectedIds;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
|
|
@ -346,7 +432,13 @@ class _InventoryBody extends StatelessWidget {
|
|||
currentCategory = category;
|
||||
rows.add(_CategoryHeader(title: category));
|
||||
}
|
||||
rows.add(_VarietyTile(item: item));
|
||||
rows.add(
|
||||
_VarietyTile(
|
||||
item: item,
|
||||
selectionMode: selectionMode,
|
||||
selected: selectedIds.contains(item.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView(children: rows);
|
||||
}
|
||||
|
|
@ -374,15 +466,35 @@ class _CategoryHeader extends StatelessWidget {
|
|||
}
|
||||
|
||||
class _VarietyTile extends StatelessWidget {
|
||||
const _VarietyTile({required this.item});
|
||||
const _VarietyTile({
|
||||
required this.item,
|
||||
this.selectionMode = false,
|
||||
this.selected = false,
|
||||
});
|
||||
|
||||
final VarietyListItem item;
|
||||
|
||||
/// Whether the list is picking seeds to print labels for: tap toggles the
|
||||
/// selection instead of opening the detail, and the tile shows a checkbox.
|
||||
final bool selectionMode;
|
||||
final bool selected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cubit = context.read<InventoryCubit>();
|
||||
void open() => context.push('/variety/${item.id}');
|
||||
void toggle() => cubit.toggleSelection(item.id);
|
||||
|
||||
return ListTile(
|
||||
leading: _Avatar(item: item),
|
||||
key: Key('inventory.tile.${item.id}'),
|
||||
selected: selectionMode && selected,
|
||||
leading: selectionMode
|
||||
? Checkbox(
|
||||
key: Key('inventory.select.${item.id}'),
|
||||
value: selected,
|
||||
onChanged: (_) => toggle(),
|
||||
)
|
||||
: _Avatar(item: item),
|
||||
title: Text(
|
||||
item.label,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
|
|
@ -393,42 +505,47 @@ class _VarietyTile extends StatelessWidget {
|
|||
item.scientificName!,
|
||||
style: const TextStyle(fontStyle: FontStyle.italic),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (item.isShared)
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 4),
|
||||
child: Tooltip(
|
||||
key: Key('inventory.shared.${item.id}'),
|
||||
message: context.t.share.filterChip,
|
||||
child: const Icon(
|
||||
Icons.volunteer_activism_outlined,
|
||||
size: 20,
|
||||
// In selection mode the trailing actions (which navigate away) give way to
|
||||
// the checkbox flow.
|
||||
trailing: selectionMode
|
||||
? null
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (item.isShared)
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 4),
|
||||
child: Tooltip(
|
||||
key: Key('inventory.shared.${item.id}'),
|
||||
message: context.t.share.filterChip,
|
||||
child: const Icon(
|
||||
Icons.volunteer_activism_outlined,
|
||||
size: 20,
|
||||
color: seedGreen,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (item.isOrganic)
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 4),
|
||||
child: Tooltip(
|
||||
key: Key('inventory.organic.${item.id}'),
|
||||
message: context.t.editVariety.organic,
|
||||
child: const Icon(Icons.eco, size: 20, color: seedGreen),
|
||||
),
|
||||
),
|
||||
_ViabilityDot(item.viability),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
// Action colour: this is a tap target, not secondary text.
|
||||
color: seedGreen,
|
||||
tooltip: context.t.common.edit,
|
||||
onPressed: open,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (item.isOrganic)
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 4),
|
||||
child: Tooltip(
|
||||
key: Key('inventory.organic.${item.id}'),
|
||||
message: context.t.editVariety.organic,
|
||||
child: const Icon(Icons.eco, size: 20, color: seedGreen),
|
||||
),
|
||||
),
|
||||
_ViabilityDot(item.viability),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
// Action colour: this is a tap target, not secondary text.
|
||||
color: seedGreen,
|
||||
tooltip: context.t.common.edit,
|
||||
onPressed: open,
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: open,
|
||||
onTap: selectionMode ? toggle : open,
|
||||
onLongPress: selectionMode ? null : () => cubit.enterSelection(item.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
160
apps/app_seeds/lib/ui/label_print_sheet.dart
Normal file
160
apps/app_seeds/lib/ui/label_print_sheet.dart
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/export_import/seed_label_codec.dart';
|
||||
import '../data/variety_repository.dart';
|
||||
import '../di/injector.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/label_sheet_service.dart';
|
||||
import 'quantity_kind_l10n.dart';
|
||||
|
||||
/// Opens the print-labels sheet for [entries] (already gathered from the
|
||||
/// current selection): pick a label size, then save a PDF sheet of labels —
|
||||
/// each with a QR that carries the seed's data.
|
||||
Future<void> showLabelPrintSheet(
|
||||
BuildContext context,
|
||||
List<SeedLabelEntry> entries,
|
||||
) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => _LabelPrintSheet(entries: entries),
|
||||
);
|
||||
}
|
||||
|
||||
class _LabelPrintSheet extends StatefulWidget {
|
||||
const _LabelPrintSheet({required this.entries});
|
||||
|
||||
final List<SeedLabelEntry> entries;
|
||||
|
||||
@override
|
||||
State<_LabelPrintSheet> createState() => _LabelPrintSheetState();
|
||||
}
|
||||
|
||||
class _LabelPrintSheetState extends State<_LabelPrintSheet> {
|
||||
LabelSheetFormat _format = LabelSheetFormat.stickers;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
t.printLabels.title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
t.printLabels.count(n: widget.entries.length),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
RadioGroup<LabelSheetFormat>(
|
||||
groupValue: _format,
|
||||
onChanged: _pick,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RadioListTile<LabelSheetFormat>(
|
||||
key: const Key('printLabels.format.stickers'),
|
||||
value: LabelSheetFormat.stickers,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(t.printLabels.formatStickers),
|
||||
subtitle: Text(t.printLabels.formatStickersHint),
|
||||
),
|
||||
RadioListTile<LabelSheetFormat>(
|
||||
key: const Key('printLabels.format.cards'),
|
||||
value: LabelSheetFormat.cards,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(t.printLabels.formatCards),
|
||||
subtitle: Text(t.printLabels.formatCardsHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: FilledButton.icon(
|
||||
key: const Key('printLabels.save'),
|
||||
onPressed: _saving ? null : _save,
|
||||
icon: const Icon(Icons.save_alt_outlined),
|
||||
label: Text(t.printLabels.save),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _pick(LabelSheetFormat? value) {
|
||||
if (value != null) setState(() => _format = value);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final t = context.t;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
final rtl = Directionality.of(context) == TextDirection.rtl;
|
||||
setState(() => _saving = true);
|
||||
|
||||
final labels = [for (final e in widget.entries) _toLabel(t, e)];
|
||||
final saved = await getIt<LabelSheetService>().saveLabels(
|
||||
labels: labels,
|
||||
format: _format,
|
||||
suggestedName: 'tanemaki-labels-${_today()}.pdf',
|
||||
rtl: rtl,
|
||||
);
|
||||
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(saved ? t.printLabels.saved : t.printLabels.cancelled),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds one printable label from a [SeedLabelEntry]: the visible fields plus
|
||||
/// the QR payload (structured, so a future scan can read it).
|
||||
LabelSheetLabel _toLabel(Translations t, SeedLabelEntry e) {
|
||||
final origin = _origin(e);
|
||||
final details = <String>[
|
||||
if (e.harvestYear != null) '${e.harvestYear}',
|
||||
?origin,
|
||||
if (e.quantity != null) quantityDisplay(t, e.quantity!),
|
||||
];
|
||||
return LabelSheetLabel(
|
||||
varietyLabel: e.varietyLabel,
|
||||
commonName: e.commonName,
|
||||
scientificName: e.scientificName,
|
||||
details: details.isEmpty ? null : details.join(' · '),
|
||||
qrData: SeedLabelCodec.encode(
|
||||
SeedLabelData(
|
||||
varietyLabel: e.varietyLabel,
|
||||
scientificName: e.scientificName,
|
||||
commonName: e.commonName,
|
||||
year: e.harvestYear,
|
||||
origin: origin,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _origin(SeedLabelEntry e) {
|
||||
final parts = <String>[
|
||||
if (e.originName != null && e.originName!.trim().isNotEmpty)
|
||||
e.originName!.trim(),
|
||||
if (e.originPlace != null && e.originPlace!.trim().isNotEmpty)
|
||||
e.originPlace!.trim(),
|
||||
];
|
||||
return parts.isEmpty ? null : parts.join(' · ');
|
||||
}
|
||||
|
||||
String _today() => DateTime.now().toIso8601String().substring(0, 10);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue