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'; /// Attaches a customer to the current bill: a mobile number, a name, or /// neither. /// /// Deliberately not a lookup screen. There is no search against the customers /// already on the terminal, no recent list, and no membership tier — a cashier /// at a queue keys the number, keys the name if the shopper gives one, and /// carries on. A number that turns out to be registered already is reused /// silently rather than being turned into a decision the counter has to make. /// /// Nothing here blocks the sale: Skip closes it with a walk-in bill. 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 the number under the name given, then puts it on the bill. /// /// The name is optional — a bare number is still worth keeping, because it /// is what the WhatsApp bill is sent to. /// /// A number already on file is the ordinary case, not an error: the existing /// record is picked up and attached, and a freshly typed name is written /// over the stored one so a correction at the counter sticks. The cashier /// sees the same thing either way, which is the point of not having a lookup /// step. Future _save() async { if (!_complete) { setState( () => _error = 'Enter all ' '${AppConstants.mobileNumberLength} digits of the mobile number.', ); return; } final typed = _name.text.trim(); final name = typed.isEmpty ? 'Customer ${_digits.substring(_digits.length - 4)}' : typed; setState(() { _saving = true; _error = null; }); final repo = ref.read(customerRepositoryProvider); try { final created = await repo.create( Customer(id: '', name: name, mobile: _digits), ); if (mounted) _attachAndClose(created); } on StateError { // Already registered. Reuse the row rather than making the counter // reconcile it. try { final existing = await repo.findByMobile(_digits); if (existing == null) throw StateError('lookup failed'); final updated = typed.isEmpty || typed == existing.name ? existing : await repo.update(existing.copyWith(name: typed)); if (mounted) _attachAndClose(updated); } catch (_) { if (!mounted) return; setState(() { _saving = false; _error = 'Could not save customer. Try again.'; }); } } catch (_) { if (!mounted) return; setState(() { _saving = false; _error = 'Could not save customer. Try again.'; }); } } @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(), // Everything inside is 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 fields at opposite ends of the desk. Flexible( child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 900), 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: LayoutBuilder( builder: (context, box) { // Side by side once there is room for both. final wide = box.maxWidth >= 660; final entry = _entryColumn(); final details = _detailsColumn(); if (!wide) { return Column( children: [ entry, const SizedBox(height: AppSpacing.xl), details, ], ); } return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(width: 300, child: entry), const SizedBox(width: AppSpacing.xxl), Expanded(child: details), ], ); }, ), ), ), ], ), ), ), ), ], ), ), ), ); } 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 — number and name, or skip', overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5, color: AppColors.textSecondary, ), ), ], ), ), const SizedBox(width: AppSpacing.sm), TextButton( onPressed: _saving ? null : () => _attachAndClose(null), style: TextButton.styleFrom( foregroundColor: AppColors.textSecondary, minimumSize: const Size(0, 38), ), child: const Text('Skip'), ), IconButton( onPressed: _saving ? null : () => Navigator.of(context).pop(), icon: const Icon(Icons.close_rounded), color: AppColors.textTertiary, tooltip: 'Close', ), ], ), ); // ----------------------------------------------------------------- Entry Widget _entryColumn() => Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _display(), const SizedBox(height: AppSpacing.sm), Text( _complete ? 'Number complete' : '${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: 300, onKey: _append, onBackspace: _backspace, onClear: _clear, ), ), ], ); /// 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: _saving ? null : _clear, icon: const Icon(Icons.backspace_outlined, size: 18), color: AppColors.textTertiary, tooltip: 'Clear', ), ], ), ); } // --------------------------------------------------------------- Details Widget _detailsColumn() => Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ _hint(), 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.person_add_alt_1_rounded, large: true, busy: _saving, onPressed: _complete && !_saving ? _save : null, ), const SizedBox(height: AppSpacing.sm), PrimaryButton( label: 'Skip — no customer', tone: ButtonTone.neutral, onPressed: _saving ? null : () => _attachAndClose(null), ), ], ); Widget _hint() => Container( width: double.infinity, padding: const EdgeInsets.all(AppSpacing.lg), decoration: BoxDecoration( color: AppColors.surfaceAlt, borderRadius: AppRadius.brLg, border: Border.all(color: AppColors.border), ), child: const Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.dialpad_rounded, size: 19, color: AppColors.textTertiary,), SizedBox(width: AppSpacing.md), Expanded( child: Text( 'Key in the mobile number, add a name if the shopper gives ' 'one, then save. The name is optional and the whole step can ' 'be skipped — the sale is never held up by it.', style: TextStyle( fontSize: 13, color: AppColors.textSecondary, height: 1.5, ), ), ), ], ), ); }