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: 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: 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,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|