Alur
This commit is contained in:
parent
4091624ec4
commit
664acb389e
File diff suppressed because one or more lines are too long
|
|
@ -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<void> initialize() async {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ class ApiConfigService {
|
|||
static const String _selectedPresetKey = 'selected_preset';
|
||||
|
||||
static const Map<String, String> 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': '',
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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<void> processCameraImage(CameraImage image, CameraDescription camera, InputImageRotation rotation) async {
|
||||
Future<void> 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<Face> 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;
|
||||
|
||||
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<void> _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,17 +250,23 @@ class FaceProvider with ChangeNotifier {
|
|||
_instructionText = "Data lengkap. Mengunggah...";
|
||||
}
|
||||
|
||||
_faceStatus = FaceStatus.noFace;
|
||||
notifyListeners();
|
||||
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
// 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();
|
||||
|
|
@ -183,13 +305,13 @@ class FaceProvider with ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _faceStatus = {};
|
||||
Map<String, dynamic> get faceStatus => _faceStatus;
|
||||
Map<String, dynamic> _faceStatus2 = {};
|
||||
Map<String, dynamic> get faceStatus2 => _faceStatus2;
|
||||
|
||||
Future<void> loadFaceStatus() async {
|
||||
try {
|
||||
final status = await _repository.getFaceStatus();
|
||||
_faceStatus = status;
|
||||
_faceStatus2 = status;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint("Error loading face status: $e");
|
||||
|
|
|
|||
|
|
@ -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<void> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
);
|
||||
|
||||
if (success && mounted) {
|
||||
Navigator.pushReplacementNamed(context, '/home');
|
||||
Navigator.pushReplacementNamed(context, '/onboarding/check');
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
|
|
|
|||
|
|
@ -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<FaceEnrollmentScreen> createState() => _FaceEnrollmentScreenState();
|
||||
|
|
@ -23,6 +27,13 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen> {
|
|||
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<FaceEnrollmentScreen> {
|
|||
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<FaceEnrollmentScreen> {
|
|||
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,6 +149,7 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen> {
|
|||
),
|
||||
body: Consumer<FaceProvider>(
|
||||
builder: (context, provider, child) {
|
||||
// Deteksi step baru → ambil foto
|
||||
if (provider.currentStep.index > _lastStepIndex && _lastStepIndex != -1) {
|
||||
HapticFeedback.lightImpact();
|
||||
_takePictureAndSave(provider);
|
||||
|
|
@ -106,9 +159,12 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen> {
|
|||
}
|
||||
|
||||
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<FaceEnrollmentScreen> {
|
|||
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<Color>(AppTheme.statusGreen),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// --- Banner Instruksi Atas ---
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + kToolbarHeight + 20,
|
||||
left: 20,
|
||||
|
|
@ -139,33 +215,56 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen> {
|
|||
style: AppTheme.heading3.copyWith(color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ClipRRect(
|
||||
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: Colors.white.withOpacity(0.15),
|
||||
color: isHolding
|
||||
? AppTheme.statusGreen.withOpacity(0.25)
|
||||
: Colors.white.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.3)),
|
||||
border: Border.all(
|
||||
color: isHolding
|
||||
? AppTheme.statusGreen.withOpacity(0.6)
|
||||
: Colors.white.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isComplete ? Icons.check_circle : Icons.face,
|
||||
color: isComplete ? AppTheme.statusGreen : AppTheme.primaryOrange,
|
||||
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(
|
||||
provider.instructionText,
|
||||
isComplete
|
||||
? provider.instructionText
|
||||
: _getStatusText(provider),
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTheme.bodyLarge.copyWith(
|
||||
color: isComplete ? AppTheme.statusGreen : Colors.white,
|
||||
fontWeight: FontWeight.bold
|
||||
color: isComplete
|
||||
? AppTheme.statusGreen
|
||||
: isHolding
|
||||
? AppTheme.statusGreen
|
||||
: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -174,10 +273,12 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen> {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// --- Panel Bawah: Progress Steps & Tombol Selesai ---
|
||||
Positioned(
|
||||
bottom: 40,
|
||||
left: 20,
|
||||
|
|
@ -247,12 +348,22 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen> {
|
|||
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();
|
||||
if (widget.isOnboarding) {
|
||||
context.read<SetupCheckProvider>().reset();
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const SignatureScreen(isOnboarding: true),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
@ -278,7 +389,11 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen> {
|
|||
provider.processCameraImage(
|
||||
image,
|
||||
_frontCamera!,
|
||||
CameraUtils.rotationIntToImageRotation(_frontCamera!.sensorOrientation)
|
||||
CameraUtils.rotationIntToImageRotation(_frontCamera!.sensorOrientation),
|
||||
scanBoxLeft: _boxLeft,
|
||||
scanBoxRight: _boxRight,
|
||||
scanBoxTop: _boxTop,
|
||||
scanBoxBottom: _boxBottom,
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -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<OnboardingCheckScreen> createState() => _OnboardingCheckScreenState();
|
||||
}
|
||||
|
||||
class _OnboardingCheckScreenState extends State<OnboardingCheckScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _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<void> _runCheck() async {
|
||||
final provider = context.read<SetupCheckProvider>();
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -330,14 +330,14 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||
icon: Icons.face,
|
||||
label: "Registrasi Wajah",
|
||||
onTap: () => Navigator.pushNamed(context, '/onboarding/face-enrollment'),
|
||||
trailingText: context.watch<FaceProvider>().faceStatus['is_registered'] == true
|
||||
trailingText: context.watch<FaceProvider>().faceStatus2['is_registered'] == true
|
||||
? "Terdaftar"
|
||||
: "Belum Terdaftar",
|
||||
trailingColor: context.watch<FaceProvider>().faceStatus['is_registered'] == true
|
||||
trailingColor: context.watch<FaceProvider>().faceStatus2['is_registered'] == true
|
||||
? AppTheme.statusGreen
|
||||
: AppTheme.statusRed,
|
||||
),
|
||||
if (context.watch<FaceProvider>().faceStatus['is_registered'] == true)
|
||||
if (context.watch<FaceProvider>().faceStatus2['is_registered'] == true)
|
||||
_buildActionItem(
|
||||
icon: Icons.face_retouching_natural,
|
||||
label: "Test Pengenalan Wajah",
|
||||
|
|
|
|||
|
|
@ -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<SignatureScreen> createState() => _SignatureScreenState();
|
||||
|
|
@ -114,7 +116,13 @@ class _SignatureScreenState extends State<SignatureScreen> {
|
|||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
if (success) _clearCanvas();
|
||||
if (success) {
|
||||
if (widget.isOnboarding && mounted) {
|
||||
Navigator.pushReplacementNamed(context, '/home');
|
||||
} else {
|
||||
_clearCanvas();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -173,7 +181,9 @@ class _SignatureScreenState extends State<SignatureScreen> {
|
|||
backgroundColor: AppTheme.bgWhite,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
leading: IconButton(
|
||||
leading: widget.isOnboarding
|
||||
? const SizedBox.shrink()
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: AppTheme.textPrimary),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ class _SplashScreenState extends State<SplashScreen> 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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
));
|
||||
|
|
|
|||
Loading…
Reference in New Issue