M3 Overhaul

This commit is contained in:
2026-03-06 20:03:32 +08:00
parent 82fe619f22
commit 73dc735cce
32 changed files with 1940 additions and 682 deletions
+41 -26
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../theme/m3_motion.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
// showcaseview removed due to null-safety incompatibility; onboarding shown via dialog
@@ -7,6 +8,7 @@ import '../providers/auth_provider.dart';
import '../providers/notifications_provider.dart';
import '../providers/profile_provider.dart';
import 'app_breakpoints.dart';
import 'profile_avatar.dart';
final GlobalKey notificationBellKey = GlobalKey();
@@ -49,7 +51,7 @@ class AppScaffold extends ConsumerWidget {
appBar: AppBar(
title: Row(
children: [
const Icon(Icons.memory),
Image.asset('assets/tasq_ico.png', width: 28, height: 28),
const SizedBox(width: 8),
Text('TasQ'),
],
@@ -73,7 +75,7 @@ class AppScaffold extends ConsumerWidget {
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
const Icon(Icons.account_circle),
ProfileAvatar(fullName: displayName, radius: 16),
const SizedBox(width: 8),
Text(displayName),
const SizedBox(width: 4),
@@ -89,7 +91,7 @@ class AppScaffold extends ConsumerWidget {
IconButton(
tooltip: 'Profile',
onPressed: () => context.go('/profile'),
icon: const Icon(Icons.account_circle),
icon: ProfileAvatar(fullName: displayName, radius: 16),
),
IconButton(
tooltip: 'Sign out',
@@ -164,23 +166,44 @@ class AppNavigationRail extends StatelessWidget {
@override
Widget build(BuildContext context) {
final currentIndex = _currentIndex(location, items);
final cs = Theme.of(context).colorScheme;
// M3 Expressive: tonal surface container instead of a hard border divider.
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
right: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
),
decoration: BoxDecoration(color: cs.surfaceContainerLow),
child: NavigationRail(
backgroundColor: Colors.transparent,
extended: extended,
selectedIndex: currentIndex,
onDestinationSelected: (value) {
items[value].onTap(context, onLogout: onLogout);
},
leading: const SizedBox.shrink(),
trailing: const SizedBox.shrink(),
leading: Padding(
padding: EdgeInsets.symmetric(
vertical: extended ? 12 : 8,
horizontal: extended ? 16 : 0,
),
child: Center(
child: Image.asset(
'assets/tasq_ico.png',
width: extended ? 48 : 40,
height: extended ? 48 : 40,
),
),
),
trailing: Expanded(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: IconButton(
tooltip: 'Sign out',
onPressed: onLogout,
icon: const Icon(Icons.logout),
),
),
),
),
destinations: [
for (final item in items)
NavigationRailDestination(
@@ -256,6 +279,8 @@ class _NotificationBell extends ConsumerWidget {
}
}
/// M3 Expressive shell background — uses a subtle tonal surface tint
/// rather than a gradient to create the organic, seed-colored feel.
class _ShellBackground extends StatelessWidget {
const _ShellBackground({required this.child});
@@ -263,17 +288,8 @@ class _ShellBackground extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Theme.of(context).colorScheme.surface,
Theme.of(context).colorScheme.surfaceContainerLowest,
],
),
),
return ColoredBox(
color: Theme.of(context).scaffoldBackgroundColor,
child: child,
);
}
@@ -531,9 +547,8 @@ Future<void> _showOverflowSheet(
List<NavItem> items,
VoidCallback onLogout,
) async {
await showModalBottomSheet<void>(
await m3ShowBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: ListView(
+136
View File
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
/// M3 Expressive Card variants.
///
/// Use these factory constructors to build semantically correct cards:
/// - [M3Card.elevated] — default, uses tonal surface tint (no hard shadow).
/// - [M3Card.filled] — uses surfaceContainerHighest for emphasis.
/// - [M3Card.outlined] — transparent fill with a subtle outline border.
class M3Card extends StatelessWidget {
const M3Card._({
required this.child,
this.color,
this.elevation,
this.shadowColor,
this.surfaceTintColor,
this.shape,
this.margin,
this.clipBehavior = Clip.none,
this.onTap,
});
/// Elevated card — tonal surface tint, minimal shadow.
/// Best for primary content surfaces (metric cards, detail panels).
factory M3Card.elevated({
required Widget child,
Color? color,
ShapeBorder? shape,
EdgeInsetsGeometry? margin,
Clip clipBehavior = Clip.none,
VoidCallback? onTap,
}) {
return M3Card._(
color: color,
elevation: 1,
shadowColor: Colors.transparent,
shape: shape,
margin: margin,
clipBehavior: clipBehavior,
onTap: onTap,
child: child,
);
}
/// Filled card — uses surfaceContainerHighest for high emphasis.
/// Best for summary cards, status counts, callout panels.
factory M3Card.filled({
required Widget child,
Color? color,
ShapeBorder? shape,
EdgeInsetsGeometry? margin,
Clip clipBehavior = Clip.none,
VoidCallback? onTap,
}) {
return M3Card._(
color: color, // caller passes surfaceContainerHighest or semantic color
elevation: 0,
shadowColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
shape: shape,
margin: margin,
clipBehavior: clipBehavior,
onTap: onTap,
child: child,
);
}
/// Outlined card — transparent fill with outline border.
/// Best for list items, form sections, grouped content.
factory M3Card.outlined({
required Widget child,
Color? color,
ShapeBorder? shape,
EdgeInsetsGeometry? margin,
Clip clipBehavior = Clip.none,
VoidCallback? onTap,
}) {
return M3Card._(
color: color,
elevation: 0,
shadowColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
shape: shape,
margin: margin,
clipBehavior: clipBehavior,
onTap: onTap,
child: child,
);
}
final Widget child;
final Color? color;
final double? elevation;
final Color? shadowColor;
final Color? surfaceTintColor;
final ShapeBorder? shape;
final EdgeInsetsGeometry? margin;
final Clip clipBehavior;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
// For outlined, we need the border side
final resolvedShape =
shape ??
(elevation == 0 && surfaceTintColor == Colors.transparent
? RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: (shadowColor == Colors.transparent && color == null)
? BorderSide(color: cs.outlineVariant)
: BorderSide.none,
)
: null);
final card = Card(
color: color,
elevation: elevation,
shadowColor: shadowColor,
surfaceTintColor: surfaceTintColor,
shape: resolvedShape,
margin: margin ?? EdgeInsets.zero,
clipBehavior: clipBehavior,
child: onTap != null
? InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: child,
)
: child,
);
return card;
}
}
+5 -5
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../theme/m3_motion.dart';
/// Lightweight, bounds-safe multi-select picker used in dialogs.
/// - Renders chips for selected items and a `Select` ActionChip.
@@ -43,7 +44,7 @@ class _MultiSelectPickerState<T> extends State<MultiSelectPicker<T>> {
List<String>? result;
if (isMobile) {
result = await showModalBottomSheet<List<String>>(
result = await m3ShowBottomSheet<List<String>>(
context: context,
isScrollControlled: true,
builder: (sheetContext) {
@@ -132,7 +133,7 @@ class _MultiSelectPickerState<T> extends State<MultiSelectPicker<T>> {
child: const Text('Cancel'),
),
const SizedBox(width: 8),
ElevatedButton(
FilledButton(
onPressed: () =>
Navigator.of(sheetContext).pop(working),
child: const Text('Done'),
@@ -148,9 +149,8 @@ class _MultiSelectPickerState<T> extends State<MultiSelectPicker<T>> {
},
);
} else {
result = await showDialog<List<String>>(
result = await m3ShowDialog<List<String>>(
context: context,
useRootNavigator: true,
builder: (dialogContext) {
List<String> working = List<String>.from(_selectedIds);
bool workingSelectAll =
@@ -232,7 +232,7 @@ class _MultiSelectPickerState<T> extends State<MultiSelectPicker<T>> {
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
FilledButton(
onPressed: () =>
Navigator.of(dialogContext).pop(working),
child: const Text('Done'),
+85
View File
@@ -0,0 +1,85 @@
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)
class ProfileAvatar extends StatelessWidget {
const ProfileAvatar({
super.key,
required this.fullName,
this.avatarUrl,
this.radius = 18,
});
final String fullName;
final String? avatarUrl;
final double radius;
String _getInitials() {
final trimmed = fullName.trim();
if (trimmed.isEmpty) return 'U';
final parts = trimmed.split(RegExp(r'\s+'));
if (parts.length == 1) {
return parts[0].substring(0, 1).toUpperCase();
}
// Get first letter of first and last name
return '${parts.first[0]}${parts.last[0]}'.toUpperCase();
}
Color _getInitialsColor(String initials) {
// Generate a deterministic color based on initials
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,
];
return colors[hash % colors.length];
}
@override
Widget build(BuildContext context) {
final initials = _getInitials();
// If avatar URL is provided, attempt to load the image
if (avatarUrl != null && avatarUrl!.isNotEmpty) {
return CircleAvatar(
radius: radius,
backgroundImage: NetworkImage(avatarUrl!),
onBackgroundImageError: (_, __) {
// Silently fall back to initials if image fails
},
child: null, // Image will display if loaded successfully
);
}
// 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,
),
),
);
}
}
+9 -7
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
/// M3 Expressive status pill — uses tonal container colors with a
/// smooth, spring-physics-inspired animation.
class StatusPill extends StatelessWidget {
const StatusPill({super.key, required this.label, this.isEmphasized = false});
@@ -11,23 +13,23 @@ class StatusPill extends StatelessWidget {
final scheme = Theme.of(context).colorScheme;
final background = isEmphasized
? scheme.tertiaryContainer
: scheme.tertiaryContainer.withValues(alpha: 0.65);
: scheme.tertiaryContainer;
final foreground = scheme.onTertiaryContainer;
return AnimatedContainer(
duration: const Duration(milliseconds: 220),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
duration: const Duration(milliseconds: 400),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: scheme.tertiary.withValues(alpha: 0.3)),
borderRadius: BorderRadius.circular(28),
),
child: Text(
label.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: foreground,
fontWeight: FontWeight.w600,
letterSpacing: 0.4,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
);
+3 -2
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../theme/m3_motion.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/profile.dart';
@@ -117,7 +118,7 @@ class TaskAssignmentSection extends ConsumerWidget {
// consider vacancy anymore because everyone is eligible, so the only
// reason for the dialog to be unusable is an empty staff list.
if (eligibleStaff.isEmpty && assignedIds.isEmpty) {
await showDialog<void>(
await m3ShowDialog<void>(
context: context,
builder: (dialogContext) {
return AlertDialog(
@@ -137,7 +138,7 @@ class TaskAssignmentSection extends ConsumerWidget {
}
final selection = assignedIds.toSet();
await showDialog<void>(
await m3ShowDialog<void>(
context: context,
builder: (dialogContext) {
var isSaving = false;
+4 -11
View File
@@ -597,7 +597,7 @@ class _DesktopTableViewState<T> extends State<_DesktopTableView<T>> {
}
}
/// Mobile tile wrapper that applies Material 2 style elevation.
/// Mobile tile wrapper that applies M3 Expressive tonal elevation.
class _MobileTile<T> extends StatelessWidget {
const _MobileTile({
required this.item,
@@ -615,21 +615,14 @@ class _MobileTile<T> extends StatelessWidget {
Widget build(BuildContext context) {
final tile = mobileTileBuilder(context, item, actions);
// Apply Material 2 style elevation for Cards (per Hybrid M3/M2 guidelines).
// Mobile tiles deliberately use a slightly smaller corner radius for
// compactness, but they should inherit the global card elevation and
// shadow color from the theme to maintain visual consistency.
// M3 Expressive: cards use tonal surface tints. The theme's CardThemeData
// already specifies surfaceTintColor and low elevation. We apply the
// compact shape for list density.
if (tile is Card) {
final themeCard = Theme.of(context).cardTheme;
return Card(
color: tile.color,
elevation: themeCard.elevation ?? 3,
margin: tile.margin,
// prefer the tile's explicit shape. For mobile tiles we intentionally
// use the compact radius token so list items feel denser while
// remaining theme-driven.
shape: tile.shape ?? AppSurfaces.of(context).compactShape,
shadowColor: AppSurfaces.of(context).compactShadowColor,
clipBehavior: tile.clipBehavior,
child: tile.child,
);