Team Color, image compression for attendance verification, improved wherebouts

This commit is contained in:
2026-03-08 12:23:28 +08:00
parent a8751ca728
commit d87b5e73d7
11 changed files with 757 additions and 191 deletions
+14 -2
View File
@@ -31,6 +31,10 @@ class AppScaffold extends ConsumerWidget {
},
orElse: () => 'User',
);
final avatarUrl = profileAsync.maybeWhen(
data: (profile) => profile?.avatarUrl,
orElse: () => null,
);
final isStandard = role == 'standard';
final location = GoRouterState.of(context).uri.toString();
@@ -75,7 +79,11 @@ class AppScaffold extends ConsumerWidget {
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
ProfileAvatar(fullName: displayName, radius: 16),
ProfileAvatar(
fullName: displayName,
avatarUrl: avatarUrl,
radius: 16,
),
const SizedBox(width: 8),
Text(displayName),
const SizedBox(width: 4),
@@ -91,7 +99,11 @@ class AppScaffold extends ConsumerWidget {
IconButton(
tooltip: 'Profile',
onPressed: () => context.go('/profile'),
icon: ProfileAvatar(fullName: displayName, radius: 16),
icon: ProfileAvatar(
fullName: displayName,
avatarUrl: avatarUrl,
radius: 16,
),
),
IconButton(
tooltip: 'Sign out',
+44 -7
View File
@@ -8,11 +8,20 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/attendance_provider.dart';
import '../providers/profile_provider.dart';
import '../services/face_verification.dart' as face;
import '../services/image_compress_service.dart';
import '../theme/m3_motion.dart';
import '../widgets/qr_verification_dialog.dart';
/// Phases of the full-screen face verification overlay.
enum _Phase { liveness, downloading, matching, success, failed, cancelled }
enum _Phase {
liveness,
downloading,
matching,
saving,
success,
failed,
cancelled,
}
/// Result returned from the overlay.
class FaceVerificationResult {
@@ -170,14 +179,19 @@ class _FaceVerificationOverlayState
final score = await face.compareFaces(result.imageBytes, enrolledBytes);
if (score >= 0.60) {
// Success!
// Success! Transition to saving phase for compress + upload.
if (!mounted) return;
setState(() {
_phase = _Phase.saving;
_statusText = 'Compressing & saving photo...';
});
await _compressAndUpload(result.imageBytes, 'verified');
if (!mounted) return;
setState(() {
_phase = _Phase.success;
_statusText =
'Face verified!\n${(score * 100).toStringAsFixed(0)}% match';
});
await _uploadResult(result.imageBytes, 'verified');
await Future.delayed(const Duration(milliseconds: 1200));
if (mounted) {
Navigator.of(
@@ -201,13 +215,18 @@ class _FaceVerificationOverlayState
} else {
// All attempts exhausted
if (!mounted) return;
setState(() {
_phase = _Phase.saving;
_statusText = 'Compressing & saving photo...';
});
await _compressAndUpload(result.imageBytes, 'unverified');
if (!mounted) return;
setState(() {
_phase = _Phase.failed;
_statusText =
'Face did not match after ${widget.maxAttempts} attempts\n'
'${(score * 100).toStringAsFixed(0)}% similarity';
});
await _uploadResult(result.imageBytes, 'unverified');
await Future.delayed(const Duration(milliseconds: 1500));
if (mounted) {
Navigator.of(
@@ -218,16 +237,17 @@ class _FaceVerificationOverlayState
}
}
Future<void> _uploadResult(Uint8List bytes, String status) async {
Future<void> _compressAndUpload(Uint8List bytes, String status) async {
if (!widget.uploadAttendanceResult || widget.attendanceLogId == null) {
return;
}
try {
final compressed = await ImageCompressService.compress(bytes);
await ref
.read(attendanceControllerProvider)
.uploadVerification(
attendanceId: widget.attendanceLogId!,
bytes: bytes,
bytes: compressed,
fileName: 'verification.jpg',
status: status,
);
@@ -280,6 +300,8 @@ class _FaceVerificationOverlayState
? Colors.green
: _phase == _Phase.failed
? colors.error
: _phase == _Phase.saving
? colors.tertiary
: colors.onSurface,
fontWeight: FontWeight.w600,
),
@@ -304,6 +326,7 @@ class _FaceVerificationOverlayState
_phase == _Phase.liveness ||
_phase == _Phase.downloading ||
_phase == _Phase.matching;
final isSaving = _phase == _Phase.saving;
final isSuccess = _phase == _Phase.success;
final isFailed = _phase == _Phase.failed;
@@ -312,6 +335,8 @@ class _FaceVerificationOverlayState
ringColor = Colors.green;
} else if (isFailed) {
ringColor = colors.error;
} else if (isSaving) {
ringColor = colors.tertiary;
} else {
ringColor = colors.primary;
}
@@ -324,7 +349,9 @@ class _FaceVerificationOverlayState
children: [
// Pulsing ring
ScaleTransition(
scale: isActive ? _pulseAnim : const AlwaysStoppedAnimation(1.0),
scale: isActive || isSaving
? _pulseAnim
: const AlwaysStoppedAnimation(1.0),
child: Container(
width: 180,
height: 180,
@@ -355,6 +382,8 @@ class _FaceVerificationOverlayState
? Icons.check_circle_rounded
: isFailed
? Icons.error_rounded
: isSaving
? Icons.cloud_upload_rounded
: Icons.face_rounded,
key: ValueKey(_phase),
size: 64,
@@ -400,6 +429,14 @@ class _FaceVerificationOverlayState
borderRadius: BorderRadius.circular(4),
);
}
if (_phase == _Phase.saving) {
return LinearProgressIndicator(
value: null,
backgroundColor: colors.tertiary.withValues(alpha: 0.12),
color: colors.tertiary,
borderRadius: BorderRadius.circular(4),
);
}
return LinearProgressIndicator(
value: null,
backgroundColor: colors.primary.withValues(alpha: 0.12),