M3 Overhaul

This commit is contained in:
Marc Rejohn Castillano 2026-03-06 20:03:32 +08:00
parent 82fe619f22
commit 73dc735cce
32 changed files with 1940 additions and 682 deletions

View File

@ -24,6 +24,7 @@ import '../screens/tickets/tickets_list_screen.dart';
import '../screens/workforce/workforce_screen.dart'; import '../screens/workforce/workforce_screen.dart';
import '../widgets/app_shell.dart'; import '../widgets/app_shell.dart';
import '../screens/teams/teams_screen.dart'; import '../screens/teams/teams_screen.dart';
import '../theme/m3_motion.dart';
final appRouterProvider = Provider<GoRouter>((ref) { final appRouterProvider = Provider<GoRouter>((ref) {
final notifier = RouterNotifier(ref); final notifier = RouterNotifier(ref);
@ -79,82 +80,131 @@ final appRouterProvider = Provider<GoRouter>((ref) {
routes: [ routes: [
GoRoute( GoRoute(
path: '/settings/teams', path: '/settings/teams',
builder: (context, state) => const TeamsScreen(), pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const TeamsScreen(),
),
), ),
GoRoute( GoRoute(
path: '/dashboard', path: '/dashboard',
builder: (context, state) => const DashboardScreen(), pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const DashboardScreen(),
),
), ),
GoRoute( GoRoute(
path: '/tickets', path: '/tickets',
builder: (context, state) => const TicketsListScreen(), pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const TicketsListScreen(),
),
routes: [ routes: [
GoRoute( GoRoute(
path: ':id', path: ':id',
builder: (context, state) => TicketDetailScreen( pageBuilder: (context, state) => M3ContainerTransformPage(
ticketId: state.pathParameters['id'] ?? '', key: state.pageKey,
child: TicketDetailScreen(
ticketId: state.pathParameters['id'] ?? '',
),
), ),
), ),
], ],
), ),
GoRoute( GoRoute(
path: '/tasks', path: '/tasks',
builder: (context, state) => const TasksListScreen(), pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const TasksListScreen(),
),
routes: [ routes: [
GoRoute( GoRoute(
path: ':id', path: ':id',
builder: (context, state) => pageBuilder: (context, state) => M3ContainerTransformPage(
TaskDetailScreen(taskId: state.pathParameters['id'] ?? ''), key: state.pageKey,
child: TaskDetailScreen(
taskId: state.pathParameters['id'] ?? '',
),
),
), ),
], ],
), ),
GoRoute( GoRoute(
path: '/events', path: '/events',
builder: (context, state) => const UnderDevelopmentScreen( pageBuilder: (context, state) => M3SharedAxisPage(
title: 'Events', key: state.pageKey,
subtitle: 'Event monitoring is under development.', child: const UnderDevelopmentScreen(
icon: Icons.event, title: 'Events',
subtitle: 'Event monitoring is under development.',
icon: Icons.event,
),
), ),
), ),
GoRoute( GoRoute(
path: '/announcements', path: '/announcements',
builder: (context, state) => const UnderDevelopmentScreen( pageBuilder: (context, state) => M3SharedAxisPage(
title: 'Announcement', key: state.pageKey,
subtitle: 'Operational broadcasts are coming soon.', child: const UnderDevelopmentScreen(
icon: Icons.campaign, title: 'Announcement',
subtitle: 'Operational broadcasts are coming soon.',
icon: Icons.campaign,
),
), ),
), ),
GoRoute( GoRoute(
path: '/workforce', path: '/workforce',
builder: (context, state) => const WorkforceScreen(), pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const WorkforceScreen(),
),
), ),
GoRoute( GoRoute(
path: '/reports', path: '/reports',
builder: (context, state) => const ReportsScreen(), pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const ReportsScreen(),
),
), ),
GoRoute( GoRoute(
path: '/settings/users', path: '/settings/users',
builder: (context, state) => const UserManagementScreen(), pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const UserManagementScreen(),
),
), ),
GoRoute( GoRoute(
path: '/settings/offices', path: '/settings/offices',
builder: (context, state) => const OfficesScreen(), pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const OfficesScreen(),
),
), ),
GoRoute( GoRoute(
path: '/settings/geofence-test', path: '/settings/geofence-test',
builder: (context, state) => const GeofenceTestScreen(), pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const GeofenceTestScreen(),
),
), ),
GoRoute( GoRoute(
path: '/settings/permissions', path: '/settings/permissions',
builder: (context, state) => const PermissionsScreen(), pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const PermissionsScreen(),
),
), ),
GoRoute( GoRoute(
path: '/notifications', path: '/notifications',
builder: (context, state) => const NotificationsScreen(), pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const NotificationsScreen(),
),
), ),
GoRoute( GoRoute(
path: '/profile', path: '/profile',
builder: (context, state) => const ProfileScreen(), pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const ProfileScreen(),
),
), ),
], ],
), ),

View File

@ -370,7 +370,8 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
right: 12, right: 12,
top: 12, top: 12,
child: Card( child: Card(
elevation: 2, elevation: 0,
shadowColor: Colors.transparent,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 10.0, horizontal: 10.0,

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/m3_motion.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/office.dart'; import '../../models/office.dart';
@ -153,7 +154,7 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
right: 16, right: 16,
bottom: 16, bottom: 16,
child: SafeArea( child: SafeArea(
child: FloatingActionButton.extended( child: M3ExpandedFab(
onPressed: () => _showOfficeDialog(context, ref), onPressed: () => _showOfficeDialog(context, ref),
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('New Office'), label: const Text('New Office'),
@ -172,7 +173,7 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
final nameController = TextEditingController(text: office?.name ?? ''); final nameController = TextEditingController(text: office?.name ?? '');
String? selectedServiceId = office?.serviceId; String? selectedServiceId = office?.serviceId;
await showDialog<void>( await m3ShowDialog<void>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
bool saving = false; bool saving = false;
@ -298,7 +299,7 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
WidgetRef ref, WidgetRef ref,
Office office, Office office,
) async { ) async {
await showDialog<void>( await m3ShowDialog<void>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
return AlertDialog( return AlertDialog(

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/m3_motion.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/office.dart'; import '../../models/office.dart';
@ -306,7 +307,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
} }
if (!context.mounted) return; if (!context.mounted) return;
await showDialog<void>( await m3ShowDialog<void>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
return StatefulBuilder( return StatefulBuilder(
@ -314,8 +315,8 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
return AlertDialog( return AlertDialog(
shape: AppSurfaces.of(context).dialogShape, shape: AppSurfaces.of(context).dialogShape,
title: const Text('Update user'), title: const Text('Update user'),
content: ConstrainedBox( content: SizedBox(
constraints: const BoxConstraints(maxWidth: 520), width: 520,
child: SingleChildScrollView( child: SingleChildScrollView(
child: _buildUserForm( child: _buildUserForm(
context, context,
@ -442,10 +443,17 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
const SizedBox(height: 8), const SizedBox(height: 8),
if (offices.isEmpty) const Text('No offices available.'), if (offices.isEmpty) const Text('No offices available.'),
if (offices.isNotEmpty) if (offices.isNotEmpty)
Column( Container(
children: offices height: 240,
.map( clipBehavior: Clip.hardEdge,
(office) => CheckboxListTile( decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(4),
),
child: SingleChildScrollView(
child: Column(
children: offices.map((office) {
return CheckboxListTile(
value: _selectedOfficeIds.contains(office.id), value: _selectedOfficeIds.contains(office.id),
onChanged: _isSaving onChanged: _isSaving
? null ? null
@ -460,10 +468,11 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
}, },
title: Text(office.name), title: Text(office.name),
controlAffinity: ListTileControlAffinity.leading, controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero, contentPadding: const EdgeInsets.symmetric(horizontal: 8),
), );
) }).toList(),
.toList(), ),
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
@ -559,7 +568,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
final controller = TextEditingController(); final controller = TextEditingController();
final formKey = GlobalKey<FormState>(); final formKey = GlobalKey<FormState>();
await showDialog<void>( await m3ShowDialog<void>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
return AlertDialog( return AlertDialog(

View File

@ -7,7 +7,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../widgets/responsive_body.dart'; import '../../theme/m3_motion.dart';
import '../../utils/snackbar.dart'; import '../../utils/snackbar.dart';
class LoginScreen extends ConsumerStatefulWidget { class LoginScreen extends ConsumerStatefulWidget {
@ -17,15 +17,39 @@ class LoginScreen extends ConsumerStatefulWidget {
ConsumerState<LoginScreen> createState() => _LoginScreenState(); ConsumerState<LoginScreen> createState() => _LoginScreenState();
} }
class _LoginScreenState extends ConsumerState<LoginScreen> { class _LoginScreenState extends ConsumerState<LoginScreen>
with SingleTickerProviderStateMixin {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController(); final _emailController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
late final AnimationController _entranceController;
late final Animation<double> _fadeIn;
late final Animation<Offset> _slideIn;
bool _isLoading = false; bool _isLoading = false;
bool _obscurePassword = true;
@override
void initState() {
super.initState();
_entranceController = AnimationController(
vsync: this,
duration: M3Motion.long,
);
_fadeIn = CurvedAnimation(
parent: _entranceController,
curve: M3Motion.emphasizedEnter,
);
_slideIn = Tween<Offset>(
begin: const Offset(0, 0.06),
end: Offset.zero,
).animate(_fadeIn);
_entranceController.forward();
}
@override @override
void dispose() { void dispose() {
_entranceController.dispose();
_emailController.dispose(); _emailController.dispose();
_passwordController.dispose(); _passwordController.dispose();
super.dispose(); super.dispose();
@ -83,93 +107,230 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Sign In')), body: SafeArea(
body: ResponsiveBody( child: Center(
maxWidth: 480, child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 24), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: Form( child: FadeTransition(
key: _formKey, opacity: _fadeIn,
child: Column( child: SlideTransition(
mainAxisAlignment: MainAxisAlignment.center, position: _slideIn,
crossAxisAlignment: CrossAxisAlignment.stretch, child: ConstrainedBox(
children: [ constraints: const BoxConstraints(maxWidth: 420),
Center( child: Column(
child: Column( mainAxisSize: MainAxisSize.min,
children: [ children: [
Image.asset('assets/tasq_ico.png', height: 72, width: 72), // Branding
const SizedBox(height: 12), Hero(
Text( tag: 'tasq-logo',
'TasQ', child: Image.asset(
style: Theme.of(context).textTheme.headlineSmall, 'assets/tasq_ico.png',
), height: 80,
], width: 80,
),
),
const SizedBox(height: 16),
Text(
'TasQ',
style: tt.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
color: cs.primary,
),
),
const SizedBox(height: 4),
Text(
'Task management, simplified',
style: tt.bodyMedium?.copyWith(
color: cs.onSurfaceVariant,
),
),
const SizedBox(height: 32),
// Sign-in card
Card(
elevation: 0,
color: cs.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 28,
),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Sign in',
style: tt.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 20),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Email is required.';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () => setState(
() => _obscurePassword =
!_obscurePassword,
),
),
),
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) {
if (!_isLoading) _handleEmailSignIn();
},
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password is required.';
}
return null;
},
),
const SizedBox(height: 24),
M3AnimatedSwitcher(
child: _isLoading
? const SizedBox(
key: ValueKey('loading'),
height: 48,
child: Center(
child: CircularProgressIndicator(),
),
)
: FilledButton(
key: const ValueKey('sign-in'),
onPressed: _handleEmailSignIn,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(
48,
),
),
child: const Text('Sign In'),
),
),
],
),
),
),
),
const SizedBox(height: 20),
// Divider
Row(
children: [
Expanded(child: Divider(color: cs.outlineVariant)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'or continue with',
style: tt.labelMedium?.copyWith(
color: cs.onSurfaceVariant,
),
),
),
Expanded(child: Divider(color: cs.outlineVariant)),
],
),
const SizedBox(height: 20),
// OAuth buttons
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _isLoading
? null
: () => _handleOAuthSignIn(google: true),
icon: const FaIcon(
FontAwesomeIcons.google,
size: 18,
),
label: const Text('Google'),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _isLoading
? null
: () => _handleOAuthSignIn(google: false),
icon: const FaIcon(
FontAwesomeIcons.facebook,
size: 18,
),
label: const Text('Meta'),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
),
),
],
),
const SizedBox(height: 24),
// Create account link
TextButton(
onPressed: _isLoading
? null
: () => context.go('/signup'),
child: Text.rich(
TextSpan(
text: "Don't have an account? ",
style: tt.bodyMedium?.copyWith(
color: cs.onSurfaceVariant,
),
children: [
TextSpan(
text: 'Sign up',
style: TextStyle(
color: cs.primary,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
],
),
), ),
), ),
const SizedBox(height: 24), ),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Email is required.';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) {
if (!_isLoading) {
_handleEmailSignIn();
}
},
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password is required.';
}
return null;
},
),
const SizedBox(height: 24),
FilledButton(
onPressed: _isLoading ? null : _handleEmailSignIn,
child: _isLoading
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Sign In'),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _isLoading
? null
: () => _handleOAuthSignIn(google: true),
icon: const FaIcon(FontAwesomeIcons.google, size: 18),
label: const Text('Continue with Google'),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _isLoading
? null
: () => _handleOAuthSignIn(google: false),
icon: const FaIcon(FontAwesomeIcons.facebook, size: 18),
label: const Text('Continue with Meta'),
),
const SizedBox(height: 16),
TextButton(
onPressed: _isLoading ? null : () => context.go('/signup'),
child: const Text('Create account'),
),
],
), ),
), ),
), ),

View File

@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../providers/tickets_provider.dart'; import '../../providers/tickets_provider.dart';
import '../../widgets/responsive_body.dart'; import '../../theme/m3_motion.dart';
import '../../utils/snackbar.dart'; import '../../utils/snackbar.dart';
class SignUpScreen extends ConsumerStatefulWidget { class SignUpScreen extends ConsumerStatefulWidget {
@ -14,28 +14,49 @@ class SignUpScreen extends ConsumerStatefulWidget {
ConsumerState<SignUpScreen> createState() => _SignUpScreenState(); ConsumerState<SignUpScreen> createState() => _SignUpScreenState();
} }
class _SignUpScreenState extends ConsumerState<SignUpScreen> { class _SignUpScreenState extends ConsumerState<SignUpScreen>
with SingleTickerProviderStateMixin {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _fullNameController = TextEditingController(); final _fullNameController = TextEditingController();
final _emailController = TextEditingController(); final _emailController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController(); final _confirmPasswordController = TextEditingController();
late final AnimationController _entranceController;
late final Animation<double> _fadeIn;
late final Animation<Offset> _slideIn;
final Set<String> _selectedOfficeIds = {}; final Set<String> _selectedOfficeIds = {};
double _passwordStrength = 0.0; double _passwordStrength = 0.0;
String _passwordStrengthLabel = 'Very weak'; String _passwordStrengthLabel = 'Very weak';
Color _passwordStrengthColor = Colors.red; Color _passwordStrengthColor = Colors.red;
bool _isLoading = false; bool _isLoading = false;
bool _obscurePassword = true;
bool _obscureConfirm = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_passwordController.addListener(_updatePasswordStrength); _passwordController.addListener(_updatePasswordStrength);
_entranceController = AnimationController(
vsync: this,
duration: M3Motion.long,
);
_fadeIn = CurvedAnimation(
parent: _entranceController,
curve: M3Motion.emphasizedEnter,
);
_slideIn = Tween<Offset>(
begin: const Offset(0, 0.06),
end: Offset.zero,
).animate(_fadeIn);
_entranceController.forward();
} }
@override @override
void dispose() { void dispose() {
_entranceController.dispose();
_passwordController.removeListener(_updatePasswordStrength); _passwordController.removeListener(_updatePasswordStrength);
_fullNameController.dispose(); _fullNameController.dispose();
_emailController.dispose(); _emailController.dispose();
@ -76,196 +97,377 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
final officesAsync = ref.watch(officesOnceProvider); final officesAsync = ref.watch(officesOnceProvider);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Create Account')), body: SafeArea(
body: ResponsiveBody( child: Center(
maxWidth: 480, child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 24), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: SingleChildScrollView( child: FadeTransition(
child: Form( opacity: _fadeIn,
key: _formKey, child: SlideTransition(
child: Column( position: _slideIn,
mainAxisSize: MainAxisSize.min, child: ConstrainedBox(
crossAxisAlignment: CrossAxisAlignment.stretch, constraints: const BoxConstraints(maxWidth: 420),
children: [
Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Image.asset('assets/tasq_ico.png', height: 72, width: 72), // Branding
const SizedBox(height: 12), Hero(
tag: 'tasq-logo',
child: Image.asset(
'assets/tasq_ico.png',
height: 80,
width: 80,
),
),
const SizedBox(height: 16),
Text( Text(
'TasQ', 'TasQ',
style: Theme.of(context).textTheme.headlineSmall, style: tt.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
color: cs.primary,
),
),
const SizedBox(height: 4),
Text(
'Create your account',
style: tt.bodyMedium?.copyWith(
color: cs.onSurfaceVariant,
),
),
const SizedBox(height: 32),
// Sign-up card
Card(
elevation: 0,
color: cs.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 28,
),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Sign up',
style: tt.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 20),
TextFormField(
controller: _fullNameController,
decoration: const InputDecoration(
labelText: 'Full name',
prefixIcon: Icon(Icons.person_outlined),
),
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Full name is required.';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Email is required.';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () => setState(
() => _obscurePassword =
!_obscurePassword,
),
),
),
obscureText: _obscurePassword,
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password is required.';
}
if (value.length < 6) {
return 'Use at least 6 characters.';
}
return null;
},
),
const SizedBox(height: 8),
// Password strength (AnimatedSize)
AnimatedSize(
duration: M3Motion.short,
curve: M3Motion.standard_,
child: _passwordController.text.isEmpty
? const SizedBox.shrink()
: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Strength: ',
style: tt.labelSmall
?.copyWith(
color:
cs.onSurfaceVariant,
),
),
Text(
_passwordStrengthLabel,
style: tt.labelSmall?.copyWith(
color:
_passwordStrengthColor,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius:
BorderRadius.circular(8),
child: LinearProgressIndicator(
value: _passwordStrength,
minHeight: 6,
color: _passwordStrengthColor,
backgroundColor:
cs.surfaceContainerHighest,
),
),
const SizedBox(height: 8),
],
),
),
TextFormField(
controller: _confirmPasswordController,
decoration: InputDecoration(
labelText: 'Confirm password',
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(
_obscureConfirm
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () => setState(
() =>
_obscureConfirm = !_obscureConfirm,
),
),
),
obscureText: _obscureConfirm,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) {
if (!_isLoading) _handleSignUp();
},
validator: (value) {
if (value == null || value.isEmpty) {
return 'Confirm your password.';
}
if (value != _passwordController.text) {
return 'Passwords do not match.';
}
return null;
},
),
const SizedBox(height: 20),
// Office selection
Text('Offices', style: tt.titleSmall),
const SizedBox(height: 8),
officesAsync.when(
data: (offices) {
if (offices.isEmpty) {
return Text(
'No offices available.',
style: tt.bodySmall?.copyWith(
color: cs.onSurfaceVariant,
),
);
}
final officeNameById = <String, String>{
for (final o in offices) o.id: o.name,
};
return Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
FilledButton.tonalIcon(
onPressed: _isLoading
? null
: () =>
_showOfficeSelectionDialog(
offices,
),
icon: const Icon(
Icons.place_outlined,
),
label: const Text('Select Offices'),
),
const SizedBox(height: 8),
AnimatedSize(
duration: M3Motion.short,
curve: M3Motion.standard_,
child: _selectedOfficeIds.isEmpty
? Padding(
padding:
const EdgeInsets.only(
top: 4,
),
child: Text(
'No office selected.',
style: tt.bodySmall
?.copyWith(
color: cs
.onSurfaceVariant,
),
),
)
: Builder(
builder: (context) {
final sortedIds =
List<String>.from(
_selectedOfficeIds,
)..sort(
(
a,
b,
) => (officeNameById[a] ?? a)
.toLowerCase()
.compareTo(
(officeNameById[b] ??
b)
.toLowerCase(),
),
);
return Wrap(
spacing: 8,
runSpacing: 8,
children: sortedIds.map((
id,
) {
final name =
officeNameById[id] ??
id;
return Chip(
label: Text(name),
onDeleted: _isLoading
? null
: () {
setState(
() => _selectedOfficeIds
.remove(
id,
),
);
},
);
}).toList(),
);
},
),
),
],
);
},
loading: () =>
const LinearProgressIndicator(),
error: (error, _) =>
Text('Failed to load offices: $error'),
),
const SizedBox(height: 24),
M3AnimatedSwitcher(
child: _isLoading
? const SizedBox(
key: ValueKey('loading'),
height: 48,
child: Center(
child: CircularProgressIndicator(),
),
)
: FilledButton(
key: const ValueKey('sign-up'),
onPressed: _handleSignUp,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(
48,
),
),
child: const Text('Create Account'),
),
),
],
),
),
),
),
const SizedBox(height: 24),
// Back to sign in
TextButton(
onPressed: _isLoading
? null
: () => context.go('/login'),
child: Text.rich(
TextSpan(
text: 'Already have an account? ',
style: tt.bodyMedium?.copyWith(
color: cs.onSurfaceVariant,
),
children: [
TextSpan(
text: 'Sign in',
style: TextStyle(
color: cs.primary,
fontWeight: FontWeight.w600,
),
),
],
),
),
), ),
], ],
), ),
), ),
const SizedBox(height: 24), ),
TextFormField(
controller: _fullNameController,
decoration: const InputDecoration(labelText: 'Full name'),
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Full name is required.';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Email is required.';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password is required.';
}
if (value.length < 6) {
return 'Use at least 6 characters.';
}
return null;
},
),
const SizedBox(height: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Password strength: $_passwordStrengthLabel',
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 6),
LinearProgressIndicator(
value: _passwordStrength,
minHeight: 8,
borderRadius: BorderRadius.circular(8),
color: _passwordStrengthColor,
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
],
),
const SizedBox(height: 12),
TextFormField(
controller: _confirmPasswordController,
decoration: const InputDecoration(
labelText: 'Confirm password',
),
obscureText: true,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) {
if (!_isLoading) {
_handleSignUp();
}
},
validator: (value) {
if (value == null || value.isEmpty) {
return 'Confirm your password.';
}
if (value != _passwordController.text) {
return 'Passwords do not match.';
}
return null;
},
),
const SizedBox(height: 12),
Text('Offices', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
officesAsync.when(
data: (offices) {
if (offices.isEmpty) {
return const Text('No offices available.');
}
final officeNameById = <String, String>{
for (final o in offices) o.id: o.name,
};
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ElevatedButton.icon(
onPressed: _isLoading
? null
: () => _showOfficeSelectionDialog(offices),
icon: const Icon(Icons.place),
label: const Text('Select Offices'),
),
const SizedBox(height: 8),
if (_selectedOfficeIds.isEmpty)
const Text('No office selected.')
else
Builder(
builder: (context) {
final sortedIds =
List<String>.from(_selectedOfficeIds)..sort(
(a, b) => (officeNameById[a] ?? a)
.toLowerCase()
.compareTo(
(officeNameById[b] ?? b)
.toLowerCase(),
),
);
return Wrap(
spacing: 8,
runSpacing: 8,
children: sortedIds.map((id) {
final name = officeNameById[id] ?? id;
return Chip(
label: Text(name),
onDeleted: _isLoading
? null
: () {
setState(
() =>
_selectedOfficeIds.remove(id),
);
},
);
}).toList(),
);
},
),
],
);
},
loading: () => const LinearProgressIndicator(),
error: (error, _) => Text('Failed to load offices: $error'),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _isLoading ? null : _handleSignUp,
child: _isLoading
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Create Account'),
),
const SizedBox(height: 12),
TextButton(
onPressed: _isLoading ? null : () => context.go('/login'),
child: const Text('Back to sign in'),
),
],
), ),
), ),
), ),
@ -274,16 +476,26 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
} }
Future<void> _showOfficeSelectionDialog(List<dynamic> offices) async { Future<void> _showOfficeSelectionDialog(List<dynamic> offices) async {
final cs = Theme.of(context).colorScheme;
final tempSelected = Set<String>.from(_selectedOfficeIds); final tempSelected = Set<String>.from(_selectedOfficeIds);
await showDialog<void>( await m3ShowDialog<void>(
context: context, context: context,
builder: (dialogCtx) => StatefulBuilder( builder: (dialogCtx) => StatefulBuilder(
builder: (ctx2, setStateDialog) { builder: (ctx2, setStateDialog) {
return AlertDialog( return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
title: const Text('Select Offices'), title: const Text('Select Offices'),
content: ConstrainedBox( content: Container(
constraints: const BoxConstraints(maxWidth: 480), width: 480,
height: 400,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: cs.surfaceContainerLowest,
),
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -303,7 +515,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
}, },
title: Text(name), title: Text(name),
controlAffinity: ListTileControlAffinity.leading, controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero, contentPadding: const EdgeInsets.symmetric(horizontal: 8),
); );
}).toList(), }).toList(),
), ),
@ -314,7 +526,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
onPressed: () => Navigator.of(dialogCtx).pop(), onPressed: () => Navigator.of(dialogCtx).pop(),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
ElevatedButton( FilledButton(
onPressed: () { onPressed: () {
setState(() { setState(() {
_selectedOfficeIds _selectedOfficeIds

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/m3_motion.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
@ -319,7 +320,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
final seen = prefs.getBool('has_seen_notif_showcase') ?? false; final seen = prefs.getBool('has_seen_notif_showcase') ?? false;
if (!seen) { if (!seen) {
if (!mounted) return; if (!mounted) return;
await showDialog<void>( await m3ShowDialog<void>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Never miss an update'), title: const Text('Never miss an update'),
@ -469,9 +470,10 @@ class _DashboardScreenState extends State<DashboardScreen> {
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
child: Text( child: Text(
title, title,
style: Theme.of( style: Theme.of(context).textTheme.titleMedium?.copyWith(
context, fontWeight: FontWeight.w700,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), color: Theme.of(context).colorScheme.onSurfaceVariant,
),
), ),
); );
} }
@ -571,6 +573,7 @@ class _MetricCard extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final cs = Theme.of(context).colorScheme;
// Only watch the single string value for this card so unrelated metric // Only watch the single string value for this card so unrelated metric
// updates don't rebuild the whole card. This makes updates feel much // updates don't rebuild the whole card. This makes updates feel much
// smoother and avoids full-page refreshes. // smoother and avoids full-page refreshes.
@ -584,37 +587,42 @@ class _MetricCard extends ConsumerWidget {
), ),
); );
// M3 Expressive: tonal surface container with 16 dp radius, no hard border.
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 220), duration: const Duration(milliseconds: 400),
padding: const EdgeInsets.all(16), curve: Curves.easeOutCubic,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface, color: cs.surfaceContainerLow,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
title, title,
style: Theme.of( style: Theme.of(context).textTheme.labelLarge?.copyWith(
context, fontWeight: FontWeight.w600,
).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), color: cs.onSurfaceVariant,
),
), ),
const SizedBox(height: 10), const SizedBox(height: 12),
// Animate only the metric text (not the whole card) for a // Animate only the metric text (not the whole card) for a
// subtle, smooth update. // subtle, smooth update.
AnimatedSwitcher( AnimatedSwitcher(
duration: const Duration(milliseconds: 220), duration: const Duration(milliseconds: 400),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, anim) => transitionBuilder: (child, anim) =>
FadeTransition(opacity: anim, child: child), FadeTransition(opacity: anim, child: child),
child: MonoText( child: MonoText(
value, value,
key: ValueKey(value), key: ValueKey(value),
style: Theme.of( style: Theme.of(context).textTheme.headlineSmall?.copyWith(
context, fontWeight: FontWeight.w700,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700), color: cs.onSurface,
),
), ),
), ),
], ],
@ -628,12 +636,15 @@ class _StaffTable extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
// M3 Expressive: tonal surface container, 28 dp radius for large containers.
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface, color: cs.surfaceContainerLow,
borderRadius: BorderRadius.circular(AppSurfaces.of(context).cardRadius), borderRadius: BorderRadius.circular(
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), AppSurfaces.of(context).containerRadius,
),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -124,11 +124,9 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
final title = _notificationTitle(item.type, actorName); final title = _notificationTitle(item.type, actorName);
final icon = _notificationIcon(item.type); final icon = _notificationIcon(item.type);
// Use a slightly more compact card for dense notification lists // M3 Expressive: compact card shape, no shadow.
// 12px radius, subtle shadow so the list remains readable.
return Card( return Card(
shape: AppSurfaces.of(context).compactShape, shape: AppSurfaces.of(context).compactShape,
shadowColor: AppSurfaces.of(context).compactShadowColor,
child: ListTile( child: ListTile(
leading: Icon(icon), leading: Icon(icon),
title: Text(title), title: Text(title),

View File

@ -105,7 +105,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
children: [ children: [
ElevatedButton( FilledButton(
onPressed: _savingDetails ? null : _onSaveDetails, onPressed: _savingDetails ? null : _onSaveDetails,
child: Text( child: Text(
_savingDetails ? 'Saving...' : 'Save details', _savingDetails ? 'Saving...' : 'Save details',
@ -176,7 +176,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
children: [ children: [
ElevatedButton( FilledButton(
onPressed: _changingPassword onPressed: _changingPassword
? null ? null
: _onChangePassword, : _onChangePassword,
@ -224,7 +224,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
children: [ children: [
ElevatedButton( FilledButton(
onPressed: _savingOffices onPressed: _savingOffices
? null ? null
: _onSaveOffices, : _onSaveOffices,

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/m3_motion.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/reports_provider.dart'; import '../../providers/reports_provider.dart';
@ -50,7 +51,7 @@ class ReportDateFilter extends ConsumerWidget {
} }
void _showDateFilterDialog(BuildContext context, WidgetRef ref) { void _showDateFilterDialog(BuildContext context, WidgetRef ref) {
showDialog( m3ShowDialog(
context: context, context: context,
builder: (ctx) => _DateFilterDialog( builder: (ctx) => _DateFilterDialog(
current: ref.read(reportDateRangeProvider), current: ref.read(reportDateRangeProvider),

View File

@ -91,7 +91,10 @@ class ReportCardWrapper extends StatelessWidget {
); );
final card = Card( final card = Card(
// Rely on CardTheme for elevation (M2 exception in hybrid system). elevation: 0,
shadowColor: Colors.transparent,
color: colors.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: cardContent, child: cardContent,
); );

View File

@ -1,4 +1,4 @@
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -84,8 +84,7 @@ class _RequestTypeChartState extends ConsumerState<RequestTypeChart> {
child: PieChart( child: PieChart(
PieChartData( PieChartData(
pieTouchData: PieTouchData( pieTouchData: PieTouchData(
touchCallback: touchCallback: (FlTouchEvent event, pieTouchResponse) {
(FlTouchEvent event, pieTouchResponse) {
setState(() { setState(() {
if (!event.isInterestedForInteractions || if (!event.isInterestedForInteractions ||
pieTouchResponse == null || pieTouchResponse == null ||
@ -93,9 +92,8 @@ class _RequestTypeChartState extends ConsumerState<RequestTypeChart> {
_touchedIndex = -1; _touchedIndex = -1;
return; return;
} }
_touchedIndex = pieTouchResponse _touchedIndex =
.touchedSection! pieTouchResponse.touchedSection!.touchedSectionIndex;
.touchedSectionIndex;
}); });
}, },
), ),
@ -217,8 +215,7 @@ class _RequestCategoryChartState extends ConsumerState<RequestCategoryChart> {
child: PieChart( child: PieChart(
PieChartData( PieChartData(
pieTouchData: PieTouchData( pieTouchData: PieTouchData(
touchCallback: touchCallback: (FlTouchEvent event, pieTouchResponse) {
(FlTouchEvent event, pieTouchResponse) {
setState(() { setState(() {
if (!event.isInterestedForInteractions || if (!event.isInterestedForInteractions ||
pieTouchResponse == null || pieTouchResponse == null ||
@ -226,9 +223,8 @@ class _RequestCategoryChartState extends ConsumerState<RequestCategoryChart> {
_touchedIndex = -1; _touchedIndex = -1;
return; return;
} }
_touchedIndex = pieTouchResponse _touchedIndex =
.touchedSection! pieTouchResponse.touchedSection!.touchedSectionIndex;
.touchedSectionIndex;
}); });
}, },
), ),
@ -274,7 +270,7 @@ class _RequestCategoryChartState extends ConsumerState<RequestCategoryChart> {
} }
} }
// Shared helpers // Shared helpers
class _HoverLegendItem extends StatelessWidget { class _HoverLegendItem extends StatelessWidget {
const _HoverLegendItem({ const _HoverLegendItem({

View File

@ -1,4 +1,4 @@
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -58,8 +58,7 @@ class _TicketsByStatusChartState extends ConsumerState<TicketsByStatusChart> {
child: PieChart( child: PieChart(
PieChartData( PieChartData(
pieTouchData: PieTouchData( pieTouchData: PieTouchData(
touchCallback: touchCallback: (FlTouchEvent event, pieTouchResponse) {
(FlTouchEvent event, pieTouchResponse) {
setState(() { setState(() {
if (!event.isInterestedForInteractions || if (!event.isInterestedForInteractions ||
pieTouchResponse == null || pieTouchResponse == null ||
@ -85,10 +84,7 @@ class _TicketsByStatusChartState extends ConsumerState<TicketsByStatusChart> {
radius: isTouched ? 60 : 50, radius: isTouched ? 60 : 50,
color: _ticketStatusColor(context, e.status), color: _ticketStatusColor(context, e.status),
borderSide: isTouched borderSide: isTouched
? const BorderSide( ? const BorderSide(color: Colors.white, width: 2)
color: Colors.white,
width: 2,
)
: BorderSide.none, : BorderSide.none,
); );
}).toList(), }).toList(),
@ -140,8 +136,7 @@ class TasksByStatusChart extends ConsumerStatefulWidget {
final GlobalKey? repaintKey; final GlobalKey? repaintKey;
@override @override
ConsumerState<TasksByStatusChart> createState() => ConsumerState<TasksByStatusChart> createState() => _TasksByStatusChartState();
_TasksByStatusChartState();
} }
class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> { class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
@ -187,8 +182,7 @@ class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
child: PieChart( child: PieChart(
PieChartData( PieChartData(
pieTouchData: PieTouchData( pieTouchData: PieTouchData(
touchCallback: touchCallback: (FlTouchEvent event, pieTouchResponse) {
(FlTouchEvent event, pieTouchResponse) {
setState(() { setState(() {
if (!event.isInterestedForInteractions || if (!event.isInterestedForInteractions ||
pieTouchResponse == null || pieTouchResponse == null ||
@ -214,10 +208,7 @@ class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
radius: isTouched ? 60 : 50, radius: isTouched ? 60 : 50,
color: _taskStatusColor(context, e.status), color: _taskStatusColor(context, e.status),
borderSide: isTouched borderSide: isTouched
? const BorderSide( ? const BorderSide(color: Colors.white, width: 2)
color: Colors.white,
width: 2,
)
: BorderSide.none, : BorderSide.none,
); );
}).toList(), }).toList(),
@ -280,7 +271,7 @@ class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
} }
} }
// Shared helpers // Shared helpers
class _LegendItem extends StatelessWidget { class _LegendItem extends StatelessWidget {
const _LegendItem({ const _LegendItem({

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/m3_motion.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
@ -84,10 +85,10 @@ class _PermissionsScreenState extends ConsumerState<PermissionsScreen> {
); );
}, },
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: M3Fab(
onPressed: _refreshStatuses, onPressed: _refreshStatuses,
tooltip: 'Refresh', tooltip: 'Refresh',
child: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
), ),
); );
} }

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/app_surfaces.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
class UnderDevelopmentScreen extends StatelessWidget { class UnderDevelopmentScreen extends StatelessWidget {
@ -17,34 +16,28 @@ class UnderDevelopmentScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return ResponsiveBody( return ResponsiveBody(
maxWidth: 720, maxWidth: 720,
padding: const EdgeInsets.symmetric(vertical: 32), padding: const EdgeInsets.symmetric(vertical: 32),
child: Center( child: Center(
// M3 Expressive: elevated card with tonal fill, 28 dp radius.
child: Card( child: Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(40),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( Container(
width: 72, width: 80,
height: 72, height: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of( color: cs.primaryContainer,
context, borderRadius: BorderRadius.circular(28),
).colorScheme.primaryContainer.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(
AppSurfaces.of(context).dialogRadius,
),
),
child: Icon(
icon,
size: 36,
color: Theme.of(context).colorScheme.primary,
), ),
child: Icon(icon, size: 40, color: cs.onPrimaryContainer),
), ),
const SizedBox(height: 20), const SizedBox(height: 24),
Text( Text(
title, title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(context).textTheme.headlineSmall?.copyWith(
@ -55,26 +48,27 @@ class UnderDevelopmentScreen extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
subtitle, subtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(
color: Theme.of(context).colorScheme.onSurfaceVariant, context,
), ).textTheme.bodyMedium?.copyWith(color: cs.onSurfaceVariant),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 20), const SizedBox(height: 24),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 16, horizontal: 20,
vertical: 8, vertical: 10,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(28),
color: Theme.of( color: cs.secondaryContainer,
context,
).colorScheme.surfaceContainerHighest,
), ),
child: Text( child: Text(
'Under development', 'Under development',
style: Theme.of(context).textTheme.labelLarge, style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: cs.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
), ),
), ),
], ],

View File

@ -1,5 +1,6 @@
// ignore_for_file: use_build_context_synchronously // ignore_for_file: use_build_context_synchronously
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/m3_motion.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -1919,7 +1920,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
onPressed: () async { onPressed: () async {
final urlCtrl = final urlCtrl =
TextEditingController(); TextEditingController();
final res = await showDialog<String?>( final res = await m3ShowDialog<String?>(
context: context:
context, context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
@ -2234,7 +2235,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
onPressed: () async { onPressed: () async {
final urlCtrl = final urlCtrl =
TextEditingController(); TextEditingController();
final res = await showDialog<String?>( final res = await m3ShowDialog<String?>(
context: context:
context, context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
@ -2848,7 +2849,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
child: SizedBox( child: SizedBox(
width: 280, width: 280,
child: Card( child: Card(
elevation: 4, elevation: 0,
shadowColor: Colors.transparent,
child: Padding( child: Padding(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(12.0),
child: Row( child: Row(
@ -3779,7 +3781,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
Timer? titleTypingTimer; Timer? titleTypingTimer;
try { try {
await showDialog<void>( await m3ShowDialog<void>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
var saving = false; var saving = false;
@ -3999,7 +4001,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
: () => Navigator.of(dialogContext).pop(), : () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
ElevatedButton( FilledButton(
onPressed: saving onPressed: saving
? null ? null
: () async { : () async {
@ -4174,7 +4176,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
// If cancelling, require a reason show dialog with spinner. // If cancelling, require a reason show dialog with spinner.
if (value == 'cancelled') { if (value == 'cancelled') {
final reasonCtrl = TextEditingController(); final reasonCtrl = TextEditingController();
await showDialog<void>( await m3ShowDialog<void>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
var isSaving = false; var isSaving = false;
@ -4399,7 +4401,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
if (!mounted) return; if (!mounted) return;
// Show loading dialog // Show loading dialog
showDialog( m3ShowDialog(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (BuildContext dialogContext) { builder: (BuildContext dialogContext) {
@ -4543,7 +4545,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
Future<void> _deleteTaskAttachment(String taskId, String fileName) async { Future<void> _deleteTaskAttachment(String taskId, String fileName) async {
try { try {
final confirmed = await showDialog<bool>( final confirmed = await m3ShowDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Delete Attachment?'), title: const Text('Delete Attachment?'),

View File

@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/m3_motion.dart';
import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_quill/flutter_quill.dart' as quill; import 'package:flutter_quill/flutter_quill.dart' as quill;
import 'package:pdf/widgets.dart' as pw; import 'package:pdf/widgets.dart' as pw;
@ -495,7 +496,7 @@ Future<void> showTaskPdfPreview(
List<TaskAssignment> assignments, List<TaskAssignment> assignments,
List<Profile> profiles, List<Profile> profiles,
) async { ) async {
await showDialog<void>( await m3ShowDialog<void>(
context: context, context: context,
builder: (ctx) => TaskPdfDialog( builder: (ctx) => TaskPdfDialog(
task: task, task: task,

View File

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/m3_motion.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart';
@ -554,7 +555,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
right: 16, right: 16,
bottom: 16, bottom: 16,
child: SafeArea( child: SafeArea(
child: FloatingActionButton.extended( child: M3ExpandedFab(
onPressed: () => _showCreateTaskDialog(context, ref), onPressed: () => _showCreateTaskDialog(context, ref),
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('New Task'), label: const Text('New Task'),
@ -588,7 +589,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
var showTitleGemini = false; var showTitleGemini = false;
Timer? titleTypingTimer; Timer? titleTypingTimer;
await showDialog<void>( await m3ShowDialog<void>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
bool saving = false; bool saving = false;
@ -1099,8 +1100,11 @@ class _StatusSummaryCard extends StatelessWidget {
_ => scheme.onSurfaceVariant, _ => scheme.onSurfaceVariant,
}; };
// M3 Expressive: filled card with semantic tonal color, no shadow.
return Card( return Card(
color: background, color: background,
elevation: 0,
shadowColor: Colors.transparent,
// summary cards are compact use compact token for consistent density // summary cards are compact use compact token for consistent density
shape: AppSurfaces.of(context).compactShape, shape: AppSurfaces.of(context).compactShape,
child: Padding( child: Padding(

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/m3_motion.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/office.dart'; import '../../models/office.dart';
import '../../models/profile.dart'; import '../../models/profile.dart';
@ -257,10 +258,10 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')), error: (err, stack) => Center(child: Text('Error: $err')),
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: M3Fab(
onPressed: () => _showTeamDialog(context), onPressed: () => _showTeamDialog(context),
tooltip: 'Add Team', tooltip: 'Add Team',
child: const Icon(Icons.add), icon: const Icon(Icons.add),
), ),
); );
} }
@ -520,10 +521,9 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
if (isMobileDialog) { if (isMobileDialog) {
// Mobile: bottom sheet presentation // Mobile: bottom sheet presentation
await showModalBottomSheet<void>( await m3ShowBottomSheet<void>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
shape: AppSurfaces.of(context).dialogShape,
builder: (sheetContext) { builder: (sheetContext) {
return StatefulBuilder( return StatefulBuilder(
builder: (sheetContext, setState) { builder: (sheetContext, setState) {
@ -555,7 +555,7 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
ElevatedButton( FilledButton(
onPressed: () => onSave(setState, navigator), onPressed: () => onSave(setState, navigator),
child: Text(isEdit ? 'Save' : 'Add'), child: Text(isEdit ? 'Save' : 'Add'),
), ),
@ -571,7 +571,7 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
); );
} else { } else {
// Desktop / Tablet: centered fixed-width AlertDialog // Desktop / Tablet: centered fixed-width AlertDialog
await showDialog<void>( await m3ShowDialog<void>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
return Center( return Center(
@ -589,7 +589,7 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
onPressed: () => navigator.pop(), onPressed: () => navigator.pop(),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
ElevatedButton( FilledButton(
onPressed: () => onSave(setState, navigator), onPressed: () => onSave(setState, navigator),
child: Text(isEdit ? 'Save' : 'Add'), child: Text(isEdit ? 'Save' : 'Add'),
), ),
@ -605,7 +605,7 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
} }
void _deleteTeam(BuildContext context, String teamId) async { void _deleteTeam(BuildContext context, String teamId) async {
final confirmed = await showDialog<bool?>( final confirmed = await m3ShowDialog<bool?>(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
shape: AppSurfaces.of(dialogContext).dialogShape, shape: AppSurfaces.of(dialogContext).dialogShape,
@ -619,7 +619,7 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
onPressed: () => Navigator.of(dialogContext).pop(false), onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
ElevatedButton( FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true), onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Delete'), child: const Text('Delete'),
), ),

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/m3_motion.dart';
import 'package:tasq/utils/app_time.dart'; import 'package:tasq/utils/app_time.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@ -858,7 +859,7 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
} }
Future<void> _showTimelineDialog(BuildContext context, Ticket ticket) async { Future<void> _showTimelineDialog(BuildContext context, Ticket ticket) async {
await showDialog<void>( await m3ShowDialog<void>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
return AlertDialog( return AlertDialog(
@ -897,7 +898,7 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
final descCtrl = TextEditingController(text: ticket.description); final descCtrl = TextEditingController(text: ticket.description);
String? selectedOffice = ticket.officeId; String? selectedOffice = ticket.officeId;
await showDialog<void>( await m3ShowDialog<void>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
var saving = false; var saving = false;
@ -967,7 +968,7 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
: () => Navigator.of(dialogContext).pop(), : () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
ElevatedButton( FilledButton(
onPressed: saving onPressed: saving
? null ? null
: () async { : () async {

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/m3_motion.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tasq/utils/app_time.dart'; import 'package:tasq/utils/app_time.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@ -341,7 +342,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
right: 16, right: 16,
bottom: 16, bottom: 16,
child: SafeArea( child: SafeArea(
child: FloatingActionButton.extended( child: M3ExpandedFab(
onPressed: () => _showCreateTicketDialog(context, ref), onPressed: () => _showCreateTicketDialog(context, ref),
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('New Ticket'), label: const Text('New Ticket'),
@ -361,7 +362,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
final descriptionController = TextEditingController(); final descriptionController = TextEditingController();
Office? selectedOffice; Office? selectedOffice;
await showDialog<void>( await m3ShowDialog<void>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
bool saving = false; bool saving = false;
@ -644,8 +645,11 @@ class _StatusSummaryCard extends StatelessWidget {
_ => scheme.onSurfaceVariant, _ => scheme.onSurfaceVariant,
}; };
// M3 Expressive: filled card with semantic tonal color, no shadow.
return Card( return Card(
color: background, color: background,
elevation: 0,
shadowColor: Colors.transparent,
// summary cards are compact use compact token for consistent density // summary cards are compact use compact token for consistent density
shape: AppSurfaces.of(context).compactShape, shape: AppSurfaces.of(context).compactShape,
child: Padding( child: Padding(

View File

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/m3_motion.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tasq/utils/app_time.dart'; import 'package:tasq/utils/app_time.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
@ -451,7 +452,7 @@ class _ScheduleTile extends ConsumerWidget {
List<DutySchedule> recipientShifts = []; List<DutySchedule> recipientShifts = [];
String? selectedTargetShiftId; String? selectedTargetShiftId;
final confirmed = await showDialog<bool>( final confirmed = await m3ShowDialog<bool>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
return StatefulBuilder( return StatefulBuilder(
@ -614,7 +615,7 @@ class _ScheduleTile extends ConsumerWidget {
required String title, required String title,
required String message, required String message,
}) async { }) async {
await showDialog<void>( await m3ShowDialog<void>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
return AlertDialog( return AlertDialog(
@ -633,7 +634,7 @@ class _ScheduleTile extends ConsumerWidget {
Future<BuildContext> _showCheckInProgress(BuildContext context) { Future<BuildContext> _showCheckInProgress(BuildContext context) {
final completer = Completer<BuildContext>(); final completer = Completer<BuildContext>();
showDialog<void>( m3ShowDialog<void>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (dialogContext) { builder: (dialogContext) {
@ -1140,7 +1141,7 @@ class _ScheduleGeneratorPanelState
existing?.endTime ?? start.add(const Duration(hours: 8)), existing?.endTime ?? start.add(const Duration(hours: 8)),
); );
final result = await showDialog<_DraftSchedule>( final result = await m3ShowDialog<_DraftSchedule>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
return StatefulBuilder( return StatefulBuilder(
@ -2002,7 +2003,7 @@ class _SwapRequestsPanel extends ConsumerWidget {
} }
Profile? choice = eligible.first; Profile? choice = eligible.first;
final selected = await showDialog<Profile?>( final selected = await m3ShowDialog<Profile?>(
context: context, context: context,
builder: (context) { builder: (context) {
return AlertDialog( return AlertDialog(

View File

@ -1,22 +1,34 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// M3 Expressive surface tokens.
///
/// Cards now use **tonal elevation** (color tints) instead of drop-shadows.
/// Large containers adopt the M3 standard 28 dp corner radius; compact items
/// use 16 dp; small chips/badges use 12 dp.
@immutable @immutable
class AppSurfaces extends ThemeExtension<AppSurfaces> { class AppSurfaces extends ThemeExtension<AppSurfaces> {
const AppSurfaces({ const AppSurfaces({
required this.cardRadius, required this.cardRadius,
required this.compactCardRadius, required this.compactCardRadius,
required this.containerRadius,
required this.dialogRadius, required this.dialogRadius,
required this.cardElevation, required this.chipRadius,
required this.cardShadowColor,
required this.compactShadowColor,
}); });
/// Standard card radius 16 dp (M3 medium shape).
final double cardRadius; final double cardRadius;
/// Compact card radius for dense list tiles 12 dp.
final double compactCardRadius; final double compactCardRadius;
/// Large container radius 28 dp (M3 Expressive).
final double containerRadius;
/// Dialog / bottom-sheet radius 28 dp.
final double dialogRadius; final double dialogRadius;
final double cardElevation;
final Color cardShadowColor; /// Chip / badge radius 12 dp.
final Color compactShadowColor; final double chipRadius;
// convenience shapes // convenience shapes
RoundedRectangleBorder get standardShape => RoundedRectangleBorder get standardShape =>
@ -24,6 +36,9 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
RoundedRectangleBorder get compactShape => RoundedRectangleBorder( RoundedRectangleBorder get compactShape => RoundedRectangleBorder(
borderRadius: BorderRadius.circular(compactCardRadius), borderRadius: BorderRadius.circular(compactCardRadius),
); );
RoundedRectangleBorder get containerShape => RoundedRectangleBorder(
borderRadius: BorderRadius.circular(containerRadius),
);
RoundedRectangleBorder get dialogShape => RoundedRectangleBorder get dialogShape =>
RoundedRectangleBorder(borderRadius: BorderRadius.circular(dialogRadius)); RoundedRectangleBorder(borderRadius: BorderRadius.circular(dialogRadius));
@ -33,10 +48,9 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
const AppSurfaces( const AppSurfaces(
cardRadius: 16, cardRadius: 16,
compactCardRadius: 12, compactCardRadius: 12,
dialogRadius: 20, containerRadius: 28,
cardElevation: 3, dialogRadius: 28,
cardShadowColor: Color.fromRGBO(0, 0, 0, 0.12), chipRadius: 12,
compactShadowColor: Color.fromRGBO(0, 0, 0, 0.08),
); );
} }
@ -44,18 +58,16 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
AppSurfaces copyWith({ AppSurfaces copyWith({
double? cardRadius, double? cardRadius,
double? compactCardRadius, double? compactCardRadius,
double? containerRadius,
double? dialogRadius, double? dialogRadius,
double? cardElevation, double? chipRadius,
Color? cardShadowColor,
Color? compactShadowColor,
}) { }) {
return AppSurfaces( return AppSurfaces(
cardRadius: cardRadius ?? this.cardRadius, cardRadius: cardRadius ?? this.cardRadius,
compactCardRadius: compactCardRadius ?? this.compactCardRadius, compactCardRadius: compactCardRadius ?? this.compactCardRadius,
containerRadius: containerRadius ?? this.containerRadius,
dialogRadius: dialogRadius ?? this.dialogRadius, dialogRadius: dialogRadius ?? this.dialogRadius,
cardElevation: cardElevation ?? this.cardElevation, chipRadius: chipRadius ?? this.chipRadius,
cardShadowColor: cardShadowColor ?? this.cardShadowColor,
compactShadowColor: compactShadowColor ?? this.compactShadowColor,
); );
} }
@ -63,28 +75,17 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
AppSurfaces lerp(ThemeExtension<AppSurfaces>? other, double t) { AppSurfaces lerp(ThemeExtension<AppSurfaces>? other, double t) {
if (other is! AppSurfaces) return this; if (other is! AppSurfaces) return this;
return AppSurfaces( return AppSurfaces(
cardRadius: lerpDouble(cardRadius, other.cardRadius, t) ?? cardRadius, cardRadius: _lerpDouble(cardRadius, other.cardRadius, t),
compactCardRadius: compactCardRadius: _lerpDouble(
lerpDouble(compactCardRadius, other.compactCardRadius, t) ?? compactCardRadius,
compactCardRadius, other.compactCardRadius,
dialogRadius: t,
lerpDouble(dialogRadius, other.dialogRadius, t) ?? dialogRadius, ),
cardElevation: containerRadius: _lerpDouble(containerRadius, other.containerRadius, t),
lerpDouble(cardElevation, other.cardElevation, t) ?? cardElevation, dialogRadius: _lerpDouble(dialogRadius, other.dialogRadius, t),
cardShadowColor: chipRadius: _lerpDouble(chipRadius, other.chipRadius, t),
Color.lerp(cardShadowColor, other.cardShadowColor, t) ??
cardShadowColor,
compactShadowColor:
Color.lerp(compactShadowColor, other.compactShadowColor, t) ??
compactShadowColor,
); );
} }
} }
// Helper because dart:ui lerpDouble isn't exported here double _lerpDouble(double a, double b, double t) => a + (b - a) * t;
double? lerpDouble(num? a, num? b, double t) {
if (a == null && b == null) return null;
a = a ?? 0;
b = b ?? 0;
return a + (b - a) * t;
}

View File

@ -4,143 +4,59 @@ import 'package:google_fonts/google_fonts.dart';
import 'app_typography.dart'; import 'app_typography.dart';
import 'app_surfaces.dart'; import 'app_surfaces.dart';
/// M3 Expressive theme for TasQ.
///
/// Key differences from the previous Hybrid M2/M3 theme:
/// * Cards use **tonal elevation** (surfaceTint color overlays) instead of
/// drop-shadows, giving surfaces an organic, seed-tinted look.
/// * Large containers use the M3 standard **28 dp** corner radius.
/// * Buttons follow the M3 hierarchy: FilledButton (primary), Tonal, Elevated,
/// Outlined, and Text.
/// * NavigationBar / NavigationRail use pill-shaped indicators with the
/// secondary-container tonal color.
/// * Spring-physics inspired durations: transitions default to 400 ms with an
/// emphasized easing curve.
class AppTheme { class AppTheme {
/// The seed color drives M3's entire tonal palette generation.
static const Color _seed = Color(0xFF4A6FA5);
//
// LIGHT
//
static ThemeData light() { static ThemeData light() {
final base = ThemeData( final base = ThemeData(
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF334155), seedColor: _seed,
brightness: Brightness.light, brightness: Brightness.light,
), ),
useMaterial3: true, useMaterial3: true,
); );
final textTheme = GoogleFonts.spaceGroteskTextTheme(base.textTheme); return _apply(base);
final monoTheme = GoogleFonts.robotoMonoTextTheme(base.textTheme);
final mono = AppMonoText(
label:
monoTheme.labelMedium?.copyWith(letterSpacing: 0.3) ??
const TextStyle(letterSpacing: 0.3),
body:
monoTheme.bodyMedium?.copyWith(letterSpacing: 0.2) ??
const TextStyle(letterSpacing: 0.2),
);
final surfaces = AppSurfaces(
cardRadius: 16,
compactCardRadius: 12,
dialogRadius: 20,
cardElevation: 3,
cardShadowColor: const Color.fromRGBO(0, 0, 0, 0.12),
compactShadowColor: const Color.fromRGBO(0, 0, 0, 0.08),
);
return base.copyWith(
textTheme: textTheme,
scaffoldBackgroundColor: base.colorScheme.surfaceContainerLowest,
extensions: [mono, surfaces],
appBarTheme: AppBarTheme(
backgroundColor: base.colorScheme.surface,
foregroundColor: base.colorScheme.onSurface,
elevation: 0,
scrolledUnderElevation: 1,
surfaceTintColor: base.colorScheme.surfaceTint,
centerTitle: false,
titleTextStyle: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
),
cardTheme: CardThemeData(
color: base.colorScheme.surface,
elevation: 3, // M2-style elevation for visible separation (2-4 allowed)
margin: EdgeInsets.zero,
shadowColor: const Color.fromRGBO(0, 0, 0, 0.12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: base.colorScheme.outlineVariant, width: 1),
),
),
chipTheme: ChipThemeData(
backgroundColor: base.colorScheme.surfaceContainerHighest,
side: BorderSide(color: base.colorScheme.outlineVariant),
labelStyle: textTheme.labelSmall,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
dividerTheme: DividerThemeData(
color: base.colorScheme.outlineVariant,
thickness: 1,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: base.colorScheme.surfaceContainerLow,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: base.colorScheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: base.colorScheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: base.colorScheme.primary, width: 1.5),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
side: BorderSide(color: base.colorScheme.outline),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
),
),
navigationDrawerTheme: NavigationDrawerThemeData(
backgroundColor: base.colorScheme.surface,
indicatorColor: base.colorScheme.secondaryContainer,
tileHeight: 52,
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: base.colorScheme.surface,
selectedIconTheme: IconThemeData(color: base.colorScheme.primary),
selectedLabelTextStyle: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
unselectedIconTheme: IconThemeData(color: base.colorScheme.onSurface),
indicatorColor: base.colorScheme.secondaryContainer,
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: base.colorScheme.surface,
indicatorColor: base.colorScheme.primaryContainer,
labelTextStyle: WidgetStateProperty.all(
textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600),
),
),
listTileTheme: ListTileThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
tileColor: base.colorScheme.surface,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
),
);
} }
//
// DARK
//
static ThemeData dark() { static ThemeData dark() {
final base = ThemeData( final base = ThemeData(
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF334155), seedColor: _seed,
brightness: Brightness.dark, brightness: Brightness.dark,
), ),
useMaterial3: true, useMaterial3: true,
); );
return _apply(base);
}
//
// SHARED BUILDER
//
static ThemeData _apply(ThemeData base) {
final cs = base.colorScheme;
final isDark = cs.brightness == Brightness.dark;
final textTheme = GoogleFonts.spaceGroteskTextTheme(base.textTheme); final textTheme = GoogleFonts.spaceGroteskTextTheme(base.textTheme);
final monoTheme = GoogleFonts.robotoMonoTextTheme(base.textTheme); final monoTheme = GoogleFonts.robotoMonoTextTheme(base.textTheme);
final mono = AppMonoText( final mono = AppMonoText(
@ -152,110 +68,261 @@ class AppTheme {
const TextStyle(letterSpacing: 0.2), const TextStyle(letterSpacing: 0.2),
); );
final surfaces = AppSurfaces( const surfaces = AppSurfaces(
cardRadius: 16, cardRadius: 16,
compactCardRadius: 12, compactCardRadius: 12,
dialogRadius: 20, containerRadius: 28,
cardElevation: 3, dialogRadius: 28,
cardShadowColor: const Color.fromRGBO(0, 0, 0, 0.24), chipRadius: 12,
compactShadowColor: const Color.fromRGBO(0, 0, 0, 0.12),
); );
return base.copyWith( return base.copyWith(
textTheme: textTheme, textTheme: textTheme,
scaffoldBackgroundColor: base.colorScheme.surface, scaffoldBackgroundColor: isDark ? cs.surface : cs.surfaceContainerLowest,
extensions: [mono, surfaces], extensions: [mono, surfaces],
// AppBar
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
backgroundColor: base.colorScheme.surface, backgroundColor: cs.surface,
foregroundColor: base.colorScheme.onSurface, foregroundColor: cs.onSurface,
elevation: 0, elevation: 0,
scrolledUnderElevation: 1, scrolledUnderElevation: 2,
surfaceTintColor: base.colorScheme.surfaceTint, surfaceTintColor: cs.surfaceTint,
centerTitle: false, centerTitle: false,
titleTextStyle: textTheme.titleLarge?.copyWith( titleTextStyle: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.2, letterSpacing: 0.2,
), ),
), ),
// Cards M3 Elevated (tonal surface tint, no hard shadow)
cardTheme: CardThemeData( cardTheme: CardThemeData(
color: base.colorScheme.surfaceContainer, color: isDark ? cs.surfaceContainer : cs.surfaceContainerLow,
elevation: 3, // M2-style elevation for visible separation (2-4 allowed) elevation: 1,
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
shadowColor: const Color.fromRGBO(0, 0, 0, 0.24), shadowColor: Colors.transparent,
shape: RoundedRectangleBorder( surfaceTintColor: cs.surfaceTint,
borderRadius: BorderRadius.circular(16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
side: BorderSide(color: base.colorScheme.outlineVariant, width: 1),
),
), ),
// Chips
chipTheme: ChipThemeData( chipTheme: ChipThemeData(
backgroundColor: base.colorScheme.surfaceContainerHighest, backgroundColor: cs.surfaceContainerHighest,
side: BorderSide(color: base.colorScheme.outlineVariant), side: BorderSide.none,
labelStyle: textTheme.labelSmall, labelStyle: textTheme.labelSmall,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
), ),
dividerTheme: DividerThemeData(
color: base.colorScheme.outlineVariant, // Dividers
thickness: 1, dividerTheme: DividerThemeData(color: cs.outlineVariant, thickness: 1),
),
// Input Fields
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: base.colorScheme.surfaceContainerLow, fillColor: cs.surfaceContainerLow,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: base.colorScheme.outlineVariant), borderSide: BorderSide(color: cs.outlineVariant),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: base.colorScheme.outlineVariant), borderSide: BorderSide(color: cs.outlineVariant),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: base.colorScheme.primary, width: 1.5), borderSide: BorderSide(color: cs.primary, width: 2),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
), ),
), ),
// Buttons M3 Expressive hierarchy
filledButtonTheme: FilledButtonThemeData( filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
textStyle: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: cs.surfaceContainerLow,
foregroundColor: cs.primary,
elevation: 1,
shadowColor: Colors.transparent,
surfaceTintColor: cs.surfaceTint,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
textStyle: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
), ),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
), ),
), ),
outlinedButtonTheme: OutlinedButtonThemeData( outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
side: BorderSide(color: cs.outline),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
textStyle: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
textStyle: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
side: BorderSide(color: base.colorScheme.outline),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
), ),
), ),
segmentedButtonTheme: SegmentedButtonThemeData(
style: ButtonStyle(
shape: WidgetStateProperty.all(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
),
),
// FAB M3 Expressive
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: cs.primaryContainer,
foregroundColor: cs.onPrimaryContainer,
elevation: 3,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
// Navigation M3 Expressive pill indicators
navigationDrawerTheme: NavigationDrawerThemeData( navigationDrawerTheme: NavigationDrawerThemeData(
backgroundColor: base.colorScheme.surface, backgroundColor: cs.surface,
indicatorColor: base.colorScheme.secondaryContainer, indicatorColor: cs.secondaryContainer,
tileHeight: 52, tileHeight: 56,
indicatorShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
), ),
navigationRailTheme: NavigationRailThemeData( navigationRailTheme: NavigationRailThemeData(
backgroundColor: base.colorScheme.surface, backgroundColor: cs.surface,
selectedIconTheme: IconThemeData(color: base.colorScheme.primary), selectedIconTheme: IconThemeData(color: cs.onSecondaryContainer),
selectedLabelTextStyle: textTheme.labelLarge?.copyWith( unselectedIconTheme: IconThemeData(color: cs.onSurfaceVariant),
fontWeight: FontWeight.w600, selectedLabelTextStyle: textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
color: cs.onSurface,
),
unselectedLabelTextStyle: textTheme.labelMedium?.copyWith(
color: cs.onSurfaceVariant,
),
indicatorColor: cs.secondaryContainer,
indicatorShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
), ),
unselectedIconTheme: IconThemeData(color: base.colorScheme.onSurface),
indicatorColor: base.colorScheme.secondaryContainer,
), ),
navigationBarTheme: NavigationBarThemeData( navigationBarTheme: NavigationBarThemeData(
backgroundColor: base.colorScheme.surface, backgroundColor: cs.surfaceContainer,
indicatorColor: base.colorScheme.primaryContainer, indicatorColor: cs.secondaryContainer,
labelTextStyle: WidgetStateProperty.all( indicatorShape: RoundedRectangleBorder(
textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600), borderRadius: BorderRadius.circular(16),
), ),
labelTextStyle: WidgetStateProperty.resolveWith((states) {
final selected = states.contains(WidgetState.selected);
return textTheme.labelMedium?.copyWith(
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? cs.onSurface : cs.onSurfaceVariant,
);
}),
elevation: 2,
surfaceTintColor: cs.surfaceTint,
), ),
// List Tiles
listTileTheme: ListTileThemeData( listTileTheme: ListTileThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
tileColor: base.colorScheme.surfaceContainer, tileColor: Colors.transparent,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
), ),
// Dialogs
dialogTheme: DialogThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
surfaceTintColor: cs.surfaceTint,
backgroundColor: isDark
? cs.surfaceContainerHigh
: cs.surfaceContainerLowest,
),
// Bottom Sheets
bottomSheetTheme: BottomSheetThemeData(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
backgroundColor: isDark
? cs.surfaceContainerHigh
: cs.surfaceContainerLowest,
surfaceTintColor: cs.surfaceTint,
showDragHandle: true,
),
// Snackbar
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
// Search Bar
searchBarTheme: SearchBarThemeData(
elevation: WidgetStateProperty.all(0),
backgroundColor: WidgetStateProperty.all(cs.surfaceContainerHigh),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
),
),
// Tooltips
tooltipTheme: TooltipThemeData(
decoration: BoxDecoration(
color: cs.inverseSurface,
borderRadius: BorderRadius.circular(8),
),
textStyle: textTheme.bodySmall?.copyWith(color: cs.onInverseSurface),
),
// Tab Bar
tabBarTheme: TabBarThemeData(
labelStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700),
unselectedLabelStyle: textTheme.labelLarge,
indicatorColor: cs.primary,
labelColor: cs.primary,
unselectedLabelColor: cs.onSurfaceVariant,
indicatorSize: TabBarIndicatorSize.label,
dividerColor: cs.outlineVariant,
),
// PopupMenu
popupMenuTheme: PopupMenuThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
surfaceTintColor: cs.surfaceTint,
color: isDark ? cs.surfaceContainerHigh : cs.surfaceContainerLowest,
),
); );
} }
} }

516
lib/theme/m3_motion.dart Normal file
View File

@ -0,0 +1,516 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// M3 Expressive motion constants and helpers.
///
/// Transitions use spring-physics inspired curves with an emphasized easing
/// feel. Duration targets are tuned for fluidity:
/// * **Micro** (150 ms): status-pill toggles, icon swaps.
/// * **Short** (250 ms): list item reveal, chip state changes.
/// * **Standard** (400 ms): page transitions, container expansions.
/// * **Long** (550 ms): full-page shared-axis transitions.
class M3Motion {
M3Motion._();
// Durations
static const Duration micro = Duration(milliseconds: 150);
static const Duration short = Duration(milliseconds: 250);
static const Duration standard = Duration(milliseconds: 400);
static const Duration long = Duration(milliseconds: 550);
// Curves (M3 Expressive)
/// Emphasized enter starts slow then accelerates.
static const Curve emphasizedEnter = Curves.easeOutCubic;
/// Emphasized exit decelerates to a stop.
static const Curve emphasizedExit = Curves.easeInCubic;
/// Standard easing for most container transforms.
static const Curve standard_ = Curves.easeInOutCubicEmphasized;
/// Spring-physics inspired curve for bouncy interactions.
static const Curve spring = _SpringCurve();
/// M3 Expressive emphasized decelerate the hero curve for enter motions.
static const Curve expressiveDecelerate = Cubic(0.05, 0.7, 0.1, 1.0);
/// M3 Expressive emphasized accelerate for exit motions.
static const Curve expressiveAccelerate = Cubic(0.3, 0.0, 0.8, 0.15);
}
/// A simple spring-physics curve that produces a slight overshoot.
class _SpringCurve extends Curve {
const _SpringCurve();
@override
double transformInternal(double t) {
// Attempt a more natural spring feel: slight overshoot then settle.
// Based on damped harmonic oscillator approximation.
const damping = 0.7;
const freq = 3.5;
return 1.0 -
math.pow(math.e, -damping * freq * t) *
math.cos(freq * math.sqrt(1 - damping * damping) * t * math.pi);
}
}
/// Wraps a child with a staggered fade + slide-up entrance animation.
///
/// Use inside lists for sequential reveal of items.
class M3FadeSlideIn extends StatefulWidget {
const M3FadeSlideIn({
super.key,
required this.child,
this.delay = Duration.zero,
this.duration = const Duration(milliseconds: 400),
});
final Widget child;
final Duration delay;
final Duration duration;
@override
State<M3FadeSlideIn> createState() => _M3FadeSlideInState();
}
class _M3FadeSlideInState extends State<M3FadeSlideIn>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _opacity;
late final Animation<Offset> _slide;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: widget.duration);
_opacity = CurvedAnimation(
parent: _controller,
curve: M3Motion.emphasizedEnter,
);
_slide = Tween<Offset>(begin: const Offset(0, 0.04), end: Offset.zero)
.animate(
CurvedAnimation(parent: _controller, curve: M3Motion.emphasizedEnter),
);
if (widget.delay == Duration.zero) {
_controller.forward();
} else {
Future.delayed(widget.delay, () {
if (mounted) _controller.forward();
});
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _opacity,
child: SlideTransition(position: _slide, child: widget.child),
);
}
}
/// A page-route that uses a shared-axis (vertical) transition.
///
/// ```dart
/// Navigator.of(context).push(M3SharedAxisRoute(child: DetailScreen()));
/// ```
class M3SharedAxisRoute<T> extends PageRouteBuilder<T> {
M3SharedAxisRoute({required this.child})
: super(
transitionDuration: M3Motion.standard,
reverseTransitionDuration: M3Motion.short,
pageBuilder: (_, a, b) => child,
transitionsBuilder: _fadeThroughBuilder,
);
final Widget child;
static Widget _fadeThroughBuilder(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return _M3FadeThrough(
animation: animation,
secondaryAnimation: secondaryAnimation,
child: child,
);
}
}
// GoRouter-compatible transition pages
/// A [CustomTransitionPage] that performs a container-transform-style
/// transition: the incoming page fades in + scales up smoothly while the
/// outgoing page fades through. Uses M3 Expressive emphasized curves for
/// a fluid, spring-like feel.
///
/// Use this for card detail navigations (tickets, tasks).
class M3ContainerTransformPage<T> extends CustomTransitionPage<T> {
const M3ContainerTransformPage({
required super.child,
super.key,
super.name,
super.arguments,
super.restorationId,
}) : super(
transitionDuration: M3Motion.standard,
reverseTransitionDuration: M3Motion.standard,
transitionsBuilder: _containerTransformBuilder,
);
static Widget _containerTransformBuilder(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
// M3 Expressive: Fade-through with scale. The outgoing content fades out
// in the first 35 % of the duration, then the incoming content fades in
// with a subtle scale from 94 % 100 %. This prevents the "double image"
// overlap that makes transitions feel choppy.
return AnimatedBuilder(
animation: Listenable.merge([animation, secondaryAnimation]),
builder: (context, _) {
// Forward / reverse animation
final t = animation.value;
final curved = M3Motion.expressiveDecelerate.transform(t);
// Outgoing: when this page is being moved out by a NEW incoming page
final st = secondaryAnimation.value;
// Scale: 0.94 1.0 on enter, 1.0 0.96 on exit-by-secondary
final scale = 1.0 - (1.0 - curved) * 0.06;
final secondaryScale = 1.0 - st * 0.04;
// Opacity: fade in first 40 %, stay 1.0 rest. On secondary, fade first 30 %.
final opacity = (t / 0.4).clamp(0.0, 1.0);
final secondaryOpacity = (1.0 - (st / 0.3).clamp(0.0, 1.0));
return Opacity(
opacity: secondaryOpacity,
child: Transform.scale(
scale: secondaryScale,
child: Opacity(
opacity: opacity,
child: Transform.scale(scale: scale, child: child),
),
),
);
},
);
}
}
/// A [CustomTransitionPage] implementing the M3 fade-through transition.
/// Best for top-level navigation changes within a shell.
///
/// Uses a proper "fade through" pattern: outgoing fades out first, then
/// incoming fades in with a subtle vertical shift. This eliminates the
/// "double image" overlap that causes choppiness.
class M3SharedAxisPage<T> extends CustomTransitionPage<T> {
const M3SharedAxisPage({
required super.child,
super.key,
super.name,
super.arguments,
super.restorationId,
}) : super(
transitionDuration: M3Motion.standard,
reverseTransitionDuration: M3Motion.short,
transitionsBuilder: _sharedAxisBuilder,
);
static Widget _sharedAxisBuilder(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return _M3FadeThrough(
animation: animation,
secondaryAnimation: secondaryAnimation,
slideOffset: const Offset(0, 0.02),
child: child,
);
}
}
/// Core M3 fade-through transition widget used by both shared-axis and
/// route transitions.
///
/// **Pattern**: outgoing content fades to 0 in the first third incoming
/// content fades from 0 to 1 in the remaining two-thirds with an optional
/// subtle slide. This two-phase approach prevents "ghosting".
class _M3FadeThrough extends StatelessWidget {
const _M3FadeThrough({
required this.animation,
required this.secondaryAnimation,
required this.child,
this.slideOffset = Offset.zero,
});
final Animation<double> animation;
final Animation<double> secondaryAnimation;
final Widget child;
final Offset slideOffset;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: Listenable.merge([animation, secondaryAnimation]),
builder: (context, _) {
final t = animation.value;
final st = secondaryAnimation.value;
// Incoming: fade in from t=0.3..1.0, with decelerate curve
final enterT = ((t - 0.3) / 0.7).clamp(0.0, 1.0);
final enterOpacity = M3Motion.expressiveDecelerate.transform(enterT);
// Outgoing: when THIS page is pushed off by a new page, fade first 35 %
final exitOpacity = (1.0 - (st / 0.35).clamp(0.0, 1.0));
// Slide: subtle vertical offset on enter
final slideY = slideOffset.dy * (1.0 - enterT);
final slideX = slideOffset.dx * (1.0 - enterT);
return Opacity(
opacity: exitOpacity,
child: Transform.translate(
offset: Offset(slideX * 100, slideY * 100),
child: Opacity(opacity: enterOpacity, child: child),
),
);
},
);
}
}
/// An [AnimatedSwitcher] pre-configured with M3 Expressive timing.
///
/// Use for state-change animations (loading content, empty data, etc.).
class M3AnimatedSwitcher extends StatelessWidget {
const M3AnimatedSwitcher({
super.key,
required this.child,
this.duration = M3Motion.short,
});
final Widget child;
final Duration duration;
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: duration,
switchInCurve: M3Motion.emphasizedEnter,
switchOutCurve: M3Motion.emphasizedExit,
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.02),
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
child: child,
);
}
}
//
// M3 Expressive FAB animated entrance + press feedback
//
/// A [FloatingActionButton] with M3 Expressive entrance animation:
/// scales up with spring on first build, and provides subtle scale-down
/// feedback on press. Use [M3ExpandedFab] for the extended variant.
class M3Fab extends StatefulWidget {
const M3Fab({
super.key,
required this.onPressed,
required this.icon,
this.tooltip,
this.heroTag,
});
final VoidCallback onPressed;
final Widget icon;
final String? tooltip;
final Object? heroTag;
@override
State<M3Fab> createState() => _M3FabState();
}
class _M3FabState extends State<M3Fab> with SingleTickerProviderStateMixin {
late final AnimationController _scaleCtrl;
late final Animation<double> _scaleAnim;
@override
void initState() {
super.initState();
_scaleCtrl = AnimationController(vsync: this, duration: M3Motion.long);
_scaleAnim = CurvedAnimation(parent: _scaleCtrl, curve: M3Motion.spring);
_scaleCtrl.forward();
}
@override
void dispose() {
_scaleCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ScaleTransition(
scale: _scaleAnim,
child: FloatingActionButton(
heroTag: widget.heroTag,
onPressed: widget.onPressed,
tooltip: widget.tooltip,
child: widget.icon,
),
);
}
}
/// An extended [FloatingActionButton] with M3 Expressive entrance animation:
/// slides in from the right + scales with spring, then provides a smooth
/// press dialog/navigation transition.
class M3ExpandedFab extends StatefulWidget {
const M3ExpandedFab({
super.key,
required this.onPressed,
required this.icon,
required this.label,
this.heroTag,
});
final VoidCallback onPressed;
final Widget icon;
final Widget label;
final Object? heroTag;
@override
State<M3ExpandedFab> createState() => _M3ExpandedFabState();
}
class _M3ExpandedFabState extends State<M3ExpandedFab>
with SingleTickerProviderStateMixin {
late final AnimationController _entranceCtrl;
late final Animation<double> _scale;
late final Animation<Offset> _slide;
@override
void initState() {
super.initState();
_entranceCtrl = AnimationController(vsync: this, duration: M3Motion.long);
_scale = CurvedAnimation(parent: _entranceCtrl, curve: M3Motion.spring);
_slide = Tween<Offset>(begin: const Offset(0.3, 0), end: Offset.zero)
.animate(
CurvedAnimation(
parent: _entranceCtrl,
curve: M3Motion.expressiveDecelerate,
),
);
_entranceCtrl.forward();
}
@override
void dispose() {
_entranceCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SlideTransition(
position: _slide,
child: ScaleTransition(
scale: _scale,
child: FloatingActionButton.extended(
heroTag: widget.heroTag,
onPressed: widget.onPressed,
icon: widget.icon,
label: widget.label,
),
),
);
}
}
//
// M3 Dialog / BottomSheet helpers smooth open/close
//
/// Opens a dialog with an M3 Expressive transition: the dialog scales up
/// from 90 % with a decelerate curve and fades in, giving a smooth "surface
/// rising" effect instead of the default abrupt material grow.
Future<T?> m3ShowDialog<T>({
required BuildContext context,
required WidgetBuilder builder,
bool barrierDismissible = true,
}) {
return showGeneralDialog<T>(
context: context,
barrierDismissible: barrierDismissible,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
barrierColor: Colors.black54,
transitionDuration: M3Motion.standard,
transitionBuilder: (context, animation, secondaryAnimation, child) {
final curved = CurvedAnimation(
parent: animation,
curve: M3Motion.expressiveDecelerate,
reverseCurve: M3Motion.expressiveAccelerate,
);
return FadeTransition(
opacity: CurvedAnimation(
parent: animation,
curve: const Interval(0.0, 0.65, curve: Curves.easeOut),
reverseCurve: const Interval(0.2, 1.0, curve: Curves.easeIn),
),
child: ScaleTransition(
scale: Tween<double>(begin: 0.88, end: 1.0).animate(curved),
child: child,
),
);
},
pageBuilder: (context, animation, secondaryAnimation) => builder(context),
);
}
/// Opens a modal bottom sheet with M3 Expressive spring animation.
Future<T?> m3ShowBottomSheet<T>({
required BuildContext context,
required WidgetBuilder builder,
bool showDragHandle = true,
bool isScrollControlled = false,
}) {
return showModalBottomSheet<T>(
context: context,
showDragHandle: showDragHandle,
isScrollControlled: isScrollControlled,
transitionAnimationController: AnimationController(
vsync: Navigator.of(context),
duration: M3Motion.standard,
reverseDuration: M3Motion.short,
),
builder: builder,
);
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/m3_motion.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
// showcaseview removed due to null-safety incompatibility; onboarding shown via dialog // 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/notifications_provider.dart';
import '../providers/profile_provider.dart'; import '../providers/profile_provider.dart';
import 'app_breakpoints.dart'; import 'app_breakpoints.dart';
import 'profile_avatar.dart';
final GlobalKey notificationBellKey = GlobalKey(); final GlobalKey notificationBellKey = GlobalKey();
@ -49,7 +51,7 @@ class AppScaffold extends ConsumerWidget {
appBar: AppBar( appBar: AppBar(
title: Row( title: Row(
children: [ children: [
const Icon(Icons.memory), Image.asset('assets/tasq_ico.png', width: 28, height: 28),
const SizedBox(width: 8), const SizedBox(width: 8),
Text('TasQ'), Text('TasQ'),
], ],
@ -73,7 +75,7 @@ class AppScaffold extends ConsumerWidget {
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.account_circle), ProfileAvatar(fullName: displayName, radius: 16),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(displayName), Text(displayName),
const SizedBox(width: 4), const SizedBox(width: 4),
@ -89,7 +91,7 @@ class AppScaffold extends ConsumerWidget {
IconButton( IconButton(
tooltip: 'Profile', tooltip: 'Profile',
onPressed: () => context.go('/profile'), onPressed: () => context.go('/profile'),
icon: const Icon(Icons.account_circle), icon: ProfileAvatar(fullName: displayName, radius: 16),
), ),
IconButton( IconButton(
tooltip: 'Sign out', tooltip: 'Sign out',
@ -164,23 +166,44 @@ class AppNavigationRail extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final currentIndex = _currentIndex(location, items); final currentIndex = _currentIndex(location, items);
final cs = Theme.of(context).colorScheme;
// M3 Expressive: tonal surface container instead of a hard border divider.
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(color: cs.surfaceContainerLow),
color: Theme.of(context).colorScheme.surface,
border: Border(
right: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
),
child: NavigationRail( child: NavigationRail(
backgroundColor: Colors.transparent,
extended: extended, extended: extended,
selectedIndex: currentIndex, selectedIndex: currentIndex,
onDestinationSelected: (value) { onDestinationSelected: (value) {
items[value].onTap(context, onLogout: onLogout); items[value].onTap(context, onLogout: onLogout);
}, },
leading: const SizedBox.shrink(), leading: Padding(
trailing: const SizedBox.shrink(), 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: [ destinations: [
for (final item in items) for (final item in items)
NavigationRailDestination( 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 { class _ShellBackground extends StatelessWidget {
const _ShellBackground({required this.child}); const _ShellBackground({required this.child});
@ -263,17 +288,8 @@ class _ShellBackground extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return ColoredBox(
decoration: BoxDecoration( color: Theme.of(context).scaffoldBackgroundColor,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Theme.of(context).colorScheme.surface,
Theme.of(context).colorScheme.surfaceContainerLowest,
],
),
),
child: child, child: child,
); );
} }
@ -531,9 +547,8 @@ Future<void> _showOverflowSheet(
List<NavItem> items, List<NavItem> items,
VoidCallback onLogout, VoidCallback onLogout,
) async { ) async {
await showModalBottomSheet<void>( await m3ShowBottomSheet<void>(
context: context, context: context,
showDragHandle: true,
builder: (context) { builder: (context) {
return SafeArea( return SafeArea(
child: ListView( child: ListView(

136
lib/widgets/m3_card.dart Normal file
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;
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/m3_motion.dart';
/// Lightweight, bounds-safe multi-select picker used in dialogs. /// Lightweight, bounds-safe multi-select picker used in dialogs.
/// - Renders chips for selected items and a `Select` ActionChip. /// - Renders chips for selected items and a `Select` ActionChip.
@ -43,7 +44,7 @@ class _MultiSelectPickerState<T> extends State<MultiSelectPicker<T>> {
List<String>? result; List<String>? result;
if (isMobile) { if (isMobile) {
result = await showModalBottomSheet<List<String>>( result = await m3ShowBottomSheet<List<String>>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (sheetContext) { builder: (sheetContext) {
@ -132,7 +133,7 @@ class _MultiSelectPickerState<T> extends State<MultiSelectPicker<T>> {
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
ElevatedButton( FilledButton(
onPressed: () => onPressed: () =>
Navigator.of(sheetContext).pop(working), Navigator.of(sheetContext).pop(working),
child: const Text('Done'), child: const Text('Done'),
@ -148,9 +149,8 @@ class _MultiSelectPickerState<T> extends State<MultiSelectPicker<T>> {
}, },
); );
} else { } else {
result = await showDialog<List<String>>( result = await m3ShowDialog<List<String>>(
context: context, context: context,
useRootNavigator: true,
builder: (dialogContext) { builder: (dialogContext) {
List<String> working = List<String>.from(_selectedIds); List<String> working = List<String>.from(_selectedIds);
bool workingSelectAll = bool workingSelectAll =
@ -232,7 +232,7 @@ class _MultiSelectPickerState<T> extends State<MultiSelectPicker<T>> {
onPressed: () => Navigator.of(dialogContext).pop(), onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
ElevatedButton( FilledButton(
onPressed: () => onPressed: () =>
Navigator.of(dialogContext).pop(working), Navigator.of(dialogContext).pop(working),
child: const Text('Done'), child: const Text('Done'),

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,
),
),
);
}
}

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// M3 Expressive status pill uses tonal container colors with a
/// smooth, spring-physics-inspired animation.
class StatusPill extends StatelessWidget { class StatusPill extends StatelessWidget {
const StatusPill({super.key, required this.label, this.isEmphasized = false}); 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 scheme = Theme.of(context).colorScheme;
final background = isEmphasized final background = isEmphasized
? scheme.tertiaryContainer ? scheme.tertiaryContainer
: scheme.tertiaryContainer.withValues(alpha: 0.65); : scheme.tertiaryContainer;
final foreground = scheme.onTertiaryContainer; final foreground = scheme.onTertiaryContainer;
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 220), duration: const Duration(milliseconds: 400),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: background, color: background,
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(28),
border: Border.all(color: scheme.tertiary.withValues(alpha: 0.3)),
), ),
child: Text( child: Text(
label.toUpperCase(), label.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall?.copyWith( style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: foreground, color: foreground,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
letterSpacing: 0.4, letterSpacing: 0.5,
), ),
), ),
); );

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/m3_motion.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/profile.dart'; import '../models/profile.dart';
@ -117,7 +118,7 @@ class TaskAssignmentSection extends ConsumerWidget {
// consider vacancy anymore because everyone is eligible, so the only // consider vacancy anymore because everyone is eligible, so the only
// reason for the dialog to be unusable is an empty staff list. // reason for the dialog to be unusable is an empty staff list.
if (eligibleStaff.isEmpty && assignedIds.isEmpty) { if (eligibleStaff.isEmpty && assignedIds.isEmpty) {
await showDialog<void>( await m3ShowDialog<void>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
return AlertDialog( return AlertDialog(
@ -137,7 +138,7 @@ class TaskAssignmentSection extends ConsumerWidget {
} }
final selection = assignedIds.toSet(); final selection = assignedIds.toSet();
await showDialog<void>( await m3ShowDialog<void>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
var isSaving = false; var isSaving = false;

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 { class _MobileTile<T> extends StatelessWidget {
const _MobileTile({ const _MobileTile({
required this.item, required this.item,
@ -615,21 +615,14 @@ class _MobileTile<T> extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tile = mobileTileBuilder(context, item, actions); final tile = mobileTileBuilder(context, item, actions);
// Apply Material 2 style elevation for Cards (per Hybrid M3/M2 guidelines). // M3 Expressive: cards use tonal surface tints. The theme's CardThemeData
// Mobile tiles deliberately use a slightly smaller corner radius for // already specifies surfaceTintColor and low elevation. We apply the
// compactness, but they should inherit the global card elevation and // compact shape for list density.
// shadow color from the theme to maintain visual consistency.
if (tile is Card) { if (tile is Card) {
final themeCard = Theme.of(context).cardTheme;
return Card( return Card(
color: tile.color, color: tile.color,
elevation: themeCard.elevation ?? 3,
margin: tile.margin, 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, shape: tile.shape ?? AppSurfaces.of(context).compactShape,
shadowColor: AppSurfaces.of(context).compactShadowColor,
clipBehavior: tile.clipBehavior, clipBehavior: tile.clipBehavior,
child: tile.child, child: tile.child,
); );