second commit

This commit is contained in:
2026-07-29 11:41:53 +05:30
parent fcccf22bac
commit d72522e737
211 changed files with 19260 additions and 0 deletions

View File

@@ -0,0 +1,485 @@
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 '../../../domain/entities/sync_event.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(syncEventsProvider)
.where((e) => e.status != SyncStatus.synced)
.length;
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,
),
),
],
],
),
),
),
),
);
}
}