added local db
This commit is contained in:
@@ -44,7 +44,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
password: _password.text,
|
||||
);
|
||||
|
||||
if (ok && mounted) context.go(AppRoutes.welcome);
|
||||
if (ok && mounted) context.go(AppRoutes.pos);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
561
lib/presentation/customer/widgets/customer_capture_sheet.dart
Normal file
561
lib/presentation/customer/widgets/customer_capture_sheet.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -5,27 +5,28 @@ import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../core/widgets/primary_button.dart';
|
||||
import '../../../domain/entities/sync_event.dart';
|
||||
import '../../../domain/entities/transaction.dart';
|
||||
import '../../../domain/repositories/sync_repository.dart';
|
||||
import '../../sync/providers/sync_controller.dart';
|
||||
import '../widgets/module_widgets.dart';
|
||||
|
||||
/// The terminal's outbound half: what today produced, and what has been sent.
|
||||
/// End-of-day sync.
|
||||
///
|
||||
/// Shows what the terminal produced today and uploads every bill still at
|
||||
/// `sync_status = 0`. Accepted bills flip to 1; anything that fails stays at 0
|
||||
/// and is retried on the next tap.
|
||||
class EventsView extends ConsumerWidget {
|
||||
const EventsView({super.key});
|
||||
|
||||
static Color statusColor(SyncStatus s) => switch (s) {
|
||||
SyncStatus.synced => AppColors.success,
|
||||
SyncStatus.failed => AppColors.danger,
|
||||
SyncStatus.syncing => AppColors.info,
|
||||
SyncStatus.pending => AppColors.warning,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final report = ref.watch(shiftReportProvider);
|
||||
final report = ref.watch(todayReportProvider);
|
||||
final pending = ref.watch(unsyncedCountProvider).value ?? 0;
|
||||
final rows = ref.watch(orderSyncRowsProvider).value ?? const [];
|
||||
final syncState = ref.watch(orderSyncProvider);
|
||||
final events = ref.watch(syncEventsProvider);
|
||||
final pushing = ref.watch(reportPushProvider);
|
||||
|
||||
final r = report.value;
|
||||
|
||||
return ModulePage(
|
||||
children: [
|
||||
@@ -35,112 +36,110 @@ class EventsView extends ConsumerWidget {
|
||||
children: [
|
||||
StatTile(
|
||||
label: 'Bills Today',
|
||||
value: '${report.billCount}',
|
||||
value: '${r?.billCount ?? 0}',
|
||||
icon: Icons.receipt_long_rounded,
|
||||
caption: report.firstBillAt == null
|
||||
caption: r?.firstBillAt == null
|
||||
? 'no sales yet'
|
||||
: '${Formatters.time(report.firstBillAt!)} – '
|
||||
'${Formatters.time(report.lastBillAt!)}',
|
||||
: '${Formatters.time(r!.firstBillAt!)} – '
|
||||
'${Formatters.time(r.lastBillAt!)}',
|
||||
),
|
||||
StatTile(
|
||||
label: 'Items Sold',
|
||||
value: report.itemCount.toStringAsFixed(0),
|
||||
value: (r?.itemCount ?? 0).toStringAsFixed(0),
|
||||
icon: Icons.shopping_basket_rounded,
|
||||
color: AppColors.info,
|
||||
caption: 'units across all bills',
|
||||
),
|
||||
StatTile(
|
||||
label: "Today's Sales",
|
||||
value: Formatters.money(report.grossSales),
|
||||
value: Formatters.money(r?.grossSales ?? 0),
|
||||
icon: Icons.payments_rounded,
|
||||
color: AppColors.success,
|
||||
caption: 'gross takings',
|
||||
),
|
||||
StatTile(
|
||||
label: 'Average Basket',
|
||||
value: Formatters.money(report.averageBasket),
|
||||
icon: Icons.trending_up_rounded,
|
||||
color: AppColors.tierGold,
|
||||
caption: 'per bill',
|
||||
label: 'Awaiting Sync',
|
||||
value: '$pending',
|
||||
icon: Icons.cloud_off_rounded,
|
||||
color: pending > 0 ? AppColors.warning : AppColors.success,
|
||||
caption: pending > 0
|
||||
? 'held on this terminal'
|
||||
: 'everything uploaded',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
PanelCard(
|
||||
title: 'Shift report',
|
||||
subtitle: '${Formatters.date(report.businessDate)} · '
|
||||
'${report.cashierName} · ${report.terminalId}',
|
||||
title: 'Upload bills to server',
|
||||
subtitle: r == null
|
||||
? 'Reading today\u2019s trading from SQLite\u2026'
|
||||
: '${Formatters.date(r.businessDate)} \u00b7 ${r.cashierName} '
|
||||
'\u00b7 ${r.terminalId}',
|
||||
action: TagChip(
|
||||
report.isEmpty ? 'Nothing to send' : 'Ready to push',
|
||||
color: report.isEmpty ? AppColors.textSecondary : AppColors.warning,
|
||||
pending > 0 ? '$pending pending' : 'All synced',
|
||||
color: pending > 0 ? AppColors.warning : AppColors.success,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_row('Bills', '${report.billCount}'),
|
||||
_row('Items sold', report.itemCount.toStringAsFixed(0)),
|
||||
_row('Gross sales', Formatters.money(report.grossSales)),
|
||||
_row('Net of tax', Formatters.money(report.netOfTax)),
|
||||
_row('GST collected', Formatters.money(report.taxCollected)),
|
||||
_row('Discount given', Formatters.money(report.discountGiven)),
|
||||
_row('Round off', Formatters.money(report.roundOff)),
|
||||
_row('Points issued', '${report.loyaltyPointsIssued}'),
|
||||
_row('Points redeemed', '${report.loyaltyPointsRedeemed}'),
|
||||
|
||||
if (report.paymentBreakdown.isNotEmpty) ...[
|
||||
const Divider(height: AppSpacing.xxl),
|
||||
const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'By payment method',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
if (r != null && !r.isEmpty) ...[
|
||||
_row('Bills', '${r.billCount}'),
|
||||
_row('Items sold', r.itemCount.toStringAsFixed(0)),
|
||||
_row('Gross sales', Formatters.money(r.grossSales)),
|
||||
_row('GST collected', Formatters.money(r.taxCollected)),
|
||||
_row('Discount given', Formatters.money(r.discountGiven)),
|
||||
_row('Average basket', Formatters.money(r.averageBasket)),
|
||||
if (r.paymentBreakdown.isNotEmpty) ...[
|
||||
const Divider(height: AppSpacing.xxl),
|
||||
for (final e in r.paymentBreakdown.entries)
|
||||
ProgressRow(
|
||||
label: '${e.key.emoji} ${e.key.label}',
|
||||
value: Formatters.money(e.value),
|
||||
fraction:
|
||||
r.grossSales <= 0 ? 0 : e.value / r.grossSales,
|
||||
color: _methodColor(e.key),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
],
|
||||
|
||||
if (syncState is SyncRunning) ...[
|
||||
Text(
|
||||
syncState.stage,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
for (final e in report.paymentBreakdown.entries)
|
||||
ProgressRow(
|
||||
label: '${e.key.emoji} ${e.key.label}',
|
||||
value: Formatters.money(e.value),
|
||||
fraction: report.grossSales <= 0
|
||||
? 0
|
||||
: e.value / report.grossSales,
|
||||
color: _methodColor(e.key),
|
||||
ClipRRect(
|
||||
borderRadius: AppRadius.brPill,
|
||||
child: LinearProgressIndicator(
|
||||
value: syncState.progress,
|
||||
minHeight: 8,
|
||||
backgroundColor: AppColors.divider,
|
||||
valueColor:
|
||||
const AlwaysStoppedAnimation<Color>(AppColors.primary),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
],
|
||||
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
if (syncState is SyncFinished)
|
||||
_outcomeBanner(syncState.outcome),
|
||||
|
||||
PrimaryButton(
|
||||
label: 'Push report to server',
|
||||
label: pending > 0
|
||||
? 'Sync $pending bill${pending == 1 ? '' : 's'}'
|
||||
: 'Nothing to sync',
|
||||
icon: Icons.cloud_upload_rounded,
|
||||
large: true,
|
||||
busy: pushing,
|
||||
onPressed: report.isEmpty
|
||||
busy: syncState is SyncRunning,
|
||||
onPressed: pending == 0
|
||||
? null
|
||||
: () async {
|
||||
final event = await ref
|
||||
.read(reportPushProvider.notifier)
|
||||
.pushToday();
|
||||
if (!context.mounted) return;
|
||||
final ok = event.status == SyncStatus.synced;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(
|
||||
backgroundColor:
|
||||
ok ? AppColors.success : AppColors.danger,
|
||||
content: Text(
|
||||
ok
|
||||
? 'Shift report sent.'
|
||||
: 'Push failed — the report is still saved '
|
||||
'on this terminal.',
|
||||
),
|
||||
));
|
||||
},
|
||||
: () => ref.read(orderSyncProvider.notifier).run(),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
const Row(
|
||||
@@ -151,8 +150,9 @@ class EventsView extends ConsumerWidget {
|
||||
SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'A failed push never discards data. The report stays '
|
||||
'queued below and can be retried at any time.',
|
||||
'Bills are written to SQLite the moment a sale '
|
||||
'completes. A failed upload changes nothing on disk — '
|
||||
'every bill stays until the server confirms it.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textTertiary,
|
||||
@@ -168,64 +168,113 @@ class EventsView extends ConsumerWidget {
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
PanelCard(
|
||||
title: 'Event log',
|
||||
subtitle: '${events.length} recorded · '
|
||||
'${events.where((e) => e.status != SyncStatus.synced).length} '
|
||||
'outstanding',
|
||||
child: events.isEmpty
|
||||
? const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: AppSpacing.lg),
|
||||
child: Text(
|
||||
'No sync activity yet. Importing the catalogue or pushing '
|
||||
'a report will appear here.',
|
||||
style: TextStyle(color: AppColors.textTertiary),
|
||||
),
|
||||
)
|
||||
: ResponsiveTable(
|
||||
columns: const [
|
||||
TableCol('Event', flex: 3),
|
||||
TableCol('Detail', flex: 5, priority: 1),
|
||||
TableCol('Time', flex: 2, numeric: true, priority: 1),
|
||||
TableCol('Status', flex: 2, numeric: true),
|
||||
],
|
||||
rows: events
|
||||
.map((e) => [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
e.type.isInbound
|
||||
? Icons.cloud_download_rounded
|
||||
: Icons.cloud_upload_rounded,
|
||||
size: 15,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Flexible(child: Cell(e.type.label, bold: true)),
|
||||
],
|
||||
),
|
||||
Cell(
|
||||
e.error ?? e.summary,
|
||||
color: e.error != null
|
||||
? AppColors.danger
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
Cell(Formatters.time(e.createdAt),
|
||||
color: AppColors.textTertiary),
|
||||
e.status == SyncStatus.failed
|
||||
? _RetryButton(eventId: e.id)
|
||||
: TagChip(
|
||||
e.status.label,
|
||||
color: statusColor(e.status),
|
||||
),
|
||||
])
|
||||
.toList(),
|
||||
),
|
||||
title: 'Orders',
|
||||
subtitle: '${rows.length} stored \u00b7 $pending awaiting upload',
|
||||
child: ResponsiveTable(
|
||||
columns: const [
|
||||
TableCol('Invoice', flex: 3),
|
||||
TableCol('Time', flex: 2, priority: 1),
|
||||
TableCol('Total', flex: 2, numeric: true),
|
||||
TableCol('Sync', flex: 2, numeric: true),
|
||||
],
|
||||
rows: rows
|
||||
.map((o) => [
|
||||
Cell(o.invoiceNumber, bold: true, mono: true),
|
||||
Cell(Formatters.time(o.createdAt),
|
||||
color: AppColors.textTertiary),
|
||||
Cell(Formatters.money(o.total), mono: true, bold: true),
|
||||
TagChip(
|
||||
o.isSynced ? 'Synced' : 'Pending',
|
||||
color:
|
||||
o.isSynced ? AppColors.success : AppColors.warning,
|
||||
),
|
||||
])
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
|
||||
if (events.isNotEmpty) ...[
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
PanelCard(
|
||||
title: 'Sync history',
|
||||
subtitle: 'This session',
|
||||
child: ResponsiveTable(
|
||||
columns: const [
|
||||
TableCol('Event', flex: 3),
|
||||
TableCol('Detail', flex: 5, priority: 1),
|
||||
TableCol('Time', flex: 2, numeric: true),
|
||||
],
|
||||
rows: events
|
||||
.map((e) => [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
e.type.isInbound
|
||||
? Icons.cloud_download_rounded
|
||||
: Icons.cloud_upload_rounded,
|
||||
size: 15,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Flexible(child: Cell(e.type.label, bold: true)),
|
||||
],
|
||||
),
|
||||
Cell(
|
||||
e.error ?? e.summary,
|
||||
color: e.error != null
|
||||
? AppColors.danger
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
Cell(Formatters.time(e.createdAt),
|
||||
color: AppColors.textTertiary),
|
||||
])
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _outcomeBanner(SyncOutcome outcome) {
|
||||
final ok = outcome.isSuccess;
|
||||
final uploaded = outcome.uploaded;
|
||||
final attempted = outcome.attempted;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: ok ? AppColors.successSurface : AppColors.dangerSurface,
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
ok ? Icons.check_circle_outline_rounded : Icons.wifi_off_rounded,
|
||||
size: 18,
|
||||
color: ok ? AppColors.success : AppColors.danger,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
ok
|
||||
? '$uploaded of $attempted bills uploaded and marked synced.'
|
||||
: '${outcome.error}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.45,
|
||||
color: ok ? AppColors.success : AppColors.danger,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Color _methodColor(PaymentMethod m) => switch (m) {
|
||||
PaymentMethod.cash => AppColors.success,
|
||||
PaymentMethod.card => AppColors.info,
|
||||
@@ -242,6 +291,7 @@ class EventsView extends ConsumerWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
color: AppColors.textSecondary,
|
||||
@@ -254,34 +304,9 @@ class EventsView extends ConsumerWidget {
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _RetryButton extends ConsumerWidget {
|
||||
const _RetryButton({required this.eventId});
|
||||
|
||||
final String eventId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final busy = ref.watch(reportPushProvider);
|
||||
|
||||
return TextButton.icon(
|
||||
onPressed: busy
|
||||
? null
|
||||
: () => ref.read(reportPushProvider.notifier).retry(eventId),
|
||||
icon: const Icon(Icons.refresh_rounded, size: 15),
|
||||
label: const Text('Retry', style: TextStyle(fontSize: 12.5)),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.danger,
|
||||
minimumSize: const Size(0, 30),
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../domain/entities/store_account.dart';
|
||||
import '../../../domain/entities/sync_event.dart';
|
||||
import '../../auth/providers/auth_controller.dart';
|
||||
import '../../sync/providers/sync_controller.dart';
|
||||
import '../widgets/module_widgets.dart';
|
||||
@@ -196,10 +195,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
Widget _connectivityCard() {
|
||||
final ready = ref.watch(catalogueReadyProvider);
|
||||
final lastImport = ref.watch(lastImportAtProvider);
|
||||
final outstanding = ref
|
||||
.watch(syncEventsProvider)
|
||||
.where((e) => e.status != SyncStatus.synced)
|
||||
.length;
|
||||
final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
|
||||
|
||||
return PanelCard(
|
||||
title: 'Connectivity & sync',
|
||||
@@ -213,7 +209,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
'Last import',
|
||||
lastImport == null ? 'Never' : Formatters.dateTime(lastImport),
|
||||
),
|
||||
_row('Outstanding pushes', '$outstanding'),
|
||||
_row('Unsynced bills', '$outstanding'),
|
||||
_toggle(
|
||||
'Simulate offline',
|
||||
'Forces import and push to fail, so you can confirm nothing is '
|
||||
@@ -222,7 +218,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
(v) {
|
||||
setState(() => _offline = v);
|
||||
ref.read(remoteCatalogueProvider).simulateOffline = v;
|
||||
ref.read(remoteReportSinkProvider).simulateOffline = v;
|
||||
ref.read(remoteOrderSinkProvider).simulateOffline = v;
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -237,7 +233,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
children: [
|
||||
_row('Application', '${AppConstants.appName} 1.0.0'),
|
||||
_row('Terminal', 'TERM-01'),
|
||||
_row('Data store', 'Local — offline first'),
|
||||
_row('Data store', 'SQLite (on device)'),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -256,20 +252,24 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 148,
|
||||
Flexible(
|
||||
flex: 3,
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Flexible(
|
||||
flex: 4,
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.right,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../../domain/entities/transaction.dart';
|
||||
import '../../../domain/usecases/checkout_sale.dart';
|
||||
import '../../pos/providers/cart_controller.dart';
|
||||
import '../../pos/providers/catalog_providers.dart';
|
||||
import '../../sync/providers/sync_controller.dart';
|
||||
|
||||
/// UI state for the payment screen.
|
||||
class PaymentState {
|
||||
@@ -161,9 +162,11 @@ class PaymentController extends StateNotifier<PaymentState> {
|
||||
unawaited(receipts.openCashDrawer());
|
||||
unawaited(_ref.read(soundServiceProvider).saleComplete());
|
||||
|
||||
// Stock changed, so the grid must refresh.
|
||||
// Stock changed, so the grid must refresh; the new order changes the
|
||||
// unsynced tally and today's totals.
|
||||
_ref.invalidate(allProductsProvider);
|
||||
_ref.invalidate(visibleProductsProvider);
|
||||
_ref.read(orderVersionProvider.notifier).state++;
|
||||
|
||||
return result;
|
||||
} on CheckoutFailure catch (e) {
|
||||
|
||||
@@ -7,18 +7,26 @@ import '../../../core/router/app_router.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../core/utils/extensions.dart';
|
||||
import '../../../core/utils/formatters.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/transaction.dart';
|
||||
import '../../customer/widgets/customer_capture_sheet.dart';
|
||||
import '../../pos/providers/cart_controller.dart';
|
||||
import '../providers/payment_controller.dart';
|
||||
|
||||
/// Tender capture and sale completion.
|
||||
///
|
||||
/// Customer identification happens here rather than at the start of the sale:
|
||||
/// a mobile number is all that is asked for, and the step can be skipped.
|
||||
class PaymentScreen extends ConsumerStatefulWidget {
|
||||
const PaymentScreen({super.key});
|
||||
|
||||
/// Below this the two columns stack into one scrolling page.
|
||||
static const double twoColumnAbove = 1080;
|
||||
|
||||
@override
|
||||
ConsumerState<PaymentScreen> createState() => _PaymentScreenState();
|
||||
}
|
||||
@@ -27,8 +35,9 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
String _cashBuffer = '';
|
||||
|
||||
void _syncCash() {
|
||||
final value = double.tryParse(_cashBuffer) ?? 0;
|
||||
ref.read(paymentControllerProvider.notifier).setCashTendered(value);
|
||||
ref
|
||||
.read(paymentControllerProvider.notifier)
|
||||
.setCashTendered(double.tryParse(_cashBuffer) ?? 0);
|
||||
}
|
||||
|
||||
void _appendCash(String d) {
|
||||
@@ -40,7 +49,8 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
|
||||
void _backspaceCash() {
|
||||
if (_cashBuffer.isEmpty) return;
|
||||
setState(() => _cashBuffer = _cashBuffer.substring(0, _cashBuffer.length - 1));
|
||||
setState(
|
||||
() => _cashBuffer = _cashBuffer.substring(0, _cashBuffer.length - 1));
|
||||
_syncCash();
|
||||
}
|
||||
|
||||
@@ -53,7 +63,6 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
final result = await ref.read(paymentControllerProvider.notifier).confirm();
|
||||
if (result == null || !mounted) return;
|
||||
|
||||
// Sale is banked — clear the terminal and show the receipt.
|
||||
ref.read(cartControllerProvider.notifier).reset();
|
||||
context.go(AppRoutes.receipt, extra: result.transaction);
|
||||
}
|
||||
@@ -71,221 +80,321 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
onPressed: () => context.pop(),
|
||||
tooltip: 'Back to bill',
|
||||
),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.xxl),
|
||||
child: context.isCompact
|
||||
? SingleChildScrollView(
|
||||
child: Column(children: [
|
||||
_amountCard(controller, state),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_methodsCard(controller, state),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_tenderCard(controller, state),
|
||||
]),
|
||||
)
|
||||
: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Column(children: [
|
||||
_amountCard(controller, state),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Expanded(child: _methodsCard(controller, state)),
|
||||
]),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.lg),
|
||||
Expanded(flex: 5, child: _tenderCard(controller, state)),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.xxl,
|
||||
0,
|
||||
AppSpacing.xxl,
|
||||
AppSpacing.xxl,
|
||||
),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
if (state.error != null) ...[
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
margin: const EdgeInsets.only(bottom: 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(state.error!,
|
||||
style: const TextStyle(color: AppColors.danger)),
|
||||
),
|
||||
]),
|
||||
).animate().shake(duration: 300.ms, hz: 3),
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final twoColumn = constraints.maxWidth >= PaymentScreen.twoColumnAbove;
|
||||
final pad =
|
||||
constraints.maxWidth < 700 ? AppSpacing.lg : AppSpacing.xxl;
|
||||
|
||||
final left = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_amountCard(controller, state, cart.grandTotal, cart.lineCount),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_customerCard(),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_methodsCard(controller, state),
|
||||
],
|
||||
PrimaryButton(
|
||||
label: 'Complete Sale',
|
||||
icon: Icons.check_circle_outline_rounded,
|
||||
large: true,
|
||||
tone: ButtonTone.success,
|
||||
busy: state.isProcessing,
|
||||
onPressed: cart.isEmpty ? null : _confirm,
|
||||
trailing: Text(
|
||||
Formatters.money(cart.grandTotal),
|
||||
style: AppTypography.money(21, color: Colors.white),
|
||||
);
|
||||
|
||||
final right = _tenderCard(controller, state);
|
||||
|
||||
if (!twoColumn) {
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.all(pad),
|
||||
child: Column(
|
||||
children: [left, const SizedBox(height: AppSpacing.md), right],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.all(pad),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(flex: 4, child: SingleChildScrollView(child: left)),
|
||||
const SizedBox(width: AppSpacing.lg),
|
||||
Expanded(flex: 5, child: SingleChildScrollView(child: right)),
|
||||
],
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
bottomNavigationBar: _bottomBar(cart.grandTotal, state, cart.isEmpty),
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- Sections
|
||||
Widget _amountCard(PaymentController controller, PaymentState state) {
|
||||
final cart = ref.watch(cartControllerProvider);
|
||||
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppSpacing.xxl),
|
||||
radius: AppRadius.xl,
|
||||
tinted: true,
|
||||
child: Column(children: [
|
||||
Text('Amount due', style: context.text.labelMedium),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
Formatters.money(controller.balanceDue),
|
||||
style: AppTypography.money(40, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
_mini('Items', '${cart.lineCount}'),
|
||||
_dot(),
|
||||
_mini('Bill total', Formatters.money(cart.grandTotal)),
|
||||
if (state.settled > 0) ...[
|
||||
_dot(),
|
||||
_mini('Settled', Formatters.money(state.settled)),
|
||||
],
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _methodsCard(PaymentController controller, PaymentState state) {
|
||||
// ------------------------------------------------------------ Amount due
|
||||
Widget _amountCard(
|
||||
PaymentController controller,
|
||||
PaymentState state,
|
||||
double total,
|
||||
int lineCount,
|
||||
) {
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||
radius: AppRadius.xl,
|
||||
tinted: true,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Amount due',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
// Scales instead of clipping when the amount runs long.
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
Formatters.money(controller.balanceDue),
|
||||
style: AppTypography.money(38, color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: AppSpacing.lg,
|
||||
runSpacing: AppSpacing.xs,
|
||||
children: [
|
||||
_mini('Items', '$lineCount'),
|
||||
_mini('Bill total', Formatters.money(total)),
|
||||
if (state.settled > 0)
|
||||
_mini('Settled', Formatters.money(state.settled)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- Customer
|
||||
Widget _customerCard() {
|
||||
final cart = ref.watch(cartControllerProvider);
|
||||
final customer = cart.customer;
|
||||
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
radius: AppRadius.xl,
|
||||
onTap: () => showCustomerCaptureSheet(context),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: customer == null
|
||||
? AppColors.surfaceAlt
|
||||
: AppColors.primarySurface,
|
||||
child: customer == null
|
||||
? const Icon(Icons.person_add_alt_1_outlined,
|
||||
size: 19, color: AppColors.textSecondary)
|
||||
: Text(
|
||||
Formatters.initials(customer.name),
|
||||
style: const TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
customer?.name ?? 'Walk-in customer',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (customer != null) ...[
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
StatusPill.tier(customer.tier, dense: true),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
customer == null
|
||||
? 'Add a mobile number for loyalty — or skip'
|
||||
: '${Formatters.mobile(customer.mobile)} · earns '
|
||||
'+${cart.pointsEarned} pts',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
TextButton(
|
||||
onPressed: () => showCustomerCaptureSheet(context),
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size(0, 38),
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||
),
|
||||
child: Text(customer == null ? 'Add' : 'Change'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- Methods
|
||||
Widget _methodsCard(PaymentController controller, PaymentState state) {
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
radius: AppRadius.xl,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Payment method', style: context.text.titleMedium),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Wrap(
|
||||
spacing: AppSpacing.md,
|
||||
runSpacing: AppSpacing.md,
|
||||
children: PaymentMethod.values
|
||||
.where((m) => m != PaymentMethod.loyalty)
|
||||
.map((m) => _MethodTile(
|
||||
method: m,
|
||||
selected: state.activeMethod == m,
|
||||
onTap: () {
|
||||
setState(() => _cashBuffer = '');
|
||||
controller.selectMethod(m);
|
||||
},
|
||||
))
|
||||
.toList(),
|
||||
const Text(
|
||||
'Payment method',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: [
|
||||
for (final m in PaymentMethod.values)
|
||||
if (m != PaymentMethod.loyalty)
|
||||
_MethodTile(
|
||||
method: m,
|
||||
selected: state.activeMethod == m,
|
||||
onTap: () {
|
||||
setState(() => _cashBuffer = '');
|
||||
controller.selectMethod(m);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (state.splits.isNotEmpty) ...[
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
const Divider(),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Row(children: [
|
||||
Text('Split tenders', style: context.text.titleSmall),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: controller.clearSplits,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.danger),
|
||||
child: const Text('Clear all'),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
...state.splits.asMap().entries.map((e) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
child: Row(children: [
|
||||
const Divider(height: AppSpacing.xxl),
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Split tenders',
|
||||
style:
|
||||
TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: controller.clearSplits,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.danger,
|
||||
minimumSize: const Size(0, 32),
|
||||
),
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final e in state.splits.asMap().entries)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(e.value.method.emoji,
|
||||
style: const TextStyle(fontSize: 16)),
|
||||
style: const TextStyle(fontSize: 15)),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(child: Text(e.value.method.label)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
e.value.method.label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 13.5),
|
||||
),
|
||||
),
|
||||
Text(Formatters.money(e.value.amount),
|
||||
style: AppTypography.money(14.5)),
|
||||
style: AppTypography.money(13.5)),
|
||||
IconButton(
|
||||
onPressed: () => controller.removeSplit(e.key),
|
||||
icon: const Icon(Icons.close_rounded, size: 17),
|
||||
icon: const Icon(Icons.close_rounded, size: 16),
|
||||
color: AppColors.textTertiary,
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: 30, minHeight: 30),
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: 'Remove tender',
|
||||
),
|
||||
]),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Tender
|
||||
Widget _tenderCard(PaymentController controller, PaymentState state) {
|
||||
final isCash = state.activeMethod.needsChange;
|
||||
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
radius: AppRadius.xl,
|
||||
child: isCash
|
||||
? _cashTender(controller, state)
|
||||
child: state.activeMethod.needsChange
|
||||
? _cashTender(controller)
|
||||
: _referenceTender(controller, state),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _cashTender(PaymentController controller, PaymentState state) {
|
||||
final change = controller.changeDue;
|
||||
|
||||
Widget _cashTender(PaymentController controller) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Cash received', style: context.text.titleMedium),
|
||||
const Text(
|
||||
'Cash received',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xl,
|
||||
vertical: AppSpacing.lg,
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceAlt,
|
||||
borderRadius: AppRadius.brLg,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Row(children: [
|
||||
const Text('₹',
|
||||
style: TextStyle(fontSize: 24, color: AppColors.textTertiary)),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_cashBuffer.isEmpty ? '0' : _cashBuffer,
|
||||
style: AppTypography.money(30),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('₹',
|
||||
style:
|
||||
TextStyle(fontSize: 22, color: AppColors.textTertiary)),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
_cashBuffer.isEmpty ? '0' : _cashBuffer,
|
||||
style: AppTypography.money(28),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
@@ -296,58 +405,17 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
label: const Text('Exact'),
|
||||
onPressed: () => _setCash(controller.balanceDue),
|
||||
),
|
||||
...[50, 100, 200, 500, 2000].map(
|
||||
(note) => ActionChip(
|
||||
for (final note in const [50, 100, 200, 500, 2000])
|
||||
ActionChip(
|
||||
label: Text('₹$note'),
|
||||
onPressed: () => _setCash(
|
||||
(double.tryParse(_cashBuffer) ?? 0) + note,
|
||||
),
|
||||
onPressed: () =>
|
||||
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
AnimatedContainer(
|
||||
duration: AppMotion.normal,
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: change > 0
|
||||
? AppColors.successSurface
|
||||
: AppColors.surfaceAlt,
|
||||
borderRadius: AppRadius.brMd,
|
||||
),
|
||||
child: Row(children: [
|
||||
Icon(
|
||||
change > 0
|
||||
? Icons.currency_exchange_rounded
|
||||
: Icons.info_outline_rounded,
|
||||
size: 19,
|
||||
color: change > 0 ? AppColors.success : AppColors.textTertiary,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Text(
|
||||
'Change to return',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: change > 0
|
||||
? AppColors.success
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
Formatters.money(change),
|
||||
style: AppTypography.money(
|
||||
22,
|
||||
color: change > 0 ? AppColors.success : AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_changeRow(controller.changeDue),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Center(
|
||||
child: NumericKeypad(
|
||||
allowDecimal: true,
|
||||
@@ -356,61 +424,100 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
onBackspace: _backspaceCash,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
OutlinedButton.icon(
|
||||
onPressed: controller.balanceDue > 0
|
||||
? () {
|
||||
controller.addSplit(
|
||||
amount: (double.tryParse(_cashBuffer) ?? 0)
|
||||
.clamp(0, controller.balanceDue)
|
||||
.toDouble(),
|
||||
);
|
||||
setState(() => _cashBuffer = '');
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.call_split_rounded, size: 17),
|
||||
label: const Text('Add as split payment'),
|
||||
),
|
||||
_splitButton(controller, amount: double.tryParse(_cashBuffer) ?? 0),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _changeRow(double change) {
|
||||
final active = change > 0;
|
||||
return AnimatedContainer(
|
||||
duration: AppMotion.normal,
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? AppColors.successSurface : AppColors.surfaceAlt,
|
||||
borderRadius: AppRadius.brMd,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
active
|
||||
? Icons.currency_exchange_rounded
|
||||
: Icons.info_outline_rounded,
|
||||
size: 18,
|
||||
color: active ? AppColors.success : AppColors.textTertiary,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Change to return',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: active ? AppColors.success : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(
|
||||
Formatters.money(change),
|
||||
style: AppTypography.money(
|
||||
20,
|
||||
color: active ? AppColors.success : AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _referenceTender(PaymentController controller, PaymentState state) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(children: [
|
||||
Text(state.activeMethod.emoji, style: const TextStyle(fontSize: 22)),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text('${state.activeMethod.label} payment',
|
||||
style: context.text.titleMedium),
|
||||
]),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
|
||||
Center(
|
||||
child: Column(children: [
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primarySurface,
|
||||
borderRadius: AppRadius.brXl,
|
||||
Row(
|
||||
children: [
|
||||
Text(state.activeMethod.emoji,
|
||||
style: const TextStyle(fontSize: 20)),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${state.activeMethod.label} payment',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style:
|
||||
const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(state.activeMethod.emoji,
|
||||
style: const TextStyle(fontSize: 52)),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Text(
|
||||
'Charge ${Formatters.money(controller.balanceDue)} '
|
||||
'on the ${state.activeMethod.label.toLowerCase()} terminal',
|
||||
textAlign: TextAlign.center,
|
||||
style: context.text.bodyMedium,
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 104,
|
||||
height: 104,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primarySurface,
|
||||
borderRadius: AppRadius.brXl,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(state.activeMethod.emoji,
|
||||
style: const TextStyle(fontSize: 46)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Text(
|
||||
'Charge ${Formatters.money(controller.balanceDue)} on the '
|
||||
'${state.activeMethod.label.toLowerCase()} terminal',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
if (state.activeMethod.needsReference)
|
||||
TextField(
|
||||
@@ -425,37 +532,103 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
prefixIcon: const Icon(Icons.tag_rounded),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.xxxl),
|
||||
OutlinedButton.icon(
|
||||
onPressed: controller.balanceDue > 0
|
||||
? () => controller.addSplit()
|
||||
: null,
|
||||
icon: const Icon(Icons.call_split_rounded, size: 17),
|
||||
label: const Text('Add as split payment'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_splitButton(controller),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _mini(String label, String value) => Column(children: [
|
||||
Text(label,
|
||||
Widget _splitButton(PaymentController controller, {double? amount}) {
|
||||
return OutlinedButton.icon(
|
||||
onPressed: controller.balanceDue > 0
|
||||
? () {
|
||||
controller.addSplit(
|
||||
amount: amount == null
|
||||
? null
|
||||
: amount.clamp(0, controller.balanceDue).toDouble(),
|
||||
);
|
||||
setState(() => _cashBuffer = '');
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.call_split_rounded, size: 17),
|
||||
label: const Text('Add as split payment'),
|
||||
style: OutlinedButton.styleFrom(minimumSize: const Size(0, 44)),
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Bottom bar
|
||||
Widget _bottomBar(double total, PaymentState state, bool cartEmpty) {
|
||||
return SafeArea(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.xxl,
|
||||
AppSpacing.md,
|
||||
AppSpacing.xxl,
|
||||
AppSpacing.lg,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border(top: BorderSide(color: AppColors.border)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (state.error != null)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
margin: const EdgeInsets.only(bottom: 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(
|
||||
state.error!,
|
||||
style: const TextStyle(
|
||||
color: AppColors.danger,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
).animate().shake(duration: 300.ms, hz: 3),
|
||||
PrimaryButton(
|
||||
label: 'Complete Sale',
|
||||
icon: Icons.check_circle_outline_rounded,
|
||||
large: true,
|
||||
tone: ButtonTone.success,
|
||||
busy: state.isProcessing,
|
||||
onPressed: cartEmpty ? null : _confirm,
|
||||
trailing: Text(
|
||||
Formatters.money(total),
|
||||
style: AppTypography.money(19, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _mini(String label, String value) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textSecondary,
|
||||
)),
|
||||
Text(value,
|
||||
style: AppTypography.money(14, weight: FontWeight.w600)),
|
||||
]);
|
||||
|
||||
Widget _dot() => Container(
|
||||
width: 3,
|
||||
height: 3,
|
||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.textTertiary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(value, style: AppTypography.money(13.5)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -480,10 +653,10 @@ class _MethodTile extends StatelessWidget {
|
||||
borderRadius: AppRadius.brMd,
|
||||
child: AnimatedContainer(
|
||||
duration: AppMotion.fast,
|
||||
width: 118,
|
||||
width: 104,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.lg,
|
||||
horizontal: AppSpacing.sm,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: AppRadius.brMd,
|
||||
@@ -491,19 +664,24 @@ class _MethodTile extends StatelessWidget {
|
||||
color: selected ? AppColors.primary : AppColors.border,
|
||||
),
|
||||
),
|
||||
child: Column(children: [
|
||||
Text(method.emoji, style: const TextStyle(fontSize: 24)),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
method.label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: selected ? Colors.white : AppColors.textPrimary,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(method.emoji, style: const TextStyle(fontSize: 22)),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
method.label,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: selected ? Colors.white : AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@ import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_layout.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../domain/entities/sync_event.dart';
|
||||
import '../../auth/providers/auth_controller.dart';
|
||||
import '../../sync/widgets/sign_out_dialog.dart';
|
||||
import '../providers/cart_controller.dart';
|
||||
@@ -232,10 +231,7 @@ class _Section extends ConsumerWidget {
|
||||
final active = ref.watch(activeModuleProvider);
|
||||
final cartCount = ref.watch(cartItemCountProvider);
|
||||
final ready = ref.watch(catalogueReadyProvider);
|
||||
final outstanding = ref
|
||||
.watch(syncEventsProvider)
|
||||
.where((e) => e.status != SyncStatus.synced)
|
||||
.length;
|
||||
final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -82,61 +82,104 @@ class _Header extends ConsumerWidget {
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.xl,
|
||||
AppSpacing.lg,
|
||||
AppSpacing.md,
|
||||
AppSpacing.lg,
|
||||
AppSpacing.sm,
|
||||
AppSpacing.md,
|
||||
),
|
||||
child: Row(children: [
|
||||
Text('Cart', style: context.text.headlineSmall),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
if (cart.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primarySurface,
|
||||
borderRadius: AppRadius.brPill,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
'${cart.lineCount}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 13,
|
||||
'Cart',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.text.titleLarge,
|
||||
),
|
||||
),
|
||||
if (cart.isNotEmpty) ...[
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primarySurface,
|
||||
borderRadius: AppRadius.brPill,
|
||||
),
|
||||
child: Text(
|
||||
'${cart.lineCount}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 12.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (controller.canUndo)
|
||||
IconButton(
|
||||
tooltip: 'Undo (F8)',
|
||||
onPressed: controller.undo,
|
||||
icon: const Icon(Icons.undo_rounded, size: 19),
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
if (cart.isNotEmpty) ...[
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
await controller.park();
|
||||
ref.invalidate(parkedBillsProvider);
|
||||
if (context.mounted) context.showSnack('Bill parked');
|
||||
},
|
||||
icon: const Icon(Icons.pause_circle_outline_rounded, size: 17),
|
||||
label: const Text('Park'),
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.warning),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: controller.clear,
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.danger),
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
// Icon-only actions: labelled buttons overflowed the 380px panel.
|
||||
if (controller.canUndo)
|
||||
_IconAction(
|
||||
icon: Icons.undo_rounded,
|
||||
tooltip: 'Undo (F8)',
|
||||
color: AppColors.textSecondary,
|
||||
onTap: controller.undo,
|
||||
),
|
||||
if (cart.isNotEmpty) ...[
|
||||
_IconAction(
|
||||
icon: Icons.pause_circle_outline_rounded,
|
||||
tooltip: 'Park bill',
|
||||
color: AppColors.warning,
|
||||
onTap: () async {
|
||||
await controller.park();
|
||||
ref.invalidate(parkedBillsProvider);
|
||||
if (context.mounted) context.showSnack('Bill parked');
|
||||
},
|
||||
),
|
||||
_IconAction(
|
||||
icon: Icons.delete_outline_rounded,
|
||||
tooltip: 'Clear bill',
|
||||
color: AppColors.danger,
|
||||
onTap: controller.clear,
|
||||
),
|
||||
],
|
||||
if (inSheet)
|
||||
_IconAction(
|
||||
icon: Icons.close_rounded,
|
||||
tooltip: 'Close',
|
||||
color: AppColors.textSecondary,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
if (inSheet)
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact square action used in the bill header.
|
||||
class _IconAction extends StatelessWidget {
|
||||
const _IconAction({
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: IconButton(
|
||||
onPressed: onTap,
|
||||
icon: Icon(icon, size: 19),
|
||||
color: color,
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -305,11 +348,16 @@ class _Row extends StatelessWidget {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
|
||||
child: Row(children: [
|
||||
Text(label,
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
)),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hint != null) ...[
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Text('($hint)',
|
||||
@@ -318,6 +366,7 @@ class _Row extends StatelessWidget {
|
||||
color: AppColors.textTertiary,
|
||||
)),
|
||||
],
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
const Spacer(),
|
||||
Text(
|
||||
value,
|
||||
|
||||
@@ -22,7 +22,11 @@ class CartFab extends ConsumerWidget {
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Material(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.sizeOf(context).width - AppSpacing.xxxl,
|
||||
),
|
||||
child: Material(
|
||||
color: AppColors.primary,
|
||||
borderRadius: AppRadius.brLg,
|
||||
elevation: 0,
|
||||
@@ -57,15 +61,18 @@ class CartFab extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
const Text(
|
||||
'View bill',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
const Flexible(
|
||||
child: Text(
|
||||
'View bill',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xl),
|
||||
const SizedBox(width: AppSpacing.lg),
|
||||
Text(
|
||||
Formatters.money(cart.grandTotal),
|
||||
style: AppTypography.money(19, color: Colors.white),
|
||||
@@ -77,6 +84,7 @@ class CartFab extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
).animate().fadeIn(duration: 180.ms).slideY(begin: 0.3, end: 0);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.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/status_pill.dart';
|
||||
import '../../customer/widgets/customer_capture_sheet.dart';
|
||||
import '../providers/cart_controller.dart';
|
||||
|
||||
/// Strip above the product grid showing who the sale belongs to.
|
||||
@@ -85,7 +83,7 @@ class CustomerBar extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.push(AppRoutes.existingCustomer),
|
||||
onPressed: () => showCustomerCaptureSheet(context),
|
||||
icon: const Icon(Icons.sync_alt_rounded, size: 17),
|
||||
label: Text(customer == null ? 'Add Customer' : 'Change'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
|
||||
@@ -137,12 +137,12 @@ class _DiscountSheetState extends State<_DiscountSheet> {
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: DiscountType.percentage,
|
||||
label: Text('Percentage'),
|
||||
label: Text('Percent'),
|
||||
icon: Icon(Icons.percent_rounded, size: 17),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: DiscountType.flat,
|
||||
label: Text('Flat amount'),
|
||||
label: Text('Flat'),
|
||||
icon: Icon(Icons.currency_rupee_rounded, size: 17),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'package:flutter/material.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/theme/app_layout.dart';
|
||||
@@ -29,6 +27,8 @@ class PageHeader extends ConsumerWidget {
|
||||
final module = ref.watch(activeModuleProvider);
|
||||
final now = ref.watch(clockProvider).value ?? DateTime.now();
|
||||
final compact = layout.sidebarIsDrawer;
|
||||
// Status chrome is the first thing dropped when width gets tight.
|
||||
final showStatus = MediaQuery.sizeOf(context).width >= 1180;
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
|
||||
@@ -75,7 +75,7 @@ class PageHeader extends ConsumerWidget {
|
||||
|
||||
const Spacer(),
|
||||
|
||||
if (!compact) ...[
|
||||
if (showStatus) ...[
|
||||
const _LivePill(),
|
||||
const SizedBox(width: AppSpacing.lg),
|
||||
Text(
|
||||
@@ -250,7 +250,7 @@ class _ParkedBillsButton extends ConsumerWidget {
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Parked bills'),
|
||||
content: SizedBox(
|
||||
width: 380,
|
||||
width: (MediaQuery.sizeOf(context).width - 96).clamp(280.0, 380.0),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: parked.length,
|
||||
@@ -297,7 +297,10 @@ class _NewSaleButton extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
void start() {
|
||||
ref.read(cartControllerProvider.notifier).reset();
|
||||
context.go(AppRoutes.welcome);
|
||||
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(const SnackBar(content: Text('Started a new sale.')));
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
|
||||
@@ -88,10 +88,13 @@ class _ProductCardState extends State<ProductCard> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs + 2),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// Scales down rather than overflowing on small tiles.
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
Formatters.money(p.price),
|
||||
style: AppTypography.money(17,
|
||||
@@ -112,8 +115,9 @@ class _ProductCardState extends State<ProductCard> {
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
|
||||
@@ -58,7 +58,7 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
||||
void _newSale() {
|
||||
_timer?.cancel();
|
||||
ref.read(cartControllerProvider.notifier).reset();
|
||||
if (mounted) context.go(AppRoutes.welcome);
|
||||
if (mounted) context.go(AppRoutes.pos);
|
||||
}
|
||||
|
||||
void _continueBilling() {
|
||||
|
||||
@@ -3,10 +3,28 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../app/providers.dart';
|
||||
import '../../../domain/entities/shift_report.dart';
|
||||
import '../../../domain/entities/sync_event.dart';
|
||||
import '../../../domain/repositories/sync_repository.dart';
|
||||
import '../../auth/providers/auth_controller.dart';
|
||||
import '../../pos/providers/catalog_providers.dart';
|
||||
|
||||
/// Progress of a catalogue pull.
|
||||
/// Bumped after every import so catalogue-backed providers refetch.
|
||||
final catalogueVersionProvider = StateProvider<int>((ref) => 0);
|
||||
|
||||
/// Bumped after every sale or sync so order-backed providers refetch.
|
||||
final orderVersionProvider = StateProvider<int>((ref) => 0);
|
||||
|
||||
/// Whether products exist on this terminal. Billing is gated on it.
|
||||
final catalogueReadyProvider = Provider<bool>((ref) {
|
||||
ref.watch(catalogueVersionProvider);
|
||||
return ref.watch(syncRepositoryProvider).hasCatalogue;
|
||||
});
|
||||
|
||||
final lastImportAtProvider = Provider<DateTime?>((ref) {
|
||||
ref.watch(catalogueVersionProvider);
|
||||
return ref.watch(syncRepositoryProvider).lastImportAt;
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- Morning: import
|
||||
sealed class ImportState {
|
||||
const ImportState();
|
||||
}
|
||||
@@ -34,20 +52,6 @@ class ImportFailed extends ImportState {
|
||||
final String message;
|
||||
}
|
||||
|
||||
/// Bumped after every successful import so catalogue providers refetch.
|
||||
final catalogueVersionProvider = StateProvider<int>((ref) => 0);
|
||||
|
||||
/// Whether the terminal has products to sell. The POS is gated on this.
|
||||
final catalogueReadyProvider = Provider<bool>((ref) {
|
||||
ref.watch(catalogueVersionProvider);
|
||||
return ref.watch(syncRepositoryProvider).hasCatalogue;
|
||||
});
|
||||
|
||||
final lastImportAtProvider = Provider<DateTime?>((ref) {
|
||||
ref.watch(catalogueVersionProvider);
|
||||
return ref.watch(syncRepositoryProvider).lastImportAt;
|
||||
});
|
||||
|
||||
class CatalogueImportController extends StateNotifier<ImportState> {
|
||||
CatalogueImportController(this._ref) : super(const ImportIdle());
|
||||
|
||||
@@ -55,7 +59,6 @@ class CatalogueImportController extends StateNotifier<ImportState> {
|
||||
|
||||
Future<bool> run() async {
|
||||
if (state is ImportRunning) return false;
|
||||
|
||||
state = const ImportRunning(0, 'Starting…');
|
||||
|
||||
final event = await _ref.read(syncRepositoryProvider).importCatalogue(
|
||||
@@ -65,13 +68,11 @@ class CatalogueImportController extends StateNotifier<ImportState> {
|
||||
);
|
||||
|
||||
if (event.status == SyncStatus.synced) {
|
||||
// Force every catalogue-backed provider to refetch.
|
||||
_ref.read(catalogueVersionProvider.notifier).state++;
|
||||
_ref.invalidate(allProductsProvider);
|
||||
_ref.invalidate(visibleProductsProvider);
|
||||
_ref.invalidate(categoryCountsProvider);
|
||||
_ref.invalidate(lowStockProductsProvider);
|
||||
|
||||
state = ImportDone(event);
|
||||
return true;
|
||||
}
|
||||
@@ -79,8 +80,6 @@ class CatalogueImportController extends StateNotifier<ImportState> {
|
||||
state = ImportFailed(event.error ?? 'Import failed.');
|
||||
return false;
|
||||
}
|
||||
|
||||
void reset() => state = const ImportIdle();
|
||||
}
|
||||
|
||||
final catalogueImportProvider =
|
||||
@@ -88,69 +87,89 @@ final catalogueImportProvider =
|
||||
(ref) => CatalogueImportController(ref),
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------------ Events
|
||||
/// Bumped whenever the event log changes.
|
||||
final syncVersionProvider = StateProvider<int>((ref) => 0);
|
||||
|
||||
final syncEventsProvider = Provider<List<SyncEvent>>((ref) {
|
||||
ref.watch(syncVersionProvider);
|
||||
ref.watch(catalogueVersionProvider);
|
||||
return ref.watch(syncRepositoryProvider).events;
|
||||
// ---------------------------------------------------- Business hours: read
|
||||
/// Bills still held on this terminal at sync_status = 0.
|
||||
final unsyncedCountProvider = FutureProvider<int>((ref) {
|
||||
ref.watch(orderVersionProvider);
|
||||
return ref.watch(syncRepositoryProvider).unsyncedCount();
|
||||
});
|
||||
|
||||
final hasUnsyncedProvider = Provider<bool>((ref) {
|
||||
ref.watch(syncVersionProvider);
|
||||
ref.watch(catalogueVersionProvider);
|
||||
return ref.watch(syncRepositoryProvider).hasUnsyncedEvents;
|
||||
});
|
||||
|
||||
/// Today's takings, recomputed from local sales on every change.
|
||||
final shiftReportProvider = Provider<ShiftReport>((ref) {
|
||||
ref.watch(syncVersionProvider);
|
||||
/// Today's totals, read back from SQLite.
|
||||
final todayReportProvider = FutureProvider<ShiftReport>((ref) {
|
||||
ref.watch(orderVersionProvider);
|
||||
final session = ref.watch(cashierSessionProvider);
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
return ref.watch(syncRepositoryProvider).buildShiftReport(
|
||||
businessDate: DateTime.now(),
|
||||
return ref.watch(syncRepositoryProvider).todayReport(
|
||||
terminalId: session.terminalId,
|
||||
cashierName: user?.name ?? session.name,
|
||||
);
|
||||
});
|
||||
|
||||
/// Drives the push button and the sign-out dialog.
|
||||
class ReportPushController extends StateNotifier<bool> {
|
||||
ReportPushController(this._ref) : super(false);
|
||||
/// Per-order sync state for the events log.
|
||||
final orderSyncRowsProvider = FutureProvider<List<OrderSyncRow>>((ref) {
|
||||
ref.watch(orderVersionProvider);
|
||||
return ref.watch(syncRepositoryProvider).orderSyncRows();
|
||||
});
|
||||
|
||||
final syncEventsProvider = Provider<List<SyncEvent>>((ref) {
|
||||
ref.watch(orderVersionProvider);
|
||||
ref.watch(catalogueVersionProvider);
|
||||
return ref.watch(syncRepositoryProvider).events;
|
||||
});
|
||||
|
||||
// ------------------------------------------------------ End of day: upload
|
||||
sealed class OrderSyncState {
|
||||
const OrderSyncState();
|
||||
}
|
||||
|
||||
class SyncIdle extends OrderSyncState {
|
||||
const SyncIdle();
|
||||
}
|
||||
|
||||
class SyncRunning extends OrderSyncState {
|
||||
const SyncRunning(this.progress, this.stage);
|
||||
|
||||
final double progress;
|
||||
final String stage;
|
||||
}
|
||||
|
||||
class SyncFinished extends OrderSyncState {
|
||||
const SyncFinished(this.outcome);
|
||||
|
||||
final SyncOutcome outcome;
|
||||
}
|
||||
|
||||
class OrderSyncController extends StateNotifier<OrderSyncState> {
|
||||
OrderSyncController(this._ref) : super(const SyncIdle());
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
/// Pushes today's report. Returns the resulting event so the caller can
|
||||
/// tell the cashier whether it landed.
|
||||
Future<SyncEvent> pushToday() async {
|
||||
state = true;
|
||||
try {
|
||||
final report = _ref.read(shiftReportProvider);
|
||||
final event =
|
||||
await _ref.read(syncRepositoryProvider).pushShiftReport(report);
|
||||
_ref.read(syncVersionProvider.notifier).state++;
|
||||
return event;
|
||||
} finally {
|
||||
if (mounted) state = false;
|
||||
bool get isRunning => state is SyncRunning;
|
||||
|
||||
/// Uploads every bill at sync_status = 0 and flips the accepted ones to 1.
|
||||
Future<SyncOutcome> run() async {
|
||||
if (isRunning) {
|
||||
return const SyncOutcome(attempted: 0, uploaded: 0);
|
||||
}
|
||||
|
||||
state = const SyncRunning(0, 'Starting…');
|
||||
|
||||
final outcome = await _ref.read(syncRepositoryProvider).syncOrders(
|
||||
onProgress: (progress, stage) {
|
||||
if (mounted) state = SyncRunning(progress, stage);
|
||||
},
|
||||
);
|
||||
|
||||
_ref.read(orderVersionProvider.notifier).state++;
|
||||
if (mounted) state = SyncFinished(outcome);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
Future<SyncEvent> retry(String eventId) async {
|
||||
state = true;
|
||||
try {
|
||||
final event = await _ref.read(syncRepositoryProvider).retry(eventId);
|
||||
_ref.read(syncVersionProvider.notifier).state++;
|
||||
return event;
|
||||
} finally {
|
||||
if (mounted) state = false;
|
||||
}
|
||||
}
|
||||
void reset() => state = const SyncIdle();
|
||||
}
|
||||
|
||||
final reportPushProvider =
|
||||
StateNotifierProvider<ReportPushController, bool>(
|
||||
(ref) => ReportPushController(ref),
|
||||
final orderSyncProvider =
|
||||
StateNotifierProvider<OrderSyncController, OrderSyncState>(
|
||||
(ref) => OrderSyncController(ref),
|
||||
);
|
||||
|
||||
@@ -7,9 +7,9 @@ import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../core/widgets/primary_button.dart';
|
||||
import '../../../domain/entities/sync_event.dart';
|
||||
import '../../auth/providers/auth_controller.dart';
|
||||
import '../../pos/providers/cart_controller.dart';
|
||||
import '../../../domain/repositories/sync_repository.dart';
|
||||
import '../providers/sync_controller.dart';
|
||||
|
||||
/// End-of-shift flow.
|
||||
@@ -33,7 +33,7 @@ class _SignOutDialog extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
||||
SyncEvent? _result;
|
||||
SyncOutcome? _result;
|
||||
|
||||
void _finish() {
|
||||
ref.read(cartControllerProvider.notifier).reset();
|
||||
@@ -43,12 +43,12 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
||||
}
|
||||
|
||||
Future<void> _pushThenFinish() async {
|
||||
final event = await ref.read(reportPushProvider.notifier).pushToday();
|
||||
final outcome = await ref.read(orderSyncProvider.notifier).run();
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() => _result = event);
|
||||
setState(() => _result = outcome);
|
||||
|
||||
if (event.status == SyncStatus.synced) {
|
||||
if (outcome.isSuccess) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 700));
|
||||
if (mounted) _finish();
|
||||
}
|
||||
@@ -56,10 +56,11 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final report = ref.watch(shiftReportProvider);
|
||||
final report = ref.watch(todayReportProvider).value;
|
||||
final pending = ref.watch(unsyncedCountProvider).value ?? 0;
|
||||
final cart = ref.watch(cartControllerProvider);
|
||||
final pushing = ref.watch(reportPushProvider);
|
||||
final failed = _result?.status == SyncStatus.failed;
|
||||
final pushing = ref.watch(orderSyncProvider) is SyncRunning;
|
||||
final failed = _result != null && !_result!.isSuccess;
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('End shift'),
|
||||
@@ -70,7 +71,8 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
||||
AppSpacing.sm,
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
// Never wider than the viewport allows.
|
||||
width: (MediaQuery.sizeOf(context).width - 96).clamp(280.0, 420.0),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -85,17 +87,17 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
||||
'and will be cleared. Park it first if you need it.',
|
||||
),
|
||||
|
||||
if (report.isEmpty)
|
||||
if (pending == 0)
|
||||
const _Banner(
|
||||
icon: Icons.info_outline_rounded,
|
||||
color: AppColors.textSecondary,
|
||||
background: AppColors.surfaceAlt,
|
||||
message: 'No sales were recorded today, so there is nothing '
|
||||
'to push.',
|
||||
icon: Icons.check_circle_outline_rounded,
|
||||
color: AppColors.success,
|
||||
background: AppColors.successSurface,
|
||||
message: 'Every bill has already been uploaded. Nothing is '
|
||||
'waiting on this terminal.',
|
||||
)
|
||||
else ...[
|
||||
const Text(
|
||||
"Today's takings",
|
||||
'Waiting to upload',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -103,12 +105,14 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_row('Bills', '${report.billCount}'),
|
||||
_row('Items sold', report.itemCount.toStringAsFixed(0)),
|
||||
_row('Gross sales', Formatters.money(report.grossSales)),
|
||||
_row('GST collected', Formatters.money(report.taxCollected)),
|
||||
_row('Average basket',
|
||||
Formatters.money(report.averageBasket)),
|
||||
_row('Bills pending sync', '$pending'),
|
||||
if (report != null) ...[
|
||||
_row('Bills today', '${report.billCount}'),
|
||||
_row('Items sold', report.itemCount.toStringAsFixed(0)),
|
||||
_row('Gross sales', Formatters.money(report.grossSales)),
|
||||
_row('GST collected',
|
||||
Formatters.money(report.taxCollected)),
|
||||
],
|
||||
],
|
||||
|
||||
if (failed) ...[
|
||||
@@ -118,7 +122,7 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
||||
color: AppColors.danger,
|
||||
background: AppColors.dangerSurface,
|
||||
message: _result?.error ??
|
||||
'The push failed. The report is still saved on this '
|
||||
'Upload failed. Every bill is still stored on this '
|
||||
'terminal and can be retried from Events.',
|
||||
),
|
||||
],
|
||||
@@ -149,14 +153,14 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
||||
foregroundColor: AppColors.textSecondary,
|
||||
),
|
||||
child: Text(
|
||||
report.isEmpty ? 'Sign out' : 'Sign out without pushing',
|
||||
pending == 0 ? 'Sign out' : 'Sign out without syncing',
|
||||
),
|
||||
),
|
||||
if (!report.isEmpty)
|
||||
if (pending > 0)
|
||||
SizedBox(
|
||||
width: 190,
|
||||
child: PrimaryButton(
|
||||
label: failed ? 'Retry push' : 'Push & sign out',
|
||||
label: failed ? 'Retry sync' : 'Sync & sign out',
|
||||
icon: Icons.cloud_upload_rounded,
|
||||
busy: pushing,
|
||||
onPressed: _pushThenFinish,
|
||||
|
||||
@@ -1,337 +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 '../../../app/providers.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/glass_card.dart';
|
||||
import '../../pos/providers/cart_controller.dart';
|
||||
import '../widgets/welcome_illustration.dart';
|
||||
|
||||
/// Screen 1 — the terminal's resting state between sales.
|
||||
class WelcomeScreen extends ConsumerWidget {
|
||||
const WelcomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final session = ref.watch(cashierSessionProvider);
|
||||
final clock = ref.watch(clockProvider).value ?? DateTime.now();
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.primaryGradient),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_TopBar(cashier: session.name, now: clock),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppSpacing.xxl),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 920),
|
||||
child: GlassCard(
|
||||
blur: 18,
|
||||
padding: EdgeInsets.all(
|
||||
context.responsive(
|
||||
compact: AppSpacing.xxl,
|
||||
expanded: AppSpacing.giant,
|
||||
),
|
||||
),
|
||||
radius: AppRadius.xxl,
|
||||
shadows: AppColors.shadowLg,
|
||||
child: context.isCompact
|
||||
? const _StackedLayout()
|
||||
: const _SideBySideLayout(),
|
||||
),
|
||||
),
|
||||
).animate().fadeIn(duration: 350.ms).slideY(
|
||||
begin: 0.04,
|
||||
end: 0,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
),
|
||||
),
|
||||
const _BottomHint(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SideBySideLayout extends StatelessWidget {
|
||||
const _SideBySideLayout();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: const [
|
||||
Expanded(flex: 4, child: WelcomeIllustration(size: 260)),
|
||||
SizedBox(width: AppSpacing.giant),
|
||||
Expanded(flex: 5, child: _WelcomeContent()),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StackedLayout extends StatelessWidget {
|
||||
const _StackedLayout();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
WelcomeIllustration(size: 150),
|
||||
SizedBox(height: AppSpacing.xxl),
|
||||
_WelcomeContent(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WelcomeContent extends ConsumerWidget {
|
||||
const _WelcomeContent();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Welcome to',
|
||||
style: context.text.titleMedium?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
letterSpacing: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
'Nearle POS',
|
||||
style: context.text.displaySmall?.copyWith(
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text(
|
||||
'Start a new sale by identifying the shopper, '
|
||||
'or skip straight to billing.',
|
||||
style: context.text.bodyMedium
|
||||
?.copyWith(color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxxl),
|
||||
|
||||
_WelcomeAction(
|
||||
icon: Icons.person_add_alt_1_rounded,
|
||||
title: 'New Customer',
|
||||
subtitle: 'Register and start earning loyalty points',
|
||||
onTap: () => context.push(AppRoutes.registerCustomer),
|
||||
primary: true,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_WelcomeAction(
|
||||
icon: Icons.badge_outlined,
|
||||
title: 'Existing Customer',
|
||||
subtitle: 'Look up by mobile number',
|
||||
onTap: () => context.push(AppRoutes.existingCustomer),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_WelcomeAction(
|
||||
icon: Icons.directions_walk_rounded,
|
||||
title: 'Skip Customer',
|
||||
subtitle: 'Walk-in sale, no loyalty tracking',
|
||||
onTap: () {
|
||||
ref.read(cartControllerProvider.notifier).reset();
|
||||
context.go(AppRoutes.pos);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A tall, unmistakable target — the cashier taps this hundreds of times a day.
|
||||
class _WelcomeAction extends StatefulWidget {
|
||||
const _WelcomeAction({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
this.primary = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
final bool primary;
|
||||
|
||||
@override
|
||||
State<_WelcomeAction> createState() => _WelcomeActionState();
|
||||
}
|
||||
|
||||
class _WelcomeActionState extends State<_WelcomeAction> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bg = widget.primary
|
||||
? AppColors.primary
|
||||
: (_hovered ? AppColors.primarySurface : AppColors.surface);
|
||||
final fg =
|
||||
widget.primary ? AppColors.textOnPrimary : AppColors.textPrimary;
|
||||
final sub = widget.primary
|
||||
? AppColors.textOnPrimary.withValues(alpha: 0.78)
|
||||
: AppColors.textSecondary;
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: AppMotion.fast,
|
||||
transform: Matrix4.translationValues(0, _hovered ? -2 : 0, 0),
|
||||
child: Material(
|
||||
color: bg,
|
||||
borderRadius: AppRadius.brLg,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
borderRadius: AppRadius.brLg,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xl,
|
||||
vertical: AppSpacing.lg,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: AppRadius.brLg,
|
||||
border: Border.all(
|
||||
color: widget.primary
|
||||
? Colors.transparent
|
||||
: AppColors.border,
|
||||
),
|
||||
boxShadow: widget.primary && _hovered
|
||||
? AppColors.shadowMd
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.primary
|
||||
? Colors.white.withValues(alpha: 0.18)
|
||||
: AppColors.primarySurface,
|
||||
borderRadius: AppRadius.brMd,
|
||||
),
|
||||
child: Icon(
|
||||
widget.icon,
|
||||
color: widget.primary
|
||||
? AppColors.textOnPrimary
|
||||
: AppColors.primary,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.lg),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
style: context.text.titleMedium?.copyWith(color: fg),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.subtitle,
|
||||
style: context.text.bodySmall?.copyWith(color: sub),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.arrow_forward_rounded, color: sub, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TopBar extends StatelessWidget {
|
||||
const _TopBar({required this.cashier, required this.now});
|
||||
|
||||
final String cashier;
|
||||
final DateTime now;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xxl,
|
||||
vertical: AppSpacing.lg,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.storefront_rounded,
|
||||
color: Colors.white, size: 26),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Text(
|
||||
AppConstants.storeName,
|
||||
style: context.text.titleLarge?.copyWith(color: Colors.white),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${Formatters.date(now)} ${Formatters.time(now)}',
|
||||
style: context.text.bodyMedium
|
||||
?.copyWith(color: Colors.white.withValues(alpha: 0.85)),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xxl),
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.2),
|
||||
child: Text(
|
||||
Formatters.initials(cashier),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(cashier,
|
||||
style: context.text.bodyMedium?.copyWith(color: Colors.white)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BottomHint extends StatelessWidget {
|
||||
const _BottomHint();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.xl),
|
||||
child: Text(
|
||||
'Scan a barcode at any time to begin a walk-in sale',
|
||||
style: context.text.bodySmall
|
||||
?.copyWith(color: Colors.white.withValues(alpha: 0.7)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
|
||||
/// Vector shopping-cart illustration drawn in code.
|
||||
///
|
||||
/// Painting it avoids shipping a raster asset and keeps it crisp on 4K desktop
|
||||
/// displays as well as tablet screens.
|
||||
class WelcomeIllustration extends StatelessWidget {
|
||||
const WelcomeIllustration({super.key, this.size = 240});
|
||||
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CustomPaint(painter: _CartPainter()),
|
||||
)
|
||||
.animate(onPlay: (c) => c.repeat(reverse: true))
|
||||
.moveY(begin: 0, end: -8, duration: 2400.ms, curve: Curves.easeInOut);
|
||||
}
|
||||
}
|
||||
|
||||
class _CartPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final w = size.width;
|
||||
final h = size.height;
|
||||
final unit = w / 100;
|
||||
|
||||
// Soft backdrop disc.
|
||||
canvas.drawCircle(
|
||||
Offset(w * 0.5, h * 0.5),
|
||||
w * 0.46,
|
||||
Paint()..color = AppColors.primarySurface,
|
||||
);
|
||||
|
||||
// Decorative arc.
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: Offset(w * 0.5, h * 0.5), radius: w * 0.46),
|
||||
-1.1,
|
||||
2.0,
|
||||
false,
|
||||
Paint()
|
||||
..color = AppColors.primaryBorder
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = unit * 1.6
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
|
||||
final stroke = Paint()
|
||||
..color = AppColors.primary
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = unit * 3
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round;
|
||||
|
||||
final fill = Paint()..color = AppColors.primary.withValues(alpha: 0.16);
|
||||
|
||||
// Cart basket.
|
||||
final basket = Path()
|
||||
..moveTo(w * 0.30, h * 0.36)
|
||||
..lineTo(w * 0.78, h * 0.36)
|
||||
..lineTo(w * 0.70, h * 0.60)
|
||||
..lineTo(w * 0.37, h * 0.60)
|
||||
..close();
|
||||
canvas.drawPath(basket, fill);
|
||||
canvas.drawPath(basket, stroke);
|
||||
|
||||
// Handle running to the push bar.
|
||||
canvas.drawPath(
|
||||
Path()
|
||||
..moveTo(w * 0.16, h * 0.26)
|
||||
..lineTo(w * 0.24, h * 0.26)
|
||||
..lineTo(w * 0.30, h * 0.36),
|
||||
stroke,
|
||||
);
|
||||
|
||||
// Basket ribs.
|
||||
for (var i = 1; i <= 2; i++) {
|
||||
final t = i / 3;
|
||||
canvas.drawLine(
|
||||
Offset(w * (0.30 + 0.48 * t), h * 0.36),
|
||||
Offset(w * (0.37 + 0.33 * t), h * 0.60),
|
||||
stroke..strokeWidth = unit * 1.6,
|
||||
);
|
||||
}
|
||||
stroke.strokeWidth = unit * 3;
|
||||
|
||||
// Wheels.
|
||||
for (final dx in [0.44, 0.66]) {
|
||||
canvas.drawCircle(
|
||||
Offset(w * dx, h * 0.70),
|
||||
unit * 5,
|
||||
Paint()..color = AppColors.surface,
|
||||
);
|
||||
canvas.drawCircle(Offset(w * dx, h * 0.70), unit * 5, stroke);
|
||||
}
|
||||
|
||||
// Groceries poking out of the basket.
|
||||
_item(canvas, Offset(w * 0.42, h * 0.30), unit * 5.5,
|
||||
AppColors.tierGold.withValues(alpha: 0.9));
|
||||
_item(canvas, Offset(w * 0.55, h * 0.27), unit * 6.5,
|
||||
AppColors.success.withValues(alpha: 0.85));
|
||||
_item(canvas, Offset(w * 0.67, h * 0.31), unit * 5,
|
||||
AppColors.danger.withValues(alpha: 0.75));
|
||||
|
||||
// Receipt tape drifting away from the terminal.
|
||||
final receipt = Path()
|
||||
..moveTo(w * 0.80, h * 0.20)
|
||||
..lineTo(w * 0.94, h * 0.20)
|
||||
..lineTo(w * 0.94, h * 0.44)
|
||||
..lineTo(w * 0.905, h * 0.40)
|
||||
..lineTo(w * 0.87, h * 0.44)
|
||||
..lineTo(w * 0.835, h * 0.40)
|
||||
..lineTo(w * 0.80, h * 0.44)
|
||||
..close();
|
||||
canvas.drawPath(receipt, Paint()..color = AppColors.surface);
|
||||
canvas.drawPath(
|
||||
receipt,
|
||||
Paint()
|
||||
..color = AppColors.primaryLight
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = unit * 1.4
|
||||
..strokeJoin = StrokeJoin.round,
|
||||
);
|
||||
|
||||
// Receipt lines.
|
||||
final line = Paint()
|
||||
..color = AppColors.primaryBorder
|
||||
..strokeWidth = unit * 1.2
|
||||
..strokeCap = StrokeCap.round;
|
||||
for (var i = 0; i < 3; i++) {
|
||||
final y = h * (0.25 + i * 0.05);
|
||||
canvas.drawLine(Offset(w * 0.835, y), Offset(w * 0.905, y), line);
|
||||
}
|
||||
}
|
||||
|
||||
void _item(Canvas canvas, Offset center, double radius, Color color) {
|
||||
canvas.drawCircle(center, radius, Paint()..color = color);
|
||||
canvas.drawCircle(
|
||||
center,
|
||||
radius,
|
||||
Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.5)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
Reference in New Issue
Block a user