* 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:
@@ -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(
|
||||||
|
|||||||
@@ -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',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
final data = await client
|
try {
|
||||||
.from('app_settings')
|
final data = await client
|
||||||
.select()
|
.from('app_settings')
|
||||||
.eq('key', 'geofence')
|
.select()
|
||||||
.maybeSingle();
|
.eq('key', 'geofence')
|
||||||
if (data == null) return null;
|
.maybeSingle();
|
||||||
final setting = AppSetting.fromMap(data);
|
final prefs = await SharedPreferences.getInstance();
|
||||||
return GeofenceConfig.fromJson(setting.value);
|
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<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(
|
||||||
|
|||||||
@@ -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,65 +151,74 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(
|
return SafeArea(
|
||||||
mainAxisSize: MainAxisSize.min,
|
bottom: true,
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
child: Column(
|
||||||
children: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
// Leave option
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
if (canFileLeave) ...[
|
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(
|
_FabMenuItem(
|
||||||
heroTag: 'fab_leave',
|
heroTag: 'fab_slip',
|
||||||
label: 'File Leave',
|
label: 'Request Slip',
|
||||||
icon: Icons.event_busy,
|
icon: Icons.receipt_long,
|
||||||
color: colors.tertiaryContainer,
|
color: colors.secondaryContainer,
|
||||||
onColor: colors.onTertiaryContainer,
|
onColor: colors.onSecondaryContainer,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
setState(() => _fabMenuOpen = false);
|
setState(() => _fabMenuOpen = false);
|
||||||
_showLeaveDialog(context, isAdmin);
|
_showPassSlipDialog(context, profile);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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) {
|
void _showLeaveDialog(BuildContext context, bool isAdmin) {
|
||||||
m3ShowDialog(
|
final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet;
|
||||||
context: context,
|
void onSubmitted() {
|
||||||
builder: (ctx) => _FileLeaveDialog(
|
if (mounted) {
|
||||||
isAdmin: isAdmin,
|
showSuccessSnackBar(
|
||||||
onSubmitted: () {
|
context,
|
||||||
if (mounted) {
|
isAdmin ? 'Leave filed and auto-approved.' : 'Leave application submitted for approval.',
|
||||||
showSuccessSnackBar(
|
);
|
||||||
context,
|
}
|
||||||
isAdmin
|
}
|
||||||
? 'Leave filed and auto-approved.'
|
if (isMobile) {
|
||||||
: 'Leave application submitted for approval.',
|
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,17 +253,24 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
m3ShowDialog(
|
void onSubmitted() {
|
||||||
context: context,
|
if (mounted) showSuccessSnackBar(context, 'Pass slip requested.');
|
||||||
builder: (ctx) => _PassSlipDialog(
|
}
|
||||||
scheduleId: todaySchedule.first.id,
|
|
||||||
onSubmitted: () {
|
final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet;
|
||||||
if (mounted) {
|
if (isMobile) {
|
||||||
showSuccessSnackBar(context, 'Pass slip requested.');
|
m3ShowBottomSheet<void>(
|
||||||
}
|
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 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(
|
||||||
currentUserId: currentUserId,
|
dateLabel: groups[i].key,
|
||||||
onReverify: onReverify,
|
shiftGroups: _groupByShift(groups[i].value),
|
||||||
);
|
currentUserId: currentUserId,
|
||||||
}).toList(),
|
onReverify: onReverify,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,18 +5054,17 @@ 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,
|
historySlipList[i],
|
||||||
slip,
|
profileById,
|
||||||
profileById,
|
showActions: false,
|
||||||
showActions: false,
|
isPending: pendingSlips.any((p) => p.id == historySlipList[i].id),
|
||||||
isPending: pendingSlips.any((p) => p.id == slip.id),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
if (slips.isEmpty)
|
if (slips.isEmpty)
|
||||||
Center(
|
Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -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,15 +5322,17 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
...pendingApprovals.map(
|
for (int i = 0; i < pendingApprovals.length; i++)
|
||||||
(leave) => _buildLeaveCard(
|
M3FadeSlideIn(
|
||||||
context,
|
delay: Duration(milliseconds: i.clamp(0, 6) * 60),
|
||||||
leave,
|
child: _buildLeaveCard(
|
||||||
profileById,
|
context,
|
||||||
showApproval: true,
|
pendingApprovals[i],
|
||||||
isPending: pendingLeavesList.any((l) => l.id == leave.id),
|
profileById,
|
||||||
|
showApproval: true,
|
||||||
|
isPending: pendingLeavesList.any((l) => l.id == pendingApprovals[i].id),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -5325,17 +5356,17 @@ 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),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// ── All Leave History (admin only) ──
|
// ── All Leave History (admin only) ──
|
||||||
if (isAdmin) ...[
|
if (isAdmin) ...[
|
||||||
@@ -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,
|
allLeaveHistory[i],
|
||||||
leave,
|
profileById,
|
||||||
profileById,
|
showApproval: false,
|
||||||
showApproval: false,
|
isPending: pendingLeavesList.any((l) => l.id == allLeaveHistory[i].id),
|
||||||
isPending: pendingLeavesList.any((l) => l.id == leave.id),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (leaves
|
),
|
||||||
.where((l) => l.status != 'pending' && l.userId != profile.id)
|
if (allLeaveHistory.isEmpty)
|
||||||
.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,121 +5724,127 @@ class _PassSlipDialogState extends ConsumerState<_PassSlipDialog> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(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(
|
return Dialog(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 480),
|
constraints: const BoxConstraints(maxWidth: 480),
|
||||||
child: Padding(
|
child: Padding(padding: const EdgeInsets.all(24), child: content),
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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,165 +6014,153 @@ class _FileLeaveDialogState extends ConsumerState<_FileLeaveDialog> {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colors = theme.colorScheme;
|
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<String>(
|
||||||
|
// 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(
|
return Dialog(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 480),
|
constraints: const BoxConstraints(maxWidth: 480),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(child: content),
|
||||||
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<String>(
|
|
||||||
// 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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,10 +459,15 @@ class _ItServiceRequestDetailScreenState
|
|||||||
),
|
),
|
||||||
body: Skeletonizer(
|
body: Skeletonizer(
|
||||||
enabled: showSkeleton,
|
enabled: showSkeleton,
|
||||||
child: request == null && !showSkeleton
|
child: M3AnimatedSwitcher(
|
||||||
? const Center(child: Text('Request not found'))
|
child: request == null && !showSkeleton
|
||||||
: ResponsiveBody(
|
? const Center(
|
||||||
maxWidth: double.infinity,
|
key: ValueKey('isr_not_found'),
|
||||||
|
child: Text('Request not found'),
|
||||||
|
)
|
||||||
|
: ResponsiveBody(
|
||||||
|
key: const ValueKey('isr_content'),
|
||||||
|
maxWidth: double.infinity,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
AnimatedSwitcher(
|
AnimatedSwitcher(
|
||||||
@@ -516,6 +522,7 @@ class _ItServiceRequestDetailScreenState
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,138 +163,129 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final isWide = constraints.maxWidth >= 600;
|
final isWide = constraints.maxWidth >= 600;
|
||||||
|
|
||||||
|
final chartSections = <Widget>[
|
||||||
|
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(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// ── Status donuts (paired on desktop) ──
|
for (int i = 0; i < chartSections.length; i++)
|
||||||
if (_anyEnabled(enabled, [
|
M3FadeSlideIn(
|
||||||
ReportWidgetType.ticketsByStatus,
|
delay: Duration(milliseconds: i.clamp(0, 4) * 80),
|
||||||
ReportWidgetType.tasksByStatus,
|
child: chartSections[i],
|
||||||
]))
|
|
||||||
_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],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,106 +325,114 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
|||||||
latestAssigneeByTaskId: latestAssigneeByTaskId,
|
latestAssigneeByTaskId: latestAssigneeByTaskId,
|
||||||
);
|
);
|
||||||
|
|
||||||
final filterHeader = Wrap(
|
final filterHeader = LayoutBuilder(
|
||||||
spacing: 12,
|
builder: (context, constraints) {
|
||||||
runSpacing: 12,
|
final isMobile = constraints.maxWidth < 600;
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
final halfWidth = (constraints.maxWidth - 12) / 2;
|
||||||
children: [
|
return Wrap(
|
||||||
SizedBox(
|
spacing: 12,
|
||||||
width: 220,
|
runSpacing: 12,
|
||||||
child: TextField(
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
controller: _subjectController,
|
children: [
|
||||||
onChanged: (_) => setState(() {}),
|
SizedBox(
|
||||||
decoration: const InputDecoration(
|
width: isMobile ? constraints.maxWidth : 220,
|
||||||
labelText: 'Subject',
|
child: TextField(
|
||||||
prefixIcon: Icon(Icons.search),
|
controller: _subjectController,
|
||||||
),
|
onChanged: (_) => setState(() {}),
|
||||||
),
|
decoration: const InputDecoration(
|
||||||
),
|
labelText: 'Subject',
|
||||||
SizedBox(
|
prefixIcon: Icon(Icons.search),
|
||||||
width: 200,
|
),
|
||||||
child: DropdownButtonFormField<String?>(
|
|
||||||
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<String?>(
|
|
||||||
isExpanded: true,
|
|
||||||
key: ValueKey(_selectedAssigneeId),
|
|
||||||
initialValue: _selectedAssigneeId,
|
|
||||||
items: staffOptions,
|
|
||||||
onChanged: (value) =>
|
|
||||||
setState(() => _selectedAssigneeId = value),
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Assigned staff',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
SizedBox(
|
||||||
SizedBox(
|
width: isMobile ? halfWidth : 200,
|
||||||
width: 180,
|
child: DropdownButtonFormField<String?>(
|
||||||
child: DropdownButtonFormField<String?>(
|
isExpanded: true,
|
||||||
isExpanded: true,
|
key: ValueKey(_selectedOfficeId),
|
||||||
key: ValueKey(_selectedStatus),
|
initialValue: _selectedOfficeId,
|
||||||
initialValue: _selectedStatus,
|
items: officeOptions,
|
||||||
items: statusOptions,
|
onChanged: (value) =>
|
||||||
onChanged: (value) =>
|
setState(() => _selectedOfficeId = value),
|
||||||
setState(() => _selectedStatus = value),
|
decoration:
|
||||||
decoration: const InputDecoration(labelText: 'Status'),
|
const InputDecoration(labelText: 'Office'),
|
||||||
),
|
|
||||||
),
|
|
||||||
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,
|
SizedBox(
|
||||||
);
|
width: isMobile ? halfWidth : 160,
|
||||||
if (!mounted) return;
|
child: TextField(
|
||||||
setState(() => _selectedDateRange = next);
|
controller: _taskNumberController,
|
||||||
},
|
onChanged: (_) => setState(() {}),
|
||||||
icon: const Icon(Icons.date_range),
|
decoration: const InputDecoration(
|
||||||
label: Text(
|
labelText: 'Task #',
|
||||||
_selectedDateRange == null
|
prefixIcon: Icon(Icons.filter_alt),
|
||||||
? 'Date range'
|
),
|
||||||
: AppTime.formatDateRange(_selectedDateRange!),
|
),
|
||||||
),
|
),
|
||||||
),
|
if (_tabController.index == 1)
|
||||||
if (_hasTaskFilters)
|
SizedBox(
|
||||||
TextButton.icon(
|
width: isMobile ? halfWidth : 220,
|
||||||
onPressed: () => setState(() {
|
child: DropdownButtonFormField<String?>(
|
||||||
_subjectController.clear();
|
isExpanded: true,
|
||||||
_selectedOfficeId = null;
|
key: ValueKey(_selectedAssigneeId),
|
||||||
_selectedStatus = null;
|
initialValue: _selectedAssigneeId,
|
||||||
_selectedAssigneeId = null;
|
items: staffOptions,
|
||||||
_selectedDateRange = null;
|
onChanged: (value) =>
|
||||||
}),
|
setState(() => _selectedAssigneeId = value),
|
||||||
icon: const Icon(Icons.close),
|
decoration: const InputDecoration(
|
||||||
label: const Text('Clear'),
|
labelText: 'Assigned staff',
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: isMobile ? halfWidth : 180,
|
||||||
|
child: DropdownButtonFormField<String?>(
|
||||||
|
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
|
// reusable helper for rendering a list given a subset of tasks
|
||||||
@@ -803,25 +812,22 @@ 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(
|
mainAxisSize: MainAxisSize.min,
|
||||||
width: 360,
|
children: [
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -1038,77 +1044,126 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
|||||||
error: (error, _) =>
|
error: (error, _) =>
|
||||||
Text('Failed to load offices: $error'),
|
Text('Failed to load offices: $error'),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> 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<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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
),
|
||||||
TextButton(
|
);
|
||||||
onPressed: saving
|
},
|
||||||
? null
|
);
|
||||||
: () => Navigator.of(dialogContext).pop(),
|
} else {
|
||||||
child: const Text('Cancel'),
|
await m3ShowDialog<void>(
|
||||||
),
|
context: context,
|
||||||
FilledButton(
|
builder: (dialogContext) {
|
||||||
onPressed: saving
|
final officesAsync = ref.watch(officesProvider);
|
||||||
? null
|
return StatefulBuilder(
|
||||||
: () async {
|
builder: (context, setState) => AlertDialog(
|
||||||
final title =
|
scrollable: true,
|
||||||
SubjectSuggestionEngine.normalizeDisplay(
|
shape: AppSurfaces.of(context).dialogShape,
|
||||||
titleController.text.trim(),
|
title: const Text('Create Task'),
|
||||||
);
|
content: SizedBox(
|
||||||
final description = descriptionController.text.trim();
|
width: 600,
|
||||||
final officeId = selectedOfficeId;
|
child: buildFormColumn(setState, officesAsync),
|
||||||
if (title.isEmpty || officeId == null) {
|
),
|
||||||
return;
|
actions: buildActions(dialogContext, setState),
|
||||||
}
|
),
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _hasTaskMention(
|
bool _hasTaskMention(
|
||||||
|
|||||||
@@ -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,152 +442,163 @@ 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(
|
final officesAsync = ref.watch(officesProvider);
|
||||||
shape: AppSurfaces.of(context).dialogShape,
|
return Column(
|
||||||
title: const Text('Create Ticket'),
|
mainAxisSize: MainAxisSize.min,
|
||||||
content: Consumer(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
builder: (context, ref, child) {
|
children: [
|
||||||
final officesAsync = ref.watch(officesProvider);
|
TextField(
|
||||||
return SizedBox(
|
controller: subjectController,
|
||||||
width: 360,
|
decoration: const InputDecoration(labelText: 'Subject'),
|
||||||
child: Column(
|
enabled: !saving,
|
||||||
mainAxisSize: MainAxisSize.min,
|
),
|
||||||
children: [
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: subjectController,
|
controller: descriptionController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(labelText: 'Description'),
|
||||||
labelText: 'Subject',
|
maxLines: 3,
|
||||||
),
|
enabled: !saving,
|
||||||
enabled: !saving,
|
),
|
||||||
),
|
const SizedBox(height: 12),
|
||||||
const SizedBox(height: 12),
|
officesAsync.when(
|
||||||
TextField(
|
data: (offices) {
|
||||||
controller: descriptionController,
|
if (offices.isEmpty) return const Text('No offices assigned.');
|
||||||
decoration: const InputDecoration(
|
final officesSorted = List<Office>.from(offices)
|
||||||
labelText: 'Description',
|
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
),
|
selectedOffice ??= officesSorted.first;
|
||||||
maxLines: 3,
|
return DropdownButtonFormField<Office>(
|
||||||
enabled: !saving,
|
key: ValueKey(selectedOffice?.id),
|
||||||
),
|
initialValue: selectedOffice,
|
||||||
const SizedBox(height: 12),
|
items: officesSorted
|
||||||
officesAsync.when(
|
.map((o) => DropdownMenuItem(value: o, child: Text(o.name)))
|
||||||
data: (offices) {
|
.toList(),
|
||||||
if (offices.isEmpty) {
|
onChanged: saving ? null : (v) => setState(() => selectedOffice = v),
|
||||||
return const Text('No offices assigned.');
|
decoration: const InputDecoration(labelText: 'Office'),
|
||||||
}
|
|
||||||
final officesSorted = List<Office>.from(offices)
|
|
||||||
..sort(
|
|
||||||
(a, b) => a.name.toLowerCase().compareTo(
|
|
||||||
b.name.toLowerCase(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
selectedOffice ??= officesSorted.first;
|
|
||||||
return DropdownButtonFormField<Office>(
|
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
loading: () => const LinearProgressIndicator(),
|
||||||
|
error: (e, _) => Text('Failed to load offices: $e'),
|
||||||
),
|
),
|
||||||
actions: [
|
],
|
||||||
TextButton(
|
);
|
||||||
onPressed: saving
|
},
|
||||||
? null
|
);
|
||||||
: () => Navigator.of(dialogContext).pop(),
|
}
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
List<Widget> buildActions(BuildContext closeCtx, StateSetter setState) {
|
||||||
FilledButton(
|
return [
|
||||||
onPressed: saving
|
TextButton(
|
||||||
? null
|
onPressed: saving ? null : () => Navigator.of(closeCtx).pop(),
|
||||||
: () async {
|
child: const Text('Cancel'),
|
||||||
final subject = subjectController.text.trim();
|
),
|
||||||
final description = descriptionController.text.trim();
|
FilledButton(
|
||||||
if (subject.isEmpty ||
|
onPressed: saving
|
||||||
description.isEmpty ||
|
? null
|
||||||
selectedOffice == null) {
|
: () async {
|
||||||
showWarningSnackBar(
|
final subject = subjectController.text.trim();
|
||||||
context,
|
final description = descriptionController.text.trim();
|
||||||
'Fill out all fields.',
|
if (subject.isEmpty || description.isEmpty || selectedOffice == null) {
|
||||||
);
|
showWarningSnackBar(closeCtx, 'Fill out all fields.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => saving = true);
|
setState(() => saving = true);
|
||||||
try {
|
try {
|
||||||
final pendingTicket = await ref
|
final pendingTicket = await ref
|
||||||
.read(ticketsControllerProvider)
|
.read(ticketsControllerProvider)
|
||||||
.createTicket(
|
.createTicket(
|
||||||
subject: subject,
|
subject: subject,
|
||||||
description: description,
|
description: description,
|
||||||
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 (closeCtx.mounted) {
|
||||||
if (context.mounted) {
|
Navigator.of(closeCtx).pop();
|
||||||
Navigator.of(dialogContext).pop();
|
showSuccessSnackBar(
|
||||||
showSuccessSnackBar(
|
closeCtx,
|
||||||
context,
|
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 (!closeCtx.mounted) return;
|
||||||
if (!dialogContext.mounted) return;
|
showErrorSnackBarGlobal('Failed to create ticket: $e');
|
||||||
showErrorSnackBarGlobal('Failed to create ticket: $e');
|
} finally {
|
||||||
} finally {
|
if (closeCtx.mounted) setState(() => saving = false);
|
||||||
if (dialogContext.mounted) {
|
}
|
||||||
setState(() => saving = false);
|
},
|
||||||
}
|
child: saving
|
||||||
}
|
? const SizedBox(
|
||||||
},
|
height: 18,
|
||||||
child: saving
|
width: 18,
|
||||||
? const SizedBox(
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
height: 18,
|
)
|
||||||
width: 18,
|
: const Text('Create'),
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
),
|
||||||
)
|
];
|
||||||
: 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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user