967 lines
32 KiB
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,
|
|
),
|
|
);
|
|
}
|
|
}
|