bharat_erp/lib/shared/widgets/app_sidebar.dart

967 lines
32 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants/app_constants.dart';
import '../../core/constants/enums.dart';
import '../../core/constants/route_constants.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/theme_provider.dart';
import '../../modules/settings/presentation/providers/settings_provider.dart';
import '../models/user_model.dart';
import '../providers/auth_provider.dart';
import '../routes/menu_config.dart' as menu;
import 'sidebar_logo.dart';
const _sidebarExpandedWidth = 280.0;
const _sidebarCollapsedWidth = 72.0;
const _sidebarNarrowBreakpoint = 200.0;
const _sidebarItemHeight = 44.0;
const _sidebarItemPadding = 12.0;
const _sidebarIconSize = 20.0;
const _sidebarChildIndent = 28.0;
class AppSidebar extends ConsumerStatefulWidget {
const AppSidebar({
super.key,
required this.menuItems,
required this.currentRoute,
required this.onItemTap,
this.user,
this.collapsed = false,
this.onToggleCollapse,
this.isDrawer = false,
});
final List<menu.MenuItem> menuItems;
final String currentRoute;
final void Function(String route) onItemTap;
final UserModel? user;
final bool collapsed;
final VoidCallback? onToggleCollapse;
final bool isDrawer;
@override
ConsumerState<AppSidebar> createState() => _AppSidebarState();
}
class _AppSidebarState extends ConsumerState<AppSidebar> {
final Set<String> _expandedMenus = <String>{};
final Set<String> _manuallyCollapsedMenus = <String>{};
List<menu.MenuItem> get _mainMenuItems =>
widget.menuItems.where((item) => item.route != RouteConstants.settings).toList();
bool _routeMatches(String route) {
final current = widget.currentRoute;
if (route == '/') return current == route;
if (current == route) return true;
return current.startsWith('$route/');
}
bool _isSelected(String route) => _routeMatches(route);
bool _isSelectedAmongSiblings(String route, List<menu.MenuItem> siblings) {
if (!_routeMatches(route)) return false;
for (final sibling in siblings) {
if (sibling.route == route) continue;
if (sibling.route.length > route.length && _routeMatches(sibling.route)) {
return false;
}
}
return true;
}
bool _isGroupActive(menu.MenuItem item) => item.children
.any((child) => _isSelectedAmongSiblings(child.route, item.children));
bool _isGroupExpanded(menu.MenuItem item) {
if (_manuallyCollapsedMenus.contains(item.route)) return false;
return _expandedMenus.contains(item.route) || _isGroupActive(item);
}
void _toggleGroup(menu.MenuItem item) {
setState(() {
if (_isGroupExpanded(item)) {
_expandedMenus.remove(item.route);
_manuallyCollapsedMenus.add(item.route);
} else {
_manuallyCollapsedMenus.remove(item.route);
_expandedMenus.add(item.route);
}
});
}
@override
void didUpdateWidget(covariant AppSidebar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.currentRoute != widget.currentRoute) {
for (final item in _mainMenuItems) {
if (item.children.isNotEmpty && _isGroupActive(item)) {
_expandedMenus.add(item.route);
_manuallyCollapsedMenus.remove(item.route);
}
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final themeMode = ref.watch(themeModeProvider);
final isLightActive = themeMode == ThemeModeOption.light ||
(themeMode == ThemeModeOption.system && !isDark);
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
width: widget.isDrawer
? double.infinity
: (widget.collapsed ? _sidebarCollapsedWidth : _sidebarExpandedWidth),
child: DecoratedBox(
decoration: BoxDecoration(
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.08),
),
boxShadow: widget.isDrawer
? null
: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.06),
blurRadius: 24,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: LayoutBuilder(
builder: (context, constraints) {
final isNarrow = !widget.isDrawer &&
constraints.maxWidth < _sidebarNarrowBreakpoint;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(context, isNarrow: isNarrow),
if (!isNarrow) ...[
const SizedBox(height: 16),
_buildThemeToggle(context, isLightActive),
],
const SizedBox(height: 20),
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: isNarrow ? 8 : 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!isNarrow)
const _SectionLabel(label: 'MAIN MENU')
else
const SizedBox(height: 4),
..._mainMenuItems.map((item) {
if (isNarrow && item.children.isNotEmpty) {
return _CollapsedFlyoutNavItem(
item: item,
selected: _isGroupActive(item),
isChildSelected: (route) =>
_isSelectedAmongSiblings(route, item.children),
onChildTap: widget.onItemTap,
);
}
if (item.children.isNotEmpty && !isNarrow) {
final expanded = _isGroupExpanded(item);
final active = _isGroupActive(item);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_SidebarNavItem(
icon: item.icon,
label: item.label,
selected: active,
collapsed: false,
showChevron: true,
chevronExpanded: expanded,
onTap: () => _toggleGroup(item),
),
if (expanded)
...item.children.map(
(child) => _SidebarNavItem(
icon: child.icon,
label: child.label,
selected: _isSelectedAmongSiblings(
child.route,
item.children,
),
collapsed: false,
indent: _sidebarChildIndent,
onTap: () => widget.onItemTap(child.route),
),
),
],
);
}
return _SidebarNavItem(
icon: item.icon,
label: item.label,
selected: _isSelected(item.route) ||
(isNarrow && _isGroupActive(item)),
collapsed: isNarrow,
onTap: () {
widget.onItemTap(item.route);
},
);
}),
const SizedBox(height: 16),
if (!isNarrow) const _SectionLabel(label: 'SUPPORT'),
_SidebarNavItem(
icon: Icons.notifications_outlined,
label: 'Notifications',
selected: false,
collapsed: isNarrow,
badge: isNarrow ? null : '3',
onTap: () {},
),
if (_hasSettings)
_SidebarNavItem(
icon: Icons.settings_outlined,
label: 'Settings',
selected: _isSelected(RouteConstants.settings),
collapsed: isNarrow,
onTap: () => widget.onItemTap(RouteConstants.settings),
),
],
),
),
),
_buildUserProfile(context, isNarrow: isNarrow),
],
);
},
),
),
),
);
}
bool get _hasSettings =>
widget.menuItems.any((item) => item.route == RouteConstants.settings);
Widget _buildHeader(BuildContext context, {required bool isNarrow}) {
final theme = Theme.of(context);
final companyProfile = ref.watch(appSettingsProvider).companyProfile;
final branding = ref.watch(brandingProvider);
final logoUrl = resolveSidebarLogoUrl(
companyProfileLogo: companyProfile.logoUrl,
brandingLogo: branding.logoUrl,
);
final title = resolveSidebarTitle(
companyName: companyProfile.companyName,
fallback: AppConstants.appName,
);
if (isNarrow) {
return Padding(
padding: const EdgeInsets.fromLTRB(6, 16, 6, 0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SidebarLogo(logoUrl: logoUrl, size: 32),
if (widget.onToggleCollapse != null) ...[
const SizedBox(height: 2),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 32, height: 32),
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.chevron_right, size: 18),
tooltip: 'Expand sidebar',
onPressed: widget.onToggleCollapse,
),
],
],
),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(14, 16, 8, 0),
child: Row(
children: [
SidebarLogo(logoUrl: logoUrl, size: 36),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
),
),
),
if (widget.onToggleCollapse != null)
IconButton(
icon: const Icon(Icons.chevron_left, size: 20),
tooltip: 'Collapse sidebar',
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints.tightFor(width: 36, height: 36),
onPressed: widget.onToggleCollapse,
),
],
),
);
}
Widget _buildThemeToggle(BuildContext context, bool isLightActive) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Container(
height: 36,
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
Expanded(
child: _ThemeToggleOption(
label: 'LIGHT',
selected: isLightActive,
onTap: () => ref.read(themeModeProvider.notifier).setThemeMode(ThemeModeOption.light),
),
),
Expanded(
child: _ThemeToggleOption(
label: 'DARK',
selected: !isLightActive,
onTap: () => ref.read(themeModeProvider.notifier).setThemeMode(ThemeModeOption.dark),
),
),
],
),
),
);
}
Widget _buildUserProfile(BuildContext context, {required bool isNarrow}) {
final theme = Theme.of(context);
final user = widget.user;
final name = user?.name ?? 'User';
final email = user?.email ?? '';
if (isNarrow) {
return Padding(
padding: const EdgeInsets.all(12),
child: Center(
child: _UserProfileMenu(
userName: name,
menuOffset: const Offset(-8, -210),
child: _UserAvatar(name: name),
),
),
);
}
return Padding(
padding: const EdgeInsets.all(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
_UserAvatar(name: name),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
Text(
email,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
_UserProfileMenu(userName: name),
],
),
),
);
}
}
class _CollapsedFlyoutNavItem extends StatefulWidget {
const _CollapsedFlyoutNavItem({
required this.item,
required this.selected,
required this.isChildSelected,
required this.onChildTap,
});
final menu.MenuItem item;
final bool selected;
final bool Function(String route) isChildSelected;
final void Function(String route) onChildTap;
@override
State<_CollapsedFlyoutNavItem> createState() =>
_CollapsedFlyoutNavItemState();
}
class _CollapsedFlyoutNavItemState extends State<_CollapsedFlyoutNavItem> {
final _anchorKey = GlobalKey();
OverlayEntry? _overlayEntry;
Timer? _hideTimer;
@override
void dispose() {
_hideTimer?.cancel();
_removeOverlay();
super.dispose();
}
void _cancelHide() {
_hideTimer?.cancel();
_hideTimer = null;
}
void _scheduleHide() {
_cancelHide();
_hideTimer = Timer(const Duration(milliseconds: 50), () {
if (!mounted) return;
_removeOverlay();
});
}
void _removeOverlay() {
_overlayEntry?.remove();
_overlayEntry = null;
if (mounted) setState(() {});
}
void _showOverlay() {
if (_overlayEntry != null) return;
_insertOverlay();
if (_overlayEntry == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _overlayEntry != null) return;
_insertOverlay();
});
}
}
void _insertOverlay() {
if (_overlayEntry != null) return;
final renderBox =
_anchorKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null) return;
final anchorTopLeft = renderBox.localToGlobal(Offset.zero);
final anchorSize = renderBox.size;
const panelWidth = 220.0;
_overlayEntry = OverlayEntry(
builder: (overlayContext) {
final theme = Theme.of(overlayContext);
final primary = theme.colorScheme.primary;
return Positioned(
left: anchorTopLeft.dx,
top: anchorTopLeft.dy,
child: TapRegion(
onTapOutside: (_) => _removeOverlay(),
child: MouseRegion(
onEnter: (_) => _cancelHide(),
onExit: (_) => _scheduleHide(),
child: _SidebarFlyoutFade(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: anchorSize.width,
height: anchorSize.height,
),
Material(
elevation: 8,
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
shadowColor: Colors.black45,
child: SizedBox(
width: panelWidth,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 8),
child: Text(
widget.item.label,
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.4,
),
),
),
const Divider(height: 1),
...widget.item.children.map((child) {
final childSelected =
widget.isChildSelected(child.route);
return InkWell(
onTap: () {
_removeOverlay();
widget.onChildTap(child.route);
},
child: Container(
height: _sidebarItemHeight,
padding: const EdgeInsets.symmetric(
horizontal: _sidebarItemPadding,
),
color: childSelected
? primary.withValues(alpha: 0.1)
: null,
child: Row(
children: [
Icon(
child.icon,
size: _sidebarIconSize,
color: childSelected
? primary
: theme
.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
child.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium
?.copyWith(
color: childSelected
? primary
: theme.colorScheme
.onSurfaceVariant,
fontWeight: childSelected
? FontWeight.w600
: FontWeight.w500,
),
),
),
],
),
),
);
}),
],
),
),
),
],
),
),
),
),
);
},
);
Overlay.of(context).insert(_overlayEntry!);
setState(() {});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final primary = theme.colorScheme.primary;
return MouseRegion(
onEnter: (_) {
_cancelHide();
_showOverlay();
},
onExit: (_) {
if (_overlayEntry != null) return;
_scheduleHide();
},
child: KeyedSubtree(
key: _anchorKey,
child: Padding(
padding: const EdgeInsets.only(bottom: 4),
child: SizedBox(
width: double.infinity,
child: InkWell(
onTap: _showOverlay,
borderRadius: BorderRadius.circular(12),
child: Container(
height: _sidebarItemHeight,
alignment: Alignment.center,
decoration: BoxDecoration(
color: widget.selected || _overlayEntry != null
? primary.withValues(alpha: 0.12)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
widget.item.icon,
size: 22,
color: widget.selected || _overlayEntry != null
? primary
: theme.colorScheme.onSurfaceVariant,
),
Icon(
Icons.chevron_right,
size: 12,
color: theme.colorScheme.onSurfaceVariant
.withValues(alpha: 0.7),
),
],
),
),
),
),
),
),
);
}
}
class _SidebarFlyoutFade extends StatefulWidget {
const _SidebarFlyoutFade({required this.child});
final Widget child;
@override
State<_SidebarFlyoutFade> createState() => _SidebarFlyoutFadeState();
}
class _SidebarFlyoutFadeState extends State<_SidebarFlyoutFade>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _fade;
late final Animation<Offset> _slide;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 120),
);
final curve = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic);
_fade = curve;
_slide = Tween<Offset>(
begin: const Offset(-0.04, 0),
end: Offset.zero,
).animate(curve);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _fade,
child: SlideTransition(
position: _slide,
child: widget.child,
),
);
}
}
class _SectionLabel extends StatelessWidget {
const _SectionLabel({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 12, bottom: 8),
child: Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
),
),
);
}
}
class _SidebarNavItem extends StatelessWidget {
const _SidebarNavItem({
required this.icon,
required this.label,
required this.selected,
required this.collapsed,
required this.onTap,
this.badge,
this.indent = 0,
this.showChevron = false,
this.chevronExpanded = false,
});
final IconData icon;
final String label;
final bool selected;
final bool collapsed;
final VoidCallback onTap;
final String? badge;
final double indent;
final bool showChevron;
final bool chevronExpanded;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final primary = theme.colorScheme.primary;
const itemHeight = _sidebarItemHeight;
const itemPadding = _sidebarItemPadding;
const iconSize = _sidebarIconSize;
if (collapsed) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: SizedBox(
width: double.infinity,
child: Tooltip(
message: label,
waitDuration: const Duration(milliseconds: 250),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
height: itemHeight,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? primary.withValues(alpha: 0.12) : Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
size: 22,
color: selected ? primary : theme.colorScheme.onSurfaceVariant,
),
),
),
),
),
);
}
return Padding(
padding: EdgeInsets.only(left: indent, bottom: 4),
child: Material(
color: selected ? primary.withValues(alpha: 0.1) : Colors.transparent,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: itemHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: itemPadding),
child: Row(
children: [
SizedBox(
width: iconSize,
child: Icon(
icon,
size: iconSize,
color: selected ? primary : theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: selected ? primary : theme.colorScheme.onSurfaceVariant,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
),
),
),
if (badge != null)
Container(
margin: const EdgeInsets.only(right: 4),
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: AppColors.success,
borderRadius: BorderRadius.circular(10),
),
child: Text(
badge!,
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
if (showChevron)
Icon(
chevronExpanded
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
),
),
),
),
);
}
}
class _ThemeToggleOption extends StatelessWidget {
const _ThemeToggleOption({
required this.label,
required this.selected,
required this.onTap,
});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: AppConstants.animationDuration,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? theme.colorScheme.primary : Colors.transparent,
borderRadius: BorderRadius.circular(16),
),
child: Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: selected ? Colors.white : theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
),
);
}
}
class _UserAvatar extends StatelessWidget {
const _UserAvatar({required this.name});
final String name;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return CircleAvatar(
radius: 18,
backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.15),
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : 'U',
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
);
}
}
class _UserProfileMenu extends ConsumerWidget {
const _UserProfileMenu({
this.userName,
this.child,
this.menuOffset,
});
final String? userName;
final Widget? child;
final Offset? menuOffset;
@override
Widget build(BuildContext context, WidgetRef ref) {
return PopupMenuButton<String>(
tooltip: userName ?? 'Account menu',
padding: EdgeInsets.zero,
offset: menuOffset ?? Offset.zero,
position: child != null
? PopupMenuPosition.over
: PopupMenuPosition.under,
onSelected: (value) async {
switch (value) {
case 'profile':
context.push(RouteConstants.profile);
case 'password':
context.push(RouteConstants.changePassword);
case 'settings':
context.go(RouteConstants.settings);
case 'logout':
await ref.read(authStateProvider.notifier).logout();
if (context.mounted) context.go(RouteConstants.login);
}
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'profile', child: Text('Profile')),
const PopupMenuItem(value: 'password', child: Text('Change Password')),
const PopupMenuItem(value: 'settings', child: Text('Settings')),
const PopupMenuDivider(),
const PopupMenuItem(value: 'logout', child: Text('Logout')),
],
child: child ??
Icon(
Icons.more_vert,
size: 20,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
}