Files
nearle_pos/lib/presentation/pos/widgets/app_sidebar.dart
Suriya af3933092f Fix billing data integrity, sale atomicity and stock safety
Bills were persisted correctly but read back wrong. The read path rebuilt a
cart from its lines alone, dropping bill-level discounts and loyalty, so every
figure derived from a stored bill was overstated: the upload payload, the day
archive and the shift report. A discounted 529 bill read back as 620.

Money and data integrity
- order_dao: restore bill_discount and points_redeemed when rebuilding a cart;
  keep the reconstruction tier-less so the membership discount is not applied
  twice. Trust the recorded total and points via SaleTransaction.storedTotal.
- checkout_sale + order_dao.commitSale: write the bill, its stock movement and
  the loyalty update in one transaction. Previously a failure part-way through
  left a persisted bill the cashier believed had failed, inviting a duplicate.
- checkout_sale: re-check every line against live stock. A parked bill resumed
  after its stock was sold passed validation and oversold.
- catalogue_dao: allocate the invoice sequence in one transaction; the previous
  read-modify-write could hand two sales the same number and fail UNIQUE.
- local_store: replay unsynced sales after a catalogue import, so a mid-shift
  re-import cannot restore stock that has already been sold.
- payment_controller: stamp the signed-in operator on the bill instead of the
  hardcoded seed session, and pass the terminal id through.
- cart: reconcile per-slab GST against the bill total so the parts sum to the
  whole on a tax invoice.

Sync and reporting
- sync_repository: drain unsynced bills in a loop rather than silently capping
  at one page; stop on rejection so rejected rows cannot loop forever.
- sync_log_dao (new): persist the sync history to the sync_log table, which the
  schema already defined but nothing used. It was in memory, so the only record
  that bills had been uploaded died at restart.
- Scope shift reports by cashier. day_archive is re-keyed to
  (business_date, cashier_name) so a till stays settleable after its bills are
  uploaded and deleted. Schema v4 with a migration that carries v3 rows across.

Input and UI
- barcode_service: consume machine-paced keystrokes so a scan cannot also land
  in the focused field, and raise the bar to 60ms/char while a text field has
  focus so typing a mobile number is not read as a scan. Clock and focus check
  injected so the behaviour is testable.
- primary_button: make the label flexible; label plus trailing total overflowed
  the Charge button by up to 131px.
- app_router: redirect instead of null-casting when the receipt route is
  entered without its transaction.
- customer_repository: reduce the search query to digits so a punctuated mobile
  number matches.

Cleanup
- Remove TransactionRepository.save, CustomerRepository.recordSale and
  OrderDao.insertOrder, all superseded by commitSale.
- dart fix across the tree; 251 analyzer issues down to 3 info-level.

Tests: 23 passing / 15 failing -> 90 passing. Fixed the two defects that broke
the existing suite (containsAll type argument, reset() needing a catalogue) and
deleted the leftover template test. Added coverage for the order round trip,
the day archive after a real sync, stock safety, checkout atomicity, the v3->v4
migration, scanner-versus-human input, and an app-level smoke test that renders
every module.

Note: bills already uploaded with a discount went up overstated. This stops it
happening again but does not correct historical server data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:34:10 +05:30

482 lines
14 KiB
Dart

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/theme/app_layout.dart';
import '../../../core/theme/app_typography.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';
/// Left navigation rail.
///
/// Renders in three widths: full labels on desktop, icons only on tablet
/// landscape, and off-canvas below that. The same widget serves all three so
/// the active state and badges never drift apart.
class AppSidebar extends ConsumerWidget {
const AppSidebar({
super.key,
required this.mode,
this.onDestinationTap,
});
final SidebarMode mode;
/// Lets the drawer close itself after a tap.
final VoidCallback? onDestinationTap;
@override
Widget build(BuildContext context, WidgetRef ref) {
// The drawer presentation uses the expanded layout at full width.
final expanded = mode != SidebarMode.rail;
final width = mode == SidebarMode.rail
? PosLayout.railWidth
: PosLayout.expandedWidth;
return AnimatedContainer(
duration: AppMotion.normal,
curve: AppMotion.emphasized,
width: width,
decoration: const BoxDecoration(
color: AppColors.surface,
border: Border(right: BorderSide(color: AppColors.border)),
),
child: SafeArea(
right: false,
child: Column(
children: [
_Brand(expanded: expanded),
const Divider(height: 1),
_Profile(expanded: expanded),
const Divider(height: 1),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.md),
child: Column(
children: [
for (final section in NavSection.values)
_Section(
section: section,
expanded: expanded,
onDestinationTap: onDestinationTap,
),
],
),
),
),
const Divider(height: 1),
_LogoutTile(expanded: expanded),
],
),
),
);
}
}
class _Brand extends StatelessWidget {
const _Brand({required this.expanded});
final bool expanded;
@override
Widget build(BuildContext context) {
return Container(
height: AppSizes.headerHeight,
padding: EdgeInsets.symmetric(
horizontal: expanded ? AppSpacing.xl : AppSpacing.md,
),
alignment: expanded ? Alignment.centerLeft : Alignment.center,
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,
),
),
),
if (expanded) ...[
const SizedBox(width: AppSpacing.md),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Nearle',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
color: AppColors.textPrimary,
height: 1.1,
),
),
Text(
'POS',
style: AppTypography.sectionLabel()
.copyWith(color: AppColors.primary),
),
],
),
),
],
],
),
);
}
}
class _Profile extends ConsumerWidget {
const _Profile({required this.expanded});
final bool expanded;
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(currentUserProvider);
final name = user?.name ?? ref.watch(cashierSessionProvider).name;
final role = user?.role.label ?? ref.watch(cashierSessionProvider).role;
return Padding(
padding: EdgeInsets.symmetric(
horizontal: expanded ? AppSpacing.lg : AppSpacing.sm,
vertical: AppSpacing.md,
),
child: Row(
mainAxisAlignment:
expanded ? MainAxisAlignment.start : MainAxisAlignment.center,
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brSm,
border: Border.all(color: AppColors.primaryBorder),
),
alignment: Alignment.center,
child: Text(
Formatters.initials(name),
style: const TextStyle(
color: AppColors.primary,
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
),
if (expanded) ...[
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
height: 1.2,
),
),
Text(
role,
style: const TextStyle(
fontSize: 11.5,
color: AppColors.textTertiary,
height: 1.3,
),
),
],
),
),
],
],
),
);
}
}
class _Section extends ConsumerWidget {
const _Section({
required this.section,
required this.expanded,
this.onDestinationTap,
});
final NavSection section;
final bool expanded;
final VoidCallback? onDestinationTap;
@override
Widget build(BuildContext context, WidgetRef ref) {
final active = ref.watch(activeModuleProvider);
final cartCount = ref.watch(cartItemCountProvider);
final ready = ref.watch(catalogueReadyProvider);
final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (expanded)
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.lg,
AppSpacing.xl,
AppSpacing.sm,
),
child: Text(section.label.toUpperCase(),
style: AppTypography.sectionLabel(),),
)
else
const Padding(
padding: EdgeInsets.symmetric(
horizontal: AppSpacing.xl,
vertical: AppSpacing.md,
),
child: Divider(height: 1),
),
for (final module in section.modules)
_NavTile(
module: module,
expanded: expanded,
selected: active == module,
badge: switch (module) {
PosModule.pos => cartCount > 0 ? cartCount : null,
PosModule.productImport => ready ? null : 1,
PosModule.events => outstanding > 0 ? outstanding : null,
_ => null,
},
badgeColor: switch (module) {
PosModule.productImport => AppColors.danger,
PosModule.events => AppColors.warning,
_ => AppColors.primary,
},
onTap: () {
ref.read(activeModuleProvider.notifier).state = module;
onDestinationTap?.call();
},
),
],
);
}
}
class _NavTile extends StatefulWidget {
const _NavTile({
required this.module,
required this.expanded,
required this.selected,
required this.onTap,
this.badge,
this.badgeColor,
});
final PosModule module;
final bool expanded;
final bool selected;
final VoidCallback onTap;
final int? badge;
final Color? badgeColor;
@override
State<_NavTile> createState() => _NavTileState();
}
class _NavTileState extends State<_NavTile> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final selected = widget.selected;
final fg = selected
? AppColors.primary
: (_hovered ? AppColors.textPrimary : AppColors.textSecondary);
final tile = AnimatedContainer(
duration: AppMotion.fast,
height: AppSizes.navItemHeight,
padding: EdgeInsets.symmetric(
horizontal: widget.expanded ? AppSpacing.md : 0,
),
decoration: BoxDecoration(
color: selected
? AppColors.primarySurface
: (_hovered ? AppColors.surfaceAlt : Colors.transparent),
borderRadius: AppRadius.brSm,
),
child: Row(
mainAxisAlignment: widget.expanded
? MainAxisAlignment.start
: MainAxisAlignment.center,
children: [
Stack(
clipBehavior: Clip.none,
children: [
Icon(widget.module.icon, size: 20, color: fg),
// In rail mode the label is gone, so the badge rides the icon.
if (widget.badge != null && !widget.expanded)
Positioned(
top: -5,
right: -8,
child: _Badge(
value: widget.badge!,
color: widget.badgeColor ?? AppColors.primary,
),
),
],
),
if (widget.expanded) ...[
const SizedBox(width: AppSpacing.md),
Expanded(
child: Text(
widget.module.label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: fg,
),
),
),
if (widget.badge != null)
_Badge(
value: widget.badge!,
color: widget.badgeColor ?? AppColors.primary,
),
],
],
),
);
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: widget.expanded ? AppSpacing.md : AppSpacing.lg,
vertical: 2,
),
child: Stack(
children: [
Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onTap,
borderRadius: AppRadius.brSm,
child: widget.expanded
? tile
: Tooltip(message: widget.module.title, child: tile),
),
),
// Accent bar marking the active destination.
if (selected)
Positioned(
left: 0,
top: 10,
bottom: 10,
child: Container(
width: 3,
decoration: const BoxDecoration(
color: AppColors.primary,
borderRadius: AppRadius.brPill,
),
),
),
],
),
),
);
}
}
class _Badge extends StatelessWidget {
const _Badge({required this.value, required this.color});
final int value;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(minWidth: 19),
height: 19,
padding: const EdgeInsets.symmetric(horizontal: 5),
decoration: BoxDecoration(color: color, borderRadius: AppRadius.brPill),
alignment: Alignment.center,
child: Text(
value > 99 ? '99+' : '$value',
style: const TextStyle(
color: Colors.white,
fontSize: 10.5,
fontWeight: FontWeight.w700,
),
),
);
}
}
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,
),
),
],
],
),
),
),
),
);
}
}