Major UI overhaul
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user