Enhanced material design 3 implementation

This commit is contained in:
2026-03-20 15:15:38 +08:00
parent 27ebb89052
commit 74197c525d
26 changed files with 1345 additions and 515 deletions
+101
View File
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import '../theme/m3_motion.dart';
/// Standardized M3 Expressive page header with animated entrance.
///
/// Replaces the ad-hoc `Padding(Align(Text(...)))` pattern found across
/// screens. Provides:
/// * `headlineSmall` typography — correct M3 size for a top-level page title.
/// * [M3FadeSlideIn] entrance animation — 400 ms with emphasized easing.
/// * Optional [subtitle] in `bodyMedium` / `onSurfaceVariant`.
/// * Optional [actions] row rendered at the trailing end.
/// * Responsive alignment: left-aligned on desktop (≥600 dp), centered on
/// mobile to match the existing navigation-bar-centric layout.
class AppPageHeader extends StatelessWidget {
const AppPageHeader({
super.key,
required this.title,
this.subtitle,
this.actions,
this.padding = const EdgeInsets.only(top: 20, bottom: 12),
});
final String title;
final String? subtitle;
/// Optional trailing action widgets (e.g. filter chip, icon button).
final List<Widget>? actions;
/// Vertical padding around the header. Horizontal padding is handled by
/// the parent [ResponsiveBody] so only top/bottom should be set here.
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
final titleWidget = Text(
title,
style: tt.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
color: cs.onSurface,
),
textAlign: TextAlign.center,
);
final subtitleWidget = subtitle == null
? null
: Text(
subtitle!,
style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant),
textAlign: TextAlign.center,
);
final hasActions = actions != null && actions!.isNotEmpty;
Widget content;
if (!hasActions) {
content = Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
titleWidget,
if (subtitleWidget != null) ...[
const SizedBox(height: 4),
subtitleWidget,
],
],
);
} else {
// With actions — centered title/subtitle, actions sit to the right.
content = Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
titleWidget,
if (subtitleWidget != null) ...[
const SizedBox(height: 4),
subtitleWidget,
],
],
),
),
const SizedBox(width: 8),
Row(mainAxisSize: MainAxisSize.min, children: actions!),
],
);
}
return M3FadeSlideIn(
duration: M3Motion.standard,
child: Padding(padding: padding, child: content),
);
}
}
+35 -25
View File
@@ -84,6 +84,7 @@ class AppScaffold extends ConsumerWidget {
fullName: displayName,
avatarUrl: avatarUrl,
radius: 16,
heroTag: 'profile-avatar',
),
const SizedBox(width: 8),
Text(displayName),
@@ -104,6 +105,7 @@ class AppScaffold extends ConsumerWidget {
fullName: displayName,
avatarUrl: avatarUrl,
radius: 16,
heroTag: 'profile-avatar',
),
),
IconButton(
@@ -273,19 +275,42 @@ class _NotificationBell extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final unreadCount = ref.watch(unreadNotificationsCountProvider);
final cs = Theme.of(context).colorScheme;
return IconButton(
tooltip: 'Notifications',
onPressed: () => context.go('/notifications'),
icon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.notifications),
if (unreadCount > 0)
const Positioned(
right: -2,
top: -2,
child: Icon(Icons.circle, size: 10, color: Colors.red),
const Icon(Icons.notifications_outlined),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) => ScaleTransition(
scale: animation,
child: child,
),
child: unreadCount > 0
? Positioned(
key: const ValueKey('badge'),
right: -3,
top: -3,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: cs.error,
shape: BoxShape.circle,
border: Border.all(
color: cs.surface,
width: 1.5,
),
),
),
)
: const SizedBox.shrink(key: ValueKey('no-badge')),
),
],
),
);
@@ -422,24 +447,12 @@ List<NavSection> _buildSections(String role) {
icon: Icons.apartment_outlined,
selectedIcon: Icons.apartment,
),
NavItem(
label: 'Geofence test',
route: '/settings/geofence-test',
icon: Icons.map_outlined,
selectedIcon: Icons.map,
),
NavItem(
label: 'IT Staff Teams',
route: '/settings/teams',
icon: Icons.groups_2_outlined,
selectedIcon: Icons.groups_2,
),
NavItem(
label: 'Permissions',
route: '/settings/permissions',
icon: Icons.lock_open,
selectedIcon: Icons.lock,
),
if (kIsWeb) ...[
NavItem(
label: 'App Update',
@@ -459,19 +472,16 @@ List<NavSection> _buildSections(String role) {
];
}
// non-admin users still get a simple Settings section containing only
// permissions. this keeps the screen accessible without exposing the
// administrative management screens.
return [
NavSection(label: 'Operations', items: mainItems),
NavSection(
label: 'Settings',
items: [
NavItem(
label: 'Permissions',
route: '/settings/permissions',
icon: Icons.lock_open,
selectedIcon: Icons.lock,
label: 'Logout',
route: '',
icon: Icons.logout,
isLogout: true,
),
],
),
+165
View File
@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import '../theme/m3_motion.dart';
/// A centered error state with an icon, title, human-readable message and an
/// optional retry button.
///
/// Usage:
/// ```dart
/// if (async.hasError && !async.hasValue) {
/// return AppErrorView(
/// error: async.error!,
/// onRetry: () => ref.invalidate(myProvider),
/// );
/// }
/// ```
class AppErrorView extends StatelessWidget {
const AppErrorView({
super.key,
required this.error,
this.title = 'Something went wrong',
this.onRetry,
});
final Object error;
/// Short title shown above the error message.
final String title;
/// When provided, a "Try again" button is rendered below the message.
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
final message = _humanise(error);
return Center(
child: M3FadeSlideIn(
duration: M3Motion.standard,
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
M3BounceIcon(
icon: Icons.error_outline_rounded,
iconColor: cs.onErrorContainer,
backgroundColor: cs.errorContainer,
),
const SizedBox(height: 20),
Text(
title,
style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w700),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
message,
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
textAlign: TextAlign.center,
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
if (onRetry != null) ...[
const SizedBox(height: 24),
OutlinedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Try again'),
),
],
],
),
),
),
);
}
/// Strips common Dart exception prefixes so the user sees a clean message.
static String _humanise(Object error) {
var text = error.toString();
const prefixes = ['Exception: ', 'Error: ', 'FormatException: '];
for (final p in prefixes) {
if (text.startsWith(p)) {
text = text.substring(p.length);
break;
}
}
return text.isEmpty ? 'An unexpected error occurred.' : text;
}
}
/// A centered empty-state view with an icon, title and optional subtitle.
///
/// Usage:
/// ```dart
/// if (items.isEmpty && !loading) {
/// return const AppEmptyView(
/// icon: Icons.task_outlined,
/// title: 'No tasks yet',
/// subtitle: 'Tasks assigned to you will appear here.',
/// );
/// }
/// ```
class AppEmptyView extends StatelessWidget {
const AppEmptyView({
super.key,
this.icon = Icons.inbox_outlined,
required this.title,
this.subtitle,
this.action,
});
final IconData icon;
final String title;
final String? subtitle;
/// Optional call-to-action placed below the subtitle.
final Widget? action;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
return Center(
child: M3FadeSlideIn(
duration: M3Motion.standard,
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
M3BounceIcon(
icon: icon,
iconColor: cs.onSurfaceVariant,
backgroundColor: cs.surfaceContainerHighest,
),
const SizedBox(height: 20),
Text(
title,
style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
),
if (subtitle != null) ...[
const SizedBox(height: 8),
Text(
subtitle!,
style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant),
textAlign: TextAlign.center,
),
],
if (action != null) ...[
const SizedBox(height: 20),
action!,
],
],
),
),
),
);
}
}
+49 -32
View File
@@ -3,18 +3,25 @@ import 'package:flutter/material.dart';
/// Native Flutter profile avatar that displays either:
/// 1. User's avatar image URL (if provided)
/// 2. Initials derived from full name (fallback)
///
/// Pass [heroTag] to participate in a Hero transition to/from a destination
/// that uses the same tag (e.g., the profile screen's large avatar).
class ProfileAvatar extends StatelessWidget {
const ProfileAvatar({
super.key,
required this.fullName,
this.avatarUrl,
this.radius = 18,
this.heroTag,
});
final String fullName;
final String? avatarUrl;
final double radius;
/// When non-null, wraps the avatar in a [Hero] with this tag.
final Object? heroTag;
String _getInitials() {
final trimmed = fullName.trim();
if (trimmed.isEmpty) return 'U';
@@ -28,37 +35,37 @@ class ProfileAvatar extends StatelessWidget {
return '${parts.first[0]}${parts.last[0]}'.toUpperCase();
}
Color _getInitialsColor(String initials) {
// Generate a deterministic color based on initials
/// Returns a (background, foreground) pair from the M3 tonal palette.
///
/// Uses a deterministic hash of the initials to cycle through the scheme's
/// semantic container colors so every avatar is theme-aware and accessible.
(Color, Color) _getTonalColors(String initials, ColorScheme cs) {
final hash =
initials.codeUnitAt(0) +
(initials.length > 1 ? initials.codeUnitAt(1) * 256 : 0);
final colors = [
Colors.red,
Colors.pink,
Colors.purple,
Colors.deepPurple,
Colors.indigo,
Colors.blue,
Colors.lightBlue,
Colors.cyan,
Colors.teal,
Colors.green,
Colors.lightGreen,
Colors.orange,
Colors.deepOrange,
Colors.brown,
// Six M3-compliant container pairs (background / on-color text).
final pairs = [
(cs.primaryContainer, cs.onPrimaryContainer),
(cs.secondaryContainer, cs.onSecondaryContainer),
(cs.tertiaryContainer, cs.onTertiaryContainer),
(cs.errorContainer, cs.onErrorContainer),
(cs.primary, cs.onPrimary),
(cs.secondary, cs.onSecondary),
];
return colors[hash % colors.length];
return pairs[hash % pairs.length];
}
@override
Widget build(BuildContext context) {
final initials = _getInitials();
Widget avatar;
// If avatar URL is provided, attempt to load the image
if (avatarUrl != null && avatarUrl!.isNotEmpty) {
return CircleAvatar(
avatar = CircleAvatar(
radius: radius,
backgroundImage: NetworkImage(avatarUrl!),
onBackgroundImageError: (_, _) {
@@ -66,20 +73,30 @@ class ProfileAvatar extends StatelessWidget {
},
child: null, // Image will display if loaded successfully
);
} else {
final (bg, fg) = _getTonalColors(
initials,
Theme.of(context).colorScheme,
);
// Fallback to initials
avatar = CircleAvatar(
radius: radius,
backgroundColor: bg,
child: Text(
initials,
style: TextStyle(
color: fg,
fontSize: radius * 0.8,
fontWeight: FontWeight.w600,
),
),
);
}
// Fallback to initials
return CircleAvatar(
radius: radius,
backgroundColor: _getInitialsColor(initials),
child: Text(
initials,
style: TextStyle(
color: Colors.white,
fontSize: radius * 0.8,
fontWeight: FontWeight.w600,
),
),
);
if (heroTag != null) {
return Hero(tag: heroTag!, child: avatar);
}
return avatar;
}
}
+6 -2
View File
@@ -11,10 +11,14 @@ class StatusPill extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
// isEmphasized uses primaryContainer (higher tonal weight) to visually
// distinguish high-priority statuses from routine ones.
final background = isEmphasized
? scheme.tertiaryContainer
? scheme.primaryContainer
: scheme.tertiaryContainer;
final foreground = scheme.onTertiaryContainer;
final foreground = isEmphasized
? scheme.onPrimaryContainer
: scheme.onTertiaryContainer;
return AnimatedContainer(
duration: const Duration(milliseconds: 400),
+46 -38
View File
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import '../theme/app_typography.dart';
import '../theme/app_surfaces.dart';
import '../theme/m3_motion.dart';
import 'mono_text.dart';
/// A column configuration for the [TasQAdaptiveList] desktop table view.
@@ -205,11 +206,18 @@ class TasQAdaptiveList<T> extends StatelessWidget {
}
final item = items[index];
final actions = rowActions?.call(item) ?? const <Widget>[];
return _MobileTile(
item: item,
actions: actions,
mobileTileBuilder: mobileTileBuilder,
onRowTap: onRowTap,
// M3 Expressive: stagger first 8 items on enter (50 ms per step).
final staggerDelay = Duration(
milliseconds: math.min(index, 8) * 50,
);
return M3FadeSlideIn(
delay: staggerDelay,
child: _MobileTile(
item: item,
actions: actions,
mobileTileBuilder: mobileTileBuilder,
onRowTap: onRowTap,
),
);
},
);
@@ -225,11 +233,17 @@ class TasQAdaptiveList<T> extends StatelessWidget {
}
final item = items[index];
final actions = rowActions?.call(item) ?? const <Widget>[];
return _MobileTile(
item: item,
actions: actions,
mobileTileBuilder: mobileTileBuilder,
onRowTap: onRowTap,
final staggerDelay = Duration(
milliseconds: math.min(index, 8) * 50,
);
return M3FadeSlideIn(
delay: staggerDelay,
child: _MobileTile(
item: item,
actions: actions,
mobileTileBuilder: mobileTileBuilder,
onRowTap: onRowTap,
),
);
},
shrinkWrap: true,
@@ -294,35 +308,29 @@ class TasQAdaptiveList<T> extends StatelessWidget {
Widget _loadingTile(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
height: 72,
child: Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
),
child: Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
M3ShimmerBox(
width: 40,
height: 40,
borderRadius: BorderRadius.circular(6),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
M3ShimmerBox(height: 12),
const SizedBox(height: 8),
M3ShimmerBox(width: 150, height: 10),
],
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(height: 12, color: Colors.white),
const SizedBox(height: 8),
Container(height: 10, width: 150, color: Colors.white),
],
),
),
],
),
),
],
),
),
),
+5 -3
View File
@@ -151,11 +151,13 @@ class _UpdateDialogState extends State<UpdateDialog> {
),
],
if (_failed)
const Padding(
padding: EdgeInsets.only(top: 8.0),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'An error occurred while downloading. Please try again.',
style: TextStyle(color: Colors.red),
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
),
],