Skip to main content

scan_tally 0.1.1 copy "scan_tally: ^0.1.1" to clipboard
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.

example/lib/main.dart

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),
                                  ),
                                ],
                              ),
                            ),
                          ),
                        );
                      },
                    ),
            ),
          ],
        );
      },
    );
  }
}
0
likes
160
points
--
downloads

Documentation

API reference

Publisher

verified publisherabdurrahmankaraoglu.com

Weekly Downloads

Continuous barcode scanning for stock counts: duplicate filtering, tally sessions with undo, keyboard wedge scanners, GS1 parsing, check digits and feedback.

Homepage
Repository (GitHub)
View/report issues

Topics

#barcode #scanner #inventory #gs1 #keyboard

License

MIT (license)

Dependencies

flutter

More

Packages that depend on scan_tally