diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index c4c19a56..d3b59094 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -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((ref) { final notifier = RouterNotifier(ref); @@ -79,82 +80,131 @@ final appRouterProvider = Provider((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(), + ), ), ], ), diff --git a/lib/screens/admin/geofence_test_screen.dart b/lib/screens/admin/geofence_test_screen.dart index 92de4a16..6c1e3a16 100644 --- a/lib/screens/admin/geofence_test_screen.dart +++ b/lib/screens/admin/geofence_test_screen.dart @@ -370,7 +370,8 @@ class _GeofenceTestScreenState extends ConsumerState { right: 12, top: 12, child: Card( - elevation: 2, + elevation: 0, + shadowColor: Colors.transparent, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 10.0, diff --git a/lib/screens/admin/offices_screen.dart b/lib/screens/admin/offices_screen.dart index 83ff7ff3..a5c2505a 100644 --- a/lib/screens/admin/offices_screen.dart +++ b/lib/screens/admin/offices_screen.dart @@ -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 { 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 { final nameController = TextEditingController(text: office?.name ?? ''); String? selectedServiceId = office?.serviceId; - await showDialog( + await m3ShowDialog( context: context, builder: (dialogContext) { bool saving = false; @@ -298,7 +299,7 @@ class _OfficesScreenState extends ConsumerState { WidgetRef ref, Office office, ) async { - await showDialog( + await m3ShowDialog( context: context, builder: (dialogContext) { return AlertDialog( diff --git a/lib/screens/admin/user_management_screen.dart b/lib/screens/admin/user_management_screen.dart index 605c3259..c7959260 100644 --- a/lib/screens/admin/user_management_screen.dart +++ b/lib/screens/admin/user_management_screen.dart @@ -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 { } if (!context.mounted) return; - await showDialog( + await m3ShowDialog( context: context, builder: (dialogContext) { return StatefulBuilder( @@ -314,8 +315,8 @@ class _UserManagementScreenState extends ConsumerState { 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 { 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 { }, 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 { final controller = TextEditingController(); final formKey = GlobalKey(); - await showDialog( + await m3ShowDialog( context: context, builder: (dialogContext) { return AlertDialog( diff --git a/lib/screens/auth/login_screen.dart b/lib/screens/auth/login_screen.dart index 990494e0..aa30efb7 100644 --- a/lib/screens/auth/login_screen.dart +++ b/lib/screens/auth/login_screen.dart @@ -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 createState() => _LoginScreenState(); } -class _LoginScreenState extends ConsumerState { +class _LoginScreenState extends ConsumerState + with SingleTickerProviderStateMixin { final _formKey = GlobalKey(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); + late final AnimationController _entranceController; + late final Animation _fadeIn; + late final Animation _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( + 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 { @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'), - ), - ], + ), ), ), ), diff --git a/lib/screens/auth/signup_screen.dart b/lib/screens/auth/signup_screen.dart index c623834d..70f2daab 100644 --- a/lib/screens/auth/signup_screen.dart +++ b/lib/screens/auth/signup_screen.dart @@ -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 createState() => _SignUpScreenState(); } -class _SignUpScreenState extends ConsumerState { +class _SignUpScreenState extends ConsumerState + with SingleTickerProviderStateMixin { final _formKey = GlobalKey(); final _fullNameController = TextEditingController(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); final _confirmPasswordController = TextEditingController(); + late final AnimationController _entranceController; + late final Animation _fadeIn; + late final Animation _slideIn; + final Set _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( + 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 { @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 = { + 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.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 = { - 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.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 { } Future _showOfficeSelectionDialog(List offices) async { + final cs = Theme.of(context).colorScheme; final tempSelected = Set.from(_selectedOfficeIds); - await showDialog( + await m3ShowDialog( 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 { }, title: Text(name), controlAffinity: ListTileControlAffinity.leading, - contentPadding: EdgeInsets.zero, + contentPadding: const EdgeInsets.symmetric(horizontal: 8), ); }).toList(), ), @@ -314,7 +526,7 @@ class _SignUpScreenState extends ConsumerState { onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Cancel'), ), - ElevatedButton( + FilledButton( onPressed: () { setState(() { _selectedOfficeIds diff --git a/lib/screens/dashboard/dashboard_screen.dart b/lib/screens/dashboard/dashboard_screen.dart index f924e517..c1da4015 100644 --- a/lib/screens/dashboard/dashboard_screen.dart +++ b/lib/screens/dashboard/dashboard_screen.dart @@ -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 { final seen = prefs.getBool('has_seen_notif_showcase') ?? false; if (!seen) { if (!mounted) return; - await showDialog( + await m3ShowDialog( context: context, builder: (context) => AlertDialog( title: const Text('Never miss an update'), @@ -469,9 +470,10 @@ class _DashboardScreenState extends State { 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, diff --git a/lib/screens/notifications/notifications_screen.dart b/lib/screens/notifications/notifications_screen.dart index e74b5272..d98a92a6 100644 --- a/lib/screens/notifications/notifications_screen.dart +++ b/lib/screens/notifications/notifications_screen.dart @@ -124,11 +124,9 @@ class _NotificationsScreenState extends ConsumerState { 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), diff --git a/lib/screens/profile/profile_screen.dart b/lib/screens/profile/profile_screen.dart index 0d509d6d..e8c2a70a 100644 --- a/lib/screens/profile/profile_screen.dart +++ b/lib/screens/profile/profile_screen.dart @@ -105,7 +105,7 @@ class _ProfileScreenState extends ConsumerState { 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 { const SizedBox(height: 12), Row( children: [ - ElevatedButton( + FilledButton( onPressed: _changingPassword ? null : _onChangePassword, @@ -224,7 +224,7 @@ class _ProfileScreenState extends ConsumerState { const SizedBox(height: 12), Row( children: [ - ElevatedButton( + FilledButton( onPressed: _savingOffices ? null : _onSaveOffices, diff --git a/lib/screens/reports/report_date_filter.dart b/lib/screens/reports/report_date_filter.dart index 4042fe6d..faf68055 100644 --- a/lib/screens/reports/report_date_filter.dart +++ b/lib/screens/reports/report_date_filter.dart @@ -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), diff --git a/lib/screens/reports/widgets/report_card_wrapper.dart b/lib/screens/reports/widgets/report_card_wrapper.dart index 48baa81c..98f840e9 100644 --- a/lib/screens/reports/widgets/report_card_wrapper.dart +++ b/lib/screens/reports/widgets/report_card_wrapper.dart @@ -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, ); diff --git a/lib/screens/reports/widgets/request_distribution_charts.dart b/lib/screens/reports/widgets/request_distribution_charts.dart index 73f7ffde..82b46324 100644 --- a/lib/screens/reports/widgets/request_distribution_charts.dart +++ b/lib/screens/reports/widgets/request_distribution_charts.dart @@ -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 { 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 { _touchedIndex = -1; return; } - _touchedIndex = pieTouchResponse - .touchedSection! - .touchedSectionIndex; + _touchedIndex = + pieTouchResponse.touchedSection!.touchedSectionIndex; }); }, ), @@ -217,8 +215,7 @@ class _RequestCategoryChartState extends ConsumerState { 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 { _touchedIndex = -1; return; } - _touchedIndex = pieTouchResponse - .touchedSection! - .touchedSectionIndex; + _touchedIndex = + pieTouchResponse.touchedSection!.touchedSectionIndex; }); }, ), @@ -274,7 +270,7 @@ class _RequestCategoryChartState extends ConsumerState { } } -// Shared helpers +// Shared helpers class _HoverLegendItem extends StatelessWidget { const _HoverLegendItem({ diff --git a/lib/screens/reports/widgets/status_charts.dart b/lib/screens/reports/widgets/status_charts.dart index 07e17aaf..d4cc4365 100644 --- a/lib/screens/reports/widgets/status_charts.dart +++ b/lib/screens/reports/widgets/status_charts.dart @@ -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 { 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 { 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 createState() => - _TasksByStatusChartState(); + ConsumerState createState() => _TasksByStatusChartState(); } class _TasksByStatusChartState extends ConsumerState { @@ -187,8 +182,7 @@ class _TasksByStatusChartState extends ConsumerState { 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 { 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 { } } -// Shared helpers +// Shared helpers class _LegendItem extends StatelessWidget { const _LegendItem({ diff --git a/lib/screens/shared/permissions_screen.dart b/lib/screens/shared/permissions_screen.dart index cd7055a1..78ff05e8 100644 --- a/lib/screens/shared/permissions_screen.dart +++ b/lib/screens/shared/permissions_screen.dart @@ -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 { ); }, ), - floatingActionButton: FloatingActionButton( + floatingActionButton: M3Fab( onPressed: _refreshStatuses, tooltip: 'Refresh', - child: const Icon(Icons.refresh), + icon: const Icon(Icons.refresh), ), ); } diff --git a/lib/screens/shared/under_development_screen.dart b/lib/screens/shared/under_development_screen.dart index b9542c01..449a4198 100644 --- a/lib/screens/shared/under_development_screen.dart +++ b/lib/screens/shared/under_development_screen.dart @@ -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, + ), ), ), ], diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index d70d832b..500696fb 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -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 onPressed: () async { final urlCtrl = TextEditingController(); - final res = await showDialog( + final res = await m3ShowDialog( context: context, builder: (ctx) => AlertDialog( @@ -2234,7 +2235,7 @@ class _TaskDetailScreenState extends ConsumerState onPressed: () async { final urlCtrl = TextEditingController(); - final res = await showDialog( + final res = await m3ShowDialog( context: context, builder: (ctx) => AlertDialog( @@ -2848,7 +2849,8 @@ class _TaskDetailScreenState extends ConsumerState 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 Timer? titleTypingTimer; try { - await showDialog( + await m3ShowDialog( context: context, builder: (dialogContext) { var saving = false; @@ -3999,7 +4001,7 @@ class _TaskDetailScreenState extends ConsumerState : () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), - ElevatedButton( + FilledButton( onPressed: saving ? null : () async { @@ -4174,7 +4176,7 @@ class _TaskDetailScreenState extends ConsumerState // If cancelling, require a reason — show dialog with spinner. if (value == 'cancelled') { final reasonCtrl = TextEditingController(); - await showDialog( + await m3ShowDialog( context: context, builder: (dialogContext) { var isSaving = false; @@ -4399,7 +4401,7 @@ class _TaskDetailScreenState extends ConsumerState if (!mounted) return; // Show loading dialog - showDialog( + m3ShowDialog( context: context, barrierDismissible: false, builder: (BuildContext dialogContext) { @@ -4543,7 +4545,7 @@ class _TaskDetailScreenState extends ConsumerState Future _deleteTaskAttachment(String taskId, String fileName) async { try { - final confirmed = await showDialog( + final confirmed = await m3ShowDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Delete Attachment?'), diff --git a/lib/screens/tasks/task_pdf.dart b/lib/screens/tasks/task_pdf.dart index 0b9a0ba1..b401d2ab 100644 --- a/lib/screens/tasks/task_pdf.dart +++ b/lib/screens/tasks/task_pdf.dart @@ -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 showTaskPdfPreview( List assignments, List profiles, ) async { - await showDialog( + await m3ShowDialog( context: context, builder: (ctx) => TaskPdfDialog( task: task, diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index 53ed8764..e74f0e19 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -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 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 var showTitleGemini = false; Timer? titleTypingTimer; - await showDialog( + await m3ShowDialog( 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( diff --git a/lib/screens/teams/teams_screen.dart b/lib/screens/teams/teams_screen.dart index bb012388..8d327153 100644 --- a/lib/screens/teams/teams_screen.dart +++ b/lib/screens/teams/teams_screen.dart @@ -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 { 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 { if (isMobileDialog) { // Mobile: bottom sheet presentation - await showModalBottomSheet( + await m3ShowBottomSheet( context: context, isScrollControlled: true, - shape: AppSurfaces.of(context).dialogShape, builder: (sheetContext) { return StatefulBuilder( builder: (sheetContext, setState) { @@ -555,7 +555,7 @@ class _TeamsScreenState extends ConsumerState { 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 { ); } else { // Desktop / Tablet: centered fixed-width AlertDialog - await showDialog( + await m3ShowDialog( context: context, builder: (dialogContext) { return Center( @@ -589,7 +589,7 @@ class _TeamsScreenState extends ConsumerState { 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 { } void _deleteTeam(BuildContext context, String teamId) async { - final confirmed = await showDialog( + final confirmed = await m3ShowDialog( context: context, builder: (dialogContext) => AlertDialog( shape: AppSurfaces.of(dialogContext).dialogShape, @@ -619,7 +619,7 @@ class _TeamsScreenState extends ConsumerState { onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel'), ), - ElevatedButton( + FilledButton( onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('Delete'), ), diff --git a/lib/screens/tickets/ticket_detail_screen.dart b/lib/screens/tickets/ticket_detail_screen.dart index 40b9b37e..ef2e6cf2 100644 --- a/lib/screens/tickets/ticket_detail_screen.dart +++ b/lib/screens/tickets/ticket_detail_screen.dart @@ -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 { } Future _showTimelineDialog(BuildContext context, Ticket ticket) async { - await showDialog( + await m3ShowDialog( context: context, builder: (dialogContext) { return AlertDialog( @@ -897,7 +898,7 @@ class _TicketDetailScreenState extends ConsumerState { final descCtrl = TextEditingController(text: ticket.description); String? selectedOffice = ticket.officeId; - await showDialog( + await m3ShowDialog( context: context, builder: (dialogContext) { var saving = false; @@ -967,7 +968,7 @@ class _TicketDetailScreenState extends ConsumerState { : () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), - ElevatedButton( + FilledButton( onPressed: saving ? null : () async { diff --git a/lib/screens/tickets/tickets_list_screen.dart b/lib/screens/tickets/tickets_list_screen.dart index 86a5e6b7..1d47fe9f 100644 --- a/lib/screens/tickets/tickets_list_screen.dart +++ b/lib/screens/tickets/tickets_list_screen.dart @@ -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 { 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 { final descriptionController = TextEditingController(); Office? selectedOffice; - await showDialog( + await m3ShowDialog( 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( diff --git a/lib/screens/workforce/workforce_screen.dart b/lib/screens/workforce/workforce_screen.dart index 5716c554..a91d298b 100644 --- a/lib/screens/workforce/workforce_screen.dart +++ b/lib/screens/workforce/workforce_screen.dart @@ -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 recipientShifts = []; String? selectedTargetShiftId; - final confirmed = await showDialog( + final confirmed = await m3ShowDialog( context: context, builder: (dialogContext) { return StatefulBuilder( @@ -614,7 +615,7 @@ class _ScheduleTile extends ConsumerWidget { required String title, required String message, }) async { - await showDialog( + await m3ShowDialog( context: context, builder: (dialogContext) { return AlertDialog( @@ -633,7 +634,7 @@ class _ScheduleTile extends ConsumerWidget { Future _showCheckInProgress(BuildContext context) { final completer = Completer(); - showDialog( + m3ShowDialog( 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( + final selected = await m3ShowDialog( context: context, builder: (context) { return AlertDialog( diff --git a/lib/theme/app_surfaces.dart b/lib/theme/app_surfaces.dart index cd2b203e..91116280 100644 --- a/lib/theme/app_surfaces.dart +++ b/lib/theme/app_surfaces.dart @@ -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 { 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 { 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 { 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 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 lerp(ThemeExtension? 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; diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index f8f91d90..756d08ae 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -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, + ), ); } } diff --git a/lib/theme/m3_motion.dart b/lib/theme/m3_motion.dart new file mode 100644 index 00000000..ca47036a --- /dev/null +++ b/lib/theme/m3_motion.dart @@ -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 createState() => _M3FadeSlideInState(); +} + +class _M3FadeSlideInState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _opacity; + late final Animation _slide; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: widget.duration); + _opacity = CurvedAnimation( + parent: _controller, + curve: M3Motion.emphasizedEnter, + ); + _slide = Tween(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 extends PageRouteBuilder { + 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 animation, + Animation 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 extends CustomTransitionPage { + 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 animation, + Animation 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 extends CustomTransitionPage { + 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 animation, + Animation 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 animation; + final Animation 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( + 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 createState() => _M3FabState(); +} + +class _M3FabState extends State with SingleTickerProviderStateMixin { + late final AnimationController _scaleCtrl; + late final Animation _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 createState() => _M3ExpandedFabState(); +} + +class _M3ExpandedFabState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _entranceCtrl; + late final Animation _scale; + late final Animation _slide; + + @override + void initState() { + super.initState(); + _entranceCtrl = AnimationController(vsync: this, duration: M3Motion.long); + _scale = CurvedAnimation(parent: _entranceCtrl, curve: M3Motion.spring); + _slide = Tween(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 m3ShowDialog({ + required BuildContext context, + required WidgetBuilder builder, + bool barrierDismissible = true, +}) { + return showGeneralDialog( + 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(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 m3ShowBottomSheet({ + required BuildContext context, + required WidgetBuilder builder, + bool showDragHandle = true, + bool isScrollControlled = false, +}) { + return showModalBottomSheet( + context: context, + showDragHandle: showDragHandle, + isScrollControlled: isScrollControlled, + transitionAnimationController: AnimationController( + vsync: Navigator.of(context), + duration: M3Motion.standard, + reverseDuration: M3Motion.short, + ), + builder: builder, + ); +} diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index e01a1464..5f6672db 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -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 _showOverflowSheet( List items, VoidCallback onLogout, ) async { - await showModalBottomSheet( + await m3ShowBottomSheet( context: context, - showDragHandle: true, builder: (context) { return SafeArea( child: ListView( diff --git a/lib/widgets/m3_card.dart b/lib/widgets/m3_card.dart new file mode 100644 index 00000000..76eac398 --- /dev/null +++ b/lib/widgets/m3_card.dart @@ -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; + } +} diff --git a/lib/widgets/multi_select_picker.dart b/lib/widgets/multi_select_picker.dart index 24f25c47..42578f40 100644 --- a/lib/widgets/multi_select_picker.dart +++ b/lib/widgets/multi_select_picker.dart @@ -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 extends State> { List? result; if (isMobile) { - result = await showModalBottomSheet>( + result = await m3ShowBottomSheet>( context: context, isScrollControlled: true, builder: (sheetContext) { @@ -132,7 +133,7 @@ class _MultiSelectPickerState extends State> { 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 extends State> { }, ); } else { - result = await showDialog>( + result = await m3ShowDialog>( context: context, - useRootNavigator: true, builder: (dialogContext) { List working = List.from(_selectedIds); bool workingSelectAll = @@ -232,7 +232,7 @@ class _MultiSelectPickerState extends State> { onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), - ElevatedButton( + FilledButton( onPressed: () => Navigator.of(dialogContext).pop(working), child: const Text('Done'), diff --git a/lib/widgets/profile_avatar.dart b/lib/widgets/profile_avatar.dart new file mode 100644 index 00000000..b71b1677 --- /dev/null +++ b/lib/widgets/profile_avatar.dart @@ -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, + ), + ), + ); + } +} diff --git a/lib/widgets/status_pill.dart b/lib/widgets/status_pill.dart index ea852064..7875328f 100644 --- a/lib/widgets/status_pill.dart +++ b/lib/widgets/status_pill.dart @@ -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, ), ), ); diff --git a/lib/widgets/task_assignment_section.dart b/lib/widgets/task_assignment_section.dart index 7c3c4ebe..202ba5dc 100644 --- a/lib/widgets/task_assignment_section.dart +++ b/lib/widgets/task_assignment_section.dart @@ -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( + await m3ShowDialog( context: context, builder: (dialogContext) { return AlertDialog( @@ -137,7 +138,7 @@ class TaskAssignmentSection extends ConsumerWidget { } final selection = assignedIds.toSet(); - await showDialog( + await m3ShowDialog( context: context, builder: (dialogContext) { var isSaving = false; diff --git a/lib/widgets/tasq_adaptive_list.dart b/lib/widgets/tasq_adaptive_list.dart index b63bf227..64d61ea0 100644 --- a/lib/widgets/tasq_adaptive_list.dart +++ b/lib/widgets/tasq_adaptive_list.dart @@ -597,7 +597,7 @@ class _DesktopTableViewState extends State<_DesktopTableView> { } } -/// Mobile tile wrapper that applies Material 2 style elevation. +/// Mobile tile wrapper that applies M3 Expressive tonal elevation. class _MobileTile extends StatelessWidget { const _MobileTile({ required this.item, @@ -615,21 +615,14 @@ class _MobileTile 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, );