pos changes
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -35,6 +35,11 @@ import 'pos_view.dart';
|
||||
/// * `1120–1300` sidebar as an icon rail, docked bill
|
||||
/// * `920–1120` 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(
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],),
|
||||
],),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user