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/utils/formatters.dart'; import '../providers/cart_controller.dart'; import '../providers/navigation_provider.dart'; /// White page header: breadcrumb, title, and the terminal's quick actions. /// /// Replaces the old purple app bar now that branding lives in the sidebar. class PageHeader extends ConsumerWidget { const PageHeader({ super.key, required this.layout, this.onMenuTap, }); final PosLayout layout; final VoidCallback? onMenuTap; @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(); return Container( constraints: const BoxConstraints(minHeight: AppSizes.headerHeight), padding: EdgeInsets.symmetric( horizontal: layout.contentPadding, vertical: AppSpacing.md, ), decoration: const BoxDecoration( color: AppColors.surface, border: Border(bottom: BorderSide(color: AppColors.border)), ), child: Row( children: [ if (compact) ...[ IconButton( onPressed: onMenuTap, icon: const Icon(Icons.menu_rounded), tooltip: 'Menu', color: AppColors.textPrimary, ), const SizedBox(width: AppSpacing.xs), ], if (showStatus) ...[ _LivePill(offline: ref.watch(simulateOfflineProvider)), const SizedBox(width: AppSpacing.lg), Text( Formatters.time(now), style: const TextStyle( fontSize: 13.5, fontWeight: FontWeight.w500, color: AppColors.textSecondary, fontFeatures: [FontFeature.tabularFigures()], ), ), const SizedBox(width: AppSpacing.lg), Container(width: 1, height: 26, color: AppColors.border), const SizedBox(width: AppSpacing.lg), ], _ParkedBillsButton(compact: compact), const SizedBox(width: AppSpacing.sm), _NewSaleButton(compact: compact), ], ), ); } } /// What the pill is saying, in the order it takes precedence. enum _Liveness { offlineSim, halted, syncing, queued, live } class _LivePill extends ConsumerStatefulWidget { const _LivePill({required this.offline}); final bool offline; @override ConsumerState<_LivePill> createState() => _LivePillState(); } class _LivePillState extends ConsumerState<_LivePill> with SingleTickerProviderStateMixin { late final AnimationController _c = AnimationController( vsync: this, duration: const Duration(milliseconds: 1400), )..repeat(reverse: true); @override void dispose() { _c.dispose(); super.dispose(); } @override Widget build(BuildContext context) { // The engine may not have emitted yet on a cold start, so fall back to its // current value rather than showing nothing. final sync = ref.watch(syncEngineStateProvider).value ?? ref.watch(syncEngineProvider).state; final liveness = switch (0) { _ when widget.offline => _Liveness.offlineSim, _ when sync.isHalted => _Liveness.halted, _ when sync.isSyncing => _Liveness.syncing, // Bills waiting is normal for a few seconds after a sale; it is only // worth flagging once they are visibly piling up. _ when sync.pending > 0 => _Liveness.queued, _ => _Liveness.live, }; final (label, tone, surface, message) = switch (liveness) { _Liveness.offlineSim => ( 'OFFLINE (SIM)', AppColors.warning, AppColors.warningSurface, 'Simulate offline is ON in Settings — imports and syncs are being ' 'failed deliberately.', ), _Liveness.halted => ( 'SYNC HALTED', AppColors.danger, AppColors.dangerSurface, 'Uploading stopped because retrying will not help: ' '${sync.lastError ?? 'the back office refused the batch'}. ' 'Every bill is still safe on this terminal. Press Sync to try ' 'again once it is sorted.', ), _Liveness.syncing => ( 'SYNCING', AppColors.primary, AppColors.primarySurface, 'Uploading bills to the back office.', ), _Liveness.queued => ( '${sync.pending} QUEUED', AppColors.warning, AppColors.warningSurface, '${sync.pending} bill(s) are stored on this terminal and waiting to ' 'upload. They are safe; nothing is lost while the line is down.', ), _Liveness.live => ( 'LIVE', AppColors.success, AppColors.successSurface, 'Terminal is operating normally and everything rung has been ' 'uploaded.', ), }; return Tooltip( message: message, 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), Text( label, style: TextStyle( color: tone, fontSize: 10.5, fontWeight: FontWeight.w700, letterSpacing: 0.8, ), ), ], ), ), ); } } class _ParkedBillsButton extends ConsumerWidget { const _ParkedBillsButton({required this.compact}); final bool compact; @override Widget build(BuildContext context, WidgetRef ref) { final parked = ref.watch(parkedBillsProvider).value ?? const []; if (compact) { return IconButton( tooltip: 'Parked bills', onPressed: () => _openParked(context, ref), icon: Badge( isLabelVisible: parked.isNotEmpty, label: Text('${parked.length}'), backgroundColor: AppColors.warning, child: const Icon(Icons.pause_circle_outline_rounded), ), ); } return OutlinedButton.icon( onPressed: () => _openParked(context, ref), icon: const Icon(Icons.pause_circle_outline_rounded, size: 17), label: Text( parked.isEmpty ? 'Parked' : 'Parked (${parked.length})', ), style: OutlinedButton.styleFrom( minimumSize: const Size(0, 42), padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), foregroundColor: AppColors.textSecondary, ), ); } void _openParked(BuildContext context, WidgetRef ref) { final parked = ref.read(parkedBillsProvider).value ?? const []; if (parked.isEmpty) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(const SnackBar(content: Text('No parked bills.'))); return; } showDialog( context: context, builder: (_) => AlertDialog( title: const Text('Parked bills'), content: SizedBox( width: (MediaQuery.sizeOf(context).width - 96).clamp(280.0, 380.0), child: ListView.separated( shrinkWrap: true, itemCount: parked.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (_, i) { final bill = parked[i]; return ListTile( leading: const Icon(Icons.receipt_long_rounded, color: AppColors.primary,), title: Text(bill.displayLabel), subtitle: Text( '${bill.cart.lineCount} items · ' '${Formatters.money(bill.cart.grandTotal)} · ' '${Formatters.time(bill.parkedAt)}', ), onTap: () async { final hadItems = ref.read(cartControllerProvider).isNotEmpty; await ref .read(cartControllerProvider.notifier) .resume(bill); ref.invalidate(parkedBillsProvider); if (!context.mounted) return; Navigator.of(context).pop(); if (hadItems) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(const SnackBar( content: Text( 'The cart you were on was saved back to Parked ' 'bills.', ), ),); } }, ); }, ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Close'), ), ], ), ); } } class _NewSaleButton extends ConsumerWidget { const _NewSaleButton({required this.compact}); final bool compact; @override Widget build(BuildContext context, WidgetRef ref) { Future start() async { final hadItems = ref.read(cartControllerProvider).isNotEmpty; await ref.read(cartControllerProvider.notifier).startNewSale(); if (hadItems) ref.invalidate(parkedBillsProvider); ref.read(activeModuleProvider.notifier).state = PosModule.pos; if (!context.mounted) return; ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(SnackBar( content: Text( hadItems ? 'Previous cart saved to Parked bills. Started a new sale.' : 'Started a new sale.', ), ),); } if (compact) { return IconButton.filled( tooltip: 'New sale', onPressed: start, icon: const Icon(Icons.add_rounded), style: IconButton.styleFrom(backgroundColor: AppColors.primary), ); } return FilledButton.icon( onPressed: start, icon: const Icon(Icons.add_rounded, size: 18), label: const Text('New Sale'), style: FilledButton.styleFrom( backgroundColor: AppColors.primary, minimumSize: const Size(0, 42), padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), shape: const RoundedRectangleBorder(borderRadius: AppRadius.brSm), ), ); } }