* 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,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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user