Files
nearle_pos/lib/presentation/customer/screens/existing_customer_screen.dart
2026-07-29 11:41:53 +05:30

505 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
],
),
);
}
}