added local db

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

View File

@@ -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

View File

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

View File

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

View File

@@ -0,0 +1,561 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/widgets/numeric_keypad.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../core/widgets/status_pill.dart';
import '../../../domain/entities/customer.dart';
import '../../pos/providers/cart_controller.dart';
import '../providers/customer_providers.dart';
/// Attaches a customer to the current bill using nothing but a mobile number.
///
/// Registration is deliberately minimal: an unknown number can be saved with
/// just a name, or the whole step skipped. Nothing here blocks the sale.
Future<void> showCustomerCaptureSheet(BuildContext context) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const _CustomerCaptureSheet(),
);
}
class _CustomerCaptureSheet extends ConsumerStatefulWidget {
const _CustomerCaptureSheet();
@override
ConsumerState<_CustomerCaptureSheet> createState() =>
_CustomerCaptureSheetState();
}
class _CustomerCaptureSheetState
extends ConsumerState<_CustomerCaptureSheet> {
String _digits = '';
final _name = TextEditingController();
bool _saving = false;
String? _error;
@override
void dispose() {
_name.dispose();
super.dispose();
}
void _append(String d) {
if (_digits.length >= AppConstants.mobileNumberLength) return;
setState(() {
_digits += d;
_error = null;
});
if (_digits.length == AppConstants.mobileNumberLength) {
ref.read(customerLookupProvider.notifier).search(_digits);
}
}
void _backspace() {
if (_digits.isEmpty) return;
setState(() => _digits = _digits.substring(0, _digits.length - 1));
ref.read(customerLookupProvider.notifier).reset();
}
void _clear() {
setState(() {
_digits = '';
_error = null;
});
ref.read(customerLookupProvider.notifier).reset();
}
void _attachAndClose(Customer? customer) {
ref.read(cartControllerProvider.notifier).attachCustomer(customer);
Navigator.of(context).pop();
}
Future<void> _quickRegister() async {
final name = _name.text.trim();
if (name.length < 2) {
setState(() => _error = 'Enter a name to save this customer');
return;
}
setState(() {
_saving = true;
_error = null;
});
try {
final created = await ref.read(customerRepositoryProvider).create(
Customer(id: '', name: name, mobile: _digits),
);
ref.invalidate(recentCustomersProvider);
if (mounted) _attachAndClose(created);
} catch (e) {
if (!mounted) return;
setState(() {
_saving = false;
_error = e is StateError ? e.message : 'Could not save customer.';
});
}
}
@override
Widget build(BuildContext context) {
final lookup = ref.watch(customerLookupProvider);
final attached = ref.watch(
cartControllerProvider.select((c) => c.customer),
);
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.9,
),
decoration: const BoxDecoration(
color: AppColors.surface,
borderRadius:
BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_grabber(),
_header(attached),
const Divider(height: 1),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.xxl),
child: LayoutBuilder(
builder: (context, constraints) {
// Side by side once there is room for both columns.
final wide = constraints.maxWidth >= 720;
final entry = _entryColumn();
final result = _resultColumn(lookup);
if (!wide) {
return Column(
children: [
entry,
const SizedBox(height: AppSpacing.xl),
result,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: entry),
const SizedBox(width: AppSpacing.xxl),
Expanded(child: result),
],
);
},
),
),
),
],
),
),
),
);
}
Widget _grabber() => Container(
width: 40,
height: 4,
margin: const EdgeInsets.symmetric(vertical: AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.border,
borderRadius: AppRadius.brPill,
),
);
Widget _header(Customer? attached) => Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xxl,
0,
AppSpacing.md,
AppSpacing.lg,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
attached == null ? 'Add customer' : 'Change customer',
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge,
),
const Text(
'Optional — for loyalty points and tier discounts',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
),
),
],
),
),
const SizedBox(width: AppSpacing.sm),
TextButton(
onPressed: () => _attachAndClose(null),
style: TextButton.styleFrom(
foregroundColor: AppColors.textSecondary,
),
child: const Text('Skip'),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close_rounded),
tooltip: 'Close',
),
],
),
);
Widget _entryColumn() => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_display(),
const SizedBox(height: AppSpacing.xl),
Center(
child: NumericKeypad(
maxWidth: 340,
onKey: _append,
onBackspace: _backspace,
onClear: _clear,
),
),
],
);
Widget _display() => Container(
height: 68,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brLg,
border: Border.all(color: AppColors.primaryBorder),
),
child: Row(
children: [
const Text(
'+91',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
const SizedBox(width: AppSpacing.md),
// FittedBox guarantees ten digits fit at any sheet width.
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
_digits.isEmpty
? ' '
: _digits.split('').join(' '),
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: 1,
color: _digits.isEmpty
? AppColors.textTertiary
: AppColors.textPrimary,
),
),
),
),
if (_digits.isNotEmpty)
IconButton(
onPressed: _clear,
icon: const Icon(Icons.close_rounded, size: 20),
color: AppColors.textTertiary,
tooltip: 'Clear',
),
],
),
);
Widget _resultColumn(CustomerLookupState state) => switch (state) {
LookupIdle() => _idle(),
LookupSearching() => const Padding(
padding: EdgeInsets.symmetric(vertical: AppSpacing.giant),
child: Center(
child: CircularProgressIndicator(color: AppColors.primary),
),
),
LookupFound(:final customer) => _found(customer),
LookupNotFound() => _notFound(),
LookupError(:final message) => _message(
Icons.error_outline_rounded,
AppColors.danger,
message,
),
};
Widget _idle() {
final recent = ref.watch(recentCustomersProvider).value ?? const [];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_message(
Icons.dialpad_rounded,
AppColors.textTertiary,
'Key in a 10-digit mobile number. The lookup runs automatically.',
),
if (recent.isNotEmpty) ...[
const SizedBox(height: AppSpacing.xl),
const Align(
alignment: Alignment.centerLeft,
child: Text(
'Recent',
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
),
const SizedBox(height: AppSpacing.sm),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
for (final c in recent.take(4))
ActionChip(
avatar: CircleAvatar(
radius: 11,
backgroundColor: AppColors.primarySurface,
child: Text(
Formatters.initials(c.name),
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
),
label: Text(
c.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12.5),
),
onPressed: () => _attachAndClose(c),
),
],
),
],
],
);
}
Widget _found(Customer c) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.successSurface,
borderRadius: AppRadius.brLg,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
CircleAvatar(
radius: 22,
backgroundColor: AppColors.surface,
child: Text(
Formatters.initials(c.name),
style: const TextStyle(
color: AppColors.primary,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
c.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
Text(
Formatters.mobile(c.mobile),
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
),
),
],
),
),
const SizedBox(width: AppSpacing.sm),
StatusPill.tier(c.tier, dense: true),
],
),
const SizedBox(height: AppSpacing.md),
Row(
children: [
Expanded(
child: _miniStat(
'${c.loyaltyPoints}', 'points held'),
),
Expanded(
child: _miniStat(
Formatters.money(c.redeemableValue),
'redeemable',
),
),
if (c.tier.discountRate > 0)
Expanded(
child: _miniStat(
Formatters.percent(c.tier.discountRate),
'auto discount',
),
),
],
),
],
),
),
const SizedBox(height: AppSpacing.lg),
PrimaryButton(
label: 'Use this customer',
icon: Icons.check_rounded,
large: true,
onPressed: () => _attachAndClose(c),
),
],
);
Widget _notFound() => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_message(
Icons.person_search_rounded,
AppColors.warning,
'Not registered yet. Add a name to save them, or carry on '
'without.',
),
const SizedBox(height: AppSpacing.lg),
TextField(
controller: _name,
textCapitalization: TextCapitalization.words,
enabled: !_saving,
onSubmitted: (_) => _quickRegister(),
inputFormatters: [LengthLimitingTextInputFormatter(60)],
decoration: const InputDecoration(
labelText: 'Customer name',
hintText: 'Full name',
prefixIcon: Icon(Icons.person_outline_rounded),
),
),
if (_error != null) ...[
const SizedBox(height: AppSpacing.sm),
Text(
_error!,
style: const TextStyle(color: AppColors.danger, fontSize: 12.5),
),
],
const SizedBox(height: AppSpacing.lg),
PrimaryButton(
label: 'Save & use',
icon: Icons.person_add_alt_1_rounded,
large: true,
busy: _saving,
onPressed: _quickRegister,
),
const SizedBox(height: AppSpacing.sm),
PrimaryButton(
label: 'Continue without customer',
tone: ButtonTone.neutral,
onPressed: _saving ? null : () => _attachAndClose(null),
),
],
);
Widget _miniStat(String value, String label) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
value,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.success,
),
),
),
Text(
label,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
),
),
],
);
Widget _message(IconData icon, Color color, String text) => Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: AppRadius.brMd,
border: Border.all(color: AppColors.border),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 19, color: color),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Text(
text,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
height: 1.5,
),
),
),
],
),
);
}

View File

@@ -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),
),
);
}
}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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,
),
),
),
]),
],
),
),
),
);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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(

View File

@@ -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),
),
],

View File

@@ -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) {

View File

@@ -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(

View File

@@ -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() {

View File

@@ -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),
);

View File

@@ -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,

View File

@@ -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)),
),
);
}
}

View File

@@ -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;
}