Files
nearle_pos/lib/presentation/modules/widgets/module_widgets.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

581 lines
17 KiB
Dart

import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_typography.dart';
/// Scrollable page body shared by every module screen.
///
/// Always scrolls vertically, so no module can overflow no matter how short
/// the viewport gets.
class ModulePage extends StatelessWidget {
const ModulePage({
super.key,
required this.children,
this.padding = AppSpacing.xxl,
});
final List<Widget> children;
final double padding;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: EdgeInsets.all(padding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
);
}
}
/// KPI card. Designed to sit inside a [Wrap] so it reflows instead of
/// overflowing when the window narrows.
class StatTile extends StatelessWidget {
const StatTile({
super.key,
required this.label,
required this.value,
required this.icon,
this.color = AppColors.primary,
this.delta,
this.deltaPositive = true,
this.caption,
this.width = 232,
});
final String label;
final String value;
final IconData icon;
final Color color;
final String? delta;
final bool deltaPositive;
final String? caption;
final double width;
@override
Widget build(BuildContext context) {
return Container(
width: width,
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: AppRadius.brLg,
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: AppRadius.brSm,
),
child: Icon(icon, size: 17, color: color),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
),
],
),
const SizedBox(height: AppSpacing.md),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(value, style: AppTypography.money(24)),
),
if (delta != null || caption != null) ...[
const SizedBox(height: AppSpacing.xs),
Row(
children: [
if (delta != null) ...[
Icon(
deltaPositive
? Icons.trending_up_rounded
: Icons.trending_down_rounded,
size: 14,
color:
deltaPositive ? AppColors.success : AppColors.danger,
),
const SizedBox(width: 3),
Text(
delta!,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color:
deltaPositive ? AppColors.success : AppColors.danger,
),
),
const SizedBox(width: AppSpacing.xs),
],
if (caption != null)
Expanded(
child: Text(
caption!,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11.5,
color: AppColors.textTertiary,
),
),
),
],
),
],
],
),
);
}
}
/// Titled container for a block of module content.
class PanelCard extends StatelessWidget {
const PanelCard({
super.key,
required this.title,
required this.child,
this.subtitle,
this.action,
this.padding = AppSpacing.lg,
});
final String title;
final Widget child;
final String? subtitle;
final Widget? action;
final double padding;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: AppRadius.brLg,
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.fromLTRB(padding, padding, padding, AppSpacing.md),
// Wrap so a long title plus an action never collide.
child: Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: AppSpacing.md,
runSpacing: AppSpacing.sm,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: const TextStyle(
fontSize: 15.5,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
if (subtitle != null)
Text(
subtitle!,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textTertiary,
),
),
],
),
if (action != null) action!,
],
),
),
const Divider(height: 1),
Padding(padding: EdgeInsets.all(padding), child: child),
],
),
);
}
}
/// One column of a [ResponsiveTable].
class TableCol {
const TableCol(
this.label, {
this.flex = 2,
this.numeric = false,
this.priority = 0,
});
final String label;
final int flex;
final bool numeric;
/// Higher numbers are dropped first as the table narrows.
final int priority;
}
/// Table that degrades into stacked cards rather than overflowing.
///
/// Above [stackBelow] it renders as aligned columns; below, each row becomes a
/// label/value card. Low-priority columns are hidden at intermediate widths.
class ResponsiveTable extends StatelessWidget {
const ResponsiveTable({
super.key,
required this.columns,
required this.rows,
this.stackBelow = 620,
this.hideSecondaryBelow = 900,
this.onRowTap,
});
final List<TableCol> columns;
/// Each row must supply exactly one cell per column.
final List<List<Widget>> rows;
final double stackBelow;
final double hideSecondaryBelow;
final void Function(int index)? onRowTap;
@override
Widget build(BuildContext context) {
if (rows.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: AppSpacing.xxl),
child: Center(
child: Text(
'Nothing to show yet.',
style: TextStyle(color: AppColors.textTertiary),
),
),
);
}
return LayoutBuilder(
builder: (context, constraints) {
final w = constraints.maxWidth;
if (w < stackBelow) return _stacked();
final visible = <int>[
for (var i = 0; i < columns.length; i++)
if (w >= hideSecondaryBelow || columns[i].priority == 0) i,
];
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: Row(
children: [
for (final i in visible)
Expanded(
flex: columns[i].flex,
child: Text(
columns[i].label.toUpperCase(),
textAlign:
columns[i].numeric ? TextAlign.right : TextAlign.left,
overflow: TextOverflow.ellipsis,
style: AppTypography.sectionLabel(),
),
),
],
),
),
const Divider(height: 1),
for (var r = 0; r < rows.length; r++)
InkWell(
onTap: onRowTap == null ? null : () => onRowTap!(r),
borderRadius: AppRadius.brXs,
child: Container(
padding:
const EdgeInsets.symmetric(vertical: AppSpacing.md),
decoration: const BoxDecoration(
border:
Border(bottom: BorderSide(color: AppColors.divider)),
),
child: Row(
children: [
for (final i in visible)
Expanded(
flex: columns[i].flex,
child: Align(
alignment: columns[i].numeric
? Alignment.centerRight
: Alignment.centerLeft,
child: rows[r][i],
),
),
],
),
),
),
],
);
},
);
}
Widget _stacked() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var r = 0; r < rows.length; r++)
Container(
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: AppRadius.brSm,
border: Border.all(color: AppColors.border),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var c = 0; c < columns.length; c++)
Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 104,
child: Text(
columns[c].label,
style: const TextStyle(
fontSize: 12,
color: AppColors.textTertiary,
),
),
),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: rows[r][c],
),
),
],
),
),
],
),
),
],
);
}
}
/// Plain text cell.
class Cell extends StatelessWidget {
const Cell(
this.text, {
super.key,
this.bold = false,
this.color,
this.mono = false,
});
final String text;
final bool bold;
final Color? color;
final bool mono;
@override
Widget build(BuildContext context) {
return Text(
text,
overflow: TextOverflow.ellipsis,
style: mono
? AppTypography.money(13.5,
weight: bold ? FontWeight.w700 : FontWeight.w500, color: color,)
: TextStyle(
fontSize: 13.5,
fontWeight: bold ? FontWeight.w600 : FontWeight.w400,
color: color ?? AppColors.textPrimary,
),
);
}
}
/// Small coloured status label.
class TagChip extends StatelessWidget {
const TagChip(this.label, {super.key, this.color = AppColors.primary});
final String label;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: AppRadius.brPill,
),
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color,
),
),
);
}
}
/// Lightweight bar chart painted in code, so no charting dependency is needed.
class MiniBarChart extends StatelessWidget {
const MiniBarChart({
super.key,
required this.values,
required this.labels,
this.height = 180,
this.color = AppColors.primary,
});
final List<double> values;
final List<String> labels;
final double height;
final Color color;
@override
Widget build(BuildContext context) {
if (values.isEmpty) return SizedBox(height: height);
final max = values.reduce((a, b) => a > b ? a : b);
return SizedBox(
height: height,
child: LayoutBuilder(
builder: (context, constraints) {
// Labels are dropped rather than squeezed when space is tight.
final showLabels = constraints.maxWidth / values.length >= 28;
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var i = 0; i < values.length; i++)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Expanded(
child: FractionallySizedBox(
alignment: Alignment.bottomCenter,
heightFactor:
max <= 0 ? 0 : (values[i] / max).clamp(0.03, 1),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
color,
color.withValues(alpha: 0.45),
],
),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(5),
),
),
),
),
),
if (showLabels) ...[
const SizedBox(height: AppSpacing.sm),
Text(
labels[i],
maxLines: 1,
overflow: TextOverflow.clip,
style: const TextStyle(
fontSize: 10.5,
color: AppColors.textTertiary,
),
),
],
],
),
),
),
],
);
},
),
);
}
}
/// Horizontal proportion bar used for breakdowns.
class ProgressRow extends StatelessWidget {
const ProgressRow({
super.key,
required this.label,
required this.value,
required this.fraction,
this.color = AppColors.primary,
});
final String label;
final String value;
final double fraction;
final Color color;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13),
),
),
const SizedBox(width: AppSpacing.sm),
Text(value, style: AppTypography.money(13)),
],
),
const SizedBox(height: AppSpacing.sm),
ClipRRect(
borderRadius: AppRadius.brPill,
child: LinearProgressIndicator(
value: fraction.clamp(0, 1),
minHeight: 6,
backgroundColor: AppColors.divider,
valueColor: AlwaysStoppedAnimation(color),
),
),
],
),
);
}
}