import 'dart:io'; import 'dart:ui'; import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/face_provider.dart'; import '../../providers/setup_check_provider.dart'; import '../../widgets/atoms/custom_button.dart'; import '../../widgets/atoms/scanner_overlay.dart'; import '../../core/utils/camera_utils.dart'; import '../../core/theme.dart'; import '../profile/signature_screen.dart'; class FaceEnrollmentScreen extends StatefulWidget { final bool isOnboarding; const FaceEnrollmentScreen({this.isOnboarding = false, super.key}); @override State createState() => _FaceEnrollmentScreenState(); } class _FaceEnrollmentScreenState extends State with TickerProviderStateMixin { CameraController? _cameraController; bool _isCameraInitialized = false; CameraDescription? _frontCamera; bool _successHandled = false; late AnimationController _pulseController; late Animation _pulseAnimation; @override void initState() { super.initState(); _initializeCamera(); _pulseController = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), )..repeat(reverse: true); _pulseAnimation = Tween(begin: 0.6, end: 1.0).animate( CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), ); WidgetsBinding.instance.addPostFrameCallback((_) { context.read().reset(); }); } Future _initializeCamera() async { final cameras = await availableCameras(); _frontCamera = cameras.firstWhere( (camera) => camera.lensDirection == CameraLensDirection.front, orElse: () => cameras.first, ); _cameraController = CameraController( _frontCamera!, ResolutionPreset.medium, enableAudio: false, imageFormatGroup: Platform.isAndroid ? ImageFormatGroup.nv21 : ImageFormatGroup.bgra8888, ); await _cameraController!.initialize(); if (!mounted) return; _cameraController!.startImageStream((CameraImage image) { if (!mounted) return; final provider = context.read(); final isIdle = provider.recordingState == VideoRecordingState.idle; final isRecording = provider.recordingState == VideoRecordingState.recording; if (isIdle || isRecording) { provider.processCameraImage( image, _frontCamera!, CameraUtils.rotationIntToImageRotation( _frontCamera!.sensorOrientation), ); } }); setState(() { _isCameraInitialized = true; }); } @override void dispose() { _cameraController?.dispose(); _pulseController.dispose(); super.dispose(); } void _showLogoutDialog() { showDialog( context: context, builder: (ctx) => AlertDialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppTheme.radiusMd), ), title: Text("Ganti Akun?", style: AppTheme.heading3), content: const Text( "Anda akan keluar dari akun ini dan kembali ke halaman login.", ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: Text( "Batal", style: AppTheme.labelLarge.copyWith(color: AppTheme.textSecondary), ), ), TextButton( onPressed: () async { Navigator.pop(ctx); await context.read().logout(); if (context.mounted) { context.read().reset(); Navigator.pushNamedAndRemoveUntil( context, '/login', (route) => false, ); } }, child: Text( "Keluar", style: AppTheme.labelLarge.copyWith(color: AppTheme.statusRed), ), ), ], ), ); } Future _handleStartRecording(FaceProvider provider) async { if (_cameraController == null || !_cameraController!.value.isInitialized) { return; } HapticFeedback.mediumImpact(); // Wajib stop image stream sebelum startVideoRecording (camera 0.10.x) try { if (_cameraController!.value.isStreamingImages) { await _cameraController!.stopImageStream(); } } catch (_) {} // Gunakan onAvailable agar frame tetap diterima selama recording await provider.startRecording( _cameraController!, onAvailable: (CameraImage image) { if (!mounted) return; final p = context.read(); if (p.recordingState == VideoRecordingState.recording) { p.processCameraImage( image, _frontCamera!, CameraUtils.rotationIntToImageRotation( _frontCamera!.sensorOrientation), ); } }, ); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, extendBodyBehindAppBar: true, appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, automaticallyImplyLeading: false, leading: IconButton( icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.black.withOpacity(0.5), shape: BoxShape.circle, ), child: Icon( widget.isOnboarding ? Icons.logout : Icons.arrow_back_ios_new, color: Colors.white, size: 16, ), ), onPressed: () { if (widget.isOnboarding) { _showLogoutDialog(); } else { Navigator.pop(context); } }, ), ), body: Consumer( builder: (context, provider, child) { if (provider.recordingState == VideoRecordingState.success && !_successHandled) { _successHandled = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { context.read().checkSetup(); } }); } if (provider.recordingState == VideoRecordingState.recording) { if (provider.needsAbort) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _cameraController != null) { provider.abortRecording(_cameraController!); } }); } else if (provider.isChecklistComplete) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _cameraController != null) { provider.stopRecording(_cameraController!); } }); } } final bool isIdle = provider.recordingState == VideoRecordingState.idle; final bool isRecording = provider.recordingState == VideoRecordingState.recording; final bool isUploading = provider.recordingState == VideoRecordingState.uploading; final bool isSuccess = provider.recordingState == VideoRecordingState.success; final bool isError = provider.recordingState == VideoRecordingState.error; Color scanColor = AppTheme.primaryOrange; if (isIdle || isRecording) { if (!provider.isFaceDetected) { scanColor = AppTheme.statusRed; } else if (provider.allChecksValid) { scanColor = AppTheme.statusGreen; } else { scanColor = AppTheme.statusYellow; } } else if (isSuccess) { scanColor = AppTheme.statusGreen; } return Stack( children: [ if (_isCameraInitialized) SizedBox.expand( child: FittedBox( fit: BoxFit.cover, child: SizedBox( width: _cameraController!.value.previewSize?.height ?? MediaQuery.of(context).size.width, height: _cameraController!.value.previewSize?.width ?? MediaQuery.of(context).size.height, child: CameraPreview(_cameraController!), ), ), ) else const Center( child: CircularProgressIndicator(color: Colors.white)), ScannerOverlay( borderColor: scanColor, isScanning: isIdle || isRecording, ), // --- REC indicator --- if (isRecording) Positioned( top: MediaQuery.of(context).padding.top + 16, right: 20, child: AnimatedBuilder( animation: _pulseAnimation, builder: (context, child) { return Opacity( opacity: _pulseAnimation.value, child: Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6), decoration: BoxDecoration( color: AppTheme.primaryBlue.withOpacity(0.8), borderRadius: BorderRadius.circular(20), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 10, height: 10, decoration: const BoxDecoration( color: Colors.white, shape: BoxShape.circle, ), ), const SizedBox(width: 6), Text( 'SCAN', style: AppTheme.bodySmall .copyWith(color: Colors.white, fontWeight: FontWeight.bold), ), ], ), ), ); }, ), ), // --- Header + Quality Checklist --- Positioned( top: MediaQuery.of(context).padding.top + 16, left: 20, right: 70, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "Pendaftaran Wajah", style: AppTheme.heading3.copyWith(color: Colors.white), ), const SizedBox(height: 10), if (isIdle || isRecording) Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( spacing: 6, runSpacing: 6, children: [ _buildQualityChip( icon: Icons.straighten, label: _getDistanceLabel(provider), isOk: provider.isDistanceOk, isDetected: provider.isFaceDetected, ), _buildQualityChip( icon: Icons.wb_sunny_outlined, label: 'Cahaya', isOk: provider.isBrightnessOk, isDetected: provider.isFaceDetected, ), _buildQualityChip( icon: Icons.blur_on, label: 'Bayangan', isOk: provider.isLightingUniform, isDetected: provider.isFaceDetected, ), _buildQualityChip( icon: Icons.face, label: 'Hadap Depan', isOk: provider.isPoseValid, isDetected: provider.isFaceDetected, ), ], ), if (provider.isFaceDetected) ...[ const SizedBox(height: 10), _buildDistanceIndicator(provider), ], ], ), if (!isIdle && !isRecording) AnimatedSwitcher( duration: const Duration(milliseconds: 250), child: ClipRRect( key: ValueKey(provider.recordingState), borderRadius: BorderRadius.circular(20), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), child: Container( padding: const EdgeInsets.symmetric( horizontal: 20, vertical: 12), decoration: BoxDecoration( color: isSuccess ? AppTheme.statusGreen.withOpacity(0.25) : isError ? Colors.red.withOpacity(0.25) : Colors.white.withOpacity(0.15), borderRadius: BorderRadius.circular(20), border: Border.all( color: isSuccess ? AppTheme.statusGreen.withOpacity(0.6) : isError ? Colors.red.withOpacity(0.6) : Colors.white.withOpacity(0.3), ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( _getStatusIcon(provider), color: _getStatusColor(provider), size: 20, ), const SizedBox(width: 8), Flexible( child: Text( _getStatusText(provider), style: AppTheme.bodyLarge.copyWith( color: _getStatusColor(provider), fontWeight: FontWeight.bold, ), ), ), ], ), ), ), ), ), ], ), ), // --- Panel Bawah --- Positioned( bottom: 40, left: 20, right: 20, child: Column( children: [ if (isRecording) ...[ const SizedBox(height: 16), ClipRRect( borderRadius: BorderRadius.circular(24), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), child: Container( padding: const EdgeInsets.symmetric( vertical: 16, horizontal: 24), decoration: BoxDecoration( color: Colors.white.withOpacity(0.1), borderRadius: BorderRadius.circular(24), ), child: Column( children: [ Text( provider.qualityMessage, style: AppTheme.heading3.copyWith( color: AppTheme.primaryOrange, ), textAlign: TextAlign.center, ), const SizedBox(height: 16), ClipRRect( borderRadius: BorderRadius.circular(8), child: LinearProgressIndicator( value: provider.totalProgress.clamp(0.0, 1.0), minHeight: 8, backgroundColor: Colors.white.withOpacity(0.2), valueColor: AlwaysStoppedAnimation( provider.allChecksValid ? AppTheme.statusGreen : AppTheme.statusYellow, ), ), ), const SizedBox(height: 10), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Progres Pose (${(provider.totalProgress * 100).toInt()}%)', style: AppTheme.bodySmall.copyWith( color: Colors.white70, ), ), ], ), ], ), ), ), ), ], // Uploading state if (isUploading) Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: Colors.black54, borderRadius: BorderRadius.circular(16), ), child: Column( children: [ const CircularProgressIndicator( color: AppTheme.primaryOrange), const SizedBox(height: 16), Text( provider.message, style: AppTheme.bodySmall .copyWith(color: Colors.white), textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( "Proses ini membutuhkan beberapa saat...", style: AppTheme.bodySmall.copyWith( color: Colors.white54, fontSize: 11, ), textAlign: TextAlign.center, ), ], ), ), // Idle state — tombol rekam if (isIdle) Column( children: [ if (provider.qualityMessage.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: 12), child: Text( provider.qualityMessage, style: AppTheme.bodySmall.copyWith( color: provider.isFaceDetected ? AppTheme.statusYellow : Colors.white70, ), textAlign: TextAlign.center, ), ), // --- Kartu Informasi UX --- Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Colors.white.withOpacity(0.05), borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.white.withOpacity(0.1)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.tips_and_updates_outlined, color: AppTheme.primaryOrange, size: 14), const SizedBox(width: 6), Text( "Syarat Akurasi Maksimal", style: AppTheme.bodySmall.copyWith( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 11, ), ), ], ), const SizedBox(height: 6), Row( children: [ Expanded( child: Row( children: [ const Icon(Icons.face_retouching_off, color: Colors.white70, size: 12), const SizedBox(width: 4), Expanded(child: Text("Lepas kacamata/masker", style: AppTheme.bodySmall.copyWith(color: Colors.white70, fontSize: 10))), ], ), ), Expanded( child: Row( children: [ const Icon(Icons.wb_sunny_outlined, color: Colors.white70, size: 12), const SizedBox(width: 4), Expanded(child: Text("Hadap cahaya terang", style: AppTheme.bodySmall.copyWith(color: Colors.white70, fontSize: 10))), ], ), ), ], ), ], ), ), CustomButton( text: provider.allChecksValid ? "Mulai Pindai Wajah" : "Penuhi Syarat di Atas", icon: Icons.face_retouching_natural, backgroundColor: provider.allChecksValid ? AppTheme.statusGreen : AppTheme.primaryDark.withOpacity(0.5), onPressed: provider.allChecksValid ? () => _handleStartRecording(provider) : null, ), ], ), // Success state if (isSuccess) Padding( padding: const EdgeInsets.only(top: 24), child: CustomButton( text: widget.isOnboarding ? (context.read().hasSignature ? "Mulai Gunakan Aplikasi" : "Lanjut: Tanda Tangan") : "Selesai", icon: widget.isOnboarding ? (context.read().hasSignature ? Icons.home : Icons.arrow_forward) : Icons.check, backgroundColor: AppTheme.statusGreen, onPressed: () { HapticFeedback.mediumImpact(); if (widget.isOnboarding) { final hasSignature = context.read().hasSignature; context.read().reset(); if (hasSignature) { Navigator.pushReplacementNamed(context, '/home'); } else { Navigator.pushReplacement( context, MaterialPageRoute( builder: (_) => const SignatureScreen(isOnboarding: true), ), ); } } else { Navigator.pop(context); } }, ), ), // Error state if (isError) Column( children: [ Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.red.withOpacity(0.15), borderRadius: BorderRadius.circular(16), border: Border.all( color: Colors.red.withOpacity(0.3)), ), child: Row( children: [ const Icon(Icons.error_outline, color: Colors.red, size: 24), const SizedBox(width: 12), Expanded( child: Text( provider.errorMessage, style: AppTheme.bodySmall .copyWith(color: Colors.red[200]), ), ), ], ), ), const SizedBox(height: 16), CustomButton( text: "Coba Lagi", icon: Icons.refresh, backgroundColor: AppTheme.primaryOrange, onPressed: () async { setState(() => _successHandled = false); provider.reset(); await _restartCameraStream(); }, ), ], ), ], ), ), ], ); }, ), ); } Widget _buildQualityChip({ required IconData icon, required String label, required bool isOk, required bool isDetected, }) { final color = !isDetected ? Colors.white.withOpacity(0.4) : isOk ? AppTheme.statusGreen : AppTheme.statusYellow; return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: color.withOpacity(0.15), borderRadius: BorderRadius.circular(20), border: Border.all(color: color.withOpacity(0.4)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( isOk && isDetected ? Icons.check_circle : icon, color: color, size: 14, ), const SizedBox(width: 4), Text( label, style: TextStyle( color: color, fontSize: 11, fontWeight: FontWeight.w600, ), ), ], ), ); } Future _restartCameraStream() async { // Dispose controller lama dulu agar tidak ada konflik setState(() => _isCameraInitialized = false); try { if (_cameraController != null) { if (_cameraController!.value.isStreamingImages) { await _cameraController!.stopImageStream(); } await _cameraController!.dispose(); _cameraController = null; } } catch (_) {} // Reinisialisasi penuh dengan NV21 agar deteksi wajah berfungsi kembali await _initializeCamera(); } String _getDistanceLabel(FaceProvider provider) { if (!provider.isFaceDetected) return 'Jarak'; if (provider.faceRatio < 0.25) return 'Terlalu Jauh'; if (provider.faceRatio > 0.75) return 'Terlalu Dekat'; return 'Jarak ✓'; } Widget _buildDistanceIndicator(FaceProvider provider) { final double ratio = provider.faceRatio.clamp(0.0, 1.0); const double minIdeal = 0.25; const double maxIdeal = 0.75; final bool inRange = ratio >= minIdeal && ratio <= maxIdeal; final Color barColor = inRange ? AppTheme.statusGreen : AppTheme.statusYellow; final double normalizedPosition = ratio.clamp(0.1, 0.8) / 0.8; return ClipRRect( borderRadius: BorderRadius.circular(12), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 6, sigmaY: 6), child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), decoration: BoxDecoration( color: Colors.white.withOpacity(0.08), borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.white.withOpacity(0.12)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( '📏 Jauh', style: TextStyle(color: Colors.white54, fontSize: 9), ), const Spacer(), Text( 'Dekat 📷', style: TextStyle(color: Colors.white54, fontSize: 9), ), ], ), const SizedBox(height: 4), SizedBox( height: 14, child: LayoutBuilder( builder: (context, constraints) { final totalWidth = constraints.maxWidth; final idealLeft = totalWidth * (minIdeal / 0.8); final idealRight = totalWidth * (maxIdeal / 0.8); final indicatorPos = totalWidth * normalizedPosition; return Stack( clipBehavior: Clip.none, children: [ Container( height: 6, margin: const EdgeInsets.only(top: 4), decoration: BoxDecoration( color: Colors.white.withOpacity(0.15), borderRadius: BorderRadius.circular(3), ), ), Positioned( left: idealLeft, top: 4, child: Container( width: idealRight - idealLeft, height: 6, decoration: BoxDecoration( color: AppTheme.statusGreen.withOpacity(0.35), borderRadius: BorderRadius.circular(3), ), ), ), Positioned( left: (indicatorPos - 5).clamp(0, totalWidth - 10), top: 0, child: Container( width: 10, height: 14, decoration: BoxDecoration( color: barColor, borderRadius: BorderRadius.circular(3), boxShadow: [ BoxShadow( color: barColor.withOpacity(0.5), blurRadius: 6, ), ], ), ), ), ], ); }, ), ), ], ), ), ), ); } String _getStatusText(FaceProvider provider) { switch (provider.recordingState) { case VideoRecordingState.idle: return ''; case VideoRecordingState.recording: return ''; case VideoRecordingState.uploading: return "⏳ Memproses..."; case VideoRecordingState.success: return "✅ ${provider.message}"; case VideoRecordingState.error: return "❌ Gagal"; } } IconData _getStatusIcon(FaceProvider provider) { switch (provider.recordingState) { case VideoRecordingState.idle: return Icons.face; case VideoRecordingState.recording: return Icons.camera_alt; case VideoRecordingState.uploading: return Icons.cloud_upload; case VideoRecordingState.success: return Icons.check_circle; case VideoRecordingState.error: return Icons.error; } } Color _getStatusColor(FaceProvider provider) { switch (provider.recordingState) { case VideoRecordingState.idle: return Colors.white; case VideoRecordingState.recording: return Colors.white; case VideoRecordingState.uploading: return Colors.white; case VideoRecordingState.success: return AppTheme.statusGreen; case VideoRecordingState.error: return Colors.red; } } }