second commit
This commit is contained in:
71
lib/presentation/customer/providers/customer_providers.dart
Normal file
71
lib/presentation/customer/providers/customer_providers.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/providers.dart';
|
||||
import '../../../domain/entities/customer.dart';
|
||||
|
||||
/// Outcome of the mobile-number lookup on the Existing Customer screen.
|
||||
sealed class CustomerLookupState {
|
||||
const CustomerLookupState();
|
||||
}
|
||||
|
||||
class LookupIdle extends CustomerLookupState {
|
||||
const LookupIdle();
|
||||
}
|
||||
|
||||
class LookupSearching extends CustomerLookupState {
|
||||
const LookupSearching();
|
||||
}
|
||||
|
||||
class LookupFound extends CustomerLookupState {
|
||||
const LookupFound(this.customer);
|
||||
|
||||
final Customer customer;
|
||||
}
|
||||
|
||||
class LookupNotFound extends CustomerLookupState {
|
||||
const LookupNotFound(this.mobile);
|
||||
|
||||
final String mobile;
|
||||
}
|
||||
|
||||
class LookupError extends CustomerLookupState {
|
||||
const LookupError(this.message);
|
||||
|
||||
final String message;
|
||||
}
|
||||
|
||||
class CustomerLookupController extends StateNotifier<CustomerLookupState> {
|
||||
CustomerLookupController(this._ref) : super(const LookupIdle());
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
Future<void> search(String mobile) async {
|
||||
final digits = mobile.replaceAll(RegExp(r'\D'), '');
|
||||
if (digits.length != 10) {
|
||||
state = const LookupIdle();
|
||||
return;
|
||||
}
|
||||
|
||||
state = const LookupSearching();
|
||||
try {
|
||||
final customer =
|
||||
await _ref.read(customerRepositoryProvider).findByMobile(digits);
|
||||
state = customer != null
|
||||
? LookupFound(customer)
|
||||
: LookupNotFound(digits);
|
||||
} catch (e) {
|
||||
state = LookupError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void reset() => state = const LookupIdle();
|
||||
}
|
||||
|
||||
final customerLookupProvider =
|
||||
StateNotifierProvider<CustomerLookupController, CustomerLookupState>(
|
||||
(ref) => CustomerLookupController(ref),
|
||||
);
|
||||
|
||||
final recentCustomersProvider = FutureProvider<List<Customer>>(
|
||||
(ref) => ref.watch(customerRepositoryProvider).recent(limit: 6),
|
||||
);
|
||||
@@ -0,0 +1,321 @@
|
||||
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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
504
lib/presentation/customer/screens/existing_customer_screen.dart
Normal file
504
lib/presentation/customer/screens/existing_customer_screen.dart
Normal file
@@ -0,0 +1,504 @@
|
||||
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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user