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

* Pass Slip + Leave — both dialogs gained an isSheet flag; callers now branch on AppBreakpoints.tablet: bottom sheet on mobile, dialog on desktop
This commit is contained in:
2026-04-30 06:45:51 +08:00
parent 48cd3bcd60
commit 783c5eb0be
19 changed files with 1224 additions and 887 deletions
+92 -4
View File
@@ -1,7 +1,9 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:flutter/foundation.dart' show debugPrint, kIsWeb;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:uuid/uuid.dart';
@@ -219,6 +221,7 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
'[attendanceLogsProvider] reconnected — syncing ${pendingUpdatesMap.length} attendance update(s)',
);
final syncedIds = <String>[];
final filesToDelete = <String>[];
for (final entry in pendingUpdatesMap.entries) {
final attendanceId = entry.key;
final fields = Map<String, dynamic>.from(entry.value);
@@ -243,12 +246,64 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
);
}
} else {
// If a verification photo was queued to disk while offline, upload
// the bytes first and append the resulting public URL to `fields`.
final localPath = fields.remove('_local_photo_path') as String?;
final isCheckOut = fields.remove('_is_checkout') as bool? ?? false;
final fileName = fields.remove('_file_name') as String?;
String? pendingFileToDelete;
if (localPath != null && !kIsWeb) {
try {
final file = File(localPath);
if (await file.exists()) {
final bytes = await file.readAsBytes();
final userId = client.auth.currentUser?.id;
if (userId != null) {
final ext = (fileName ?? 'photo.jpg')
.split('.')
.last
.toLowerCase();
final prefix = isCheckOut ? 'checkout' : 'checkin';
final storagePath =
'$userId/${prefix}_$attendanceId.$ext';
await client.storage
.from('attendance-verification')
.uploadBinary(
storagePath,
bytes,
fileOptions: const FileOptions(upsert: true),
);
final url = client.storage
.from('attendance-verification')
.getPublicUrl(storagePath);
final column = isCheckOut
? 'check_out_verification_photo_url'
: 'check_in_verification_photo_url';
fields[column] = url;
pendingFileToDelete = localPath;
}
} else {
debugPrint(
'[attendanceLogsProvider] local photo missing for id=$attendanceId — replaying status only',
);
}
} catch (e) {
// Photo upload failed — leave entry in queue for next reconnect.
debugPrint(
'[attendanceLogsProvider] failed to upload local photo id=$attendanceId: $e',
);
continue;
}
}
try {
await client
.from('attendance_logs')
.update(fields)
.eq('id', attendanceId);
syncedIds.add(attendanceId);
if (pendingFileToDelete != null) {
filesToDelete.add(pendingFileToDelete);
}
} catch (e) {
debugPrint(
'[attendanceLogsProvider] failed to sync update id=$attendanceId: $e',
@@ -256,6 +311,12 @@ final attendanceLogsProvider = StreamProvider<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) {
final remaining = Map<String, Map<String, dynamic>>.from(
ref.read(offlinePendingAttendanceUpdatesProvider),
@@ -408,11 +469,38 @@ class AttendanceController {
.eq('id', attendanceId);
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
// Queue status update without photo URL — photo bytes are not persisted
// across sessions; user will need to re-upload photo when online.
// Persist photo bytes to disk so the reconnect-replay path can upload
// them later. Disk write only on mobile/desktop — `path_provider` is
// unavailable on web, where we fall back to status-only queueing.
if (!kIsWeb) {
try {
final dir = await getApplicationDocumentsDirectory();
final pendingDir = Directory('${dir.path}/verification_pending');
if (!await pendingDir.exists()) {
await pendingDir.create(recursive: true);
}
final localPath = '${pendingDir.path}/${attendanceId}_$prefix.$ext';
await File(localPath).writeAsBytes(bytes);
_queueAttendanceUpdate(attendanceId, {
'verification_status': status,
'_local_photo_path': localPath,
'_is_checkout': isCheckOut,
'_file_name': fileName,
});
debugPrint(
'[AttendanceController] uploadVerification queued offline with photo: $attendanceId',
);
return;
} catch (writeErr) {
debugPrint(
'[AttendanceController] failed to persist offline photo for $attendanceId: $writeErr — falling back to status-only queue',
);
}
}
// Web or disk-write failure: queue status only.
_queueAttendanceUpdate(attendanceId, {'verification_status': status});
debugPrint(
'[AttendanceController] uploadVerification status queued offline (photo not persisted): $attendanceId',
'[AttendanceController] uploadVerification status queued offline (no photo): $attendanceId',
);
}
}
+17 -1
View File
@@ -1,5 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:flutter/foundation.dart' show debugPrint, kIsWeb;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
@@ -46,6 +46,22 @@ final connectivityMonitorProvider = Provider.autoDispose<void>((ref) {
/// Pings the app's own backend to determine online status.
class ConnectivityMonitor {
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 {
final res = await http
.head(Uri.parse('https://tasq.crmc.ph'))
+75 -4
View File
@@ -1,8 +1,11 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:flutter/foundation.dart' show debugPrint, kIsWeb;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../brick/cache_helpers.dart';
@@ -13,6 +16,61 @@ import 'connectivity_provider.dart';
import 'supabase_provider.dart';
import 'stream_recovery.dart';
const _kFaceCacheEnrolledAtPrefix = 'face_cache_enrolled_at:';
/// Reads cached enrolled-face bytes from app documents (mobile/desktop only).
Future<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
// ---------------------------------------------------------------------------
@@ -217,13 +275,26 @@ class ProfileController {
/// Download the face enrollment photo bytes for the given user.
/// Uses Supabase authenticated storage API (works with private buckets).
Future<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 {
return await _client.storage
final bytes = await _client.storage
.from('face-enrollment')
.download('$userId/face.jpg');
// Best-effort cache write so face matching can run offline next time.
unawaited(_writeCachedEnrolledFace(userId, bytes, enrolledAt));
return bytes;
} catch (_) {
return null;
return await _readCachedEnrolledFace(userId);
}
}
}
+31 -8
View File
@@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../brick/cache_helpers.dart';
@@ -12,16 +14,37 @@ import 'supabase_provider.dart';
import 'stream_recovery.dart';
import 'realtime_controller.dart';
const _kGeofenceCacheKey = 'geofence_cache_v1';
final geofenceProvider = FutureProvider<GeofenceConfig?>((ref) async {
final client = ref.watch(supabaseClientProvider);
final data = await client
.from('app_settings')
.select()
.eq('key', 'geofence')
.maybeSingle();
if (data == null) return null;
final setting = AppSetting.fromMap(data);
return GeofenceConfig.fromJson(setting.value);
try {
final data = await client
.from('app_settings')
.select()
.eq('key', 'geofence')
.maybeSingle();
final prefs = await SharedPreferences.getInstance();
if (data == null) {
await prefs.setString(_kGeofenceCacheKey, 'null');
return null;
}
final setting = AppSetting.fromMap(data);
await prefs.setString(_kGeofenceCacheKey, jsonEncode(setting.value));
return GeofenceConfig.fromJson(setting.value);
} catch (_) {
// Offline / network failure — fall back to cached config so geofence
// validation still runs during offline check-in.
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_kGeofenceCacheKey);
if (raw == null || raw == 'null') return null;
final json = jsonDecode(raw) as Map<String, dynamic>;
return GeofenceConfig.fromJson(json);
} catch (_) {
return null;
}
}
});
/// Toggle to show/hide past schedules. Defaults to false (hide past).