Major UI overhaul

This commit is contained in:
2026-02-10 23:11:45 +08:00
parent f4dea74394
commit 01c6b3537c
17 changed files with 2977 additions and 1219 deletions
+183 -134
View File
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import '../providers/auth_provider.dart';
import '../providers/notifications_provider.dart';
import '../providers/profile_provider.dart';
import 'app_breakpoints.dart';
class AppScaffold extends ConsumerWidget {
const AppScaffold({super.key, required this.child});
@@ -31,9 +32,15 @@ class AppScaffold extends ConsumerWidget {
final sections = _buildSections(role);
final width = MediaQuery.of(context).size.width;
final showRail = !isStandard && width >= 860;
final isExtended = !isStandard && width >= 1120;
final showDrawer = !isStandard && !showRail;
final showRail = width >= AppBreakpoints.tablet;
final isExtended = width >= AppBreakpoints.desktop;
final railItems = _flattenSections(
sections,
).where((item) => !item.isLogout).toList();
final primaryItems = _primaryItemsForRole(role);
final mobilePrimary = _mobilePrimaryItems(primaryItems);
final overflowItems = _overflowItems(railItems, mobilePrimary);
return Scaffold(
appBar: AppBar(
@@ -78,48 +85,61 @@ class AppScaffold extends ConsumerWidget {
const _NotificationBell(),
],
),
drawer: showDrawer
? Drawer(
child: AppSideNav(
sections: sections,
location: location,
extended: true,
displayName: displayName,
onLogout: () => ref.read(authControllerProvider).signOut(),
),
)
: null,
bottomNavigationBar: isStandard
? AppBottomNav(location: location, items: _standardNavItems())
: null,
body: Row(
children: [
if (showRail)
AppSideNav(
sections: sections,
bottomNavigationBar: showRail
? null
: AppBottomNav(
location: location,
extended: isExtended,
displayName: displayName,
onLogout: () => ref.read(authControllerProvider).signOut(),
items: _mobileNavItems(mobilePrimary, overflowItems),
onShowMore: overflowItems.isEmpty
? null
: () => _showOverflowSheet(
context,
overflowItems,
() => ref.read(authControllerProvider).signOut(),
),
),
Expanded(child: _ShellBackground(child: child)),
],
body: LayoutBuilder(
builder: (context, constraints) {
final railWidth = showRail ? (isExtended ? 256.0 : 80.0) : 0.0;
return Stack(
children: [
Positioned.fill(
left: railWidth,
child: _ShellBackground(child: child),
),
if (showRail)
Positioned(
left: 0,
top: 0,
bottom: 0,
width: railWidth,
child: AppNavigationRail(
items: railItems,
location: location,
extended: isExtended,
displayName: displayName,
onLogout: () => ref.read(authControllerProvider).signOut(),
),
),
],
);
},
),
);
}
}
class AppSideNav extends StatelessWidget {
const AppSideNav({
class AppNavigationRail extends StatelessWidget {
const AppNavigationRail({
super.key,
required this.sections,
required this.items,
required this.location,
required this.extended,
required this.displayName,
required this.onLogout,
});
final List<NavSection> sections;
final List<NavItem> items;
final String location;
final bool extended;
final String displayName;
@@ -127,9 +147,8 @@ class AppSideNav extends StatelessWidget {
@override
Widget build(BuildContext context) {
final width = extended ? 240.0 : 72.0;
final currentIndex = _currentIndex(location, items);
return Container(
width: width,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
@@ -138,59 +157,21 @@ class AppSideNav extends StatelessWidget {
),
),
),
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 12),
children: [
Padding(
padding: EdgeInsets.symmetric(
horizontal: extended ? 16 : 12,
vertical: 8,
child: NavigationRail(
extended: extended,
selectedIndex: currentIndex,
onDestinationSelected: (value) {
items[value].onTap(context, onLogout: onLogout);
},
leading: const SizedBox.shrink(),
trailing: const SizedBox.shrink(),
destinations: [
for (final item in items)
NavigationRailDestination(
icon: Icon(item.icon),
selectedIcon: Icon(item.selectedIcon ?? item.icon),
label: Text(item.label),
),
child: Row(
children: [
Image.asset(
'assets/tasq_ico.png',
width: 28,
height: 28,
fit: BoxFit.contain,
),
if (extended) ...[
const SizedBox(width: 12),
Expanded(
child: Text(
displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],
],
),
),
const SizedBox(height: 4),
for (final section in sections) ...[
if (section.label != null && extended)
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Text(
section.label!,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
letterSpacing: 0.4,
),
),
),
for (final item in section.items)
_NavTile(
item: item,
selected: _isSelected(location, item.route),
extended: extended,
onLogout: onLogout,
),
],
],
),
);
@@ -198,10 +179,16 @@ class AppSideNav extends StatelessWidget {
}
class AppBottomNav extends StatelessWidget {
const AppBottomNav({super.key, required this.location, required this.items});
const AppBottomNav({
super.key,
required this.location,
required this.items,
this.onShowMore,
});
final String location;
final List<NavItem> items;
final VoidCallback? onShowMore;
@override
Widget build(BuildContext context) {
@@ -209,10 +196,12 @@ class AppBottomNav extends StatelessWidget {
return NavigationBar(
selectedIndex: index,
onDestinationSelected: (value) {
final target = items[value].route;
if (target.isNotEmpty) {
context.go(target);
final item = items[value];
if (item.isOverflow) {
onShowMore?.call();
return;
}
item.onTap(context);
},
destinations: [
for (final item in items)
@@ -226,50 +215,6 @@ class AppBottomNav extends StatelessWidget {
}
}
class _NavTile extends StatelessWidget {
const _NavTile({
required this.item,
required this.selected,
required this.extended,
required this.onLogout,
});
final NavItem item;
final bool selected;
final bool extended;
final VoidCallback onLogout;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final iconColor = selected ? colorScheme.primary : colorScheme.onSurface;
final background = selected
? colorScheme.primaryContainer.withValues(alpha: 0.6)
: Colors.transparent;
final content = Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
leading: Icon(item.icon, color: iconColor),
title: extended ? Text(item.label) : null,
onTap: () => item.onTap(context, onLogout: onLogout),
dense: true,
visualDensity: VisualDensity.compact,
),
);
if (extended) {
return content;
}
return Tooltip(message: item.label, child: content);
}
}
class _NotificationBell extends ConsumerWidget {
const _NotificationBell();
@@ -325,6 +270,7 @@ class NavItem {
required this.icon,
this.selectedIcon,
this.isLogout = false,
this.isOverflow = false,
});
final String label;
@@ -332,6 +278,7 @@ class NavItem {
final IconData icon;
final IconData? selectedIcon;
final bool isLogout;
final bool isOverflow;
void onTap(BuildContext context, {VoidCallback? onLogout}) {
if (isLogout) {
@@ -458,6 +405,106 @@ List<NavItem> _standardNavItems() {
];
}
List<NavItem> _primaryItemsForRole(String role) {
if (role == 'admin') {
return [
NavItem(
label: 'Dashboard',
route: '/dashboard',
icon: Icons.grid_view_outlined,
selectedIcon: Icons.grid_view_rounded,
),
NavItem(
label: 'Tickets',
route: '/tickets',
icon: Icons.support_agent_outlined,
selectedIcon: Icons.support_agent,
),
NavItem(
label: 'Tasks',
route: '/tasks',
icon: Icons.task_outlined,
selectedIcon: Icons.task,
),
NavItem(
label: 'Workforce',
route: '/workforce',
icon: Icons.groups_outlined,
selectedIcon: Icons.groups,
),
NavItem(
label: 'Reports',
route: '/reports',
icon: Icons.analytics_outlined,
selectedIcon: Icons.analytics,
),
];
}
return _standardNavItems();
}
List<NavItem> _mobileNavItems(List<NavItem> primary, List<NavItem> overflow) {
if (overflow.isEmpty) {
return primary;
}
return [
...primary,
NavItem(label: 'More', route: '', icon: Icons.more_horiz, isOverflow: true),
];
}
List<NavItem> _mobilePrimaryItems(List<NavItem> primary) {
if (primary.length <= 4) {
return primary;
}
return primary.take(4).toList();
}
List<NavItem> _flattenSections(List<NavSection> sections) {
return [for (final section in sections) ...section.items];
}
List<NavItem> _overflowItems(List<NavItem> all, List<NavItem> primary) {
final primaryRoutes = primary.map((item) => item.route).toSet();
return all
.where(
(item) =>
!item.isLogout &&
item.route.isNotEmpty &&
!primaryRoutes.contains(item.route),
)
.toList();
}
Future<void> _showOverflowSheet(
BuildContext context,
List<NavItem> items,
VoidCallback onLogout,
) async {
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
for (final item in items)
ListTile(
leading: Icon(item.icon),
title: Text(item.label),
onTap: () {
Navigator.of(context).pop();
item.onTap(context, onLogout: onLogout);
},
),
],
),
);
},
);
}
bool _isSelected(String location, String route) {
if (route.isEmpty) return false;
if (location == route) return true;
@@ -466,5 +513,7 @@ bool _isSelected(String location, String route) {
int _currentIndex(String location, List<NavItem> items) {
final index = items.indexWhere((item) => _isSelected(location, item.route));
return index == -1 ? 0 : index;
if (index != -1) return index;
final overflowIndex = items.indexWhere((item) => item.isOverflow);
return overflowIndex == -1 ? 0 : overflowIndex;
}