scan_tally 0.1.1
scan_tally: ^0.1.1 copied to clipboard
Continuous barcode scanning for stock counts: duplicate filtering, tally sessions with undo, keyboard wedge scanners, GS1 parsing, check digits and feedback.
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:scan_tally/scan_tally_flutter.dart';
import 'package:scan_tally_mobile_scanner/scan_tally_mobile_scanner.dart';
import 'catalog.dart';
import 'l10n/app_localizations.dart';
import 'session_store.dart';
import 'sound_player.dart';
void main() => runApp(const ScanTallyExampleApp());
const Color _brandColor = Color(0xFF0B3D91);
const ScanFlashColors _flashColors = ScanFlashColors();
class ScanTallyExampleApp extends StatelessWidget {
const ScanTallyExampleApp({super.key});
ThemeData _theme(Brightness brightness) {
final scheme = ColorScheme.fromSeed(seedColor: _brandColor, brightness: brightness);
return ThemeData(
colorScheme: scheme,
useMaterial3: true,
scaffoldBackgroundColor: scheme.surfaceContainerLowest,
appBarTheme: AppBarTheme(
backgroundColor: scheme.surfaceContainerLowest,
surfaceTintColor: Colors.transparent,
centerTitle: false,
),
cardTheme: CardThemeData(
elevation: 0,
margin: EdgeInsets.zero,
color: scheme.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: scheme.outlineVariant),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
);
}
@override
Widget build(BuildContext context) {
return MaterialApp(
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
debugShowCheckedModeBanner: false,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
// Device languages other than Turkish fall back to English.
localeResolutionCallback: (locale, supported) =>
supported.firstWhere((l) => l.languageCode == locale?.languageCode, orElse: () => const Locale('en')),
theme: _theme(Brightness.light),
darkTheme: _theme(Brightness.dark),
home: const ScanHomePage(),
);
}
}
class ScanHomePage extends StatefulWidget {
const ScanHomePage({super.key});
@override
State<ScanHomePage> createState() => _ScanHomePageState();
}
class _ScanHomePageState extends State<ScanHomePage> with WidgetsBindingObserver {
static const _cooldown = DedupePolicy.cooldown(Duration(milliseconds: 1500));
static const _leaveAndReturn = DedupePolicy.leaveAndReturn();
static const _wedgeConfig = WedgeConfig();
static const _typingConfig = WedgeConfig(maxKeyInterval: Duration(seconds: 2));
final AudioplayersScanSoundPlayer _sound = AudioplayersScanSoundPlayer();
final SessionStore _store = SessionStore();
final MobileScannerController _camera = MobileScannerSource.createController(autoStart: false);
final KeyboardWedgeSource _wedge = KeyboardWedgeSource();
late final ScanSession _session;
StreamSubscription<ScanEvent>? _eventSub;
StreamSubscription<void>? _changeSub;
int _tab = 0;
bool _checkCatalog = true;
bool _typingMode = false;
ScanEvent? _lastEvent;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_session = ScanSession(
dedupe: _leaveAndReturn,
// Wedge scanners do not report the symbology; check 8/12/13/14-digit codes as GTINs.
checkDigits: CheckDigitPolicy.bySymbologyOrLength,
validate: _validate,
feedbackPlayer: FlutterScanFeedbackPlayer(sound: _sound),
);
_session.attach(MobileScannerSource(_camera));
_session.attach(_wedge);
_eventSub = _session.events.listen((event) => setState(() => _lastEvent = event));
_changeSub = _session.changes.listen((_) => _store.scheduleSave(_session));
_store.load(_session).then((_) {
if (mounted) setState(() {});
});
WidgetsBinding.instance.addPostFrameCallback((_) => _syncCamera());
}
ScanVerdict _validate(ScanCode code) {
final l10n = AppLocalizations.of(context);
return DemoCatalog.validate(
code,
checkCatalog: _checkCatalog,
languageCode: Localizations.localeOf(context).languageCode,
notFound: l10n.rejectNotFound,
);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) _store.save(_session);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_eventSub?.cancel();
_changeSub?.cancel();
_store.save(_session);
_store.dispose();
_session.dispose();
_wedge.dispose();
_camera.dispose();
_sound.dispose();
super.dispose();
}
/// The camera runs only while its tab is visible.
Future<void> _syncCamera() async {
try {
if (_tab == 0) {
await _camera.start();
} else {
await _camera.stop();
}
} catch (_) {
// Shown by the MobileScanner errorBuilder.
}
}
void _selectTab(int index) {
FocusManager.instance.primaryFocus?.unfocus();
setState(() => _tab = index);
_syncCamera();
}
void _undo() {
final l10n = AppLocalizations.of(context);
final op = _session.undoLast();
final messenger = ScaffoldMessenger.of(context)..hideCurrentSnackBar();
messenger.showSnackBar(
SnackBar(
content: Text(op == null ? l10n.nothingToUndo : l10n.undone(op.key ?? '')),
duration: const Duration(seconds: 2),
),
);
}
void _toggleSilent() {
final silent = !_session.feedback.silent;
_session.feedback = _session.feedback.copyWith(silent: silent);
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(silent ? l10n.silentOn : l10n.silentOff), duration: const Duration(seconds: 1)),
);
setState(() {});
}
Future<void> _openSettings() async {
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (context) => StatefulBuilder(
builder: (context, setSheetState) {
final l10n = AppLocalizations.of(context);
final feedback = _session.feedback;
void update(VoidCallback change) {
change();
setSheetState(() {});
setState(() {});
}
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.settings, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
_SectionLabel(l10n.dedupeTitle),
RadioGroup<DedupePolicy>(
groupValue: _session.dedupe,
onChanged: (v) => update(() => _session.dedupe = v!),
child: Column(
children: [
RadioListTile<DedupePolicy>(
value: _leaveAndReturn,
title: Text(l10n.dedupeLeave),
subtitle: Text(l10n.dedupeLeaveHint),
),
RadioListTile<DedupePolicy>(
value: _cooldown,
title: Text(l10n.dedupeCooldown),
subtitle: Text(l10n.dedupeCooldownHint),
),
],
),
),
SwitchListTile(
value: _session.confirmation.frames > 1,
title: Text(l10n.confirmFrames),
subtitle: Text(l10n.confirmFramesHint),
onChanged: (v) => update(
() => _session.confirmation = v ? const FrameConfirmation(frames: 2) : FrameConfirmation.none,
),
),
SwitchListTile(
value: _checkCatalog,
title: Text(l10n.validateCatalog),
subtitle: Text(l10n.validateCatalogHint),
onChanged: (v) => update(() => _checkCatalog = v),
),
const Divider(),
_SectionLabel(l10n.feedbackTitle),
SwitchListTile(
value: feedback.sound,
title: Text(l10n.sound),
onChanged: (v) => update(() => _session.feedback = feedback.copyWith(sound: v)),
),
SwitchListTile(
value: feedback.vibrate,
title: Text(l10n.vibration),
onChanged: (v) => update(() => _session.feedback = feedback.copyWith(vibrate: v)),
),
SwitchListTile(
value: feedback.flash,
title: Text(l10n.flash),
onChanged: (v) => update(() => _session.feedback = feedback.copyWith(flash: v)),
),
],
),
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final silent = _session.feedback.silent;
final pages = [
_CameraTab(controller: _camera, lastEvent: _lastEvent),
_TerminalTab(
session: _session,
wedge: _wedge,
active: _tab == 1,
typingMode: _typingMode,
onTypingModeChanged: (v) => setState(() {
_typingMode = v;
_wedge.config = v ? _typingConfig : _wedgeConfig;
}),
lastEvent: _lastEvent,
),
_CountTab(session: _session),
];
return Scaffold(
appBar: AppBar(
title: Text(l10n.appTitle),
actions: [
IconButton(
tooltip: silent ? l10n.silentOn : l10n.silentOff,
onPressed: _toggleSilent,
icon: Icon(silent ? Icons.volume_off_outlined : Icons.volume_up_outlined),
),
ScanSessionBuilder(
session: _session,
builder: (context, session) =>
IconButton(tooltip: l10n.undo, onPressed: session.canUndo ? _undo : null, icon: const Icon(Icons.undo)),
),
IconButton(tooltip: l10n.settings, onPressed: _openSettings, icon: const Icon(Icons.tune)),
],
),
body: ScanFlash(
session: _session,
colors: _flashColors,
child: IndexedStack(index: _tab, children: pages),
),
bottomNavigationBar: ScanSessionBuilder(
session: _session,
builder: (context, session) => NavigationBar(
selectedIndex: _tab,
onDestinationSelected: _selectTab,
destinations: [
NavigationDestination(
icon: const Icon(Icons.qr_code_scanner_outlined),
selectedIcon: const Icon(Icons.qr_code_scanner),
label: l10n.tabCamera,
),
NavigationDestination(icon: const Icon(Icons.barcode_reader), label: l10n.tabTerminal),
NavigationDestination(
icon: Badge(
isLabelVisible: session.entries.isNotEmpty,
label: Text('${session.totalQuantity}'),
child: const Icon(Icons.inventory_2_outlined),
),
selectedIcon: const Icon(Icons.inventory_2),
label: l10n.tabCount,
),
],
),
),
);
}
}
class _SectionLabel extends StatelessWidget {
const _SectionLabel(this.text);
final String text;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Text(
text,
style: Theme.of(context).textTheme.labelLarge?.copyWith(color: Theme.of(context).colorScheme.primary),
),
);
}
class _CameraTab extends StatelessWidget {
const _CameraTab({required this.controller, required this.lastEvent});
final MobileScannerController controller;
final ScanEvent? lastEvent;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Column(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Stack(
fit: StackFit.expand,
children: [
MobileScanner(
controller: controller,
errorBuilder: (context, error) => ColoredBox(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(l10n.cameraError(error.errorCode.name), textAlign: TextAlign.center),
),
),
),
),
Positioned(
right: 8,
top: 8,
child: Column(
children: [
IconButton.filledTonal(
tooltip: l10n.torch,
onPressed: controller.toggleTorch,
icon: const Icon(Icons.flashlight_on_outlined),
),
const SizedBox(height: 8),
IconButton.filledTonal(
tooltip: l10n.switchCamera,
onPressed: controller.switchCamera,
icon: const Icon(Icons.cameraswitch_outlined),
),
],
),
),
],
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(l10n.cameraHint, style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center),
),
Padding(
padding: const EdgeInsets.all(16),
child: _LastScanCard(event: lastEvent),
),
],
);
}
}
class _TerminalTab extends StatefulWidget {
const _TerminalTab({
required this.session,
required this.wedge,
required this.active,
required this.typingMode,
required this.onTypingModeChanged,
required this.lastEvent,
});
final ScanSession session;
final KeyboardWedgeSource wedge;
final bool active;
final bool typingMode;
final ValueChanged<bool> onTypingModeChanged;
final ScanEvent? lastEvent;
@override
State<_TerminalTab> createState() => _TerminalTabState();
}
class _TerminalTabState extends State<_TerminalTab> {
final TextEditingController _manual = TextEditingController();
final FocusNode _manualFocus = FocusNode();
@override
void initState() {
super.initState();
_manualFocus.addListener(() => setState(() {}));
}
@override
void dispose() {
_manual.dispose();
_manualFocus.dispose();
super.dispose();
}
/// Sends the key events a scanner would send, through the real wedge pipeline.
void _simulate(String code) {
FocusManager.instance.primaryFocus?.unfocus();
for (final event in wedgeKeyEventsFor(code, start: Duration(microseconds: DateTime.now().microsecondsSinceEpoch))) {
widget.wedge.handleKeyEvent(event);
}
}
Future<void> _addManual() async {
final value = _manual.text.trim();
if (value.isEmpty) return;
_manual.clear();
await widget.session.addValue(value);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
final listening = widget.active && !_manualFocus.hasFocus;
final samples = <(String, String)>[
(l10n.sampleValid, DemoCatalog.sampleValid),
(l10n.sampleEan8, DemoCatalog.sampleEan8),
(l10n.sampleBadCheck, DemoCatalog.sampleBadCheck),
(l10n.sampleUnknown, DemoCatalog.sampleUnknown),
(l10n.sampleGs1, DemoCatalog.sampleGs1),
];
return KeyboardWedgeListener(
source: widget.wedge,
enabled: widget.active,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.barcode_reader, color: scheme.primary),
const SizedBox(width: 12),
Expanded(child: Text(l10n.terminalTitle, style: Theme.of(context).textTheme.titleMedium)),
],
),
const SizedBox(height: 8),
Text(l10n.terminalHint, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 12),
Row(
children: [
Icon(
listening ? Icons.sensors : Icons.pause_circle_outline,
size: 18,
color: listening ? _flashColors.accepted : scheme.outline,
),
const SizedBox(width: 8),
Text(listening ? l10n.terminalListening : l10n.terminalPaused),
],
),
],
),
),
),
const SizedBox(height: 12),
Card(
child: SwitchListTile(
value: widget.typingMode,
onChanged: widget.onTypingModeChanged,
title: Text(l10n.typingMode),
subtitle: Text(l10n.typingModeHint),
),
),
const SizedBox(height: 16),
_SectionLabel(l10n.simulateTitle),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final (label, code) in samples)
ActionChip(
avatar: const Icon(Icons.qr_code_2, size: 18),
label: Text(label),
onPressed: () => _simulate(code),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextField(
controller: _manual,
focusNode: _manualFocus,
decoration: InputDecoration(labelText: l10n.manualLabel, border: const OutlineInputBorder()),
textInputAction: TextInputAction.done,
onSubmitted: (_) => _addManual(),
),
),
const SizedBox(width: 8),
FilledButton(onPressed: _addManual, child: Text(l10n.manualAdd)),
],
),
const SizedBox(height: 16),
_LastScanCard(event: widget.lastEvent),
],
),
);
}
}
class _LastScanCard extends StatelessWidget {
const _LastScanCard({required this.event});
final ScanEvent? event;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final event = this.event;
if (event == null) {
return Card(
child: ListTile(
leading: Icon(Icons.qr_code_scanner, color: theme.colorScheme.outline),
title: Text(l10n.lastScan),
subtitle: Text(l10n.noScanYet),
),
);
}
final color = _flashColors.of(event.kind);
final (IconData icon, String title, String? detail) = switch (event) {
ScanAccepted(:final entry) => (
Icons.check_circle,
l10n.accepted,
[entry.label, l10n.quantityNow(entry.quantity)].nonNulls.join(' · '),
),
ScanDuplicate(:final entry) => (
Icons.repeat,
l10n.duplicate,
entry == null ? null : l10n.quantityNow(entry.quantity),
),
ScanRejected(:final reason, :final message) => (
Icons.error,
l10n.rejected,
switch (reason) {
ScanRejectReason.invalidCheckDigit => l10n.rejectCheckDigit,
ScanRejectReason.validation => message ?? l10n.rejectNotFound,
ScanRejectReason.validatorError => l10n.rejectError,
},
),
};
final gs1 = event.code.gs1;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: color),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium?.copyWith(color: color)),
if (detail != null && detail.isNotEmpty) Text(detail, style: theme.textTheme.bodyMedium),
],
),
),
],
),
const SizedBox(height: 8),
SelectableText(
gs1?.toHumanReadable() ?? event.code.value,
style: theme.textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
),
Text(
'${event.code.symbology.name} · ${event.code.origin.name}',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.outline),
),
if (gs1 != null) ...[const Divider(height: 24), _Gs1Table(data: gs1)],
],
),
),
);
}
}
class _Gs1Table extends StatelessWidget {
const _Gs1Table({required this.data});
final Gs1Data data;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final dateFormat = DateFormat.yMMMd(Localizations.localeOf(context).toLanguageTag());
String date(Gs1Date d) =>
'${dateFormat.format(d.toDateTime())}${d.dayUnspecified ? ' (${l10n.gs1EndOfMonth})' : ''}';
final rows = <(String, String)>[
if (data.gtin != null) (l10n.gs1Gtin, data.gtin!),
if (data.batch != null) (l10n.gs1Batch, data.batch!),
if (data.expiry != null) (l10n.gs1Expiry, date(data.expiry!)),
if (data.bestBefore != null) (l10n.gs1BestBefore, date(data.bestBefore!)),
if (data.serial != null) (l10n.gs1Serial, data.serial!),
if (data.count != null) (l10n.gs1Count, '${data.count}'),
];
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.gs1Title, style: theme.textTheme.labelLarge),
const SizedBox(height: 4),
for (final (label, value) in rows)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 150,
child: Text(label, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.outline)),
),
Expanded(child: Text(value, style: theme.textTheme.bodyMedium)),
],
),
),
],
);
}
}
class _CountTab extends StatelessWidget {
const _CountTab({required this.session});
final ScanSession session;
Future<void> _editQuantity(BuildContext context, ScanEntry entry) async {
final l10n = AppLocalizations.of(context);
final controller = TextEditingController(text: '${entry.quantity}');
final result = await showDialog<int>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.setQuantity),
content: TextField(
controller: controller,
autofocus: true,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(helperText: entry.label ?? entry.key),
onSubmitted: (v) => Navigator.pop(context, int.tryParse(v)),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text(l10n.cancel)),
FilledButton(onPressed: () => Navigator.pop(context, int.tryParse(controller.text)), child: Text(l10n.save)),
],
),
);
controller.dispose();
if (result != null) session.setQuantity(entry.key, result);
}
Future<void> _clear(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.clearAll),
content: Text(l10n.clearConfirm),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: Text(l10n.cancel)),
FilledButton(onPressed: () => Navigator.pop(context, true), child: Text(l10n.clear)),
],
),
);
if (ok ?? false) session.clear();
}
Future<void> _export(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final json = const JsonEncoder.withIndent(' ').convert(session.toJson());
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.exportJson),
content: SingleChildScrollView(
child: SelectableText(json, style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
),
actions: [
TextButton(
onPressed: () {
Clipboard.setData(ClipboardData(text: json));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(l10n.copied)));
},
child: Text(l10n.copy),
),
FilledButton(onPressed: () => Navigator.pop(context), child: Text(l10n.close)),
],
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return ScanSessionBuilder(
session: session,
builder: (context, session) {
final entries = session.entries.reversed.toList();
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
child: Row(
children: [
Expanded(
child: Text(
l10n.countTitle(entries.length, session.totalQuantity),
style: theme.textTheme.titleMedium,
),
),
IconButton(
tooltip: l10n.exportJson,
onPressed: entries.isEmpty ? null : () => _export(context),
icon: const Icon(Icons.data_object),
),
IconButton(
tooltip: l10n.clearAll,
onPressed: entries.isEmpty ? null : () => _clear(context),
icon: const Icon(Icons.delete_sweep_outlined),
),
],
),
),
Expanded(
child: entries.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.inventory_2_outlined, size: 48, color: theme.colorScheme.outline),
const SizedBox(height: 12),
Text(l10n.countEmpty, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(l10n.countEmptyHint, textAlign: TextAlign.center, style: theme.textTheme.bodyMedium),
],
),
),
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
itemCount: entries.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, i) {
final entry = entries[i];
return Dismissible(
key: ValueKey(entry.key),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.delete_outline, color: theme.colorScheme.onErrorContainer),
),
onDismissed: (_) {
session.remove(entry.key);
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.removed(entry.label ?? entry.key))));
},
child: Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 4, 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.label ?? l10n.unknownProduct,
style: theme.textTheme.titleSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
entry.key,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: theme.colorScheme.outline,
),
),
],
),
),
IconButton(
onPressed: entry.quantity == 0 ? null : () => session.decrement(entry.key),
icon: const Icon(Icons.remove_circle_outline),
),
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => _editQuantity(context, entry),
child: Container(
constraints: const BoxConstraints(minWidth: 48, minHeight: 40),
alignment: Alignment.center,
child: Text('${entry.quantity}', style: theme.textTheme.titleLarge),
),
),
IconButton(
onPressed: () => session.increment(entry.key),
icon: const Icon(Icons.add_circle_outline),
),
],
),
),
),
);
},
),
),
],
);
},
);
}
}