326 lines
8.8 KiB
Dart
326 lines
8.8 KiB
Dart
// ignore_for_file: unused_local_variable
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:geolocator/geolocator.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:sidak_desa_mobile/presentation/views/main_screen.dart';
|
|
|
|
import '../../data/fetch/attedance_api.dart';
|
|
import '../../data/model/user_model.dart';
|
|
import '../../data/shared/auth_storage.dart';
|
|
import '../../utils/device_id.dart';
|
|
|
|
class AbsenController extends GetxController {
|
|
AbsenController({required Map<String, dynamic> qrData}) {
|
|
this.qrData.value = qrData;
|
|
}
|
|
|
|
final _api = AttedanceApi();
|
|
final _storage = AuthStorage();
|
|
|
|
final user = Rxn<UserModel>();
|
|
final qrData = <String, dynamic>{}.obs;
|
|
final nameController = TextEditingController();
|
|
|
|
final isSubmitting = false.obs;
|
|
final isLoadingDaily = false.obs;
|
|
final isLoadingLocation = false.obs;
|
|
final latitude = RxnDouble();
|
|
final longitude = RxnDouble();
|
|
final locationError = RxnString();
|
|
|
|
final selectedDate = ''.obs;
|
|
final daily = Rxn<Map<String, dynamic>>();
|
|
|
|
@override
|
|
void onInit() {
|
|
_init();
|
|
super.onInit();
|
|
}
|
|
|
|
Future<void> _init() async {
|
|
selectedDate.value = _todayDate();
|
|
await loadUser();
|
|
await fetchDaily();
|
|
await getCurrentLocation();
|
|
}
|
|
|
|
Future<void> loadUser() async {
|
|
user.value = await _storage.getUser();
|
|
nameController.text = user.value?.name ?? '';
|
|
}
|
|
|
|
@override
|
|
void onClose() {
|
|
nameController.dispose();
|
|
super.onClose();
|
|
}
|
|
|
|
String get checkInText => _formatTime(daily.value?['check_in']);
|
|
String get checkOutText => _formatTime(daily.value?['check_out']);
|
|
|
|
Future<void> fetchDaily() async {
|
|
final uid = user.value?.id;
|
|
if (uid == null) return;
|
|
|
|
isLoadingDaily.value = true;
|
|
|
|
try {
|
|
final res = await _api.getDailyAttendance(
|
|
userId: uid,
|
|
date: selectedDate.value,
|
|
);
|
|
|
|
if (res['ok'] == true) {
|
|
if (res['data'] != null && res['data'] is Map<String, dynamic>) {
|
|
daily.value = Map<String, dynamic>.from(res['data']);
|
|
} else {
|
|
daily.value = null;
|
|
}
|
|
} else {
|
|
daily.value = null;
|
|
}
|
|
} catch (_) {
|
|
daily.value = null;
|
|
} finally {
|
|
isLoadingDaily.value = false;
|
|
}
|
|
}
|
|
|
|
Future<void> submitAbsen() async {
|
|
if (isSubmitting.value) return;
|
|
|
|
final token = (qrData['raw'] ?? '').toString().trim();
|
|
final uid = user.value?.id;
|
|
if (token.isEmpty || uid == null) return;
|
|
|
|
isSubmitting.value = true;
|
|
|
|
try {
|
|
final deviceId = await DeviceIdUtil.getDeviceId();
|
|
|
|
final response = await _api.verifyAttendance(
|
|
token: token,
|
|
userId: uid,
|
|
deviceInfo: deviceId,
|
|
latitude: latitude.value,
|
|
longitude: longitude.value,
|
|
);
|
|
|
|
await fetchDaily();
|
|
|
|
// ✅ DIALOG BERHASIL
|
|
Get.dialog(
|
|
Dialog(
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 80,
|
|
height: 80,
|
|
decoration: const BoxDecoration(
|
|
color: Color(0xFF0F766E), // hijau teal
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(Icons.check, color: Colors.white, size: 45),
|
|
),
|
|
const SizedBox(height: 16),
|
|
const Text(
|
|
"Absensi Berhasil",
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 8),
|
|
const Text(
|
|
"Absensi berhasil dilakukan",
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 20),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: () {
|
|
Get.offAll(() => const MainScreen());
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.orange,
|
|
),
|
|
child: const Text(
|
|
"Oke",
|
|
style: TextStyle(color: Colors.white),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
barrierDismissible: false,
|
|
);
|
|
} catch (e) {
|
|
// ❌ DIALOG DITOLAK
|
|
Get.dialog(
|
|
Dialog(
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 80,
|
|
height: 80,
|
|
decoration: const BoxDecoration(
|
|
color: Colors.red,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(Icons.close, color: Colors.white, size: 45),
|
|
),
|
|
const SizedBox(height: 16),
|
|
const Text(
|
|
"Absen Ditolak",
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
e.toString().replaceAll('Exception:', ''),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 20),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: () => Get.back(),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.red,
|
|
),
|
|
child: const Text(
|
|
"Tutup",
|
|
style: TextStyle(color: Colors.white),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
barrierDismissible: false,
|
|
);
|
|
} finally {
|
|
isSubmitting.value = false;
|
|
}
|
|
}
|
|
|
|
Future<void> getCurrentLocation() async {
|
|
isLoadingLocation.value = true;
|
|
locationError.value = null;
|
|
|
|
try {
|
|
final isServiceEnabled = await Geolocator.isLocationServiceEnabled();
|
|
if (!isServiceEnabled) {
|
|
locationError.value = 'Layanan lokasi tidak aktif';
|
|
return;
|
|
}
|
|
|
|
var permission = await Geolocator.checkPermission();
|
|
if (permission == LocationPermission.denied) {
|
|
permission = await Geolocator.requestPermission();
|
|
}
|
|
|
|
if (permission == LocationPermission.denied ||
|
|
permission == LocationPermission.deniedForever) {
|
|
locationError.value = 'Izin lokasi ditolak';
|
|
return;
|
|
}
|
|
|
|
final position = await Geolocator.getCurrentPosition(
|
|
locationSettings: const LocationSettings(
|
|
accuracy: LocationAccuracy.high,
|
|
),
|
|
);
|
|
|
|
latitude.value = position.latitude;
|
|
longitude.value = position.longitude;
|
|
} catch (_) {
|
|
locationError.value = 'Gagal mengambil lokasi terkini';
|
|
} finally {
|
|
isLoadingLocation.value = false;
|
|
}
|
|
}
|
|
|
|
String _todayDate() {
|
|
final now = DateTime.now();
|
|
final yyyy = now.year.toString().padLeft(4, '0');
|
|
final mm = now.month.toString().padLeft(2, '0');
|
|
final dd = now.day.toString().padLeft(2, '0');
|
|
return '$yyyy-$mm-$dd';
|
|
}
|
|
|
|
String _formatTime(dynamic value) {
|
|
if (value == null) return '-';
|
|
final s = value.toString().trim();
|
|
if (s.isEmpty || s == 'null') return '-';
|
|
|
|
if (s.contains('T')) {
|
|
try {
|
|
final dt = DateTime.parse(s).toLocal();
|
|
final hh = dt.hour.toString().padLeft(2, '0');
|
|
final mm = dt.minute.toString().padLeft(2, '0');
|
|
return '$hh:$mm';
|
|
} catch (_) {}
|
|
}
|
|
|
|
final parts = s.split(':');
|
|
if (parts.length >= 2) {
|
|
return '${parts[0].padLeft(2, '0')}:${parts[1].padLeft(2, '0')}';
|
|
}
|
|
|
|
return s;
|
|
}
|
|
|
|
String get tanggalFormatted {
|
|
final now = DateTime.now();
|
|
return "${now.day} ${_bulanIndo(now.month)} ${now.year}";
|
|
}
|
|
|
|
String _bulanIndo(int bulan) {
|
|
const bulanList = [
|
|
"",
|
|
"Januari",
|
|
"Februari",
|
|
"Maret",
|
|
"April",
|
|
"Mei",
|
|
"Juni",
|
|
"Juli",
|
|
"Agustus",
|
|
"September",
|
|
"Oktober",
|
|
"November",
|
|
"Desember",
|
|
];
|
|
return bulanList[bulan];
|
|
}
|
|
|
|
String get statusText {
|
|
if (daily.value == null) return "-";
|
|
if (daily.value?['check_in'] != null) {
|
|
return "Hadir";
|
|
}
|
|
return "-";
|
|
}
|
|
|
|
String get keteranganText {
|
|
if (daily.value == null) return "-";
|
|
final checkIn = daily.value?['check_in'];
|
|
if (checkIn == null) return "-";
|
|
return "Tepat Waktu";
|
|
}
|
|
}
|