Attendance validation involving Location Detection + Facial Recoginition with Liveness Detection

This commit is contained in:
2026-03-07 23:46:43 +08:00
parent 52ef36faac
commit 3dbebd4006
25 changed files with 4840 additions and 361 deletions
+431
View File
@@ -0,0 +1,431 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
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 '../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 }
/// Result returned from the overlay.
class FaceVerificationResult {
final bool verified;
final double? matchScore;
FaceVerificationResult({required this.verified, this.matchScore});
}
/// Shows a full-screen animated face verification overlay with up to
/// [maxAttempts] retries. Returns whether the user was verified.
Future<FaceVerificationResult?> showFaceVerificationOverlay({
required BuildContext context,
required WidgetRef ref,
required String attendanceLogId,
int maxAttempts = 3,
}) {
return Navigator.of(context).push<FaceVerificationResult>(
PageRouteBuilder(
opaque: true,
pageBuilder: (ctx, anim, secAnim) => _FaceVerificationOverlay(
attendanceLogId: attendanceLogId,
maxAttempts: maxAttempts,
),
transitionsBuilder: (ctx, anim, secAnim, child) {
return FadeTransition(opacity: anim, child: child);
},
transitionDuration: M3Motion.standard,
reverseTransitionDuration: M3Motion.short,
),
);
}
class _FaceVerificationOverlay extends ConsumerStatefulWidget {
const _FaceVerificationOverlay({
required this.attendanceLogId,
required this.maxAttempts,
});
final String attendanceLogId;
final int maxAttempts;
@override
ConsumerState<_FaceVerificationOverlay> createState() =>
_FaceVerificationOverlayState();
}
class _FaceVerificationOverlayState
extends ConsumerState<_FaceVerificationOverlay>
with TickerProviderStateMixin {
_Phase _phase = _Phase.liveness;
int _attempt = 1;
String _statusText = 'Preparing liveness check...';
late AnimationController _scanCtrl;
late AnimationController _pulseCtrl;
late Animation<double> _pulseAnim;
@override
void initState() {
super.initState();
_scanCtrl = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat();
_pulseCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat(reverse: true);
_pulseAnim = Tween<double>(
begin: 0.85,
end: 1.0,
).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
WidgetsBinding.instance.addPostFrameCallback((_) => _runAttempt());
}
@override
void dispose() {
_scanCtrl.dispose();
_pulseCtrl.dispose();
super.dispose();
}
Future<void> _runAttempt() async {
if (!mounted) return;
setState(() {
_phase = _Phase.liveness;
_statusText =
'Attempt $_attempt of ${widget.maxAttempts}\nPerforming liveness check...';
});
// 1. Liveness check
final result = await face.runFaceLiveness(context);
if (result == null) {
if (!mounted) return;
// Cancelled: on web offer QR, otherwise mark cancelled
if (kIsWeb) {
final completed = await showQrVerificationDialog(
context: context,
ref: ref,
type: 'verification',
contextId: widget.attendanceLogId,
);
if (mounted) {
Navigator.of(
context,
).pop(FaceVerificationResult(verified: completed));
}
} else {
setState(() {
_phase = _Phase.cancelled;
_statusText = 'Verification cancelled.';
});
await Future.delayed(const Duration(milliseconds: 800));
if (mounted) {
Navigator.of(context).pop(FaceVerificationResult(verified: false));
}
}
return;
}
// 2. Download enrolled photo
if (!mounted) return;
setState(() {
_phase = _Phase.downloading;
_statusText = 'Fetching enrolled face data...';
});
final profile = ref.read(currentProfileProvider).valueOrNull;
if (profile == null || !profile.hasFaceEnrolled) {
_skipVerification('No enrolled face found.');
return;
}
final enrolledBytes = await ref
.read(profileControllerProvider)
.downloadFacePhoto(profile.id);
if (enrolledBytes == null) {
_skipVerification('Could not download enrolled face.');
return;
}
// 3. Face matching
if (!mounted) return;
setState(() {
_phase = _Phase.matching;
_statusText = 'Analyzing face match...';
});
final score = await face.compareFaces(result.imageBytes, enrolledBytes);
if (score >= 0.60) {
// Success!
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(
context,
).pop(FaceVerificationResult(verified: true, matchScore: score));
}
} else {
// Failed attempt
if (_attempt < widget.maxAttempts) {
if (!mounted) return;
setState(() {
_phase = _Phase.failed;
_statusText =
'No match (${(score * 100).toStringAsFixed(0)}%)\n'
'Attempt $_attempt of ${widget.maxAttempts}\n'
'Retrying...';
});
await Future.delayed(const Duration(milliseconds: 1500));
_attempt++;
_runAttempt();
} else {
// All attempts exhausted
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(
context,
).pop(FaceVerificationResult(verified: false, matchScore: score));
}
}
}
}
Future<void> _uploadResult(Uint8List bytes, String status) async {
try {
await ref
.read(attendanceControllerProvider)
.uploadVerification(
attendanceId: widget.attendanceLogId,
bytes: bytes,
fileName: 'verification.jpg',
status: status,
);
} catch (_) {}
}
Future<void> _skipVerification(String reason) async {
try {
await ref
.read(attendanceControllerProvider)
.skipVerification(widget.attendanceLogId);
} catch (_) {}
if (!mounted) return;
setState(() {
_phase = _Phase.cancelled;
_statusText = reason;
});
await Future.delayed(const Duration(milliseconds: 1200));
if (mounted) {
Navigator.of(context).pop(FaceVerificationResult(verified: false));
}
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
backgroundColor: colors.surface,
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildFaceIndicator(colors),
const SizedBox(height: 32),
AnimatedSwitcher(
duration: M3Motion.short,
child: Text(
_statusText,
key: ValueKey('$_phase-$_attempt'),
textAlign: TextAlign.center,
style: textTheme.titleMedium?.copyWith(
color: _phase == _Phase.success
? Colors.green
: _phase == _Phase.failed
? colors.error
: colors.onSurface,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 16),
if (_phase != _Phase.success && _phase != _Phase.cancelled)
_buildProgressIndicator(colors),
const SizedBox(height: 24),
_buildAttemptDots(colors),
],
),
),
),
),
);
}
/// Animated face icon with scanning line and pulse.
Widget _buildFaceIndicator(ColorScheme colors) {
final isActive =
_phase == _Phase.liveness ||
_phase == _Phase.downloading ||
_phase == _Phase.matching;
final isSuccess = _phase == _Phase.success;
final isFailed = _phase == _Phase.failed;
final Color ringColor;
if (isSuccess) {
ringColor = Colors.green;
} else if (isFailed) {
ringColor = colors.error;
} else {
ringColor = colors.primary;
}
return SizedBox(
width: 200,
height: 200,
child: Stack(
alignment: Alignment.center,
children: [
// Pulsing ring
ScaleTransition(
scale: isActive ? _pulseAnim : const AlwaysStoppedAnimation(1.0),
child: Container(
width: 180,
height: 180,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: ringColor.withValues(alpha: 0.3),
width: 3,
),
),
),
),
// Inner circle with icon
AnimatedContainer(
duration: M3Motion.standard,
curve: M3Motion.standard_,
width: 140,
height: 140,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: ringColor.withValues(alpha: 0.08),
border: Border.all(color: ringColor, width: 2),
),
child: AnimatedSwitcher(
duration: M3Motion.short,
child: Icon(
isSuccess
? Icons.check_circle_rounded
: isFailed
? Icons.error_rounded
: Icons.face_rounded,
key: ValueKey(_phase),
size: 64,
color: ringColor,
),
),
),
// Scanning line (only during active phases)
if (isActive)
AnimatedBuilder(
animation: _scanCtrl,
builder: (context, child) {
final yOffset = sin(_scanCtrl.value * 2 * pi) * 60;
return Transform.translate(
offset: Offset(0, yOffset),
child: Container(
width: 120,
height: 2,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
colors.primary.withValues(alpha: 0.0),
colors.primary.withValues(alpha: 0.6),
colors.primary.withValues(alpha: 0.0),
],
),
),
),
);
},
),
],
),
);
}
Widget _buildProgressIndicator(ColorScheme colors) {
if (_phase == _Phase.failed) {
return LinearProgressIndicator(
value: null,
backgroundColor: colors.error.withValues(alpha: 0.12),
color: colors.error,
borderRadius: BorderRadius.circular(4),
);
}
return LinearProgressIndicator(
value: null,
backgroundColor: colors.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
);
}
/// Dots showing attempt progress.
Widget _buildAttemptDots(ColorScheme colors) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(widget.maxAttempts, (i) {
final attemptNum = i + 1;
final bool isDone = attemptNum < _attempt;
final bool isCurrent = attemptNum == _attempt;
final bool isSuccess_ = isCurrent && _phase == _Phase.success;
Color dotColor;
if (isSuccess_) {
dotColor = Colors.green;
} else if (isDone) {
dotColor = colors.error.withValues(alpha: 0.5);
} else if (isCurrent) {
dotColor = colors.primary;
} else {
dotColor = colors.outlineVariant;
}
return AnimatedContainer(
duration: M3Motion.short,
curve: M3Motion.standard_,
margin: const EdgeInsets.symmetric(horizontal: 4),
width: isCurrent ? 12 : 8,
height: isCurrent ? 12 : 8,
decoration: BoxDecoration(shape: BoxShape.circle, color: dotColor),
);
}),
);
}
}
+230
View File
@@ -0,0 +1,230 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../models/verification_session.dart';
import '../providers/profile_provider.dart';
import '../providers/verification_session_provider.dart';
import '../utils/snackbar.dart';
/// Shows a dialog with a QR code for cross-device face verification.
///
/// On web, when the camera is unavailable, this dialog is shown so the user
/// can scan the QR with the TasQ mobile app to perform liveness detection.
/// The dialog listens via Supabase Realtime for the session to complete.
///
/// Returns `true` if the verification was completed successfully.
Future<bool> showQrVerificationDialog({
required BuildContext context,
required WidgetRef ref,
required String type,
String? contextId,
}) async {
final controller = ref.read(verificationSessionControllerProvider);
// Create the verification session
final session = await controller.createSession(
type: type,
contextId: contextId,
);
if (!context.mounted) return false;
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => _QrVerificationDialog(session: session),
);
// If dismissed without completion, expire the session
if (result != true) {
await controller.expireSession(session.id);
}
return result == true;
}
class _QrVerificationDialog extends ConsumerStatefulWidget {
const _QrVerificationDialog({required this.session});
final VerificationSession session;
@override
ConsumerState<_QrVerificationDialog> createState() =>
_QrVerificationDialogState();
}
class _QrVerificationDialogState extends ConsumerState<_QrVerificationDialog> {
StreamSubscription<VerificationSession>? _subscription;
bool _completed = false;
bool _expired = false;
late Timer _expiryTimer;
@override
void initState() {
super.initState();
// Listen for session completion via realtime
final controller = ref.read(verificationSessionControllerProvider);
_subscription = controller.watchSession(widget.session.id).listen((
session,
) {
if (session.isCompleted && !_completed) {
_onCompleted(session);
}
}, onError: (_) {});
// Auto-expire after the session's TTL
final remaining = widget.session.expiresAt.difference(
DateTime.now().toUtc(),
);
_expiryTimer = Timer(remaining.isNegative ? Duration.zero : remaining, () {
if (mounted && !_completed) {
setState(() => _expired = true);
}
});
}
Future<void> _onCompleted(VerificationSession session) async {
_completed = true;
if (!mounted) return;
// Apply the result (update profile face photo or attendance log)
final controller = ref.read(verificationSessionControllerProvider);
await controller.applySessionResult(session);
// Invalidate profile so UI refreshes
ref.invalidate(currentProfileProvider);
if (mounted) {
Navigator.of(context).pop(true);
showSuccessSnackBar(
context,
session.type == 'enrollment'
? 'Face enrolled successfully via mobile.'
: 'Face verification completed via mobile.',
);
}
}
@override
void dispose() {
_subscription?.cancel();
_expiryTimer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final qrData = 'tasq://verify/${widget.session.id}';
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
title: Row(
children: [
Icon(Icons.qr_code_2, color: colors.primary),
const SizedBox(width: 12),
const Expanded(child: Text('Scan with Mobile')),
],
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.session.type == 'enrollment'
? 'Open the TasQ app on your phone and scan this QR code to enroll your face with liveness detection.'
: 'Open the TasQ app on your phone and scan this QR code to verify your face with liveness detection.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 24),
if (_expired)
_buildExpiredState(theme, colors)
else
_buildQrCode(theme, colors, qrData),
const SizedBox(height: 16),
if (!_expired) ...[
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colors.primary,
),
),
const SizedBox(width: 12),
Text(
'Waiting for mobile verification...',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
],
);
}
Widget _buildQrCode(ThemeData theme, ColorScheme colors, String qrData) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colors.outlineVariant),
),
child: QrImageView(
data: qrData,
version: QrVersions.auto,
size: 220,
eyeStyle: const QrEyeStyle(
eyeShape: QrEyeShape.square,
color: Colors.black87,
),
dataModuleStyle: const QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: Colors.black87,
),
),
);
}
Widget _buildExpiredState(ThemeData theme, ColorScheme colors) {
return Column(
children: [
Icon(Icons.timer_off, size: 48, color: colors.error),
const SizedBox(height: 12),
Text(
'Session expired',
style: theme.textTheme.titleMedium?.copyWith(color: colors.error),
),
const SizedBox(height: 4),
Text(
'Close this dialog and try again.',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
);
}
}