added local db

This commit is contained in:
2026-07-29 13:06:39 +05:30
parent d72522e737
commit d9886880cc
38 changed files with 2895 additions and 2261 deletions

View File

@@ -1,321 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/providers.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/extensions.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/utils/validators.dart';
import '../../../core/widgets/glass_card.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../domain/entities/customer.dart';
import '../../pos/providers/cart_controller.dart';
import '../providers/customer_providers.dart';
/// Screen 2 — registers a shopper and drops straight into billing.
class CustomerRegistrationScreen extends ConsumerStatefulWidget {
const CustomerRegistrationScreen({super.key, this.prefillMobile});
final String? prefillMobile;
@override
ConsumerState<CustomerRegistrationScreen> createState() =>
_CustomerRegistrationScreenState();
}
class _CustomerRegistrationScreenState
extends ConsumerState<CustomerRegistrationScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _mobile;
final _name = TextEditingController();
final _email = TextEditingController();
Gender _gender = Gender.unspecified;
DateTime? _dob;
bool _saving = false;
String? _serverError;
@override
void initState() {
super.initState();
_mobile = TextEditingController(text: widget.prefillMobile ?? '');
}
@override
void dispose() {
_mobile.dispose();
_name.dispose();
_email.dispose();
super.dispose();
}
Future<void> _save() async {
setState(() => _serverError = null);
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() => _saving = true);
try {
final customer = await ref.read(customerRepositoryProvider).create(
Customer(
id: '',
name: _name.text,
mobile: _mobile.text,
email: _email.text,
gender: _gender,
dateOfBirth: _dob,
),
);
ref.read(cartControllerProvider.notifier).attachCustomer(customer);
ref.invalidate(recentCustomersProvider);
if (!mounted) return;
context.go(AppRoutes.pos);
} catch (e) {
if (!mounted) return;
setState(() {
_saving = false;
_serverError = e is StateError ? e.message : 'Could not save customer.';
});
}
}
Future<void> _pickDob() async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: _dob ?? DateTime(now.year - 25),
firstDate: DateTime(now.year - 100),
lastDate: now,
helpText: 'Date of birth',
);
if (picked != null) setState(() => _dob = picked);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
title: const Text('New Customer'),
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => context.pop(),
),
),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.xxl),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: GlassCard(
padding: const EdgeInsets.all(AppSpacing.xxxl),
radius: AppRadius.xl,
shadows: AppColors.shadowMd,
child: Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Register a shopper',
style: context.text.headlineSmall),
const SizedBox(height: AppSpacing.xs),
Text(
'Only the mobile number and name are required.',
style: context.text.bodySmall,
),
const SizedBox(height: AppSpacing.xxl),
_Field(
label: 'Mobile Number',
required: true,
child: TextFormField(
controller: _mobile,
autofocus: true,
keyboardType: TextInputType.phone,
maxLength: 10,
validator: Validators.mobile,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
decoration: const InputDecoration(
hintText: '10-digit mobile number',
prefixText: '+91 ',
counterText: '',
prefixIcon: Icon(Icons.phone_outlined),
),
),
),
_Field(
label: 'Customer Name',
required: true,
child: TextFormField(
controller: _name,
textCapitalization: TextCapitalization.words,
validator: Validators.name,
decoration: const InputDecoration(
hintText: 'Full name',
prefixIcon: Icon(Icons.person_outline_rounded),
),
),
),
_Field(
label: 'Email',
child: TextFormField(
controller: _email,
keyboardType: TextInputType.emailAddress,
validator: Validators.emailOptional,
decoration: const InputDecoration(
hintText: 'name@example.com',
prefixIcon: Icon(Icons.mail_outline_rounded),
),
),
),
_Field(
label: 'Gender',
child: Wrap(
spacing: AppSpacing.sm,
children: Gender.values
.map((g) => ChoiceChip(
label: Text(g.label),
selected: _gender == g,
onSelected: (_) =>
setState(() => _gender = g),
labelStyle: TextStyle(
color: _gender == g
? Colors.white
: AppColors.textSecondary,
fontWeight: FontWeight.w600,
),
))
.toList(),
),
),
_Field(
label: 'Date of Birth',
child: InkWell(
onTap: _pickDob,
borderRadius: AppRadius.brMd,
child: InputDecorator(
decoration: const InputDecoration(
prefixIcon: Icon(Icons.cake_outlined),
),
child: Text(
_dob == null
? 'Select a date (optional)'
: Formatters.date(_dob!),
style: TextStyle(
color: _dob == null
? AppColors.textTertiary
: AppColors.textPrimary,
fontSize: 15,
),
),
),
),
),
if (_serverError != null) ...[
const SizedBox(height: AppSpacing.sm),
Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.dangerSurface,
borderRadius: AppRadius.brSm,
),
child: Row(children: [
const Icon(Icons.error_outline_rounded,
color: AppColors.danger, size: 18),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
_serverError!,
style: const TextStyle(
color: AppColors.danger,
fontSize: 13.5,
),
),
),
]),
),
],
const SizedBox(height: AppSpacing.xxl),
Row(children: [
Expanded(
child: PrimaryButton(
label: 'Cancel',
tone: ButtonTone.neutral,
onPressed:
_saving ? null : () => context.pop(),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
flex: 2,
child: PrimaryButton(
label: 'Save & Continue',
icon: Icons.check_rounded,
busy: _saving,
onPressed: _save,
),
),
]),
],
),
),
),
),
),
),
);
}
}
class _Field extends StatelessWidget {
const _Field({
required this.label,
required this.child,
this.required = false,
});
final String label;
final Widget child;
final bool required;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Text(label,
style: context.text.labelMedium
?.copyWith(color: AppColors.textSecondary)),
if (required)
const Text(' *',
style: TextStyle(color: AppColors.danger, fontSize: 13)),
if (!required)
Text(' optional',
style: context.text.labelSmall
?.copyWith(color: AppColors.textTertiary)),
]),
const SizedBox(height: AppSpacing.sm),
child,
],
),
);
}
}

View File

@@ -1,504 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/extensions.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../core/widgets/glass_card.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';
/// Screen 3 — mobile lookup that auto-searches on the tenth digit.
class ExistingCustomerScreen extends ConsumerStatefulWidget {
const ExistingCustomerScreen({super.key});
@override
ConsumerState<ExistingCustomerScreen> createState() =>
_ExistingCustomerScreenState();
}
class _ExistingCustomerScreenState
extends ConsumerState<ExistingCustomerScreen> {
String _digits = '';
void _append(String d) {
if (_digits.length >= AppConstants.mobileNumberLength) return;
setState(() => _digits += d);
if (_digits.length == AppConstants.mobileNumberLength) _search();
}
void _backspace() {
if (_digits.isEmpty) return;
setState(() => _digits = _digits.substring(0, _digits.length - 1));
ref.read(customerLookupProvider.notifier).reset();
}
void _clear() {
setState(() => _digits = '');
ref.read(customerLookupProvider.notifier).reset();
}
void _search() => ref.read(customerLookupProvider.notifier).search(_digits);
void _continueWith(Customer customer) {
ref.read(cartControllerProvider.notifier).attachCustomer(customer);
context.go(AppRoutes.pos);
}
@override
Widget build(BuildContext context) {
final lookup = ref.watch(customerLookupProvider);
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
title: const Text('Find Customer'),
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => context.pop(),
),
actions: [
TextButton.icon(
onPressed: () {
ref.read(cartControllerProvider.notifier).attachCustomer(null);
context.go(AppRoutes.pos);
},
icon: const Icon(Icons.directions_walk_rounded,
color: Colors.white, size: 18),
label: const Text('Continue as Walk-in',
style: TextStyle(color: Colors.white)),
),
const SizedBox(width: AppSpacing.lg),
],
),
body: Padding(
padding: const EdgeInsets.all(AppSpacing.xxl),
child: context.isCompact
? SingleChildScrollView(
child: Column(children: [
_entryPanel(),
const SizedBox(height: AppSpacing.xxl),
// Bound the height: the panel uses Expanded/Spacer
// internally, which a scroll view cannot supply.
SizedBox(height: 480, child: _resultPanel(lookup)),
]),
)
: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(flex: 4, child: _entryPanel()),
const SizedBox(width: AppSpacing.xxl),
Expanded(flex: 5, child: _resultPanel(lookup)),
],
),
),
);
}
Widget _entryPanel() {
return GlassCard(
padding: const EdgeInsets.all(AppSpacing.xxl),
radius: AppRadius.xl,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Mobile number', style: context.text.labelMedium),
const SizedBox(height: AppSpacing.md),
_display(),
const SizedBox(height: AppSpacing.xxl),
Center(
child: NumericKeypad(
onKey: _append,
onBackspace: _backspace,
onClear: _clear,
onSubmit:
_digits.length == AppConstants.mobileNumberLength
? _search
: null,
submitLabel: 'Search',
),
),
],
),
);
}
/// Ten slots so the cashier can see progress at a glance.
Widget _display() {
return Container(
height: 72,
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: 20,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
)),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(
AppConstants.mobileNumberLength,
(i) {
final filled = i < _digits.length;
return AnimatedContainer(
duration: AppMotion.fast,
width: 22,
alignment: Alignment.center,
child: Text(
filled ? _digits[i] : '',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: filled
? AppColors.textPrimary
: AppColors.textTertiary.withValues(alpha: 0.5),
),
),
);
},
),
),
),
if (_digits.isNotEmpty)
IconButton(
onPressed: _clear,
icon: const Icon(Icons.close_rounded, size: 20),
color: AppColors.textTertiary,
tooltip: 'Clear',
),
]),
);
}
Widget _resultPanel(CustomerLookupState state) {
return GlassCard(
padding: const EdgeInsets.all(AppSpacing.xxl),
radius: AppRadius.xl,
child: switch (state) {
LookupIdle() => _idle(),
LookupSearching() => const Center(
child: CircularProgressIndicator(color: AppColors.primary),
),
LookupFound(:final customer) => _found(customer),
LookupNotFound(:final mobile) => _notFound(mobile),
LookupError(:final message) => EmptyState(
title: 'Something went wrong',
message: message,
emoji: '⚠️',
),
},
);
}
Widget _idle() {
final recent = ref.watch(recentCustomersProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Recent customers', style: context.text.titleMedium),
const SizedBox(height: AppSpacing.xs),
Text('Tap to select, or key in a mobile number.',
style: context.text.bodySmall),
const SizedBox(height: AppSpacing.lg),
Expanded(
child: recent.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => EmptyState(
title: 'Could not load customers',
message: '$e',
emoji: '⚠️',
compact: true,
),
data: (customers) => customers.isEmpty
? const EmptyState(
title: 'No customers yet',
message: 'Register the first one from the welcome screen.',
emoji: '👤',
compact: true,
)
: ListView.separated(
itemCount: customers.length,
separatorBuilder: (_, __) =>
const SizedBox(height: AppSpacing.sm),
itemBuilder: (_, i) => _RecentTile(
customer: customers[i],
onTap: () => _continueWith(customers[i]),
),
),
),
),
],
);
}
Widget _found(Customer c) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(children: [
CircleAvatar(
radius: 30,
backgroundColor: AppColors.primarySurface,
child: Text(
Formatters.initials(c.name),
style: const TextStyle(
color: AppColors.primary,
fontSize: 22,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: AppSpacing.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Flexible(
child: Text(c.name,
style: context.text.headlineSmall,
overflow: TextOverflow.ellipsis),
),
const SizedBox(width: AppSpacing.sm),
StatusPill.tier(c.tier),
]),
const SizedBox(height: 2),
Text('+91 ${Formatters.mobile(c.mobile)}',
style: context.text.bodyMedium
?.copyWith(color: AppColors.textSecondary)),
],
),
),
]),
if (c.isBirthdayToday) ...[
const SizedBox(height: AppSpacing.lg),
Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.warningSurface,
borderRadius: AppRadius.brSm,
),
child: const Row(children: [
Text('🎂', style: TextStyle(fontSize: 18)),
SizedBox(width: AppSpacing.sm),
Text("It's their birthday today — wish them!",
style: TextStyle(
color: AppColors.warning,
fontWeight: FontWeight.w600,
)),
]),
),
],
const SizedBox(height: AppSpacing.xxl),
Row(children: [
Expanded(
child: _Stat(
label: 'Loyalty Points',
value: '${c.loyaltyPoints}',
caption: 'Worth ${Formatters.money(c.redeemableValue)}',
icon: Icons.stars_rounded,
color: AppColors.tierGold,
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: _Stat(
label: 'Lifetime Spend',
value: Formatters.moneyCompact(c.lifetimeSpend),
caption: '${c.visitCount} visits',
icon: Icons.receipt_long_rounded,
color: AppColors.primary,
),
),
]),
if (c.tier.discountRate > 0) ...[
const SizedBox(height: AppSpacing.md),
Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.successSurface,
borderRadius: AppRadius.brSm,
),
child: Row(children: [
const Icon(Icons.local_offer_rounded,
color: AppColors.success, size: 18),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
'${c.tier.label} members get '
'${Formatters.percent(c.tier.discountRate)} off '
'automatically on every bill.',
style: const TextStyle(
color: AppColors.success,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
]),
),
],
const Spacer(),
PrimaryButton(
label: 'Continue to Billing',
icon: Icons.point_of_sale_rounded,
large: true,
onPressed: () => _continueWith(c),
),
],
).animate().fadeIn(duration: 200.ms);
}
Widget _notFound(String mobile) {
return Column(
children: [
Expanded(
child: EmptyState(
title: 'No customer found',
message: 'Nobody is registered against '
'+91 ${Formatters.mobile(mobile)}.',
emoji: '🔍',
),
),
PrimaryButton(
label: 'Register Customer',
icon: Icons.person_add_alt_1_rounded,
onPressed: () => context.push(
'${AppRoutes.registerCustomer}?mobile=$mobile',
),
),
const SizedBox(height: AppSpacing.md),
PrimaryButton(
label: 'Continue as Walk-in',
icon: Icons.directions_walk_rounded,
tone: ButtonTone.neutral,
onPressed: () {
ref.read(cartControllerProvider.notifier).attachCustomer(null);
context.go(AppRoutes.pos);
},
),
],
).animate().fadeIn(duration: 200.ms);
}
}
class _RecentTile extends StatelessWidget {
const _RecentTile({required this.customer, required this.onTap});
final Customer customer;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.surfaceAlt,
borderRadius: AppRadius.brMd,
child: InkWell(
onTap: onTap,
borderRadius: AppRadius.brMd,
child: Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Row(children: [
CircleAvatar(
radius: 18,
backgroundColor: AppColors.primarySurface,
child: Text(
Formatters.initials(customer.name),
style: const TextStyle(
color: AppColors.primary,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(customer.name,
style: context.text.titleSmall,
overflow: TextOverflow.ellipsis),
Text(Formatters.maskedMobile(customer.mobile),
style: context.text.bodySmall),
],
),
),
StatusPill.tier(customer.tier, dense: true),
const SizedBox(width: AppSpacing.sm),
const Icon(Icons.chevron_right_rounded,
color: AppColors.textTertiary),
]),
),
),
);
}
}
class _Stat extends StatelessWidget {
const _Stat({
required this.label,
required this.value,
required this.caption,
required this.icon,
required this.color,
});
final String label;
final String value;
final String caption;
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: AppRadius.brMd,
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: AppSpacing.xs),
Text(label,
style: context.text.labelSmall
?.copyWith(color: AppColors.textSecondary)),
]),
const SizedBox(height: AppSpacing.sm),
Text(value,
style: context.text.headlineSmall?.copyWith(color: color)),
Text(caption, style: context.text.bodySmall),
],
),
);
}
}

View File

@@ -0,0 +1,561 @@
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();
}
Future<void> _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,
),
),
),
],
),
);
}