diff --git a/RotationConfig()) b/RotationConfig()) deleted file mode 100644 index e69de29b..00000000 diff --git a/await b/await deleted file mode 100644 index e69de29b..00000000 diff --git a/lib/app.dart b/lib/app.dart index e77364cc..c453509e 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_quill/flutter_quill.dart'; @@ -41,6 +43,17 @@ class TasqApp extends ConsumerWidget { } else { await stopBackgroundLocationUpdates(); } + // Pre-warm the enrolled-face cache so offline face matching has bytes + // to compare against. No-op on web, or when cache is up-to-date. + if (profile != null && profile.hasFaceEnrolled) { + unawaited( + prewarmEnrolledFaceCache( + controller: ref.read(profileControllerProvider), + userId: profile.id, + enrolledAt: profile.faceEnrolledAt?.toIso8601String(), + ), + ); + } }); return MaterialApp.router( diff --git a/lib/providers/attendance_provider.dart b/lib/providers/attendance_provider.dart index 96287a81..8349edef 100644 --- a/lib/providers/attendance_provider.dart +++ b/lib/providers/attendance_provider.dart @@ -1,7 +1,9 @@ +import 'dart:io'; import 'dart:typed_data'; -import 'package:flutter/foundation.dart' show debugPrint; +import 'package:flutter/foundation.dart' show debugPrint, kIsWeb; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:uuid/uuid.dart'; @@ -219,6 +221,7 @@ final attendanceLogsProvider = StreamProvider>((ref) { '[attendanceLogsProvider] reconnected — syncing ${pendingUpdatesMap.length} attendance update(s)', ); final syncedIds = []; + final filesToDelete = []; for (final entry in pendingUpdatesMap.entries) { final attendanceId = entry.key; final fields = Map.from(entry.value); @@ -243,12 +246,64 @@ final attendanceLogsProvider = StreamProvider>((ref) { ); } } else { + // If a verification photo was queued to disk while offline, upload + // the bytes first and append the resulting public URL to `fields`. + final localPath = fields.remove('_local_photo_path') as String?; + final isCheckOut = fields.remove('_is_checkout') as bool? ?? false; + final fileName = fields.remove('_file_name') as String?; + String? pendingFileToDelete; + if (localPath != null && !kIsWeb) { + try { + final file = File(localPath); + if (await file.exists()) { + final bytes = await file.readAsBytes(); + final userId = client.auth.currentUser?.id; + if (userId != null) { + final ext = (fileName ?? 'photo.jpg') + .split('.') + .last + .toLowerCase(); + final prefix = isCheckOut ? 'checkout' : 'checkin'; + final storagePath = + '$userId/${prefix}_$attendanceId.$ext'; + await client.storage + .from('attendance-verification') + .uploadBinary( + storagePath, + bytes, + fileOptions: const FileOptions(upsert: true), + ); + final url = client.storage + .from('attendance-verification') + .getPublicUrl(storagePath); + final column = isCheckOut + ? 'check_out_verification_photo_url' + : 'check_in_verification_photo_url'; + fields[column] = url; + pendingFileToDelete = localPath; + } + } else { + debugPrint( + '[attendanceLogsProvider] local photo missing for id=$attendanceId — replaying status only', + ); + } + } catch (e) { + // Photo upload failed — leave entry in queue for next reconnect. + debugPrint( + '[attendanceLogsProvider] failed to upload local photo id=$attendanceId: $e', + ); + continue; + } + } try { await client .from('attendance_logs') .update(fields) .eq('id', attendanceId); syncedIds.add(attendanceId); + if (pendingFileToDelete != null) { + filesToDelete.add(pendingFileToDelete); + } } catch (e) { debugPrint( '[attendanceLogsProvider] failed to sync update id=$attendanceId: $e', @@ -256,6 +311,12 @@ final attendanceLogsProvider = StreamProvider>((ref) { } } } + // Best-effort cleanup of uploaded local photo files. + for (final path in filesToDelete) { + try { + await File(path).delete(); + } catch (_) {} + } if (syncedIds.isNotEmpty) { final remaining = Map>.from( ref.read(offlinePendingAttendanceUpdatesProvider), @@ -408,11 +469,38 @@ class AttendanceController { .eq('id', attendanceId); } catch (e) { if (!isOfflineSaveError(e)) rethrow; - // Queue status update without photo URL — photo bytes are not persisted - // across sessions; user will need to re-upload photo when online. + // Persist photo bytes to disk so the reconnect-replay path can upload + // them later. Disk write only on mobile/desktop — `path_provider` is + // unavailable on web, where we fall back to status-only queueing. + if (!kIsWeb) { + try { + final dir = await getApplicationDocumentsDirectory(); + final pendingDir = Directory('${dir.path}/verification_pending'); + if (!await pendingDir.exists()) { + await pendingDir.create(recursive: true); + } + final localPath = '${pendingDir.path}/${attendanceId}_$prefix.$ext'; + await File(localPath).writeAsBytes(bytes); + _queueAttendanceUpdate(attendanceId, { + 'verification_status': status, + '_local_photo_path': localPath, + '_is_checkout': isCheckOut, + '_file_name': fileName, + }); + debugPrint( + '[AttendanceController] uploadVerification queued offline with photo: $attendanceId', + ); + return; + } catch (writeErr) { + debugPrint( + '[AttendanceController] failed to persist offline photo for $attendanceId: $writeErr — falling back to status-only queue', + ); + } + } + // Web or disk-write failure: queue status only. _queueAttendanceUpdate(attendanceId, {'verification_status': status}); debugPrint( - '[AttendanceController] uploadVerification status queued offline (photo not persisted): $attendanceId', + '[AttendanceController] uploadVerification status queued offline (no photo): $attendanceId', ); } } diff --git a/lib/providers/connectivity_provider.dart b/lib/providers/connectivity_provider.dart index 0b33dd34..c4e9939a 100644 --- a/lib/providers/connectivity_provider.dart +++ b/lib/providers/connectivity_provider.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.dart' show debugPrint; +import 'package:flutter/foundation.dart' show debugPrint, kIsWeb; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; @@ -46,6 +46,22 @@ final connectivityMonitorProvider = Provider.autoDispose((ref) { /// Pings the app's own backend to determine online status. class ConnectivityMonitor { static Future check() async { + if (kIsWeb) { + // On web the browser enforces CORS: a HEAD request to an external domain + // (tasq.crmc.ph) is blocked even when online, making it look permanently + // offline. Instead probe the Supabase REST endpoint, which is already + // CORS-configured for this app's origin. Any HTTP response (including + // 401 without auth headers) proves the network is reachable. + try { + final restUrl = Supabase.instance.client.rest.url; + final res = await http + .head(Uri.parse(restUrl)) + .timeout(const Duration(seconds: 5)); + return res.statusCode < 500; + } catch (_) { + return false; + } + } try { final res = await http .head(Uri.parse('https://tasq.crmc.ph')) diff --git a/lib/providers/profile_provider.dart b/lib/providers/profile_provider.dart index aa1b5d0f..591c20eb 100644 --- a/lib/providers/profile_provider.dart +++ b/lib/providers/profile_provider.dart @@ -1,8 +1,11 @@ import 'dart:async'; +import 'dart:io'; import 'dart:typed_data'; -import 'package:flutter/foundation.dart' show debugPrint; +import 'package:flutter/foundation.dart' show debugPrint, kIsWeb; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../brick/cache_helpers.dart'; @@ -13,6 +16,61 @@ import 'connectivity_provider.dart'; import 'supabase_provider.dart'; import 'stream_recovery.dart'; +const _kFaceCacheEnrolledAtPrefix = 'face_cache_enrolled_at:'; + +/// Reads cached enrolled-face bytes from app documents (mobile/desktop only). +Future _readCachedEnrolledFace(String userId) async { + if (kIsWeb) return null; + try { + final dir = await getApplicationDocumentsDirectory(); + final file = File('${dir.path}/face_enrollment/$userId.jpg'); + if (await file.exists()) { + return await file.readAsBytes(); + } + } catch (_) {} + return null; +} + +/// Writes enrolled-face bytes to disk so face matching can run offline. +/// Best-effort — never throws. +Future _writeCachedEnrolledFace( + String userId, + Uint8List bytes, + String? enrolledAt, +) async { + if (kIsWeb) return; + try { + final dir = await getApplicationDocumentsDirectory(); + final cacheDir = Directory('${dir.path}/face_enrollment'); + if (!await cacheDir.exists()) { + await cacheDir.create(recursive: true); + } + final file = File('${cacheDir.path}/$userId.jpg'); + await file.writeAsBytes(bytes); + if (enrolledAt != null) { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('$_kFaceCacheEnrolledAtPrefix$userId', enrolledAt); + } + } catch (_) {} +} + +/// Pre-warms the enrolled-face cache once per session if it is missing or +/// out-of-date relative to the server's `face_enrolled_at` timestamp. +/// No-op on web. Safe to call from a profile listener. +Future prewarmEnrolledFaceCache({ + required ProfileController controller, + required String userId, + required String? enrolledAt, +}) async { + if (kIsWeb || enrolledAt == null) return; + try { + final prefs = await SharedPreferences.getInstance(); + final cachedAt = prefs.getString('$_kFaceCacheEnrolledAtPrefix$userId'); + if (cachedAt == enrolledAt) return; // already up-to-date + await controller.downloadFacePhoto(userId, enrolledAt: enrolledAt); + } catch (_) {} +} + // --------------------------------------------------------------------------- // Offline-pending state // --------------------------------------------------------------------------- @@ -217,13 +275,26 @@ class ProfileController { /// Download the face enrollment photo bytes for the given user. /// Uses Supabase authenticated storage API (works with private buckets). - Future downloadFacePhoto(String userId) async { + /// + /// On a successful online download, bytes are also written to a local cache + /// (`${appDocs}/face_enrollment/.jpg`) so subsequent offline + /// verification can read them. When the storage download fails (offline, + /// auth refresh, etc.) we fall back to the cached file. Pass [enrolledAt] + /// (the server's `face_enrolled_at` ISO timestamp) so the prewarm helper + /// can detect re-enrollments and refresh the cache. + Future downloadFacePhoto( + String userId, { + String? enrolledAt, + }) async { try { - return await _client.storage + final bytes = await _client.storage .from('face-enrollment') .download('$userId/face.jpg'); + // Best-effort cache write so face matching can run offline next time. + unawaited(_writeCachedEnrolledFace(userId, bytes, enrolledAt)); + return bytes; } catch (_) { - return null; + return await _readCachedEnrolledFace(userId); } } } diff --git a/lib/providers/workforce_provider.dart b/lib/providers/workforce_provider.dart index 99288390..c2cb16ca 100644 --- a/lib/providers/workforce_provider.dart +++ b/lib/providers/workforce_provider.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../brick/cache_helpers.dart'; @@ -12,16 +14,37 @@ import 'supabase_provider.dart'; import 'stream_recovery.dart'; import 'realtime_controller.dart'; +const _kGeofenceCacheKey = 'geofence_cache_v1'; + final geofenceProvider = FutureProvider((ref) async { final client = ref.watch(supabaseClientProvider); - final data = await client - .from('app_settings') - .select() - .eq('key', 'geofence') - .maybeSingle(); - if (data == null) return null; - final setting = AppSetting.fromMap(data); - return GeofenceConfig.fromJson(setting.value); + try { + final data = await client + .from('app_settings') + .select() + .eq('key', 'geofence') + .maybeSingle(); + final prefs = await SharedPreferences.getInstance(); + if (data == null) { + await prefs.setString(_kGeofenceCacheKey, 'null'); + return null; + } + final setting = AppSetting.fromMap(data); + await prefs.setString(_kGeofenceCacheKey, jsonEncode(setting.value)); + return GeofenceConfig.fromJson(setting.value); + } catch (_) { + // Offline / network failure — fall back to cached config so geofence + // validation still runs during offline check-in. + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_kGeofenceCacheKey); + if (raw == null || raw == 'null') return null; + final json = jsonDecode(raw) as Map; + return GeofenceConfig.fromJson(json); + } catch (_) { + return null; + } + } }); /// Toggle to show/hide past schedules. Defaults to false (hide past). diff --git a/lib/screens/announcements/announcements_screen.dart b/lib/screens/announcements/announcements_screen.dart index 5d570477..5ea3e2bc 100644 --- a/lib/screens/announcements/announcements_screen.dart +++ b/lib/screens/announcements/announcements_screen.dart @@ -261,9 +261,23 @@ class _AnnouncementCard extends ConsumerStatefulWidget { class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> { static const _cooldownSeconds = 60; DateTime? _sentAt; + Timer? _cooldownTimer; + + void _startCooldownTimer() { + _cooldownTimer?.cancel(); + _cooldownTimer = Timer.periodic(const Duration(milliseconds: 500), (_) { + if (!mounted) { + _cooldownTimer?.cancel(); + return; + } + setState(() {}); + if (!_inCooldown) _cooldownTimer?.cancel(); + }); + } @override void dispose() { + _cooldownTimer?.cancel(); super.dispose(); } @@ -289,9 +303,15 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> { await ref .read(announcementsControllerProvider) .resendAnnouncementNotification(widget.announcement); - if (mounted) setState(() => _sentAt = DateTime.now()); + if (mounted) { + setState(() => _sentAt = DateTime.now()); + _startCooldownTimer(); + } } on AnnouncementNotificationException { - if (mounted) setState(() => _sentAt = DateTime.now()); + if (mounted) { + setState(() => _sentAt = DateTime.now()); + _startCooldownTimer(); + } } catch (e) { if (mounted) showErrorSnackBar(context, 'Failed to send: $e'); } @@ -326,13 +346,6 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> { final isAnnouncementPending = pendingNew || pendingUpdates.containsKey(widget.announcement.id); - // Rebuild UI when cooldown is active. - if (_inCooldown) { - Future.delayed(const Duration(milliseconds: 500), () { - if (mounted) setState(() {}); - }); - } - final hasBanner = widget.announcement.bannerEnabled; return Padding( @@ -456,7 +469,14 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> { ), Padding( padding: const EdgeInsets.fromLTRB(16, 6, 16, 0), - child: Text(widget.announcement.body, style: tt.bodyMedium), + child: Text( + widget.announcement.body, + style: tt.bodyMedium, + maxLines: widget.isExpanded ? null : 8, + overflow: widget.isExpanded + ? TextOverflow.clip + : TextOverflow.ellipsis, + ), ), // Visible roles chips Padding( diff --git a/lib/screens/attendance/attendance_screen.dart b/lib/screens/attendance/attendance_screen.dart index 972ceff0..7f7e8f64 100644 --- a/lib/screens/attendance/attendance_screen.dart +++ b/lib/screens/attendance/attendance_screen.dart @@ -39,6 +39,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_breakpoints.dart'; import '../../widgets/app_page_header.dart'; import '../../widgets/responsive_body.dart'; import '../../widgets/sync_pending_badge.dart'; @@ -150,65 +151,74 @@ class _AttendanceScreenState extends ConsumerState ); } - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - // Leave option - if (canFileLeave) ...[ + return SafeArea( + bottom: true, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // Leave option + if (canFileLeave) ...[ + _FabMenuItem( + heroTag: 'fab_leave', + label: 'File Leave', + icon: Icons.event_busy, + color: colors.tertiaryContainer, + onColor: colors.onTertiaryContainer, + onTap: () { + setState(() => _fabMenuOpen = false); + _showLeaveDialog(context, isAdmin); + }, + ), + const SizedBox(height: 12), + ], + // Pass Slip option _FabMenuItem( - heroTag: 'fab_leave', - label: 'File Leave', - icon: Icons.event_busy, - color: colors.tertiaryContainer, - onColor: colors.onTertiaryContainer, + heroTag: 'fab_slip', + label: 'Request Slip', + icon: Icons.receipt_long, + color: colors.secondaryContainer, + onColor: colors.onSecondaryContainer, onTap: () { setState(() => _fabMenuOpen = false); - _showLeaveDialog(context, isAdmin); + _showPassSlipDialog(context, profile); }, ), const SizedBox(height: 12), + // Close button + M3Fab( + heroTag: 'fab_close', + onPressed: () => setState(() => _fabMenuOpen = false), + icon: const Icon(Icons.close), + ), ], - // Pass Slip option - _FabMenuItem( - heroTag: 'fab_slip', - label: 'Request Slip', - icon: Icons.receipt_long, - color: colors.secondaryContainer, - onColor: colors.onSecondaryContainer, - onTap: () { - setState(() => _fabMenuOpen = false); - _showPassSlipDialog(context, profile); - }, - ), - const SizedBox(height: 12), - // Close button - M3Fab( - heroTag: 'fab_close', - onPressed: () => setState(() => _fabMenuOpen = false), - icon: const Icon(Icons.close), - ), - ], + ), ); } void _showLeaveDialog(BuildContext context, bool isAdmin) { - m3ShowDialog( - context: context, - builder: (ctx) => _FileLeaveDialog( - isAdmin: isAdmin, - onSubmitted: () { - if (mounted) { - showSuccessSnackBar( - context, - isAdmin - ? 'Leave filed and auto-approved.' - : 'Leave application submitted for approval.', - ); - } - }, - ), - ); + final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet; + void onSubmitted() { + if (mounted) { + showSuccessSnackBar( + context, + isAdmin ? 'Leave filed and auto-approved.' : 'Leave application submitted for approval.', + ); + } + } + if (isMobile) { + m3ShowBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => _FileLeaveDialog(isAdmin: isAdmin, onSubmitted: onSubmitted, isSheet: true), + ); + } else { + m3ShowDialog( + context: context, + builder: (_) => _FileLeaveDialog(isAdmin: isAdmin, onSubmitted: onSubmitted), + ); + } } void _showPassSlipDialog(BuildContext context, Profile profile) { @@ -243,17 +253,24 @@ class _AttendanceScreenState extends ConsumerState return; } - m3ShowDialog( - context: context, - builder: (ctx) => _PassSlipDialog( - scheduleId: todaySchedule.first.id, - onSubmitted: () { - if (mounted) { - showSuccessSnackBar(context, 'Pass slip requested.'); - } - }, - ), - ); + void onSubmitted() { + if (mounted) showSuccessSnackBar(context, 'Pass slip requested.'); + } + + final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet; + if (isMobile) { + m3ShowBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => _PassSlipDialog(scheduleId: todaySchedule.first.id, onSubmitted: onSubmitted, isSheet: true), + ); + } else { + m3ShowDialog( + context: context, + builder: (_) => _PassSlipDialog(scheduleId: todaySchedule.first.id, onSubmitted: onSubmitted), + ); + } } } @@ -2195,17 +2212,21 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> { }) { final groupedByDate = _groupByDate(entries); + final groups = groupedByDate.entries.toList(); return ListView( padding: const EdgeInsets.symmetric(horizontal: 16), - children: groupedByDate.entries.map((group) { - final shiftGroups = _groupByShift(group.value); - return _DateGroupTile( - dateLabel: group.key, - shiftGroups: shiftGroups, - currentUserId: currentUserId, - onReverify: onReverify, - ); - }).toList(), + children: [ + for (int i = 0; i < groups.length; i++) + M3FadeSlideIn( + delay: Duration(milliseconds: i.clamp(0, 6) * 60), + child: _DateGroupTile( + dateLabel: groups[i].key, + shiftGroups: _groupByShift(groups[i].value), + currentUserId: currentUserId, + onReverify: onReverify, + ), + ), + ], ); } @@ -2463,10 +2484,9 @@ class _DateGroupTile extends StatelessWidget { final isMobile = MediaQuery.sizeOf(context).width < 700; if (isMobile) { - showModalBottomSheet( + m3ShowBottomSheet( context: context, isScrollControlled: true, - showDragHandle: true, useSafeArea: true, builder: (ctx) => DraggableScrollableSheet( initialChildSize: 0.9, @@ -3558,7 +3578,9 @@ class _MyScheduleTabState extends ConsumerState<_MyScheduleTab> { s.requesterId == currentUserId && s.status == 'pending'); - return Padding( + return M3FadeSlideIn( + delay: Duration(milliseconds: index.clamp(0, 6) * 60), + child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Card( child: Padding( @@ -3663,6 +3685,7 @@ class _MyScheduleTabState extends ConsumerState<_MyScheduleTab> { ), ), ), + ), ); }, ), @@ -4111,7 +4134,7 @@ class _MyScheduleTabState extends ConsumerState<_MyScheduleTab> { ) { final isMobile = MediaQuery.sizeOf(context).width < 600; if (isMobile) { - showModalBottomSheet( + m3ShowBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, @@ -4904,6 +4927,8 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> { return slipsAsync.when( data: (slips) { + final pendingSlipList = slips.where((s) => s.status == 'pending').toList(); + final historySlipList = slips.where((s) => s.status != 'pending' || !isAdmin).take(50).toList(); return ListView( padding: const EdgeInsets.all(16), children: [ @@ -4997,18 +5022,18 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> { ), ), const SizedBox(height: 8), - ...slips - .where((s) => s.status == 'pending') - .map( - (slip) => _buildSlipCard( - context, - slip, - profileById, - showActions: true, - isPending: pendingSlips.any((p) => p.id == slip.id), - ), + for (int i = 0; i < pendingSlipList.length; i++) + M3FadeSlideIn( + delay: Duration(milliseconds: i.clamp(0, 6) * 60), + child: _buildSlipCard( + context, + pendingSlipList[i], + profileById, + showActions: true, + isPending: pendingSlips.any((p) => p.id == pendingSlipList[i].id), ), - if (slips.where((s) => s.status == 'pending').isEmpty) + ), + if (pendingSlipList.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( @@ -5029,18 +5054,17 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> { ), ), const SizedBox(height: 8), - ...slips - .where((s) => s.status != 'pending' || !isAdmin) - .take(50) - .map( - (slip) => _buildSlipCard( - context, - slip, - profileById, - showActions: false, - isPending: pendingSlips.any((p) => p.id == slip.id), - ), + for (int i = 0; i < historySlipList.length; i++) + M3FadeSlideIn( + delay: Duration(milliseconds: i.clamp(0, 6) * 60), + child: _buildSlipCard( + context, + historySlipList[i], + profileById, + showActions: false, + isPending: pendingSlips.any((p) => p.id == historySlipList[i].id), ), + ), if (slips.isEmpty) Center( child: Padding( @@ -5271,6 +5295,11 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> { .toList() : []; + final myLeavesList = myLeaves.take(50).toList(); + final allLeaveHistory = leaves + .where((l) => l.status != 'pending' && l.userId != profile.id) + .take(50) + .toList(); return ListView( padding: const EdgeInsets.all(16), children: [ @@ -5293,15 +5322,17 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> { ), ), ), - ...pendingApprovals.map( - (leave) => _buildLeaveCard( - context, - leave, - profileById, - showApproval: true, - isPending: pendingLeavesList.any((l) => l.id == leave.id), + for (int i = 0; i < pendingApprovals.length; i++) + M3FadeSlideIn( + delay: Duration(milliseconds: i.clamp(0, 6) * 60), + child: _buildLeaveCard( + context, + pendingApprovals[i], + profileById, + showApproval: true, + isPending: pendingLeavesList.any((l) => l.id == pendingApprovals[i].id), + ), ), - ), const SizedBox(height: 24), ], @@ -5325,17 +5356,17 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> { ), ), ), - ...myLeaves - .take(50) - .map( - (leave) => _buildLeaveCard( - context, - leave, - profileById, - showApproval: false, - isPending: pendingLeavesList.any((l) => l.id == leave.id), - ), + for (int i = 0; i < myLeavesList.length; i++) + M3FadeSlideIn( + delay: Duration(milliseconds: i.clamp(0, 6) * 60), + child: _buildLeaveCard( + context, + myLeavesList[i], + profileById, + showApproval: false, + isPending: pendingLeavesList.any((l) => l.id == myLeavesList[i].id), ), + ), // ── All Leave History (admin only) ── if (isAdmin) ...[ @@ -5347,21 +5378,18 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> { ), ), const SizedBox(height: 8), - ...leaves - .where((l) => l.status != 'pending' && l.userId != profile.id) - .take(50) - .map( - (leave) => _buildLeaveCard( - context, - leave, - profileById, - showApproval: false, - isPending: pendingLeavesList.any((l) => l.id == leave.id), - ), + for (int i = 0; i < allLeaveHistory.length; i++) + M3FadeSlideIn( + delay: Duration(milliseconds: i.clamp(0, 6) * 60), + child: _buildLeaveCard( + context, + allLeaveHistory[i], + profileById, + showApproval: false, + isPending: pendingLeavesList.any((l) => l.id == allLeaveHistory[i].id), ), - if (leaves - .where((l) => l.status != 'pending' && l.userId != profile.id) - .isEmpty) + ), + if (allLeaveHistory.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( @@ -5629,9 +5657,10 @@ class _FabMenuItem extends StatelessWidget { // ─── Pass Slip Dialog (with Gemini) ───────────────────────────── class _PassSlipDialog extends ConsumerStatefulWidget { - const _PassSlipDialog({required this.scheduleId, required this.onSubmitted}); + const _PassSlipDialog({required this.scheduleId, required this.onSubmitted, this.isSheet = false}); final String scheduleId; final VoidCallback onSubmitted; + final bool isSheet; @override ConsumerState<_PassSlipDialog> createState() => _PassSlipDialogState(); @@ -5695,121 +5724,127 @@ class _PassSlipDialogState extends ConsumerState<_PassSlipDialog> { Widget build(BuildContext context) { final theme = Theme.of(context); + final content = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Request Pass Slip', style: theme.textTheme.headlineSmall), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: GeminiAnimatedTextField( + controller: _reasonController, + labelText: 'Reason', + maxLines: 3, + enabled: !_submitting, + isProcessing: _isGeminiProcessing, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: GeminiButton( + textController: _reasonController, + onTextUpdated: (text) { + setState(() => _reasonController.text = text); + }, + onProcessingStateChanged: (processing) { + setState(() => _isGeminiProcessing = processing); + }, + tooltip: 'Translate/Enhance with AI', + promptBuilder: (_) => + 'Translate this sentence to clear professional English ' + 'if needed, and enhance grammar/clarity while preserving ' + 'the original meaning. Return ONLY the improved text, ' + 'with no explanations, no recommendations, and no extra context.', + ), + ), + ], + ), + const SizedBox(height: 16), + InkWell( + borderRadius: BorderRadius.circular(12), + onTap: _submitting + ? null + : () async { + final picked = await showTimePicker( + context: context, + initialTime: _requestedStartTime ?? TimeOfDay.now(), + ); + if (picked != null && mounted) { + setState(() => _requestedStartTime = picked); + } + }, + child: InputDecorator( + decoration: InputDecoration( + labelText: 'Preferred start time (optional)', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + prefixIcon: const Icon(Icons.schedule), + suffixIcon: _requestedStartTime != null + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: _submitting + ? null + : () => setState(() => _requestedStartTime = null), + tooltip: 'Clear', + ) + : null, + ), + child: Text( + _requestedStartTime != null + ? _requestedStartTime!.format(context) + : 'Immediately upon approval', + style: theme.textTheme.bodyLarge?.copyWith( + color: _requestedStartTime != null + ? null + : theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _submitting ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _submitting ? null : _submit, + child: _submitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Submit'), + ), + ], + ), + ], + ); + + if (widget.isSheet) { + return Padding( + padding: EdgeInsets.only( + left: 24, + right: 24, + top: 8, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: SingleChildScrollView(child: content), + ); + } + return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Request Pass Slip', style: theme.textTheme.headlineSmall), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: GeminiAnimatedTextField( - controller: _reasonController, - labelText: 'Reason', - maxLines: 3, - enabled: !_submitting, - isProcessing: _isGeminiProcessing, - ), - ), - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: GeminiButton( - textController: _reasonController, - onTextUpdated: (text) { - setState(() => _reasonController.text = text); - }, - onProcessingStateChanged: (processing) { - setState(() => _isGeminiProcessing = processing); - }, - tooltip: 'Translate/Enhance with AI', - promptBuilder: (_) => - 'Translate this sentence to clear professional English ' - 'if needed, and enhance grammar/clarity while preserving ' - 'the original meaning. Return ONLY the improved text, ' - 'with no explanations, no recommendations, and no extra context.', - ), - ), - ], - ), - const SizedBox(height: 16), - // Optional start time picker - InkWell( - borderRadius: BorderRadius.circular(12), - onTap: _submitting - ? null - : () async { - final picked = await showTimePicker( - context: context, - initialTime: - _requestedStartTime ?? TimeOfDay.now(), - ); - if (picked != null && mounted) { - setState(() => _requestedStartTime = picked); - } - }, - child: InputDecorator( - decoration: InputDecoration( - labelText: 'Preferred start time (optional)', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - prefixIcon: const Icon(Icons.schedule), - suffixIcon: _requestedStartTime != null - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: _submitting - ? null - : () => setState( - () => _requestedStartTime = null), - tooltip: 'Clear', - ) - : null, - ), - child: Text( - _requestedStartTime != null - ? _requestedStartTime!.format(context) - : 'Immediately upon approval', - style: theme.textTheme.bodyLarge?.copyWith( - color: _requestedStartTime != null - ? null - : theme.colorScheme.onSurfaceVariant, - ), - ), - ), - ), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: _submitting - ? null - : () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const SizedBox(width: 8), - FilledButton( - onPressed: _submitting ? null : _submit, - child: _submitting - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Submit'), - ), - ], - ), - ], - ), - ), + child: Padding(padding: const EdgeInsets.all(24), child: content), ), ); } @@ -5817,9 +5852,10 @@ class _PassSlipDialogState extends ConsumerState<_PassSlipDialog> { // ─── File Leave Dialog ────────────────────────────────────────── class _FileLeaveDialog extends ConsumerStatefulWidget { - const _FileLeaveDialog({required this.isAdmin, required this.onSubmitted}); + const _FileLeaveDialog({required this.isAdmin, required this.onSubmitted, this.isSheet = false}); final bool isAdmin; final VoidCallback onSubmitted; + final bool isSheet; @override ConsumerState<_FileLeaveDialog> createState() => _FileLeaveDialogState(); @@ -5978,165 +6014,153 @@ class _FileLeaveDialogState extends ConsumerState<_FileLeaveDialog> { final theme = Theme.of(context); final colors = theme.colorScheme; + final content = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('File Leave of Absence', style: theme.textTheme.headlineSmall), + const SizedBox(height: 16), + + // Leave type + DropdownButtonFormField( + // ignore: deprecated_member_use + value: _leaveType, + decoration: const InputDecoration(labelText: 'Leave Type'), + items: _leaveTypes.entries + .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: (v) { + if (v != null) setState(() => _leaveType = v); + }, + ), + const SizedBox(height: 12), + + // Date picker + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.calendar_today), + title: Text( + _startDate == null ? 'Select Date' : AppTime.formatDate(_startDate!), + ), + subtitle: const Text('Current or future dates only'), + onTap: _pickDate, + ), + + // Time range + Row( + children: [ + Expanded( + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.access_time), + title: Text(_startTime == null ? 'Start Time' : _startTime!.format(context)), + onTap: _pickStartTime, + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Icon(Icons.arrow_forward), + ), + Expanded( + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.access_time), + title: Text(_endTime == null ? 'End Time' : _endTime!.format(context)), + subtitle: const Text('From shift schedule'), + onTap: _pickEndTime, + ), + ), + ], + ), + const SizedBox(height: 12), + + // Justification with AI + Row( + children: [ + Expanded( + child: GeminiAnimatedTextField( + controller: _justificationController, + labelText: 'Justification', + maxLines: 3, + enabled: !_submitting, + isProcessing: _isGeminiProcessing, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: GeminiButton( + textController: _justificationController, + onTextUpdated: (text) { + setState(() => _justificationController.text = text); + }, + onProcessingStateChanged: (processing) { + setState(() => _isGeminiProcessing = processing); + }, + tooltip: 'Translate/Enhance with AI', + promptBuilder: (_) => + 'Translate this sentence to clear professional English ' + 'if needed, and enhance grammar/clarity while preserving ' + 'the original meaning. Return ONLY the improved text, ' + 'with no explanations, no recommendations, and no extra context.', + ), + ), + ], + ), + const SizedBox(height: 16), + + if (widget.isAdmin) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + 'As admin, your leave will be auto-approved.', + style: theme.textTheme.bodySmall?.copyWith(color: colors.primary), + ), + ), + + // Actions + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _submitting ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _submitting ? null : _submit, + icon: _submitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.event_busy), + label: const Text('File Leave'), + ), + ], + ), + ], + ); + + if (widget.isSheet) { + return Padding( + padding: EdgeInsets.only( + left: 24, + right: 24, + top: 8, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: SingleChildScrollView(child: content), + ); + } + return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Padding( padding: const EdgeInsets.all(24), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'File Leave of Absence', - style: theme.textTheme.headlineSmall, - ), - const SizedBox(height: 16), - - // Leave type - DropdownButtonFormField( - // ignore: deprecated_member_use - value: _leaveType, - decoration: const InputDecoration(labelText: 'Leave Type'), - items: _leaveTypes.entries - .map( - (e) => DropdownMenuItem( - value: e.key, - child: Text(e.value), - ), - ) - .toList(), - onChanged: (v) { - if (v != null) setState(() => _leaveType = v); - }, - ), - const SizedBox(height: 12), - - // Date picker - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.calendar_today), - title: Text( - _startDate == null - ? 'Select Date' - : AppTime.formatDate(_startDate!), - ), - subtitle: const Text('Current or future dates only'), - onTap: _pickDate, - ), - - // Time range - Row( - children: [ - Expanded( - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.access_time), - title: Text( - _startTime == null - ? 'Start Time' - : _startTime!.format(context), - ), - onTap: _pickStartTime, - ), - ), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 8), - child: Icon(Icons.arrow_forward), - ), - Expanded( - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.access_time), - title: Text( - _endTime == null - ? 'End Time' - : _endTime!.format(context), - ), - subtitle: const Text('From shift schedule'), - onTap: _pickEndTime, - ), - ), - ], - ), - const SizedBox(height: 12), - - // Justification with AI - Row( - children: [ - Expanded( - child: GeminiAnimatedTextField( - controller: _justificationController, - labelText: 'Justification', - maxLines: 3, - enabled: !_submitting, - isProcessing: _isGeminiProcessing, - ), - ), - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: GeminiButton( - textController: _justificationController, - onTextUpdated: (text) { - setState(() { - _justificationController.text = text; - }); - }, - onProcessingStateChanged: (processing) { - setState(() => _isGeminiProcessing = processing); - }, - tooltip: 'Translate/Enhance with AI', - promptBuilder: (_) => - 'Translate this sentence to clear professional English ' - 'if needed, and enhance grammar/clarity while preserving ' - 'the original meaning. Return ONLY the improved text, ' - 'with no explanations, no recommendations, and no extra context.', - ), - ), - ], - ), - const SizedBox(height: 16), - - if (widget.isAdmin) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - 'As admin, your leave will be auto-approved.', - style: theme.textTheme.bodySmall?.copyWith( - color: colors.primary, - ), - ), - ), - - // Actions - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: _submitting - ? null - : () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const SizedBox(width: 8), - FilledButton.icon( - onPressed: _submitting ? null : _submit, - icon: _submitting - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.event_busy), - label: const Text('File Leave'), - ), - ], - ), - ], - ), - ), + child: SingleChildScrollView(child: content), ), ), ); diff --git a/lib/screens/dashboard/dashboard_screen.dart b/lib/screens/dashboard/dashboard_screen.dart index 6fda110a..5af60d11 100644 --- a/lib/screens/dashboard/dashboard_screen.dart +++ b/lib/screens/dashboard/dashboard_screen.dart @@ -740,7 +740,11 @@ class _DashboardScreenState extends State { subtitle: 'Live metrics and team activity', ), const _DashboardStatusBanner(), - ...sections, + for (int i = 0; i < sections.length; i++) + M3FadeSlideIn( + delay: Duration(milliseconds: i.clamp(0, 5) * 40), + child: sections[i], + ), ], ); diff --git a/lib/screens/it_service_requests/it_service_request_detail_screen.dart b/lib/screens/it_service_requests/it_service_request_detail_screen.dart index 3a3d7670..d1d83a8e 100644 --- a/lib/screens/it_service_requests/it_service_request_detail_screen.dart +++ b/lib/screens/it_service_requests/it_service_request_detail_screen.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:async'; import 'dart:typed_data'; +import '../../theme/m3_motion.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:file_picker/file_picker.dart'; @@ -458,10 +459,15 @@ class _ItServiceRequestDetailScreenState ), body: Skeletonizer( enabled: showSkeleton, - child: request == null && !showSkeleton - ? const Center(child: Text('Request not found')) - : ResponsiveBody( - maxWidth: double.infinity, + child: M3AnimatedSwitcher( + child: request == null && !showSkeleton + ? const Center( + key: ValueKey('isr_not_found'), + child: Text('Request not found'), + ) + : ResponsiveBody( + key: const ValueKey('isr_content'), + maxWidth: double.infinity, child: Column( children: [ AnimatedSwitcher( @@ -516,6 +522,7 @@ class _ItServiceRequestDetailScreenState ], ), ), + ), ), ); } diff --git a/lib/screens/reports/reports_screen.dart b/lib/screens/reports/reports_screen.dart index b41f1f69..48f5abf7 100644 --- a/lib/screens/reports/reports_screen.dart +++ b/lib/screens/reports/reports_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../theme/m3_motion.dart'; import '../../providers/reports_provider.dart'; import '../../widgets/app_page_header.dart'; import 'report_date_filter.dart'; @@ -162,138 +163,129 @@ class _ReportsScreenState extends ConsumerState { builder: (context, constraints) { final isWide = constraints.maxWidth >= 600; + final chartSections = [ + if (_anyEnabled(enabled, [ + ReportWidgetType.ticketsByStatus, + ReportWidgetType.tasksByStatus, + ])) + _responsiveRow( + isWide: isWide, + children: [ + if (enabled.contains(ReportWidgetType.ticketsByStatus)) + TicketsByStatusChart( + repaintKey: _keys[ReportWidgetType.ticketsByStatus], + ), + if (enabled.contains(ReportWidgetType.tasksByStatus)) + TasksByStatusChart( + repaintKey: _keys[ReportWidgetType.tasksByStatus], + ), + ], + ), + if (enabled.contains(ReportWidgetType.conversionRate)) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: ConversionRateCard( + repaintKey: _keys[ReportWidgetType.conversionRate], + ), + ), + if (_anyEnabled(enabled, [ + ReportWidgetType.requestType, + ReportWidgetType.requestCategory, + ])) + _responsiveRow( + isWide: isWide, + children: [ + if (enabled.contains(ReportWidgetType.requestType)) + RequestTypeChart( + repaintKey: _keys[ReportWidgetType.requestType], + ), + if (enabled.contains(ReportWidgetType.requestCategory)) + RequestCategoryChart( + repaintKey: _keys[ReportWidgetType.requestCategory], + ), + ], + ), + if (enabled.contains(ReportWidgetType.monthlyOverview)) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: MonthlyOverviewChart( + repaintKey: _keys[ReportWidgetType.monthlyOverview], + ), + ), + if (_anyEnabled(enabled, [ + ReportWidgetType.tasksByHour, + ReportWidgetType.ticketsByHour, + ])) + _responsiveRow( + isWide: isWide, + children: [ + if (enabled.contains(ReportWidgetType.tasksByHour)) + TasksByHourChart( + repaintKey: _keys[ReportWidgetType.tasksByHour], + ), + if (enabled.contains(ReportWidgetType.ticketsByHour)) + TicketsByHourChart( + repaintKey: _keys[ReportWidgetType.ticketsByHour], + ), + ], + ), + if (_anyEnabled(enabled, [ + ReportWidgetType.topOfficesTickets, + ReportWidgetType.topTicketSubjects, + ])) + _responsiveRow( + isWide: isWide, + children: [ + if (enabled.contains(ReportWidgetType.topOfficesTickets)) + TopOfficesTicketsChart( + repaintKey: _keys[ReportWidgetType.topOfficesTickets], + ), + if (enabled.contains(ReportWidgetType.topTicketSubjects)) + TopTicketSubjectsChart( + repaintKey: _keys[ReportWidgetType.topTicketSubjects], + ), + ], + ), + if (_anyEnabled(enabled, [ + ReportWidgetType.topOfficesTasks, + ReportWidgetType.topTaskSubjects, + ])) + _responsiveRow( + isWide: isWide, + children: [ + if (enabled.contains(ReportWidgetType.topOfficesTasks)) + TopOfficesTasksChart( + repaintKey: _keys[ReportWidgetType.topOfficesTasks], + ), + if (enabled.contains(ReportWidgetType.topTaskSubjects)) + TopTaskSubjectsChart( + repaintKey: _keys[ReportWidgetType.topTaskSubjects], + ), + ], + ), + if (enabled.contains(ReportWidgetType.avgResolution)) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: AvgResolutionChart( + repaintKey: _keys[ReportWidgetType.avgResolution], + ), + ), + if (enabled.contains(ReportWidgetType.staffWorkload)) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: StaffWorkloadChart( + repaintKey: _keys[ReportWidgetType.staffWorkload], + ), + ), + ]; + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // ── Status donuts (paired on desktop) ── - if (_anyEnabled(enabled, [ - ReportWidgetType.ticketsByStatus, - ReportWidgetType.tasksByStatus, - ])) - _responsiveRow( - isWide: isWide, - children: [ - if (enabled.contains(ReportWidgetType.ticketsByStatus)) - TicketsByStatusChart( - repaintKey: _keys[ReportWidgetType.ticketsByStatus], - ), - if (enabled.contains(ReportWidgetType.tasksByStatus)) - TasksByStatusChart( - repaintKey: _keys[ReportWidgetType.tasksByStatus], - ), - ], - ), - - // ── Conversion rate KPI ── - if (enabled.contains(ReportWidgetType.conversionRate)) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: ConversionRateCard( - repaintKey: _keys[ReportWidgetType.conversionRate], - ), - ), - - // ── Request type + category donuts ── - if (_anyEnabled(enabled, [ - ReportWidgetType.requestType, - ReportWidgetType.requestCategory, - ])) - _responsiveRow( - isWide: isWide, - children: [ - if (enabled.contains(ReportWidgetType.requestType)) - RequestTypeChart( - repaintKey: _keys[ReportWidgetType.requestType], - ), - if (enabled.contains(ReportWidgetType.requestCategory)) - RequestCategoryChart( - repaintKey: _keys[ReportWidgetType.requestCategory], - ), - ], - ), - - // ── Monthly overview (full width) ── - if (enabled.contains(ReportWidgetType.monthlyOverview)) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: MonthlyOverviewChart( - repaintKey: _keys[ReportWidgetType.monthlyOverview], - ), - ), - - // ── Hourly charts (paired) ── - if (_anyEnabled(enabled, [ - ReportWidgetType.tasksByHour, - ReportWidgetType.ticketsByHour, - ])) - _responsiveRow( - isWide: isWide, - children: [ - if (enabled.contains(ReportWidgetType.tasksByHour)) - TasksByHourChart( - repaintKey: _keys[ReportWidgetType.tasksByHour], - ), - if (enabled.contains(ReportWidgetType.ticketsByHour)) - TicketsByHourChart( - repaintKey: _keys[ReportWidgetType.ticketsByHour], - ), - ], - ), - - // ── Top offices + subjects for Tickets (paired) ── - if (_anyEnabled(enabled, [ - ReportWidgetType.topOfficesTickets, - ReportWidgetType.topTicketSubjects, - ])) - _responsiveRow( - isWide: isWide, - children: [ - if (enabled.contains(ReportWidgetType.topOfficesTickets)) - TopOfficesTicketsChart( - repaintKey: _keys[ReportWidgetType.topOfficesTickets], - ), - if (enabled.contains(ReportWidgetType.topTicketSubjects)) - TopTicketSubjectsChart( - repaintKey: _keys[ReportWidgetType.topTicketSubjects], - ), - ], - ), - - // ── Top offices + subjects for Tasks (paired) ── - if (_anyEnabled(enabled, [ - ReportWidgetType.topOfficesTasks, - ReportWidgetType.topTaskSubjects, - ])) - _responsiveRow( - isWide: isWide, - children: [ - if (enabled.contains(ReportWidgetType.topOfficesTasks)) - TopOfficesTasksChart( - repaintKey: _keys[ReportWidgetType.topOfficesTasks], - ), - if (enabled.contains(ReportWidgetType.topTaskSubjects)) - TopTaskSubjectsChart( - repaintKey: _keys[ReportWidgetType.topTaskSubjects], - ), - ], - ), - - // ── Avg resolution (full width) ── - if (enabled.contains(ReportWidgetType.avgResolution)) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: AvgResolutionChart( - repaintKey: _keys[ReportWidgetType.avgResolution], - ), - ), - - // ── Staff workload (full width) ── - if (enabled.contains(ReportWidgetType.staffWorkload)) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: StaffWorkloadChart( - repaintKey: _keys[ReportWidgetType.staffWorkload], - ), + for (int i = 0; i < chartSections.length; i++) + M3FadeSlideIn( + delay: Duration(milliseconds: i.clamp(0, 4) * 80), + child: chartSections[i], ), ], ); diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index c2f5d1bf..f7a3b0e4 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -28,6 +28,7 @@ import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/typing_dots.dart'; import '../../theme/app_surfaces.dart'; import '../../utils/snackbar.dart'; +import '../../widgets/app_breakpoints.dart'; import '../../widgets/app_page_header.dart'; import '../../widgets/app_state_view.dart'; import '../../utils/subject_suggestions.dart'; @@ -324,106 +325,114 @@ class _TasksListScreenState extends ConsumerState latestAssigneeByTaskId: latestAssigneeByTaskId, ); - final filterHeader = Wrap( - spacing: 12, - runSpacing: 12, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - SizedBox( - width: 220, - child: TextField( - controller: _subjectController, - onChanged: (_) => setState(() {}), - decoration: const InputDecoration( - labelText: 'Subject', - prefixIcon: Icon(Icons.search), - ), - ), - ), - SizedBox( - width: 200, - child: DropdownButtonFormField( - isExpanded: true, - key: ValueKey(_selectedOfficeId), - initialValue: _selectedOfficeId, - items: officeOptions, - onChanged: (value) => - setState(() => _selectedOfficeId = value), - decoration: const InputDecoration(labelText: 'Office'), - ), - ), - SizedBox( - width: 160, - child: TextField( - controller: _taskNumberController, - onChanged: (_) => setState(() {}), - decoration: const InputDecoration( - labelText: 'Task #', - prefixIcon: Icon(Icons.filter_alt), - ), - ), - ), - if (_tabController.index == 1) - SizedBox( - width: 220, - child: DropdownButtonFormField( - isExpanded: true, - key: ValueKey(_selectedAssigneeId), - initialValue: _selectedAssigneeId, - items: staffOptions, - onChanged: (value) => - setState(() => _selectedAssigneeId = value), - decoration: const InputDecoration( - labelText: 'Assigned staff', + final filterHeader = LayoutBuilder( + builder: (context, constraints) { + final isMobile = constraints.maxWidth < 600; + final halfWidth = (constraints.maxWidth - 12) / 2; + return Wrap( + spacing: 12, + runSpacing: 12, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + SizedBox( + width: isMobile ? constraints.maxWidth : 220, + child: TextField( + controller: _subjectController, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'Subject', + prefixIcon: Icon(Icons.search), + ), ), ), - ), - SizedBox( - width: 180, - child: DropdownButtonFormField( - isExpanded: true, - key: ValueKey(_selectedStatus), - initialValue: _selectedStatus, - items: statusOptions, - onChanged: (value) => - setState(() => _selectedStatus = value), - decoration: const InputDecoration(labelText: 'Status'), - ), - ), - OutlinedButton.icon( - onPressed: () async { - final next = await showDateRangePicker( - context: context, - firstDate: DateTime(2020), - lastDate: AppTime.now().add( - const Duration(days: 365), + SizedBox( + width: isMobile ? halfWidth : 200, + child: DropdownButtonFormField( + isExpanded: true, + key: ValueKey(_selectedOfficeId), + initialValue: _selectedOfficeId, + items: officeOptions, + onChanged: (value) => + setState(() => _selectedOfficeId = value), + decoration: + const InputDecoration(labelText: 'Office'), ), - currentDate: AppTime.now(), - initialDateRange: _selectedDateRange, - ); - if (!mounted) return; - setState(() => _selectedDateRange = next); - }, - icon: const Icon(Icons.date_range), - label: Text( - _selectedDateRange == null - ? 'Date range' - : AppTime.formatDateRange(_selectedDateRange!), - ), - ), - if (_hasTaskFilters) - TextButton.icon( - onPressed: () => setState(() { - _subjectController.clear(); - _selectedOfficeId = null; - _selectedStatus = null; - _selectedAssigneeId = null; - _selectedDateRange = null; - }), - icon: const Icon(Icons.close), - label: const Text('Clear'), - ), - ], + ), + SizedBox( + width: isMobile ? halfWidth : 160, + child: TextField( + controller: _taskNumberController, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'Task #', + prefixIcon: Icon(Icons.filter_alt), + ), + ), + ), + if (_tabController.index == 1) + SizedBox( + width: isMobile ? halfWidth : 220, + child: DropdownButtonFormField( + isExpanded: true, + key: ValueKey(_selectedAssigneeId), + initialValue: _selectedAssigneeId, + items: staffOptions, + onChanged: (value) => + setState(() => _selectedAssigneeId = value), + decoration: const InputDecoration( + labelText: 'Assigned staff', + ), + ), + ), + SizedBox( + width: isMobile ? halfWidth : 180, + child: DropdownButtonFormField( + isExpanded: true, + key: ValueKey(_selectedStatus), + initialValue: _selectedStatus, + items: statusOptions, + onChanged: (value) => + setState(() => _selectedStatus = value), + decoration: + const InputDecoration(labelText: 'Status'), + ), + ), + OutlinedButton.icon( + onPressed: () async { + final next = await showDateRangePicker( + context: context, + firstDate: DateTime(2020), + lastDate: AppTime.now().add( + const Duration(days: 365), + ), + currentDate: AppTime.now(), + initialDateRange: _selectedDateRange, + ); + if (!mounted) return; + setState(() => _selectedDateRange = next); + }, + icon: const Icon(Icons.date_range), + label: Text( + _selectedDateRange == null + ? 'Date range' + : AppTime.formatDateRange(_selectedDateRange!), + ), + ), + if (_hasTaskFilters) + TextButton.icon( + onPressed: () => setState(() { + _subjectController.clear(); + _selectedOfficeId = null; + _selectedStatus = null; + _selectedAssigneeId = null; + _selectedDateRange = null; + }), + icon: const Icon(Icons.close), + label: const Text('Clear'), + ), + ], + ); + }, ); // reusable helper for rendering a list given a subset of tasks @@ -803,25 +812,22 @@ class _TasksListScreenState extends ConsumerState var showTitleGemini = false; Timer? titleTypingTimer; - await m3ShowDialog( - context: context, - builder: (dialogContext) { - bool saving = false; - bool titleProcessing = false; - bool descProcessing = false; - bool titleDeepSeek = false; - bool descDeepSeek = false; - final officesAsync = ref.watch(officesProvider); - return StatefulBuilder( - builder: (context, setState) { - return AlertDialog( - shape: AppSurfaces.of(context).dialogShape, - title: const Text('Create Task'), - content: SizedBox( - width: 360, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ + final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet; + + // Lifted so both mobile/desktop branches share one set of form state vars. + var saving = false; + var titleProcessing = false; + var descProcessing = false; + var titleDeepSeek = false; + var descDeepSeek = false; + + Column buildFormColumn( + StateSetter setState, + AsyncValue> officesAsync, + ) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ Row( children: [ Expanded( @@ -1038,77 +1044,126 @@ class _TasksListScreenState extends ConsumerState error: (error, _) => Text('Failed to load offices: $error'), ), + ], + ); + } + + List buildActions(BuildContext closeCtx, StateSetter setState) { + return [ + TextButton( + onPressed: saving ? null : () => Navigator.of(closeCtx).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: saving + ? null + : () async { + final title = SubjectSuggestionEngine.normalizeDisplay( + titleController.text.trim(), + ); + final description = descriptionController.text.trim(); + final officeId = selectedOfficeId; + if (title.isEmpty || officeId == null) return; + setState(() => saving = true); + try { + await ref.read(tasksControllerProvider).createTask( + title: title, + description: description, + officeId: officeId, + requestType: selectedRequestType, + requestTypeOther: requestTypeOther, + requestCategory: selectedRequestCategory, + ); + if (closeCtx.mounted) { + Navigator.of(closeCtx).pop(); + showSuccessSnackBarGlobal( + 'Task "$title" has been created successfully.', + ); + } + } catch (e) { + if (!closeCtx.mounted) return; + if (isOfflineSaveError(e)) { + Navigator.of(closeCtx).pop(); + showSuccessSnackBarGlobal( + 'Task "$title" saved offline — will sync when connected.', + ); + } else { + showErrorSnackBarGlobal('Failed to create task: $e'); + } + } finally { + if (closeCtx.mounted) setState(() => saving = false); + } + }, + child: saving + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Create'), + ), + ]; + } + + if (isMobile) { + await m3ShowBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (ctx) { + final officesAsync = ref.watch(officesProvider); + return StatefulBuilder( + builder: (context, setState) => Padding( + padding: EdgeInsets.only( + left: 24, + right: 24, + top: 8, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Create Task', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + buildFormColumn(setState, officesAsync), + const SizedBox(height: 16), + OverflowBar( + alignment: MainAxisAlignment.end, + children: buildActions(ctx, setState), + ), + const SizedBox(height: 8), ], ), ), - actions: [ - TextButton( - onPressed: saving - ? null - : () => Navigator.of(dialogContext).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: saving - ? null - : () async { - final title = - SubjectSuggestionEngine.normalizeDisplay( - titleController.text.trim(), - ); - final description = descriptionController.text.trim(); - final officeId = selectedOfficeId; - if (title.isEmpty || officeId == null) { - return; - } - setState(() => saving = true); - try { - await ref - .read(tasksControllerProvider) - .createTask( - title: title, - description: description, - officeId: officeId, - requestType: selectedRequestType, - requestTypeOther: requestTypeOther, - requestCategory: selectedRequestCategory, - ); - if (context.mounted) { - Navigator.of(dialogContext).pop(); - showSuccessSnackBarGlobal( - 'Task "$title" has been created successfully.', - ); - } - } catch (e) { - if (!dialogContext.mounted) return; - if (isOfflineSaveError(e)) { - Navigator.of(dialogContext).pop(); - showSuccessSnackBarGlobal( - 'Task "$title" saved offline — will sync when connected.', - ); - } else { - showErrorSnackBarGlobal('Failed to create task: $e'); - } - } finally { - if (dialogContext.mounted) { - setState(() => saving = false); - } - } - }, - child: saving - ? const SizedBox( - height: 18, - width: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Create'), - ), - ], - ); - }, - ); - }, - ); + ), + ); + }, + ); + } else { + await m3ShowDialog( + context: context, + builder: (dialogContext) { + final officesAsync = ref.watch(officesProvider); + return StatefulBuilder( + builder: (context, setState) => AlertDialog( + scrollable: true, + shape: AppSurfaces.of(context).dialogShape, + title: const Text('Create Task'), + content: SizedBox( + width: 600, + child: buildFormColumn(setState, officesAsync), + ), + actions: buildActions(dialogContext, setState), + ), + ); + }, + ); + } } bool _hasTaskMention( diff --git a/lib/screens/tickets/tickets_list_screen.dart b/lib/screens/tickets/tickets_list_screen.dart index 7f0cbbb8..ce7b25ec 100644 --- a/lib/screens/tickets/tickets_list_screen.dart +++ b/lib/screens/tickets/tickets_list_screen.dart @@ -21,6 +21,7 @@ import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/typing_dots.dart'; import '../../theme/app_surfaces.dart'; import '../../utils/snackbar.dart'; +import '../../widgets/app_breakpoints.dart'; import '../../widgets/app_page_header.dart'; import '../../widgets/app_state_view.dart'; import '../../widgets/sync_pending_badge.dart'; @@ -441,152 +442,163 @@ class _TicketsListScreenState extends ConsumerState { final descriptionController = TextEditingController(); Office? selectedOffice; - await m3ShowDialog( - context: context, - builder: (dialogContext) { - bool saving = false; - return StatefulBuilder( - builder: (context, setState) { - return AlertDialog( - shape: AppSurfaces.of(context).dialogShape, - title: const Text('Create Ticket'), - content: Consumer( - builder: (context, ref, child) { - final officesAsync = ref.watch(officesProvider); - return SizedBox( - width: 360, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: subjectController, - decoration: const InputDecoration( - labelText: 'Subject', - ), - enabled: !saving, - ), - const SizedBox(height: 12), - TextField( - controller: descriptionController, - decoration: const InputDecoration( - labelText: 'Description', - ), - maxLines: 3, - enabled: !saving, - ), - const SizedBox(height: 12), - officesAsync.when( - data: (offices) { - if (offices.isEmpty) { - return const Text('No offices assigned.'); - } - final officesSorted = List.from(offices) - ..sort( - (a, b) => a.name.toLowerCase().compareTo( - b.name.toLowerCase(), - ), - ); - selectedOffice ??= officesSorted.first; - return DropdownButtonFormField( - key: ValueKey(selectedOffice?.id), - initialValue: selectedOffice, - items: officesSorted - .map( - (office) => DropdownMenuItem( - value: office, - child: Text(office.name), - ), - ) - .toList(), - onChanged: saving - ? null - : (value) => - setState(() => selectedOffice = value), - decoration: const InputDecoration( - labelText: 'Office', - ), - ); - }, - loading: () => const LinearProgressIndicator(), - error: (error, _) => - Text('Failed to load offices: $error'), - ), - ], - ), + final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet; + var saving = false; + + Widget buildFormContent(StateSetter setState) { + return Consumer( + builder: (context, ref, _) { + final officesAsync = ref.watch(officesProvider); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: subjectController, + decoration: const InputDecoration(labelText: 'Subject'), + enabled: !saving, + ), + const SizedBox(height: 12), + TextField( + controller: descriptionController, + decoration: const InputDecoration(labelText: 'Description'), + maxLines: 3, + enabled: !saving, + ), + const SizedBox(height: 12), + officesAsync.when( + data: (offices) { + if (offices.isEmpty) return const Text('No offices assigned.'); + final officesSorted = List.from(offices) + ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + selectedOffice ??= officesSorted.first; + return DropdownButtonFormField( + key: ValueKey(selectedOffice?.id), + initialValue: selectedOffice, + items: officesSorted + .map((o) => DropdownMenuItem(value: o, child: Text(o.name))) + .toList(), + onChanged: saving ? null : (v) => setState(() => selectedOffice = v), + decoration: const InputDecoration(labelText: 'Office'), ); }, + loading: () => const LinearProgressIndicator(), + error: (e, _) => Text('Failed to load offices: $e'), ), - actions: [ - TextButton( - onPressed: saving - ? null - : () => Navigator.of(dialogContext).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: saving - ? null - : () async { - final subject = subjectController.text.trim(); - final description = descriptionController.text.trim(); - if (subject.isEmpty || - description.isEmpty || - selectedOffice == null) { - showWarningSnackBar( - context, - 'Fill out all fields.', - ); - return; - } - setState(() => saving = true); - try { - final pendingTicket = await ref - .read(ticketsControllerProvider) - .createTicket( - subject: subject, - description: description, - officeId: selectedOffice!.id, - ); - if (pendingTicket != null) { - // Offline: show immediately in the list until sync. - final current = ref.read(offlinePendingTicketsProvider); - ref.read(offlinePendingTicketsProvider.notifier).state = [ - pendingTicket, - ...current, - ]; - } - if (context.mounted) { - Navigator.of(dialogContext).pop(); - showSuccessSnackBar( - context, - pendingTicket != null - ? 'Ticket "$subject" saved offline — will sync when connected.' - : 'Ticket "$subject" has been created successfully.', - ); - } - } catch (e) { - if (!dialogContext.mounted) return; - showErrorSnackBarGlobal('Failed to create ticket: $e'); - } finally { - if (dialogContext.mounted) { - setState(() => saving = false); - } - } - }, - child: saving - ? const SizedBox( - height: 18, - width: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Create'), - ), - ], - ); - }, - ); - }, - ); + ], + ); + }, + ); + } + + List buildActions(BuildContext closeCtx, StateSetter setState) { + return [ + TextButton( + onPressed: saving ? null : () => Navigator.of(closeCtx).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: saving + ? null + : () async { + final subject = subjectController.text.trim(); + final description = descriptionController.text.trim(); + if (subject.isEmpty || description.isEmpty || selectedOffice == null) { + showWarningSnackBar(closeCtx, 'Fill out all fields.'); + return; + } + setState(() => saving = true); + try { + final pendingTicket = await ref + .read(ticketsControllerProvider) + .createTicket( + subject: subject, + description: description, + officeId: selectedOffice!.id, + ); + if (pendingTicket != null) { + final current = ref.read(offlinePendingTicketsProvider); + ref.read(offlinePendingTicketsProvider.notifier).state = [ + pendingTicket, + ...current, + ]; + } + if (closeCtx.mounted) { + Navigator.of(closeCtx).pop(); + showSuccessSnackBar( + closeCtx, + pendingTicket != null + ? 'Ticket "$subject" saved offline — will sync when connected.' + : 'Ticket "$subject" has been created successfully.', + ); + } + } catch (e) { + if (!closeCtx.mounted) return; + showErrorSnackBarGlobal('Failed to create ticket: $e'); + } finally { + if (closeCtx.mounted) setState(() => saving = false); + } + }, + child: saving + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Create'), + ), + ]; + } + + if (isMobile) { + await m3ShowBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (ctx) => StatefulBuilder( + builder: (context, setState) => Padding( + padding: EdgeInsets.only( + left: 24, + right: 24, + top: 8, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Create Ticket', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 16), + buildFormContent(setState), + const SizedBox(height: 16), + OverflowBar( + alignment: MainAxisAlignment.end, + children: buildActions(ctx, setState), + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ), + ); + } else { + await m3ShowDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + shape: AppSurfaces.of(context).dialogShape, + title: const Text('Create Ticket'), + content: SizedBox( + width: 600, + child: buildFormContent(setState), + ), + actions: buildActions(dialogContext, setState), + ), + ), + ); + } } Map _unreadByTicketId( diff --git a/lib/theme/m3_motion.dart b/lib/theme/m3_motion.dart index 79efb3ff..4f79f275 100644 --- a/lib/theme/m3_motion.dart +++ b/lib/theme/m3_motion.dart @@ -533,11 +533,13 @@ Future m3ShowBottomSheet({ required WidgetBuilder builder, bool showDragHandle = true, bool isScrollControlled = false, + bool useSafeArea = false, }) { return showModalBottomSheet( context: context, showDragHandle: showDragHandle, isScrollControlled: isScrollControlled, + useSafeArea: useSafeArea, transitionAnimationController: AnimationController( vsync: Navigator.of(context), duration: M3Motion.standard, diff --git a/lib/widgets/app_page_header.dart b/lib/widgets/app_page_header.dart index a81c1afc..301b5a95 100644 --- a/lib/widgets/app_page_header.dart +++ b/lib/widgets/app_page_header.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../theme/m3_motion.dart'; +import 'app_breakpoints.dart'; /// Standardized M3 Expressive page header with animated entrance. /// @@ -18,7 +19,7 @@ class AppPageHeader extends StatelessWidget { required this.title, this.subtitle, this.actions, - this.padding = const EdgeInsets.only(top: 20, bottom: 12), + this.padding, }); final String title; @@ -29,16 +30,18 @@ class AppPageHeader extends StatelessWidget { /// Vertical padding around the header. Horizontal padding is handled by /// the parent [ResponsiveBody] so only top/bottom should be set here. - final EdgeInsetsGeometry padding; + /// When null, defaults to responsive bottom padding (24dp desktop / 12dp mobile). + final EdgeInsetsGeometry? padding; @override Widget build(BuildContext context) { final tt = Theme.of(context).textTheme; final cs = Theme.of(context).colorScheme; + final isMobile = AppBreakpoints.isMobile(context); final titleWidget = Text( title, - style: tt.headlineSmall?.copyWith( + style: (isMobile ? tt.titleLarge : tt.headlineSmall)?.copyWith( fontWeight: FontWeight.w700, letterSpacing: -0.3, color: cs.onSurface, @@ -46,7 +49,7 @@ class AppPageHeader extends StatelessWidget { textAlign: TextAlign.center, ); - final subtitleWidget = subtitle == null + final subtitleWidget = (subtitle == null || isMobile) ? null : Text( subtitle!, @@ -93,9 +96,15 @@ class AppPageHeader extends StatelessWidget { ); } + final effectivePadding = padding ?? + EdgeInsets.only( + top: isMobile ? 12 : 20, + bottom: AppBreakpoints.isDesktop(context) ? 24 : 12, + ); + return M3FadeSlideIn( duration: M3Motion.standard, - child: Padding(padding: padding, child: content), + child: Padding(padding: effectivePadding, child: content), ); } } diff --git a/lib/widgets/offline_banner.dart b/lib/widgets/offline_banner.dart index be348e28..e8831034 100644 --- a/lib/widgets/offline_banner.dart +++ b/lib/widgets/offline_banner.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -27,7 +28,7 @@ class OfflineBanner extends ConsumerWidget { if (!isOnline) { final label = pendingCount > 0 ? 'Offline — $pendingCount item${pendingCount == 1 ? '' : 's'} queued' - : 'No internet — changes saved locally'; + : kIsWeb ? 'No internet connection' : 'No internet — changes saved locally'; banner = _BannerStrip( key: const ValueKey('offline'), backgroundColor: scheme.errorContainer, diff --git a/status b/status deleted file mode 100644 index e69de29b..00000000 diff --git a/test/profile_screen_test.dart b/test/profile_screen_test.dart index a2a8d452..b618854b 100644 --- a/test/profile_screen_test.dart +++ b/test/profile_screen_test.dart @@ -50,7 +50,7 @@ class _FakeProfileController implements ProfileController { } @override - Future downloadFacePhoto(String userId) async { + Future downloadFacePhoto(String userId, {String? enrolledAt}) async { return null; } }