last
BIN
analyze.txt
BIN
analyze_fcm.txt
|
|
@ -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>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 906 B After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 621 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 7.7 KiB |
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 354 B After Width: | Height: | Size: 524 B |
|
Before Width: | Height: | Size: 525 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 767 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 436 B After Width: | Height: | Size: 805 B |
|
Before Width: | Height: | Size: 741 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 525 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1016 B After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 661 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 702 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 906 B After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 980 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 6.6 KiB |
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,21 @@ 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 prefs = await SharedPreferences.getInstance();
|
final envUrl = dotenv.env['API_BASE_URL'];
|
||||||
|
if (envUrl != null && envUrl.isNotEmpty) {
|
||||||
|
return envUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
final savedUrl = prefs.getString(_apiUrlKey);
|
final savedUrl = prefs.getString(_apiUrlKey);
|
||||||
if (savedUrl != null && savedUrl.isNotEmpty) {
|
if (savedUrl != null && savedUrl.isNotEmpty) {
|
||||||
return savedUrl;
|
return savedUrl;
|
||||||
|
|
@ -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':
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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'];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: ', ''));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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)}%",
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
88
pubspec.lock
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||