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 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; @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(); } Future _quickRegister() async { final name = _name.text.trim(); if (name.length < 2) { setState(() => _error = 'Enter a name to save this customer'); return; } 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: 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.', ), 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: 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, 'Not registered yet. Add a name to save them, or carry on ' 'without.', ), const SizedBox(height: AppSpacing.lg), TextField( controller: _name, textCapitalization: TextCapitalization.words, enabled: !_saving, onSubmitted: (_) => _quickRegister(), inputFormatters: [LengthLimitingTextInputFormatter(60)], decoration: const InputDecoration( labelText: 'Customer name', hintText: 'Full name', 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: _quickRegister, ), const SizedBox(height: AppSpacing.sm), PrimaryButton( label: 'Continue without customer', tone: ButtonTone.neutral, onPressed: _saving ? null : () => _attachAndClose(null), ), ], ); 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, ), ), ), ], ), ); }