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, ), ), ], ], ), ), ), ), ); } }