second commit
This commit is contained in:
@@ -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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user