From 74197c525ddec548882992538b028c3431328470 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Fri, 20 Mar 2026 15:15:38 +0800 Subject: [PATCH] Enhanced material design 3 implementation --- lib/providers/attendance_provider.dart | 25 +- lib/providers/workforce_provider.dart | 27 +- lib/screens/admin/app_update_screen.dart | 27 +- lib/screens/admin/offices_screen.dart | 210 ++++----- lib/screens/admin/user_management_screen.dart | 47 ++- lib/screens/attendance/attendance_screen.dart | 18 +- lib/screens/auth/login_screen.dart | 14 +- lib/screens/dashboard/dashboard_screen.dart | 48 ++- .../it_service_requests_list_screen.dart | 21 +- .../notifications/notifications_screen.dart | 99 +++-- lib/screens/profile/profile_screen.dart | 42 +- lib/screens/reports/reports_screen.dart | 18 +- lib/screens/tasks/tasks_list_screen.dart | 57 ++- lib/screens/teams/teams_screen.dart | 27 +- lib/screens/tickets/tickets_list_screen.dart | 55 ++- .../whereabouts/whereabouts_screen.dart | 31 +- lib/screens/workforce/workforce_screen.dart | 160 ++++--- lib/theme/m3_motion.dart | 399 ++++++++++++++++++ lib/utils/snackbar.dart | 28 +- lib/widgets/app_page_header.dart | 101 +++++ lib/widgets/app_shell.dart | 60 +-- lib/widgets/app_state_view.dart | 165 ++++++++ lib/widgets/profile_avatar.dart | 81 ++-- lib/widgets/status_pill.dart | 8 +- lib/widgets/tasq_adaptive_list.dart | 84 ++-- lib/widgets/update_dialog.dart | 8 +- 26 files changed, 1345 insertions(+), 515 deletions(-) create mode 100644 lib/widgets/app_page_header.dart create mode 100644 lib/widgets/app_state_view.dart diff --git a/lib/providers/attendance_provider.dart b/lib/providers/attendance_provider.dart index f6737ced..3e13cc88 100644 --- a/lib/providers/attendance_provider.dart +++ b/lib/providers/attendance_provider.dart @@ -28,15 +28,22 @@ final attendanceUserFilterProvider = StateProvider>((ref) => []); /// All visible attendance logs (own for standard, all for admin/dispatcher/it_staff). final attendanceLogsProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); - final profileAsync = ref.watch(currentProfileProvider); - final profile = profileAsync.valueOrNull; - if (profile == null) return Stream.value(const []); + + // Use .select() so the stream is only recreated when the user id or role + // actually changes (not on avatar/name edits, etc.). + final profileId = ref.watch( + currentProfileProvider.select((p) => p.valueOrNull?.id), + ); + final profileRole = ref.watch( + currentProfileProvider.select((p) => p.valueOrNull?.role), + ); + if (profileId == null) return Stream.value(const []); final hasFullAccess = - profile.role == 'admin' || - profile.role == 'programmer' || - profile.role == 'dispatcher' || - profile.role == 'it_staff'; + profileRole == 'admin' || + profileRole == 'programmer' || + profileRole == 'dispatcher' || + profileRole == 'it_staff'; final wrapper = StreamRecoveryWrapper( stream: hasFullAccess @@ -47,14 +54,14 @@ final attendanceLogsProvider = StreamProvider>((ref) { : client .from('attendance_logs') .stream(primaryKey: ['id']) - .eq('user_id', profile.id) + .eq('user_id', profileId) .order('check_in_at', ascending: false), onPollData: () async { final query = client.from('attendance_logs').select(); final data = hasFullAccess ? await query.order('check_in_at', ascending: false) : await query - .eq('user_id', profile.id) + .eq('user_id', profileId) .order('check_in_at', ascending: false); return data.map(AttendanceLog.fromMap).toList(); }, diff --git a/lib/providers/workforce_provider.dart b/lib/providers/workforce_provider.dart index 73eb31f1..94990a32 100644 --- a/lib/providers/workforce_provider.dart +++ b/lib/providers/workforce_provider.dart @@ -26,9 +26,11 @@ final showPastSchedulesProvider = StateProvider((ref) => false); final dutySchedulesProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); - final profileAsync = ref.watch(currentProfileProvider); - final profile = profileAsync.valueOrNull; - if (profile == null) { + // Only recreate stream when user id changes (not on other profile edits). + final profileId = ref.watch( + currentProfileProvider.select((p) => p.valueOrNull?.id), + ); + if (profileId == null) { return Stream.value(const []); } @@ -95,16 +97,21 @@ final dutySchedulesForUserProvider = final swapRequestsProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); - final profileAsync = ref.watch(currentProfileProvider); - final profile = profileAsync.valueOrNull; - if (profile == null) { + // Only recreate stream when user id or role changes. + final profileId = ref.watch( + currentProfileProvider.select((p) => p.valueOrNull?.id), + ); + final profileRole = ref.watch( + currentProfileProvider.select((p) => p.valueOrNull?.role), + ); + if (profileId == null) { return Stream.value(const []); } final isAdmin = - profile.role == 'admin' || - profile.role == 'programmer' || - profile.role == 'dispatcher'; + profileRole == 'admin' || + profileRole == 'programmer' || + profileRole == 'dispatcher'; final wrapper = StreamRecoveryWrapper( stream: isAdmin @@ -135,7 +142,7 @@ final swapRequestsProvider = StreamProvider>((ref) { // either party. admins still see "admin_review" rows so they can act on // escalated cases. return result.data.where((row) { - if (!(row.requesterId == profile.id || row.recipientId == profile.id)) { + if (!(row.requesterId == profileId || row.recipientId == profileId)) { return false; } // only keep pending and admin_review statuses diff --git a/lib/screens/admin/app_update_screen.dart b/lib/screens/admin/app_update_screen.dart index 595ea5f7..4abbe85b 100644 --- a/lib/screens/admin/app_update_screen.dart +++ b/lib/screens/admin/app_update_screen.dart @@ -11,6 +11,7 @@ import 'package:flutter_quill/flutter_quill.dart' as quill; import '../../services/ai_service.dart'; import '../../utils/snackbar.dart'; +import '../../widgets/app_page_header.dart'; import '../../widgets/gemini_animated_text_field.dart'; /// A simple admin-only web page allowing the upload of a new APK and the @@ -478,13 +479,20 @@ class _AppUpdateScreenState extends ConsumerState { } return Scaffold( - appBar: AppBar(title: const Text('APK Update Uploader')), - body: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 800), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Card( + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const AppPageHeader( + title: 'App Update', + subtitle: 'Upload and manage APK releases', + ), + Expanded( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 800), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Card( elevation: 2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), @@ -899,6 +907,9 @@ class _AppUpdateScreenState extends ConsumerState { ), ), ), - ); + ), + ], + ), + ); } } diff --git a/lib/screens/admin/offices_screen.dart b/lib/screens/admin/offices_screen.dart index a5c2505a..666d1ee2 100644 --- a/lib/screens/admin/offices_screen.dart +++ b/lib/screens/admin/offices_screen.dart @@ -6,6 +6,8 @@ import '../../models/office.dart'; import '../../providers/profile_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../providers/services_provider.dart'; +import '../../widgets/app_page_header.dart'; +import '../../widgets/app_state_view.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; import '../../theme/app_surfaces.dart'; @@ -39,114 +41,118 @@ class _OfficesScreenState extends ConsumerState { maxWidth: double.infinity, child: !isAdmin ? const Center(child: Text('Admin access required.')) - : officesAsync.when( - data: (offices) { - if (offices.isEmpty) { - return const Center(child: Text('No offices found.')); - } + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const AppPageHeader( + title: 'Office Management', + subtitle: 'Create and manage office locations', + ), + Expanded( + child: officesAsync.when( + data: (offices) { + if (offices.isEmpty) { + return const AppEmptyView( + icon: Icons.apartment_outlined, + title: 'No offices yet', + subtitle: + 'Create an office to start assigning users and schedules.', + ); + } - final query = _searchController.text.trim().toLowerCase(); - final filteredOffices = query.isEmpty - ? offices - : offices - .where( - (office) => - office.name.toLowerCase().contains(query) || - office.id.toLowerCase().contains(query), - ) - .toList(); + final query = + _searchController.text.trim().toLowerCase(); + final filteredOffices = query.isEmpty + ? offices + : offices + .where( + (office) => + office.name + .toLowerCase() + .contains(query) || + office.id + .toLowerCase() + .contains(query), + ) + .toList(); - final listBody = TasQAdaptiveList( - items: filteredOffices, - filterHeader: SizedBox( - width: 320, - child: TextField( - controller: _searchController, - onChanged: (_) => setState(() {}), - decoration: const InputDecoration( - labelText: 'Search name', - prefixIcon: Icon(Icons.search), - ), - ), - ), - columns: [ - TasQColumn( - header: 'Office ID', - technical: true, - cellBuilder: (context, office) => Text(office.id), - ), - TasQColumn( - header: 'Office Name', - cellBuilder: (context, office) => Text(office.name), - ), - ], - rowActions: (office) => [ - IconButton( - tooltip: 'Edit', - icon: const Icon(Icons.edit), - onPressed: () => - _showOfficeDialog(context, ref, office: office), - ), - IconButton( - tooltip: 'Delete', - icon: const Icon(Icons.delete), - onPressed: () => _confirmDelete(context, ref, office), - ), - ], - mobileTileBuilder: (context, office, actions) { - return Card( - child: ListTile( - dense: true, - visualDensity: VisualDensity.compact, - leading: const Icon(Icons.apartment_outlined), - title: Text(office.name), - subtitle: MonoText('ID ${office.id}'), - trailing: Wrap(spacing: 8, children: actions), - ), - ); - }, - onRequestRefresh: () { - // For server-side pagination, update the query provider - ref.read(officesQueryProvider.notifier).state = - const OfficeQuery(offset: 0, limit: 50); - }, - onPageChanged: (firstRow) { - ref - .read(officesQueryProvider.notifier) - .update((q) => q.copyWith(offset: firstRow)); - }, - isLoading: false, - ); - - return Column( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.only(top: 16, bottom: 8), - child: Stack( - alignment: Alignment.center, - children: [ - Align( - alignment: Alignment.center, - child: Text( - 'Office Management', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge - ?.copyWith(fontWeight: FontWeight.w700), + return TasQAdaptiveList( + items: filteredOffices, + filterHeader: SizedBox( + width: 320, + child: TextField( + controller: _searchController, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'Search name', + prefixIcon: Icon(Icons.search), ), ), + ), + columns: [ + TasQColumn( + header: 'Office ID', + technical: true, + cellBuilder: (context, office) => + Text(office.id), + ), + TasQColumn( + header: 'Office Name', + cellBuilder: (context, office) => + Text(office.name), + ), ], - ), + rowActions: (office) => [ + IconButton( + tooltip: 'Edit', + icon: const Icon(Icons.edit), + onPressed: () => _showOfficeDialog( + context, + ref, + office: office, + ), + ), + IconButton( + tooltip: 'Delete', + icon: const Icon(Icons.delete), + onPressed: () => + _confirmDelete(context, ref, office), + ), + ], + mobileTileBuilder: (context, office, actions) { + return Card( + child: ListTile( + dense: true, + visualDensity: VisualDensity.compact, + leading: + const Icon(Icons.apartment_outlined), + title: Text(office.name), + subtitle: MonoText('ID ${office.id}'), + trailing: Wrap(spacing: 8, children: actions), + ), + ); + }, + onRequestRefresh: () { + ref.read(officesQueryProvider.notifier).state = + const OfficeQuery(offset: 0, limit: 50); + }, + onPageChanged: (firstRow) { + ref + .read(officesQueryProvider.notifier) + .update((q) => q.copyWith(offset: firstRow)); + }, + isLoading: false, + ); + }, + loading: () => + const Center(child: CircularProgressIndicator()), + error: (error, _) => AppErrorView( + error: error, + onRetry: () => ref.invalidate(officesProvider), ), - Expanded(child: listBody), - ], - ); - }, - loading: () => - const Center(child: CircularProgressIndicator()), - error: (error, _) => - Center(child: Text('Failed to load offices: $error')), + ), + ), + ], ), ), if (isAdmin) diff --git a/lib/screens/admin/user_management_screen.dart b/lib/screens/admin/user_management_screen.dart index e432f817..927f8a10 100644 --- a/lib/screens/admin/user_management_screen.dart +++ b/lib/screens/admin/user_management_screen.dart @@ -14,6 +14,8 @@ import '../../theme/app_surfaces.dart'; import '../../providers/user_offices_provider.dart'; import '../../utils/app_time.dart'; +import '../../widgets/app_page_header.dart'; +import '../../widgets/app_state_view.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; import '../../widgets/tasq_adaptive_list.dart'; @@ -105,7 +107,14 @@ class _UserManagementScreenState extends ConsumerState { assignmentsAsync.error ?? messagesAsync.error ?? 'Unknown error'; - return Center(child: Text('Failed to load data: $error')); + return AppErrorView( + error: error, + onRetry: () { + ref.invalidate(profilesProvider); + ref.invalidate(officesProvider); + ref.invalidate(userOfficesProvider); + }, + ); } final profiles = profilesAsync.valueOrNull ?? []; @@ -124,7 +133,11 @@ class _UserManagementScreenState extends ConsumerState { } if (profiles.isEmpty) { - return const Center(child: Text('No users found.')); + return const AppEmptyView( + icon: Icons.people_outline, + title: 'No users found', + subtitle: 'Users who sign up will appear here.', + ); } final query = _searchController.text.trim().toLowerCase(); @@ -269,26 +282,16 @@ class _UserManagementScreenState extends ConsumerState { isLoading: false, ); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Column( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Align( - alignment: Alignment.center, - child: Text( - 'User Management', - textAlign: TextAlign.center, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), - ), - ), - const SizedBox(height: 16), - Expanded(child: listBody), - ], - ), + return Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const AppPageHeader( + title: 'User Management', + subtitle: 'Manage user roles and office assignments', + ), + Expanded(child: listBody), + ], ); } diff --git a/lib/screens/attendance/attendance_screen.dart b/lib/screens/attendance/attendance_screen.dart index 654c7800..b7266195 100644 --- a/lib/screens/attendance/attendance_screen.dart +++ b/lib/screens/attendance/attendance_screen.dart @@ -33,6 +33,7 @@ import '../../utils/snackbar.dart'; import '../../widgets/gemini_animated_text_field.dart'; import '../../widgets/gemini_button.dart'; import '../../widgets/multi_select_picker.dart'; +import '../../widgets/app_page_header.dart'; import '../../widgets/responsive_body.dart'; class AttendanceScreen extends ConsumerStatefulWidget { @@ -86,20 +87,9 @@ class _AttendanceScreenState extends ConsumerState : null, body: Column( children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: Row( - children: [ - Expanded( - child: Text( - 'Attendance', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ), + const AppPageHeader( + title: 'Attendance', + subtitle: 'Check in, logbook, pass slip and leave', ), TabBar( controller: _tabController, diff --git a/lib/screens/auth/login_screen.dart b/lib/screens/auth/login_screen.dart index aa30efb7..a5faed8a 100644 --- a/lib/screens/auth/login_screen.dart +++ b/lib/screens/auth/login_screen.dart @@ -28,6 +28,7 @@ class _LoginScreenState extends ConsumerState bool _isLoading = false; bool _obscurePassword = true; + bool _hasValidationError = false; @override void initState() { @@ -56,7 +57,13 @@ class _LoginScreenState extends ConsumerState } Future _handleEmailSignIn() async { - if (!_formKey.currentState!.validate()) return; + if (!_formKey.currentState!.validate()) { + setState(() { + _hasValidationError = !_hasValidationError; + }); + return; + } + setState(() => _hasValidationError = false); setState(() => _isLoading = true); final auth = ref.read(authControllerProvider); @@ -151,7 +158,9 @@ class _LoginScreenState extends ConsumerState const SizedBox(height: 32), // ── Sign-in card ── - Card( + M3ErrorShake( + hasError: _hasValidationError, + child: Card( elevation: 0, color: cs.surfaceContainerLow, shape: RoundedRectangleBorder( @@ -245,6 +254,7 @@ class _LoginScreenState extends ConsumerState ), ), ), + ), // M3ErrorShake const SizedBox(height: 20), // ── Divider ── diff --git a/lib/screens/dashboard/dashboard_screen.dart b/lib/screens/dashboard/dashboard_screen.dart index 824bb9a2..6fda110a 100644 --- a/lib/screens/dashboard/dashboard_screen.dart +++ b/lib/screens/dashboard/dashboard_screen.dart @@ -38,6 +38,7 @@ import '../../providers/realtime_controller.dart'; import 'package:skeletonizer/skeletonizer.dart'; import '../../theme/app_surfaces.dart'; import '../../widgets/mono_text.dart'; +import '../../widgets/app_page_header.dart'; import '../../utils/app_time.dart'; class DashboardMetrics { @@ -732,20 +733,11 @@ class _DashboardScreenState extends State { final content = Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: const EdgeInsets.only(top: 16, bottom: 8), - child: Align( - alignment: Alignment.center, - child: Text( - 'Dashboard', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), + const AppPageHeader( + title: 'Dashboard', + subtitle: 'Live metrics and team activity', ), const _DashboardStatusBanner(), ...sections, @@ -864,12 +856,34 @@ class _DashboardStatusBanner extends ConsumerWidget { if (bannerState.startsWith('error:')) { final errorText = bannerState.substring(6); + final cs = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.only(bottom: 12), - child: Text( - 'Dashboard data error: $errorText', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.error, + child: Material( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + Icon( + Icons.warning_amber_rounded, + size: 18, + color: cs.onErrorContainer, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + errorText, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: cs.onErrorContainer, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), ), ), ); diff --git a/lib/screens/it_service_requests/it_service_requests_list_screen.dart b/lib/screens/it_service_requests/it_service_requests_list_screen.dart index 16e1f775..3d76f562 100644 --- a/lib/screens/it_service_requests/it_service_requests_list_screen.dart +++ b/lib/screens/it_service_requests/it_service_requests_list_screen.dart @@ -18,6 +18,8 @@ import '../../utils/snackbar.dart'; import '../../widgets/m3_card.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/reconnect_overlay.dart'; +import '../../widgets/app_page_header.dart'; +import '../../widgets/app_state_view.dart'; import '../../widgets/responsive_body.dart'; import '../../widgets/status_pill.dart'; @@ -103,17 +105,20 @@ class _ItServiceRequestsListScreenState child: Builder( builder: (context) { if (requestsAsync.hasError && !requestsAsync.hasValue) { - return Center( - child: Text( - 'Failed to load requests: ${requestsAsync.error}', - ), + return AppErrorView( + error: requestsAsync.error!, + onRetry: () => + ref.invalidate(itServiceRequestsProvider), ); } final allRequests = requestsAsync.valueOrNull ?? []; if (allRequests.isEmpty && !showSkeleton) { - return const Center( - child: Text('No IT service requests yet.'), + return const AppEmptyView( + icon: Icons.miscellaneous_services_outlined, + title: 'No service requests yet', + subtitle: + 'IT service requests submitted by your team will appear here.', ); } final offices = officesAsync.valueOrNull ?? []; @@ -146,6 +151,10 @@ class _ItServiceRequestsListScreenState return Column( children: [ + const AppPageHeader( + title: 'IT Service Requests', + subtitle: 'Manage and track IT support tickets', + ), // Status summary cards _StatusSummaryRow( requests: allRequests, diff --git a/lib/screens/notifications/notifications_screen.dart b/lib/screens/notifications/notifications_screen.dart index d98a92a6..4820d9a2 100644 --- a/lib/screens/notifications/notifications_screen.dart +++ b/lib/screens/notifications/notifications_screen.dart @@ -9,6 +9,8 @@ import '../../providers/notifications_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/tasks_provider.dart'; import '../../providers/tickets_provider.dart'; +import '../../widgets/app_page_header.dart'; +import '../../widgets/app_state_view.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; import '../../theme/app_surfaces.dart'; @@ -58,48 +60,44 @@ class _NotificationsScreenState extends ConsumerState { }; return ResponsiveBody( - child: notificationsAsync.when( - data: (items) { - if (items.isEmpty) { - return const Center(child: Text('No notifications yet.')); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.only(top: 16, bottom: 8), - child: Text( - 'Notifications', - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const AppPageHeader( + title: 'Notifications', + subtitle: 'Updates and mentions across tasks and tickets', + ), + if (_showBanner && !_dismissed) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: MaterialBanner( + content: const Text( + 'Push notifications are currently silenced. Tap here to fix.', ), - ), - if (_showBanner && !_dismissed) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: MaterialBanner( - content: const Text( - 'Push notifications are currently silenced. Tap here to fix.', - ), - actions: [ - TextButton( - onPressed: () { - openAppSettings(); - }, - child: const Text('Open settings'), - ), - TextButton( - onPressed: () { - setState(() => _dismissed = true); - }, - child: const Text('Dismiss'), - ), - ], + actions: [ + TextButton( + onPressed: openAppSettings, + child: const Text('Open settings'), ), - ), - Expanded( - child: ListView.separated( + TextButton( + onPressed: () => setState(() => _dismissed = true), + child: const Text('Dismiss'), + ), + ], + ), + ), + Expanded( + child: notificationsAsync.when( + data: (items) { + if (items.isEmpty) { + return const AppEmptyView( + icon: Icons.notifications_none_outlined, + title: 'No notifications yet', + subtitle: + "You'll see updates here when something needs your attention.", + ); + } + return ListView.separated( padding: const EdgeInsets.only(bottom: 24), itemCount: items.length, separatorBuilder: (context, index) => @@ -124,7 +122,6 @@ class _NotificationsScreenState extends ConsumerState { final title = _notificationTitle(item.type, actorName); final icon = _notificationIcon(item.type); - // M3 Expressive: compact card shape, no shadow. return Card( shape: AppSurfaces.of(context).compactShape, child: ListTile( @@ -142,10 +139,10 @@ class _NotificationsScreenState extends ConsumerState { ], ), trailing: item.isUnread - ? const Icon( + ? Icon( Icons.circle, size: 10, - color: Colors.red, + color: Theme.of(context).colorScheme.error, ) : null, onTap: () async { @@ -174,14 +171,16 @@ class _NotificationsScreenState extends ConsumerState { ), ); }, - ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => AppErrorView( + error: error, + onRetry: () => ref.invalidate(notificationsProvider), ), - ], - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => - Center(child: Text('Failed to load notifications: $error')), + ), + ), + ], ), ); } diff --git a/lib/screens/profile/profile_screen.dart b/lib/screens/profile/profile_screen.dart index b8e50747..862117fb 100644 --- a/lib/screens/profile/profile_screen.dart +++ b/lib/screens/profile/profile_screen.dart @@ -12,6 +12,7 @@ import '../../services/face_verification.dart' as face; import '../../widgets/face_verification_overlay.dart'; import '../../widgets/multi_select_picker.dart'; import '../../widgets/qr_verification_dialog.dart'; +import '../../widgets/app_page_header.dart'; import '../../widgets/responsive_body.dart'; import '../../utils/snackbar.dart'; @@ -74,12 +75,14 @@ class _ProfileScreenState extends ConsumerState { return ResponsiveBody( child: SingleChildScrollView( - padding: const EdgeInsets.only(top: 16, bottom: 32), + padding: const EdgeInsets.only(bottom: 32), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('My Profile', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 12), + const AppPageHeader( + title: 'My Profile', + subtitle: 'Manage your account and preferences', + ), // ── Avatar Card ── _buildAvatarCard(context, profileAsync), @@ -287,19 +290,22 @@ class _ProfileScreenState extends ConsumerState { Center( child: Stack( children: [ - CircleAvatar( - radius: 56, - backgroundColor: colors.surfaceContainerHighest, - backgroundImage: avatarUrl != null - ? NetworkImage(avatarUrl) - : null, - child: avatarUrl == null - ? Icon( - Icons.person, - size: 48, - color: colors.onSurfaceVariant, - ) - : null, + Hero( + tag: 'profile-avatar', + child: CircleAvatar( + radius: 56, + backgroundColor: colors.surfaceContainerHighest, + backgroundImage: avatarUrl != null + ? NetworkImage(avatarUrl) + : null, + child: avatarUrl == null + ? Icon( + Icons.person, + size: 48, + color: colors.onSurfaceVariant, + ) + : null, + ), ), if (_uploadingAvatar) const Positioned.fill( @@ -368,7 +374,7 @@ class _ProfileScreenState extends ConsumerState { children: [ Icon( hasFace ? Icons.check_circle : Icons.cancel, - color: hasFace ? Colors.green : colors.error, + color: hasFace ? colors.tertiary : colors.error, ), const SizedBox(width: 8), Expanded( diff --git a/lib/screens/reports/reports_screen.dart b/lib/screens/reports/reports_screen.dart index 51aa64a1..b41f1f69 100644 --- a/lib/screens/reports/reports_screen.dart +++ b/lib/screens/reports/reports_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../providers/reports_provider.dart'; +import '../../widgets/app_page_header.dart'; import 'report_date_filter.dart'; import 'report_widget_selector.dart'; import 'report_pdf_export.dart'; @@ -69,7 +70,7 @@ class _ReportsScreenState extends ConsumerState { @override Widget build(BuildContext context) { final enabled = ref.watch(reportWidgetToggleProvider); - final theme = Theme.of(context); + return Scaffold( body: Column( @@ -82,17 +83,10 @@ class _ReportsScreenState extends ConsumerState { constraints: const BoxConstraints(maxWidth: 1200), child: Column( children: [ - // Title row - Row( - children: [ - Expanded( - child: Text( - 'Reports', - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), + AppPageHeader( + title: 'Reports', + subtitle: 'Analytics and performance insights', + actions: [ FilledButton.icon( onPressed: _exporting ? null : _exportPdf, icon: _exporting diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index 2b0e7f4c..6bbd4831 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -28,6 +28,8 @@ import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/typing_dots.dart'; import '../../theme/app_surfaces.dart'; import '../../utils/snackbar.dart'; +import '../../widgets/app_page_header.dart'; +import '../../widgets/app_state_view.dart'; import '../../utils/subject_suggestions.dart'; import '../../widgets/gemini_button.dart'; import '../../widgets/gemini_animated_text_field.dart'; @@ -156,17 +158,14 @@ class _TasksListScreenState extends ConsumerState builder: (context) { // Show error only when there is genuinely no data. if (tasksAsync.hasError && !tasksAsync.hasValue) { - return Center( - child: Text('Failed to load tasks: ${tasksAsync.error}'), + return AppErrorView( + error: tasksAsync.error!, + title: 'Could not load tasks', + onRetry: () => ref.invalidate(tasksProvider), ); } final tasks = tasksAsync.valueOrNull ?? []; - - // True empty state — data loaded but nothing returned. - if (tasks.isEmpty && !effectiveShowSkeleton) { - return const Center(child: Text('No tasks yet.')); - } final offices = officesAsync.valueOrNull ?? []; final officesSorted = List.from(offices) ..sort( @@ -479,12 +478,12 @@ class _TasksListScreenState extends ConsumerState ), ], if (hasMention) - const Padding( - padding: EdgeInsets.only(left: 8), + Padding( + padding: const EdgeInsets.only(left: 8), child: Icon( Icons.circle, size: 10, - color: Colors.red, + color: Theme.of(context).colorScheme.error, ), ), ], @@ -513,17 +512,9 @@ class _TasksListScreenState extends ConsumerState mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: const EdgeInsets.only(top: 16, bottom: 8), - child: Align( - alignment: Alignment.center, - child: Text( - 'Tasks', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge - ?.copyWith(fontWeight: FontWeight.w700), - ), - ), + const AppPageHeader( + title: 'Tasks', + subtitle: 'Work items assigned to your team', ), Expanded( child: Column( @@ -539,8 +530,28 @@ class _TasksListScreenState extends ConsumerState child: TabBarView( controller: _tabController, children: [ - makeList(myTasks), - makeList(filteredTasks), + myTasks.isEmpty && !effectiveShowSkeleton + ? AppEmptyView( + icon: Icons.task_outlined, + title: _hasTaskFilters + ? 'No matching tasks' + : 'No tasks assigned to you', + subtitle: _hasTaskFilters + ? 'Try adjusting your filters.' + : 'Tasks assigned to you will appear here.', + ) + : makeList(myTasks), + filteredTasks.isEmpty && !effectiveShowSkeleton + ? AppEmptyView( + icon: Icons.task_alt_outlined, + title: _hasTaskFilters + ? 'No matching tasks' + : 'No tasks yet', + subtitle: _hasTaskFilters + ? 'Try adjusting your filters.' + : 'Tasks created for your team will appear here.', + ) + : makeList(filteredTasks), ], ), ), diff --git a/lib/screens/teams/teams_screen.dart b/lib/screens/teams/teams_screen.dart index e44eeb3f..686c7a7b 100644 --- a/lib/screens/teams/teams_screen.dart +++ b/lib/screens/teams/teams_screen.dart @@ -12,6 +12,8 @@ import '../../utils/supabase_response.dart'; import 'package:tasq/widgets/multi_select_picker.dart'; import '../../theme/app_surfaces.dart'; import '../../widgets/tasq_adaptive_list.dart'; +import '../../widgets/app_page_header.dart'; +import '../../widgets/app_state_view.dart'; import '../../utils/snackbar.dart'; // Note: `officesProvider` is provided globally in `tickets_provider.dart` so @@ -233,30 +235,19 @@ class _TeamsScreenState extends ConsumerState { mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: const EdgeInsets.only(top: 16, bottom: 8), - child: Stack( - alignment: Alignment.center, - children: [ - Align( - alignment: Alignment.center, - child: Text( - 'Team Management', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ), + const AppPageHeader( + title: 'IT Staff Teams', + subtitle: 'Manage support teams and assignments', ), Expanded(child: listBody), ], ); }, loading: () => const Center(child: CircularProgressIndicator()), - error: (err, stack) => Center(child: Text('Error: $err')), + error: (err, stack) => AppErrorView( + error: err, + onRetry: () => ref.invalidate(teamsProvider), + ), ), floatingActionButton: M3Fab( onPressed: () => _showTeamDialog(context), diff --git a/lib/screens/tickets/tickets_list_screen.dart b/lib/screens/tickets/tickets_list_screen.dart index df97868a..1ad72bbe 100644 --- a/lib/screens/tickets/tickets_list_screen.dart +++ b/lib/screens/tickets/tickets_list_screen.dart @@ -21,6 +21,8 @@ import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/typing_dots.dart'; import '../../theme/app_surfaces.dart'; import '../../utils/snackbar.dart'; +import '../../widgets/app_page_header.dart'; +import '../../widgets/app_state_view.dart'; class TicketsListScreen extends ConsumerStatefulWidget { const TicketsListScreen({super.key}); @@ -90,6 +92,14 @@ class _TicketsListScreenState extends ConsumerState { builder: (context) { // Build the list UI immediately so `Skeletonizer` can // render placeholders while providers are still loading. + if (ticketsAsync.hasError && !ticketsAsync.hasValue) { + return AppErrorView( + error: ticketsAsync.error!, + title: 'Could not load tickets', + onRetry: () => ref.invalidate(ticketsProvider), + ); + } + final tickets = ticketsAsync.valueOrNull ?? []; final officeById = { for (final office in officesAsync.valueOrNull ?? []) @@ -205,6 +215,31 @@ class _TicketsListScreenState extends ConsumerState { ], ); + if (filteredTickets.isEmpty && !effectiveShowSkeleton) { + final hasFilters = _hasTicketFilters; + return Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const AppPageHeader( + title: 'Tickets', + subtitle: 'Support requests and service tickets', + ), + Expanded( + child: AppEmptyView( + icon: Icons.confirmation_number_outlined, + title: hasFilters + ? 'No matching tickets' + : 'No tickets yet', + subtitle: hasFilters + ? 'Try adjusting your filters.' + : 'Tickets submitted by your team will appear here.', + ), + ), + ], + ); + } + final listBody = TasQAdaptiveList( items: filteredTickets, onRowTap: (ticket) => context.go('/tickets/${ticket.id}'), @@ -303,12 +338,12 @@ class _TicketsListScreenState extends ConsumerState { ), ], if (hasMention) - const Padding( - padding: EdgeInsets.only(left: 8), + Padding( + padding: const EdgeInsets.only(left: 8), child: Icon( Icons.circle, size: 10, - color: Colors.red, + color: Theme.of(context).colorScheme.error, ), ), ], @@ -323,17 +358,9 @@ class _TicketsListScreenState extends ConsumerState { mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: const EdgeInsets.only(top: 16, bottom: 8), - child: Align( - alignment: Alignment.center, - child: Text( - 'Tickets', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge - ?.copyWith(fontWeight: FontWeight.w700), - ), - ), + const AppPageHeader( + title: 'Tickets', + subtitle: 'Support requests and service tickets', ), Expanded(child: listBody), ], diff --git a/lib/screens/whereabouts/whereabouts_screen.dart b/lib/screens/whereabouts/whereabouts_screen.dart index 82056956..1dca4b68 100644 --- a/lib/screens/whereabouts/whereabouts_screen.dart +++ b/lib/screens/whereabouts/whereabouts_screen.dart @@ -12,6 +12,7 @@ import '../../providers/profile_provider.dart'; import '../../providers/whereabouts_provider.dart'; import '../../providers/workforce_provider.dart'; import '../../theme/app_surfaces.dart'; +import '../../widgets/app_page_header.dart'; import '../../widgets/responsive_body.dart'; import '../../utils/app_time.dart'; @@ -19,12 +20,12 @@ import '../../utils/app_time.dart'; const _trackedRoles = {'admin', 'dispatcher', 'it_staff'}; /// Role color mapping shared between map pins and legend. -Color _roleColor(String? role) { +Color _roleColor(String? role, ColorScheme cs) { return switch (role) { - 'admin' => Colors.blue.shade700, - 'it_staff' => Colors.green.shade700, - 'dispatcher' => Colors.orange.shade700, - _ => Colors.grey, + 'admin' => cs.primary, + 'it_staff' => cs.tertiary, + 'dispatcher' => cs.secondary, + _ => cs.outline, }; } @@ -106,16 +107,11 @@ class _WhereaboutsScreenState extends ConsumerState { return ResponsiveBody( maxWidth: 1200, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Text( - 'Whereabouts', - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), - ), + const AppPageHeader( + title: 'Whereabouts', + subtitle: 'Live staff positions and active check-ins', ), // Map Expanded( @@ -182,7 +178,8 @@ class _WhereaboutsMap extends StatelessWidget { final profile = profileById[pos.userId]; final name = profile?.fullName ?? 'Unknown'; final stale = _isStale(pos.updatedAt); - final pinColor = stale ? Colors.grey : _roleColor(profile?.role); + final cs = Theme.of(context).colorScheme; + final pinColor = stale ? cs.outlineVariant : _roleColor(profile?.role, cs); return Marker( point: LatLng(pos.lat, pos.lng), width: 80, @@ -419,7 +416,7 @@ class _StaffLegendTile extends StatelessWidget { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - final roleColor = _roleColor(profile.role); + final roleColor = _roleColor(profile.role, cs); final hasPosition = position != null; final isInPremise = position?.inPremise ?? false; @@ -436,7 +433,7 @@ class _StaffLegendTile extends StatelessWidget { final effectiveColor = (isActive || inferredInPremise) ? roleColor - : Colors.grey.shade400; + : cs.outlineVariant; // Build status label final String statusText; diff --git a/lib/screens/workforce/workforce_screen.dart b/lib/screens/workforce/workforce_screen.dart index 05ef472a..42ce25fa 100644 --- a/lib/screens/workforce/workforce_screen.dart +++ b/lib/screens/workforce/workforce_screen.dart @@ -14,6 +14,8 @@ import '../../providers/rotation_config_provider.dart'; import '../../providers/workforce_provider.dart'; import '../../providers/chat_provider.dart'; import '../../providers/ramadan_provider.dart'; +import '../../widgets/app_page_header.dart'; +import '../../widgets/app_state_view.dart'; import '../../widgets/responsive_body.dart'; import '../../theme/app_surfaces.dart'; import '../../utils/snackbar.dart'; @@ -30,59 +32,71 @@ class WorkforceScreen extends ConsumerWidget { role == 'admin' || role == 'programmer' || role == 'dispatcher'; return ResponsiveBody( - child: LayoutBuilder( - builder: (context, constraints) { - final isWide = constraints.maxWidth >= 980; - final schedulePanel = _SchedulePanel(isAdmin: isAdmin); - final swapsPanel = _SwapRequestsPanel(isAdmin: isAdmin); - final generatorPanel = _ScheduleGeneratorPanel(enabled: isAdmin); + child: Column( + children: [ + const AppPageHeader( + title: 'Workforce', + subtitle: 'Duty schedules and shift management', + ), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= 980; + final schedulePanel = _SchedulePanel(isAdmin: isAdmin); + final swapsPanel = _SwapRequestsPanel(isAdmin: isAdmin); + final generatorPanel = _ScheduleGeneratorPanel( + enabled: isAdmin, + ); - if (isWide) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(flex: 3, child: schedulePanel), - const SizedBox(width: 16), - Expanded( - flex: 2, + if (isWide) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 3, child: schedulePanel), + const SizedBox(width: 16), + Expanded( + flex: 2, + child: Column( + children: [ + if (isAdmin) generatorPanel, + if (isAdmin) const SizedBox(height: 16), + Expanded(child: swapsPanel), + ], + ), + ), + ], + ); + } + + return DefaultTabController( + length: isAdmin ? 3 : 2, child: Column( children: [ - if (isAdmin) generatorPanel, - if (isAdmin) const SizedBox(height: 16), - Expanded(child: swapsPanel), + const SizedBox(height: 8), + TabBar( + tabs: [ + const Tab(text: 'Schedule'), + const Tab(text: 'Swaps'), + if (isAdmin) const Tab(text: 'Generator'), + ], + ), + const SizedBox(height: 8), + Expanded( + child: TabBarView( + children: [ + schedulePanel, + swapsPanel, + if (isAdmin) generatorPanel, + ], + ), + ), ], ), - ), - ], - ); - } - - return DefaultTabController( - length: isAdmin ? 3 : 2, - child: Column( - children: [ - const SizedBox(height: 8), - TabBar( - tabs: [ - const Tab(text: 'Schedule'), - const Tab(text: 'Swaps'), - if (isAdmin) const Tab(text: 'Generator'), - ], - ), - const SizedBox(height: 8), - Expanded( - child: TabBarView( - children: [ - schedulePanel, - swapsPanel, - if (isAdmin) generatorPanel, - ], - ), - ), - ], + ); + }, ), - ); - }, + ), + ], ), ); } @@ -140,7 +154,12 @@ class _SchedulePanel extends ConsumerWidget { .toList(); if (schedules.isEmpty) { - return const Center(child: Text('No schedules yet.')); + return const AppEmptyView( + icon: Icons.calendar_month_outlined, + title: 'No schedules yet', + subtitle: + 'Generated schedules will appear here. Use the Generator tab to create them.', + ); } final Map profileById = { @@ -192,6 +211,7 @@ class _SchedulePanel extends ConsumerWidget { ), isMine: schedule.userId == currentUserId, isAdmin: isAdmin, + role: profileById[schedule.userId]?.role, ), ), ], @@ -201,8 +221,10 @@ class _SchedulePanel extends ConsumerWidget { ); }, loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => - Center(child: Text('Failed to load schedules: $error')), + error: (error, _) => AppErrorView( + error: error, + onRetry: () => ref.invalidate(dutySchedulesProvider), + ), ), ), ], @@ -280,6 +302,7 @@ class _ScheduleTile extends ConsumerWidget { required this.relieverLabels, required this.isMine, required this.isAdmin, + this.role, }); final DutySchedule schedule; @@ -287,29 +310,27 @@ class _ScheduleTile extends ConsumerWidget { final List relieverLabels; final bool isMine; final bool isAdmin; + final String? role; @override Widget build(BuildContext context, WidgetRef ref) { final currentUserId = ref.watch(currentUserIdProvider); - final swaps = ref.watch(swapRequestsProvider).valueOrNull ?? []; + // Use .select() so this tile only rebuilds when its own swap status changes, + // not every time any swap in the list is updated. + final hasRequestedSwap = ref.watch( + swapRequestsProvider.select( + (async) => (async.valueOrNull ?? const []).any( + (swap) => + swap.requesterScheduleId == schedule.id && + swap.requesterId == currentUserId && + swap.status == 'pending', + ), + ), + ); final now = AppTime.now(); final isPast = schedule.startTime.isBefore(now); - final hasRequestedSwap = swaps.any( - (swap) => - swap.requesterScheduleId == schedule.id && - swap.requesterId == currentUserId && - swap.status == 'pending', - ); final canRequestSwap = isMine && schedule.status != 'absent' && !isPast; - final profiles = ref.watch(profilesProvider).valueOrNull ?? []; - Profile? profile; - try { - profile = profiles.firstWhere((p) => p.id == schedule.userId); - } catch (_) { - profile = null; - } - final role = profile?.role; final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull; ShiftTypeConfig? shiftTypeConfig; @@ -889,15 +910,16 @@ class _ScheduleTile extends ConsumerWidget { } Color _statusColor(BuildContext context, String status) { + final cs = Theme.of(context).colorScheme; switch (status) { case 'arrival': - return Colors.green; + return cs.tertiary; case 'late': - return Colors.orange; + return cs.secondary; case 'absent': - return Colors.red; + return cs.error; default: - return Theme.of(context).colorScheme.onSurfaceVariant; + return cs.onSurfaceVariant; } } } diff --git a/lib/theme/m3_motion.dart b/lib/theme/m3_motion.dart index ca47036a..23331ff8 100644 --- a/lib/theme/m3_motion.dart +++ b/lib/theme/m3_motion.dart @@ -3,6 +3,15 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +// ── Reduced-motion helper ───────────────────────────────────────────────────── + +/// Returns true when the OS/user has requested reduced motion. +/// +/// Always query this in the [build] method — never in [initState] — because +/// the value is read from [MediaQuery] which requires a valid [BuildContext]. +bool m3ReducedMotion(BuildContext context) => + MediaQuery.of(context).disableAnimations; + /// M3 Expressive motion constants and helpers. /// /// Transitions use spring-physics inspired curves with an emphasized easing @@ -111,6 +120,8 @@ class _M3FadeSlideInState extends State @override Widget build(BuildContext context) { + // Skip animation entirely when the OS requests reduced motion. + if (m3ReducedMotion(context)) return widget.child; return FadeTransition( opacity: _opacity, child: SlideTransition(position: _slide, child: widget.child), @@ -514,3 +525,391 @@ Future m3ShowBottomSheet({ builder: builder, ); } + +// ═══════════════════════════════════════════════════════════════ +// M3ShimmerBox — animated loading skeleton shimmer +// ═══════════════════════════════════════════════════════════════ + +/// An animated shimmer placeholder used during loading states. +/// +/// The highlight sweeps from left to right at 1.2-second intervals, +/// matching the "loading skeleton" pattern from M3 guidelines. +/// Automatically falls back to a static surface when the OS requests +/// reduced motion. +/// +/// ```dart +/// M3ShimmerBox(width: 120, height: 14, borderRadius: BorderRadius.circular(4)) +/// ``` +class M3ShimmerBox extends StatefulWidget { + const M3ShimmerBox({ + super.key, + this.width, + this.height, + this.borderRadius, + this.child, + }); + + final double? width; + final double? height; + + /// Defaults to `BorderRadius.circular(6)` when null. + final BorderRadius? borderRadius; + + /// Optional child rendered on top of the shimmer surface. + final Widget? child; + + @override + State createState() => _M3ShimmerBoxState(); +} + +class _M3ShimmerBoxState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final radius = widget.borderRadius ?? BorderRadius.circular(6); + final baseColor = cs.surfaceContainerHighest; + final highlightColor = cs.surfaceContainerHigh; + + if (m3ReducedMotion(context)) { + return Container( + width: widget.width, + height: widget.height, + decoration: BoxDecoration(color: baseColor, borderRadius: radius), + child: widget.child, + ); + } + + return AnimatedBuilder( + animation: _ctrl, + builder: (context, child) { + // Shimmer highlight sweeps from -0.5 → 1.5 across the widget width. + final t = _ctrl.value; + final dx = -0.5 + t * 2.0; + return Container( + width: widget.width, + height: widget.height, + decoration: BoxDecoration( + borderRadius: radius, + gradient: LinearGradient( + begin: Alignment(dx - 0.5, 0), + end: Alignment(dx + 0.5, 0), + colors: [baseColor, highlightColor, baseColor], + ), + ), + child: child, + ); + }, + child: widget.child, + ); + } +} + +// ═══════════════════════════════════════════════════════════════ +// M3PressScale — press-to-scale micro-interaction +// ═══════════════════════════════════════════════════════════════ + +/// Wraps any widget with a subtle scale-down animation on press, giving +/// tactile visual feedback for tappable surfaces (cards, list tiles, etc.). +/// +/// Uses [M3Motion.micro] duration with [M3Motion.emphasizedEnter] curve +/// so the press response feels instant but controlled. +/// +/// ```dart +/// M3PressScale( +/// onTap: () => context.go('/detail'), +/// child: Card(child: ...), +/// ) +/// ``` +class M3PressScale extends StatefulWidget { + const M3PressScale({ + super.key, + required this.child, + this.onTap, + this.onLongPress, + this.scale = 0.97, + }); + + final Widget child; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + + /// The scale factor applied during a press. Defaults to `0.97`. + final double scale; + + @override + State createState() => _M3PressScaleState(); +} + +class _M3PressScaleState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + late final Animation _scaleAnim; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController(vsync: this, duration: M3Motion.micro); + _scaleAnim = Tween(begin: 1.0, end: widget.scale).animate( + CurvedAnimation(parent: _ctrl, curve: M3Motion.emphasizedEnter), + ); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + void _onDown(TapDownDetails _) => _ctrl.forward(); + void _onUp(TapUpDetails _) => _ctrl.reverse(); + void _onCancel() => _ctrl.reverse(); + + @override + Widget build(BuildContext context) { + // Skip animation when the OS requests reduced motion. + if (m3ReducedMotion(context)) { + return GestureDetector( + onTap: widget.onTap, + onLongPress: widget.onLongPress, + child: widget.child, + ); + } + + return GestureDetector( + onTapDown: _onDown, + onTapUp: _onUp, + onTapCancel: _onCancel, + onTap: widget.onTap, + onLongPress: widget.onLongPress, + child: ScaleTransition(scale: _scaleAnim, child: widget.child), + ); + } +} + +// ═══════════════════════════════════════════════════════════════ +// M3ErrorShake — horizontal shake animation for validation errors +// ═══════════════════════════════════════════════════════════════ + +/// Plays a brief horizontal shake animation whenever [hasError] transitions +/// from `false` to `true` — ideal for wrapping form cards or individual +/// fields to signal a validation failure. +/// +/// Automatically skips the animation when the OS requests reduced motion. +/// +/// ```dart +/// M3ErrorShake( +/// hasError: _isLoading == false && _errorMessage != null, +/// child: Card(child: Form(...)), +/// ) +/// ``` +class M3ErrorShake extends StatefulWidget { + const M3ErrorShake({ + super.key, + required this.child, + required this.hasError, + }); + + final Widget child; + + /// When this transitions from `false` → `true` the shake fires once. + final bool hasError; + + @override + State createState() => _M3ErrorShakeState(); +} + +class _M3ErrorShakeState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + bool _prevError = false; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 450), + ); + } + + @override + void didUpdateWidget(M3ErrorShake oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.hasError && !_prevError) { + _ctrl.forward(from: 0); + } + _prevError = widget.hasError; + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (m3ReducedMotion(context)) return widget.child; + + return AnimatedBuilder( + animation: _ctrl, + builder: (context, child) { + // Damped oscillation: amplitude fades as t → 1. + final t = _ctrl.value; + final dx = math.sin(t * math.pi * 5) * 8.0 * (1.0 - t); + return Transform.translate(offset: Offset(dx, 0), child: child); + }, + child: widget.child, + ); + } +} + +// ═══════════════════════════════════════════════════════════════ +// M3BounceIcon — entrance + idle pulse for empty/error states +// ═══════════════════════════════════════════════════════════════ + +/// Displays an [icon] inside a colored circular badge with: +/// 1. A spring-bounce entrance animation on first build. +/// 2. A slow, gentle idle pulse (scale 1.0 → 1.06 → 1.0) that repeats +/// indefinitely to draw attention without being distracting. +/// +/// Respects [m3ReducedMotion] — both animations are skipped when reduced +/// motion is enabled. +class M3BounceIcon extends StatefulWidget { + const M3BounceIcon({ + super.key, + required this.icon, + required this.iconColor, + required this.backgroundColor, + this.size = 72.0, + this.iconSize = 36.0, + }); + + final IconData icon; + final Color iconColor; + final Color backgroundColor; + final double size; + final double iconSize; + + @override + State createState() => _M3BounceIconState(); +} + +class _M3BounceIconState extends State + with TickerProviderStateMixin { + late final AnimationController _entranceCtrl; + late final AnimationController _pulseCtrl; + late final Animation _entrance; + late final Animation _pulse; + + @override + void initState() { + super.initState(); + + // Entrance: scale 0 → 1.0 with spring overshoot. + _entranceCtrl = AnimationController(vsync: this, duration: M3Motion.long); + _entrance = CurvedAnimation(parent: _entranceCtrl, curve: M3Motion.spring); + + // Idle pulse: 1.0 → 1.06 → 1.0, repeating every 2.5 s. + _pulseCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2500), + )..repeat(reverse: true); + _pulse = Tween(begin: 1.0, end: 1.06).animate( + CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut), + ); + + _entranceCtrl.forward(); + } + + @override + void dispose() { + _entranceCtrl.dispose(); + _pulseCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final badge = Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + color: widget.backgroundColor, + shape: BoxShape.circle, + ), + child: Icon(widget.icon, size: widget.iconSize, color: widget.iconColor), + ); + + if (m3ReducedMotion(context)) return badge; + + return ScaleTransition( + scale: _entrance, + child: AnimatedBuilder( + animation: _pulse, + builder: (_, child) => + Transform.scale(scale: _pulse.value, child: child), + child: badge, + ), + ); + } +} + +// ═══════════════════════════════════════════════════════════════ +// M3AnimatedCounter — smooth number animation +// ═══════════════════════════════════════════════════════════════ + +/// Animates an integer value from its previous value to [value] using a +/// [TweenAnimationBuilder], producing a smooth counting effect on metric +/// cards and dashboard KPIs. +/// +/// ```dart +/// M3AnimatedCounter( +/// value: totalTasks, +/// style: theme.textTheme.headlineMedium, +/// ) +/// ``` +class M3AnimatedCounter extends StatelessWidget { + const M3AnimatedCounter({ + super.key, + required this.value, + this.style, + this.duration = M3Motion.standard, + }); + + final int value; + final TextStyle? style; + final Duration duration; + + @override + Widget build(BuildContext context) { + if (m3ReducedMotion(context)) { + return Text(value.toString(), style: style); + } + + return TweenAnimationBuilder( + tween: IntTween(begin: 0, end: value), + duration: duration, + curve: M3Motion.emphasizedEnter, + builder: (_, v, _) => Text(v.toString(), style: style), + ); + } +} diff --git a/lib/utils/snackbar.dart b/lib/utils/snackbar.dart index cc0eb473..633e82ca 100644 --- a/lib/utils/snackbar.dart +++ b/lib/utils/snackbar.dart @@ -114,19 +114,39 @@ void showAwesomeSnackBar( } void showSuccessSnackBar(BuildContext context, String message) { - showSuccessSnackBarGlobal(message); + showAwesomeSnackBar( + context, + title: 'Success', + message: message, + snackType: SnackType.success, + ); } void showErrorSnackBar(BuildContext context, String message) { - showErrorSnackBarGlobal(message); + showAwesomeSnackBar( + context, + title: 'Error', + message: message, + snackType: SnackType.error, + ); } void showInfoSnackBar(BuildContext context, String message) { - showInfoSnackBarGlobal(message); + showAwesomeSnackBar( + context, + title: 'Info', + message: message, + snackType: SnackType.info, + ); } void showWarningSnackBar(BuildContext context, String message) { - showWarningSnackBarGlobal(message); + showAwesomeSnackBar( + context, + title: 'Warning', + message: message, + snackType: SnackType.warning, + ); } /// Global helpers that use the app-level `scaffoldMessengerKey` directly. diff --git a/lib/widgets/app_page_header.dart b/lib/widgets/app_page_header.dart new file mode 100644 index 00000000..a81c1afc --- /dev/null +++ b/lib/widgets/app_page_header.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; + +import '../theme/m3_motion.dart'; + +/// Standardized M3 Expressive page header with animated entrance. +/// +/// Replaces the ad-hoc `Padding(Align(Text(...)))` pattern found across +/// screens. Provides: +/// * `headlineSmall` typography — correct M3 size for a top-level page title. +/// * [M3FadeSlideIn] entrance animation — 400 ms with emphasized easing. +/// * Optional [subtitle] in `bodyMedium` / `onSurfaceVariant`. +/// * Optional [actions] row rendered at the trailing end. +/// * Responsive alignment: left-aligned on desktop (≥600 dp), centered on +/// mobile to match the existing navigation-bar-centric layout. +class AppPageHeader extends StatelessWidget { + const AppPageHeader({ + super.key, + required this.title, + this.subtitle, + this.actions, + this.padding = const EdgeInsets.only(top: 20, bottom: 12), + }); + + final String title; + final String? subtitle; + + /// Optional trailing action widgets (e.g. filter chip, icon button). + final List? actions; + + /// Vertical padding around the header. Horizontal padding is handled by + /// the parent [ResponsiveBody] so only top/bottom should be set here. + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final tt = Theme.of(context).textTheme; + final cs = Theme.of(context).colorScheme; + + final titleWidget = Text( + title, + style: tt.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + color: cs.onSurface, + ), + textAlign: TextAlign.center, + ); + + final subtitleWidget = subtitle == null + ? null + : Text( + subtitle!, + style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant), + textAlign: TextAlign.center, + ); + + final hasActions = actions != null && actions!.isNotEmpty; + + Widget content; + if (!hasActions) { + content = Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + titleWidget, + if (subtitleWidget != null) ...[ + const SizedBox(height: 4), + subtitleWidget, + ], + ], + ); + } else { + // With actions — centered title/subtitle, actions sit to the right. + content = Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + titleWidget, + if (subtitleWidget != null) ...[ + const SizedBox(height: 4), + subtitleWidget, + ], + ], + ), + ), + const SizedBox(width: 8), + Row(mainAxisSize: MainAxisSize.min, children: actions!), + ], + ); + } + + return M3FadeSlideIn( + duration: M3Motion.standard, + child: Padding(padding: padding, child: content), + ); + } +} diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 9bb7c7d0..6b4a9aa0 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -84,6 +84,7 @@ class AppScaffold extends ConsumerWidget { fullName: displayName, avatarUrl: avatarUrl, radius: 16, + heroTag: 'profile-avatar', ), const SizedBox(width: 8), Text(displayName), @@ -104,6 +105,7 @@ class AppScaffold extends ConsumerWidget { fullName: displayName, avatarUrl: avatarUrl, radius: 16, + heroTag: 'profile-avatar', ), ), IconButton( @@ -273,19 +275,42 @@ class _NotificationBell extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final unreadCount = ref.watch(unreadNotificationsCountProvider); + final cs = Theme.of(context).colorScheme; return IconButton( tooltip: 'Notifications', onPressed: () => context.go('/notifications'), icon: Stack( clipBehavior: Clip.none, children: [ - const Icon(Icons.notifications), - if (unreadCount > 0) - const Positioned( - right: -2, - top: -2, - child: Icon(Icons.circle, size: 10, color: Colors.red), + const Icon(Icons.notifications_outlined), + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOutBack, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => ScaleTransition( + scale: animation, + child: child, ), + child: unreadCount > 0 + ? Positioned( + key: const ValueKey('badge'), + right: -3, + top: -3, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: cs.error, + shape: BoxShape.circle, + border: Border.all( + color: cs.surface, + width: 1.5, + ), + ), + ), + ) + : const SizedBox.shrink(key: ValueKey('no-badge')), + ), ], ), ); @@ -422,24 +447,12 @@ List _buildSections(String role) { icon: Icons.apartment_outlined, selectedIcon: Icons.apartment, ), - NavItem( - label: 'Geofence test', - route: '/settings/geofence-test', - icon: Icons.map_outlined, - selectedIcon: Icons.map, - ), NavItem( label: 'IT Staff Teams', route: '/settings/teams', icon: Icons.groups_2_outlined, selectedIcon: Icons.groups_2, ), - NavItem( - label: 'Permissions', - route: '/settings/permissions', - icon: Icons.lock_open, - selectedIcon: Icons.lock, - ), if (kIsWeb) ...[ NavItem( label: 'App Update', @@ -459,19 +472,16 @@ List _buildSections(String role) { ]; } - // non-admin users still get a simple Settings section containing only - // permissions. this keeps the screen accessible without exposing the - // administrative management screens. return [ NavSection(label: 'Operations', items: mainItems), NavSection( label: 'Settings', items: [ NavItem( - label: 'Permissions', - route: '/settings/permissions', - icon: Icons.lock_open, - selectedIcon: Icons.lock, + label: 'Logout', + route: '', + icon: Icons.logout, + isLogout: true, ), ], ), diff --git a/lib/widgets/app_state_view.dart b/lib/widgets/app_state_view.dart new file mode 100644 index 00000000..bf6366b2 --- /dev/null +++ b/lib/widgets/app_state_view.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; + +import '../theme/m3_motion.dart'; + +/// A centered error state with an icon, title, human-readable message and an +/// optional retry button. +/// +/// Usage: +/// ```dart +/// if (async.hasError && !async.hasValue) { +/// return AppErrorView( +/// error: async.error!, +/// onRetry: () => ref.invalidate(myProvider), +/// ); +/// } +/// ``` +class AppErrorView extends StatelessWidget { + const AppErrorView({ + super.key, + required this.error, + this.title = 'Something went wrong', + this.onRetry, + }); + + final Object error; + + /// Short title shown above the error message. + final String title; + + /// When provided, a "Try again" button is rendered below the message. + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final tt = Theme.of(context).textTheme; + final message = _humanise(error); + + return Center( + child: M3FadeSlideIn( + duration: M3Motion.standard, + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + M3BounceIcon( + icon: Icons.error_outline_rounded, + iconColor: cs.onErrorContainer, + backgroundColor: cs.errorContainer, + ), + const SizedBox(height: 20), + Text( + title, + style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w700), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + message, + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), + textAlign: TextAlign.center, + maxLines: 4, + overflow: TextOverflow.ellipsis, + ), + if (onRetry != null) ...[ + const SizedBox(height: 24), + OutlinedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh_rounded), + label: const Text('Try again'), + ), + ], + ], + ), + ), + ), + ); + } + + /// Strips common Dart exception prefixes so the user sees a clean message. + static String _humanise(Object error) { + var text = error.toString(); + const prefixes = ['Exception: ', 'Error: ', 'FormatException: ']; + for (final p in prefixes) { + if (text.startsWith(p)) { + text = text.substring(p.length); + break; + } + } + return text.isEmpty ? 'An unexpected error occurred.' : text; + } +} + +/// A centered empty-state view with an icon, title and optional subtitle. +/// +/// Usage: +/// ```dart +/// if (items.isEmpty && !loading) { +/// return const AppEmptyView( +/// icon: Icons.task_outlined, +/// title: 'No tasks yet', +/// subtitle: 'Tasks assigned to you will appear here.', +/// ); +/// } +/// ``` +class AppEmptyView extends StatelessWidget { + const AppEmptyView({ + super.key, + this.icon = Icons.inbox_outlined, + required this.title, + this.subtitle, + this.action, + }); + + final IconData icon; + final String title; + final String? subtitle; + + /// Optional call-to-action placed below the subtitle. + final Widget? action; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final tt = Theme.of(context).textTheme; + + return Center( + child: M3FadeSlideIn( + duration: M3Motion.standard, + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + M3BounceIcon( + icon: icon, + iconColor: cs.onSurfaceVariant, + backgroundColor: cs.surfaceContainerHighest, + ), + const SizedBox(height: 20), + Text( + title, + style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w600), + textAlign: TextAlign.center, + ), + if (subtitle != null) ...[ + const SizedBox(height: 8), + Text( + subtitle!, + style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant), + textAlign: TextAlign.center, + ), + ], + if (action != null) ...[ + const SizedBox(height: 20), + action!, + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/profile_avatar.dart b/lib/widgets/profile_avatar.dart index 993ab4b7..63cf6720 100644 --- a/lib/widgets/profile_avatar.dart +++ b/lib/widgets/profile_avatar.dart @@ -3,18 +3,25 @@ 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) +/// +/// Pass [heroTag] to participate in a Hero transition to/from a destination +/// that uses the same tag (e.g., the profile screen's large avatar). class ProfileAvatar extends StatelessWidget { const ProfileAvatar({ super.key, required this.fullName, this.avatarUrl, this.radius = 18, + this.heroTag, }); final String fullName; final String? avatarUrl; final double radius; + /// When non-null, wraps the avatar in a [Hero] with this tag. + final Object? heroTag; + String _getInitials() { final trimmed = fullName.trim(); if (trimmed.isEmpty) return 'U'; @@ -28,37 +35,37 @@ class ProfileAvatar extends StatelessWidget { return '${parts.first[0]}${parts.last[0]}'.toUpperCase(); } - Color _getInitialsColor(String initials) { - // Generate a deterministic color based on initials + /// Returns a (background, foreground) pair from the M3 tonal palette. + /// + /// Uses a deterministic hash of the initials to cycle through the scheme's + /// semantic container colors so every avatar is theme-aware and accessible. + (Color, Color) _getTonalColors(String initials, ColorScheme cs) { 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, + + // Six M3-compliant container pairs (background / on-color text). + final pairs = [ + (cs.primaryContainer, cs.onPrimaryContainer), + (cs.secondaryContainer, cs.onSecondaryContainer), + (cs.tertiaryContainer, cs.onTertiaryContainer), + (cs.errorContainer, cs.onErrorContainer), + (cs.primary, cs.onPrimary), + (cs.secondary, cs.onSecondary), ]; - return colors[hash % colors.length]; + + return pairs[hash % pairs.length]; } @override Widget build(BuildContext context) { final initials = _getInitials(); + Widget avatar; + // If avatar URL is provided, attempt to load the image if (avatarUrl != null && avatarUrl!.isNotEmpty) { - return CircleAvatar( + avatar = CircleAvatar( radius: radius, backgroundImage: NetworkImage(avatarUrl!), onBackgroundImageError: (_, _) { @@ -66,20 +73,30 @@ class ProfileAvatar extends StatelessWidget { }, child: null, // Image will display if loaded successfully ); + } else { + final (bg, fg) = _getTonalColors( + initials, + Theme.of(context).colorScheme, + ); + + // Fallback to initials + avatar = CircleAvatar( + radius: radius, + backgroundColor: bg, + child: Text( + initials, + style: TextStyle( + color: fg, + fontSize: radius * 0.8, + fontWeight: FontWeight.w600, + ), + ), + ); } - // 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, - ), - ), - ); + if (heroTag != null) { + return Hero(tag: heroTag!, child: avatar); + } + return avatar; } } diff --git a/lib/widgets/status_pill.dart b/lib/widgets/status_pill.dart index 7875328f..ebd6a65c 100644 --- a/lib/widgets/status_pill.dart +++ b/lib/widgets/status_pill.dart @@ -11,10 +11,14 @@ class StatusPill extends StatelessWidget { @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; + // isEmphasized uses primaryContainer (higher tonal weight) to visually + // distinguish high-priority statuses from routine ones. final background = isEmphasized - ? scheme.tertiaryContainer + ? scheme.primaryContainer : scheme.tertiaryContainer; - final foreground = scheme.onTertiaryContainer; + final foreground = isEmphasized + ? scheme.onPrimaryContainer + : scheme.onTertiaryContainer; return AnimatedContainer( duration: const Duration(milliseconds: 400), diff --git a/lib/widgets/tasq_adaptive_list.dart b/lib/widgets/tasq_adaptive_list.dart index 64d61ea0..940c420f 100644 --- a/lib/widgets/tasq_adaptive_list.dart +++ b/lib/widgets/tasq_adaptive_list.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import '../theme/app_typography.dart'; import '../theme/app_surfaces.dart'; +import '../theme/m3_motion.dart'; import 'mono_text.dart'; /// A column configuration for the [TasQAdaptiveList] desktop table view. @@ -205,11 +206,18 @@ class TasQAdaptiveList extends StatelessWidget { } final item = items[index]; final actions = rowActions?.call(item) ?? const []; - return _MobileTile( - item: item, - actions: actions, - mobileTileBuilder: mobileTileBuilder, - onRowTap: onRowTap, + // M3 Expressive: stagger first 8 items on enter (50 ms per step). + final staggerDelay = Duration( + milliseconds: math.min(index, 8) * 50, + ); + return M3FadeSlideIn( + delay: staggerDelay, + child: _MobileTile( + item: item, + actions: actions, + mobileTileBuilder: mobileTileBuilder, + onRowTap: onRowTap, + ), ); }, ); @@ -225,11 +233,17 @@ class TasQAdaptiveList extends StatelessWidget { } final item = items[index]; final actions = rowActions?.call(item) ?? const []; - return _MobileTile( - item: item, - actions: actions, - mobileTileBuilder: mobileTileBuilder, - onRowTap: onRowTap, + final staggerDelay = Duration( + milliseconds: math.min(index, 8) * 50, + ); + return M3FadeSlideIn( + delay: staggerDelay, + child: _MobileTile( + item: item, + actions: actions, + mobileTileBuilder: mobileTileBuilder, + onRowTap: onRowTap, + ), ); }, shrinkWrap: true, @@ -294,35 +308,29 @@ class TasQAdaptiveList extends StatelessWidget { Widget _loadingTile(BuildContext context) { return Padding( padding: const EdgeInsets.only(top: 8), - child: SizedBox( - height: 72, - child: Card( - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(6), - ), + child: Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + M3ShimmerBox( + width: 40, + height: 40, + borderRadius: BorderRadius.circular(6), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + M3ShimmerBox(height: 12), + const SizedBox(height: 8), + M3ShimmerBox(width: 150, height: 10), + ], ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container(height: 12, color: Colors.white), - const SizedBox(height: 8), - Container(height: 10, width: 150, color: Colors.white), - ], - ), - ), - ], - ), + ), + ], ), ), ), diff --git a/lib/widgets/update_dialog.dart b/lib/widgets/update_dialog.dart index 0778f8b9..ae5d19be 100644 --- a/lib/widgets/update_dialog.dart +++ b/lib/widgets/update_dialog.dart @@ -151,11 +151,13 @@ class _UpdateDialogState extends State { ), ], if (_failed) - const Padding( - padding: EdgeInsets.only(top: 8.0), + Padding( + padding: const EdgeInsets.only(top: 8.0), child: Text( 'An error occurred while downloading. Please try again.', - style: TextStyle(color: Colors.red), + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), ), ), ],