Files
nearle_pos/lib/presentation/pos/screens/pos_dashboard_screen.dart
2026-08-07 15:31:20 +05:30

216 lines
8.1 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/services/barcode_service.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_layout.dart';
import '../../auth/providers/auth_controller.dart';
import '../../modules/screens/customers_view.dart';
import '../../modules/screens/events_view.dart';
import '../../modules/screens/product_import_view.dart';
import '../../modules/screens/promos_view.dart';
import '../../modules/screens/settings_view.dart';
import '../../modules/widgets/staff_dialogs.dart';
import '../../sync/providers/sync_controller.dart';
import '../providers/cart_controller.dart';
import '../providers/catalog_providers.dart';
import '../providers/navigation_provider.dart';
import '../widgets/app_sidebar.dart';
import '../widgets/billing_panel.dart';
import '../widgets/cart_fab.dart';
import '../widgets/page_header.dart';
import 'pos_view.dart';
/// Application shell.
///
/// Owns the sidebar, page header and the body for whichever module is active.
/// Keeping one shell means navigation never rebuilds the chrome, and the
/// scanner stays live across modules.
///
/// Layout collapses in a fixed order as width shrinks:
///
/// * `>= 1300` sidebar with labels, docked bill
/// * `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});
@override
ConsumerState<PosDashboardScreen> createState() => _PosDashboardScreenState();
}
class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
final FocusNode _searchFocus = FocusNode();
late final BarcodeService _barcode;
/// Guards against re-opening the PIN dialog on every rebuild.
bool _promptedForPin = false;
@override
void initState() {
super.initState();
// Anyone still on a PIN this build shipped with is asked to choose their
// own before ringing a sale. Deferred to the first frame because it opens
// a dialog, which needs a Navigator that exists.
WidgetsBinding.instance.addPostFrameCallback((_) => _maybePromptForPin());
// The scanner behaves like a keyboard, so listen globally rather than
// depending on any one field holding focus. A scan from another module
// jumps back to billing, which is what a cashier expects.
_barcode = BarcodeService(
onScan: (code) {
// Without an imported catalogue there is nothing to resolve against.
if (!ref.read(catalogueReadyProvider)) return;
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
// Drop any character the burst leaked into the search box before the
// scan was recognised, so the grid is not left filtered to nothing.
ref.read(searchQueryProvider.notifier).state = '';
ref.read(cartControllerProvider.notifier).scanBarcode(code);
},
)..attach();
}
Future<void> _maybePromptForPin() async {
if (_promptedForPin || !mounted) return;
if (!ref.read(mustChangePinProvider)) return;
_promptedForPin = true;
await showForcedPinChange(context);
}
@override
void dispose() {
_barcode.dispose();
_searchFocus.dispose();
super.dispose();
}
void _openBillingSheet() {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const FractionallySizedBox(
heightFactor: 0.92,
child: ClipRRect(
borderRadius: BorderRadius.vertical(
top: Radius.circular(AppRadius.xxl),
),
child: BillingPanel(inSheet: true),
),
),
);
}
Widget _body(PosModule module, PosLayout layout) => switch (module) {
PosModule.pos => PosView(layout: layout, searchFocus: _searchFocus),
PosModule.customers => const CustomersView(),
PosModule.productImport => const ProductImportView(),
PosModule.promos => const PromosView(),
PosModule.events => const EventsView(),
PosModule.settings => const SettingsView(),
};
@override
Widget build(BuildContext context) {
final layout = PosLayout.of(context);
// 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;
return Scaffold(
key: _scaffoldKey,
backgroundColor: AppColors.background,
drawer: showSidebar && layout.sidebarIsDrawer
? Drawer(
width: PosLayout.expandedWidth,
backgroundColor: AppColors.surface,
child: AppSidebar(
mode: SidebarMode.expanded,
onDestinationTap: () => Navigator.of(context).maybePop(),
),
)
: null,
floatingActionButton:
showCartFab ? CartFab(onTap: _openBillingSheet) : null,
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
body: CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.f2):
_searchFocus.requestFocus,
const SingleActivator(LogicalKeyboardKey.f8): () =>
ref.read(cartControllerProvider.notifier).undo(),
const SingleActivator(LogicalKeyboardKey.escape):
_searchFocus.unfocus,
},
child: Focus(
autofocus: true,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (showSidebar && !layout.sidebarIsDrawer)
AppSidebar(mode: layout.sidebar),
Expanded(
child: Column(
children: [
PageHeader(
layout: layout,
// 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(
duration: AppMotion.fast,
// Top, not the default centre. The switcher stacks its
// children with loose constraints, so a module page
// shorter than the viewport — Promotions with two
// campaigns, Product Import before anything is pulled —
// sized itself to its content and then floated in the
// middle of the screen with dead space above it.
child: KeyedSubtree(
key: ValueKey(module),
child: _body(module, layout),
),
),
),
],
),
),
if (showDockedBill) ...[
const VerticalDivider(width: 1),
SizedBox(
width: layout.billingWidth,
child: const BillingPanel(),
),
],
],
),
),
),
);
}
}