This commit is contained in:
jouel88 2026-05-03 08:26:19 +07:00
parent 914f648c68
commit a4f964bb20
75 changed files with 1852 additions and 696 deletions

Binary file not shown.

Binary file not shown.

View File

@ -70,5 +70,13 @@
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain"/>
</intent> </intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="http"/>
</intent>
</queries> </queries>
</manifest> </manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 906 B

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 621 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@ -14,9 +14,12 @@ rootProject.layout.buildDirectory.value(newBuildDir)
subprojects { subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir) project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app") project.evaluationDependsOn(":app")
project.plugins.withId("com.android.library") {
project.extensions.configure<com.android.build.gradle.LibraryExtension> {
compileSdk = 36
}
}
} }
tasks.register<Delete>("clean") { tasks.register<Delete>("clean") {

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,4 @@
org.gradle.jvmargs=-Xmx3G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
kotlin.daemon.jvmargs=-Xmx1024m -XX:MaxMetaspaceSize=512m
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true

BIN
assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 B

After

Width:  |  Height:  |  Size: 524 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 525 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 767 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 B

After

Width:  |  Height:  |  Size: 805 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 741 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 525 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1016 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 661 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 702 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 906 B

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 980 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -16,7 +16,7 @@ class ApiUrl {
return _cachedImageBaseUrl!; return _cachedImageBaseUrl!;
} }
static String get baseUrl => _cachedBaseUrl ?? dotenv.env['API_BASE_URL'] ?? 'http://10.10.4.20:8000/api'; static String get baseUrl => _cachedBaseUrl ?? dotenv.env['API_BASE_URL'] ?? 'http://192.168.110.18:8000/api';
static String get imageBaseUrl => _cachedImageBaseUrl ?? baseUrl.replaceAll('/api', '/storage/'); static String get imageBaseUrl => _cachedImageBaseUrl ?? baseUrl.replaceAll('/api', '/storage/');
static Future<void> initialize() async { static Future<void> initialize() async {

View File

@ -6,13 +6,19 @@ class ApiConfigService {
static const String _selectedPresetKey = 'selected_preset'; static const String _selectedPresetKey = 'selected_preset';
static const Map<String, String> presets = { static const Map<String, String> presets = {
'current_ip': 'http://192.168.1.3:8000/api', 'cloudflare': 'https://exams-rhode-agency-outsourcing.trycloudflare.com/api',
'current_ip': 'http://192.168.1.98:8000/api',
'hostname': 'http://LAPTOP-I0SUKSKL:8000/api', 'hostname': 'http://LAPTOP-I0SUKSKL:8000/api',
'emulator': 'http://10.0.2.2:8000/api', 'emulator': 'http://10.0.2.2:8000/api',
'custom': '', 'custom': '',
}; };
static Future<String> getApiUrl() async { static Future<String> getApiUrl() async {
final envUrl = dotenv.env['API_BASE_URL'];
if (envUrl != null && envUrl.isNotEmpty) {
return envUrl;
}
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final savedUrl = prefs.getString(_apiUrlKey); final savedUrl = prefs.getString(_apiUrlKey);
@ -28,11 +34,6 @@ class ApiConfigService {
} }
} }
final envUrl = dotenv.env['API_BASE_URL'];
if (envUrl != null && envUrl.isNotEmpty) {
return envUrl;
}
return presets['hostname']!; return presets['hostname']!;
} }
@ -87,6 +88,8 @@ class ApiConfigService {
static String getPresetDisplayName(String presetKey) { static String getPresetDisplayName(String presetKey) {
switch (presetKey) { switch (presetKey) {
case 'cloudflare':
return 'Cloudflare Tunnel (Internet)';
case 'current_ip': case 'current_ip':
return 'IP Laptop Saat Ini - Recommended'; return 'IP Laptop Saat Ini - Recommended';
case 'hostname': case 'hostname':

View File

@ -59,15 +59,77 @@ class InputImageFormatUtils {
} }
class BrightnessUtils { class BrightnessUtils {
static double calculateBrightness(CameraImage image) { static double calculateBrightness(CameraImage image, {bool centerOnly = false}) {
final yPlane = image.planes[0]; final yPlane = image.planes[0];
final bytes = yPlane.bytes; final bytes = yPlane.bytes;
int bytesPerRow = yPlane.bytesPerRow;
int startX = 0;
int endX = image.width;
int startY = 0;
int endY = image.height;
if (centerOnly) {
startX = (image.width * 0.45).toInt();
endX = (image.width * 0.55).toInt();
startY = (image.height * 0.45).toInt();
endY = (image.height * 0.55).toInt();
}
int sum = 0; int sum = 0;
int count = 0; int count = 0;
for (int i = 0; i < bytes.length; i += 10) {
sum += bytes[i]; for (int y = startY; y < endY; y += 4) {
count++; for (int x = startX; x < endX; x += 4) {
int index = y * bytesPerRow + x;
if (index < bytes.length) {
sum += bytes[index];
count++;
}
}
}
return count > 0 ? sum / count : 0.0;
}
static double calculateFaceUniformity(CameraImage image) {
final yPlane = image.planes[0];
final bytes = yPlane.bytes;
int bytesPerRow = yPlane.bytesPerRow;
int startX = (image.width * 0.30).toInt();
int endX = (image.width * 0.70).toInt();
int startY = (image.height * 0.30).toInt();
int endY = (image.height * 0.70).toInt();
int midX = (startX + endX) ~/ 2;
int midY = (startY + endY) ~/ 2;
double topBrightness = _zoneBrightness(bytes, bytesPerRow, startX, endX, startY, midY);
double bottomBrightness = _zoneBrightness(bytes, bytesPerRow, startX, endX, midY, endY);
double leftBrightness = _zoneBrightness(bytes, bytesPerRow, startX, midX, startY, endY);
double rightBrightness = _zoneBrightness(bytes, bytesPerRow, midX, endX, startY, endY);
final zones = [topBrightness, bottomBrightness, leftBrightness, rightBrightness];
double maxZone = zones.reduce((a, b) => a > b ? a : b);
double minZone = zones.reduce((a, b) => a < b ? a : b);
if (maxZone <= 0) return 0.0;
return minZone / maxZone;
}
static double _zoneBrightness(
Uint8List bytes, int bytesPerRow,
int startX, int endX, int startY, int endY,
) {
int sum = 0;
int count = 0;
for (int y = startY; y < endY; y += 4) {
for (int x = startX; x < endX; x += 4) {
int index = y * bytesPerRow + x;
if (index < bytes.length) {
sum += bytes[index];
count++;
}
}
} }
return count > 0 ? sum / count : 0.0; return count > 0 ? sum / count : 0.0;
} }

View File

@ -6,6 +6,8 @@ class AnnouncementModel {
final String jabatan; final String jabatan;
final String namaPembuat; final String namaPembuat;
final String? avatarUrl; final String? avatarUrl;
final String? fotoUrl;
final String? lampiranUrl;
AnnouncementModel({ AnnouncementModel({
required this.id, required this.id,
@ -15,6 +17,8 @@ class AnnouncementModel {
required this.jabatan, required this.jabatan,
this.namaPembuat = 'Admin', this.namaPembuat = 'Admin',
this.avatarUrl, this.avatarUrl,
this.fotoUrl,
this.lampiranUrl,
}); });
factory AnnouncementModel.fromJson(Map<String, dynamic> json) { factory AnnouncementModel.fromJson(Map<String, dynamic> json) {
@ -26,6 +30,8 @@ class AnnouncementModel {
jabatan: json['jabatan']?.toString() ?? '', jabatan: json['jabatan']?.toString() ?? '',
namaPembuat: json['nama_pembuat']?.toString() ?? 'Admin', namaPembuat: json['nama_pembuat']?.toString() ?? 'Admin',
avatarUrl: json['avatar_url']?.toString(), avatarUrl: json['avatar_url']?.toString(),
fotoUrl: json['foto_url']?.toString(),
lampiranUrl: json['lampiran_url']?.toString(),
); );
} }
} }

View File

@ -98,7 +98,11 @@ class AttendanceProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> fetchHistory() async { bool _hasLoaded = false;
Future<void> fetchHistory({bool force = false}) async {
if (_hasLoaded && !force) return;
isLoading = true; isLoading = true;
notifyListeners(); notifyListeners();
try { try {
@ -106,6 +110,7 @@ class AttendanceProvider extends ChangeNotifier {
month: selectedMonth, month: selectedMonth,
year: selectedYear, year: selectedYear,
); );
_hasLoaded = true;
} catch (e) { } catch (e) {
print('Error fetching history: $e'); print('Error fetching history: $e');
} finally { } finally {
@ -117,13 +122,13 @@ class AttendanceProvider extends ChangeNotifier {
void setMonth(int month) { void setMonth(int month) {
selectedMonth = month; selectedMonth = month;
notifyListeners(); notifyListeners();
fetchHistory(); fetchHistory(force: true);
} }
void setYear(int year) { void setYear(int year) {
selectedYear = year; selectedYear = year;
notifyListeners(); notifyListeners();
fetchHistory(); fetchHistory(force: true);
} }
Future<bool> submitPresensi(String type, File photoFile) async { Future<bool> submitPresensi(String type, File photoFile) async {
@ -169,7 +174,7 @@ class AttendanceProvider extends ChangeNotifier {
try { try {
final success = await _repository.resubmitPresensi(idPresensi, keterangan); final success = await _repository.resubmitPresensi(idPresensi, keterangan);
if (success) { if (success) {
await fetchHistory(); await fetchHistory(force: true);
} }
return success; return success;
} catch (e) { } catch (e) {

View File

@ -6,11 +6,11 @@ import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
import '../repositories/face_repository.dart'; import '../repositories/face_repository.dart';
import '../core/utils/camera_utils.dart'; import '../core/utils/camera_utils.dart';
enum VideoRecordingState { idle, ready, recording, uploading, success, error } enum VideoRecordingState { idle, recording, uploading, success, error }
enum FaceDetectionStatus { noFace, detected } enum FaceDetectionStatus { noFace, detected }
enum EnrollmentStep { front, right, left } enum EnrollmentStep { front, right, left, done }
class FaceProvider with ChangeNotifier { class FaceProvider with ChangeNotifier {
final FaceRepository _repository = FaceRepository(); final FaceRepository _repository = FaceRepository();
@ -31,22 +31,29 @@ class FaceProvider with ChangeNotifier {
String _errorMessage = ''; String _errorMessage = '';
bool _isProcessingFrame = false; bool _isProcessingFrame = false;
// Guided enrollment
EnrollmentStep _currentStep = EnrollmentStep.front;
double _headEulerAngleY = 0.0; double _headEulerAngleY = 0.0;
bool _isPoseValid = false; bool _isPoseValid = false;
double _faceRatio = 0.0; double _faceRatio = 0.0;
bool _isDistanceOk = false; bool _isDistanceOk = false;
double _brightness = 0.0; double _brightness = 0.0;
bool _isBrightnessOk = false; bool _isBrightnessOk = false;
double _stepProgress = 0.0; double _lightingUniformity = 0.0;
double _rightAngleSign = 0.0; bool _isLightingUniform = false;
int _consecutiveValidPosedFrames = 0;
Timer? _progressTimer;
static const double _frontDuration = 4.0; EnrollmentStep _currentStep = EnrollmentStep.front;
static const double _sideDuration = 6.0; // Tambahan jeda ekstra untuk menoleh yang rapi DateTime? _poseStartTime;
static const double _tickInterval = 100; bool _needsAbort = false;
String _abortReason = '';
Timer? _maxDurationTimer;
static const int _maxRecordingSeconds = 45;
DateTime? _recordingStartTime;
static const int _warmupMs = 2000;
static const double _turnThreshold = 10.0;
int _consecutiveBadFrames = 0;
static const int _badFrameThreshold = 30;
static const int _holdDurationMs = 4000;
VideoRecordingState get recordingState => _recordingState; VideoRecordingState get recordingState => _recordingState;
FaceDetectionStatus get faceDetectionStatus => _faceDetectionStatus; FaceDetectionStatus get faceDetectionStatus => _faceDetectionStatus;
@ -55,96 +62,107 @@ class FaceProvider with ChangeNotifier {
bool get isFaceDetected => bool get isFaceDetected =>
_faceDetectionStatus == FaceDetectionStatus.detected; _faceDetectionStatus == FaceDetectionStatus.detected;
EnrollmentStep get currentStep => _currentStep;
double get headEulerAngleY => _headEulerAngleY; double get headEulerAngleY => _headEulerAngleY;
bool get isPoseValid => _isPoseValid; bool get isPoseValid => _isPoseValid;
double get faceRatio => _faceRatio; double get faceRatio => _faceRatio;
bool get isDistanceOk => _isDistanceOk; bool get isDistanceOk => _isDistanceOk;
double get brightness => _brightness; double get brightness => _brightness;
bool get isBrightnessOk => _isBrightnessOk; bool get isBrightnessOk => _isBrightnessOk;
double get stepProgress => _stepProgress; double get lightingUniformity => _lightingUniformity;
bool get isLightingUniform => _isLightingUniform;
double get stepProgress {
int completed = 0;
if (_currentStep == EnrollmentStep.right) completed = 1;
if (_currentStep == EnrollmentStep.left) completed = 2;
if (_currentStep == EnrollmentStep.done) completed = 3;
double holdProgress = 0.0;
if (_poseStartTime != null && _currentStep != EnrollmentStep.done) {
holdProgress = (DateTime.now().difference(_poseStartTime!).inMilliseconds / _holdDurationMs.toDouble()).clamp(0.0, 1.0) / 3.0;
}
return (completed / 3.0) + holdProgress;
}
bool get needsAbort => _needsAbort;
String get abortReason => _abortReason;
bool get isChecklistComplete => _currentStep == EnrollmentStep.done;
bool get allChecksValid => bool get allChecksValid =>
isFaceDetected && _isPoseValid && _isDistanceOk && _isBrightnessOk; isFaceDetected && _isPoseValid && _isDistanceOk && _isBrightnessOk && _isLightingUniform;
String get qualityMessage { String get qualityMessage {
if (_recordingState == VideoRecordingState.ready) return 'Liveliness Lulus! Tekan tombol Rekam di bawah.';
if (_recordingState == VideoRecordingState.recording) { if (_recordingState == VideoRecordingState.recording) {
if (_currentStep == EnrollmentStep.front) return 'Tahan posisi hadap depan...'; int secondsLeft = (_holdDurationMs / 1000).ceil();
if (_currentStep == EnrollmentStep.right) return 'Mulai toleh ke kanan perlahan...'; if (_poseStartTime != null) {
return 'Mulai toleh arah sebaliknya perlahan...'; int elapsed = DateTime.now().difference(_poseStartTime!).inMilliseconds;
secondsLeft = ((_holdDurationMs / 1000).ceil() - (elapsed / 1000).floor()).clamp(1, (_holdDurationMs / 1000).ceil());
}
switch (_currentStep) {
case EnrollmentStep.front:
if (_poseStartTime == null) return 'Tatap lurus ke kamera...';
return 'Tahan pose depan... ($secondsLeft detik)';
case EnrollmentStep.right:
if (_poseStartTime == null) return 'Menoleh perlahan ke KANAN 👉';
return 'Tahan pose kanan... ($secondsLeft detik)';
case EnrollmentStep.left:
if (_poseStartTime == null) return 'Menoleh perlahan ke KIRI 👈';
return 'Tahan pose kiri... ($secondsLeft detik)';
case EnrollmentStep.done:
return 'Pose Selesai! Menyimpan...';
}
} }
if (!isFaceDetected) return 'Arahkan wajah ke dalam bingkai'; if (!isFaceDetected) return 'Arahkan wajah ke dalam bingkai';
if (_faceRatio < 0.20) return 'Terlalu jauh, dekatkan wajah ke kamera'; if (_faceRatio < 0.25) return 'Terlalu jauh, dekatkan wajah ke kamera';
if (_faceRatio > 0.70) return 'Terlalu dekat, mundur sedikit'; if (_faceRatio > 0.75) return 'Terlalu dekat, mundur sedikit';
if (!_isBrightnessOk) return 'Pencahayaan kurang, cari terang'; if (!_isBrightnessOk) return 'Pencahayaan kurang, cari tempat terang';
if (!_isPoseValid) { if (!_isLightingUniform) return 'Ada bayangan di wajah, hadap langsung ke cahaya';
switch (_currentStep) { if (!_isPoseValid) return 'Hadap depan lurus ke kamera';
case EnrollmentStep.front:
return 'UJI: Hadap depan (Ideal: <12, Skg: ${_headEulerAngleY.toStringAsFixed(0)})';
case EnrollmentStep.right:
return 'UJI: Toleh kanan/kiri (Minimal 12 derajat, Skg: ${_headEulerAngleY.toStringAsFixed(0)})';
case EnrollmentStep.left:
return 'UJI: Toleh arah sebaliknya (Minimal 12 derajat, Skg: ${_headEulerAngleY.toStringAsFixed(0)})';
}
}
return ''; return '';
} }
String get stepLabel {
if (_recordingState == VideoRecordingState.idle) {
switch (_currentStep) {
case EnrollmentStep.front: return 'Tes Liveliness: Depan';
case EnrollmentStep.right: return 'Tes Liveliness: Toleh Kanan';
case EnrollmentStep.left: return 'Tes Liveliness: Toleh Kiri';
}
}
switch (_currentStep) {
case EnrollmentStep.front:
return 'Merekam: Hadap Depan';
case EnrollmentStep.right:
return 'Merekam: Toleh Kanan';
case EnrollmentStep.left:
return 'Merekam: Toleh Kiri';
}
}
void reset() { void reset() {
_recordingState = VideoRecordingState.idle; _recordingState = VideoRecordingState.idle;
_faceDetectionStatus = FaceDetectionStatus.noFace; _faceDetectionStatus = FaceDetectionStatus.noFace;
_videoFile = null; _videoFile = null;
_message = ''; _message = '';
_errorMessage = ''; _errorMessage = '';
_isProcessingFrame = false;
_currentStep = EnrollmentStep.front; _currentStep = EnrollmentStep.front;
_headEulerAngleY = 0.0; _consecutiveBadFrames = 0;
_isPoseValid = false; _poseStartTime = null;
_recordingStartTime = null;
_needsAbort = false;
_abortReason = '';
_faceRatio = 0.0; _faceRatio = 0.0;
_isDistanceOk = false; _isDistanceOk = false;
_brightness = 0.0; _brightness = 0.0;
_isBrightnessOk = false; _isBrightnessOk = false;
_stepProgress = 0.0; _lightingUniformity = 0.0;
_rightAngleSign = 0.0; _isLightingUniform = false;
_consecutiveValidPosedFrames = 0; _maxDurationTimer?.cancel();
_progressTimer?.cancel(); _maxDurationTimer = null;
_progressTimer = null;
notifyListeners(); notifyListeners();
} }
Future<void> startRecording(CameraController controller) async { Future<void> startRecording(
CameraController controller, {
void Function(CameraImage)? onAvailable,
}) async {
if (_recordingState == VideoRecordingState.recording) return; if (_recordingState == VideoRecordingState.recording) return;
try { try {
await controller.startVideoRecording(); await controller.startVideoRecording(onAvailable: onAvailable);
_recordingState = VideoRecordingState.recording; _recordingState = VideoRecordingState.recording;
_currentStep = EnrollmentStep.front; _currentStep = EnrollmentStep.front;
_stepProgress = 0.0; _poseStartTime = null;
_message = 'Hadap Depan'; _needsAbort = false;
_consecutiveBadFrames = 0;
_recordingStartTime = DateTime.now();
_message = 'Tatap lurus ke kamera...';
notifyListeners(); notifyListeners();
_startProgressTimer(controller); _startWatchdogTimer(controller);
} catch (e) { } catch (e) {
_recordingState = VideoRecordingState.error; _recordingState = VideoRecordingState.error;
_errorMessage = 'Gagal memulai rekaman: $e'; _errorMessage = 'Gagal memulai rekaman: $e';
@ -152,58 +170,40 @@ class FaceProvider with ChangeNotifier {
} }
} }
void _startProgressTimer(CameraController controller) { void _startWatchdogTimer(CameraController controller) {
_progressTimer?.cancel(); _maxDurationTimer?.cancel();
_progressTimer = Timer.periodic( _maxDurationTimer = Timer(const Duration(seconds: _maxRecordingSeconds), () {
Duration(milliseconds: _tickInterval.toInt()), if (_recordingState == VideoRecordingState.recording) {
(timer) { _triggerAbort("Gagal menyelesaikan instruksi wajah.");
if (_recordingState != VideoRecordingState.recording) { }
timer.cancel(); });
return;
}
// Fase 2: Hardware Android Camera2 pada banyak device mematikan aliran Stream Gambar
// ketika Stream Video berjalan (freeze). Bypass strict MLKit, andalkan jeda waktu murni.
final duration = _currentStep == EnrollmentStep.front
? _frontDuration
: _sideDuration;
final increment = (_tickInterval / 1000) / duration;
_stepProgress += increment;
if (_stepProgress >= 1.0) {
_advanceStep(controller);
}
notifyListeners();
},
);
} }
void _advanceStep(CameraController controller) { void _triggerAbort(String reason) {
switch (_currentStep) { _needsAbort = true;
case EnrollmentStep.front: _abortReason = reason;
_currentStep = EnrollmentStep.right; notifyListeners();
_stepProgress = 0.0; }
_isPoseValid = false;
_rightAngleSign = 0.0; Future<void> abortRecording(CameraController controller) async {
_message = 'Toleh Kanan'; _maxDurationTimer?.cancel();
break; if (controller.value.isRecordingVideo) {
case EnrollmentStep.right: try {
_currentStep = EnrollmentStep.left; await controller.stopVideoRecording();
_stepProgress = 0.0; } catch (_) {}
_isPoseValid = false;
_message = 'Toleh Kiri';
break;
case EnrollmentStep.left:
_progressTimer?.cancel();
stopRecording(controller);
break;
} }
await Future.delayed(const Duration(milliseconds: 500));
_recordingState = VideoRecordingState.error;
_errorMessage = _abortReason.isNotEmpty ? _abortReason : "Perekaman dibatalkan.";
_needsAbort = false;
notifyListeners();
} }
Future<void> stopRecording(CameraController controller) async { Future<void> stopRecording(CameraController controller) async {
_progressTimer?.cancel(); _maxDurationTimer?.cancel();
if (!controller.value.isRecordingVideo) return; if (!controller.value.isRecordingVideo) return;
@ -230,14 +230,19 @@ class FaceProvider with ChangeNotifier {
} }
_recordingState = VideoRecordingState.uploading; _recordingState = VideoRecordingState.uploading;
_message = 'Mengunggah & memproses video...'; final fileSize = await _videoFile!.length();
debugPrint('[FaceUpload] File: ${_videoFile!.path}, Size: ${(fileSize / 1024 / 1024).toStringAsFixed(2)} MB');
_message = 'Mengunggah video (${(fileSize / 1024 / 1024).toStringAsFixed(1)} MB)...';
notifyListeners(); notifyListeners();
try { try {
debugPrint('[FaceUpload] Mulai upload...');
await _repository.enrollFace(videoFile: _videoFile!); await _repository.enrollFace(videoFile: _videoFile!);
debugPrint('[FaceUpload] Upload BERHASIL');
_recordingState = VideoRecordingState.success; _recordingState = VideoRecordingState.success;
_message = 'Pendaftaran Wajah Berhasil!'; _message = 'Pendaftaran Wajah Berhasil!';
} catch (e) { } catch (e) {
debugPrint('[FaceUpload] Upload GAGAL: $e');
_recordingState = VideoRecordingState.error; _recordingState = VideoRecordingState.error;
_errorMessage = e.toString().replaceAll('Exception: ', ''); _errorMessage = e.toString().replaceAll('Exception: ', '');
} }
@ -253,8 +258,11 @@ class FaceProvider with ChangeNotifier {
_isProcessingFrame = true; _isProcessingFrame = true;
try { try {
_brightness = BrightnessUtils.calculateBrightness(image); _brightness = BrightnessUtils.calculateBrightness(image, centerOnly: true);
_isBrightnessOk = _brightness > 60; _isBrightnessOk = _brightness > 70;
_lightingUniformity = BrightnessUtils.calculateFaceUniformity(image);
_isLightingUniform = _lightingUniformity > 0.45;
final inputImage = CameraUtils.inputImageFromCameraImage( final inputImage = CameraUtils.inputImageFromCameraImage(
image: image, image: image,
@ -270,53 +278,108 @@ class FaceProvider with ChangeNotifier {
_headEulerAngleY = face.headEulerAngleY ?? 0.0; _headEulerAngleY = face.headEulerAngleY ?? 0.0;
final imageWidth = image.width.toDouble(); // Tentukan dimensi efektif berdasarkan rotasi sensor
final box = face.boundingBox; // Sensor landscape (270°/90°) dimensi display-nya DIBALIK
_faceRatio = box.width / imageWidth; final bool isRotated90or270 =
_isDistanceOk = _faceRatio >= 0.20 && _faceRatio <= 0.70; rotation == InputImageRotation.rotation90deg ||
rotation == InputImageRotation.rotation270deg;
final double effectiveWidth = isRotated90or270
? image.height.toDouble()
: image.width.toDouble();
final double effectiveHeight = isRotated90or270
? image.width.toDouble()
: image.height.toDouble();
switch (_currentStep) { final box = face.boundingBox;
case EnrollmentStep.front:
_isPoseValid = _headEulerAngleY.abs() < 12; // faceRatio dihitung dari lebar display yang sebenarnya
break; _faceRatio = box.width / effectiveWidth;
case EnrollmentStep.right:
_isPoseValid = _headEulerAngleY.abs() >= 12; // Validasi: Wajah harus berada SEPENUHNYA di dalam frame (tidak terpotong)
if (_isPoseValid) { final double marginW = effectiveWidth * 0.03;
_rightAngleSign = _headEulerAngleY.sign; final double marginH = effectiveHeight * 0.03;
} final bool isFaceInsideFrame =
break; box.left >= marginW &&
case EnrollmentStep.left: box.top >= marginH &&
_isPoseValid = _headEulerAngleY.abs() >= 12 && box.right <= (effectiveWidth - marginW) &&
(_rightAngleSign == 0.0 || _headEulerAngleY.sign != _rightAngleSign); box.bottom <= (effectiveHeight - marginH);
break;
if (_recordingState == VideoRecordingState.recording) {
_isDistanceOk = isFaceInsideFrame && _faceRatio >= 0.20 && _faceRatio <= 0.80;
} else {
_isDistanceOk = isFaceInsideFrame && _faceRatio >= 0.25 && _faceRatio <= 0.75;
} }
// Auto Advance Phase 1 _isPoseValid = _headEulerAngleY.abs() < 18.0;
if (_recordingState == VideoRecordingState.idle && allChecksValid) {
_consecutiveValidPosedFrames++; if (_recordingState == VideoRecordingState.recording) {
if (_consecutiveValidPosedFrames > 5) { final bool isWarmup = _recordingStartTime != null &&
_consecutiveValidPosedFrames = 0; DateTime.now().difference(_recordingStartTime!).inMilliseconds < _warmupMs;
if (_currentStep == EnrollmentStep.front) {
_currentStep = EnrollmentStep.right; if (!_isDistanceOk || !_isBrightnessOk || !_isLightingUniform) {
_isPoseValid = false; if (!isWarmup) {
_rightAngleSign = 0.0; _consecutiveBadFrames++;
} else if (_currentStep == EnrollmentStep.right) { if (_consecutiveBadFrames >= _badFrameThreshold) {
_currentStep = EnrollmentStep.left; _triggerAbort("Perekaman batal: Jarak wajah atau cahaya tidak sesuai.");
_isPoseValid = false; }
} else if (_currentStep == EnrollmentStep.left) { }
_recordingState = VideoRecordingState.ready; _poseStartTime = null;
_currentStep = EnrollmentStep.front; // Reset for recording phase } else {
_isPoseValid = true; _consecutiveBadFrames = 0;
bool isHoldingCorrectPose = false;
switch (_currentStep) {
case EnrollmentStep.front:
isHoldingCorrectPose = _headEulerAngleY.abs() < 18.0;
break;
case EnrollmentStep.right:
isHoldingCorrectPose = _headEulerAngleY < -_turnThreshold;
break;
case EnrollmentStep.left:
isHoldingCorrectPose = _headEulerAngleY > _turnThreshold;
break;
case EnrollmentStep.done:
isHoldingCorrectPose = true;
break;
}
if (isHoldingCorrectPose) {
if (_poseStartTime == null) {
_poseStartTime = DateTime.now();
} else if (DateTime.now().difference(_poseStartTime!).inMilliseconds >= _holdDurationMs) {
if (_currentStep == EnrollmentStep.front) {
_currentStep = EnrollmentStep.right;
_poseStartTime = null;
} else if (_currentStep == EnrollmentStep.right) {
_currentStep = EnrollmentStep.left;
_poseStartTime = null;
} else if (_currentStep == EnrollmentStep.left) {
_currentStep = EnrollmentStep.done;
_poseStartTime = null;
notifyListeners();
}
}
} else {
_poseStartTime = null;
} }
} }
} else {
_consecutiveValidPosedFrames = 0;
} }
} else { } else {
_faceDetectionStatus = FaceDetectionStatus.noFace; _faceDetectionStatus = FaceDetectionStatus.noFace;
_isPoseValid = false; _isPoseValid = false;
_isDistanceOk = false; _isDistanceOk = false;
_faceRatio = 0.0; _faceRatio = 0.0;
if (_recordingState == VideoRecordingState.recording) {
final bool isWarmup = _recordingStartTime != null &&
DateTime.now().difference(_recordingStartTime!).inMilliseconds < _warmupMs;
if (!isWarmup) {
_consecutiveBadFrames++;
if (_consecutiveBadFrames >= _badFrameThreshold) {
_triggerAbort("Perekaman batal: Wajah tidak ditemukan dalam bingkai.");
}
}
}
} }
notifyListeners(); notifyListeners();
@ -342,7 +405,7 @@ class FaceProvider with ChangeNotifier {
@override @override
void dispose() { void dispose() {
_progressTimer?.cancel(); _maxDurationTimer?.cancel();
_faceDetector.close(); _faceDetector.close();
super.dispose(); super.dispose();
} }

View File

@ -19,10 +19,15 @@ class HomeProvider extends ChangeNotifier {
bool _isLoading = false; bool _isLoading = false;
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
bool _hasLoaded = false;
bool get hasLoaded => _hasLoaded;
String? _errorMessage; String? _errorMessage;
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
Future<void> fetchDashboardData() async { Future<void> fetchDashboardData({bool force = false}) async {
if (_hasLoaded && !force) return;
_isLoading = true; _isLoading = true;
notifyListeners(); notifyListeners();
@ -36,6 +41,7 @@ class HomeProvider extends ChangeNotifier {
izinCount = data['izin_count'] ?? 0; izinCount = data['izin_count'] ?? 0;
alphaCount = data['alpha_count'] ?? 0; alphaCount = data['alpha_count'] ?? 0;
_isLoading = false; _isLoading = false;
_hasLoaded = true;
_scheduleAttendanceReminders(); _scheduleAttendanceReminders();
} catch (e) { } catch (e) {

View File

@ -126,7 +126,7 @@ class PengajuanProvider extends ChangeNotifier {
try { try {
return await _repository.submitPengajuan(type, data); return await _repository.submitPengajuan(type, data);
} catch(e) { } catch(e) {
return false; rethrow;
} finally { } finally {
isLoading = false; isLoading = false;
notifyListeners(); notifyListeners();

View File

@ -30,7 +30,11 @@ class PoinProvider extends ChangeNotifier {
TimeOfDay? get shiftEnd => _shiftEnd; TimeOfDay? get shiftEnd => _shiftEnd;
String? get namaShift => _namaShift; String? get namaShift => _namaShift;
Future<void> loadExpiringPoints() async { bool _hasLoadedExpiring = false;
Future<void> loadExpiringPoints({bool force = false}) async {
if (_hasLoadedExpiring && !force) return;
_isLoading = true; _isLoading = true;
_errorMessage = null; _errorMessage = null;
notifyListeners(); notifyListeners();
@ -49,6 +53,7 @@ class PoinProvider extends ChangeNotifier {
} }
} }
_errorMessage = null; _errorMessage = null;
_hasLoadedExpiring = true;
} else { } else {
_errorMessage = result['message']; _errorMessage = result['message'];
} }

View File

@ -9,13 +9,18 @@ class FaceRepository {
Future<Map<String, dynamic>> enrollFace({ Future<Map<String, dynamic>> enrollFace({
required File videoFile, required File videoFile,
}) async { }) async {
final url = '${ApiUrl.baseUrl}/face/enroll';
final fileSize = await videoFile.length();
final fileSizeMB = (fileSize / 1024 / 1024).toStringAsFixed(2);
print('[FaceRepo] URL: $url');
print('[FaceRepo] File: ${videoFile.path}, Size: $fileSizeMB MB');
try { try {
final token = CacheManager.authBox.get('auth_token'); final token = CacheManager.authBox.get('auth_token');
print('[FaceRepo] Token: ${token != null ? "ada (${token.toString().substring(0, 20)}...)" : "NULL!"}');
var request = http.MultipartRequest( var request = http.MultipartRequest('POST', Uri.parse(url));
'POST',
Uri.parse('${ApiUrl.baseUrl}/face/enroll'),
);
request.headers.addAll({ request.headers.addAll({
'Authorization': 'Bearer $token', 'Authorization': 'Bearer $token',
@ -28,12 +33,25 @@ class FaceRepository {
contentType: MediaType('video', 'mp4'), contentType: MediaType('video', 'mp4'),
)); ));
var streamedResponse = await request.send().timeout( print('[FaceRepo] Sending request...');
const Duration(minutes: 3), final stopwatch = Stopwatch()..start();
);
var response = await http.Response.fromStream(streamedResponse);
final body = jsonDecode(response.body); var streamedResponse = await request.send().timeout(
const Duration(minutes: 5),
);
stopwatch.stop();
print('[FaceRepo] Response received in ${stopwatch.elapsed.inSeconds}s, status: ${streamedResponse.statusCode}');
var response = await http.Response.fromStream(streamedResponse);
print('[FaceRepo] Body: ${response.body}');
Map<String, dynamic> body;
try {
body = jsonDecode(response.body);
} catch (_) {
throw Exception('Server mengembalikan format tidak valid. Coba ulangi lagi.');
}
if (response.statusCode == 200 || response.statusCode == 201) { if (response.statusCode == 200 || response.statusCode == 201) {
return { return {
@ -45,11 +63,12 @@ class FaceRepository {
throw Exception(body['message'] ?? 'Gagal mendaftarkan wajah: ${response.statusCode}'); throw Exception(body['message'] ?? 'Gagal mendaftarkan wajah: ${response.statusCode}');
} }
} catch (e) { } catch (e) {
print('[FaceRepo] ERROR: $e');
rethrow; rethrow;
} }
} }
Future<Map<String, dynamic>> verifyFace(File imageFile, {String tipe = 'presensi'}) async { Future<Map<String, dynamic>> verifyFace(File videoFile, {String tipe = 'presensi'}) async {
final token = CacheManager.authBox.get('auth_token'); final token = CacheManager.authBox.get('auth_token');
var request = http.MultipartRequest( var request = http.MultipartRequest(
'POST', 'POST',
@ -61,10 +80,16 @@ class FaceRepository {
'Accept': 'application/json', 'Accept': 'application/json',
}); });
request.files.add(await http.MultipartFile.fromPath('foto', imageFile.path)); request.files.add(await http.MultipartFile.fromPath(
'file_wajah',
videoFile.path,
contentType: MediaType('video', 'mp4'),
));
request.fields['tipe'] = tipe; request.fields['tipe'] = tipe;
var streamedResponse = await request.send(); var streamedResponse = await request.send().timeout(
const Duration(minutes: 3),
);
var response = await http.Response.fromStream(streamedResponse); var response = await http.Response.fromStream(streamedResponse);
final body = jsonDecode(response.body); final body = jsonDecode(response.body);

View File

@ -50,7 +50,11 @@ class HomeRepository {
); );
} }
final pengumumanList = await getPengumuman(); // Fetch pengumuman secara terpisah error tidak merusak dashboard
List<AnnouncementModel> pengumumanList = [];
try {
pengumumanList = await getPengumuman();
} catch (_) {}
return { return {
'user': user, 'user': user,
@ -81,7 +85,7 @@ class HomeRepository {
Future<List<AnnouncementModel>> getPengumuman() async { Future<List<AnnouncementModel>> getPengumuman() async {
try { try {
final response = await _apiClient.dio.get('/pengumuman'); final response = await _apiClient.dio.get('/pengumuman');
if (response.data['success'] == true) { if (response.data is Map && response.data['success'] == true) {
final List data = response.data['data']; final List data = response.data['data'];
return data.map((json) => AnnouncementModel.fromJson(json)).toList(); return data.map((json) => AnnouncementModel.fromJson(json)).toList();
} }

View File

@ -26,45 +26,43 @@ class PengajuanRepository {
} }
Future<bool> submitPengajuan(String jenis, Map<String, dynamic> data) async { Future<bool> submitPengajuan(String jenis, Map<String, dynamic> data) async {
try { final typesResponse = await _apiClient.dio.get('/submission/types');
final typesResponse = await _apiClient.dio.get('/submission/types'); if (typesResponse.data['success'] != true) {
if (typesResponse.data['success'] != true) return false; throw Exception('Gagal memuat jenis izin');
}
final List types = typesResponse.data['data']; final List types = typesResponse.data['data'];
final jenisObj = types.firstWhere( final jenisObj = types.firstWhere(
(t) => t['nama_izin'].toString().toLowerCase() == jenis.toLowerCase(), (t) => t['nama_izin'].toString().toLowerCase() == jenis.toLowerCase(),
orElse: () => null, orElse: () => null,
); );
if (jenisObj == null) throw Exception('Jenis izin tidak ditemukan'); if (jenisObj == null) throw Exception('Jenis izin tidak ditemukan');
final int jenisId = jenisObj['id_jenis_izin']; final int jenisId = jenisObj['id_jenis_izin'];
FormData formData = FormData.fromMap({ FormData formData = FormData.fromMap({
'id_jenis_izin': jenisId, 'id_jenis_izin': jenisId,
'tanggal_mulai': data['tanggal_mulai'], 'tanggal_mulai': data['tanggal_mulai'],
'tanggal_selesai': data['tanggal_selesai'], 'tanggal_selesai': data['tanggal_selesai'],
'alasan': data['alasan'], 'alasan': data['alasan'],
}); });
if (data['bukti_file'] != null) { if (data['bukti_file'] != null) {
try { final file = File(data['bukti_file']);
final file = File(data['bukti_file']); if (await file.exists()) {
if (await file.exists()) { formData.files.add(MapEntry(
formData.files.add(MapEntry( 'bukti_file',
'bukti_file', await MultipartFile.fromFile(data['bukti_file']),
await MultipartFile.fromFile(data['bukti_file']), ));
));
}
} catch (e) {
print('File upload skipped: $e');
}
} }
}
final response = await _apiClient.dio.post('/submission', data: formData); final response = await _apiClient.dio.post('/submission', data: formData);
return response.data['success'] == true;
} catch (e) { if (response.data['success'] == true) {
print('Error submitting pengajuan: $e'); return true;
return false; } else {
throw Exception(response.data['message'] ?? 'Pengajuan gagal');
} }
} }
} }

View File

@ -67,16 +67,6 @@ class _LoginScreenState extends State<LoginScreen> {
width: 200, width: 200,
fit: BoxFit.contain, fit: BoxFit.contain,
), ),
const SizedBox(height: AppTheme.spacingMd),
Text(
"HRIS MP",
style: AppTheme.heading2.copyWith(letterSpacing: 1),
),
const SizedBox(height: 4),
Text(
"Sistem Informasi SDM",
style: AppTheme.bodySmall.copyWith(color: AppTheme.textTertiary),
),
const SizedBox(height: AppTheme.spacingXxl), const SizedBox(height: AppTheme.spacingXxl),

View File

@ -66,7 +66,7 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: () => provider.fetchDashboardData(), onPressed: () => provider.fetchDashboardData(force: true),
icon: const Icon(Icons.refresh, size: 18), icon: const Icon(Icons.refresh, size: 18),
label: const Text("Coba Lagi"), label: const Text("Coba Lagi"),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@ -90,7 +90,7 @@ class _HomeScreenState extends State<HomeScreen> {
backgroundColor: AppTheme.bgLight, backgroundColor: AppTheme.bgLight,
body: SafeArea( body: SafeArea(
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () => provider.fetchDashboardData(), onRefresh: () => provider.fetchDashboardData(force: true),
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: AppTheme.spacingMd, vertical: AppTheme.spacingMd), padding: const EdgeInsets.symmetric(horizontal: AppTheme.spacingMd, vertical: AppTheme.spacingMd),
@ -135,7 +135,7 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
).then((result) { ).then((result) {
if (result != null && result['success'] == true) { if (result != null && result['success'] == true) {
provider.fetchDashboardData(); provider.fetchDashboardData(force: true);
ErrorHandler.showSuccess(result['message'] ?? 'Presensi berhasil'); ErrorHandler.showSuccess(result['message'] ?? 'Presensi berhasil');
} }
}); });
@ -207,7 +207,7 @@ class _HomeScreenState extends State<HomeScreen> {
) )
else else
SizedBox( SizedBox(
height: 130, height: 170,
child: ListView.builder( child: ListView.builder(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: provider.pengumumanList.length, itemCount: provider.pengumumanList.length,

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/theme.dart'; import '../../core/theme.dart';
import '../../models/announcement_model.dart'; import '../../models/announcement_model.dart';
import '../../widgets/atoms/custom_avatar.dart'; import '../../widgets/atoms/custom_avatar.dart';
@ -220,6 +221,150 @@ class PengumumanDetailScreen extends StatelessWidget {
), ),
), ),
// Foto pengumuman
if (pengumuman.fotoUrl != null) ...[
const SizedBox(height: 16),
FadeInUp(
delayMs: 120,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
boxShadow: AppTheme.shadowSm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
child: Row(
children: [
Container(
width: 4,
height: 20,
decoration: BoxDecoration(
color: AppTheme.primaryBlue,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Text(
'Foto',
style: AppTheme.heading3.copyWith(fontSize: 14),
),
],
),
),
ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(AppTheme.radiusLg),
bottomRight: Radius.circular(AppTheme.radiusLg),
),
child: Image.network(
pengumuman.fotoUrl!,
width: double.infinity,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
height: 120,
color: AppTheme.bgLight,
child: const Center(
child: Icon(Icons.broken_image_outlined,
color: Colors.grey, size: 40),
),
),
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return Container(
height: 180,
color: AppTheme.bgLight,
child: const Center(
child: CircularProgressIndicator()),
);
},
),
),
],
),
),
),
],
// Lampiran (file)
if (pengumuman.lampiranUrl != null) ...[
const SizedBox(height: 16),
FadeInUp(
delayMs: 160,
child: GestureDetector(
onTap: () async {
final uri = Uri.tryParse(pengumuman.lampiranUrl!);
if (uri != null && await canLaunchUrl(uri)) {
await launchUrl(uri,
mode: LaunchMode.externalApplication);
}
},
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius:
BorderRadius.circular(AppTheme.radiusLg),
boxShadow: AppTheme.shadowSm,
border: Border.all(
color: AppTheme.primaryBlue.withValues(alpha: 0.2),
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppTheme.primaryBlue
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.attach_file_rounded,
color: AppTheme.primaryBlue,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Lampiran',
style: AppTheme.labelLarge.copyWith(
color: AppTheme.primaryBlue,
fontWeight: FontWeight.w700,
),
),
Text(
pengumuman.lampiranUrl!
.split('/')
.last,
style: AppTheme.bodySmall.copyWith(
color: AppTheme.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Icon(
Icons.download_rounded,
color: AppTheme.primaryBlue,
size: 20,
),
],
),
),
),
),
],
const SizedBox(height: 80), const SizedBox(height: 80),
], ],
), ),

View File

@ -1,10 +1,15 @@
import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:animations/animations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../core/theme.dart'; import '../core/theme.dart';
import '../providers/notification_provider.dart'; import '../providers/notification_provider.dart';
import '../providers/home_provider.dart';
import '../providers/pengajuan_provider.dart';
import '../providers/attendance_provider.dart';
import '../providers/poin_provider.dart';
import '../services/fcm_service.dart'; import '../services/fcm_service.dart';
import '../services/websocket_service.dart';
import 'home/home_screen.dart'; import 'home/home_screen.dart';
import 'profile/profile_screen.dart'; import 'profile/profile_screen.dart';
import 'presensi/presensi_screen.dart'; import 'presensi/presensi_screen.dart';
@ -20,13 +25,15 @@ class MainScreen extends StatefulWidget {
class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver { class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
int _currentIndex = 0; int _currentIndex = 0;
final List<Widget> _screens = [ final List<Widget> _screens = const [
const HomeScreen(), HomeScreen(),
const PresensiScreen(), PresensiScreen(),
const PengajuanScreen(), PengajuanScreen(),
const ProfileScreen(), ProfileScreen(),
]; ];
StreamSubscription? _wsSubscription;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -34,11 +41,52 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<NotificationProvider>().fetchUnreadCount(); context.read<NotificationProvider>().fetchUnreadCount();
FcmService().initialize(context); FcmService().initialize(context);
_initWebSocket();
});
}
void _initWebSocket() {
final ws = WebSocketService();
ws.connect();
_wsSubscription = ws.eventStream.listen((event) {
final eventName = event['event'] as String? ?? '';
if (eventName.contains('Notifikasi')) {
context.read<NotificationProvider>().fetchUnreadCount();
}
if (eventName.contains('Pengajuan') ||
eventName.contains('Lembur') ||
eventName.contains('Poin') ||
eventName.contains('Cuti') ||
eventName.contains('Surat')) {
context.read<PengajuanProvider>().fetchPengajuan();
context.read<HomeProvider>().fetchDashboardData(force: true);
}
if (eventName.contains('Presensi')) {
context.read<AttendanceProvider>().fetchHistory(force: true);
context.read<HomeProvider>().fetchDashboardData(force: true);
}
if (eventName.contains('Jadwal') || eventName.contains('TukarShift')) {
context.read<HomeProvider>().fetchDashboardData(force: true);
}
if (eventName.contains('Pengumuman')) {
context.read<HomeProvider>().fetchDashboardData(force: true);
}
if (eventName.contains('FaceEnrollment')) {
context.read<HomeProvider>().fetchDashboardData(force: true);
}
}); });
} }
@override @override
void dispose() { void dispose() {
_wsSubscription?.cancel();
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();
} }
@ -55,19 +103,9 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
return Scaffold( return Scaffold(
extendBody: true, extendBody: true,
body: PageTransitionSwitcher( body: IndexedStack(
duration: const Duration(milliseconds: 300), index: _currentIndex,
transitionBuilder: (child, animation, secondaryAnimation) { children: _screens,
return FadeThroughTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
child: child,
);
},
child: KeyedSubtree(
key: ValueKey<int>(_currentIndex),
child: _screens[_currentIndex],
),
), ),
bottomNavigationBar: SafeArea( bottomNavigationBar: SafeArea(
child: Container( child: Container(

View File

@ -4,6 +4,7 @@ import 'package:camera/camera.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/face_provider.dart'; import '../../providers/face_provider.dart';
import '../../providers/setup_check_provider.dart'; import '../../providers/setup_check_provider.dart';
import '../../widgets/atoms/custom_button.dart'; import '../../widgets/atoms/custom_button.dart';
@ -59,7 +60,7 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
_cameraController = CameraController( _cameraController = CameraController(
_frontCamera!, _frontCamera!,
ResolutionPreset.high, ResolutionPreset.medium,
enableAudio: false, enableAudio: false,
imageFormatGroup: Platform.isAndroid imageFormatGroup: Platform.isAndroid
? ImageFormatGroup.nv21 ? ImageFormatGroup.nv21
@ -74,9 +75,8 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
if (!mounted) return; if (!mounted) return;
final provider = context.read<FaceProvider>(); final provider = context.read<FaceProvider>();
var isIdle = provider.recordingState == VideoRecordingState.idle; var isIdle = provider.recordingState == VideoRecordingState.idle;
var isReady = provider.recordingState == VideoRecordingState.ready;
var isRecording = provider.recordingState == VideoRecordingState.recording; var isRecording = provider.recordingState == VideoRecordingState.recording;
if (isIdle || isReady || isRecording) { if (isIdle || isRecording) {
provider.processCameraImage( provider.processCameraImage(
image, image,
_frontCamera!, _frontCamera!,
@ -97,6 +97,45 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
_pulseController.dispose(); _pulseController.dispose();
super.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: () {
Navigator.pop(ctx);
context.read<AuthProvider>().logout();
Navigator.pushNamedAndRemoveUntil(
context,
'/login',
(route) => false,
);
},
child: Text(
"Keluar",
style: AppTheme.labelLarge.copyWith(color: AppTheme.statusRed),
),
),
],
),
);
}
Future<void> _handleStartRecording(FaceProvider provider) async { Future<void> _handleStartRecording(FaceProvider provider) async {
if (_cameraController == null || !_cameraController!.value.isInitialized) { if (_cameraController == null || !_cameraController!.value.isInitialized) {
@ -104,7 +143,28 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
} }
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
await provider.startRecording(_cameraController!);
// Matikan stream gambar sementara (aturan wajib Android camera plugin)
if (_cameraController!.value.isStreamingImages) {
await _cameraController!.stopImageStream();
}
// Beri waktu kamera settle (fix layar gelap di beberapa HP)
await Future.delayed(const Duration(milliseconds: 500));
// Mulai rekam video dan oper callback onAvailable
await provider.startRecording(
_cameraController!,
onAvailable: (CameraImage image) {
if (!mounted) return;
provider.processCameraImage(
image,
_frontCamera!,
CameraUtils.rotationIntToImageRotation(
_frontCamera!.sensorOrientation),
);
},
);
} }
@override @override
@ -115,6 +175,7 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
automaticallyImplyLeading: false,
leading: IconButton( leading: IconButton(
icon: Container( icon: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
@ -122,15 +183,23 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
color: Colors.black.withOpacity(0.5), color: Colors.black.withOpacity(0.5),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon(Icons.arrow_back_ios_new, child: Icon(
color: Colors.white, size: 16), widget.isOnboarding ? Icons.logout : Icons.arrow_back_ios_new,
color: Colors.white,
size: 16,
),
), ),
onPressed: () => Navigator.pop(context), onPressed: () {
if (widget.isOnboarding) {
_showLogoutDialog();
} else {
Navigator.pop(context);
}
},
), ),
), ),
body: Consumer<FaceProvider>( body: Consumer<FaceProvider>(
builder: (context, provider, child) { builder: (context, provider, child) {
// Refresh status dari server saat enrollment berhasil
if (provider.recordingState == VideoRecordingState.success && !_successHandled) { if (provider.recordingState == VideoRecordingState.success && !_successHandled) {
_successHandled = true; _successHandled = true;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@ -139,10 +208,25 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
} }
}); });
} }
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 = final bool isIdle =
provider.recordingState == VideoRecordingState.idle; provider.recordingState == VideoRecordingState.idle;
final bool isReady =
provider.recordingState == VideoRecordingState.ready;
final bool isRecording = final bool isRecording =
provider.recordingState == VideoRecordingState.recording; provider.recordingState == VideoRecordingState.recording;
final bool isUploading = final bool isUploading =
@ -193,7 +277,7 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
// --- REC indicator --- // --- REC indicator ---
if (isRecording) if (isRecording)
Positioned( Positioned(
top: MediaQuery.of(context).padding.top + kToolbarHeight + 10, top: MediaQuery.of(context).padding.top + 16,
right: 20, right: 20,
child: AnimatedBuilder( child: AnimatedBuilder(
animation: _pulseAnimation, animation: _pulseAnimation,
@ -234,7 +318,7 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
// --- Header + Quality Checklist --- // --- Header + Quality Checklist ---
Positioned( Positioned(
top: MediaQuery.of(context).padding.top + kToolbarHeight + 20, top: MediaQuery.of(context).padding.top + 16,
left: 20, left: 20,
right: 70, right: 70,
child: Column( child: Column(
@ -247,34 +331,47 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
// Quality checklist chips
if (isIdle || isRecording) if (isIdle || isRecording)
Wrap( Column(
spacing: 6, crossAxisAlignment: CrossAxisAlignment.start,
runSpacing: 6,
children: [ children: [
_buildQualityChip( Wrap(
icon: Icons.straighten, spacing: 6,
label: 'Jarak', runSpacing: 6,
isOk: provider.isDistanceOk, children: [
isDetected: provider.isFaceDetected, _buildQualityChip(
), icon: Icons.straighten,
_buildQualityChip( label: _getDistanceLabel(provider),
icon: Icons.wb_sunny_outlined, isOk: provider.isDistanceOk,
label: 'Cahaya', isDetected: provider.isFaceDetected,
isOk: provider.isBrightnessOk, ),
isDetected: provider.isFaceDetected, _buildQualityChip(
), icon: Icons.wb_sunny_outlined,
_buildQualityChip( label: 'Cahaya',
icon: Icons.face, isOk: provider.isBrightnessOk,
label: provider.stepLabel, isDetected: provider.isFaceDetected,
isOk: provider.isPoseValid, ),
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),
],
], ],
), ),
// Status banner for uploading/success/error
if (!isIdle && !isRecording) if (!isIdle && !isRecording)
AnimatedSwitcher( AnimatedSwitcher(
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
@ -329,69 +426,6 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
), ),
), ),
// --- Instruksi di tengah saat recording ---
if (isRecording)
Center(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.2),
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
child: Column(
key: ValueKey(provider.currentStep),
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getStepIcon(provider.currentStep),
size: 60,
color: provider.allChecksValid
? Colors.white
: Colors.white.withOpacity(0.4),
),
const SizedBox(height: 8),
Text(
provider.stepLabel,
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
color: provider.allChecksValid
? Colors.white
: Colors.white.withOpacity(0.4),
),
),
if (!provider.allChecksValid &&
provider.qualityMessage.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
borderRadius: BorderRadius.circular(20),
),
child: Text(
provider.qualityMessage,
style: AppTheme.bodySmall.copyWith(
color: AppTheme.statusYellow,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
),
// --- Panel Bawah --- // --- Panel Bawah ---
Positioned( Positioned(
@ -400,9 +434,7 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
right: 20, right: 20,
child: Column( child: Column(
children: [ children: [
// Step indicator + progress (saat recording)
if (isRecording) ...[ if (isRecording) ...[
_buildStepIndicator(provider),
const SizedBox(height: 16), const SizedBox(height: 16),
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
@ -417,6 +449,14 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
), ),
child: Column( child: Column(
children: [ children: [
Text(
provider.qualityMessage,
style: AppTheme.heading3.copyWith(
color: AppTheme.primaryOrange,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: LinearProgressIndicator( child: LinearProgressIndicator(
@ -432,16 +472,16 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Row(
provider.allChecksValid mainAxisAlignment: MainAxisAlignment.spaceBetween,
? 'Merekam...' children: [
: 'Menunggu — ${provider.qualityMessage}', Text(
style: AppTheme.bodySmall.copyWith( 'Progres Pose (${(provider.stepProgress * 100).toInt()}%)',
color: provider.allChecksValid style: AppTheme.bodySmall.copyWith(
? Colors.white70 color: Colors.white70,
: AppTheme.statusYellow, ),
), ),
textAlign: TextAlign.center, ],
), ),
], ],
), ),
@ -482,8 +522,8 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
), ),
), ),
// Idle state tombol mulai rekam (hanya muncul/aktif saat ready) // Idle state tombol rekam
if (isIdle || isReady) if (isIdle)
Column( Column(
children: [ children: [
if (provider.qualityMessage.isNotEmpty) if (provider.qualityMessage.isNotEmpty)
@ -492,20 +532,75 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
child: Text( child: Text(
provider.qualityMessage, provider.qualityMessage,
style: AppTheme.bodySmall.copyWith( style: AppTheme.bodySmall.copyWith(
color: provider.isFaceDetected || isReady color: provider.isFaceDetected
? AppTheme.statusYellow ? AppTheme.statusYellow
: Colors.white70, : Colors.white70,
), ),
textAlign: TextAlign.center, 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( CustomButton(
text: isReady ? "Mulai Rekam Dataset" : "Selesaikan Tes Anti-Palsu", text: provider.allChecksValid
? "Mulai Rekam Wajah"
: "Penuhi Syarat di Atas",
icon: Icons.videocam, icon: Icons.videocam,
backgroundColor: isReady backgroundColor: provider.allChecksValid
? AppTheme.statusGreen ? AppTheme.statusGreen
: AppTheme.primaryDark.withOpacity(0.5), : AppTheme.primaryDark.withOpacity(0.5),
onPressed: isReady onPressed: provider.allChecksValid
? () => _handleStartRecording(provider) ? () => _handleStartRecording(provider)
: null, : null,
), ),
@ -602,100 +697,6 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
); );
} }
Widget _buildStepIndicator(FaceProvider provider) {
final steps = [
(EnrollmentStep.front, 'Depan'),
(EnrollmentStep.right, 'Kanan'),
(EnrollmentStep.left, 'Kiri'),
];
final currentIndex = EnrollmentStep.values.indexOf(provider.currentStep);
return ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: List.generate(steps.length * 2 - 1, (index) {
if (index.isOdd) {
final lineIndex = index ~/ 2;
final isCompleted = lineIndex < currentIndex;
return Expanded(
child: Container(
height: 3,
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: isCompleted
? AppTheme.statusGreen
: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(2),
),
),
);
}
final stepIndex = index ~/ 2;
final step = steps[stepIndex];
final isCompleted = stepIndex < currentIndex;
final isCurrent = stepIndex == currentIndex;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: isCurrent ? 28 : 22,
height: isCurrent ? 28 : 22,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isCompleted
? AppTheme.statusGreen
: isCurrent
? AppTheme.primaryOrange
: Colors.white.withOpacity(0.2),
border: isCurrent
? Border.all(color: Colors.white, width: 2)
: null,
),
child: Center(
child: isCompleted
? const Icon(Icons.check, color: Colors.white, size: 14)
: Text(
'${stepIndex + 1}',
style: TextStyle(
color: Colors.white,
fontSize: isCurrent ? 13 : 11,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 4),
Text(
step.$2,
style: TextStyle(
color: isCurrent
? Colors.white
: Colors.white.withOpacity(0.5),
fontSize: 10,
fontWeight:
isCurrent ? FontWeight.bold : FontWeight.normal,
),
),
],
);
}),
),
),
),
);
}
Widget _buildQualityChip({ Widget _buildQualityChip({
required IconData icon, required IconData icon,
required String label, required String label,
@ -737,17 +738,6 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
); );
} }
IconData _getStepIcon(EnrollmentStep step) {
switch (step) {
case EnrollmentStep.front:
return Icons.face;
case EnrollmentStep.right:
return Icons.turn_right;
case EnrollmentStep.left:
return Icons.turn_left;
}
}
void _restartCameraStream() { void _restartCameraStream() {
if (_cameraController == null || if (_cameraController == null ||
!_cameraController!.value.isInitialized) return; !_cameraController!.value.isInitialized) return;
@ -768,10 +758,116 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
} catch (_) {} } catch (_) {}
} }
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) { String _getStatusText(FaceProvider provider) {
switch (provider.recordingState) { switch (provider.recordingState) {
case VideoRecordingState.idle: case VideoRecordingState.idle:
case VideoRecordingState.ready:
return ''; return '';
case VideoRecordingState.recording: case VideoRecordingState.recording:
return ''; return '';
@ -788,8 +884,6 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
switch (provider.recordingState) { switch (provider.recordingState) {
case VideoRecordingState.idle: case VideoRecordingState.idle:
return Icons.face; return Icons.face;
case VideoRecordingState.ready:
return Icons.check_circle_outline;
case VideoRecordingState.recording: case VideoRecordingState.recording:
return Icons.videocam; return Icons.videocam;
case VideoRecordingState.uploading: case VideoRecordingState.uploading:
@ -805,12 +899,10 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
switch (provider.recordingState) { switch (provider.recordingState) {
case VideoRecordingState.idle: case VideoRecordingState.idle:
return Colors.white; return Colors.white;
case VideoRecordingState.ready:
return AppTheme.statusGreen;
case VideoRecordingState.recording: case VideoRecordingState.recording:
return Colors.white; return Colors.white;
case VideoRecordingState.uploading: case VideoRecordingState.uploading:
return AppTheme.primaryOrange; return Colors.white;
case VideoRecordingState.success: case VideoRecordingState.success:
return AppTheme.statusGreen; return AppTheme.statusGreen;
case VideoRecordingState.error: case VideoRecordingState.error:

View File

@ -59,6 +59,8 @@ class _CutiFormScreenState extends State<CutiFormScreen> {
if (success && mounted) { if (success && mounted) {
Navigator.pop(context); Navigator.pop(context);
ErrorHandler.showSuccess('Pengajuan Cuti Berhasil'); ErrorHandler.showSuccess('Pengajuan Cuti Berhasil');
} else if (mounted) {
ErrorHandler.showError('Pengajuan gagal. Periksa kembali form atau ukuran file (maksimal 10MB)');
} }
} }

View File

@ -51,16 +51,22 @@ class _IzinFormScreenState extends State<IzinFormScreen> {
return; return;
} }
final success = await context.read<PengajuanProvider>().submitIzin({ try {
'tanggal_mulai': DateFormat('yyyy-MM-dd').format(_selectedDate!), final success = await context.read<PengajuanProvider>().submitIzin({
'tanggal_selesai': DateFormat('yyyy-MM-dd').format(_selectedDate!), 'tanggal_mulai': DateFormat('yyyy-MM-dd').format(_selectedDate!),
'alasan': _reasonController.text, 'tanggal_selesai': DateFormat('yyyy-MM-dd').format(_selectedDate!),
'bukti_file': _uploadedFilePath, 'alasan': _reasonController.text,
}); 'bukti_file': _uploadedFilePath,
});
if (success && mounted) { if (success && mounted) {
Navigator.pop(context); Navigator.pop(context);
ErrorHandler.showSuccess('Pengajuan Izin Berhasil'); ErrorHandler.showSuccess('Pengajuan Izin Berhasil');
}
} catch (e) {
if (mounted) {
ErrorHandler.showError(e.toString().replaceAll('Exception: ', ''));
}
} }
} }

View File

@ -95,7 +95,9 @@ class _LemburFormScreenState extends State<LemburFormScreen> {
if (success && mounted) { if (success && mounted) {
Navigator.pop(context); Navigator.pop(context);
ErrorHandler.showSuccess('Pengajuan Lembur Berhasil Disimpan'); ErrorHandler.showSuccess('Pengajuan Lembur Berhasil');
} else if (mounted) {
ErrorHandler.showError('Pengajuan gagal. Pastikan form sudah terisi dengan benar');
} }
} }
} }

View File

@ -69,6 +69,8 @@ class _SakitFormScreenState extends State<SakitFormScreen> {
if (success && mounted) { if (success && mounted) {
Navigator.pop(context); Navigator.pop(context);
ErrorHandler.showSuccess('Pengajuan Sakit Berhasil'); ErrorHandler.showSuccess('Pengajuan Sakit Berhasil');
} else if (mounted) {
ErrorHandler.showError('Pengajuan gagal. Periksa kembali form atau ukuran file (maksimal 10MB)');
} }
} }

View File

@ -16,10 +16,10 @@ class CameraCaptureScreen extends StatefulWidget {
State<CameraCaptureScreen> createState() => _CameraCaptureScreenState(); State<CameraCaptureScreen> createState() => _CameraCaptureScreenState();
} }
class _CameraCaptureScreenState extends State<CameraCaptureScreen> { class _CameraCaptureScreenState extends State<CameraCaptureScreen>
with SingleTickerProviderStateMixin {
CameraController? _controller; CameraController? _controller;
CameraDescription? _frontCamera; CameraDescription? _frontCamera;
XFile? _capturedImage;
bool _isCameraInitialized = false; bool _isCameraInitialized = false;
bool _isProcessingFrame = false; bool _isProcessingFrame = false;
@ -30,22 +30,40 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen> {
double _faceRatio = 0.0; double _faceRatio = 0.0;
double _brightness = 0.0; double _brightness = 0.0;
// State rekam video
bool _isRecording = false;
bool _isRecordingDone = false;
XFile? _recordedVideo;
XFile? _capturedPhoto;
// Countdown
int _countdown = 1;
int _recordingSecondsLeft = 1;
late FaceDetector _faceDetector; late FaceDetector _faceDetector;
late AnimationController _pulseController;
bool get _allChecksValid => bool get _allChecksValid =>
_isFaceDetected && _isDistanceOk && _isBrightnessOk; _isFaceDetected && _isDistanceOk && _isBrightnessOk;
String get _qualityMessage { String get _qualityMessage {
if (_isRecordingDone) return 'Video berhasil direkam ✓';
if (_isRecording) return 'Tetap hadap kamera... ($_recordingSecondsLeft detik)';
if (!_isFaceDetected) return 'Arahkan wajah ke dalam bingkai'; if (!_isFaceDetected) return 'Arahkan wajah ke dalam bingkai';
if (_faceRatio < 0.20) return 'Terlalu jauh, dekatkan wajah'; if (_faceRatio < 0.20) return 'Terlalu jauh, dekatkan wajah';
if (_faceRatio > 0.70) return 'Terlalu dekat, mundur sedikit'; if (_faceRatio > 0.70) return 'Terlalu dekat, mundur sedikit';
if (!_isBrightnessOk) return 'Pencahayaan kurang terang'; if (!_isBrightnessOk) return 'Pencahayaan kurang terang';
return 'Siap — ambil foto'; return 'Siap — tekan tombol untuk merekam';
} }
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
)..repeat(reverse: true);
_faceDetector = FaceDetector( _faceDetector = FaceDetector(
options: FaceDetectorOptions( options: FaceDetectorOptions(
enableClassification: false, enableClassification: false,
@ -79,7 +97,7 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen> {
if (!mounted) return; if (!mounted) return;
_controller!.startImageStream((CameraImage image) { _controller!.startImageStream((CameraImage image) {
if (!mounted || _capturedImage != null) return; if (!mounted || _isRecording || _isRecordingDone) return;
_processFrame(image); _processFrame(image);
}); });
@ -125,52 +143,85 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen> {
} }
} }
@override Future<void> _startRecording() async {
void dispose() { if (!_allChecksValid || _isRecording) return;
_controller?.dispose();
_faceDetector.close();
super.dispose();
}
Future<void> _takePicture() async {
if (!_allChecksValid) return;
try { try {
try { await _controller!.stopImageStream();
await _controller!.stopImageStream(); } catch (_) {}
} catch (_) {}
final image = await _controller!.takePicture(); XFile? photo;
HapticFeedback.lightImpact(); try {
if (!mounted) return; photo = await _controller!.takePicture();
setState(() {
_capturedImage = image;
});
} catch (e) { } catch (e) {
debugPrint("Error taking picture: $e"); debugPrint("Gagal mengambil foto: $e");
} }
setState(() {
_isRecording = true;
_recordingSecondsLeft = 1;
});
HapticFeedback.mediumImpact();
await _controller!.prepareForVideoRecording();
await _controller!.startVideoRecording();
// Countdown 1 detik
for (int i = 1; i >= 1; i--) {
if (!mounted) return;
setState(() => _recordingSecondsLeft = i);
await Future.delayed(const Duration(seconds: 1));
}
if (!mounted) return;
final video = await _controller!.stopVideoRecording();
HapticFeedback.heavyImpact();
setState(() {
_isRecording = false;
_isRecordingDone = true;
_recordedVideo = video;
_capturedPhoto = photo;
});
} }
void _retakePicture() { void _retake() {
setState(() { setState(() {
_capturedImage = null; _isRecording = false;
_isRecordingDone = false;
_recordedVideo = null;
_capturedPhoto = null;
_isFaceDetected = false; _isFaceDetected = false;
_isDistanceOk = false; _isDistanceOk = false;
_isBrightnessOk = false; _isBrightnessOk = false;
}); });
_controller!.startImageStream((CameraImage image) { _controller!.startImageStream((CameraImage image) {
if (!mounted || _capturedImage != null) return; if (!mounted || _isRecording || _isRecordingDone) return;
_processFrame(image); _processFrame(image);
}); });
} }
void _usePicture() { void _useVideo() {
if (_capturedImage != null) { if (_recordedVideo != null) {
Navigator.pop(context, File(_capturedImage!.path)); Navigator.pop(context, {
'video': File(_recordedVideo!.path),
'photo': _capturedPhoto != null ? File(_capturedPhoto!.path) : null,
});
} }
} }
@override
void dispose() {
_pulseController.dispose();
_controller?.dispose();
_faceDetector.close();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -193,6 +244,7 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen> {
), ),
body: Stack( body: Stack(
children: [ children: [
// Preview kamera
if (_isCameraInitialized) if (_isCameraInitialized)
SizedBox.expand( SizedBox.expand(
child: FittedBox( child: FittedBox(
@ -207,28 +259,70 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen> {
), ),
) )
else else
const Center( const Center(child: CircularProgressIndicator(color: Colors.white)),
child: CircularProgressIndicator(color: Colors.white)),
if (_capturedImage != null) // Overlay sambil recording: REC badge + countdown besar
SizedBox.expand( if (_isRecording)
child: Image.file( Positioned.fill(
File(_capturedImage!.path), child: Container(
fit: BoxFit.cover, color: Colors.black.withOpacity(0.15),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 80),
AnimatedBuilder(
animation: _pulseController,
builder: (context, child) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.red.withOpacity(
0.7 + 0.3 * _pulseController.value),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
const Text('REC',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14)),
],
),
),
),
],
),
),
), ),
), ),
// Scanner overlay
ScannerOverlay( ScannerOverlay(
borderColor: _capturedImage != null borderColor: _isRecordingDone
? AppTheme.statusGreen ? AppTheme.statusGreen
: _allChecksValid : _isRecording
? AppTheme.statusGreen ? Colors.red
: _isFaceDetected : _allChecksValid
? AppTheme.statusYellow ? AppTheme.statusGreen
: AppTheme.primaryBlue, : _isFaceDetected
isScanning: _capturedImage == null, ? AppTheme.statusYellow
: AppTheme.primaryBlue,
isScanning: !_isRecordingDone,
), ),
// Header info
Positioned( Positioned(
top: MediaQuery.of(context).padding.top + kToolbarHeight + 20, top: MediaQuery.of(context).padding.top + kToolbarHeight + 20,
left: 20, left: 20,
@ -236,7 +330,11 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen> {
child: Column( child: Column(
children: [ children: [
Text( Text(
"Ambil Foto Selfie", _isRecording
? 'Merekam Video...'
: _isRecordingDone
? 'Video Siap'
: 'Rekam Video Wajah',
style: AppTheme.heading3.copyWith(color: Colors.white), style: AppTheme.heading3.copyWith(color: Colors.white),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
@ -250,20 +348,20 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15), color: Colors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all( border:
color: Colors.white.withOpacity(0.3)), Border.all(color: Colors.white.withOpacity(0.3)),
), ),
child: Text( child: Text(
_capturedImage != null _qualityMessage,
? "Foto berhasil diambil ✓"
: _qualityMessage,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: AppTheme.bodyLarge.copyWith( style: AppTheme.bodyLarge.copyWith(
color: _capturedImage != null color: _isRecordingDone
? AppTheme.statusGreen ? AppTheme.statusGreen
: _allChecksValid : _isRecording
? AppTheme.statusGreen ? Colors.red.shade200
: Colors.white, : _allChecksValid
? AppTheme.statusGreen
: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@ -271,8 +369,8 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen> {
), ),
), ),
// Quality chips (saat belum capture) // Quality chips (sebelum rekam)
if (_capturedImage == null) ...[ if (!_isRecording && !_isRecordingDone) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -286,23 +384,48 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen> {
], ],
), ),
], ],
// Countdown bundar besar saat merekam
if (_isRecording) ...[
const SizedBox(height: 20),
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.red, width: 3),
color: Colors.black.withOpacity(0.4),
),
child: Center(
child: Text(
'$_recordingSecondsLeft',
style: const TextStyle(
color: Colors.white,
fontSize: 36,
fontWeight: FontWeight.bold,
),
),
),
),
],
], ],
), ),
), ),
// Tombol bawah
Positioned( Positioned(
bottom: 40, bottom: 40,
left: 20, left: 20,
right: 20, right: 20,
child: _capturedImage != null child: _isRecordingDone
? Row( ? Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
Expanded( Expanded(
child: CustomButton( child: CustomButton(
text: "Foto Ulang", text: "Rekam Ulang",
type: ButtonType.secondary, type: ButtonType.secondary,
onPressed: _retakePicture, onPressed: _retake,
icon: Icons.refresh, icon: Icons.refresh,
), ),
), ),
@ -311,51 +434,49 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen> {
child: CustomButton( child: CustomButton(
text: "Gunakan", text: "Gunakan",
type: ButtonType.primary, type: ButtonType.primary,
onPressed: _usePicture, onPressed: _useVideo,
icon: Icons.check, icon: Icons.check,
backgroundColor: AppTheme.statusGreen, backgroundColor: AppTheme.statusGreen,
), ),
), ),
], ],
) )
: Center( : _isRecording
child: GestureDetector( ? const SizedBox.shrink()
onTap: _allChecksValid ? _takePicture : null, : Center(
child: Container( child: GestureDetector(
width: 76, onTap: _allChecksValid ? _startRecording : null,
height: 76, child: Container(
decoration: BoxDecoration( width: 76,
shape: BoxShape.circle, height: 76,
border: Border.all( decoration: BoxDecoration(
color: _allChecksValid shape: BoxShape.circle,
? Colors.white.withOpacity(0.8) border: Border.all(
: Colors.white.withOpacity(0.3), color: _allChecksValid
width: 6, ? Colors.white.withOpacity(0.8)
), : Colors.white.withOpacity(0.3),
color: _allChecksValid width: 6,
? Colors.white.withOpacity(0.2) ),
: Colors.white.withOpacity(0.05), color: _allChecksValid
boxShadow: _allChecksValid ? Colors.red.withOpacity(0.8)
? [ : Colors.white.withOpacity(0.05),
BoxShadow( boxShadow: _allChecksValid
color: Colors.black.withOpacity(0.2), ? [
blurRadius: 10, BoxShadow(
) color: Colors.red.withOpacity(0.4),
] blurRadius: 16,
: null, )
), ]
child: Container( : null,
margin: const EdgeInsets.all(6), ),
decoration: BoxDecoration( child: const Icon(
shape: BoxShape.circle, Icons.videocam,
color: _allChecksValid color: Colors.white,
? Colors.white size: 30,
: Colors.white.withOpacity(0.3), ),
), ),
), ),
), ),
),
),
), ),
], ],
), ),

View File

@ -875,27 +875,44 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
} }
Future<void> _startFaceRecognitionFlow(BuildContext context) async { Future<void> _startFaceRecognitionFlow(BuildContext context) async {
final File? photoResult = await Navigator.push( final Map<String, dynamic>? captureResult = await Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => const CameraCaptureScreen(), builder: (context) => const CameraCaptureScreen(),
), ),
); );
if (photoResult == null || !mounted) return; if (captureResult == null || !mounted) return;
final File? videoFile = captureResult['video'];
final File? photoFile = captureResult['photo'];
if (videoFile == null || photoFile == null) {
_showErrorSnackBar("Gagal mengambil data kamera.");
return;
}
setState(() { setState(() {
_isProcessing = true; _isProcessing = true;
_processingStatus = "Memverifikasi wajah..."; _processingStatus = "Memproses video wajah...";
}); });
try { try {
final faceResult = await FaceRepository().verifyFace(photoResult); final faceResult = await FaceRepository().verifyFace(videoFile);
final bool isVerified = faceResult['verified'] == true; final bool isVerified = faceResult['verified'] == true;
final double? confidence = (faceResult['confidence'] is num)
// Mode video: gunakan approved_ratio (lebih representatif dari SVM proba)
// Mode foto: fallback ke confidence biasa
final double? approvedRatio = (faceResult['approved_ratio'] is num)
? (faceResult['approved_ratio'] as num).toDouble()
: null;
final double? rawConf = (faceResult['confidence'] is num)
? (faceResult['confidence'] as num).toDouble() ? (faceResult['confidence'] as num).toDouble()
: null; : null;
final double? confidence = approvedRatio ?? rawConf;
final int? framesTotal = (faceResult['frames_total'] as num?)?.toInt();
final int? framesApproved = (faceResult['frames_approved'] as num?)?.toInt();
if (!isVerified) { if (!isVerified) {
if (!mounted) return; if (!mounted) return;
@ -905,6 +922,8 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
_showFaceFailedDialog( _showFaceFailedDialog(
confidence: confidence, confidence: confidence,
framesTotal: framesTotal,
framesApproved: framesApproved,
message: faceResult['message']?.toString() ?? "Wajah tidak cocok.", message: faceResult['message']?.toString() ?? "Wajah tidak cocok.",
); );
return; return;
@ -915,7 +934,7 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
}); });
final provider = context.read<AttendanceProvider>(); final provider = context.read<AttendanceProvider>();
final success = await provider.submitPresensi(widget.type, photoResult); final success = await provider.submitPresensi(widget.type, photoFile);
if (!mounted) return; if (!mounted) return;
@ -948,7 +967,12 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
} }
} }
void _showFaceFailedDialog({double? confidence, required String message}) { void _showFaceFailedDialog({
double? confidence,
int? framesTotal,
int? framesApproved,
required String message,
}) {
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
@ -965,7 +989,13 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(message, style: AppTheme.bodyMedium), Text(message, style: AppTheme.bodyMedium),
if (confidence != null && confidence < 1.0) ...[ if (framesTotal != null) ...[
const SizedBox(height: 12),
Text(
"Frame Cocok: $framesApproved/$framesTotal",
style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary),
),
] else if (confidence != null && confidence < 1.0) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
"Confidence: ${(confidence * 100).toStringAsFixed(1)}%", "Confidence: ${(confidence * 100).toStringAsFixed(1)}%",

View File

@ -101,8 +101,8 @@ class _PresensiScreenState extends State<PresensiScreen> {
), ),
body: RefreshIndicator( body: RefreshIndicator(
onRefresh: () async { onRefresh: () async {
await homeProvider.fetchDashboardData(); await homeProvider.fetchDashboardData(force: true);
await attendanceProvider.fetchHistory(); await attendanceProvider.fetchHistory(force: true);
}, },
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(AppTheme.spacingMd), padding: const EdgeInsets.all(AppTheme.spacingMd),
@ -508,8 +508,8 @@ class _PresensiScreenState extends State<PresensiScreen> {
if (!mounted) return; if (!mounted) return;
if (result != null && result['success'] == true) { if (result != null && result['success'] == true) {
await context.read<HomeProvider>().fetchDashboardData(); await context.read<HomeProvider>().fetchDashboardData(force: true);
await context.read<AttendanceProvider>().fetchHistory(); await context.read<AttendanceProvider>().fetchHistory(force: true);
_showResultDialog(true, "Absensi Berhasil!", result['message'] ?? "Data absensi Anda telah tercatat."); _showResultDialog(true, "Absensi Berhasil!", result['message'] ?? "Data absensi Anda telah tercatat.");
} }
} }

View File

@ -34,16 +34,24 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
bool? _verified; bool? _verified;
double? _confidence; double? _confidence;
double? _svmConfidence; double? _svmDf;
int? _framesTotal;
int? _framesApproved;
String? _statusText; String? _statusText;
String? _errorMessage; String? _errorMessage;
String? _predictedName;
late FaceDetector _faceDetector; late FaceDetector _faceDetector;
bool get _allChecksValid => bool get _allChecksValid =>
_isFaceDetected && _isDistanceOk && _isBrightnessOk; _isFaceDetected && _isDistanceOk && _isBrightnessOk;
// State recording
bool _isRecording = false;
int _recordingSecondsLeft = 1;
String get _qualityMessage { String get _qualityMessage {
if (_isRecording) return 'Tetap hadap kamera... ($_recordingSecondsLeft detik)';
if (!_isFaceDetected) return 'Arahkan wajah ke dalam bingkai'; if (!_isFaceDetected) return 'Arahkan wajah ke dalam bingkai';
if (_faceRatio < 0.20) return 'Terlalu jauh, dekatkan wajah'; if (_faceRatio < 0.20) return 'Terlalu jauh, dekatkan wajah';
if (_faceRatio > 0.70) return 'Terlalu dekat, mundur sedikit'; if (_faceRatio > 0.70) return 'Terlalu dekat, mundur sedikit';
@ -147,39 +155,62 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
} }
} }
Future<void> _captureAndVerify() async { Future<void> _recordAndVerify() async {
if (_cameraController == null || _isVerifying || !_allChecksValid) return; if (_cameraController == null || _isVerifying || !_allChecksValid) return;
setState(() { setState(() {
_isVerifying = true; _isRecording = true;
_recordingSecondsLeft = 1;
_showResult = false; _showResult = false;
_errorMessage = null; _errorMessage = null;
}); });
try { try {
try { await _cameraController!.stopImageStream();
await _cameraController!.stopImageStream(); } catch (_) {}
} catch (_) {}
final XFile photo = await _cameraController!.takePicture(); try {
final File imageFile = File(photo.path); await _cameraController!.prepareForVideoRecording();
await _cameraController!.startVideoRecording();
final result = for (int i = 1; i >= 1; i--) {
await FaceRepository().verifyFace(imageFile, tipe: 'test'); if (!mounted) return;
setState(() => _recordingSecondsLeft = i);
await Future.delayed(const Duration(seconds: 1));
}
if (!mounted) return;
final XFile video = await _cameraController!.stopVideoRecording();
final File videoFile = File(video.path);
setState(() {
_isRecording = false;
_isVerifying = true;
});
final result = await FaceRepository().verifyFace(videoFile, tipe: 'test');
if (mounted) { if (mounted) {
setState(() { setState(() {
_verified = result['verified'] == true; _verified = result['verified'] == true;
_confidence = (result['confidence'] is num)
final approvedRatio = (result['approved_ratio'] is num)
? (result['approved_ratio'] as num).toDouble()
: null;
final rawConf = (result['confidence'] is num)
? (result['confidence'] as num).toDouble() ? (result['confidence'] as num).toDouble()
: null; : null;
_svmConfidence = (result['svm_confidence'] is num) _confidence = approvedRatio ?? rawConf;
? (result['svm_confidence'] as num).toDouble() _svmDf = (result['svm_df'] is num)
? (result['svm_df'] as num).toDouble()
: null; : null;
_statusText = result['verification_status']?.toString() ?? _framesTotal = (result['frames_total'] as num?)?.toInt();
_framesApproved = (result['frames_approved'] as num?)?.toInt();
_statusText = result['verification_status']?.toString() ??
result['status']?.toString(); result['status']?.toString();
_showResult = true; _predictedName = result['predicted_name']?.toString();
_isVerifying = false; _showResult = true;
_isVerifying = false;
}); });
if (_verified == true) { if (_verified == true) {
@ -192,6 +223,7 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
if (mounted) { if (mounted) {
setState(() { setState(() {
_verified = false; _verified = false;
_isRecording = false;
_errorMessage = e.toString().replaceAll('Exception: ', ''); _errorMessage = e.toString().replaceAll('Exception: ', '');
_showResult = true; _showResult = true;
_isVerifying = false; _isVerifying = false;
@ -203,14 +235,18 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
void _resetTest() { void _resetTest() {
setState(() { setState(() {
_showResult = false; _showResult = false;
_verified = null; _verified = null;
_confidence = null; _confidence = null;
_svmConfidence = null; _svmDf = null;
_statusText = null; _framesTotal = null;
_errorMessage = null; _framesApproved = null;
_statusText = null;
_errorMessage = null;
_predictedName = null;
_isRecording = false;
_isFaceDetected = false; _isFaceDetected = false;
_isDistanceOk = false; _isDistanceOk = false;
_isBrightnessOk = false; _isBrightnessOk = false;
}); });
@ -414,36 +450,58 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
), ),
child: Column( child: Column(
children: [ children: [
Text( // Mode video: tampilkan frame ratio
"Confidence: ${(_confidence! * 100).toStringAsFixed(1)}%", if (_framesTotal != null) ...[
style: AppTheme.bodyMedium.copyWith(
color: AppTheme.textSecondary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
_confidence! >= 0.75
? "Sangat cocok (≥ 75%)"
: _confidence! >= 0.55
? "Meragukan (55-74%)"
: "Tidak cocok (< 55%)",
style: AppTheme.bodySmall.copyWith(
color: _confidence! >= 0.75
? AppTheme.statusGreen
: _confidence! >= 0.55
? AppTheme.statusYellow
: AppTheme.statusRed,
),
),
if (_svmConfidence != null) ...[
const SizedBox(height: 8),
const Divider(),
const SizedBox(height: 8),
Text( Text(
"SVM Conf: ${(_svmConfidence! * 100).toStringAsFixed(1)}%", "Frame Cocok: $_framesApproved/$_framesTotal",
style: style: AppTheme.bodyMedium.copyWith(
AppTheme.labelMedium.copyWith( color: AppTheme.textSecondary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
// Progress bar frames
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: _confidence ?? 0,
backgroundColor: AppTheme.statusRed.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>(
(_confidence ?? 0) >= 0.8
? AppTheme.statusGreen
: (_confidence ?? 0) >= 0.5
? AppTheme.statusYellow
: AppTheme.statusRed,
),
minHeight: 8,
),
),
const SizedBox(height: 4),
Text(
"${((_confidence ?? 0) * 100).toStringAsFixed(0)}% frame terverifikasi",
style: AppTheme.bodySmall.copyWith(
color: (_confidence ?? 0) >= 0.8
? AppTheme.statusGreen
: (_confidence ?? 0) >= 0.5
? AppTheme.statusYellow
: AppTheme.statusRed,
),
),
] else ...[
// Mode foto: tampilkan SVM confidence biasa
Text(
"Confidence: ${(_confidence! * 100).toStringAsFixed(1)}%",
style: AppTheme.bodyMedium.copyWith(
color: AppTheme.textSecondary,
fontWeight: FontWeight.w600,
),
),
],
if (_svmDf != null) ...[
const SizedBox(height: 4),
Text(
"SVM Decision Function: ${_svmDf!.toStringAsFixed(3)}",
style: AppTheme.bodySmall.copyWith(
color: AppTheme.textSecondary, color: AppTheme.textSecondary,
fontFamily: 'monospace', fontFamily: 'monospace',
), ),
@ -452,6 +510,65 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
], ],
), ),
), ),
// Prediksi ML
if (_predictedName != null)
Container(
margin: const EdgeInsets.only(top: 12),
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
const Color(0xFF6366F1).withOpacity(0.08),
const Color(0xFF8B5CF6).withOpacity(0.08),
],
),
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(
color: const Color(0xFF6366F1).withOpacity(0.25),
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: const Color(0xFF6366F1).withOpacity(0.15),
shape: BoxShape.circle,
),
child: const Icon(
Icons.psychology_rounded,
color: Color(0xFF6366F1),
size: 16,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Prediksi ML',
style: AppTheme.labelMedium.copyWith(
color: const Color(0xFF6366F1),
fontWeight: FontWeight.w600,
),
),
Text(
_predictedName!,
style: AppTheme.bodyMedium.copyWith(
color: AppTheme.textPrimary,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
),
if (_statusText != null) if (_statusText != null)
Padding( Padding(
padding: const EdgeInsets.only(top: 12), padding: const EdgeInsets.only(top: 12),
@ -459,8 +576,7 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 6), horizontal: 14, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color: AppTheme.primaryBlue.withOpacity(0.1),
AppTheme.primaryBlue.withOpacity(0.1),
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
AppTheme.radiusFull), AppTheme.radiusFull),
), ),
@ -497,22 +613,40 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
right: 20, right: 20,
child: Column( child: Column(
children: [ children: [
if (!_showResult && !_isVerifying) if (!_showResult && !_isVerifying && !_isRecording)
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: CustomButton( child: CustomButton(
text: "Mulai Verifikasi", text: "Mulai Rekam & Verifikasi",
icon: Icons.face_rounded, icon: Icons.videocam_rounded,
onPressed: _allChecksValid && _isCameraInitialized onPressed: _allChecksValid && _isCameraInitialized
? _captureAndVerify ? _recordAndVerify
: null, : null,
backgroundColor: _allChecksValid backgroundColor: _allChecksValid
? AppTheme.primaryBlue ? AppTheme.primaryBlue
: AppTheme.primaryDark.withOpacity(0.5), : AppTheme.primaryDark.withOpacity(0.5),
), ),
), ),
if (_isVerifying) if (_isRecording)
const CircularProgressIndicator(color: Colors.white), Column(
children: [
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.8),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Merekam... $_recordingSecondsLeft detik',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold),
),
),
],
),
if (_showResult) if (_showResult)
Row( Row(
children: [ children: [

View File

@ -356,11 +356,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
label: "Tanda Tangan Digital", label: "Tanda Tangan Digital",
onTap: () => Navigator.pushNamed(context, '/profile/signature'), onTap: () => Navigator.pushNamed(context, '/profile/signature'),
), ),
_buildActionItem(
icon: Icons.settings_ethernet,
label: "Pengaturan Server",
onTap: () => Navigator.pushNamed(context, '/settings/api'),
),
_buildActionItem( _buildActionItem(
icon: Icons.logout, icon: Icons.logout,
label: "Keluar", label: "Keluar",

View File

@ -18,6 +18,7 @@ class ApiClient {
dio.interceptors.add(InterceptorsWrapper( dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async { onRequest: (options, handler) async {
options.baseUrl = ApiUrl.baseUrl;
final token = CacheManager.authBox.get('auth_token'); final token = CacheManager.authBox.get('auth_token');
if (token != null && token.isNotEmpty) { if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token'; options.headers['Authorization'] = 'Bearer $token';
@ -25,6 +26,22 @@ class ApiClient {
return handler.next(options); return handler.next(options);
}, },
onResponse: (response, handler) { onResponse: (response, handler) {
// Deteksi response HTML (redirect ke login page token expired)
final contentType = response.headers.value('content-type') ?? '';
final isHtml = contentType.contains('text/html') ||
(response.data is String &&
(response.data as String).trimLeft().startsWith('<!'));
if (isHtml) {
return handler.reject(
DioException(
requestOptions: response.requestOptions,
response: response,
type: DioExceptionType.badResponse,
error: 'Sesi habis, silakan login ulang.',
),
);
}
return handler.next(response); return handler.next(response);
}, },
onError: (DioException e, handler) { onError: (DioException e, handler) {

View File

@ -8,7 +8,40 @@ import '../repositories/notifikasi_repository.dart';
import '../main.dart'; import '../main.dart';
@pragma('vm:entry-point') @pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {} Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
// Jika message sudah punya notification payload,
// FCM otomatis menampilkan system notification.
// Kita TIDAK perlu tampilkan lagi via local notification (mencegah dobel).
if (message.notification != null) return;
// Hanya tampilkan manual untuk data-only messages
final title = message.data['title'] ?? '';
final body = message.data['body'] ?? '';
if (title.isEmpty) return;
final plugin = FlutterLocalNotificationsPlugin();
await plugin.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
),
);
await plugin.show(
id: message.hashCode,
title: title,
body: body,
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
'hris_channel',
'HRIS Notifications',
importance: Importance.max,
priority: Priority.high,
icon: '@mipmap/ic_launcher',
),
),
);
}
class FcmService { class FcmService {
static final FcmService _instance = FcmService._internal(); static final FcmService _instance = FcmService._internal();
@ -22,6 +55,9 @@ class FcmService {
bool _initialized = false; bool _initialized = false;
Future<void> initialize(BuildContext context) async { Future<void> initialize(BuildContext context) async {
// Selalu refresh token FCM ke server setiap initialize (tidak diblokir _initialized)
await _refreshAndSaveToken();
if (_initialized) return; if (_initialized) return;
_initialized = true; _initialized = true;
@ -38,10 +74,23 @@ class FcmService {
onDidReceiveNotificationResponse: _onNotificationTap, onDidReceiveNotificationResponse: _onNotificationTap,
); );
if (settings.authorizationStatus == AuthorizationStatus.denied) return; const AndroidNotificationChannel channel = AndroidNotificationChannel(
'hris_channel',
'HRIS Notifications',
description: 'Notifikasi untuk sistem HRIS MP',
importance: Importance.max,
);
final token = await _messaging.getToken(); final androidImplementation = _localNotifications
if (token != null) await _repository.saveDeviceToken(token); .resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
await androidImplementation?.createNotificationChannel(channel);
// Explicitly request notification permission for Android 13+
await androidImplementation?.requestNotificationsPermission();
if (settings.authorizationStatus == AuthorizationStatus.denied) return;
_messaging.onTokenRefresh.listen((t) => _repository.saveDeviceToken(t)); _messaging.onTokenRefresh.listen((t) => _repository.saveDeviceToken(t));
@ -78,6 +127,19 @@ class FcmService {
} }
} }
/// Selalu ambil FCM token terbaru dan simpan ke server.
/// Dipanggil setiap kali initialize() agar token tidak kadaluarsa.
Future<void> _refreshAndSaveToken() async {
try {
final token = await _messaging.getToken();
if (token != null) {
await _repository.saveDeviceToken(token);
}
} catch (_) {
// Abaikan error saat refresh token (misal: tidak ada koneksi)
}
}
IconData _iconForTipe(String? tipe) { IconData _iconForTipe(String? tipe) {
if (tipe == null) return Icons.notifications_outlined; if (tipe == null) return Icons.notifications_outlined;
if (tipe.contains('disetujui')) return Icons.check_circle_rounded; if (tipe.contains('disetujui')) return Icons.check_circle_rounded;

View File

@ -0,0 +1,162 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../core/cache_manager.dart';
import '../core/constants/api_url.dart';
class WebSocketService extends ChangeNotifier {
static final WebSocketService _instance = WebSocketService._internal();
factory WebSocketService() => _instance;
WebSocketService._internal();
WebSocketChannel? _channel;
Timer? _reconnectTimer;
bool _isConnected = false;
bool _disposed = false;
final _eventController = StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get eventStream => _eventController.stream;
bool get isConnected => _isConnected;
Future<void> connect() async {
if (_isConnected || _disposed) return;
try {
final baseUrl = ApiUrl.baseUrl;
final token = CacheManager.authBox.get('auth_token') ?? '';
final wsUrl = _buildWsUrl(baseUrl);
if (wsUrl == null) return;
final uri = Uri.parse('$wsUrl/app/mpg-hris-key');
_channel = WebSocketChannel.connect(uri);
await _channel!.ready;
_isConnected = true;
_reconnectTimer?.cancel();
notifyListeners();
debugPrint('[WS] Terhubung ke $uri');
_channel!.stream.listen(
(message) => _handleMessage(message, token),
onDone: () {
debugPrint('[WS] Koneksi terputus');
_isConnected = false;
notifyListeners();
_scheduleReconnect();
},
onError: (error) {
debugPrint('[WS] Error: $error');
_isConnected = false;
notifyListeners();
_scheduleReconnect();
},
);
_subscribeToChannels(token);
} catch (e) {
debugPrint('[WS] Gagal connect: $e');
_isConnected = false;
_scheduleReconnect();
}
}
String? _buildWsUrl(String apiBaseUrl) {
try {
final uri = Uri.parse(apiBaseUrl);
final scheme = uri.scheme == 'https' ? 'wss' : 'ws';
if (uri.host.contains('trycloudflare.com')) {
return '$scheme://${uri.host}';
}
return '$scheme://${uri.host}:8080';
} catch (e) {
debugPrint('[WS] URL invalid: $e');
return null;
}
}
void _subscribeToChannels(String token) {
final userId = CacheManager.authBox.get('user_id');
if (userId == null) return;
_sendSubscribe('private-user.$userId', token);
_sendSubscribe('pengumuman', null);
}
void _sendSubscribe(String channel, String? token) {
final data = {
'event': 'pusher:subscribe',
'data': {
'channel': channel,
if (token != null) 'auth': token,
},
};
_channel?.sink.add(jsonEncode(data));
debugPrint('[WS] Subscribe: $channel');
}
void _handleMessage(dynamic rawMessage, String token) {
try {
final message = jsonDecode(rawMessage as String) as Map<String, dynamic>;
final event = message['event'] as String?;
if (event == 'pusher:connection_established') {
debugPrint('[WS] Connection established');
_subscribeToChannels(token);
return;
}
if (event == 'pusher_internal:subscription_succeeded') {
debugPrint('[WS] Subscription berhasil: ${message['channel']}');
return;
}
if (event != null && !event.startsWith('pusher')) {
final dataRaw = message['data'];
final data = dataRaw is String ? jsonDecode(dataRaw) : dataRaw;
_eventController.add({
'event': event,
'channel': message['channel'],
'data': data,
});
debugPrint('[WS] Event diterima: $event');
}
} catch (e) {
debugPrint('[WS] Parse error: $e');
}
}
void _scheduleReconnect() {
_reconnectTimer?.cancel();
if (_disposed) return;
_reconnectTimer = Timer(const Duration(seconds: 5), () {
debugPrint('[WS] Reconnecting...');
connect();
});
}
void disconnect() {
_reconnectTimer?.cancel();
_channel?.sink.close();
_channel = null;
_isConnected = false;
notifyListeners();
debugPrint('[WS] Disconnected');
}
@override
void dispose() {
_disposed = true;
disconnect();
_eventController.close();
super.dispose();
}
}

View File

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import '../../core/theme.dart'; import '../../core/theme.dart';
import '../../models/announcement_model.dart'; import '../../models/announcement_model.dart';
import '../../screens/home/pengumuman_detail_screen.dart'; import '../../screens/home/pengumuman_detail_screen.dart';
import '../atoms/custom_avatar.dart';
class PengumumanCard extends StatelessWidget { class PengumumanCard extends StatelessWidget {
final AnnouncementModel pengumuman; final AnnouncementModel pengumuman;
@ -17,16 +16,29 @@ class PengumumanCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
width: 280, width: 300,
margin: const EdgeInsets.only(right: AppTheme.spacingMd), margin: const EdgeInsets.only(right: AppTheme.spacingMd),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.bgWhite, gradient: const LinearGradient(
borderRadius: BorderRadius.circular(AppTheme.radiusLg), begin: Alignment.topLeft,
boxShadow: AppTheme.shadowMd, end: Alignment.bottomRight,
colors: [
Color(0xFF1E293B),
Color(0xFF334155),
],
),
borderRadius: BorderRadius.circular(AppTheme.radiusXl),
boxShadow: [
BoxShadow(
color: const Color(0xFF1E293B).withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
), ),
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
borderRadius: BorderRadius.circular(AppTheme.radiusLg), borderRadius: BorderRadius.circular(AppTheme.radiusXl),
child: InkWell( child: InkWell(
onTap: onTap ?? () { onTap: onTap ?? () {
Navigator.push( Navigator.push(
@ -36,38 +48,101 @@ class PengumumanCard extends StatelessWidget {
), ),
); );
}, },
borderRadius: BorderRadius.circular(AppTheme.radiusLg), borderRadius: BorderRadius.circular(AppTheme.radiusXl),
child: Padding( child: Padding(
padding: const EdgeInsets.all(AppTheme.spacingMd), padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomAvatar(
name: pengumuman.namaPembuat,
imageUrl: pengumuman.avatarUrl,
size: 40,
backgroundColor: AppTheme.primaryOrange.withOpacity(0.1),
),
const SizedBox(width: AppTheme.spacingMd),
Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(pengumuman.title, style: AppTheme.labelLarge), Row(
Text(pengumuman.jabatan, style: AppTheme.bodySmall), children: [
const SizedBox(height: 8), Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryOrange.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.campaign_rounded,
color: AppTheme.primaryOrange,
size: 18,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pengumuman.title,
style: AppTheme.labelLarge.copyWith(
color: Colors.white,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
pengumuman.namaPembuat,
style: AppTheme.bodySmall.copyWith(
color: Colors.white60,
fontSize: 11,
),
),
],
),
),
],
),
const SizedBox(height: 10),
Text( Text(
pengumuman.description, pengumuman.description,
style: AppTheme.bodyMedium.copyWith(color: AppTheme.textSecondary), style: AppTheme.bodySmall.copyWith(
color: Colors.white70,
height: 1.4,
),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
pengumuman.date,
style: AppTheme.bodySmall.copyWith(
color: Colors.white38,
fontSize: 10,
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.primaryOrange.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"Baca",
style: AppTheme.bodySmall.copyWith(
color: AppTheme.primaryOrange,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
const SizedBox(width: 2),
const Icon(Icons.arrow_forward_ios, color: AppTheme.primaryOrange, size: 9),
],
),
),
],
),
], ],
), ),
), ),
],
),
),
), ),
), ),
); );

View File

@ -8,6 +8,7 @@
#include <file_selector_linux/file_selector_plugin.h> #include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h> #include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
@ -16,4 +17,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
} }

View File

@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux file_selector_linux
flutter_secure_storage_linux flutter_secure_storage_linux
url_launcher_linux
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@ -17,6 +17,7 @@ import package_info_plus
import path_provider_foundation import path_provider_foundation
import shared_preferences_foundation import shared_preferences_foundation
import sqflite_darwin import sqflite_darwin
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
@ -31,4 +32,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
} }

View File

@ -612,18 +612,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: google_mlkit_commons name: google_mlkit_commons
sha256: "046586b381cdd139f7f6a05ad6998f7e339d061bd70158249907358394b5f496" sha256: "7e9a6d6e66b44aa8cfe944bda9bc3346c52486dd890ca49e5bc98845cda40d7f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.1" version: "0.9.0"
google_mlkit_face_detection: google_mlkit_face_detection:
dependency: "direct main" dependency: "direct main"
description: description:
name: google_mlkit_face_detection name: google_mlkit_face_detection
sha256: a6ccff7d6ca8dfc2ab845ea7481645342e82aed31a81edd075169a3abbb25ed3 sha256: "65988405c884fd84a4ccc8bded7b5e3e4c33362f6f4eaaa94818bdaaba7bab7d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.9.0" version: "0.12.0"
gsettings: gsettings:
dependency: transitive dependency: transitive
description: description:
@ -1285,6 +1285,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
url: "https://pub.dev"
source: hosted
version: "6.3.29"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
url: "https://pub.dev"
source: hosted
version: "6.3.6"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid: uuid:
dependency: transitive dependency: transitive
description: description:
@ -1341,6 +1405,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: "direct main"
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
win32: win32:
dependency: transitive dependency: transitive
description: description:

View File

@ -56,7 +56,7 @@ dependencies:
hive_flutter: ^1.1.0 hive_flutter: ^1.1.0
path_provider: ^2.1.5 path_provider: ^2.1.5
file_picker: ^8.1.4 file_picker: ^8.1.4
google_mlkit_face_detection: ^0.9.0 google_mlkit_face_detection: ^0.12.0
image: ^4.0.17 image: ^4.0.17
http: ^1.6.0 http: ^1.6.0
hand_signature: ^3.0.2 hand_signature: ^3.0.2
@ -67,6 +67,8 @@ dependencies:
firebase_messaging: ^15.1.3 firebase_messaging: ^15.1.3
flutter_local_notifications: ^20.1.0 flutter_local_notifications: ^20.1.0
timezone: ^0.10.0 timezone: ^0.10.0
url_launcher: ^6.3.1
web_socket_channel: ^3.0.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@ -124,7 +126,7 @@ flutter:
flutter_launcher_icons: flutter_launcher_icons:
android: "ic_launcher" android: "ic_launcher"
ios: true ios: true
image_path: "assets/images/logo.png" image_path: "assets/images/icon.png"
min_sdk_android: 21 min_sdk_android: 21
adaptive_icon_background: "#FFFFFF" adaptive_icon_background: "#FFFFFF"
adaptive_icon_foreground: "assets/images/logo.png" adaptive_icon_foreground: "assets/images/icon.png"

View File

@ -11,6 +11,7 @@
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h> #include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <geolocator_windows/geolocator_windows.h> #include <geolocator_windows/geolocator_windows.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h> #include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar( FileSelectorWindowsRegisterWithRegistrar(
@ -23,4 +24,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("GeolocatorWindows")); registry->GetRegistrarForPlugin("GeolocatorWindows"));
PermissionHandlerWindowsPluginRegisterWithRegistrar( PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
} }

View File

@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_windows flutter_secure_storage_windows
geolocator_windows geolocator_windows
permission_handler_windows permission_handler_windows
url_launcher_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST