* 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',
);
}
}