M3 Overhaul
This commit is contained in:
+41
-26
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user