Files
nearle_pos/lib/presentation/pos/screens/pos_dashboard_screen.dart
Suriya 3513281a11 Build staff management, store details editing, and a forced PIN change
Wires the last two dead buttons in Settings and closes the loop on the
credential work: hashed PINs are only worth having if a shop can actually
change them.

Users & roles (Manage)
- Add, rename, re-role and remove staff. Admin-only at the door, because
  anyone who can edit staff can make themselves an admin.
- PIN and confirmation are both required and must match. There is no email to
  reset a PIN with, so a typo nobody can verify locks the account out until an
  admin intervenes.
- Editing someone leaves their PIN alone unless a new one is typed. An admin
  setting another person's PIN counts as a reset and re-arms must-change.
- Removal is a deactivation with a confirmation that explains why: bills
  already rung keep the cashier's name, so shift reports stay correct.
- Anyone still on a shipped PIN is flagged in the list and in Settings.

Store details (Edit)
- Name, address, GSTIN and phone now editable and persisted. GSTIN is format
  and state-code validated; it prints on every invoice as a legal requirement,
  so a typo is a compliance problem across hundreds of bills.
- Admin-only: changing the GSTIN changes what every future invoice claims
  about who collected the tax.

Forced PIN change
- Shown once after sign-in while must-change is set, and not dismissable. The
  seeded PINs are in the source of the build, so a terminal still running one
  is effectively unprotected.

Fixed while testing: the role dropdown laid its items out at natural width and
"Manager — Sales, inventory and reports" overflowed the dialog by 222px. Now
isExpanded with the description spelled out below, where it is readable.

Tests: 160 -> 168. Covers both role guards, the mismatched and too-short PIN
paths, the default-PIN flag, and GSTIN and seller-name validation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:10:49 +05:30

194 lines
6.9 KiB
Dart
Raw Permalink 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
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);
final module = ref.watch(activeModuleProvider);
final ready = ref.watch(catalogueReadyProvider);
final isPos = module == PosModule.pos && ready;
// 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: 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 (!layout.sidebarIsDrawer) AppSidebar(mode: layout.sidebar),
Expanded(
child: Column(
children: [
PageHeader(
layout: layout,
onMenuTap: () => _scaffoldKey.currentState?.openDrawer(),
),
Expanded(
child: AnimatedSwitcher(
duration: AppMotion.fast,
child: KeyedSubtree(
key: ValueKey(module),
child: _body(module, layout),
),
),
),
],
),
),
if (showDockedBill) ...[
const VerticalDivider(width: 1),
SizedBox(
width: layout.billingWidth,
child: const BillingPanel(),
),
],
],
),
),
),
);
}
}