pos changes

This commit is contained in:
2026-08-06 19:26:53 +05:30
parent cb065a0f69
commit eebd10da6d
42 changed files with 3451 additions and 2531 deletions

View File

@@ -208,10 +208,19 @@ class CartController extends StateNotifier<Cart> {
setQuantity(productId, line.quantity + by);
}
/// Steps a line down, never off the bill.
///
/// Floors at one deliberately. Taking the last unit away is a removal, and
/// removals go through the PIN gate on the close button — a stepper that
/// quietly reached zero was a way around it.
void decrement(String productId, {double by = 1}) {
final line = state.lineFor(productId);
if (line == null) return;
setQuantity(productId, line.quantity - by);
final next = line.quantity - by;
if (next < 1) return;
setQuantity(productId, next);
}
void removeLine(String productId) {

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../auth/providers/auth_controller.dart';
/// The modules a cashier needs. Deliberately excludes analytics — this
/// terminal is for billing, not back-office reporting.
enum PosModule {
@@ -41,4 +43,37 @@ enum NavSection {
PosModule.values.where((m) => m.section == this).toList();
}
/// What a cashier session may open.
///
/// Billing, and nothing else — not the catalogue, the promos, the sync log or
/// the terminal's configuration. The sidebar is hidden in cashier mode anyway,
/// so this is the belt to that braces: a module reached some other way (a scan
/// handler, a deep link, a stale value left in [activeModuleProvider] from the
/// admin's session) still cannot render.
const cashierModules = <PosModule>[PosModule.pos];
final activeModuleProvider = StateProvider<PosModule>((ref) => PosModule.pos);
/// The modules the current session is allowed to reach.
final visibleModulesProvider = Provider<List<PosModule>>((ref) {
return ref.watch(isCashierModeProvider) ? cashierModules : PosModule.values;
});
/// [activeModuleProvider], clamped to what this session may open.
///
/// Read this rather than the raw value anywhere a module decides what gets
/// built. An admin who leaves the shell on Settings and hands the till to a
/// cashier would otherwise reopen it on Settings.
final resolvedModuleProvider = Provider<PosModule>((ref) {
final active = ref.watch(activeModuleProvider);
final visible = ref.watch(visibleModulesProvider);
return visible.contains(active) ? active : PosModule.pos;
});
/// Sections that still have at least one module this session may open.
final visibleSectionsProvider = Provider<List<NavSection>>((ref) {
final visible = ref.watch(visibleModulesProvider);
return NavSection.values
.where((s) => s.modules.any(visible.contains))
.toList();
});

View File

@@ -35,6 +35,11 @@ import 'pos_view.dart';
/// * `11201300` sidebar as an icon rail, docked bill
/// * `9201120` icon rail, bill becomes a bottom sheet
/// * `< 920` sidebar goes off-canvas behind a menu button
///
/// In cashier mode the sidebar is not rendered at any width. That session has
/// exactly two destinations, and both are reachable from the header — a rail
/// holding one live tile is chrome for its own sake. Sign-out and the sync log
/// move up with it, since the sidebar was the only place they lived.
class PosDashboardScreen extends ConsumerStatefulWidget {
const PosDashboardScreen({super.key});
@@ -119,10 +124,15 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
@override
Widget build(BuildContext context) {
final layout = PosLayout.of(context);
final module = ref.watch(activeModuleProvider);
// Clamped, not raw: a module the admin left active must not carry into a
// cashier session.
final module = ref.watch(resolvedModuleProvider);
final ready = ref.watch(catalogueReadyProvider);
final isPos = module == PosModule.pos && ready;
final cashierMode = ref.watch(isCashierModeProvider);
final showSidebar = !cashierMode;
// Only the terminal itself needs the bill docked beside it.
final showDockedBill = isPos && !layout.billingIsSheet;
final showCartFab = isPos && layout.billingIsSheet;
@@ -130,7 +140,7 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
return Scaffold(
key: _scaffoldKey,
backgroundColor: AppColors.background,
drawer: layout.sidebarIsDrawer
drawer: showSidebar && layout.sidebarIsDrawer
? Drawer(
width: PosLayout.expandedWidth,
backgroundColor: AppColors.surface,
@@ -157,13 +167,18 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!layout.sidebarIsDrawer) AppSidebar(mode: layout.sidebar),
if (showSidebar && !layout.sidebarIsDrawer)
AppSidebar(mode: layout.sidebar),
Expanded(
child: Column(
children: [
PageHeader(
layout: layout,
onMenuTap: () => _scaffoldKey.currentState?.openDrawer(),
// Nothing to open in cashier mode, so the button is not
// offered rather than opening an empty drawer.
onMenuTap: showSidebar
? () => _scaffoldKey.currentState?.openDrawer()
: null,
),
Expanded(
child: AnimatedSwitcher(

View File

@@ -5,6 +5,7 @@ import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_layout.dart';
import '../../../core/widgets/primary_button.dart';
import '../../auth/providers/auth_controller.dart';
import '../../sync/providers/sync_controller.dart';
import '../providers/navigation_provider.dart';
import '../widgets/category_chips.dart';
@@ -82,6 +83,11 @@ class _CatalogueRequired extends ConsumerWidget {
final state = ref.watch(catalogueImportProvider);
final running = state is ImportRunning;
// Pulling the catalogue is an admin job, and a cashier has no Product
// Import module to be sent to. Offering them a button that opens a screen
// they cannot reach is worse than telling them who to ask.
final cashier = ref.watch(isCashierModeProvider);
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.xxl),
@@ -102,17 +108,23 @@ class _CatalogueRequired extends ConsumerWidget {
),
const SizedBox(height: AppSpacing.xxl),
Text(
'Import products to start billing',
cashier
? 'No products on this terminal'
: 'Import products to start billing',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: AppSpacing.sm),
const Text(
'This terminal has no catalogue yet. Pull the current products '
'once at the start of your shift — after that everything runs '
'offline.',
Text(
cashier
? 'Nothing has been imported for this shift yet. Ask an '
'admin to sign in and pull the catalogue — once they '
'have, sign in again and everything runs offline.'
: 'This terminal has no catalogue yet. Pull the current '
'products once at the start of your shift — after that '
'everything runs offline.',
textAlign: TextAlign.center,
style: TextStyle(
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
height: 1.6,
@@ -120,7 +132,7 @@ class _CatalogueRequired extends ConsumerWidget {
),
const SizedBox(height: AppSpacing.xxl),
if (running) ...[
if (!cashier && state is ImportRunning) ...[
Text(
state.stage,
style: const TextStyle(
@@ -142,7 +154,7 @@ class _CatalogueRequired extends ConsumerWidget {
const SizedBox(height: AppSpacing.lg),
],
if (state is ImportFailed) ...[
if (!cashier && state is ImportFailed) ...[
Container(
padding: const EdgeInsets.all(AppSpacing.md),
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
@@ -171,23 +183,52 @@ class _CatalogueRequired extends ConsumerWidget {
),
],
PrimaryButton(
label: 'Import catalogue now',
icon: Icons.cloud_download_rounded,
large: true,
busy: running,
onPressed: running
? null
: () => ref.read(catalogueImportProvider.notifier).run(),
),
const SizedBox(height: AppSpacing.md),
TextButton.icon(
onPressed: () => ref
.read(activeModuleProvider.notifier)
.state = PosModule.productImport,
icon: const Icon(Icons.open_in_new_rounded, size: 16),
label: const Text('Open Product Import'),
),
if (cashier)
Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: const BoxDecoration(
color: AppColors.infoSurface,
borderRadius: AppRadius.brSm,
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.admin_panel_settings_outlined,
size: 18, color: AppColors.info,),
SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
'Importing the catalogue is an admin job. Nothing '
'can be billed here until it has been done.',
style: TextStyle(
color: AppColors.info,
fontSize: 13,
height: 1.45,
),
),
),
],
),
)
else ...[
PrimaryButton(
label: 'Import catalogue now',
icon: Icons.cloud_download_rounded,
large: true,
busy: running,
onPressed: running
? null
: () => ref.read(catalogueImportProvider.notifier).run(),
),
const SizedBox(height: AppSpacing.md),
TextButton.icon(
onPressed: () => ref
.read(activeModuleProvider.notifier)
.state = PosModule.productImport,
icon: const Icon(Icons.open_in_new_rounded, size: 16),
label: const Text('Open Product Import'),
),
],
],
),
),

View File

@@ -5,16 +5,18 @@ 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.
/// Prompts for the removal PIN before taking a rung item back off a bill, and
/// resolves `true` only once it verifies.
///
/// The PIN is the one an admin sets in Settings, and an admin's own staff PIN
/// always works too — so a cashier can void a line at the counter without an
/// admin walking over, and the owner is never locked out of their own till.
///
/// 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(
Future<bool> requireVoidPin(
BuildContext context,
WidgetRef ref, {
required String reason,
@@ -67,11 +69,11 @@ class _AdminPinDialogState extends ConsumerState<_AdminPinDialog> {
});
final store = ref.read(localStoreProvider);
final user = await store.staff.authenticate(_pin);
final ok = await store.voidPin.verify(_pin);
if (!mounted) return;
if (user == null) {
if (!ok) {
setState(() {
_checking = false;
_error = 'Incorrect PIN.';
@@ -80,16 +82,6 @@ class _AdminPinDialogState extends ConsumerState<_AdminPinDialog> {
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);
}
@@ -112,7 +104,7 @@ class _AdminPinDialogState extends ConsumerState<_AdminPinDialog> {
const SizedBox(width: AppSpacing.sm),
const Expanded(
child: Text(
'Admin PIN required',
'Removal PIN required',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,

View File

@@ -6,9 +6,9 @@ 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/widgets/brand_mark.dart';
import '../../../core/utils/formatters.dart';
import '../../auth/providers/auth_controller.dart';
import '../../sync/widgets/sign_out_dialog.dart';
import '../providers/cart_controller.dart';
import '../../sync/providers/sync_controller.dart';
import '../providers/navigation_provider.dart';
@@ -59,7 +59,9 @@ class AppSidebar extends ConsumerWidget {
padding: const EdgeInsets.symmetric(vertical: AppSpacing.md),
child: Column(
children: [
for (final section in NavSection.values)
// Sections with nothing this session may open are not
// rendered as empty headings.
for (final section in ref.watch(visibleSectionsProvider))
_Section(
section: section,
expanded: expanded,
@@ -69,8 +71,6 @@ class AppSidebar extends ConsumerWidget {
),
),
),
const Divider(height: 1),
_LogoutTile(expanded: expanded),
],
),
),
@@ -94,23 +94,7 @@ class _Brand extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(10),
),
alignment: Alignment.center,
child: const Text(
'N',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w800,
),
),
),
const BrandMark(size: 36),
if (expanded) ...[
const SizedBox(width: AppSpacing.md),
Flexible(
@@ -228,7 +212,8 @@ class _Section extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final active = ref.watch(activeModuleProvider);
final active = ref.watch(resolvedModuleProvider);
final visible = ref.watch(visibleModulesProvider);
final cartCount = ref.watch(cartItemCountProvider);
final ready = ref.watch(catalogueReadyProvider);
final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
@@ -255,7 +240,7 @@ class _Section extends ConsumerWidget {
),
child: Divider(height: 1),
),
for (final module in section.modules)
for (final module in section.modules.where(visible.contains))
_NavTile(
module: module,
expanded: expanded,
@@ -434,48 +419,3 @@ class _Badge extends StatelessWidget {
);
}
}
class _LogoutTile extends ConsumerWidget {
const _LogoutTile({required this.expanded});
final bool expanded;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => showSignOutDialog(context, ref),
borderRadius: AppRadius.brSm,
child: Container(
height: AppSizes.navItemHeight,
padding: EdgeInsets.symmetric(
horizontal: expanded ? AppSpacing.md : 0,
),
alignment: expanded ? Alignment.centerLeft : Alignment.center,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.logout_rounded,
size: 19, color: AppColors.danger,),
if (expanded) ...[
const SizedBox(width: AppSpacing.md),
const Text(
'Logout',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.danger,
),
),
],
],
),
),
),
),
);
}
}

View File

@@ -18,7 +18,6 @@ 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';
/// Always-visible bill on the right of the dashboard.
class BillingPanel extends ConsumerWidget {
@@ -66,8 +65,6 @@ class BillingPanel extends ConsumerWidget {
controller,
line.product.id,
),
onDiscount: () =>
showLineDiscountSheet(context, ref, line),
);
},
),
@@ -79,37 +76,24 @@ 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.
/// Once an item is on the bill, taking it back off needs the removal PIN an
/// admin sets in Settings — a cashier can always start an entirely new sale
/// instead. This is the one gate every removal path goes through, so the
/// close button and the swipe can never drift apart.
Future<void> _removeLine(
BuildContext context,
WidgetRef ref,
CartController controller,
String productId,
) async {
final ok = await requireAdminPin(
final ok = await requireVoidPin(
context,
ref,
reason: 'Removing a scanned item from the bill needs admin approval.',
reason: 'Removing a scanned item from the bill needs the removal PIN.',
);
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});
@@ -118,7 +102,6 @@ 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;
@@ -127,8 +110,9 @@ class _Header extends ConsumerWidget {
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
padding: EdgeInsets.symmetric(horizontal: contentPadding),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Title and count read as one label, so they sit together rather
// than being pushed to opposite ends by a space-between row.
Flexible(
child: Text(
'Cart',
@@ -137,9 +121,12 @@ class _Header extends ConsumerWidget {
),
),
if (cart.isNotEmpty) ...[
const SizedBox(width: AppSpacing.sm),
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: 2,
),
decoration: const BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brPill,
@@ -154,52 +141,21 @@ class _Header extends ConsumerWidget {
),
),
],
SizedBox(),
SizedBox(),
Spacer(),
const Spacer(),
// Icon-only actions: labelled buttons overflowed the 380px panel.
// 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(),
),
],
),
// Undo, Park and Clear used to sit here as three icon buttons. They
// are the least-pressed controls on the panel and they were the
// first thing the eye landed on, above the bill itself. Undo is on
// F8; Park and Clear moved down beside the total, next to the button
// a cashier is already reaching for.
if (inSheet)
_IconAction(
icon: Icons.close_rounded,
tooltip: 'Close',
color: AppColors.textSecondary,
onTap: () => Navigator.of(context).pop(),
),
],
),
);
@@ -313,21 +269,6 @@ class _Summary extends ConsumerWidget {
.join(', '),
),
InkWell(
onTap: () => showBillDiscountSheet(context, ref),
borderRadius: AppRadius.brXs,
child: _Row(
label: 'Discount',
value: cart.manualBillDiscountAmount > 0
? '-${Formatters.money(cart.manualBillDiscountAmount)}'
: '-${Formatters.money(0)}',
valueColor: cart.manualBillDiscountAmount > 0
? AppColors.success
: null,
trailingIcon: Icons.edit_outlined,
),
),
if (cart.maxRedeemablePoints > 0 || cart.pointsRedeemed > 0)
InkWell(
onTap: () => cart.pointsRedeemed > 0
@@ -466,26 +407,33 @@ class _Actions extends ConsumerWidget {
AppSpacing.xl,
AppSpacing.xl,
),
child: PrimaryButton(
label: 'CHARGE',
large: true,
onPressed: enabled
? () async {
// Ask once per bill, before payment. Skipping is one tap and
// leaves the sale as walk-in.
if (ref.read(cartControllerProvider).customer == null) {
await showCustomerCaptureSheet(context);
}
// Navigation result is not needed here.
if (context.mounted) unawaited(context.push(AppRoutes.payment));
}
: null,
trailing: enabled
? Text(
Formatters.money(cart.grandTotal),
style: AppTypography.money(21, color: Colors.white),
)
: null,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
PrimaryButton(
label: 'CHARGE',
large: true,
onPressed: enabled
? () async {
// Ask once per bill, before payment. Skipping is one tap
// and leaves the sale as walk-in.
if (ref.read(cartControllerProvider).customer == null) {
await showCustomerCaptureSheet(context);
}
// Navigation result is not needed here.
if (context.mounted) {
unawaited(context.push(AppRoutes.payment));
}
}
: null,
trailing: enabled
? Text(
Formatters.money(cart.grandTotal),
style: AppTypography.money(21, color: Colors.white),
)
: null,
),
],
),
);
}

View File

@@ -14,14 +14,12 @@ class CartLineTile extends StatelessWidget {
required this.onIncrement,
required this.onDecrement,
required this.onRemove,
this.onDiscount,
});
final CartLine line;
final VoidCallback onIncrement;
final VoidCallback onDecrement;
final VoidCallback onRemove;
final VoidCallback? onDiscount;
@override
Widget build(BuildContext context) {
@@ -30,7 +28,16 @@ class CartLineTile extends StatelessWidget {
return Dismissible(
key: ValueKey('dismiss_${p.id}'),
direction: DismissDirection.endToStart,
onDismissed: (_) => onRemove(),
// Confirm rather than dismiss: [onRemove] opens the PIN dialog, and a
// refused PIN must leave the line exactly where it was. Dismissing first
// and asking after left the row gone from the screen but still in the
// cart — and Flutter asserting about a dismissed widget still in the
// tree. Returning false always is correct: when the PIN is accepted the
// line disappears because the cart changed, not because of the swipe.
confirmDismiss: (_) async {
onRemove();
return false;
},
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: AppSpacing.xl),
@@ -119,7 +126,7 @@ class CartLineTile extends StatelessWidget {
color: AppColors.textTertiary,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
padding: EdgeInsets.zero,
tooltip: 'Remove',
tooltip: 'Remove from bill (needs the removal PIN)',
),
],),
@@ -132,17 +139,6 @@ class CartLineTile extends StatelessWidget {
onIncrement: onIncrement,
onDecrement: onDecrement,
),
if (onDiscount != null) ...[
const SizedBox(width: AppSpacing.sm),
IconButton(
onPressed: onDiscount,
icon: const Icon(Icons.local_offer_outlined, size: 17),
color: AppColors.textSecondary,
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
padding: EdgeInsets.zero,
tooltip: 'Line discount',
),
],
const Spacer(),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
@@ -209,8 +205,12 @@ class _Stepper extends StatelessWidget {
borderRadius: AppRadius.brSm,
border: Border.all(color: AppColors.border),
),
// Minus stops at one rather than emptying the line. Dropping to zero
// was a silent removal that skipped the PIN the close button asks for —
// two taps of a stepper should not be a way around the till's only
// theft control.
child: Row(mainAxisSize: MainAxisSize.min, children: [
_btn(Icons.remove_rounded, onDecrement),
_btn(Icons.remove_rounded, quantity > 1 ? onDecrement : null),
Container(
constraints: const BoxConstraints(minWidth: 42),
alignment: Alignment.center,
@@ -226,7 +226,7 @@ class _Stepper extends StatelessWidget {
);
}
Widget _btn(IconData icon, VoidCallback onTap) => Material(
Widget _btn(IconData icon, VoidCallback? onTap) => Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
@@ -234,7 +234,13 @@ class _Stepper extends StatelessWidget {
child: SizedBox(
width: 34,
height: 34,
child: Icon(icon, size: 17, color: AppColors.primary),
child: Icon(
icon,
size: 17,
color: onTap == null
? AppColors.textTertiary.withValues(alpha: 0.5)
: AppColors.primary,
),
),
),
);

View File

@@ -1,213 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
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/cart.dart';
import '../providers/cart_controller.dart';
Future<void> showLineDiscountSheet(
BuildContext context,
WidgetRef ref,
CartLine line,
) {
return _show(
context: context,
title: line.product.name,
subtitle: 'Line value ${Formatters.money(line.grossAmount)}',
current: line.discount,
onApply: (d) => ref
.read(cartControllerProvider.notifier)
.applyLineDiscount(line.product.id, d),
);
}
Future<void> showBillDiscountSheet(BuildContext context, WidgetRef ref) {
final cart = ref.read(cartControllerProvider);
return _show(
context: context,
title: 'Bill discount',
subtitle: 'Subtotal ${Formatters.money(cart.subtotal)}',
current: cart.billDiscount,
onApply: (d) =>
ref.read(cartControllerProvider.notifier).applyBillDiscount(d),
);
}
Future<void> _show({
required BuildContext context,
required String title,
required String subtitle,
required Discount current,
required ValueChanged<Discount> onApply,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _DiscountSheet(
title: title,
subtitle: subtitle,
current: current,
onApply: onApply,
),
);
}
class _DiscountSheet extends StatefulWidget {
const _DiscountSheet({
required this.title,
required this.subtitle,
required this.current,
required this.onApply,
});
final String title;
final String subtitle;
final Discount current;
final ValueChanged<Discount> onApply;
@override
State<_DiscountSheet> createState() => _DiscountSheetState();
}
class _DiscountSheetState extends State<_DiscountSheet> {
late DiscountType _type =
widget.current.type == DiscountType.none
? DiscountType.percentage
: widget.current.type;
late final TextEditingController _value = TextEditingController(
text: widget.current.isActive
? widget.current.value.toStringAsFixed(0)
: '',
);
@override
void dispose() {
_value.dispose();
super.dispose();
}
void _apply() {
final v = double.tryParse(_value.text.trim()) ?? 0;
widget.onApply(
v <= 0 ? Discount.none : Discount(type: _type, value: v),
);
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(context).bottom,
),
child: Container(
padding: const EdgeInsets.all(AppSpacing.xxl),
decoration: const BoxDecoration(
color: AppColors.surface,
borderRadius:
BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 40,
height: 4,
decoration: const BoxDecoration(
color: AppColors.border,
borderRadius: AppRadius.brPill,
),
),
const SizedBox(height: AppSpacing.xl),
Text(widget.title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),),
const SizedBox(height: 2),
Text(widget.subtitle,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),),
const SizedBox(height: AppSpacing.xxl),
SegmentedButton<DiscountType>(
segments: const [
ButtonSegment(
value: DiscountType.percentage,
label: Text('Percent'),
icon: Icon(Icons.percent_rounded, size: 17),
),
ButtonSegment(
value: DiscountType.flat,
label: Text('Flat'),
icon: Icon(Icons.currency_rupee_rounded, size: 17),
),
],
selected: {_type},
onSelectionChanged: (s) => setState(() => _type = s.first),
),
const SizedBox(height: AppSpacing.xl),
TextField(
controller: _value,
autofocus: true,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}')),
],
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700),
textAlign: TextAlign.center,
decoration: InputDecoration(
hintText: '0',
prefixText: _type == DiscountType.flat ? '' : null,
suffixText: _type == DiscountType.percentage ? '%' : null,
),
onSubmitted: (_) => _apply(),
),
const SizedBox(height: AppSpacing.lg),
Wrap(
spacing: AppSpacing.sm,
children: (_type == DiscountType.percentage
? const [5, 10, 15, 20, 25]
: const [10, 20, 50, 100, 200])
.map((v) => ActionChip(
label: Text(_type == DiscountType.percentage
? '$v%'
: '$v',),
onPressed: () =>
setState(() => _value.text = v.toString()),
),)
.toList(),
),
const SizedBox(height: AppSpacing.xxl),
Row(children: [
Expanded(
child: PrimaryButton(
label: 'Remove',
tone: ButtonTone.neutral,
onPressed: () {
widget.onApply(Discount.none);
Navigator.of(context).pop();
},
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
flex: 2,
child: PrimaryButton(
label: 'Apply discount',
icon: Icons.check_rounded,
onPressed: _apply,
),
),
],),
],),
),
);
}
}

View File

@@ -6,6 +6,9 @@ import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_layout.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/widgets/brand_mark.dart';
import '../../auth/providers/auth_controller.dart';
import '../../shift/widgets/session_end_sheet.dart';
import '../providers/cart_controller.dart';
import '../providers/navigation_provider.dart';
@@ -32,7 +35,10 @@ class PageHeader extends ConsumerWidget {
return LayoutBuilder(
builder: (context, box) {
final showStatus = box.maxWidth >= 720;
return _bar(context, ref, compact, showStatus);
// The brand block only earns its space once the bar is genuinely wide;
// below that it would push the actions off the end.
final showBrand = box.maxWidth >= 1000;
return _bar(context, ref, compact, showStatus, showBrand);
},
);
}
@@ -42,10 +48,14 @@ class PageHeader extends ConsumerWidget {
WidgetRef ref,
bool compact,
bool showStatus,
bool showBrand,
) {
final module = ref.watch(activeModuleProvider);
final now = ref.watch(clockProvider).value ?? DateTime.now();
// With no sidebar there is nothing else on screen carrying the brand, the
// sync log or the way out — so all three are promoted into this bar.
final cashierMode = ref.watch(isCashierModeProvider);
return Container(
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
padding: EdgeInsets.symmetric(
@@ -58,7 +68,7 @@ class PageHeader extends ConsumerWidget {
),
child: Row(
children: [
if (compact) ...[
if (compact && onMenuTap != null) ...[
IconButton(
onPressed: onMenuTap,
icon: const Icon(Icons.menu_rounded),
@@ -68,7 +78,12 @@ class PageHeader extends ConsumerWidget {
const SizedBox(width: AppSpacing.xs),
],
// Dropped first when the bar gets tight: the actions are what the
// counter actually presses.
if (cashierMode && showBrand) ...[
const _CashierBrand(),
const SizedBox(width: AppSpacing.lg),
],
if (showStatus) ...[
_LivePill(offline: ref.watch(simulateOfflineProvider)),
@@ -82,14 +97,26 @@ class PageHeader extends ConsumerWidget {
fontFeatures: [FontFeature.tabularFigures()],
),
),
const SizedBox(width: AppSpacing.lg),
Container(width: 1, height: 26, color: AppColors.border),
const SizedBox(width: AppSpacing.lg),
],
// The bar is always the same shape: status on the left, actions
// pinned to the right, for admin and cashier alike. Packed left with
// a divider between them, the actions landed in a different place on
// every screen — mid-bar on a wide admin window, hard left on a
// narrow one — and the two roles never agreed with each other.
const Spacer(),
_ParkedBillsButton(compact: compact),
const SizedBox(width: AppSpacing.sm),
_NewSaleButton(compact: compact),
// Every role signs out from here. It used to sit at the foot of the
// sidebar for admins and up here for cashiers, which meant the same
// action lived in two places depending on who was holding the till.
const SizedBox(width: AppSpacing.md),
Container(width: 1, height: 26, color: AppColors.border),
const SizedBox(width: AppSpacing.md),
const _LogoutButton(),
],
),
);
@@ -366,3 +393,77 @@ class _NewSaleButton extends ConsumerWidget {
);
}
}
/// Brand and outlet name, shown only in cashier mode.
///
/// The sidebar normally carries these; without it the bar reads as a fragment
/// of an app rather than the top of one.
class _CashierBrand extends ConsumerWidget {
const _CashierBrand();
@override
Widget build(BuildContext context, WidgetRef ref) {
final store = ref.watch(currentStoreProvider);
final user = ref.watch(currentUserProvider);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const BrandMark(size: 32),
const SizedBox(width: AppSpacing.sm),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 170),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
store?.name ?? 'Nearle POS',
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: AppColors.textPrimary,
height: 1.15,
),
),
Text(
user == null ? 'Cashier' : '${user.name} · Cashier',
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11.5,
color: AppColors.textTertiary,
height: 1.25,
),
),
],
),
),
],
);
}
}
/// Signing out, for both roles.
///
/// Opens the session-end chooser rather than signing out directly: a cashier
/// stepping away for ten minutes and a cashier finishing for the day want two
/// very different things to happen to the drawer and the catalogue.
class _LogoutButton extends ConsumerWidget {
const _LogoutButton();
@override
Widget build(BuildContext context, WidgetRef ref) {
return IconButton(
tooltip: 'Sign out',
onPressed: () => showSessionEndSheet(context, ref),
style: IconButton.styleFrom(
foregroundColor: AppColors.danger,
backgroundColor: AppColors.dangerSurface,
),
icon: const Icon(Icons.logout_rounded),
);
}
}