485 lines
15 KiB
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),
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
}
|