* Task/Ticket desktop dialogs — widened from 520 → 600dp, and tasks now uses SizedBox(width: 600) instead of ConstrainedBox(maxWidth:) which was the root cause of the "still small" issue (max-only never forced expansion)

* Pass Slip + Leave — both dialogs gained an isSheet flag; callers now branch on AppBreakpoints.tablet: bottom sheet on mobile, dialog on desktop
This commit is contained in:
2026-04-30 06:45:51 +08:00
parent 48cd3bcd60
commit 783c5eb0be
19 changed files with 1224 additions and 887 deletions
View File
View File
+13
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_quill/flutter_quill.dart'; import 'package:flutter_quill/flutter_quill.dart';
@@ -41,6 +43,17 @@ class TasqApp extends ConsumerWidget {
} else { } else {
await stopBackgroundLocationUpdates(); 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( return MaterialApp.router(
+92 -4
View File
@@ -1,7 +1,9 @@
import 'dart:io';
import 'dart:typed_data'; 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:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
@@ -219,6 +221,7 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
'[attendanceLogsProvider] reconnected — syncing ${pendingUpdatesMap.length} attendance update(s)', '[attendanceLogsProvider] reconnected — syncing ${pendingUpdatesMap.length} attendance update(s)',
); );
final syncedIds = <String>[]; final syncedIds = <String>[];
final filesToDelete = <String>[];
for (final entry in pendingUpdatesMap.entries) { for (final entry in pendingUpdatesMap.entries) {
final attendanceId = entry.key; final attendanceId = entry.key;
final fields = Map<String, dynamic>.from(entry.value); final fields = Map<String, dynamic>.from(entry.value);
@@ -243,12 +246,64 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
); );
} }
} else { } 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 { try {
await client await client
.from('attendance_logs') .from('attendance_logs')
.update(fields) .update(fields)
.eq('id', attendanceId); .eq('id', attendanceId);
syncedIds.add(attendanceId); syncedIds.add(attendanceId);
if (pendingFileToDelete != null) {
filesToDelete.add(pendingFileToDelete);
}
} catch (e) { } catch (e) {
debugPrint( debugPrint(
'[attendanceLogsProvider] failed to sync update id=$attendanceId: $e', '[attendanceLogsProvider] failed to sync update id=$attendanceId: $e',
@@ -256,6 +311,12 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
} }
} }
} }
// Best-effort cleanup of uploaded local photo files.
for (final path in filesToDelete) {
try {
await File(path).delete();
} catch (_) {}
}
if (syncedIds.isNotEmpty) { if (syncedIds.isNotEmpty) {
final remaining = Map<String, Map<String, dynamic>>.from( final remaining = Map<String, Map<String, dynamic>>.from(
ref.read(offlinePendingAttendanceUpdatesProvider), ref.read(offlinePendingAttendanceUpdatesProvider),
@@ -408,11 +469,38 @@ class AttendanceController {
.eq('id', attendanceId); .eq('id', attendanceId);
} catch (e) { } catch (e) {
if (!isOfflineSaveError(e)) rethrow; if (!isOfflineSaveError(e)) rethrow;
// Queue status update without photo URL — photo bytes are not persisted // Persist photo bytes to disk so the reconnect-replay path can upload
// across sessions; user will need to re-upload photo when online. // 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}); _queueAttendanceUpdate(attendanceId, {'verification_status': status});
debugPrint( debugPrint(
'[AttendanceController] uploadVerification status queued offline (photo not persisted): $attendanceId', '[AttendanceController] uploadVerification status queued offline (no photo): $attendanceId',
); );
} }
} }
+17 -1
View File
@@ -1,5 +1,5 @@
import 'dart:async'; 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:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@@ -46,6 +46,22 @@ final connectivityMonitorProvider = Provider.autoDispose<void>((ref) {
/// Pings the app's own backend to determine online status. /// Pings the app's own backend to determine online status.
class ConnectivityMonitor { class ConnectivityMonitor {
static Future<bool> check() async { static Future<bool> 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 { try {
final res = await http final res = await http
.head(Uri.parse('https://tasq.crmc.ph')) .head(Uri.parse('https://tasq.crmc.ph'))
+75 -4
View File
@@ -1,8 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'dart:typed_data'; 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: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 'package:supabase_flutter/supabase_flutter.dart';
import '../brick/cache_helpers.dart'; import '../brick/cache_helpers.dart';
@@ -13,6 +16,61 @@ import 'connectivity_provider.dart';
import 'supabase_provider.dart'; import 'supabase_provider.dart';
import 'stream_recovery.dart'; import 'stream_recovery.dart';
const _kFaceCacheEnrolledAtPrefix = 'face_cache_enrolled_at:';
/// Reads cached enrolled-face bytes from app documents (mobile/desktop only).
Future<Uint8List?> _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<void> _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<void> 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 // Offline-pending state
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -217,13 +275,26 @@ class ProfileController {
/// Download the face enrollment photo bytes for the given user. /// Download the face enrollment photo bytes for the given user.
/// Uses Supabase authenticated storage API (works with private buckets). /// Uses Supabase authenticated storage API (works with private buckets).
Future<Uint8List?> downloadFacePhoto(String userId) async { ///
/// On a successful online download, bytes are also written to a local cache
/// (`${appDocs}/face_enrollment/<userId>.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<Uint8List?> downloadFacePhoto(
String userId, {
String? enrolledAt,
}) async {
try { try {
return await _client.storage final bytes = await _client.storage
.from('face-enrollment') .from('face-enrollment')
.download('$userId/face.jpg'); .download('$userId/face.jpg');
// Best-effort cache write so face matching can run offline next time.
unawaited(_writeCachedEnrolledFace(userId, bytes, enrolledAt));
return bytes;
} catch (_) { } catch (_) {
return null; return await _readCachedEnrolledFace(userId);
} }
} }
} }
+24 -1
View File
@@ -1,6 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import '../brick/cache_helpers.dart'; import '../brick/cache_helpers.dart';
@@ -12,16 +14,37 @@ import 'supabase_provider.dart';
import 'stream_recovery.dart'; import 'stream_recovery.dart';
import 'realtime_controller.dart'; import 'realtime_controller.dart';
const _kGeofenceCacheKey = 'geofence_cache_v1';
final geofenceProvider = FutureProvider<GeofenceConfig?>((ref) async { final geofenceProvider = FutureProvider<GeofenceConfig?>((ref) async {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
try {
final data = await client final data = await client
.from('app_settings') .from('app_settings')
.select() .select()
.eq('key', 'geofence') .eq('key', 'geofence')
.maybeSingle(); .maybeSingle();
if (data == null) return null; final prefs = await SharedPreferences.getInstance();
if (data == null) {
await prefs.setString(_kGeofenceCacheKey, 'null');
return null;
}
final setting = AppSetting.fromMap(data); final setting = AppSetting.fromMap(data);
await prefs.setString(_kGeofenceCacheKey, jsonEncode(setting.value));
return GeofenceConfig.fromJson(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<String, dynamic>;
return GeofenceConfig.fromJson(json);
} catch (_) {
return null;
}
}
}); });
/// Toggle to show/hide past schedules. Defaults to false (hide past). /// Toggle to show/hide past schedules. Defaults to false (hide past).
@@ -261,9 +261,23 @@ class _AnnouncementCard extends ConsumerStatefulWidget {
class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> { class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
static const _cooldownSeconds = 60; static const _cooldownSeconds = 60;
DateTime? _sentAt; 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 @override
void dispose() { void dispose() {
_cooldownTimer?.cancel();
super.dispose(); super.dispose();
} }
@@ -289,9 +303,15 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
await ref await ref
.read(announcementsControllerProvider) .read(announcementsControllerProvider)
.resendAnnouncementNotification(widget.announcement); .resendAnnouncementNotification(widget.announcement);
if (mounted) setState(() => _sentAt = DateTime.now()); if (mounted) {
setState(() => _sentAt = DateTime.now());
_startCooldownTimer();
}
} on AnnouncementNotificationException { } on AnnouncementNotificationException {
if (mounted) setState(() => _sentAt = DateTime.now()); if (mounted) {
setState(() => _sentAt = DateTime.now());
_startCooldownTimer();
}
} catch (e) { } catch (e) {
if (mounted) showErrorSnackBar(context, 'Failed to send: $e'); if (mounted) showErrorSnackBar(context, 'Failed to send: $e');
} }
@@ -326,13 +346,6 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
final isAnnouncementPending = final isAnnouncementPending =
pendingNew || pendingUpdates.containsKey(widget.announcement.id); 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; final hasBanner = widget.announcement.bannerEnabled;
return Padding( return Padding(
@@ -456,7 +469,14 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 0), 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 // Visible roles chips
Padding( Padding(
+147 -123
View File
@@ -39,6 +39,7 @@ import '../../utils/snackbar.dart';
import '../../widgets/gemini_animated_text_field.dart'; import '../../widgets/gemini_animated_text_field.dart';
import '../../widgets/gemini_button.dart'; import '../../widgets/gemini_button.dart';
import '../../widgets/multi_select_picker.dart'; import '../../widgets/multi_select_picker.dart';
import '../../widgets/app_breakpoints.dart';
import '../../widgets/app_page_header.dart'; import '../../widgets/app_page_header.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../widgets/sync_pending_badge.dart'; import '../../widgets/sync_pending_badge.dart';
@@ -150,7 +151,9 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
); );
} }
return Column( return SafeArea(
bottom: true,
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
@@ -189,26 +192,33 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
), ),
], ],
),
); );
} }
void _showLeaveDialog(BuildContext context, bool isAdmin) { void _showLeaveDialog(BuildContext context, bool isAdmin) {
m3ShowDialog( final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet;
context: context, void onSubmitted() {
builder: (ctx) => _FileLeaveDialog(
isAdmin: isAdmin,
onSubmitted: () {
if (mounted) { if (mounted) {
showSuccessSnackBar( showSuccessSnackBar(
context, context,
isAdmin isAdmin ? 'Leave filed and auto-approved.' : 'Leave application submitted for approval.',
? 'Leave filed and auto-approved.'
: 'Leave application submitted for approval.',
); );
} }
}, }
), if (isMobile) {
m3ShowBottomSheet<void>(
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) { void _showPassSlipDialog(BuildContext context, Profile profile) {
@@ -243,19 +253,26 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
return; return;
} }
void onSubmitted() {
if (mounted) showSuccessSnackBar(context, 'Pass slip requested.');
}
final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet;
if (isMobile) {
m3ShowBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => _PassSlipDialog(scheduleId: todaySchedule.first.id, onSubmitted: onSubmitted, isSheet: true),
);
} else {
m3ShowDialog( m3ShowDialog(
context: context, context: context,
builder: (ctx) => _PassSlipDialog( builder: (_) => _PassSlipDialog(scheduleId: todaySchedule.first.id, onSubmitted: onSubmitted),
scheduleId: todaySchedule.first.id,
onSubmitted: () {
if (mounted) {
showSuccessSnackBar(context, 'Pass slip requested.');
}
},
),
); );
} }
} }
}
// ──────────────────────────────────────────────── // ────────────────────────────────────────────────
// Tab 1 Check In / Check Out // Tab 1 Check In / Check Out
@@ -2195,17 +2212,21 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> {
}) { }) {
final groupedByDate = _groupByDate(entries); final groupedByDate = _groupByDate(entries);
final groups = groupedByDate.entries.toList();
return ListView( return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
children: groupedByDate.entries.map((group) { children: [
final shiftGroups = _groupByShift(group.value); for (int i = 0; i < groups.length; i++)
return _DateGroupTile( M3FadeSlideIn(
dateLabel: group.key, delay: Duration(milliseconds: i.clamp(0, 6) * 60),
shiftGroups: shiftGroups, child: _DateGroupTile(
dateLabel: groups[i].key,
shiftGroups: _groupByShift(groups[i].value),
currentUserId: currentUserId, currentUserId: currentUserId,
onReverify: onReverify, onReverify: onReverify,
); ),
}).toList(), ),
],
); );
} }
@@ -2463,10 +2484,9 @@ class _DateGroupTile extends StatelessWidget {
final isMobile = MediaQuery.sizeOf(context).width < 700; final isMobile = MediaQuery.sizeOf(context).width < 700;
if (isMobile) { if (isMobile) {
showModalBottomSheet<void>( m3ShowBottomSheet<void>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
showDragHandle: true,
useSafeArea: true, useSafeArea: true,
builder: (ctx) => DraggableScrollableSheet( builder: (ctx) => DraggableScrollableSheet(
initialChildSize: 0.9, initialChildSize: 0.9,
@@ -3558,7 +3578,9 @@ class _MyScheduleTabState extends ConsumerState<_MyScheduleTab> {
s.requesterId == currentUserId && s.requesterId == currentUserId &&
s.status == 'pending'); s.status == 'pending');
return Padding( return M3FadeSlideIn(
delay: Duration(milliseconds: index.clamp(0, 6) * 60),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Card( child: Card(
child: Padding( 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; final isMobile = MediaQuery.sizeOf(context).width < 600;
if (isMobile) { if (isMobile) {
showModalBottomSheet( m3ShowBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
useSafeArea: true, useSafeArea: true,
@@ -4904,6 +4927,8 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
return slipsAsync.when( return slipsAsync.when(
data: (slips) { 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( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
@@ -4997,18 +5022,18 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
...slips for (int i = 0; i < pendingSlipList.length; i++)
.where((s) => s.status == 'pending') M3FadeSlideIn(
.map( delay: Duration(milliseconds: i.clamp(0, 6) * 60),
(slip) => _buildSlipCard( child: _buildSlipCard(
context, context,
slip, pendingSlipList[i],
profileById, profileById,
showActions: true, showActions: true,
isPending: pendingSlips.any((p) => p.id == slip.id), isPending: pendingSlips.any((p) => p.id == pendingSlipList[i].id),
), ),
), ),
if (slips.where((s) => s.status == 'pending').isEmpty) if (pendingSlipList.isEmpty)
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: Text( child: Text(
@@ -5029,16 +5054,15 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
...slips for (int i = 0; i < historySlipList.length; i++)
.where((s) => s.status != 'pending' || !isAdmin) M3FadeSlideIn(
.take(50) delay: Duration(milliseconds: i.clamp(0, 6) * 60),
.map( child: _buildSlipCard(
(slip) => _buildSlipCard(
context, context,
slip, historySlipList[i],
profileById, profileById,
showActions: false, showActions: false,
isPending: pendingSlips.any((p) => p.id == slip.id), isPending: pendingSlips.any((p) => p.id == historySlipList[i].id),
), ),
), ),
if (slips.isEmpty) if (slips.isEmpty)
@@ -5271,6 +5295,11 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
.toList() .toList()
: <LeaveOfAbsence>[]; : <LeaveOfAbsence>[];
final myLeavesList = myLeaves.take(50).toList();
final allLeaveHistory = leaves
.where((l) => l.status != 'pending' && l.userId != profile.id)
.take(50)
.toList();
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
@@ -5293,13 +5322,15 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
), ),
), ),
), ),
...pendingApprovals.map( for (int i = 0; i < pendingApprovals.length; i++)
(leave) => _buildLeaveCard( M3FadeSlideIn(
delay: Duration(milliseconds: i.clamp(0, 6) * 60),
child: _buildLeaveCard(
context, context,
leave, pendingApprovals[i],
profileById, profileById,
showApproval: true, showApproval: true,
isPending: pendingLeavesList.any((l) => l.id == leave.id), isPending: pendingLeavesList.any((l) => l.id == pendingApprovals[i].id),
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -5325,15 +5356,15 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
), ),
), ),
), ),
...myLeaves for (int i = 0; i < myLeavesList.length; i++)
.take(50) M3FadeSlideIn(
.map( delay: Duration(milliseconds: i.clamp(0, 6) * 60),
(leave) => _buildLeaveCard( child: _buildLeaveCard(
context, context,
leave, myLeavesList[i],
profileById, profileById,
showApproval: false, showApproval: false,
isPending: pendingLeavesList.any((l) => l.id == leave.id), isPending: pendingLeavesList.any((l) => l.id == myLeavesList[i].id),
), ),
), ),
@@ -5347,21 +5378,18 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
...leaves for (int i = 0; i < allLeaveHistory.length; i++)
.where((l) => l.status != 'pending' && l.userId != profile.id) M3FadeSlideIn(
.take(50) delay: Duration(milliseconds: i.clamp(0, 6) * 60),
.map( child: _buildLeaveCard(
(leave) => _buildLeaveCard(
context, context,
leave, allLeaveHistory[i],
profileById, profileById,
showApproval: false, showApproval: false,
isPending: pendingLeavesList.any((l) => l.id == leave.id), isPending: pendingLeavesList.any((l) => l.id == allLeaveHistory[i].id),
), ),
), ),
if (leaves if (allLeaveHistory.isEmpty)
.where((l) => l.status != 'pending' && l.userId != profile.id)
.isEmpty)
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: Text( child: Text(
@@ -5629,9 +5657,10 @@ class _FabMenuItem extends StatelessWidget {
// ─── Pass Slip Dialog (with Gemini) ───────────────────────────── // ─── Pass Slip Dialog (with Gemini) ─────────────────────────────
class _PassSlipDialog extends ConsumerStatefulWidget { 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 String scheduleId;
final VoidCallback onSubmitted; final VoidCallback onSubmitted;
final bool isSheet;
@override @override
ConsumerState<_PassSlipDialog> createState() => _PassSlipDialogState(); ConsumerState<_PassSlipDialog> createState() => _PassSlipDialogState();
@@ -5695,13 +5724,7 @@ class _PassSlipDialogState extends ConsumerState<_PassSlipDialog> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Dialog( final content = Column(
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, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -5739,7 +5762,6 @@ class _PassSlipDialogState extends ConsumerState<_PassSlipDialog> {
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Optional start time picker
InkWell( InkWell(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
onTap: _submitting onTap: _submitting
@@ -5747,8 +5769,7 @@ class _PassSlipDialogState extends ConsumerState<_PassSlipDialog> {
: () async { : () async {
final picked = await showTimePicker( final picked = await showTimePicker(
context: context, context: context,
initialTime: initialTime: _requestedStartTime ?? TimeOfDay.now(),
_requestedStartTime ?? TimeOfDay.now(),
); );
if (picked != null && mounted) { if (picked != null && mounted) {
setState(() => _requestedStartTime = picked); setState(() => _requestedStartTime = picked);
@@ -5766,8 +5787,7 @@ class _PassSlipDialogState extends ConsumerState<_PassSlipDialog> {
icon: const Icon(Icons.clear), icon: const Icon(Icons.clear),
onPressed: _submitting onPressed: _submitting
? null ? null
: () => setState( : () => setState(() => _requestedStartTime = null),
() => _requestedStartTime = null),
tooltip: 'Clear', tooltip: 'Clear',
) )
: null, : null,
@@ -5789,9 +5809,7 @@ class _PassSlipDialogState extends ConsumerState<_PassSlipDialog> {
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
TextButton( TextButton(
onPressed: _submitting onPressed: _submitting ? null : () => Navigator.of(context).pop(),
? null
: () => Navigator.of(context).pop(),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -5808,8 +5826,25 @@ class _PassSlipDialogState extends ConsumerState<_PassSlipDialog> {
], ],
), ),
], ],
);
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: content),
), ),
); );
} }
@@ -5817,9 +5852,10 @@ class _PassSlipDialogState extends ConsumerState<_PassSlipDialog> {
// ─── File Leave Dialog ────────────────────────────────────────── // ─── File Leave Dialog ──────────────────────────────────────────
class _FileLeaveDialog extends ConsumerStatefulWidget { 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 bool isAdmin;
final VoidCallback onSubmitted; final VoidCallback onSubmitted;
final bool isSheet;
@override @override
ConsumerState<_FileLeaveDialog> createState() => _FileLeaveDialogState(); ConsumerState<_FileLeaveDialog> createState() => _FileLeaveDialogState();
@@ -5978,21 +6014,11 @@ class _FileLeaveDialogState extends ConsumerState<_FileLeaveDialog> {
final theme = Theme.of(context); final theme = Theme.of(context);
final colors = theme.colorScheme; final colors = theme.colorScheme;
return Dialog( final content = Column(
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, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text('File Leave of Absence', style: theme.textTheme.headlineSmall),
'File Leave of Absence',
style: theme.textTheme.headlineSmall,
),
const SizedBox(height: 16), const SizedBox(height: 16),
// Leave type // Leave type
@@ -6001,12 +6027,7 @@ class _FileLeaveDialogState extends ConsumerState<_FileLeaveDialog> {
value: _leaveType, value: _leaveType,
decoration: const InputDecoration(labelText: 'Leave Type'), decoration: const InputDecoration(labelText: 'Leave Type'),
items: _leaveTypes.entries items: _leaveTypes.entries
.map( .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
(e) => DropdownMenuItem(
value: e.key,
child: Text(e.value),
),
)
.toList(), .toList(),
onChanged: (v) { onChanged: (v) {
if (v != null) setState(() => _leaveType = v); if (v != null) setState(() => _leaveType = v);
@@ -6019,9 +6040,7 @@ class _FileLeaveDialogState extends ConsumerState<_FileLeaveDialog> {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.calendar_today), leading: const Icon(Icons.calendar_today),
title: Text( title: Text(
_startDate == null _startDate == null ? 'Select Date' : AppTime.formatDate(_startDate!),
? 'Select Date'
: AppTime.formatDate(_startDate!),
), ),
subtitle: const Text('Current or future dates only'), subtitle: const Text('Current or future dates only'),
onTap: _pickDate, onTap: _pickDate,
@@ -6034,11 +6053,7 @@ class _FileLeaveDialogState extends ConsumerState<_FileLeaveDialog> {
child: ListTile( child: ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.access_time), leading: const Icon(Icons.access_time),
title: Text( title: Text(_startTime == null ? 'Start Time' : _startTime!.format(context)),
_startTime == null
? 'Start Time'
: _startTime!.format(context),
),
onTap: _pickStartTime, onTap: _pickStartTime,
), ),
), ),
@@ -6050,11 +6065,7 @@ class _FileLeaveDialogState extends ConsumerState<_FileLeaveDialog> {
child: ListTile( child: ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.access_time), leading: const Icon(Icons.access_time),
title: Text( title: Text(_endTime == null ? 'End Time' : _endTime!.format(context)),
_endTime == null
? 'End Time'
: _endTime!.format(context),
),
subtitle: const Text('From shift schedule'), subtitle: const Text('From shift schedule'),
onTap: _pickEndTime, onTap: _pickEndTime,
), ),
@@ -6080,9 +6091,7 @@ class _FileLeaveDialogState extends ConsumerState<_FileLeaveDialog> {
child: GeminiButton( child: GeminiButton(
textController: _justificationController, textController: _justificationController,
onTextUpdated: (text) { onTextUpdated: (text) {
setState(() { setState(() => _justificationController.text = text);
_justificationController.text = text;
});
}, },
onProcessingStateChanged: (processing) { onProcessingStateChanged: (processing) {
setState(() => _isGeminiProcessing = processing); setState(() => _isGeminiProcessing = processing);
@@ -6104,9 +6113,7 @@ class _FileLeaveDialogState extends ConsumerState<_FileLeaveDialog> {
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 8),
child: Text( child: Text(
'As admin, your leave will be auto-approved.', 'As admin, your leave will be auto-approved.',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(color: colors.primary),
color: colors.primary,
),
), ),
), ),
@@ -6115,9 +6122,7 @@ class _FileLeaveDialogState extends ConsumerState<_FileLeaveDialog> {
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
TextButton( TextButton(
onPressed: _submitting onPressed: _submitting ? null : () => Navigator.of(context).pop(),
? null
: () => Navigator.of(context).pop(),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -6135,8 +6140,27 @@ class _FileLeaveDialogState extends ConsumerState<_FileLeaveDialog> {
], ],
), ),
], ],
);
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: content),
), ),
), ),
); );
+5 -1
View File
@@ -740,7 +740,11 @@ class _DashboardScreenState extends State<DashboardScreen> {
subtitle: 'Live metrics and team activity', subtitle: 'Live metrics and team activity',
), ),
const _DashboardStatusBanner(), const _DashboardStatusBanner(),
...sections, for (int i = 0; i < sections.length; i++)
M3FadeSlideIn(
delay: Duration(milliseconds: i.clamp(0, 5) * 40),
child: sections[i],
),
], ],
); );
@@ -2,6 +2,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:async'; import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import '../../theme/m3_motion.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
@@ -458,9 +459,14 @@ class _ItServiceRequestDetailScreenState
), ),
body: Skeletonizer( body: Skeletonizer(
enabled: showSkeleton, enabled: showSkeleton,
child: M3AnimatedSwitcher(
child: request == null && !showSkeleton child: request == null && !showSkeleton
? const Center(child: Text('Request not found')) ? const Center(
key: ValueKey('isr_not_found'),
child: Text('Request not found'),
)
: ResponsiveBody( : ResponsiveBody(
key: const ValueKey('isr_content'),
maxWidth: double.infinity, maxWidth: double.infinity,
child: Column( child: Column(
children: [ children: [
@@ -517,6 +523,7 @@ class _ItServiceRequestDetailScreenState
), ),
), ),
), ),
),
); );
} }
+12 -20
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../theme/m3_motion.dart';
import '../../providers/reports_provider.dart'; import '../../providers/reports_provider.dart';
import '../../widgets/app_page_header.dart'; import '../../widgets/app_page_header.dart';
import 'report_date_filter.dart'; import 'report_date_filter.dart';
@@ -162,10 +163,7 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
builder: (context, constraints) { builder: (context, constraints) {
final isWide = constraints.maxWidth >= 600; final isWide = constraints.maxWidth >= 600;
return Column( final chartSections = <Widget>[
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Status donuts (paired on desktop)
if (_anyEnabled(enabled, [ if (_anyEnabled(enabled, [
ReportWidgetType.ticketsByStatus, ReportWidgetType.ticketsByStatus,
ReportWidgetType.tasksByStatus, ReportWidgetType.tasksByStatus,
@@ -183,8 +181,6 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
), ),
], ],
), ),
// Conversion rate KPI
if (enabled.contains(ReportWidgetType.conversionRate)) if (enabled.contains(ReportWidgetType.conversionRate))
Padding( Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
@@ -192,8 +188,6 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
repaintKey: _keys[ReportWidgetType.conversionRate], repaintKey: _keys[ReportWidgetType.conversionRate],
), ),
), ),
// Request type + category donuts
if (_anyEnabled(enabled, [ if (_anyEnabled(enabled, [
ReportWidgetType.requestType, ReportWidgetType.requestType,
ReportWidgetType.requestCategory, ReportWidgetType.requestCategory,
@@ -211,8 +205,6 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
), ),
], ],
), ),
// Monthly overview (full width)
if (enabled.contains(ReportWidgetType.monthlyOverview)) if (enabled.contains(ReportWidgetType.monthlyOverview))
Padding( Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
@@ -220,8 +212,6 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
repaintKey: _keys[ReportWidgetType.monthlyOverview], repaintKey: _keys[ReportWidgetType.monthlyOverview],
), ),
), ),
// Hourly charts (paired)
if (_anyEnabled(enabled, [ if (_anyEnabled(enabled, [
ReportWidgetType.tasksByHour, ReportWidgetType.tasksByHour,
ReportWidgetType.ticketsByHour, ReportWidgetType.ticketsByHour,
@@ -239,8 +229,6 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
), ),
], ],
), ),
// Top offices + subjects for Tickets (paired)
if (_anyEnabled(enabled, [ if (_anyEnabled(enabled, [
ReportWidgetType.topOfficesTickets, ReportWidgetType.topOfficesTickets,
ReportWidgetType.topTicketSubjects, ReportWidgetType.topTicketSubjects,
@@ -258,8 +246,6 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
), ),
], ],
), ),
// Top offices + subjects for Tasks (paired)
if (_anyEnabled(enabled, [ if (_anyEnabled(enabled, [
ReportWidgetType.topOfficesTasks, ReportWidgetType.topOfficesTasks,
ReportWidgetType.topTaskSubjects, ReportWidgetType.topTaskSubjects,
@@ -277,8 +263,6 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
), ),
], ],
), ),
// Avg resolution (full width)
if (enabled.contains(ReportWidgetType.avgResolution)) if (enabled.contains(ReportWidgetType.avgResolution))
Padding( Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
@@ -286,8 +270,6 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
repaintKey: _keys[ReportWidgetType.avgResolution], repaintKey: _keys[ReportWidgetType.avgResolution],
), ),
), ),
// Staff workload (full width)
if (enabled.contains(ReportWidgetType.staffWorkload)) if (enabled.contains(ReportWidgetType.staffWorkload))
Padding( Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
@@ -295,6 +277,16 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
repaintKey: _keys[ReportWidgetType.staffWorkload], repaintKey: _keys[ReportWidgetType.staffWorkload],
), ),
), ),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (int i = 0; i < chartSections.length; i++)
M3FadeSlideIn(
delay: Duration(milliseconds: i.clamp(0, 4) * 80),
child: chartSections[i],
),
], ],
); );
}, },
+101 -46
View File
@@ -28,6 +28,7 @@ import '../../widgets/tasq_adaptive_list.dart';
import '../../widgets/typing_dots.dart'; import '../../widgets/typing_dots.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
import '../../utils/snackbar.dart'; import '../../utils/snackbar.dart';
import '../../widgets/app_breakpoints.dart';
import '../../widgets/app_page_header.dart'; import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart'; import '../../widgets/app_state_view.dart';
import '../../utils/subject_suggestions.dart'; import '../../utils/subject_suggestions.dart';
@@ -324,13 +325,17 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
latestAssigneeByTaskId: latestAssigneeByTaskId, latestAssigneeByTaskId: latestAssigneeByTaskId,
); );
final filterHeader = Wrap( final filterHeader = LayoutBuilder(
builder: (context, constraints) {
final isMobile = constraints.maxWidth < 600;
final halfWidth = (constraints.maxWidth - 12) / 2;
return Wrap(
spacing: 12, spacing: 12,
runSpacing: 12, runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
SizedBox( SizedBox(
width: 220, width: isMobile ? constraints.maxWidth : 220,
child: TextField( child: TextField(
controller: _subjectController, controller: _subjectController,
onChanged: (_) => setState(() {}), onChanged: (_) => setState(() {}),
@@ -341,7 +346,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
), ),
), ),
SizedBox( SizedBox(
width: 200, width: isMobile ? halfWidth : 200,
child: DropdownButtonFormField<String?>( child: DropdownButtonFormField<String?>(
isExpanded: true, isExpanded: true,
key: ValueKey(_selectedOfficeId), key: ValueKey(_selectedOfficeId),
@@ -349,11 +354,12 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
items: officeOptions, items: officeOptions,
onChanged: (value) => onChanged: (value) =>
setState(() => _selectedOfficeId = value), setState(() => _selectedOfficeId = value),
decoration: const InputDecoration(labelText: 'Office'), decoration:
const InputDecoration(labelText: 'Office'),
), ),
), ),
SizedBox( SizedBox(
width: 160, width: isMobile ? halfWidth : 160,
child: TextField( child: TextField(
controller: _taskNumberController, controller: _taskNumberController,
onChanged: (_) => setState(() {}), onChanged: (_) => setState(() {}),
@@ -365,7 +371,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
), ),
if (_tabController.index == 1) if (_tabController.index == 1)
SizedBox( SizedBox(
width: 220, width: isMobile ? halfWidth : 220,
child: DropdownButtonFormField<String?>( child: DropdownButtonFormField<String?>(
isExpanded: true, isExpanded: true,
key: ValueKey(_selectedAssigneeId), key: ValueKey(_selectedAssigneeId),
@@ -379,7 +385,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
), ),
), ),
SizedBox( SizedBox(
width: 180, width: isMobile ? halfWidth : 180,
child: DropdownButtonFormField<String?>( child: DropdownButtonFormField<String?>(
isExpanded: true, isExpanded: true,
key: ValueKey(_selectedStatus), key: ValueKey(_selectedStatus),
@@ -387,7 +393,8 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
items: statusOptions, items: statusOptions,
onChanged: (value) => onChanged: (value) =>
setState(() => _selectedStatus = value), setState(() => _selectedStatus = value),
decoration: const InputDecoration(labelText: 'Status'), decoration:
const InputDecoration(labelText: 'Status'),
), ),
), ),
OutlinedButton.icon( OutlinedButton.icon(
@@ -425,6 +432,8 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
), ),
], ],
); );
},
);
// reusable helper for rendering a list given a subset of tasks // reusable helper for rendering a list given a subset of tasks
Widget makeList(List<Task> tasksList) { Widget makeList(List<Task> tasksList) {
@@ -803,23 +812,20 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
var showTitleGemini = false; var showTitleGemini = false;
Timer? titleTypingTimer; Timer? titleTypingTimer;
await m3ShowDialog<void>( final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet;
context: context,
builder: (dialogContext) { // Lifted so both mobile/desktop branches share one set of form state vars.
bool saving = false; var saving = false;
bool titleProcessing = false; var titleProcessing = false;
bool descProcessing = false; var descProcessing = false;
bool titleDeepSeek = false; var titleDeepSeek = false;
bool descDeepSeek = false; var descDeepSeek = false;
final officesAsync = ref.watch(officesProvider);
return StatefulBuilder( Column buildFormColumn(
builder: (context, setState) { StateSetter setState,
return AlertDialog( AsyncValue<List<Office>> officesAsync,
shape: AppSurfaces.of(context).dialogShape, ) {
title: const Text('Create Task'), return Column(
content: SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Row( Row(
@@ -1039,33 +1045,28 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
Text('Failed to load offices: $error'), Text('Failed to load offices: $error'),
), ),
], ],
), );
), }
actions: [
List<Widget> buildActions(BuildContext closeCtx, StateSetter setState) {
return [
TextButton( TextButton(
onPressed: saving onPressed: saving ? null : () => Navigator.of(closeCtx).pop(),
? null
: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
FilledButton( FilledButton(
onPressed: saving onPressed: saving
? null ? null
: () async { : () async {
final title = final title = SubjectSuggestionEngine.normalizeDisplay(
SubjectSuggestionEngine.normalizeDisplay(
titleController.text.trim(), titleController.text.trim(),
); );
final description = descriptionController.text.trim(); final description = descriptionController.text.trim();
final officeId = selectedOfficeId; final officeId = selectedOfficeId;
if (title.isEmpty || officeId == null) { if (title.isEmpty || officeId == null) return;
return;
}
setState(() => saving = true); setState(() => saving = true);
try { try {
await ref await ref.read(tasksControllerProvider).createTask(
.read(tasksControllerProvider)
.createTask(
title: title, title: title,
description: description, description: description,
officeId: officeId, officeId: officeId,
@@ -1073,16 +1074,16 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
requestTypeOther: requestTypeOther, requestTypeOther: requestTypeOther,
requestCategory: selectedRequestCategory, requestCategory: selectedRequestCategory,
); );
if (context.mounted) { if (closeCtx.mounted) {
Navigator.of(dialogContext).pop(); Navigator.of(closeCtx).pop();
showSuccessSnackBarGlobal( showSuccessSnackBarGlobal(
'Task "$title" has been created successfully.', 'Task "$title" has been created successfully.',
); );
} }
} catch (e) { } catch (e) {
if (!dialogContext.mounted) return; if (!closeCtx.mounted) return;
if (isOfflineSaveError(e)) { if (isOfflineSaveError(e)) {
Navigator.of(dialogContext).pop(); Navigator.of(closeCtx).pop();
showSuccessSnackBarGlobal( showSuccessSnackBarGlobal(
'Task "$title" saved offline — will sync when connected.', 'Task "$title" saved offline — will sync when connected.',
); );
@@ -1090,9 +1091,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
showErrorSnackBarGlobal('Failed to create task: $e'); showErrorSnackBarGlobal('Failed to create task: $e');
} }
} finally { } finally {
if (dialogContext.mounted) { if (closeCtx.mounted) setState(() => saving = false);
setState(() => saving = false);
}
} }
}, },
child: saving child: saving
@@ -1103,13 +1102,69 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
) )
: const Text('Create'), : const Text('Create'),
), ),
];
}
if (isMobile) {
await m3ShowBottomSheet<void>(
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),
], ],
),
),
),
); );
}, },
); );
} else {
await m3ShowDialog<void>(
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( bool _hasTaskMention(
AsyncValue<List<NotificationItem>> notificationsAsync, AsyncValue<List<NotificationItem>> notificationsAsync,
+79 -67
View File
@@ -21,6 +21,7 @@ import '../../widgets/tasq_adaptive_list.dart';
import '../../widgets/typing_dots.dart'; import '../../widgets/typing_dots.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
import '../../utils/snackbar.dart'; import '../../utils/snackbar.dart';
import '../../widgets/app_breakpoints.dart';
import '../../widgets/app_page_header.dart'; import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart'; import '../../widgets/app_state_view.dart';
import '../../widgets/sync_pending_badge.dart'; import '../../widgets/sync_pending_badge.dart';
@@ -441,86 +442,59 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
final descriptionController = TextEditingController(); final descriptionController = TextEditingController();
Office? selectedOffice; Office? selectedOffice;
await m3ShowDialog<void>( final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet;
context: context, var saving = false;
builder: (dialogContext) {
bool saving = false; Widget buildFormContent(StateSetter setState) {
return StatefulBuilder( return Consumer(
builder: (context, setState) { builder: (context, ref, _) {
return AlertDialog(
shape: AppSurfaces.of(context).dialogShape,
title: const Text('Create Ticket'),
content: Consumer(
builder: (context, ref, child) {
final officesAsync = ref.watch(officesProvider); final officesAsync = ref.watch(officesProvider);
return SizedBox( return Column(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
TextField( TextField(
controller: subjectController, controller: subjectController,
decoration: const InputDecoration( decoration: const InputDecoration(labelText: 'Subject'),
labelText: 'Subject',
),
enabled: !saving, enabled: !saving,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
controller: descriptionController, controller: descriptionController,
decoration: const InputDecoration( decoration: const InputDecoration(labelText: 'Description'),
labelText: 'Description',
),
maxLines: 3, maxLines: 3,
enabled: !saving, enabled: !saving,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
officesAsync.when( officesAsync.when(
data: (offices) { data: (offices) {
if (offices.isEmpty) { if (offices.isEmpty) return const Text('No offices assigned.');
return const Text('No offices assigned.');
}
final officesSorted = List<Office>.from(offices) final officesSorted = List<Office>.from(offices)
..sort( ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
(a, b) => a.name.toLowerCase().compareTo(
b.name.toLowerCase(),
),
);
selectedOffice ??= officesSorted.first; selectedOffice ??= officesSorted.first;
return DropdownButtonFormField<Office>( return DropdownButtonFormField<Office>(
key: ValueKey(selectedOffice?.id), key: ValueKey(selectedOffice?.id),
initialValue: selectedOffice, initialValue: selectedOffice,
items: officesSorted items: officesSorted
.map( .map((o) => DropdownMenuItem(value: o, child: Text(o.name)))
(office) => DropdownMenuItem(
value: office,
child: Text(office.name),
),
)
.toList(), .toList(),
onChanged: saving onChanged: saving ? null : (v) => setState(() => selectedOffice = v),
? null decoration: const InputDecoration(labelText: 'Office'),
: (value) =>
setState(() => selectedOffice = value),
decoration: const InputDecoration(
labelText: 'Office',
),
); );
}, },
loading: () => const LinearProgressIndicator(), loading: () => const LinearProgressIndicator(),
error: (error, _) => error: (e, _) => Text('Failed to load offices: $e'),
Text('Failed to load offices: $error'),
), ),
], ],
),
); );
}, },
), );
actions: [ }
List<Widget> buildActions(BuildContext closeCtx, StateSetter setState) {
return [
TextButton( TextButton(
onPressed: saving onPressed: saving ? null : () => Navigator.of(closeCtx).pop(),
? null
: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
FilledButton( FilledButton(
@@ -529,13 +503,8 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
: () async { : () async {
final subject = subjectController.text.trim(); final subject = subjectController.text.trim();
final description = descriptionController.text.trim(); final description = descriptionController.text.trim();
if (subject.isEmpty || if (subject.isEmpty || description.isEmpty || selectedOffice == null) {
description.isEmpty || showWarningSnackBar(closeCtx, 'Fill out all fields.');
selectedOffice == null) {
showWarningSnackBar(
context,
'Fill out all fields.',
);
return; return;
} }
setState(() => saving = true); setState(() => saving = true);
@@ -548,29 +517,26 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
officeId: selectedOffice!.id, officeId: selectedOffice!.id,
); );
if (pendingTicket != null) { if (pendingTicket != null) {
// Offline: show immediately in the list until sync.
final current = ref.read(offlinePendingTicketsProvider); final current = ref.read(offlinePendingTicketsProvider);
ref.read(offlinePendingTicketsProvider.notifier).state = [ ref.read(offlinePendingTicketsProvider.notifier).state = [
pendingTicket, pendingTicket,
...current, ...current,
]; ];
} }
if (context.mounted) { if (closeCtx.mounted) {
Navigator.of(dialogContext).pop(); Navigator.of(closeCtx).pop();
showSuccessSnackBar( showSuccessSnackBar(
context, closeCtx,
pendingTicket != null pendingTicket != null
? 'Ticket "$subject" saved offline — will sync when connected.' ? 'Ticket "$subject" saved offline — will sync when connected.'
: 'Ticket "$subject" has been created successfully.', : 'Ticket "$subject" has been created successfully.',
); );
} }
} catch (e) { } catch (e) {
if (!dialogContext.mounted) return; if (!closeCtx.mounted) return;
showErrorSnackBarGlobal('Failed to create ticket: $e'); showErrorSnackBarGlobal('Failed to create ticket: $e');
} finally { } finally {
if (dialogContext.mounted) { if (closeCtx.mounted) setState(() => saving = false);
setState(() => saving = false);
}
} }
}, },
child: saving child: saving
@@ -581,13 +547,59 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
) )
: const Text('Create'), : const Text('Create'),
), ),
];
}
if (isMobile) {
await m3ShowBottomSheet<void>(
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<void>(
}, 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<String, bool> _unreadByTicketId( Map<String, bool> _unreadByTicketId(
AsyncValue<List<NotificationItem>> notificationsAsync, AsyncValue<List<NotificationItem>> notificationsAsync,
+2
View File
@@ -533,11 +533,13 @@ Future<T?> m3ShowBottomSheet<T>({
required WidgetBuilder builder, required WidgetBuilder builder,
bool showDragHandle = true, bool showDragHandle = true,
bool isScrollControlled = false, bool isScrollControlled = false,
bool useSafeArea = false,
}) { }) {
return showModalBottomSheet<T>( return showModalBottomSheet<T>(
context: context, context: context,
showDragHandle: showDragHandle, showDragHandle: showDragHandle,
isScrollControlled: isScrollControlled, isScrollControlled: isScrollControlled,
useSafeArea: useSafeArea,
transitionAnimationController: AnimationController( transitionAnimationController: AnimationController(
vsync: Navigator.of(context), vsync: Navigator.of(context),
duration: M3Motion.standard, duration: M3Motion.standard,
+14 -5
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/m3_motion.dart'; import '../theme/m3_motion.dart';
import 'app_breakpoints.dart';
/// Standardized M3 Expressive page header with animated entrance. /// Standardized M3 Expressive page header with animated entrance.
/// ///
@@ -18,7 +19,7 @@ class AppPageHeader extends StatelessWidget {
required this.title, required this.title,
this.subtitle, this.subtitle,
this.actions, this.actions,
this.padding = const EdgeInsets.only(top: 20, bottom: 12), this.padding,
}); });
final String title; final String title;
@@ -29,16 +30,18 @@ class AppPageHeader extends StatelessWidget {
/// Vertical padding around the header. Horizontal padding is handled by /// Vertical padding around the header. Horizontal padding is handled by
/// the parent [ResponsiveBody] so only top/bottom should be set here. /// 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme; final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final isMobile = AppBreakpoints.isMobile(context);
final titleWidget = Text( final titleWidget = Text(
title, title,
style: tt.headlineSmall?.copyWith( style: (isMobile ? tt.titleLarge : tt.headlineSmall)?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: -0.3, letterSpacing: -0.3,
color: cs.onSurface, color: cs.onSurface,
@@ -46,7 +49,7 @@ class AppPageHeader extends StatelessWidget {
textAlign: TextAlign.center, textAlign: TextAlign.center,
); );
final subtitleWidget = subtitle == null final subtitleWidget = (subtitle == null || isMobile)
? null ? null
: Text( : Text(
subtitle!, 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( return M3FadeSlideIn(
duration: M3Motion.standard, duration: M3Motion.standard,
child: Padding(padding: padding, child: content), child: Padding(padding: effectivePadding, child: content),
); );
} }
} }
+2 -1
View File
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -27,7 +28,7 @@ class OfflineBanner extends ConsumerWidget {
if (!isOnline) { if (!isOnline) {
final label = pendingCount > 0 final label = pendingCount > 0
? 'Offline — $pendingCount item${pendingCount == 1 ? '' : 's'} queued' ? 'Offline — $pendingCount item${pendingCount == 1 ? '' : 's'} queued'
: 'No internet — changes saved locally'; : kIsWeb ? 'No internet connection' : 'No internet — changes saved locally';
banner = _BannerStrip( banner = _BannerStrip(
key: const ValueKey('offline'), key: const ValueKey('offline'),
backgroundColor: scheme.errorContainer, backgroundColor: scheme.errorContainer,
View File
+1 -1
View File
@@ -50,7 +50,7 @@ class _FakeProfileController implements ProfileController {
} }
@override @override
Future<Uint8List?> downloadFacePhoto(String userId) async { Future<Uint8List?> downloadFacePhoto(String userId, {String? enrolledAt}) async {
return null; return null;
} }
} }