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

View File

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

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../../theme/m3_motion.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/office.dart';
@ -306,7 +307,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
}
if (!context.mounted) return;
await showDialog<void>(
await m3ShowDialog<void>(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
@ -314,8 +315,8 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
return AlertDialog(
shape: AppSurfaces.of(context).dialogShape,
title: const Text('Update user'),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
content: SizedBox(
width: 520,
child: SingleChildScrollView(
child: _buildUserForm(
context,
@ -442,10 +443,17 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
const SizedBox(height: 8),
if (offices.isEmpty) const Text('No offices available.'),
if (offices.isNotEmpty)
Column(
children: offices
.map(
(office) => CheckboxListTile(
Container(
height: 240,
clipBehavior: Clip.hardEdge,
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),
onChanged: _isSaving
? null
@ -460,10 +468,11 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
},
title: Text(office.name),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
)
.toList(),
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
);
}).toList(),
),
),
),
const SizedBox(height: 16),
Row(
@ -559,7 +568,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
final controller = TextEditingController();
final formKey = GlobalKey<FormState>();
await showDialog<void>(
await m3ShowDialog<void>(
context: context,
builder: (dialogContext) {
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 '../../providers/auth_provider.dart';
import '../../widgets/responsive_body.dart';
import '../../theme/m3_motion.dart';
import '../../utils/snackbar.dart';
class LoginScreen extends ConsumerStatefulWidget {
@ -17,15 +17,39 @@ class LoginScreen extends ConsumerStatefulWidget {
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
class _LoginScreenState extends ConsumerState<LoginScreen>
with SingleTickerProviderStateMixin {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
late final AnimationController _entranceController;
late final Animation<double> _fadeIn;
late final Animation<Offset> _slideIn;
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
void dispose() {
_entranceController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
@ -83,93 +107,230 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
return Scaffold(
appBar: AppBar(title: const Text('Sign In')),
body: ResponsiveBody(
maxWidth: 480,
padding: const EdgeInsets.symmetric(vertical: 24),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Column(
children: [
Image.asset('assets/tasq_ico.png', height: 72, width: 72),
const SizedBox(height: 12),
Text(
'TasQ',
style: Theme.of(context).textTheme.headlineSmall,
),
],
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: FadeTransition(
opacity: _fadeIn,
child: SlideTransition(
position: _slideIn,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Branding
Hero(
tag: 'tasq-logo',
child: Image.asset(
'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/tickets_provider.dart';
import '../../widgets/responsive_body.dart';
import '../../theme/m3_motion.dart';
import '../../utils/snackbar.dart';
class SignUpScreen extends ConsumerStatefulWidget {
@ -14,28 +14,49 @@ class SignUpScreen extends ConsumerStatefulWidget {
ConsumerState<SignUpScreen> createState() => _SignUpScreenState();
}
class _SignUpScreenState extends ConsumerState<SignUpScreen> {
class _SignUpScreenState extends ConsumerState<SignUpScreen>
with SingleTickerProviderStateMixin {
final _formKey = GlobalKey<FormState>();
final _fullNameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
late final AnimationController _entranceController;
late final Animation<double> _fadeIn;
late final Animation<Offset> _slideIn;
final Set<String> _selectedOfficeIds = {};
double _passwordStrength = 0.0;
String _passwordStrengthLabel = 'Very weak';
Color _passwordStrengthColor = Colors.red;
bool _isLoading = false;
bool _obscurePassword = true;
bool _obscureConfirm = true;
@override
void initState() {
super.initState();
_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
void dispose() {
_entranceController.dispose();
_passwordController.removeListener(_updatePasswordStrength);
_fullNameController.dispose();
_emailController.dispose();
@ -76,196 +97,377 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
final officesAsync = ref.watch(officesOnceProvider);
return Scaffold(
appBar: AppBar(title: const Text('Create Account')),
body: ResponsiveBody(
maxWidth: 480,
padding: const EdgeInsets.symmetric(vertical: 24),
child: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: FadeTransition(
opacity: _fadeIn,
child: SlideTransition(
position: _slideIn,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset('assets/tasq_ico.png', height: 72, width: 72),
const SizedBox(height: 12),
// Branding
Hero(
tag: 'tasq-logo',
child: Image.asset(
'assets/tasq_ico.png',
height: 80,
width: 80,
),
),
const SizedBox(height: 16),
Text(
'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 {
final cs = Theme.of(context).colorScheme;
final tempSelected = Set<String>.from(_selectedOfficeIds);
await showDialog<void>(
await m3ShowDialog<void>(
context: context,
builder: (dialogCtx) => StatefulBuilder(
builder: (ctx2, setStateDialog) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
title: const Text('Select Offices'),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
content: Container(
width: 480,
height: 400,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: cs.surfaceContainerLowest,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
@ -303,7 +515,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
},
title: Text(name),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
);
}).toList(),
),
@ -314,7 +526,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
onPressed: () => Navigator.of(dialogCtx).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
FilledButton(
onPressed: () {
setState(() {
_selectedOfficeIds

View File

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

View File

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

View File

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

View File

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

View File

@ -91,7 +91,10 @@ class ReportCardWrapper extends StatelessWidget {
);
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,
);

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_riverpod/flutter_riverpod.dart';
@ -84,8 +84,7 @@ class _RequestTypeChartState extends ConsumerState<RequestTypeChart> {
child: PieChart(
PieChartData(
pieTouchData: PieTouchData(
touchCallback:
(FlTouchEvent event, pieTouchResponse) {
touchCallback: (FlTouchEvent event, pieTouchResponse) {
setState(() {
if (!event.isInterestedForInteractions ||
pieTouchResponse == null ||
@ -93,9 +92,8 @@ class _RequestTypeChartState extends ConsumerState<RequestTypeChart> {
_touchedIndex = -1;
return;
}
_touchedIndex = pieTouchResponse
.touchedSection!
.touchedSectionIndex;
_touchedIndex =
pieTouchResponse.touchedSection!.touchedSectionIndex;
});
},
),
@ -217,8 +215,7 @@ class _RequestCategoryChartState extends ConsumerState<RequestCategoryChart> {
child: PieChart(
PieChartData(
pieTouchData: PieTouchData(
touchCallback:
(FlTouchEvent event, pieTouchResponse) {
touchCallback: (FlTouchEvent event, pieTouchResponse) {
setState(() {
if (!event.isInterestedForInteractions ||
pieTouchResponse == null ||
@ -226,9 +223,8 @@ class _RequestCategoryChartState extends ConsumerState<RequestCategoryChart> {
_touchedIndex = -1;
return;
}
_touchedIndex = pieTouchResponse
.touchedSection!
.touchedSectionIndex;
_touchedIndex =
pieTouchResponse.touchedSection!.touchedSectionIndex;
});
},
),
@ -274,7 +270,7 @@ class _RequestCategoryChartState extends ConsumerState<RequestCategoryChart> {
}
}
// Shared helpers
// Shared helpers
class _HoverLegendItem extends StatelessWidget {
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_riverpod/flutter_riverpod.dart';
@ -58,8 +58,7 @@ class _TicketsByStatusChartState extends ConsumerState<TicketsByStatusChart> {
child: PieChart(
PieChartData(
pieTouchData: PieTouchData(
touchCallback:
(FlTouchEvent event, pieTouchResponse) {
touchCallback: (FlTouchEvent event, pieTouchResponse) {
setState(() {
if (!event.isInterestedForInteractions ||
pieTouchResponse == null ||
@ -85,10 +84,7 @@ class _TicketsByStatusChartState extends ConsumerState<TicketsByStatusChart> {
radius: isTouched ? 60 : 50,
color: _ticketStatusColor(context, e.status),
borderSide: isTouched
? const BorderSide(
color: Colors.white,
width: 2,
)
? const BorderSide(color: Colors.white, width: 2)
: BorderSide.none,
);
}).toList(),
@ -140,8 +136,7 @@ class TasksByStatusChart extends ConsumerStatefulWidget {
final GlobalKey? repaintKey;
@override
ConsumerState<TasksByStatusChart> createState() =>
_TasksByStatusChartState();
ConsumerState<TasksByStatusChart> createState() => _TasksByStatusChartState();
}
class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
@ -187,8 +182,7 @@ class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
child: PieChart(
PieChartData(
pieTouchData: PieTouchData(
touchCallback:
(FlTouchEvent event, pieTouchResponse) {
touchCallback: (FlTouchEvent event, pieTouchResponse) {
setState(() {
if (!event.isInterestedForInteractions ||
pieTouchResponse == null ||
@ -214,10 +208,7 @@ class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
radius: isTouched ? 60 : 50,
color: _taskStatusColor(context, e.status),
borderSide: isTouched
? const BorderSide(
color: Colors.white,
width: 2,
)
? const BorderSide(color: Colors.white, width: 2)
: BorderSide.none,
);
}).toList(),
@ -280,7 +271,7 @@ class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
}
}
// Shared helpers
// Shared helpers
class _LegendItem extends StatelessWidget {
const _LegendItem({

View File

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

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import '../../theme/app_surfaces.dart';
import '../../widgets/responsive_body.dart';
class UnderDevelopmentScreen extends StatelessWidget {
@ -17,34 +16,28 @@ class UnderDevelopmentScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return ResponsiveBody(
maxWidth: 720,
padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
// M3 Expressive: elevated card with tonal fill, 28 dp radius.
child: Card(
child: Padding(
padding: const EdgeInsets.all(32),
padding: const EdgeInsets.all(40),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 72,
height: 72,
width: 80,
height: 80,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primaryContainer.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(
AppSurfaces.of(context).dialogRadius,
),
),
child: Icon(
icon,
size: 36,
color: Theme.of(context).colorScheme.primary,
color: cs.primaryContainer,
borderRadius: BorderRadius.circular(28),
),
child: Icon(icon, size: 40, color: cs.onPrimaryContainer),
),
const SizedBox(height: 20),
const SizedBox(height: 24),
Text(
title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
@ -55,26 +48,27 @@ class UnderDevelopmentScreen extends StatelessWidget {
const SizedBox(height: 8),
Text(
subtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: cs.onSurfaceVariant),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
horizontal: 20,
vertical: 10,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(28),
color: cs.secondaryContainer,
),
child: Text(
'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
import 'package:flutter/material.dart';
import '../../theme/m3_motion.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -1919,7 +1920,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
onPressed: () async {
final urlCtrl =
TextEditingController();
final res = await showDialog<String?>(
final res = await m3ShowDialog<String?>(
context:
context,
builder: (ctx) => AlertDialog(
@ -2234,7 +2235,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
onPressed: () async {
final urlCtrl =
TextEditingController();
final res = await showDialog<String?>(
final res = await m3ShowDialog<String?>(
context:
context,
builder: (ctx) => AlertDialog(
@ -2848,7 +2849,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
child: SizedBox(
width: 280,
child: Card(
elevation: 4,
elevation: 0,
shadowColor: Colors.transparent,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
@ -3779,7 +3781,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
Timer? titleTypingTimer;
try {
await showDialog<void>(
await m3ShowDialog<void>(
context: context,
builder: (dialogContext) {
var saving = false;
@ -3999,7 +4001,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
FilledButton(
onPressed: saving
? null
: () async {
@ -4174,7 +4176,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
// If cancelling, require a reason show dialog with spinner.
if (value == 'cancelled') {
final reasonCtrl = TextEditingController();
await showDialog<void>(
await m3ShowDialog<void>(
context: context,
builder: (dialogContext) {
var isSaving = false;
@ -4399,7 +4401,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
if (!mounted) return;
// Show loading dialog
showDialog(
m3ShowDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
@ -4543,7 +4545,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
Future<void> _deleteTaskAttachment(String taskId, String fileName) async {
try {
final confirmed = await showDialog<bool>(
final confirmed = await m3ShowDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Attachment?'),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,22 +1,34 @@
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
class AppSurfaces extends ThemeExtension<AppSurfaces> {
const AppSurfaces({
required this.cardRadius,
required this.compactCardRadius,
required this.containerRadius,
required this.dialogRadius,
required this.cardElevation,
required this.cardShadowColor,
required this.compactShadowColor,
required this.chipRadius,
});
/// Standard card radius 16 dp (M3 medium shape).
final double cardRadius;
/// Compact card radius for dense list tiles 12 dp.
final double compactCardRadius;
/// Large container radius 28 dp (M3 Expressive).
final double containerRadius;
/// Dialog / bottom-sheet radius 28 dp.
final double dialogRadius;
final double cardElevation;
final Color cardShadowColor;
final Color compactShadowColor;
/// Chip / badge radius 12 dp.
final double chipRadius;
// convenience shapes
RoundedRectangleBorder get standardShape =>
@ -24,6 +36,9 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
RoundedRectangleBorder get compactShape => RoundedRectangleBorder(
borderRadius: BorderRadius.circular(compactCardRadius),
);
RoundedRectangleBorder get containerShape => RoundedRectangleBorder(
borderRadius: BorderRadius.circular(containerRadius),
);
RoundedRectangleBorder get dialogShape =>
RoundedRectangleBorder(borderRadius: BorderRadius.circular(dialogRadius));
@ -33,10 +48,9 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
const AppSurfaces(
cardRadius: 16,
compactCardRadius: 12,
dialogRadius: 20,
cardElevation: 3,
cardShadowColor: Color.fromRGBO(0, 0, 0, 0.12),
compactShadowColor: Color.fromRGBO(0, 0, 0, 0.08),
containerRadius: 28,
dialogRadius: 28,
chipRadius: 12,
);
}
@ -44,18 +58,16 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
AppSurfaces copyWith({
double? cardRadius,
double? compactCardRadius,
double? containerRadius,
double? dialogRadius,
double? cardElevation,
Color? cardShadowColor,
Color? compactShadowColor,
double? chipRadius,
}) {
return AppSurfaces(
cardRadius: cardRadius ?? this.cardRadius,
compactCardRadius: compactCardRadius ?? this.compactCardRadius,
containerRadius: containerRadius ?? this.containerRadius,
dialogRadius: dialogRadius ?? this.dialogRadius,
cardElevation: cardElevation ?? this.cardElevation,
cardShadowColor: cardShadowColor ?? this.cardShadowColor,
compactShadowColor: compactShadowColor ?? this.compactShadowColor,
chipRadius: chipRadius ?? this.chipRadius,
);
}
@ -63,28 +75,17 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
AppSurfaces lerp(ThemeExtension<AppSurfaces>? other, double t) {
if (other is! AppSurfaces) return this;
return AppSurfaces(
cardRadius: lerpDouble(cardRadius, other.cardRadius, t) ?? cardRadius,
compactCardRadius:
lerpDouble(compactCardRadius, other.compactCardRadius, t) ??
compactCardRadius,
dialogRadius:
lerpDouble(dialogRadius, other.dialogRadius, t) ?? dialogRadius,
cardElevation:
lerpDouble(cardElevation, other.cardElevation, t) ?? cardElevation,
cardShadowColor:
Color.lerp(cardShadowColor, other.cardShadowColor, t) ??
cardShadowColor,
compactShadowColor:
Color.lerp(compactShadowColor, other.compactShadowColor, t) ??
compactShadowColor,
cardRadius: _lerpDouble(cardRadius, other.cardRadius, t),
compactCardRadius: _lerpDouble(
compactCardRadius,
other.compactCardRadius,
t,
),
containerRadius: _lerpDouble(containerRadius, other.containerRadius, t),
dialogRadius: _lerpDouble(dialogRadius, other.dialogRadius, t),
chipRadius: _lerpDouble(chipRadius, other.chipRadius, t),
);
}
}
// Helper because dart:ui lerpDouble isn't exported here
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;
}
double _lerpDouble(double a, double b, double t) => a + (b - a) * t;

View File

@ -4,143 +4,59 @@ import 'package:google_fonts/google_fonts.dart';
import 'app_typography.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 {
/// The seed color drives M3's entire tonal palette generation.
static const Color _seed = Color(0xFF4A6FA5);
//
// LIGHT
//
static ThemeData light() {
final base = ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF334155),
seedColor: _seed,
brightness: Brightness.light,
),
useMaterial3: true,
);
final textTheme = GoogleFonts.spaceGroteskTextTheme(base.textTheme);
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),
),
);
return _apply(base);
}
//
// DARK
//
static ThemeData dark() {
final base = ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF334155),
seedColor: _seed,
brightness: Brightness.dark,
),
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 monoTheme = GoogleFonts.robotoMonoTextTheme(base.textTheme);
final mono = AppMonoText(
@ -152,110 +68,261 @@ class AppTheme {
const TextStyle(letterSpacing: 0.2),
);
final surfaces = AppSurfaces(
const surfaces = AppSurfaces(
cardRadius: 16,
compactCardRadius: 12,
dialogRadius: 20,
cardElevation: 3,
cardShadowColor: const Color.fromRGBO(0, 0, 0, 0.24),
compactShadowColor: const Color.fromRGBO(0, 0, 0, 0.12),
containerRadius: 28,
dialogRadius: 28,
chipRadius: 12,
);
return base.copyWith(
textTheme: textTheme,
scaffoldBackgroundColor: base.colorScheme.surface,
scaffoldBackgroundColor: isDark ? cs.surface : cs.surfaceContainerLowest,
extensions: [mono, surfaces],
// AppBar
appBarTheme: AppBarTheme(
backgroundColor: base.colorScheme.surface,
foregroundColor: base.colorScheme.onSurface,
backgroundColor: cs.surface,
foregroundColor: cs.onSurface,
elevation: 0,
scrolledUnderElevation: 1,
surfaceTintColor: base.colorScheme.surfaceTint,
scrolledUnderElevation: 2,
surfaceTintColor: cs.surfaceTint,
centerTitle: false,
titleTextStyle: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
),
// Cards M3 Elevated (tonal surface tint, no hard shadow)
cardTheme: CardThemeData(
color: base.colorScheme.surfaceContainer,
elevation: 3, // M2-style elevation for visible separation (2-4 allowed)
color: isDark ? cs.surfaceContainer : cs.surfaceContainerLow,
elevation: 1,
margin: EdgeInsets.zero,
shadowColor: const Color.fromRGBO(0, 0, 0, 0.24),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: base.colorScheme.outlineVariant, width: 1),
),
shadowColor: Colors.transparent,
surfaceTintColor: cs.surfaceTint,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
// Chips
chipTheme: ChipThemeData(
backgroundColor: base.colorScheme.surfaceContainerHighest,
side: BorderSide(color: base.colorScheme.outlineVariant),
backgroundColor: cs.surfaceContainerHighest,
side: BorderSide.none,
labelStyle: textTheme.labelSmall,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
dividerTheme: DividerThemeData(
color: base.colorScheme.outlineVariant,
thickness: 1,
),
// Dividers
dividerTheme: DividerThemeData(color: cs.outlineVariant, thickness: 1),
// Input Fields
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: base.colorScheme.surfaceContainerLow,
fillColor: cs.surfaceContainerLow,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: base.colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: cs.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: base.colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: cs.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: base.colorScheme.primary, width: 1.5),
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: cs.primary, width: 2),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
// Buttons M3 Expressive hierarchy
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
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(
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(
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(
backgroundColor: base.colorScheme.surface,
indicatorColor: base.colorScheme.secondaryContainer,
tileHeight: 52,
backgroundColor: cs.surface,
indicatorColor: cs.secondaryContainer,
tileHeight: 56,
indicatorShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: base.colorScheme.surface,
selectedIconTheme: IconThemeData(color: base.colorScheme.primary),
selectedLabelTextStyle: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
backgroundColor: cs.surface,
selectedIconTheme: IconThemeData(color: cs.onSecondaryContainer),
unselectedIconTheme: IconThemeData(color: cs.onSurfaceVariant),
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(
backgroundColor: base.colorScheme.surface,
indicatorColor: base.colorScheme.primaryContainer,
labelTextStyle: WidgetStateProperty.all(
textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600),
backgroundColor: cs.surfaceContainer,
indicatorColor: cs.secondaryContainer,
indicatorShape: RoundedRectangleBorder(
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(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
tileColor: base.colorScheme.surfaceContainer,
tileColor: Colors.transparent,
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 '../theme/m3_motion.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
// showcaseview removed due to null-safety incompatibility; onboarding shown via dialog
@ -7,6 +8,7 @@ import '../providers/auth_provider.dart';
import '../providers/notifications_provider.dart';
import '../providers/profile_provider.dart';
import 'app_breakpoints.dart';
import 'profile_avatar.dart';
final GlobalKey notificationBellKey = GlobalKey();
@ -49,7 +51,7 @@ class AppScaffold extends ConsumerWidget {
appBar: AppBar(
title: Row(
children: [
const Icon(Icons.memory),
Image.asset('assets/tasq_ico.png', width: 28, height: 28),
const SizedBox(width: 8),
Text('TasQ'),
],
@ -73,7 +75,7 @@ class AppScaffold extends ConsumerWidget {
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
const Icon(Icons.account_circle),
ProfileAvatar(fullName: displayName, radius: 16),
const SizedBox(width: 8),
Text(displayName),
const SizedBox(width: 4),
@ -89,7 +91,7 @@ class AppScaffold extends ConsumerWidget {
IconButton(
tooltip: 'Profile',
onPressed: () => context.go('/profile'),
icon: const Icon(Icons.account_circle),
icon: ProfileAvatar(fullName: displayName, radius: 16),
),
IconButton(
tooltip: 'Sign out',
@ -164,23 +166,44 @@ class AppNavigationRail extends StatelessWidget {
@override
Widget build(BuildContext context) {
final currentIndex = _currentIndex(location, items);
final cs = Theme.of(context).colorScheme;
// M3 Expressive: tonal surface container instead of a hard border divider.
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
right: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
),
decoration: BoxDecoration(color: cs.surfaceContainerLow),
child: NavigationRail(
backgroundColor: Colors.transparent,
extended: extended,
selectedIndex: currentIndex,
onDestinationSelected: (value) {
items[value].onTap(context, onLogout: onLogout);
},
leading: const SizedBox.shrink(),
trailing: const SizedBox.shrink(),
leading: Padding(
padding: EdgeInsets.symmetric(
vertical: extended ? 12 : 8,
horizontal: extended ? 16 : 0,
),
child: Center(
child: Image.asset(
'assets/tasq_ico.png',
width: extended ? 48 : 40,
height: extended ? 48 : 40,
),
),
),
trailing: Expanded(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: IconButton(
tooltip: 'Sign out',
onPressed: onLogout,
icon: const Icon(Icons.logout),
),
),
),
),
destinations: [
for (final item in items)
NavigationRailDestination(
@ -256,6 +279,8 @@ class _NotificationBell extends ConsumerWidget {
}
}
/// M3 Expressive shell background uses a subtle tonal surface tint
/// rather than a gradient to create the organic, seed-colored feel.
class _ShellBackground extends StatelessWidget {
const _ShellBackground({required this.child});
@ -263,17 +288,8 @@ class _ShellBackground extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Theme.of(context).colorScheme.surface,
Theme.of(context).colorScheme.surfaceContainerLowest,
],
),
),
return ColoredBox(
color: Theme.of(context).scaffoldBackgroundColor,
child: child,
);
}
@ -531,9 +547,8 @@ Future<void> _showOverflowSheet(
List<NavItem> items,
VoidCallback onLogout,
) async {
await showModalBottomSheet<void>(
await m3ShowBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: ListView(

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

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

View File

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

View File

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