Bills were persisted correctly but read back wrong. The read path rebuilt a cart from its lines alone, dropping bill-level discounts and loyalty, so every figure derived from a stored bill was overstated: the upload payload, the day archive and the shift report. A discounted 529 bill read back as 620. Money and data integrity - order_dao: restore bill_discount and points_redeemed when rebuilding a cart; keep the reconstruction tier-less so the membership discount is not applied twice. Trust the recorded total and points via SaleTransaction.storedTotal. - checkout_sale + order_dao.commitSale: write the bill, its stock movement and the loyalty update in one transaction. Previously a failure part-way through left a persisted bill the cashier believed had failed, inviting a duplicate. - checkout_sale: re-check every line against live stock. A parked bill resumed after its stock was sold passed validation and oversold. - catalogue_dao: allocate the invoice sequence in one transaction; the previous read-modify-write could hand two sales the same number and fail UNIQUE. - local_store: replay unsynced sales after a catalogue import, so a mid-shift re-import cannot restore stock that has already been sold. - payment_controller: stamp the signed-in operator on the bill instead of the hardcoded seed session, and pass the terminal id through. - cart: reconcile per-slab GST against the bill total so the parts sum to the whole on a tax invoice. Sync and reporting - sync_repository: drain unsynced bills in a loop rather than silently capping at one page; stop on rejection so rejected rows cannot loop forever. - sync_log_dao (new): persist the sync history to the sync_log table, which the schema already defined but nothing used. It was in memory, so the only record that bills had been uploaded died at restart. - Scope shift reports by cashier. day_archive is re-keyed to (business_date, cashier_name) so a till stays settleable after its bills are uploaded and deleted. Schema v4 with a migration that carries v3 rows across. Input and UI - barcode_service: consume machine-paced keystrokes so a scan cannot also land in the focused field, and raise the bar to 60ms/char while a text field has focus so typing a mobile number is not read as a scan. Clock and focus check injected so the behaviour is testable. - primary_button: make the label flexible; label plus trailing total overflowed the Charge button by up to 131px. - app_router: redirect instead of null-casting when the receipt route is entered without its transaction. - customer_repository: reduce the search query to digits so a punctuated mobile number matches. Cleanup - Remove TransactionRepository.save, CustomerRepository.recordSale and OrderDao.insertOrder, all superseded by commitSale. - dart fix across the tree; 251 analyzer issues down to 3 info-level. Tests: 23 passing / 15 failing -> 90 passing. Fixed the two defects that broke the existing suite (containsAll type argument, reset() needing a catalogue) and deleted the leftover template test. Added coverage for the order round trip, the day archive after a real sync, stock safety, checkout atomicity, the v3->v4 migration, scanner-versus-human input, and an app-level smoke test that renders every module. Note: bills already uploaded with a discount went up overstated. This stops it happening again but does not correct historical server data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
568 lines
18 KiB
Dart
568 lines
18 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import '../../../app/providers.dart';
|
||
import '../../../core/constants/app_constants.dart';
|
||
import '../../../core/theme/app_colors.dart';
|
||
import '../../../core/theme/app_dimens.dart';
|
||
import '../../../core/utils/formatters.dart';
|
||
import '../../../core/widgets/numeric_keypad.dart';
|
||
import '../../../core/widgets/primary_button.dart';
|
||
import '../../../core/widgets/status_pill.dart';
|
||
import '../../../domain/entities/customer.dart';
|
||
import '../../pos/providers/cart_controller.dart';
|
||
import '../providers/customer_providers.dart';
|
||
|
||
/// Attaches a customer to the current bill using nothing but a mobile number.
|
||
///
|
||
/// Registration is deliberately minimal: an unknown number can be saved with
|
||
/// just a name, or the whole step skipped. Nothing here blocks the sale.
|
||
Future<void> showCustomerCaptureSheet(BuildContext context) {
|
||
return showModalBottomSheet<void>(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Colors.transparent,
|
||
builder: (_) => const _CustomerCaptureSheet(),
|
||
);
|
||
}
|
||
|
||
class _CustomerCaptureSheet extends ConsumerStatefulWidget {
|
||
const _CustomerCaptureSheet();
|
||
|
||
@override
|
||
ConsumerState<_CustomerCaptureSheet> createState() =>
|
||
_CustomerCaptureSheetState();
|
||
}
|
||
|
||
class _CustomerCaptureSheetState
|
||
extends ConsumerState<_CustomerCaptureSheet> {
|
||
String _digits = '';
|
||
final _name = TextEditingController();
|
||
bool _saving = false;
|
||
String? _error;
|
||
|
||
@override
|
||
void dispose() {
|
||
_name.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _append(String d) {
|
||
if (_digits.length >= AppConstants.mobileNumberLength) return;
|
||
setState(() {
|
||
_digits += d;
|
||
_error = null;
|
||
});
|
||
if (_digits.length == AppConstants.mobileNumberLength) {
|
||
ref.read(customerLookupProvider.notifier).search(_digits);
|
||
}
|
||
}
|
||
|
||
void _backspace() {
|
||
if (_digits.isEmpty) return;
|
||
setState(() => _digits = _digits.substring(0, _digits.length - 1));
|
||
ref.read(customerLookupProvider.notifier).reset();
|
||
}
|
||
|
||
void _clear() {
|
||
setState(() {
|
||
_digits = '';
|
||
_error = null;
|
||
});
|
||
ref.read(customerLookupProvider.notifier).reset();
|
||
}
|
||
|
||
void _attachAndClose(Customer? customer) {
|
||
ref.read(cartControllerProvider.notifier).attachCustomer(customer);
|
||
Navigator.of(context).pop();
|
||
}
|
||
|
||
/// Saves with whatever was given. Both fields are optional: a bare number
|
||
/// is still worth keeping, because it is what the WhatsApp bill is sent to.
|
||
Future<void> _quickRegister() async {
|
||
final typed = _name.text.trim();
|
||
final name = typed.isEmpty
|
||
? 'Customer ${_digits.substring(_digits.length - 4)}'
|
||
: typed;
|
||
|
||
setState(() {
|
||
_saving = true;
|
||
_error = null;
|
||
});
|
||
|
||
try {
|
||
final created = await ref.read(customerRepositoryProvider).create(
|
||
Customer(id: '', name: name, mobile: _digits),
|
||
);
|
||
ref.invalidate(recentCustomersProvider);
|
||
if (mounted) _attachAndClose(created);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_saving = false;
|
||
_error = e is StateError ? e.message : 'Could not save customer.';
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final lookup = ref.watch(customerLookupProvider);
|
||
final attached = ref.watch(
|
||
cartControllerProvider.select((c) => c.customer),
|
||
);
|
||
|
||
return Padding(
|
||
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
|
||
child: Container(
|
||
constraints: BoxConstraints(
|
||
maxHeight: MediaQuery.sizeOf(context).height * 0.9,
|
||
),
|
||
decoration: const BoxDecoration(
|
||
color: AppColors.surface,
|
||
borderRadius:
|
||
BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
|
||
),
|
||
child: SafeArea(
|
||
top: false,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_grabber(),
|
||
_header(attached),
|
||
const Divider(height: 1),
|
||
Flexible(
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.all(AppSpacing.xxl),
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
// Side by side once there is room for both columns.
|
||
final wide = constraints.maxWidth >= 720;
|
||
final entry = _entryColumn();
|
||
final result = _resultColumn(lookup);
|
||
|
||
if (!wide) {
|
||
return Column(
|
||
children: [
|
||
entry,
|
||
const SizedBox(height: AppSpacing.xl),
|
||
result,
|
||
],
|
||
);
|
||
}
|
||
return Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(child: entry),
|
||
const SizedBox(width: AppSpacing.xxl),
|
||
Expanded(child: result),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _grabber() => Container(
|
||
width: 40,
|
||
height: 4,
|
||
margin: const EdgeInsets.symmetric(vertical: AppSpacing.md),
|
||
decoration: const BoxDecoration(
|
||
color: AppColors.border,
|
||
borderRadius: AppRadius.brPill,
|
||
),
|
||
);
|
||
|
||
Widget _header(Customer? attached) => Padding(
|
||
padding: const EdgeInsets.fromLTRB(
|
||
AppSpacing.xxl,
|
||
0,
|
||
AppSpacing.md,
|
||
AppSpacing.lg,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
attached == null ? 'Add customer' : 'Change customer',
|
||
overflow: TextOverflow.ellipsis,
|
||
style: Theme.of(context).textTheme.titleLarge,
|
||
),
|
||
const Text(
|
||
'Optional — for loyalty points and tier discounts',
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 12.5,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: AppSpacing.sm),
|
||
TextButton(
|
||
onPressed: () => _attachAndClose(null),
|
||
style: TextButton.styleFrom(
|
||
foregroundColor: AppColors.textSecondary,
|
||
),
|
||
child: const Text('Skip'),
|
||
),
|
||
IconButton(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
icon: const Icon(Icons.close_rounded),
|
||
tooltip: 'Close',
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
Widget _entryColumn() => Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_display(),
|
||
const SizedBox(height: AppSpacing.xl),
|
||
Center(
|
||
child: NumericKeypad(
|
||
maxWidth: 340,
|
||
onKey: _append,
|
||
onBackspace: _backspace,
|
||
onClear: _clear,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
|
||
Widget _display() => Container(
|
||
height: 68,
|
||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.primarySurface,
|
||
borderRadius: AppRadius.brLg,
|
||
border: Border.all(color: AppColors.primaryBorder),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
const Text(
|
||
'+91',
|
||
style: TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
const SizedBox(width: AppSpacing.md),
|
||
// FittedBox guarantees ten digits fit at any sheet width.
|
||
Expanded(
|
||
child: FittedBox(
|
||
fit: BoxFit.scaleDown,
|
||
alignment: Alignment.centerLeft,
|
||
child: Text(
|
||
_digits.isEmpty
|
||
? '– – – – – – – – – –'
|
||
: _digits.split('').join(' '),
|
||
style: TextStyle(
|
||
fontSize: 24,
|
||
fontWeight: FontWeight.w700,
|
||
letterSpacing: 1,
|
||
color: _digits.isEmpty
|
||
? AppColors.textTertiary
|
||
: AppColors.textPrimary,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
if (_digits.isNotEmpty)
|
||
IconButton(
|
||
onPressed: _clear,
|
||
icon: const Icon(Icons.close_rounded, size: 20),
|
||
color: AppColors.textTertiary,
|
||
tooltip: 'Clear',
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
Widget _resultColumn(CustomerLookupState state) => switch (state) {
|
||
LookupIdle() => _idle(),
|
||
LookupSearching() => const Padding(
|
||
padding: EdgeInsets.symmetric(vertical: AppSpacing.giant),
|
||
child: Center(
|
||
child: CircularProgressIndicator(color: AppColors.primary),
|
||
),
|
||
),
|
||
LookupFound(:final customer) => _found(customer),
|
||
LookupNotFound() => _notFound(),
|
||
LookupError(:final message) => _message(
|
||
Icons.error_outline_rounded,
|
||
AppColors.danger,
|
||
message,
|
||
),
|
||
};
|
||
|
||
Widget _idle() {
|
||
final recent = ref.watch(recentCustomersProvider).value ?? const [];
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_message(
|
||
Icons.dialpad_rounded,
|
||
AppColors.textTertiary,
|
||
'Key in a 10-digit mobile number — the lookup runs automatically. '
|
||
'Both fields are optional.',
|
||
),
|
||
const SizedBox(height: AppSpacing.lg),
|
||
_nameField(),
|
||
if (recent.isNotEmpty) ...[
|
||
const SizedBox(height: AppSpacing.xl),
|
||
const Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Text(
|
||
'Recent',
|
||
style: TextStyle(
|
||
fontSize: 12.5,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: AppSpacing.sm),
|
||
Wrap(
|
||
spacing: AppSpacing.sm,
|
||
runSpacing: AppSpacing.sm,
|
||
children: [
|
||
for (final c in recent.take(4))
|
||
ActionChip(
|
||
avatar: CircleAvatar(
|
||
radius: 11,
|
||
backgroundColor: AppColors.primarySurface,
|
||
child: Text(
|
||
Formatters.initials(c.name),
|
||
style: const TextStyle(
|
||
fontSize: 9,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.primary,
|
||
),
|
||
),
|
||
),
|
||
label: Text(
|
||
c.name,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(fontSize: 12.5),
|
||
),
|
||
onPressed: () => _attachAndClose(c),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _found(Customer c) => Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||
decoration: const BoxDecoration(
|
||
color: AppColors.successSurface,
|
||
borderRadius: AppRadius.brLg,
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
CircleAvatar(
|
||
radius: 22,
|
||
backgroundColor: AppColors.surface,
|
||
child: Text(
|
||
Formatters.initials(c.name),
|
||
style: const TextStyle(
|
||
color: AppColors.primary,
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: AppSpacing.md),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
c.name,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
Text(
|
||
Formatters.mobile(c.mobile),
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
fontSize: 12.5,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: AppSpacing.sm),
|
||
StatusPill.tier(c.tier, dense: true),
|
||
],
|
||
),
|
||
const SizedBox(height: AppSpacing.md),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: _miniStat(
|
||
'${c.loyaltyPoints}', 'points held',),
|
||
),
|
||
Expanded(
|
||
child: _miniStat(
|
||
Formatters.money(c.redeemableValue),
|
||
'redeemable',
|
||
),
|
||
),
|
||
if (c.tier.discountRate > 0)
|
||
Expanded(
|
||
child: _miniStat(
|
||
Formatters.percent(c.tier.discountRate),
|
||
'auto discount',
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: AppSpacing.lg),
|
||
PrimaryButton(
|
||
label: 'Use this customer',
|
||
icon: Icons.check_rounded,
|
||
large: true,
|
||
onPressed: () => _attachAndClose(c),
|
||
),
|
||
],
|
||
);
|
||
|
||
Widget _notFound() => Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_message(
|
||
Icons.person_search_rounded,
|
||
AppColors.warning,
|
||
'New number. Add a name if you have it — the bill can be sent to '
|
||
'this number on WhatsApp either way.',
|
||
),
|
||
const SizedBox(height: AppSpacing.lg),
|
||
_nameField(),
|
||
if (_error != null) ...[
|
||
const SizedBox(height: AppSpacing.sm),
|
||
Text(
|
||
_error!,
|
||
style: const TextStyle(color: AppColors.danger, fontSize: 12.5),
|
||
),
|
||
],
|
||
const SizedBox(height: AppSpacing.lg),
|
||
PrimaryButton(
|
||
label: 'Save & use',
|
||
icon: Icons.person_add_alt_1_rounded,
|
||
large: true,
|
||
busy: _saving,
|
||
onPressed: _quickRegister,
|
||
),
|
||
const SizedBox(height: AppSpacing.sm),
|
||
PrimaryButton(
|
||
label: 'Continue without customer',
|
||
tone: ButtonTone.neutral,
|
||
onPressed: _saving ? null : () => _attachAndClose(null),
|
||
),
|
||
],
|
||
);
|
||
|
||
Widget _nameField() => TextField(
|
||
controller: _name,
|
||
textCapitalization: TextCapitalization.words,
|
||
enabled: !_saving,
|
||
onSubmitted: (_) => _quickRegister(),
|
||
inputFormatters: [LengthLimitingTextInputFormatter(60)],
|
||
decoration: const InputDecoration(
|
||
labelText: 'Customer name',
|
||
hintText: 'Optional',
|
||
prefixIcon: Icon(Icons.person_outline_rounded),
|
||
),
|
||
);
|
||
|
||
Widget _miniStat(String value, String label) => Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
FittedBox(
|
||
fit: BoxFit.scaleDown,
|
||
alignment: Alignment.centerLeft,
|
||
child: Text(
|
||
value,
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.success,
|
||
),
|
||
),
|
||
),
|
||
Text(
|
||
label,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
fontSize: 11,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
|
||
Widget _message(IconData icon, Color color, String text) => Container(
|
||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surfaceAlt,
|
||
borderRadius: AppRadius.brMd,
|
||
border: Border.all(color: AppColors.border),
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Icon(icon, size: 19, color: color),
|
||
const SizedBox(width: AppSpacing.md),
|
||
Expanded(
|
||
child: Text(
|
||
text,
|
||
style: const TextStyle(
|
||
fontSize: 13,
|
||
color: AppColors.textSecondary,
|
||
height: 1.5,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|