From 664acb389e66a05f87c0e6d071ad4636cff72438 Mon Sep 17 00:00:00 2001 From: jouel88 Date: Fri, 20 Mar 2026 17:02:32 +0700 Subject: [PATCH] Alur --- .../reports/problems/problems-report.html | 663 ++++++++++++++++++ lib/core/constants/api_url.dart | 2 +- lib/core/services/api_config_service.dart | 2 +- lib/main.dart | 4 + lib/providers/face_provider.dart | 218 ++++-- lib/providers/setup_check_provider.dart | 48 ++ lib/screens/auth/login_screen.dart | 2 +- .../onboarding/face_enrollment_screen.dart | 209 ++++-- .../onboarding/onboarding_check_screen.dart | 111 +++ lib/screens/profile/profile_screen.dart | 16 +- lib/screens/profile/signature_screen.dart | 22 +- lib/screens/splash_screen.dart | 2 +- lib/services/api_client.dart | 19 +- 13 files changed, 1190 insertions(+), 128 deletions(-) create mode 100644 android/build/reports/problems/problems-report.html create mode 100644 lib/providers/setup_check_provider.dart create mode 100644 lib/screens/onboarding/onboarding_check_screen.dart diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..a4257b9 --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/lib/core/constants/api_url.dart b/lib/core/constants/api_url.dart index 5e308b7..07089c6 100644 --- a/lib/core/constants/api_url.dart +++ b/lib/core/constants/api_url.dart @@ -16,7 +16,7 @@ class ApiUrl { return _cachedImageBaseUrl!; } - static String get baseUrl => dotenv.env['API_BASE_URL'] ?? _cachedBaseUrl ?? 'http://192.168.1.3:8000/api'; + static String get baseUrl => _cachedBaseUrl ?? dotenv.env['API_BASE_URL'] ?? 'http://192.168.1.4:8000/api'; static String get imageBaseUrl => _cachedImageBaseUrl ?? baseUrl.replaceAll('/api', '/storage/'); static Future initialize() async { diff --git a/lib/core/services/api_config_service.dart b/lib/core/services/api_config_service.dart index 719850d..8828346 100644 --- a/lib/core/services/api_config_service.dart +++ b/lib/core/services/api_config_service.dart @@ -6,7 +6,7 @@ class ApiConfigService { static const String _selectedPresetKey = 'selected_preset'; static const Map presets = { - 'current_ip': 'http://192.168.1.7:8000/api', + 'current_ip': 'http://192.168.1.4:8000/api', 'hostname': 'http://LAPTOP-I0SUKSKL:8000/api', 'emulator': 'http://10.0.2.2:8000/api', 'custom': '', diff --git a/lib/main.dart b/lib/main.dart index a040aee..cc6c43c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,6 +13,7 @@ import 'providers/poin_provider.dart'; import 'providers/signature_provider.dart'; import 'providers/surat_izin_provider.dart'; import 'providers/notification_provider.dart'; +import 'providers/setup_check_provider.dart'; import 'screens/splash_screen.dart'; import 'screens/auth/login_screen.dart'; @@ -36,6 +37,7 @@ import 'screens/profile/face_test_screen.dart'; import 'screens/profile/signature_screen.dart'; import 'screens/profile/edit_profile_screen.dart'; import 'screens/documents/surat_izin_screen.dart'; +import 'screens/onboarding/onboarding_check_screen.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'core/cache_manager.dart'; @@ -72,6 +74,7 @@ class MyApp extends StatelessWidget { ChangeNotifierProvider(create: (_) => SignatureProvider()), ChangeNotifierProvider(create: (_) => SuratIzinProvider()), ChangeNotifierProvider(create: (_) => NotificationProvider()), + ChangeNotifierProvider(create: (_) => SetupCheckProvider()), ], child: MaterialApp( title: 'MPG HRIS', @@ -98,6 +101,7 @@ class MyApp extends StatelessWidget { '/pengajuan/izin': (context) => const IzinFormScreen(), '/pengajuan/lembur': (context) => const LemburFormScreen(), '/onboarding/face-enrollment': (context) => const FaceEnrollmentScreen(), + '/onboarding/check': (context) => const OnboardingCheckScreen(), '/poin/usage': (context) => const PointUsageScreen(), '/poin/history': (context) => const PoinHistoryScreen(), '/notification': (context) => const NotificationScreen(), diff --git a/lib/providers/face_provider.dart b/lib/providers/face_provider.dart index df3dd3f..57f6c56 100644 --- a/lib/providers/face_provider.dart +++ b/lib/providers/face_provider.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:ui' show Rect; import 'package:camera/camera.dart'; import 'package:flutter/foundation.dart'; import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart'; @@ -7,6 +8,17 @@ import '../core/utils/camera_utils.dart'; enum EnrollmentStep { depan, kanan, kiri, bawah, selesai } +// Status feedback real-time ke UI untuk panduan user +enum FaceStatus { + noFace, // Tidak ada wajah terdeteksi + tooFar, // Wajah terlalu jauh (kotak kecil) + tooClose, // Wajah terlalu dekat (kotak besar) + outOfFrame, // Wajah tidak di dalam area scan + wrongPose, // Wajah ada & di frame, tapi pose salah + holding, // Pose benar, sedang menghitung mundur + ready, // Siap capture (internal, langsung ambil foto) +} + class FaceProvider with ChangeNotifier { final FaceRepository _repository = FaceRepository(); final FaceDetector _faceDetector = FaceDetector( @@ -23,6 +35,12 @@ class FaceProvider with ChangeNotifier { bool _isProcessing = false; bool _isUploading = false; String _instructionText = "Hadapkan wajah lurus ke depan"; + FaceStatus _faceStatus = FaceStatus.noFace; + + // Hold timer: pose harus dipertahankan selama ini sebelum capture + static const int _holdDurationMs = 1500; + DateTime? _poseValidSince; + double _holdProgress = 0.0; // 0.0 - 1.0 File? _fotoDepan; File? _fotoKanan; @@ -32,6 +50,8 @@ class FaceProvider with ChangeNotifier { EnrollmentStep get currentStep => _currentStep; bool get isUploading => _isUploading; String get instructionText => _instructionText; + FaceStatus get faceStatus => _faceStatus; + double get holdProgress => _holdProgress; void reset() { _currentStep = EnrollmentStep.depan; @@ -42,10 +62,22 @@ class FaceProvider with ChangeNotifier { _fotoBawah = null; _isProcessing = false; _isUploading = false; + _faceStatus = FaceStatus.noFace; + _poseValidSince = null; + _holdProgress = 0.0; notifyListeners(); } - Future processCameraImage(CameraImage image, CameraDescription camera, InputImageRotation rotation) async { + Future processCameraImage( + CameraImage image, + CameraDescription camera, + InputImageRotation rotation, { + // Ukuran kotak scan dalam koordinat layar (0.0 - 1.0) + double scanBoxLeft = 0.125, + double scanBoxRight = 0.875, + double scanBoxTop = 0.275, + double scanBoxBottom = 0.725, + }) async { if (_isProcessing || _currentStep == EnrollmentStep.selesai) return; _isProcessing = true; @@ -59,10 +91,12 @@ class FaceProvider with ChangeNotifier { final List faces = await _faceDetector.processImage(inputImage); - if (faces.isNotEmpty) { - final Face face = faces.first; - _checkPoseAndCapture(face, image); + if (faces.isEmpty) { + _updateFaceStatus(FaceStatus.noFace); + _resetHoldTimer(); } else { + final Face face = faces.first; + _validateAndCheckPose(face, image, scanBoxLeft, scanBoxRight, scanBoxTop, scanBoxBottom); } } catch (e) { debugPrint("Error processing face: $e"); @@ -71,53 +105,135 @@ class FaceProvider with ChangeNotifier { } } - void _checkPoseAndCapture(Face face, CameraImage originalImage) async { + void _validateAndCheckPose( + Face face, + CameraImage image, + double boxLeft, + double boxRight, + double boxTop, + double boxBottom, + ) { + final imgW = image.width.toDouble(); + final imgH = image.height.toDouble(); + + // Bounding box dari ML Kit (dalam koordinat gambar - KAMERA DEPAN MIRROR) + final Rect bb = face.boundingBox; + + // Normalisasi ke 0.0 - 1.0 + // Untuk kamera depan di Android, X perlu di-flip karena kamera mirror + final double faceLeft = 1.0 - (bb.right / imgW); + final double faceRight = 1.0 - (bb.left / imgW); + final double faceTop = bb.top / imgH; + final double faceBottom = bb.bottom / imgH; + + final double faceWidth = faceRight - faceLeft; + + // --- Validasi Jarak (ukuran wajah) --- + // Wajah ideal: lebarnya 35% - 65% dari lebar frame + const double minFaceRatio = 0.30; + const double maxFaceRatio = 0.70; + + if (faceWidth < minFaceRatio) { + _updateFaceStatus(FaceStatus.tooFar); + _resetHoldTimer(); + return; + } + if (faceWidth > maxFaceRatio) { + _updateFaceStatus(FaceStatus.tooClose); + _resetHoldTimer(); + return; + } + + // --- Validasi Posisi dalam Kotak Scan --- + // Center wajah harus berada di dalam kotak scan (dengan toleransi 10%) + final double faceCenterX = (faceLeft + faceRight) / 2; + final double faceCenterY = (faceTop + faceBottom) / 2; + + const double tolerance = 0.10; + final bool inBox = faceCenterX >= (boxLeft - tolerance) && + faceCenterX <= (boxRight + tolerance) && + faceCenterY >= (boxTop - tolerance) && + faceCenterY <= (boxBottom + tolerance); + + if (!inBox) { + _updateFaceStatus(FaceStatus.outOfFrame); + _resetHoldTimer(); + return; + } + + // --- Validasi Pose --- + final bool isPoseValid = _isPoseValid(face); + + if (!isPoseValid) { + _updateFaceStatus(FaceStatus.wrongPose); + _resetHoldTimer(); + return; + } + + // --- Pose valid: jalankan hold timer --- + _tickHoldTimer(); + } + + bool _isPoseValid(Face face) { const double thresholdFront = 10.0; - const double thresholdSide = 15.0; - const double thresholdDown = 5.0; + const double thresholdSide = 15.0; + const double thresholdDown = 5.0; - double? rotY = face.headEulerAngleY; - double? rotX = face.headEulerAngleX; + final double? rotY = face.headEulerAngleY; + final double? rotX = face.headEulerAngleX; - debugPrint("ROT X (Atas/Bawah): $rotX | ROT Y (Kiri/Kanan): $rotY"); - - if (rotY == null || rotX == null) return; - - bool isPoseValid = false; + if (rotY == null || rotX == null) return false; switch (_currentStep) { case EnrollmentStep.depan: - if (rotY.abs() < thresholdFront && rotX.abs() < thresholdFront) { - isPoseValid = true; - } - break; + return rotY.abs() < thresholdFront && rotX.abs() < thresholdFront; case EnrollmentStep.kanan: - if (rotY < -thresholdSide) { - isPoseValid = true; - } - break; + return rotY < -thresholdSide; case EnrollmentStep.kiri: - if (rotY > thresholdSide) { - isPoseValid = true; - } - break; + return rotY > thresholdSide; case EnrollmentStep.bawah: - bool isLookingForward = rotY.abs() < thresholdFront; - - if (rotX < -thresholdDown && isLookingForward) { - isPoseValid = true; - } - break; + return rotX < -thresholdDown && rotY.abs() < thresholdFront; default: - break; - } - - if (isPoseValid) { - await _captureImage(originalImage); + return false; } } - Future _captureImage(CameraImage image) async { + void _tickHoldTimer() { + final now = DateTime.now(); + + if (_poseValidSince == null) { + _poseValidSince = now; + } + + final elapsed = now.difference(_poseValidSince!).inMilliseconds; + _holdProgress = (elapsed / _holdDurationMs).clamp(0.0, 1.0); + + _updateFaceStatus(FaceStatus.holding); + + if (elapsed >= _holdDurationMs) { + // Capture! + _poseValidSince = null; + _holdProgress = 0.0; + _captureImage(); + } + } + + void _resetHoldTimer() { + if (_poseValidSince != null || _holdProgress > 0) { + _poseValidSince = null; + _holdProgress = 0.0; + notifyListeners(); + } + } + + void _updateFaceStatus(FaceStatus status) { + if (_faceStatus != status) { + _faceStatus = status; + notifyListeners(); + } + } + + void _captureImage() { _isProcessing = true; if (_currentStep == EnrollmentStep.depan) { @@ -134,20 +250,26 @@ class FaceProvider with ChangeNotifier { _instructionText = "Data lengkap. Mengunggah..."; } + _faceStatus = FaceStatus.noFace; notifyListeners(); - await Future.delayed(const Duration(seconds: 2)); - _isProcessing = false; + // Cooldown setelah capture agar tidak langsung re-trigger + Future.delayed(const Duration(milliseconds: 800), () { + _isProcessing = false; + }); } void saveCapturedFile(File file) { - if (_fotoDepan == null) _fotoDepan = file; - else if (_fotoKanan == null) _fotoKanan = file; - else if (_fotoKiri == null) _fotoKiri = file; - else if (_fotoBawah == null) { + if (_fotoDepan == null) { + _fotoDepan = file; + } else if (_fotoKanan == null) { + _fotoKanan = file; + } else if (_fotoKiri == null) { + _fotoKiri = file; + } else if (_fotoBawah == null) { _fotoBawah = file; if (_currentStep == EnrollmentStep.selesai) { - submitEnrollment(); + submitEnrollment(); } } } @@ -183,13 +305,13 @@ class FaceProvider with ChangeNotifier { } } - Map _faceStatus = {}; - Map get faceStatus => _faceStatus; + Map _faceStatus2 = {}; + Map get faceStatus2 => _faceStatus2; Future loadFaceStatus() async { try { final status = await _repository.getFaceStatus(); - _faceStatus = status; + _faceStatus2 = status; notifyListeners(); } catch (e) { debugPrint("Error loading face status: $e"); diff --git a/lib/providers/setup_check_provider.dart b/lib/providers/setup_check_provider.dart new file mode 100644 index 0000000..1c9fec6 --- /dev/null +++ b/lib/providers/setup_check_provider.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import '../repositories/face_repository.dart'; +import '../repositories/signature_repository.dart'; + +class SetupCheckProvider extends ChangeNotifier { + final FaceRepository _faceRepository = FaceRepository(); + final SignatureRepository _signatureRepository = SignatureRepository(); + + bool _isLoading = false; + bool _hasFace = false; + bool _hasSignature = false; + + bool get isLoading => _isLoading; + bool get hasFace => _hasFace; + bool get hasSignature => _hasSignature; + + Future checkSetup() async { + _isLoading = true; + notifyListeners(); + + try { + final results = await Future.wait([ + _faceRepository.getFaceStatus(), + _signatureRepository.getActiveSignature(), + ]); + + final faceData = results[0]; + final signatureData = results[1]; + + // Backend mengembalikan: { is_registered: bool, status: 'verified'|'pending'|'not_registered' } + // Dianggap sudah punya wajah jika is_registered = true (sudah upload foto, meski pending approval) + _hasFace = faceData['is_registered'] == true; + _hasSignature = signatureData['success'] == true && signatureData['data'] != null; + } catch (e) { + _hasFace = false; + _hasSignature = false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + void reset() { + _isLoading = false; + _hasFace = false; + _hasSignature = false; + } +} diff --git a/lib/screens/auth/login_screen.dart b/lib/screens/auth/login_screen.dart index f0fa568..8b31123 100644 --- a/lib/screens/auth/login_screen.dart +++ b/lib/screens/auth/login_screen.dart @@ -32,7 +32,7 @@ class _LoginScreenState extends State { ); if (success && mounted) { - Navigator.pushReplacementNamed(context, '/home'); + Navigator.pushReplacementNamed(context, '/onboarding/check'); } else if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/lib/screens/onboarding/face_enrollment_screen.dart b/lib/screens/onboarding/face_enrollment_screen.dart index f4c1f25..72b2bd7 100644 --- a/lib/screens/onboarding/face_enrollment_screen.dart +++ b/lib/screens/onboarding/face_enrollment_screen.dart @@ -5,13 +5,17 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/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 { - const FaceEnrollmentScreen({super.key}); + final bool isOnboarding; + + const FaceEnrollmentScreen({this.isOnboarding = false, super.key}); @override State createState() => _FaceEnrollmentScreenState(); @@ -23,6 +27,13 @@ class _FaceEnrollmentScreenState extends State { CameraDescription? _frontCamera; int _lastStepIndex = -1; + // Koordinat kotak scan (0.0 - 1.0) — harus cocok dengan _ScannerPainter + // ScannerOverlay: lebar 75% (0.125 - 0.875), tinggi 45% dari tengah (0.275 - 0.725) + static const double _boxLeft = 0.125; + static const double _boxRight = 0.875; + static const double _boxTop = 0.275; + static const double _boxBottom = 0.725; + @override void initState() { super.initState(); @@ -60,7 +71,11 @@ class _FaceEnrollmentScreenState extends State { provider.processCameraImage( image, _frontCamera!, - CameraUtils.rotationIntToImageRotation(_frontCamera!.sensorOrientation) + CameraUtils.rotationIntToImageRotation(_frontCamera!.sensorOrientation), + scanBoxLeft: _boxLeft, + scanBoxRight: _boxRight, + scanBoxTop: _boxTop, + scanBoxBottom: _boxBottom, ); }); @@ -75,6 +90,43 @@ class _FaceEnrollmentScreenState extends State { super.dispose(); } + // Teks panduan berdasarkan FaceStatus + String _getStatusText(FaceProvider provider) { + switch (provider.faceStatus) { + case FaceStatus.noFace: + return provider.instructionText; + case FaceStatus.tooFar: + return "⬆️ Dekatkan wajah ke kamera"; + case FaceStatus.tooClose: + return "⬇️ Jauhkan wajah dari kamera"; + case FaceStatus.outOfFrame: + return "📦 Arahkan wajah ke dalam kotak"; + case FaceStatus.wrongPose: + return provider.instructionText; + case FaceStatus.holding: + return "✅ Tahan posisi..."; + case FaceStatus.ready: + return "📸 Mengambil foto..."; + } + } + + // Warna border kotak berdasarkan status + Color _getBorderColor(FaceProvider provider) { + if (provider.currentStep == EnrollmentStep.selesai) { + return AppTheme.statusGreen; + } + switch (provider.faceStatus) { + case FaceStatus.holding: + return AppTheme.statusGreen; + case FaceStatus.outOfFrame: + case FaceStatus.tooFar: + case FaceStatus.tooClose: + return AppTheme.statusRed; + default: + return AppTheme.primaryOrange; + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -97,18 +149,22 @@ class _FaceEnrollmentScreenState extends State { ), body: Consumer( builder: (context, provider, child) { + // Deteksi step baru → ambil foto if (provider.currentStep.index > _lastStepIndex && _lastStepIndex != -1) { - HapticFeedback.lightImpact(); - _takePictureAndSave(provider); + HapticFeedback.lightImpact(); + _takePictureAndSave(provider); } if (_lastStepIndex != provider.currentStep.index) { - _lastStepIndex = provider.currentStep.index; + _lastStepIndex = provider.currentStep.index; } - final bool isComplete = provider.currentStep == EnrollmentStep.selesai; + final bool isComplete = provider.currentStep == EnrollmentStep.selesai; + final bool isHolding = provider.faceStatus == FaceStatus.holding; + final Color scanColor = _getBorderColor(provider); return Stack( children: [ + // --- Preview Kamera --- if (_isCameraInitialized) SizedBox.expand( child: FittedBox( @@ -123,11 +179,31 @@ class _FaceEnrollmentScreenState extends State { else const Center(child: CircularProgressIndicator(color: Colors.white)), + // --- Overlay Kotak Scan --- ScannerOverlay( - borderColor: isComplete ? AppTheme.statusGreen : AppTheme.primaryOrange, - isScanning: !isComplete && !provider.isUploading, + borderColor: scanColor, + isScanning: !isComplete && !provider.isUploading && !isHolding, ), + // --- Hold Progress Ring (di tengah kotak scan) --- + if (isHolding && !isComplete) + Center( + child: Container( + margin: const EdgeInsets.only(top: 0), + child: SizedBox( + width: 80, + height: 80, + child: CircularProgressIndicator( + value: provider.holdProgress, + strokeWidth: 5, + backgroundColor: Colors.white24, + valueColor: const AlwaysStoppedAnimation(AppTheme.statusGreen), + ), + ), + ), + ), + + // --- Banner Instruksi Atas --- Positioned( top: MediaQuery.of(context).padding.top + kToolbarHeight + 20, left: 20, @@ -139,37 +215,61 @@ class _FaceEnrollmentScreenState extends State { style: AppTheme.heading3.copyWith(color: Colors.white), ), const SizedBox(height: 10), - ClipRRect( - 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: Colors.white.withOpacity(0.15), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: Colors.white.withOpacity(0.3)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - isComplete ? Icons.check_circle : Icons.face, - color: isComplete ? AppTheme.statusGreen : AppTheme.primaryOrange, - size: 20, + AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: ClipRRect( + key: ValueKey(provider.faceStatus), + 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: isHolding + ? AppTheme.statusGreen.withOpacity(0.25) + : Colors.white.withOpacity(0.15), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isHolding + ? AppTheme.statusGreen.withOpacity(0.6) + : Colors.white.withOpacity(0.3), ), - const SizedBox(width: 8), - Flexible( - child: Text( - provider.instructionText, - textAlign: TextAlign.center, - style: AppTheme.bodyLarge.copyWith( - color: isComplete ? AppTheme.statusGreen : Colors.white, - fontWeight: FontWeight.bold + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isComplete + ? Icons.check_circle + : isHolding + ? Icons.timer + : Icons.face, + color: isComplete + ? AppTheme.statusGreen + : isHolding + ? AppTheme.statusGreen + : AppTheme.primaryOrange, + size: 20, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + isComplete + ? provider.instructionText + : _getStatusText(provider), + textAlign: TextAlign.center, + style: AppTheme.bodyLarge.copyWith( + color: isComplete + ? AppTheme.statusGreen + : isHolding + ? AppTheme.statusGreen + : Colors.white, + fontWeight: FontWeight.bold, + ), ), ), - ), - ], + ], + ), ), ), ), @@ -178,6 +278,7 @@ class _FaceEnrollmentScreenState extends State { ), ), + // --- Panel Bawah: Progress Steps & Tombol Selesai --- Positioned( bottom: 40, left: 20, @@ -216,7 +317,7 @@ class _FaceEnrollmentScreenState extends State { children: List.generate(4, (index) { final isActive = index == provider.currentStep.index; final isPassed = index < provider.currentStep.index; - + return AnimatedContainer( duration: const Duration(milliseconds: 300), margin: const EdgeInsets.symmetric(horizontal: 6), @@ -242,17 +343,27 @@ class _FaceEnrollmentScreenState extends State { ), ), ), - + if (isComplete) Padding( padding: const EdgeInsets.only(top: 24), child: CustomButton( - text: "Selesai", - icon: Icons.check, + text: widget.isOnboarding ? "Lanjut: Tanda Tangan" : "Selesai", + icon: widget.isOnboarding ? Icons.arrow_forward : Icons.check, backgroundColor: AppTheme.statusGreen, onPressed: () { HapticFeedback.mediumImpact(); - Navigator.pop(context); + if (widget.isOnboarding) { + context.read().reset(); + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => const SignatureScreen(isOnboarding: true), + ), + ); + } else { + Navigator.pop(context); + } }, ), ), @@ -274,12 +385,16 @@ class _FaceEnrollmentScreenState extends State { provider.saveCapturedFile(File(file.path)); await _cameraController?.startImageStream((CameraImage image) { - if (!mounted) return; - provider.processCameraImage( - image, - _frontCamera!, - CameraUtils.rotationIntToImageRotation(_frontCamera!.sensorOrientation) - ); + if (!mounted) return; + provider.processCameraImage( + image, + _frontCamera!, + CameraUtils.rotationIntToImageRotation(_frontCamera!.sensorOrientation), + scanBoxLeft: _boxLeft, + scanBoxRight: _boxRight, + scanBoxTop: _boxTop, + scanBoxBottom: _boxBottom, + ); }); } catch (e) { debugPrint("Error capturing image: $e"); diff --git a/lib/screens/onboarding/onboarding_check_screen.dart b/lib/screens/onboarding/onboarding_check_screen.dart new file mode 100644 index 0000000..623c39b --- /dev/null +++ b/lib/screens/onboarding/onboarding_check_screen.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../core/theme.dart'; +import '../../providers/setup_check_provider.dart'; +import 'face_enrollment_screen.dart'; +import '../profile/signature_screen.dart'; + +class OnboardingCheckScreen extends StatefulWidget { + const OnboardingCheckScreen({super.key}); + + @override + State createState() => _OnboardingCheckScreenState(); +} + +class _OnboardingCheckScreenState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + _fadeAnimation = CurvedAnimation(parent: _controller, curve: Curves.easeIn); + _controller.forward(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _runCheck(); + }); + } + + Future _runCheck() async { + final provider = context.read(); + await provider.checkSetup(); + + if (!mounted) return; + + // Navigasi berdasarkan hasil pengecekan + if (!provider.hasFace) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => const FaceEnrollmentScreen(isOnboarding: true), + ), + ); + } else if (!provider.hasSignature) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => const SignatureScreen(isOnboarding: true), + ), + ); + } else { + Navigator.pushReplacementNamed(context, '/home'); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.primaryDark, + body: FadeTransition( + opacity: _fadeAnimation, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + boxShadow: AppTheme.shadowMd, + ), + child: const Icon( + Icons.diamond_outlined, + size: 44, + color: AppTheme.primaryDark, + ), + ), + const SizedBox(height: AppTheme.spacingLg), + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2.5, + ), + ), + const SizedBox(height: AppTheme.spacingMd), + Text( + 'Memeriksa kelengkapan akun...', + style: AppTheme.bodySmall.copyWith(color: Colors.white70), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/profile/profile_screen.dart b/lib/screens/profile/profile_screen.dart index 6370652..a6557b7 100644 --- a/lib/screens/profile/profile_screen.dart +++ b/lib/screens/profile/profile_screen.dart @@ -330,14 +330,14 @@ class _ProfileScreenState extends State { icon: Icons.face, label: "Registrasi Wajah", onTap: () => Navigator.pushNamed(context, '/onboarding/face-enrollment'), - trailingText: context.watch().faceStatus['is_registered'] == true - ? "Terdaftar" - : "Belum Terdaftar", - trailingColor: context.watch().faceStatus['is_registered'] == true - ? AppTheme.statusGreen - : AppTheme.statusRed, - ), - if (context.watch().faceStatus['is_registered'] == true) + trailingText: context.watch().faceStatus2['is_registered'] == true + ? "Terdaftar" + : "Belum Terdaftar", + trailingColor: context.watch().faceStatus2['is_registered'] == true + ? AppTheme.statusGreen + : AppTheme.statusRed, + ), + if (context.watch().faceStatus2['is_registered'] == true) _buildActionItem( icon: Icons.face_retouching_natural, label: "Test Pengenalan Wajah", diff --git a/lib/screens/profile/signature_screen.dart b/lib/screens/profile/signature_screen.dart index 48987b6..f7f3c1a 100644 --- a/lib/screens/profile/signature_screen.dart +++ b/lib/screens/profile/signature_screen.dart @@ -11,7 +11,9 @@ import '../../providers/signature_provider.dart'; import '../../widgets/atoms/custom_button.dart'; class SignatureScreen extends StatefulWidget { - const SignatureScreen({Key? key}) : super(key: key); + final bool isOnboarding; + + const SignatureScreen({this.isOnboarding = false, Key? key}) : super(key: key); @override State createState() => _SignatureScreenState(); @@ -114,7 +116,13 @@ class _SignatureScreenState extends State { behavior: SnackBarBehavior.floating, ), ); - if (success) _clearCanvas(); + if (success) { + if (widget.isOnboarding && mounted) { + Navigator.pushReplacementNamed(context, '/home'); + } else { + _clearCanvas(); + } + } } } } catch (e) { @@ -173,10 +181,12 @@ class _SignatureScreenState extends State { backgroundColor: AppTheme.bgWhite, elevation: 0, centerTitle: true, - leading: IconButton( - icon: const Icon(Icons.arrow_back, color: AppTheme.textPrimary), - onPressed: () => Navigator.pop(context), - ), + leading: widget.isOnboarding + ? const SizedBox.shrink() + : IconButton( + icon: const Icon(Icons.arrow_back, color: AppTheme.textPrimary), + onPressed: () => Navigator.pop(context), + ), ), body: Consumer( builder: (context, provider, _) { diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index dc541fb..0c42d5d 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -42,7 +42,7 @@ class _SplashScreenState extends State with SingleTickerProviderSt final token = CacheManager.authBox.get('auth_token'); if (token != null && token.toString().isNotEmpty) { - Navigator.pushReplacementNamed(context, '/home'); + Navigator.pushReplacementNamed(context, '/onboarding/check'); } else { Navigator.pushReplacementNamed(context, '/login'); } diff --git a/lib/services/api_client.dart b/lib/services/api_client.dart index a3b7625..866fb66 100644 --- a/lib/services/api_client.dart +++ b/lib/services/api_client.dart @@ -1,13 +1,13 @@ import 'package:dio/dio.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../core/cache_manager.dart'; +import '../core/constants/api_url.dart'; class ApiClient { late final Dio dio; ApiClient() { dio = Dio(BaseOptions( - baseUrl: dotenv.env['API_BASE_URL'] ?? 'http://10.0.2.2:8000/api', + baseUrl: ApiUrl.baseUrl, connectTimeout: const Duration(seconds: 30), receiveTimeout: const Duration(seconds: 30), headers: { @@ -24,8 +24,7 @@ class ApiClient { options.headers['Authorization'] = 'Bearer $token'; } - print('[API Request] ${options.method} ${options.path}'); - print('[API Headers] ${options.headers}'); + print('[API Request] ${options.method} ${options.baseUrl}${options.path}'); return handler.next(options); }, onResponse: (response, handler) { @@ -33,22 +32,12 @@ class ApiClient { return handler.next(response); }, onError: (DioException e, handler) { - String errorMessage = 'Terjadi kesalahan'; - if (e.response != null) { print('[API Error] ${e.response?.statusCode} ${e.requestOptions.path}'); print('[API Error Data] ${e.response?.data}'); - - if (e.response!.data is Map && e.response!.data['message'] != null) { - errorMessage = e.response!.data['message']; - } } else { - print('[API Error] Network error: ${e.message}'); - errorMessage = 'Koneksi gagal. Periksa internet Anda.'; + print('[API Network Error] ${e.type}: ${e.message}'); } - - print('[API Error Message] $errorMessage'); - return handler.next(e); }, ));