This commit is contained in:
jouel88 2026-03-20 17:02:32 +07:00
parent 4091624ec4
commit 664acb389e
13 changed files with 1190 additions and 128 deletions

File diff suppressed because one or more lines are too long

View File

@ -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 {

View File

@ -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': '',

View File

@ -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(),

View File

@ -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");

View File

@ -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;
}
}

View File

@ -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(

View File

@ -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) {

View File

@ -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),
),
],
),
),
),
);
}
}

View File

@ -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",

View File

@ -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),
),

View File

@ -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');
}

View File

@ -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);
},
));