third commit

This commit is contained in:
2026-07-31 17:06:52 +05:30
parent d9886880cc
commit 9891a69a5f
32 changed files with 2083 additions and 557 deletions

View File

@@ -12,6 +12,7 @@ import '../../../core/utils/formatters.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../domain/entities/cart.dart';
import '../../customer/widgets/customer_capture_sheet.dart';
import '../providers/cart_controller.dart';
import 'cart_line_tile.dart';
import 'discount_sheet.dart';
@@ -404,7 +405,16 @@ class _Actions extends ConsumerWidget {
child: PrimaryButton(
label: 'CHARGE',
large: true,
onPressed: enabled ? () => context.push(AppRoutes.payment) : null,
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);
}
if (context.mounted) context.push(AppRoutes.payment);
}
: null,
trailing: enabled
? Text(
Formatters.money(cart.grandTotal),

View File

@@ -19,9 +19,16 @@ class CustomerBar extends ConsumerWidget {
);
return Container(
height: AppSizes.customerBarHeight,
// A hard height clipped the subtitle once it wrapped. Minimum height
// keeps the strip its usual size but lets it grow if it must.
constraints: const BoxConstraints(
minHeight: AppSizes.customerBarHeight,
),
color: AppColors.surface,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xxl),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xl,
vertical: AppSpacing.sm,
),
child: Row(children: [
CircleAvatar(
radius: 20,
@@ -63,11 +70,17 @@ class CustomerBar extends ConsumerWidget {
Text(
'${Formatters.mobile(customer.mobile)} · '
'${customer.loyaltyPoints} pts',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.text.bodySmall,
)
else
Text('No loyalty tracking for this sale',
style: context.text.bodySmall),
Text(
'No loyalty tracking for this sale',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.text.bodySmall,
),
],
),
),

View File

@@ -24,11 +24,27 @@ class PageHeader extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final compact = layout.sidebarIsDrawer;
// Measured from the header's own box, not the window. The window can be
// wide while this column is narrow — the sidebar and the docked bill both
// take from it — which is how the status chrome ended up overflowing.
return LayoutBuilder(
builder: (context, box) {
final showStatus = box.maxWidth >= 720;
return _bar(context, ref, compact, showStatus);
},
);
}
Widget _bar(
BuildContext context,
WidgetRef ref,
bool compact,
bool showStatus,
) {
final module = ref.watch(activeModuleProvider);
final now = ref.watch(clockProvider).value ?? DateTime.now();
final compact = layout.sidebarIsDrawer;
// Status chrome is the first thing dropped when width gets tight.
final showStatus = MediaQuery.sizeOf(context).width >= 1180;
return Container(
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
@@ -76,7 +92,7 @@ class PageHeader extends ConsumerWidget {
const Spacer(),
if (showStatus) ...[
const _LivePill(),
_LivePill(offline: ref.watch(simulateOfflineProvider)),
const SizedBox(width: AppSpacing.lg),
Text(
Formatters.time(now),
@@ -139,7 +155,9 @@ class _Breadcrumb extends StatelessWidget {
}
class _LivePill extends StatefulWidget {
const _LivePill();
const _LivePill({required this.offline});
final bool offline;
@override
State<_LivePill> createState() => _LivePillState();
@@ -160,40 +178,48 @@ class _LivePillState extends State<_LivePill>
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.xs + 2,
),
decoration: BoxDecoration(
color: AppColors.successSurface,
borderRadius: AppRadius.brPill,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
FadeTransition(
opacity: _c,
child: Container(
width: 7,
height: 7,
decoration: const BoxDecoration(
color: AppColors.success,
shape: BoxShape.circle,
final offline = widget.offline;
final tone = offline ? AppColors.warning : AppColors.success;
final surface =
offline ? AppColors.warningSurface : AppColors.successSurface;
return Tooltip(
message: offline
? 'Simulate offline is ON in Settings — imports and syncs are being '
'failed deliberately.'
: 'Terminal is operating normally.',
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.xs + 2,
),
decoration: BoxDecoration(
color: surface,
borderRadius: AppRadius.brPill,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
FadeTransition(
opacity: _c,
child: Container(
width: 7,
height: 7,
decoration: BoxDecoration(color: tone, shape: BoxShape.circle),
),
),
),
const SizedBox(width: AppSpacing.xs + 2),
const Text(
'LIVE',
style: TextStyle(
color: AppColors.success,
fontSize: 10.5,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
const SizedBox(width: AppSpacing.xs + 2),
Text(
offline ? 'OFFLINE (SIM)' : 'LIVE',
style: TextStyle(
color: tone,
fontSize: 10.5,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
),
),
),
],
],
),
),
);
}

View File

@@ -62,80 +62,114 @@ class _ProductCardState extends State<ProductCard> {
: AppColors.shadowSm,
),
child: Stack(children: [
Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Opacity(
opacity: disabled ? 0.4 : 1,
child: Text(p.emoji,
style: const TextStyle(fontSize: 40)),
// The grid gives tiles a width derived from the viewport, so
// on narrow screens they can be far shorter than the content's
// natural height. Everything is sized from the box we actually
// get rather than from fixed constants.
LayoutBuilder(
builder: (context, box) {
final h = box.maxHeight;
final tight = h < 170;
final emoji = (h * 0.22).clamp(20.0, 38.0);
final nameSize = tight ? 12.0 : 13.5;
final priceSize = tight ? 14.0 : 16.0;
return Padding(
padding: EdgeInsets.all(
tight ? AppSpacing.sm : AppSpacing.md,
),
const SizedBox(height: AppSpacing.sm),
Text(
p.name,
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.25,
color: disabled
? AppColors.textTertiary
: AppColors.textPrimary,
),
),
const SizedBox(height: AppSpacing.xs + 2),
// Scales down rather than overflowing on small tiles.
FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
Formatters.money(p.price),
style: AppTypography.money(17,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Opacity(
opacity: disabled ? 0.4 : 1,
child: Text(
p.emoji,
style: TextStyle(fontSize: emoji),
),
),
SizedBox(height: tight ? 2 : AppSpacing.sm),
// Flexible so a long name gives way instead of
// pushing the price and stock line out of the tile.
Flexible(
child: Text(
p.name,
maxLines: tight ? 1 : 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: nameSize,
fontWeight: FontWeight.w600,
height: 1.2,
color: disabled
? AppColors.textTertiary
: AppColors.primary),
),
if (p.hasDiscount) ...[
const SizedBox(width: AppSpacing.xs + 2),
Padding(
padding: const EdgeInsets.only(bottom: 1.5),
child: Text(
Formatters.money(p.mrp!),
style: const TextStyle(
fontSize: 11.5,
color: AppColors.textTertiary,
decoration: TextDecoration.lineThrough,
),
: AppColors.textPrimary,
),
),
),
SizedBox(height: tight ? 2 : AppSpacing.xs),
FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
Formatters.money(p.price),
style: AppTypography.money(
priceSize,
color: disabled
? AppColors.textTertiary
: AppColors.primary,
),
),
if (p.hasDiscount && !tight) ...[
const SizedBox(width: AppSpacing.xs),
Padding(
padding: const EdgeInsets.only(bottom: 1.5),
child: Text(
Formatters.money(p.mrp!),
style: const TextStyle(
fontSize: 11,
color: AppColors.textTertiary,
decoration:
TextDecoration.lineThrough,
),
),
),
],
],
),
),
// Dropped first when the tile is too short for it.
if (h >= 140) ...[
const SizedBox(height: 2),
Text(
disabled
? 'Out of stock'
: '${p.stock.toStringAsFixed(0)} in stock',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: disabled
? AppColors.danger
: (p.isLowStock
? AppColors.warning
: AppColors.textTertiary),
),
),
],
],
),
],
),
const SizedBox(height: AppSpacing.xs),
Text(
disabled
? 'Out of stock'
: '${p.stock.toStringAsFixed(0)} in stock',
style: TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w500,
color: disabled
? AppColors.danger
: (p.isLowStock
? AppColors.warning
: AppColors.textTertiary),
),
),
],
),
);
},
),
if (p.hasDiscount && !disabled)

View File

@@ -61,7 +61,10 @@ class ProductGrid extends ConsumerWidget {
maxCrossAxisExtent: tileExtent,
mainAxisSpacing: AppSpacing.md,
crossAxisSpacing: AppSpacing.md,
childAspectRatio: AppSizes.productCardAspect,
// A fixed height, not a ratio. With childAspectRatio the tile grew
// taller as the column got wider, leaving a large empty band under
// every card on a full-screen window.
mainAxisExtent: 186,
),
itemCount: items.length,
itemBuilder: (context, i) {