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/theme/app_typography.dart'; import '../../../core/widgets/numeric_keypad.dart'; import '../../../core/widgets/primary_button.dart'; import '../../../domain/entities/customer.dart'; import '../../pos/providers/cart_controller.dart'; import '../providers/customer_providers.dart'; /// Attaches a customer to the current bill. /// /// A mobile number, optionally a name, or skip. Nothing else — no lookup /// result to read, no tier, no points balance. Those were a screenful of /// information nobody at a counter acts on, in front of a queue, for a step /// that is optional in the first place. /// /// The lookup still happens; it just does not show. On save the number is /// matched against what the terminal already holds, so a returning shopper is /// attached to their existing record rather than duplicated — the loyalty /// figures stay correct, they simply are not read out at the till. Future showCustomerCaptureSheet(BuildContext context) { return showModalBottomSheet( 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; bool get _complete => _digits.length == AppConstants.mobileNumberLength; @override void dispose() { _name.dispose(); super.dispose(); } void _append(String d) { if (_digits.length >= AppConstants.mobileNumberLength) return; setState(() { _digits += d; _error = null; }); } void _backspace() { if (_digits.isEmpty) return; setState(() { _digits = _digits.substring(0, _digits.length - 1); _error = null; }); } void _clear() { setState(() { _digits = ''; _error = null; }); } void _attachAndClose(Customer? customer) { ref.read(cartControllerProvider.notifier).attachCustomer(customer); Navigator.of(context).pop(); } /// Saves with whatever was given. /// /// The name is optional: a bare number is still worth keeping, because it is /// what the WhatsApp bill is sent to. An existing record wins over creating a /// second one — silently, because a cashier does not need to be told the /// shopper has been here before to finish the sale. Future _save() async { if (!_complete) return; setState(() { _saving = true; _error = null; }); final typed = _name.text.trim(); final repository = ref.read(customerRepositoryProvider); try { final existing = await repository.findByMobile(_digits); if (existing != null) { if (mounted) _attachAndClose(existing); return; } final created = await repository.create( Customer( id: '', name: typed.isEmpty ? 'Customer ${_digits.substring(_digits.length - 4)}' : typed, 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 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(), // Capped and centred. A modal sheet on a 27-inch till used to run // the full width of the screen, which put the keypad and the save // button at opposite ends of the desk. Flexible( child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 640), child: Column( mainAxisSize: MainAxisSize.min, children: [ _header(attached), const Divider(height: 1), Flexible( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB( AppSpacing.xxl, AppSpacing.xl, AppSpacing.xxl, AppSpacing.xxl, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _display(), const SizedBox(height: AppSpacing.sm), Text( _complete ? 'Ready to save' : '${AppConstants.mobileNumberLength - _digits.length}' ' more digit(s)', textAlign: TextAlign.center, style: const TextStyle( fontSize: 11.5, color: AppColors.textTertiary, ), ), const SizedBox(height: AppSpacing.lg), Center( child: NumericKeypad( maxWidth: 320, onKey: _append, onBackspace: _backspace, onClear: _clear, ), ), const SizedBox(height: AppSpacing.lg), TextField( controller: _name, textCapitalization: TextCapitalization.words, enabled: !_saving, onSubmitted: (_) => _save(), inputFormatters: [ LengthLimitingTextInputFormatter(60), ], decoration: const InputDecoration( labelText: 'Customer name', hintText: 'Optional', prefixIcon: Icon(Icons.person_outline_rounded), ), ), 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.check_rounded, large: true, busy: _saving, onPressed: _complete ? _save : null, ), const SizedBox(height: AppSpacing.sm), PrimaryButton( label: 'Skip', tone: ButtonTone.neutral, onPressed: _saving ? null : () => _attachAndClose(null), ), ], ), ), ), ], ), ), ), ), ], ), ), ), ); } 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.lg, AppSpacing.lg, ), child: Row( children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: AppColors.primarySurface, borderRadius: AppRadius.brSm, border: Border.all(color: AppColors.primaryBorder), ), child: const Icon(Icons.person_add_alt_1_outlined, size: 20, color: AppColors.primary,), ), const SizedBox(width: AppSpacing.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( attached == null ? 'Add customer' : 'Change customer', overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w600, letterSpacing: -0.3, color: AppColors.textPrimary, height: 1.2, ), ), const Text( 'Optional — the bill can be sent to this number', overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5, color: AppColors.textSecondary, ), ), ], ), ), const SizedBox(width: AppSpacing.sm), IconButton( onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.close_rounded), color: AppColors.textTertiary, tooltip: 'Close', ), ], ), ); /// The number as it is keyed, grouped 5 + 5 the way it is read aloud. Widget _display() { final filled = _digits.isNotEmpty; final head = _digits.length <= 5 ? _digits : _digits.substring(0, 5); final tail = _digits.length <= 5 ? '' : _digits.substring(5); return Container( height: 64, padding: const EdgeInsets.only(left: AppSpacing.md, right: AppSpacing.xs), decoration: BoxDecoration( color: filled ? AppColors.surface : AppColors.surfaceAlt, borderRadius: AppRadius.brLg, border: Border.all( color: filled ? AppColors.primary : AppColors.border, width: filled ? 1.4 : 1, ), ), child: Row( children: [ Container( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.sm, vertical: 3, ), decoration: const BoxDecoration( color: AppColors.surfaceAlt, borderRadius: AppRadius.brXs, ), child: const Text( '+91', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.textSecondary, ), ), ), const SizedBox(width: AppSpacing.md), Expanded( child: FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, child: filled ? Text( tail.isEmpty ? head : '$head $tail', style: AppTypography.money(23).copyWith( letterSpacing: 1.5, color: AppColors.textPrimary, ), ) : Text( 'Mobile number', style: TextStyle( fontSize: 16, color: AppColors.textTertiary.withValues(alpha: 0.9), ), ), ), ), if (filled) IconButton( onPressed: _clear, icon: const Icon(Icons.backspace_outlined, size: 18), color: AppColors.textTertiary, tooltip: 'Clear', ), ], ), ); } }