tasq/lib/widgets/face_verification_overlay.dart

485 lines
15 KiB
Dart

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 '../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,
saving,
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,
String? attendanceLogId,
int maxAttempts = 3,
bool uploadAttendanceResult = true,
bool isCheckOut = false,
}) {
return Navigator.of(context).push<FaceVerificationResult>(
PageRouteBuilder(
opaque: true,
pageBuilder: (ctx, anim, secAnim) => _FaceVerificationOverlay(
attendanceLogId: attendanceLogId,
maxAttempts: maxAttempts,
uploadAttendanceResult: uploadAttendanceResult,
isCheckOut: isCheckOut,
),
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,
required this.uploadAttendanceResult,
this.isCheckOut = false,
});
final String? attendanceLogId;
final int maxAttempts;
final bool uploadAttendanceResult;
final bool isCheckOut;
@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 &&
widget.uploadAttendanceResult &&
widget.attendanceLogId != null) {
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! 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 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.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 Future.delayed(const Duration(milliseconds: 1500));
if (mounted) {
Navigator.of(
context,
).pop(FaceVerificationResult(verified: false, matchScore: score));
}
}
}
}
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: compressed,
fileName: 'verification.jpg',
status: status,
isCheckOut: widget.isCheckOut,
);
} catch (_) {}
}
Future<void> _skipVerification(String reason) async {
if (widget.uploadAttendanceResult && widget.attendanceLogId != null) {
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
: _phase == _Phase.saving
? colors.tertiary
: 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 isSaving = _phase == _Phase.saving;
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 if (isSaving) {
ringColor = colors.tertiary;
} else {
ringColor = colors.primary;
}
return SizedBox(
width: 200,
height: 200,
child: Stack(
alignment: Alignment.center,
children: [
// Pulsing ring
ScaleTransition(
scale: isActive || isSaving
? _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
: isSaving
? Icons.cloud_upload_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),
);
}
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),
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),
);
}),
);
}
}