added product import and billing integration

This commit is contained in:
2026-08-05 18:15:21 +05:30
parent 33b4337933
commit 6c0266c9c7
32 changed files with 889 additions and 457 deletions

View File

@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/numeric_keypad.dart';
import '../../../domain/entities/store_account.dart';
/// Prompts for an admin PIN before a theft-sensitive cart action — taking a
/// scanned item back out of the bill, or clearing it — and resolves `true`
/// only once a PIN belonging to an [StaffRole.admin] account is verified.
///
/// A cashier can always start a brand new sale; what this exists to stop is
/// quietly taking something back out of a bill a customer has already been
/// shown, after it was rung up.
Future<bool> requireAdminPin(
BuildContext context,
WidgetRef ref, {
required String reason,
}) async {
final ok = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (_) => _AdminPinDialog(reason: reason),
);
return ok ?? false;
}
class _AdminPinDialog extends ConsumerStatefulWidget {
const _AdminPinDialog({required this.reason});
final String reason;
@override
ConsumerState<_AdminPinDialog> createState() => _AdminPinDialogState();
}
class _AdminPinDialogState extends ConsumerState<_AdminPinDialog> {
String _pin = '';
String? _error;
bool _checking = false;
void _key(String digit) {
if (_checking || _pin.length >= 8) return;
setState(() {
_pin += digit;
_error = null;
});
}
void _backspace() {
if (_checking || _pin.isEmpty) return;
setState(() => _pin = _pin.substring(0, _pin.length - 1));
}
void _clear() {
if (_checking) return;
setState(() => _pin = '');
}
Future<void> _submit() async {
if (_pin.isEmpty || _checking) return;
setState(() {
_checking = true;
_error = null;
});
final store = ref.read(localStoreProvider);
final user = await store.staff.authenticate(_pin);
if (!mounted) return;
if (user == null) {
setState(() {
_checking = false;
_error = 'Incorrect PIN.';
_pin = '';
});
return;
}
if (user.role != StaffRole.admin) {
setState(() {
_checking = false;
_error = "${user.name}'s PIN is ${user.role.label.toLowerCase()}"
'this needs an admin.';
_pin = '';
});
return;
}
Navigator.of(context).pop(true);
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: const RoundedRectangleBorder(borderRadius: AppRadius.brLg),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.xl),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(Icons.lock_outline_rounded,
color: AppColors.danger, size: 20,),
const SizedBox(width: AppSpacing.sm),
const Expanded(
child: Text(
'Admin PIN required',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
InkWell(
onTap: () => Navigator.of(context).pop(false),
borderRadius: AppRadius.brSm,
child: const Padding(
padding: EdgeInsets.all(4),
child: Icon(Icons.close_rounded,
size: 20, color: AppColors.textSecondary,),
),
),
],
),
const SizedBox(height: AppSpacing.xs),
Text(
widget.reason,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
height: 1.4,
),
),
const SizedBox(height: AppSpacing.lg),
SizedBox(
height: 20,
child: _pin.isEmpty
? const Center(
child: Text(
'Enter PIN',
style: TextStyle(
fontSize: 13,
color: AppColors.textTertiary,
),
),
)
: Wrap(
alignment: WrapAlignment.center,
spacing: 10,
children: [
for (var i = 0; i < _pin.length; i++)
Container(
width: 12,
height: 12,
decoration: const BoxDecoration(
color: AppColors.primary,
shape: BoxShape.circle,
),
),
],
),
),
if (_error != null) ...[
const SizedBox(height: AppSpacing.sm),
Text(
_error!,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.danger,
fontWeight: FontWeight.w600,
),
),
],
const SizedBox(height: AppSpacing.lg),
NumericKeypad(
onKey: _key,
onBackspace: _backspace,
onClear: _clear,
onSubmit: _checking ? null : _submit,
submitLabel: _checking ? 'Checking…' : 'Approve',
),
],
),
),
),
);
}
}

View File

@@ -7,6 +7,7 @@ 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/theme/app_layout.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/extensions.dart';
import '../../../core/utils/formatters.dart';
@@ -15,6 +16,7 @@ import '../../../core/widgets/primary_button.dart';
import '../../../domain/entities/cart.dart';
import '../../customer/widgets/customer_capture_sheet.dart';
import '../providers/cart_controller.dart';
import 'admin_pin_dialog.dart';
import 'cart_line_tile.dart';
import 'discount_sheet.dart';
@@ -58,7 +60,12 @@ class BillingPanel extends ConsumerWidget {
line: line,
onIncrement: () => controller.increment(line.product.id),
onDecrement: () => controller.decrement(line.product.id),
onRemove: () => controller.removeLine(line.product.id),
onRemove: () => _removeLine(
context,
ref,
controller,
line.product.id,
),
onDiscount: () =>
showLineDiscountSheet(context, ref, line),
);
@@ -72,6 +79,37 @@ class BillingPanel extends ConsumerWidget {
}
}
/// Once an item is on the bill, taking it back off needs an admin's
/// approval — a cashier can always start an entirely new sale instead. This
/// is the one gate both removal paths (a single line, or the whole cart) go
/// through, so they can never drift out of sync with each other.
Future<void> _removeLine(
BuildContext context,
WidgetRef ref,
CartController controller,
String productId,
) async {
final ok = await requireAdminPin(
context,
ref,
reason: 'Removing a scanned item from the bill needs admin approval.',
);
if (ok) controller.removeLine(productId);
}
Future<void> _clearCart(
BuildContext context,
WidgetRef ref,
CartController controller,
) async {
final ok = await requireAdminPin(
context,
ref,
reason: 'Clearing the whole bill needs admin approval.',
);
if (ok) controller.clear();
}
class _Header extends ConsumerWidget {
const _Header({required this.cart, required this.inSheet});
@@ -81,15 +119,15 @@ class _Header extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.read(cartControllerProvider.notifier);
// Same fixed height as the page header on the left, so the two bars
// line up on one visual line instead of the cart title floating lower.
final contentPadding = PosLayout.of(context).contentPadding;
return Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.lg,
AppSpacing.md,
AppSpacing.sm,
AppSpacing.md,
),
return Container(
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
padding: EdgeInsets.symmetric(horizontal: contentPadding),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
@@ -99,9 +137,9 @@ class _Header extends ConsumerWidget {
),
),
if (cart.isNotEmpty) ...[
const SizedBox(width: AppSpacing.sm),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: const BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brPill,
@@ -116,40 +154,52 @@ class _Header extends ConsumerWidget {
),
),
],
const Spacer(),
SizedBox(),
SizedBox(),
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(),
),
// Grouped tight with even spacing, flush against the same right
// edge the header buttons on the left use.
Row(
mainAxisSize: MainAxisSize.min,
children: [
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: () => _clearCart(context, ref, controller),
),
],
if (inSheet)
_IconAction(
icon: Icons.close_rounded,
tooltip: 'Close',
color: AppColors.textSecondary,
onTap: () => Navigator.of(context).pop(),
),
],
),
],
),
);
@@ -360,7 +410,9 @@ class _Row extends StatelessWidget {
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
child: Row(children: [
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
label,
@@ -390,7 +442,7 @@ class _Row extends StatelessWidget {
),
),
if (trailingIcon != null) ...[
const SizedBox(width: AppSpacing.xs),
Icon(trailingIcon, size: 14, color: AppColors.textTertiary),
],
],),

View File

@@ -1,110 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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.
class CustomerBar extends ConsumerWidget {
const CustomerBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final customer = ref.watch(
cartControllerProvider.select((cart) => cart.customer),
);
return Container(
// A hard height clipped the subtitle once it wrapped. Minimum height
// keeps the strip its usual size but lets it grow if it must.
constraints: const BoxConstraints(
minHeight: AppSizes.customerBarHeight,
),
color: AppColors.surface,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xl,
vertical: AppSpacing.sm,
),
child: Row(children: [
CircleAvatar(
radius: 20,
backgroundColor: customer == null
? AppColors.border
: AppColors.primarySurface,
child: customer == null
? const Icon(Icons.directions_walk_rounded,
size: 20, color: AppColors.textSecondary,)
: Text(
Formatters.initials(customer.name),
style: const TextStyle(
color: AppColors.primary,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: AppSpacing.md),
Flexible(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(mainAxisSize: MainAxisSize.min, children: [
Flexible(
child: Text(
customer?.name ?? 'Walk-in Customer',
style: context.text.titleMedium,
overflow: TextOverflow.ellipsis,
),
),
if (customer != null) ...[
const SizedBox(width: AppSpacing.sm),
StatusPill.tier(customer.tier, dense: true),
],
],),
if (customer != null)
Text(
'${Formatters.mobile(customer.mobile)} · '
'${customer.loyaltyPoints} pts',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.text.bodySmall,
)
else
Text(
'No loyalty tracking for this sale',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.text.bodySmall,
),
],
),
),
const Spacer(),
if (customer != null)
TextButton.icon(
onPressed: () =>
ref.read(cartControllerProvider.notifier).attachCustomer(null),
icon: const Icon(Icons.person_off_outlined, size: 17),
label: const Text('Detach'),
style: TextButton.styleFrom(
foregroundColor: AppColors.textSecondary,),
),
const SizedBox(width: AppSpacing.sm),
OutlinedButton.icon(
onPressed: () => showCustomerCaptureSheet(context),
icon: const Icon(Icons.sync_alt_rounded, size: 17),
label: Text(customer == null ? 'Add Customer' : 'Change'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 44),
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
),
),
],),
);
}
}

View File

@@ -68,28 +68,7 @@ class PageHeader extends ConsumerWidget {
const SizedBox(width: AppSpacing.xs),
],
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
module.title,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 19,
fontWeight: FontWeight.w600,
letterSpacing: -0.4,
color: AppColors.textPrimary,
height: 1.2,
),
),
if (!compact) _Breadcrumb(module: module),
],
),
),
const Spacer(),
if (showStatus) ...[
_LivePill(offline: ref.watch(simulateOfflineProvider)),
@@ -117,42 +96,6 @@ class PageHeader extends ConsumerWidget {
}
}
class _Breadcrumb extends StatelessWidget {
const _Breadcrumb({required this.module});
final PosModule module;
@override
Widget build(BuildContext context) {
const style = TextStyle(fontSize: 12, color: AppColors.textTertiary);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Home', style: style),
const Padding(
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs + 2),
child: Icon(Icons.chevron_right_rounded,
size: 13, color: AppColors.textTertiary,),
),
Text(module.section.label, style: style),
const Padding(
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs + 2),
child: Icon(Icons.chevron_right_rounded,
size: 13, color: AppColors.textTertiary,),
),
Text(
module.label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
],
);
}
}
/// What the pill is saying, in the order it takes precedence.
enum _Liveness { offlineSim, halted, syncing, queued, live }
@@ -342,11 +285,24 @@ class _ParkedBillsButton extends ConsumerWidget {
'${Formatters.time(bill.parkedAt)}',
),
onTap: () async {
final hadItems =
ref.read(cartControllerProvider).isNotEmpty;
await ref
.read(cartControllerProvider.notifier)
.resume(bill);
ref.invalidate(parkedBillsProvider);
if (context.mounted) Navigator.of(context).pop();
if (!context.mounted) return;
Navigator.of(context).pop();
if (hadItems) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(const SnackBar(
content: Text(
'The cart you were on was saved back to Parked '
'bills.',
),
),);
}
},
);
},
@@ -370,12 +326,22 @@ class _NewSaleButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
void start() {
ref.read(cartControllerProvider.notifier).reset();
Future<void> start() async {
final hadItems = ref.read(cartControllerProvider).isNotEmpty;
await ref.read(cartControllerProvider.notifier).startNewSale();
if (hadItems) ref.invalidate(parkedBillsProvider);
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
if (!context.mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(const SnackBar(content: Text('Started a new sale.')));
..showSnackBar(SnackBar(
content: Text(
hadItems
? 'Previous cart saved to Parked bills. Started a new sale.'
: 'Started a new sale.',
),
),);
}
if (compact) {

View File

@@ -84,9 +84,10 @@ class _ProductCardState extends State<ProductCard> {
children: [
Opacity(
opacity: disabled ? 0.4 : 1,
child: Text(
p.emoji,
style: TextStyle(fontSize: emoji),
child: _ProductVisual(
imageUrl: p.imageUrl,
emoji: p.emoji,
size: emoji,
),
),
SizedBox(height: tight ? 2 : AppSpacing.sm),
@@ -252,3 +253,62 @@ class _ProductCardState extends State<ProductCard> {
);
}
}
/// Shows the catalogue's product photo when the imported record has one,
/// otherwise falls back to the emoji.
///
/// Today's imports don't carry `image_url` yet, so the emoji path is still
/// the common case — this quietly takes over per product once the back
/// office starts sending photos, with nothing else on the card changing.
class _ProductVisual extends StatelessWidget {
const _ProductVisual({
required this.imageUrl,
required this.emoji,
required this.size,
});
final String? imageUrl;
final String emoji;
/// Matches the emoji font size the caller computed for this tile, so the
/// two are visually interchangeable.
final double size;
@override
Widget build(BuildContext context) {
final url = imageUrl;
if (url == null || url.isEmpty) {
return Text(emoji, style: TextStyle(fontSize: size));
}
final box = size * 1.7;
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
url,
width: box,
height: box,
fit: BoxFit.cover,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return SizedBox(
width: box,
height: box,
child: const Center(
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
},
// A missing or unreachable photo falls back to the emoji rather than
// Flutter's default broken-image icon, so one bad URL in a catalogue
// of thousands never leaves a tile looking broken.
errorBuilder: (context, error, stackTrace) =>
Text(emoji, style: TextStyle(fontSize: size)),
),
);
}
}