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
+27
View File
@@ -0,0 +1,27 @@
import 'package:flutter/widgets.dart';
class AppBreakpoints {
static const double mobile = 600;
static const double tablet = 900;
static const double desktop = 1200;
static bool isMobile(BuildContext context) {
return MediaQuery.of(context).size.width < mobile;
}
static bool isTablet(BuildContext context) {
final width = MediaQuery.of(context).size.width;
return width >= mobile && width < desktop;
}
static bool isDesktop(BuildContext context) {
return MediaQuery.of(context).size.width >= desktop;
}
static double horizontalPadding(double width) {
if (width >= desktop) return 96;
if (width >= tablet) return 64;
if (width >= mobile) return 32;
return 16;
}
}
+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;
}
+32
View File
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import '../theme/app_typography.dart';
class MonoText extends StatelessWidget {
const MonoText(
this.text, {
super.key,
this.style,
this.maxLines,
this.overflow,
this.textAlign,
});
final String text;
final TextStyle? style;
final int? maxLines;
final TextOverflow? overflow;
final TextAlign? textAlign;
@override
Widget build(BuildContext context) {
final base = AppMonoText.of(context).body;
return Text(
text,
style: base.merge(style),
maxLines: maxLines,
overflow: overflow,
textAlign: textAlign,
);
}
}
+16 -13
View File
@@ -1,5 +1,7 @@
import 'package:flutter/widgets.dart';
import 'app_breakpoints.dart';
class ResponsiveBody extends StatelessWidget {
const ResponsiveBody({
super.key,
@@ -16,13 +18,18 @@ class ResponsiveBody extends StatelessWidget {
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final horizontalPadding = switch (width) {
>= 1200 => 96.0,
>= 900 => 64.0,
>= 600 => 32.0,
_ => 16.0,
};
final height = constraints.hasBoundedHeight
? constraints.maxHeight
: MediaQuery.sizeOf(context).height;
final width = constraints.hasBoundedWidth
? constraints.maxWidth
: maxWidth;
final horizontalPadding = AppBreakpoints.horizontalPadding(width);
final boxConstraints = BoxConstraints(
maxWidth: maxWidth,
minHeight: height,
maxHeight: height,
);
return Padding(
padding: padding.add(
@@ -31,12 +38,8 @@ class ResponsiveBody extends StatelessWidget {
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: SizedBox(
width: double.infinity,
height: constraints.maxHeight,
child: child,
),
constraints: boxConstraints,
child: SizedBox(width: width, child: child),
),
),
);
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
class StatusPill extends StatelessWidget {
const StatusPill({super.key, required this.label, this.isEmphasized = false});
final String label;
final bool isEmphasized;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final background = isEmphasized
? scheme.tertiaryContainer
: scheme.tertiaryContainer.withValues(alpha: 0.65);
final foreground = scheme.onTertiaryContainer;
return AnimatedContainer(
duration: const Duration(milliseconds: 220),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: scheme.tertiary.withValues(alpha: 0.3)),
),
child: Text(
label.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: foreground,
fontWeight: FontWeight.w600,
letterSpacing: 0.4,
),
),
);
}
}
+251
View File
@@ -0,0 +1,251 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../theme/app_typography.dart';
import 'mono_text.dart';
class TasQColumn<T> {
const TasQColumn({
required this.header,
required this.cellBuilder,
this.technical = false,
});
final String header;
final Widget Function(BuildContext context, T item) cellBuilder;
final bool technical;
}
typedef TasQMobileTileBuilder<T> =
Widget Function(BuildContext context, T item, List<Widget> actions);
typedef TasQRowActions<T> = List<Widget> Function(T item);
typedef TasQRowTap<T> = void Function(T item);
class TasQAdaptiveList<T> extends StatelessWidget {
const TasQAdaptiveList({
super.key,
required this.items,
required this.columns,
required this.mobileTileBuilder,
this.rowActions,
this.onRowTap,
this.rowsPerPage = 25,
this.tableHeader,
this.filterHeader,
this.summaryDashboard,
});
final List<T> items;
final List<TasQColumn<T>> columns;
final TasQMobileTileBuilder<T> mobileTileBuilder;
final TasQRowActions<T>? rowActions;
final TasQRowTap<T>? onRowTap;
final int rowsPerPage;
final Widget? tableHeader;
final Widget? filterHeader;
final Widget? summaryDashboard;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final isMobile = constraints.maxWidth < 600;
final hasBoundedHeight = constraints.hasBoundedHeight;
if (isMobile) {
final listView = ListView.separated(
padding: const EdgeInsets.only(bottom: 24),
itemCount: items.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final item = items[index];
final actions = rowActions?.call(item) ?? const <Widget>[];
return mobileTileBuilder(context, item, actions);
},
);
final shrinkWrappedList = ListView.separated(
padding: const EdgeInsets.only(bottom: 24),
itemCount: items.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final item = items[index];
final actions = rowActions?.call(item) ?? const <Widget>[];
return mobileTileBuilder(context, item, actions);
},
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
);
final summarySection = summaryDashboard == null
? null
: <Widget>[
SizedBox(width: double.infinity, child: summaryDashboard!),
const SizedBox(height: 12),
];
final filterSection = filterHeader == null
? null
: <Widget>[
ExpansionTile(
title: const Text('Filters'),
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: filterHeader!,
),
],
),
const SizedBox(height: 8),
];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
...?summarySection,
...?filterSection,
if (hasBoundedHeight) Expanded(child: listView),
if (!hasBoundedHeight) shrinkWrappedList,
],
),
);
}
final dataSource = _TasQTableSource<T>(
context: context,
items: items,
columns: columns,
rowActions: rowActions,
onRowTap: onRowTap,
);
final contentWidth = constraints.maxWidth * 0.8;
final tableWidth = math.max(
contentWidth,
(columns.length + (rowActions == null ? 0 : 1)) * 140.0,
);
final effectiveRowsPerPage = math.min(
rowsPerPage,
math.max(1, items.length),
);
final tableWidget = SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
width: tableWidth,
child: PaginatedDataTable(
header: tableHeader,
rowsPerPage: effectiveRowsPerPage,
columnSpacing: 20,
horizontalMargin: 16,
showCheckboxColumn: false,
headingRowColor: WidgetStateProperty.resolveWith(
(states) => Theme.of(context).colorScheme.surfaceContainer,
),
columns: [
for (final column in columns)
DataColumn(label: Text(column.header)),
if (rowActions != null)
const DataColumn(label: Text('Actions')),
],
source: dataSource,
),
),
);
final summarySection = summaryDashboard == null
? null
: <Widget>[
SizedBox(width: contentWidth, child: summaryDashboard!),
const SizedBox(height: 12),
];
final filterSection = filterHeader == null
? null
: <Widget>[filterHeader!, const SizedBox(height: 12)];
return SingleChildScrollView(
primary: hasBoundedHeight,
child: Center(
child: SizedBox(
width: contentWidth,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [...?summarySection, ...?filterSection, tableWidget],
),
),
),
);
},
);
}
}
class _TasQTableSource<T> extends DataTableSource {
_TasQTableSource({
required this.context,
required this.items,
required this.columns,
required this.rowActions,
required this.onRowTap,
});
final BuildContext context;
final List<T> items;
final List<TasQColumn<T>> columns;
final TasQRowActions<T>? rowActions;
final TasQRowTap<T>? onRowTap;
@override
DataRow? getRow(int index) {
if (index >= items.length) return null;
final item = items[index];
final cells = <DataCell>[];
for (final column in columns) {
final widget = column.cellBuilder(context, item);
cells.add(DataCell(_applyTechnicalStyle(context, widget, column)));
}
if (rowActions != null) {
final actions = rowActions!.call(item);
cells.add(
DataCell(Row(mainAxisSize: MainAxisSize.min, children: actions)),
);
}
return DataRow(
onSelectChanged: onRowTap == null ? null : (_) => onRowTap!(item),
cells: cells,
);
}
@override
bool get isRowCountApproximate => false;
@override
int get rowCount => items.length;
@override
int get selectedRowCount => 0;
}
Widget _applyTechnicalStyle<T>(
BuildContext context,
Widget child,
TasQColumn<T> column,
) {
if (!column.technical) return child;
if (child is Text && child.data != null) {
return MonoText(
child.data ?? '',
style: child.style,
maxLines: child.maxLines,
overflow: child.overflow,
textAlign: child.textAlign,
);
}
final mono = AppMonoText.of(context).body;
return DefaultTextStyle.merge(style: mono, child: child);
}