notifikasi+snack bar

This commit is contained in:
jouel88 2026-03-28 15:38:22 +07:00
parent 664acb389e
commit e736908004
49 changed files with 1404 additions and 969 deletions

BIN
analyze.txt Normal file

Binary file not shown.

BIN
analyze_fcm.txt Normal file

Binary file not shown.

View File

@ -3,16 +3,18 @@ plugins {
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services")
}
android {
namespace = "com.example.mpg_mobile"
namespace = "com.mp.hris"
// UBAH 1: Hardcode ke 34 agar support ML Kit terbaru
compileSdk = 36
ndkVersion = flutter.ndkVersion
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
@ -23,7 +25,7 @@ android {
defaultConfig {
// Pastikan applicationId ini sesuai dengan yang Anda inginkan
applicationId = "com.example.mpg_mobile"
applicationId = "com.mp.hris"
// UBAH 2: Ubah minSdk ke 23 (Android 6.0) minimal untuk AI
minSdk = flutter.minSdkVersion
@ -48,5 +50,9 @@ flutter {
// UBAH 4: Tambahkan blok dependencies ini manual di paling bawah
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
implementation("androidx.multidex:multidex:2.0.1")
implementation(platform("com.google.firebase:firebase-bom:34.11.0"))
implementation("com.google.firebase:firebase-analytics")
}

View File

@ -3,12 +3,25 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:label="mpg_mobile"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<!-- FCM: Channel default notifikasi (Android 8+) -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="hris_channel" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@mipmap/ic_launcher" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/notification_color" />
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="YOUR_GOOGLE_MAPS_API_KEY_HERE"/>

View File

@ -1,4 +1,4 @@
package com.example.mpg_mobile
package com.mp.hris
import io.flutter.embedding.android.FlutterActivity

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="notification_color">#130F26</color>
</resources>

View File

@ -22,3 +22,4 @@ subprojects {
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@ -21,6 +21,7 @@ plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
id("com.google.gms.google-services") version "4.4.1" apply false
}
include(":app")

View File

@ -4,31 +4,92 @@ class ErrorHandler {
static final GlobalKey<ScaffoldMessengerState> scaffoldMessengerKey =
GlobalKey<ScaffoldMessengerState>();
static void showError(String message, {int durationSeconds = 3}) {
scaffoldMessengerKey.currentState?.showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red.shade600,
behavior: SnackBarBehavior.floating,
duration: Duration(seconds: durationSeconds),
action: SnackBarAction(
label: 'Tutup',
textColor: Colors.white,
onPressed: () {
scaffoldMessengerKey.currentState?.hideCurrentSnackBar();
},
),
),
static void showSuccess(String message, {int durationSeconds = 3}) {
_show(
message: message,
icon: Icons.check_circle_rounded,
bgColor: const Color(0xFF10b981),
durationSeconds: durationSeconds,
);
}
static void showSuccess(String message, {int durationSeconds = 2}) {
static void showError(String message, {int durationSeconds = 4}) {
_show(
message: message,
icon: Icons.error_rounded,
bgColor: const Color(0xFFef4444),
durationSeconds: durationSeconds,
showClose: true,
);
}
static void showWarning(String message, {int durationSeconds = 3}) {
_show(
message: message,
icon: Icons.warning_amber_rounded,
bgColor: const Color(0xFFf59e0b),
durationSeconds: durationSeconds,
);
}
static void showInfo(String message, {int durationSeconds = 3}) {
_show(
message: message,
icon: Icons.info_rounded,
bgColor: const Color(0xFF3b82f6),
durationSeconds: durationSeconds,
);
}
static void _show({
required String message,
required IconData icon,
required Color bgColor,
int durationSeconds = 3,
bool showClose = false,
}) {
scaffoldMessengerKey.currentState?.hideCurrentSnackBar();
scaffoldMessengerKey.currentState?.showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.green.shade600,
content: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: Colors.white, size: 18),
),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
height: 1.3,
),
),
),
],
),
backgroundColor: bgColor,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
elevation: 6,
duration: Duration(seconds: durationSeconds),
action: showClose
? SnackBarAction(
label: 'Tutup',
textColor: Colors.white.withValues(alpha: 0.9),
onPressed: () {
scaffoldMessengerKey.currentState?.hideCurrentSnackBar();
},
)
: null,
),
);
}

View File

@ -1,5 +1,4 @@
import 'dart:ui';
import 'dart:io';
import 'package:camera/camera.dart';
import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
import 'package:flutter/services.dart';

View File

@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'services/fcm_service.dart';
import 'core/theme.dart';
import 'core/error_handler.dart';
import 'providers/auth_provider.dart';
@ -31,8 +34,7 @@ import 'screens/onboarding/face_enrollment_screen.dart';
import 'screens/poin/point_usage_screen.dart';
import 'screens/poin/poin_history_screen.dart';
import 'screens/notification/notification_screen.dart';
import 'screens/salary/salary_estimator_screen.dart';
import 'screens/api_settings_screen.dart';
import 'screens/profile/face_test_screen.dart';
import 'screens/profile/signature_screen.dart';
import 'screens/profile/edit_profile_screen.dart';
@ -44,16 +46,22 @@ import 'core/cache_manager.dart';
import 'core/constants/api_url.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:timezone/data/latest_all.dart' as tz;
import 'services/reminder_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await dotenv.load(fileName: ".env");
await CacheManager.init();
await ApiUrl.initialize();
await initializeDateFormatting('id_ID', null);
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
tz.initializeTimeZones();
await ReminderService().initialize();
runApp(const MyApp());
}
@ -105,8 +113,7 @@ class MyApp extends StatelessWidget {
'/poin/usage': (context) => const PointUsageScreen(),
'/poin/history': (context) => const PoinHistoryScreen(),
'/notification': (context) => const NotificationScreen(),
'/salary/estimator': (context) => const SalaryEstimatorScreen(),
'/settings/api': (context) => const ApiSettingsScreen(),
'/profile/face-test': (context) => const FaceTestScreen(),
'/profile/signature': (context) => const SignatureScreen(),
'/profile/edit': (context) => const EditProfileScreen(),

View File

@ -0,0 +1,31 @@
class NotifikasiModel {
final int id;
final String judul;
final String pesan;
final String tipe;
final bool isRead;
final String createdAt;
final Map<String, dynamic>? data;
const NotifikasiModel({
required this.id,
required this.judul,
required this.pesan,
required this.tipe,
required this.isRead,
required this.createdAt,
this.data,
});
factory NotifikasiModel.fromJson(Map<String, dynamic> json) {
return NotifikasiModel(
id: json['id'] ?? 0,
judul: json['judul'] ?? '',
pesan: json['pesan'] ?? '',
tipe: json['tipe'] ?? '',
isRead: json['is_read'] == true || json['is_read'] == 1,
createdAt: json['created_at'] ?? '',
data: json['data'] is Map ? Map<String, dynamic>.from(json['data']) : null,
);
}
}

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../../repositories/auth_repository.dart';
import '../../models/user_model.dart';
import '../../core/cache_manager.dart';
import '../../services/reminder_service.dart';
class AuthProvider extends ChangeNotifier {
final AuthRepository _repository = AuthRepository();
@ -129,6 +130,7 @@ class AuthProvider extends ChangeNotifier {
void logout() {
_user = null;
ReminderService().cancelAll();
notifyListeners();
}
}

View File

@ -15,7 +15,6 @@ class CalendarProvider extends ChangeNotifier {
String? get errorMessage => _errorMessage;
Future<void> fetchMonthlySchedule(int month, int year) async {
print("--- FETCHING JADWAL BULAN $month TAHUN $year ---");
_isLoading = true;
_errorMessage = null;
notifyListeners();
@ -23,9 +22,7 @@ class CalendarProvider extends ChangeNotifier {
try {
final result = await _repository.getMonthlySchedule(month, year);
_schedules = result;
print("--- SUKSES: DAPAT ${_schedules.length} DATA ---");
} catch (e) {
print("--- ERROR: $e ---");
_errorMessage = e.toString();
_schedules = [];
} finally {

View File

@ -3,6 +3,7 @@ import '../../repositories/home_repository.dart';
import '../../models/user_model.dart';
import '../../models/presensi_model.dart';
import '../../models/announcement_model.dart';
import '../../services/reminder_service.dart';
class HomeProvider extends ChangeNotifier {
final HomeRepository _repository = HomeRepository();
@ -35,10 +36,26 @@ class HomeProvider extends ChangeNotifier {
izinCount = data['izin_count'] ?? 0;
alphaCount = data['alpha_count'] ?? 0;
_isLoading = false;
_scheduleAttendanceReminders();
} catch (e) {
_errorMessage = e.toString();
_isLoading = false;
}
notifyListeners();
}
void _scheduleAttendanceReminders() {
if (presensiToday == null) return;
final jamMasuk = presensiToday!.jadwalJamMasuk;
final jamPulang = presensiToday!.jadwalJamPulang;
if (jamMasuk == null || jamPulang == null) return;
ReminderService().scheduleWeeklyReminders(
jadwalJamMasuk: jamMasuk,
jadwalJamPulang: jamPulang,
sudahAbsenMasuk: presensiToday!.sudahAbsenMasuk,
sudahAbsenPulang: presensiToday!.sudahAbsenPulang,
);
}
}

View File

@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import '../repositories/home_repository.dart';
import '../models/announcement_model.dart';
import '../repositories/notifikasi_repository.dart';
import '../../models/notifikasi_model.dart';
class NotificationProvider extends ChangeNotifier {
final HomeRepository _repository = HomeRepository();
final NotifikasiRepository _repository = NotifikasiRepository();
List<AnnouncementModel> notifications = [];
List<NotifikasiModel> notifications = [];
int unreadCount = 0;
bool _isLoading = false;
bool get isLoading => _isLoading;
@ -18,8 +19,8 @@ class NotificationProvider extends ChangeNotifier {
notifyListeners();
try {
final data = await _repository.getPengumuman();
notifications = data;
notifications = await _repository.getNotifikasi();
unreadCount = notifications.where((n) => !n.isRead).length;
_isLoading = false;
} catch (e) {
_errorMessage = e.toString();
@ -27,4 +28,44 @@ class NotificationProvider extends ChangeNotifier {
}
notifyListeners();
}
Future<void> fetchUnreadCount() async {
try {
unreadCount = await _repository.getUnreadCount();
notifyListeners();
} catch (_) {}
}
Future<void> markAsRead(int id) async {
await _repository.markAsRead(id);
final idx = notifications.indexWhere((n) => n.id == id);
if (idx != -1) {
notifications[idx] = NotifikasiModel(
id: notifications[idx].id,
judul: notifications[idx].judul,
pesan: notifications[idx].pesan,
tipe: notifications[idx].tipe,
isRead: true,
createdAt: notifications[idx].createdAt,
data: notifications[idx].data,
);
unreadCount = notifications.where((n) => !n.isRead).length;
notifyListeners();
}
}
Future<void> markAllAsRead() async {
await _repository.markAllAsRead();
notifications = notifications.map((n) => NotifikasiModel(
id: n.id,
judul: n.judul,
pesan: n.pesan,
tipe: n.tipe,
isRead: true,
createdAt: n.createdAt,
data: n.data,
)).toList();
unreadCount = 0;
notifyListeners();
}
}

View File

@ -36,52 +36,30 @@ class PengajuanProvider extends ChangeNotifier {
if (selectedTabIndex == 2) status = 'rejected';
final submissions = await _repository.getPengajuan(status: status);
print('📋 Submissions count: ${submissions.length}');
final lemburResult = await _lemburRepository.getLemburHistory();
print('🔥 Lembur API result: $lemburResult');
List<PengajuanModel> lemburList = [];
if (lemburResult['success'] == true && lemburResult['data'] != null) {
print('✅ Lembur success = true');
final dynamic lemburData = lemburResult['data'];
print('📦 Lembur data type: ${lemburData.runtimeType}');
print('📦 Lembur data: $lemburData');
List rawData = [];
if (lemburData is List) {
rawData = lemburData;
print('✅ Detected as List, count: ${rawData.length}');
} else if (lemburData is Map && lemburData['data'] != null) {
rawData = lemburData['data'];
print('✅ Detected as Map with data, count: ${rawData.length}');
}
print('🔍 Raw lembur count before filter: ${rawData.length}');
lemburList = rawData
.map((json) => PengajuanModel.fromLemburJson(json))
.where((item) {
print('🔍 Lembur item status: ${item.status}, target: $status');
return item.status == status;
})
.where((item) => item.status == status)
.toList();
print('📊 Lembur count after filter: ${lemburList.length}');
} else {
print('❌ Lembur failed or no data');
print(' success: ${lemburResult['success']}');
print(' data: ${lemburResult['data']}');
}
listPengajuan = [...submissions, ...lemburList];
print('📊 Total pengajuan count: ${listPengajuan.length}');
listPengajuan.sort((a, b) => b.tanggalPengajuan.compareTo(a.tanggalPengajuan));
} catch (e, stack) {
print('❌ Error in fetchPengajuan: $e');
print(stack);
} catch (e) {
// Gagal fetch pengajuan abaikan error silently
} finally {
isLoading = false;
notifyListeners();

View File

@ -3,7 +3,6 @@ import 'dart:convert';
import 'package:http/http.dart' as http;
import '../core/constants/api_url.dart';
import '../core/cache_manager.dart';
import '../core/error_handler.dart';
class FaceRepository {
Future<Map<String, dynamic>> enrollFace({

View File

@ -0,0 +1,52 @@
import '../services/api_client.dart';
import '../../models/notifikasi_model.dart';
class NotifikasiRepository {
final ApiClient _apiClient = ApiClient();
Future<List<NotifikasiModel>> getNotifikasi() async {
try {
final response = await _apiClient.dio.get('/notifikasi');
if (response.data['success'] == true) {
final List items = response.data['data']['data'] ?? [];
return items.map((e) => NotifikasiModel.fromJson(e)).toList();
}
return [];
} catch (e) {
return [];
}
}
Future<int> getUnreadCount() async {
try {
final response = await _apiClient.dio.get('/notifikasi/unread-count');
if (response.data['success'] == true) {
return response.data['data']['count'] ?? 0;
}
return 0;
} catch (e) {
return 0;
}
}
Future<void> markAsRead(int id) async {
try {
await _apiClient.dio.post('/notifikasi/$id/read');
} catch (_) {}
}
Future<void> markAllAsRead() async {
try {
await _apiClient.dio.post('/notifikasi/read-all');
} catch (_) {}
}
Future<void> saveDeviceToken(String token, {String deviceType = 'android'}) async {
try {
await _apiClient.dio.post('/device-token', data: {
'fcm_token': token,
'device_type': deviceType,
});
} catch (_) {}
}
}

View File

@ -1,333 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../core/services/api_config_service.dart';
import '../core/constants/api_url.dart';
import '../providers/auth_provider.dart';
import '../widgets/atoms/custom_button.dart';
class ApiSettingsScreen extends StatefulWidget {
const ApiSettingsScreen({super.key});
@override
State<ApiSettingsScreen> createState() => _ApiSettingsScreenState();
}
class _ApiSettingsScreenState extends State<ApiSettingsScreen> {
String _selectedPreset = 'hostname';
final TextEditingController _customUrlController = TextEditingController();
bool _isLoading = false;
String? _currentUrl;
@override
void initState() {
super.initState();
_loadCurrentConfig();
}
Future<void> _loadCurrentConfig() async {
setState(() => _isLoading = true);
try {
final preset = await ApiConfigService.getCurrentPreset();
final url = await ApiConfigService.getSavedUrl();
setState(() {
_selectedPreset = preset;
_currentUrl = url;
if (preset == 'custom' && url != null) {
_customUrlController.text = url;
}
});
} finally {
setState(() => _isLoading = false);
}
}
Future<void> _saveConfig() async {
setState(() => _isLoading = true);
try {
if (_selectedPreset == 'custom') {
final customUrl = _customUrlController.text.trim();
if (customUrl.isEmpty) {
_showMessage('Masukkan URL custom terlebih dahulu', isError: true);
return;
}
if (!customUrl.startsWith('http://') && !customUrl.startsWith('https://')) {
_showMessage('URL harus dimulai dengan http:// atau https://', isError: true);
return;
}
await ApiConfigService.setCustomUrl(customUrl);
} else {
await ApiConfigService.setPreset(_selectedPreset);
}
await ApiUrl.reload();
_showMessage('Konfigurasi API berhasil disimpan!');
if (mounted) {
final shouldRelogin = await _showConfirmDialog(
'Koneksi API telah diubah. Apakah Anda ingin logout dan login kembali untuk memastikan koneksi baru berfungsi?',
);
if (shouldRelogin == true && mounted) {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
authProvider.logout();
if (mounted) {
Navigator.of(context).pushReplacementNamed('/login');
}
}
}
} catch (e) {
_showMessage('Gagal menyimpan konfigurasi: $e', isError: true);
} finally {
setState(() => _isLoading = false);
}
}
Future<bool?> _showConfirmDialog(String message) {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Konfirmasi'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Tidak'),
),
CustomButton(
text: 'Ya',
type: ButtonType.primary,
isFullWidth: false,
onPressed: () => Navigator.pop(context, true),
),
],
),
);
}
void _showMessage(String message, {bool isError = false}) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: isError ? Colors.red : Colors.green,
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Pengaturan API Server'),
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
elevation: 0,
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info_outline, color: Colors.blue.shade700),
const SizedBox(width: 8),
Text(
'URL Server Saat Ini',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.blue.shade900,
),
),
],
),
const SizedBox(height: 8),
Text(
_currentUrl ?? 'Belum dikonfigurasi',
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
],
),
),
const SizedBox(height: 24),
const Text(
'Pilih Konfigurasi',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
_buildPresetOption(
value: 'hostname',
title: 'Hostname (Recommended)',
subtitle: 'LAPTOP-I0SUKSKL:8000\nTidak perlu ganti saat pindah WiFi',
icon: Icons.computer,
iconColor: Colors.green,
),
_buildPresetOption(
value: 'emulator',
title: 'Android Emulator',
subtitle: '10.0.2.2:8000\nUntuk testing di emulator',
icon: Icons.phone_android,
iconColor: Colors.orange,
),
_buildPresetOption(
value: 'custom',
title: 'Custom URL',
subtitle: 'Masukkan URL sendiri',
icon: Icons.edit,
iconColor: Colors.blue,
),
if (_selectedPreset == 'custom') ...[
const SizedBox(height: 16),
TextField(
controller: _customUrlController,
decoration: InputDecoration(
labelText: 'URL Server',
hintText: 'http://192.168.1.100:8000/api',
prefixIcon: const Icon(Icons.link),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
helperText: 'Contoh: http://192.168.1.6:8000/api',
),
keyboardType: TextInputType.url,
),
],
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.amber.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.amber.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.lightbulb_outline, color: Colors.amber.shade700),
const SizedBox(width: 8),
const Text(
'Tips',
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 8),
const Text(
'• Gunakan "Hostname" agar tidak perlu ganti setting saat pindah WiFi\n'
'• Pastikan laptop dan HP terhubung ke WiFi yang sama\n'
'• Server Laravel harus running dengan: php artisan serve --host=0.0.0.0',
style: TextStyle(fontSize: 13),
),
],
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: CustomButton(
text: 'Simpan Konfigurasi',
onPressed: _isLoading ? null : _saveConfig,
isLoading: _isLoading,
type: ButtonType.primary,
backgroundColor: Colors.black87,
),
),
],
),
),
);
}
Widget _buildPresetOption({
required String value,
required String title,
required String subtitle,
required IconData icon,
required Color iconColor,
}) {
final isSelected = _selectedPreset == value;
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
border: Border.all(
color: isSelected ? Colors.black87 : Colors.grey.shade300,
width: isSelected ? 2 : 1,
),
borderRadius: BorderRadius.circular(12),
color: isSelected ? Colors.grey.shade50 : Colors.white,
),
child: RadioListTile<String>(
value: value,
groupValue: _selectedPreset,
onChanged: (newValue) {
setState(() => _selectedPreset = newValue!);
},
title: Row(
children: [
Icon(icon, color: iconColor, size: 20),
const SizedBox(width: 8),
Flexible(
child: Text(
title,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
subtitle,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
),
activeColor: Colors.black87,
),
);
}
@override
void dispose() {
_customUrlController.dispose();
super.dispose();
}
}

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/error_handler.dart';
import '../../core/theme.dart';
import '../../providers/auth_provider.dart';
import '../../services/fcm_service.dart';
import '../../widgets/atoms/custom_text_field.dart';
import '../../widgets/atoms/custom_button.dart';
@ -32,14 +34,10 @@ class _LoginScreenState extends State<LoginScreen> {
);
if (success && mounted) {
Navigator.pushReplacementNamed(context, '/onboarding/check');
await FcmService().initialize(context);
if (mounted) Navigator.pushReplacementNamed(context, '/onboarding/check');
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.read<AuthProvider>().errorMessage ?? "Login Failed"),
backgroundColor: AppTheme.statusRed,
),
);
ErrorHandler.showError(context.read<AuthProvider>().errorMessage ?? 'Login Failed');
}
}
}

View File

@ -1,106 +1,11 @@
import 'package:flutter/material.dart';
import '../../core/theme.dart';
import '../../widgets/atoms/fade_in_up.dart';
import '../documents/surat_izin_screen.dart';
class DocumentsScreen extends StatefulWidget {
class DocumentsScreen extends StatelessWidget {
const DocumentsScreen({Key? key}) : super(key: key);
@override
State<DocumentsScreen> createState() => _DocumentsScreenState();
}
class _DocumentsScreenState extends State<DocumentsScreen> with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.bgLight,
appBar: AppBar(
title: Text("Dokumen", style: AppTheme.heading3),
backgroundColor: AppTheme.bgLight,
elevation: 0,
bottom: TabBar(
controller: _tabController,
labelColor: AppTheme.primaryDark,
unselectedLabelColor: AppTheme.textSecondary,
indicatorColor: AppTheme.primaryDark,
tabs: const [
Tab(text: "Slip Gaji"),
Tab(text: "Surat & Dokumen"),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
_buildSlipGajiList(),
_buildSuratList(),
],
),
);
}
Widget _buildSlipGajiList() {
return ListView.builder(
padding: const EdgeInsets.all(AppTheme.spacingMd),
itemCount: 5,
itemBuilder: (context, index) {
return FadeInUp(
delayMs: index * 50,
child: Container(
margin: const EdgeInsets.only(bottom: AppTheme.spacingMd),
padding: const EdgeInsets.all(AppTheme.spacingMd),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
boxShadow: AppTheme.shadowSm,
),
child: Row(
children: [
const Icon(Icons.receipt_long, color: AppTheme.primaryDark),
const SizedBox(width: AppTheme.spacingMd),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Slip Gaji - Juli 2025", style: AppTheme.labelLarge),
const SizedBox(height: 4),
Text("Diterbitkan 25 Jul 2025", style: AppTheme.bodySmall),
],
),
),
const Icon(Icons.download_rounded, color: AppTheme.textSecondary),
],
),
),
);
},
);
}
Widget _buildSuratList() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.folder_open, size: 64, color: AppTheme.textTertiary),
const SizedBox(height: AppTheme.spacingMd),
Text("Belum ada dokumen", style: AppTheme.bodyMedium.copyWith(color: AppTheme.textSecondary)),
],
),
);
return const SuratIzinScreen();
}
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/error_handler.dart';
import '../../core/theme.dart';
import '../../providers/home_provider.dart';
import '../../providers/poin_provider.dart';
@ -135,12 +136,7 @@ class _HomeScreenState extends State<HomeScreen> {
).then((result) {
if (result != null && result['success'] == true) {
provider.fetchDashboardData();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message'] ?? 'Presensi berhasil'),
backgroundColor: AppTheme.statusGreen,
),
);
ErrorHandler.showSuccess(result['message'] ?? 'Presensi berhasil');
}
});
},

View File

@ -1,7 +1,10 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:animations/animations.dart';
import 'package:provider/provider.dart';
import '../core/theme.dart';
import '../providers/notification_provider.dart';
import '../services/fcm_service.dart';
import 'home/home_screen.dart';
import 'profile/profile_screen.dart';
import 'presensi/presensi_screen.dart';
@ -14,7 +17,7 @@ class MainScreen extends StatefulWidget {
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
int _currentIndex = 0;
final List<Widget> _screens = [
@ -24,8 +27,32 @@ class _MainScreenState extends State<MainScreen> {
const ProfileScreen(),
];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<NotificationProvider>().fetchUnreadCount();
FcmService().initialize(context);
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
context.read<NotificationProvider>().fetchUnreadCount();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
extendBody: true,
body: PageTransitionSwitcher(

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/theme.dart';
import '../../providers/notification_provider.dart';
import '../../models/notifikasi_model.dart';
import '../../widgets/atoms/fade_in_up.dart';
class NotificationScreen extends StatefulWidget {
@ -20,9 +21,75 @@ class _NotificationScreenState extends State<NotificationScreen> {
});
}
IconData _iconForTipe(String tipe) {
if (tipe.contains('disetujui')) return Icons.check_circle_rounded;
if (tipe.contains('ditolak')) return Icons.cancel_rounded;
if (tipe.contains('proses')) return Icons.hourglass_top_rounded;
if (tipe.contains('pengumuman')) return Icons.campaign_rounded;
if (tipe.contains('lembur')) return Icons.schedule_rounded;
if (tipe.contains('presensi')) return Icons.fingerprint_rounded;
if (tipe.contains('izin')) return Icons.description_rounded;
if (tipe.contains('poin')) return Icons.stars_rounded;
return Icons.notifications_outlined;
}
Color _colorForTipe(String tipe) {
if (tipe.contains('disetujui')) return const Color(0xFF10b981);
if (tipe.contains('ditolak')) return const Color(0xFFef4444);
if (tipe.contains('pengumuman')) return AppTheme.primaryBlue;
if (tipe.contains('lembur')) return const Color(0xFFf59e0b);
if (tipe.contains('poin')) return const Color(0xFFe11d48);
if (tipe.contains('izin')) return const Color(0xFF8b5cf6);
if (tipe.contains('presensi')) return const Color(0xFF06b6d4);
return const Color(0xFF6366f1);
}
String _relativeTime(String createdAt) {
try {
final dt = DateTime.parse(createdAt).toLocal();
final now = DateTime.now();
final diff = now.difference(dt);
if (diff.inSeconds < 60) return 'Baru saja';
if (diff.inMinutes < 60) return '${diff.inMinutes} menit lalu';
if (diff.inHours < 24) return '${diff.inHours} jam lalu';
if (diff.inDays == 1) return 'Kemarin';
if (diff.inDays < 7) return '${diff.inDays} hari lalu';
if (diff.inDays < 30) return '${(diff.inDays / 7).floor()} minggu lalu';
return '${dt.day.toString().padLeft(2, '0')}/'
'${dt.month.toString().padLeft(2, '0')}/'
'${dt.year}';
} catch (_) {
return createdAt;
}
}
String _groupLabel(String createdAt) {
try {
final dt = DateTime.parse(createdAt).toLocal();
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final yesterday = today.subtract(const Duration(days: 1));
final dateOnly = DateTime(dt.year, dt.month, dt.day);
if (dateOnly == today) return 'Hari Ini';
if (dateOnly == yesterday) return 'Kemarin';
return 'Lebih Lama';
} catch (_) {
return 'Lainnya';
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
return PopScope(
onPopInvokedWithResult: (didPop, _) {
if (didPop) {
context.read<NotificationProvider>().fetchUnreadCount();
}
},
child: Scaffold(
backgroundColor: AppTheme.bgLight,
appBar: AppBar(
title: Text("Notifikasi", style: AppTheme.heading3),
@ -34,10 +101,21 @@ class _NotificationScreenState extends State<NotificationScreen> {
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: const Icon(Icons.refresh, color: AppTheme.textPrimary),
onPressed: () => context.read<NotificationProvider>().fetchNotifications(),
)
Consumer<NotificationProvider>(
builder: (_, provider, __) => TextButton(
onPressed: provider.notifications.any((n) => !n.isRead)
? () => provider.markAllAsRead()
: null,
child: Text(
'Baca Semua',
style: AppTheme.bodySmall.copyWith(
color: provider.notifications.any((n) => !n.isRead)
? AppTheme.primaryBlue
: AppTheme.textTertiary,
),
),
),
),
],
),
body: Consumer<NotificationProvider>(
@ -48,7 +126,10 @@ class _NotificationScreenState extends State<NotificationScreen> {
if (provider.errorMessage != null) {
return Center(
child: Text(provider.errorMessage!, style: AppTheme.bodyMedium.copyWith(color: AppTheme.statusRed)),
child: Text(
provider.errorMessage!,
style: AppTheme.bodyMedium.copyWith(color: AppTheme.statusRed),
),
);
}
@ -59,19 +140,26 @@ class _NotificationScreenState extends State<NotificationScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppTheme.textTertiary.withValues(alpha: 0.06),
shape: BoxShape.circle,
),
child: Icon(
Icons.notifications_off_outlined,
size: 64,
size: 52,
color: AppTheme.textTertiary.withValues(alpha: 0.3),
),
const SizedBox(height: 16),
),
const SizedBox(height: 20),
Text(
"Belum ada notifikasi",
style: AppTheme.heading3.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 8),
Text(
"Semua pengumuman terbaru akan muncul di sini.",
"Semua notifikasi terbaru akan muncul di sini.",
style: AppTheme.bodySmall.copyWith(color: AppTheme.textTertiary),
textAlign: TextAlign.center,
),
@ -81,79 +169,139 @@ class _NotificationScreenState extends State<NotificationScreen> {
);
}
final grouped = <String, List<NotifikasiModel>>{};
for (final item in provider.notifications) {
final label = _groupLabel(item.createdAt);
grouped.putIfAbsent(label, () => []).add(item);
}
final orderedKeys = ['Hari Ini', 'Kemarin', 'Lebih Lama']
.where((k) => grouped.containsKey(k))
.toList();
return RefreshIndicator(
onRefresh: () => provider.fetchNotifications(),
child: ListView.separated(
child: ListView.builder(
padding: const EdgeInsets.all(AppTheme.spacingMd),
itemCount: provider.notifications.length,
separatorBuilder: (context, index) => const SizedBox(height: AppTheme.spacingMd),
itemBuilder: (context, index) {
final item = provider.notifications[index];
return FadeInUp(
delayMs: index * 50,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
boxShadow: AppTheme.shadowSm,
),
child: Column(
itemCount: orderedKeys.length,
itemBuilder: (context, sectionIndex) {
final key = orderedKeys[sectionIndex];
final items = grouped[key]!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
if (sectionIndex > 0) const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 10, top: 4),
child: Text(
key,
style: AppTheme.labelLarge.copyWith(
color: AppTheme.textTertiary,
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
),
...items.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
final color = _colorForTipe(item.tipe);
return FadeInUp(
delayMs: index * 40,
child: Padding(
padding: const EdgeInsets.only(bottom: AppTheme.spacingSm),
child: GestureDetector(
onTap: () {
if (!item.isRead) {
provider.markAsRead(item.id);
}
},
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: item.isRead ? Colors.white : color.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
boxShadow: AppTheme.shadowSm,
border: item.isRead
? Border.all(color: Colors.transparent)
: Border.all(color: color.withValues(alpha: 0.15)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryBlue.withValues(alpha: 0.1),
shape: BoxShape.circle,
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.campaign, color: AppTheme.primaryBlue, size: 20),
child: Icon(_iconForTipe(item.tipe), color: color, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
style: AppTheme.labelLarge,
),
Text(
item.date,
style: AppTheme.bodySmall.copyWith(color: AppTheme.textTertiary),
),
],
),
),
],
),
const SizedBox(height: 12),
Text(
item.description,
style: AppTheme.bodyMedium.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 12),
Row(
children: [
const Icon(Icons.person_outline, size: 14, color: AppTheme.textTertiary),
const SizedBox(width: 4),
Expanded(
child: Text(
item.judul,
style: AppTheme.labelLarge.copyWith(
fontWeight: item.isRead ? FontWeight.w500 : FontWeight.w700,
),
),
),
if (!item.isRead)
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
],
),
const SizedBox(height: 3),
Text(
item.jabatan,
style: AppTheme.bodySmall.copyWith(color: AppTheme.textTertiary),
item.pesan,
style: AppTheme.bodyMedium.copyWith(
color: AppTheme.textSecondary,
height: 1.3,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 5),
Text(
_relativeTime(item.createdAt),
style: AppTheme.bodySmall.copyWith(
color: AppTheme.textTertiary,
fontSize: 11,
),
],
),
],
),
),
],
),
),
),
),
);
}),
],
);
},
),
);
},
),
),
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../../../core/error_handler.dart';
import '../../../core/theme.dart';
import '../../../widgets/atoms/custom_button.dart';
import '../../../widgets/atoms/custom_text_field.dart';
@ -39,9 +40,7 @@ class _CutiFormScreenState extends State<CutiFormScreen> {
void _handleSubmit() async {
if (_startDate == null || _endDate == null || _reasonController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Semua field wajib diisi")),
);
ErrorHandler.showWarning('Semua field wajib diisi');
return;
}
@ -53,9 +52,7 @@ class _CutiFormScreenState extends State<CutiFormScreen> {
if (success && mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Pengajuan Cuti Berhasil")),
);
ErrorHandler.showSuccess('Pengajuan Cuti Berhasil');
}
}

View File

@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import '../../../core/error_handler.dart';
import '../../../core/theme.dart';
import '../../../widgets/atoms/custom_button.dart';
import '../../../widgets/atoms/custom_text_field.dart';
import '../../../providers/pengajuan_provider.dart';
import '../../../providers/home_provider.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import 'package:file_picker/file_picker.dart';
@ -58,9 +60,7 @@ class _IzinFormScreenState extends State<IzinFormScreen> {
if (success && mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Pengajuan Izin Berhasil")),
);
ErrorHandler.showSuccess('Pengajuan Izin Berhasil');
}
}
@ -95,16 +95,12 @@ class _IzinFormScreenState extends State<IzinFormScreen> {
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('File dipilih: ${result.files.single.name}')),
);
ErrorHandler.showInfo('File dipilih: ${result.files.single.name}');
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Gagal memilih file')),
);
ErrorHandler.showError('Gagal memilih file');
}
}
}
@ -114,6 +110,7 @@ class _IzinFormScreenState extends State<IzinFormScreen> {
final pengajuanProvider = context.watch<PengajuanProvider>();
final hasSignature = pengajuanProvider.hasSignature;
final isLoading = pengajuanProvider.isLoading;
final sisaCuti = context.watch<HomeProvider>().sisaCuti;
return Scaffold(
backgroundColor: AppTheme.bgLight,
@ -151,6 +148,51 @@ class _IzinFormScreenState extends State<IzinFormScreen> {
),
),
// Banner sisa cuti
Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: sisaCuti > 0
? AppTheme.statusGreen.withValues(alpha: 0.08)
: AppTheme.statusRed.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(
color: sisaCuti > 0
? AppTheme.statusGreen.withValues(alpha: 0.3)
: AppTheme.statusRed.withValues(alpha: 0.3),
),
),
child: Row(
children: [
Icon(
sisaCuti > 0 ? Icons.event_available : Icons.event_busy,
color: sisaCuti > 0 ? AppTheme.statusGreen : AppTheme.statusRed,
size: 20,
),
const SizedBox(width: 10),
Expanded(
child: RichText(
text: TextSpan(
style: AppTheme.bodySmall.copyWith(
color: sisaCuti > 0 ? AppTheme.statusGreen : AppTheme.statusRed,
),
children: [
const TextSpan(text: 'Sisa cuti Anda: '),
TextSpan(
text: '$sisaCuti hari',
style: const TextStyle(fontWeight: FontWeight.bold),
),
if (sisaCuti <= 0)
const TextSpan(text: '. Kuota cuti Anda sudah habis.'),
],
),
),
),
],
),
),
CustomTextField(
label: "Tanggal Izin",
controller: _dateController,

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../../../core/error_handler.dart';
import '../../../core/theme.dart';
import '../../../widgets/atoms/custom_text_field.dart';
import '../../../widgets/atoms/custom_button.dart';
@ -94,9 +95,7 @@ class _LemburFormScreenState extends State<LemburFormScreen> {
if (success && mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Pengajuan Lembur Berhasil Disimpan")),
);
ErrorHandler.showSuccess('Pengajuan Lembur Berhasil Disimpan');
}
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../../../core/error_handler.dart';
import '../../../core/theme.dart';
import '../../../widgets/atoms/custom_button.dart';
import '../../../widgets/atoms/custom_text_field.dart';
@ -49,16 +50,12 @@ class _SakitFormScreenState extends State<SakitFormScreen> {
void _handleSubmit() async {
if (_startDate == null || _endDate == null || _diagnosisController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Tanggal dan diagnosis wajib diisi")),
);
ErrorHandler.showWarning('Tanggal dan diagnosis wajib diisi');
return;
}
if (_isFileRequired && _uploadedFilePath == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Surat dokter wajib untuk sakit ≥ 2 hari")),
);
ErrorHandler.showWarning('Surat dokter wajib untuk sakit ≥ 2 hari');
return;
}
@ -71,9 +68,7 @@ class _SakitFormScreenState extends State<SakitFormScreen> {
if (success && mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Pengajuan Sakit Berhasil")),
);
ErrorHandler.showSuccess('Pengajuan Sakit Berhasil');
}
}
@ -129,16 +124,12 @@ class _SakitFormScreenState extends State<SakitFormScreen> {
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('File dipilih: ${result.files.single.name}')),
);
ErrorHandler.showInfo('File dipilih: ${result.files.single.name}');
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Gagal memilih file')),
);
ErrorHandler.showError('Gagal memilih file');
}
}
}

View File

@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import 'package:intl/date_symbol_data_local.dart';
import '../../providers/poin_provider.dart';
import '../../core/error_handler.dart';
import '../../core/theme.dart';
import '../../widgets/atoms/custom_button.dart';
import '../../widgets/atoms/custom_text_field.dart';
@ -94,12 +95,7 @@ class _PointUsageScreenState extends State<PointUsageScreen> {
});
if (!hasSchedule && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(provider.errorMessage ?? 'Jadwal tidak ditemukan'),
backgroundColor: AppTheme.statusRed,
),
);
ErrorHandler.showError(provider.errorMessage ?? 'Jadwal tidak ditemukan');
}
}
}
@ -205,17 +201,13 @@ class _PointUsageScreenState extends State<PointUsageScreen> {
if (!_formKey.currentState!.validate()) return;
if (_estimasiPoin <= 0 && _selectedJenisId != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Estimasi poin 0 atau tidak valid. Cek jam input.'), backgroundColor: AppTheme.statusRed),
);
ErrorHandler.showWarning('Estimasi poin 0 atau tidak valid. Cek jam input.');
return;
}
final provider = context.read<PoinProvider>();
if ((provider.totalPoin ?? 0) < _estimasiPoin) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Saldo poin tidak mencukupi'), backgroundColor: AppTheme.statusRed),
);
ErrorHandler.showWarning('Saldo poin tidak mencukupi');
return;
}
@ -256,12 +248,7 @@ class _PointUsageScreenState extends State<PointUsageScreen> {
if (result['success']) {
_showSuccessDialog();
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message'] ?? 'Gagal memproses permintaan'),
backgroundColor: AppTheme.statusRed,
),
);
ErrorHandler.showError(result['message'] ?? 'Gagal memproses permintaan');
}
}

View File

@ -2,8 +2,10 @@ import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../../core/error_handler.dart';
import '../../core/theme.dart';
import '../../providers/home_provider.dart';
import '../../providers/attendance_provider.dart';
@ -63,8 +65,12 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
if (errorMessage.startsWith("Exception: ")) {
errorMessage = errorMessage.substring(11);
}
if (errorMessage == 'Layanan lokasi (GPS) tidak aktif. Mohon nyalakan GPS Anda.') {
_showGpsDisabledDialog();
} else {
_showErrorSnackBar(errorMessage);
}
}
} finally {
if (mounted) {
setState(() {
@ -569,12 +575,7 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
onPressed: () {
final reason = reasonController.text.trim();
if (reason.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Alasan wajib diisi!"),
backgroundColor: AppTheme.statusRed,
),
);
ErrorHandler.showWarning('Alasan wajib diisi!');
return;
}
@ -690,12 +691,7 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
onPressed: () {
final reason = reasonController.text.trim();
if (reason.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Alasan wajib diisi!"),
backgroundColor: AppTheme.statusRed,
),
);
ErrorHandler.showWarning('Alasan wajib diisi!');
return;
}
@ -847,12 +843,47 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
);
}
void _showErrorSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: AppTheme.statusRed,
void _showGpsDisabledDialog() {
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Row(
children: [
Icon(Icons.location_off_rounded, color: AppTheme.statusRed, size: 28),
const SizedBox(width: 8),
Text("GPS Tidak Aktif", style: AppTheme.heading3),
],
),
content: Text(
"Layanan lokasi (GPS) tidak aktif. Mohon nyalakan GPS Anda di Pengaturan agar dapat mengambil lokasi kantor.",
style: AppTheme.bodyMedium,
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(ctx);
Navigator.pop(context); // Kembali ke menu presensi
},
child: const Text("Batal"),
),
CustomButton(
text: "Buka Pengaturan",
type: ButtonType.primary,
isFullWidth: false,
onPressed: () async {
Navigator.pop(ctx);
await Geolocator.openLocationSettings();
// Jika user menyalakan dan kembali ke app, fetch locator lagi bisa dipicu manual dgn pencet reload
},
),
],
),
);
}
void _showErrorSnackBar(String message) {
ErrorHandler.showError(message);
}
}

View File

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:image_picker/image_picker.dart';
import '../../core/error_handler.dart';
import '../../core/theme.dart';
import '../../providers/auth_provider.dart';
import '../../widgets/atoms/custom_avatar.dart';
@ -67,24 +68,10 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
setState(() => _isSaving = false);
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Profil berhasil diperbarui'),
backgroundColor: AppTheme.statusGreen,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
ErrorHandler.showSuccess('Profil berhasil diperbarui');
Navigator.pop(context, true);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(authProvider.errorMessage ?? 'Gagal memperbarui profil'),
backgroundColor: AppTheme.statusRed,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
ErrorHandler.showError(authProvider.errorMessage ?? 'Gagal memperbarui profil');
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../../core/error_handler.dart';
import '../../core/theme.dart';
import '../../widgets/atoms/custom_avatar.dart';
import '../../widgets/organisms/change_password_dialog.dart';
@ -109,13 +110,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
void _copyToClipboard(String text, String label) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$label berhasil disalin'),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
ErrorHandler.showInfo('$label berhasil disalin');
}
String _formatTanggalIndonesia(String? dateStr) {

View File

@ -6,6 +6,7 @@ import 'package:flutter/rendering.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:hand_signature/signature.dart';
import '../../core/error_handler.dart';
import '../../core/theme.dart';
import '../../providers/signature_provider.dart';
import '../../widgets/atoms/custom_button.dart';
@ -77,12 +78,7 @@ class _SignatureScreenState extends State<SignatureScreen> {
Future<void> _saveSignature() async {
if (!_hasDrawing) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Silakan gambar tanda tangan terlebih dahulu'),
behavior: SnackBarBehavior.floating,
),
);
ErrorHandler.showWarning('Silakan gambar tanda tangan terlebih dahulu');
return;
}
@ -90,12 +86,7 @@ class _SignatureScreenState extends State<SignatureScreen> {
final imageBytes = await _captureCanvas();
if (imageBytes == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Gagal mengkonversi tanda tangan'),
behavior: SnackBarBehavior.floating,
),
);
ErrorHandler.showError('Gagal mengkonversi tanda tangan');
}
return;
}
@ -107,15 +98,11 @@ class _SignatureScreenState extends State<SignatureScreen> {
final success = await provider.uploadSignature(imageBytes);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? 'Tanda tangan berhasil disimpan!'
: provider.errorMessage ?? 'Gagal menyimpan'),
backgroundColor: success ? AppTheme.statusGreen : AppTheme.statusRed,
behavior: SnackBarBehavior.floating,
),
);
if (success) {
ErrorHandler.showSuccess('Tanda tangan berhasil disimpan!');
} else {
ErrorHandler.showError(provider.errorMessage ?? 'Gagal menyimpan');
}
if (success) {
if (widget.isOnboarding && mounted) {
Navigator.pushReplacementNamed(context, '/home');
@ -128,13 +115,7 @@ class _SignatureScreenState extends State<SignatureScreen> {
} catch (e) {
print('[Signature] Error: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: $e'),
backgroundColor: AppTheme.statusRed,
behavior: SnackBarBehavior.floating,
),
);
ErrorHandler.showError('Error: $e');
}
}
}
@ -162,12 +143,11 @@ class _SignatureScreenState extends State<SignatureScreen> {
if (confirmed == true && mounted) {
final success = await context.read<SignatureProvider>().deleteSignature();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success ? 'Tanda tangan berhasil dihapus' : 'Gagal menghapus'),
behavior: SnackBarBehavior.floating,
),
);
if (success) {
ErrorHandler.showSuccess('Tanda tangan berhasil dihapus');
} else {
ErrorHandler.showError('Gagal menghapus');
}
}
}
}

View File

@ -1,153 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/theme.dart';
import '../../providers/home_provider.dart';
import '../../widgets/atoms/fade_in_up.dart';
class SalaryEstimatorScreen extends StatelessWidget {
const SalaryEstimatorScreen({super.key});
@override
Widget build(BuildContext context) {
final homeProvider = context.watch<HomeProvider>();
final totalAbsence = homeProvider.izinCount + homeProvider.alphaCount;
return Scaffold(
backgroundColor: AppTheme.bgLight,
appBar: AppBar(
title: Text("Estimasi Gaji", style: AppTheme.heading3),
backgroundColor: AppTheme.bgLight,
elevation: 0,
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, color: AppTheme.textPrimary, size: 20),
onPressed: () => Navigator.pop(context),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(AppTheme.spacingMd),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FadeInUp(
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppTheme.primaryDark, AppTheme.primaryBlue],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
boxShadow: AppTheme.shadowMd,
),
child: Column(
children: [
Text(
"Potongan Bulan Ini",
style: AppTheme.bodyMedium.copyWith(color: Colors.white.withValues(alpha: 0.8)),
),
const SizedBox(height: 8),
Text(
"$totalAbsence Hari",
style: AppTheme.heading1.copyWith(color: Colors.white, fontSize: 36),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
),
child: Text(
"Berdasarkan data presensi Anda",
style: AppTheme.bodySmall.copyWith(color: Colors.white),
),
),
],
),
),
),
const SizedBox(height: AppTheme.spacingLg),
Text("Detail Ketidakhadiran", style: AppTheme.heading3),
const SizedBox(height: AppTheme.spacingMd),
FadeInUp(
delayMs: 100,
child: _buildDetailItem(
Icons.event_busy,
"Izin & Sakit",
"${homeProvider.izinCount} Hari",
AppTheme.statusOrange,
),
),
const SizedBox(height: AppTheme.spacingMd),
FadeInUp(
delayMs: 200,
child: _buildDetailItem(
Icons.warning_amber_rounded,
"Alpha / Mangkir",
"${homeProvider.alphaCount} Hari",
AppTheme.statusRed,
),
),
const SizedBox(height: 32),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.primaryBlue.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(color: AppTheme.primaryBlue.withValues(alpha: 0.1)),
),
child: Row(
children: [
const Icon(Icons.info_outline, color: AppTheme.primaryBlue, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
"Estimasi ini bersifat sementara dan dapat berubah sesuai dengan kebijakan payroll kantor.",
style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary),
),
),
],
),
),
],
),
),
);
}
Widget _buildDetailItem(IconData icon, String title, String value, Color color) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
boxShadow: AppTheme.shadowSm,
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
),
child: Icon(icon, color: color, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Text(title, style: AppTheme.labelLarge),
),
Text(
value,
style: AppTheme.heading3.copyWith(color: color),
),
],
),
);
}
}

View File

@ -19,25 +19,15 @@ class ApiClient {
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final token = CacheManager.authBox.get('auth_token');
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
print('[API Request] ${options.method} ${options.baseUrl}${options.path}');
return handler.next(options);
},
onResponse: (response, handler) {
print('[API Response] ${response.statusCode} ${response.requestOptions.path}');
return handler.next(response);
},
onError: (DioException e, handler) {
if (e.response != null) {
print('[API Error] ${e.response?.statusCode} ${e.requestOptions.path}');
print('[API Error Data] ${e.response?.data}');
} else {
print('[API Network Error] ${e.type}: ${e.message}');
}
return handler.next(e);
},
));

View File

@ -0,0 +1,294 @@
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../core/theme.dart';
import '../providers/notification_provider.dart';
import '../repositories/notifikasi_repository.dart';
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {}
class FcmService {
static final FcmService _instance = FcmService._internal();
factory FcmService() => _instance;
FcmService._internal();
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin();
final NotifikasiRepository _repository = NotifikasiRepository();
Future<void> initialize(BuildContext context) async {
final settings = await _messaging.requestPermission(
alert: true,
badge: true,
sound: true,
);
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const initSettings = InitializationSettings(android: androidSettings);
await _localNotifications.initialize(
settings: initSettings,
onDidReceiveNotificationResponse: (details) {
if (context.mounted) {
Navigator.of(context).pushNamedAndRemoveUntil('/home', (route) => false);
Navigator.of(context).pushNamed('/notification');
}
},
);
if (settings.authorizationStatus == AuthorizationStatus.denied) return;
final token = await _messaging.getToken();
if (token != null) await _repository.saveDeviceToken(token);
_messaging.onTokenRefresh.listen((t) => _repository.saveDeviceToken(t));
FirebaseMessaging.onMessage.listen((msg) {
if (context.mounted) {
_showBanner(context, msg);
context.read<NotificationProvider>().fetchUnreadCount();
}
_showSystemNotification(msg);
});
FirebaseMessaging.onMessageOpenedApp.listen((msg) {
if (context.mounted) _handleTap(context, msg);
});
final initial = await _messaging.getInitialMessage();
if (initial != null) {
Future.delayed(const Duration(seconds: 1), () {
if (context.mounted) _handleTap(context, initial);
});
}
}
IconData _iconForTipe(String? tipe) {
if (tipe == null) return Icons.notifications_outlined;
if (tipe.contains('disetujui')) return Icons.check_circle_rounded;
if (tipe.contains('ditolak')) return Icons.cancel_rounded;
if (tipe.contains('pengumuman')) return Icons.campaign_rounded;
if (tipe.contains('lembur')) return Icons.schedule_rounded;
if (tipe.contains('presensi')) return Icons.fingerprint_rounded;
if (tipe.contains('izin')) return Icons.description_rounded;
if (tipe.contains('poin')) return Icons.stars_rounded;
return Icons.notifications_outlined;
}
Color _colorForTipe(String? tipe) {
if (tipe == null) return AppTheme.primaryBlue;
if (tipe.contains('disetujui')) return const Color(0xFF10b981);
if (tipe.contains('ditolak')) return const Color(0xFFef4444);
if (tipe.contains('pengumuman')) return AppTheme.primaryBlue;
if (tipe.contains('lembur')) return const Color(0xFFf59e0b);
if (tipe.contains('poin')) return const Color(0xFFe11d48);
return const Color(0xFF6366f1);
}
void _showBanner(BuildContext context, RemoteMessage message) {
final title = message.notification?.title ?? '';
final body = message.notification?.body ?? '';
final tipe = message.data['tipe'] as String?;
final color = _colorForTipe(tipe);
final icon = _iconForTipe(tipe);
if (title.isEmpty) return;
final overlay = Overlay.of(context);
late OverlayEntry entry;
entry = OverlayEntry(
builder: (context) => _NotificationBanner(
title: title,
body: body,
icon: icon,
color: color,
onTap: () {
entry.remove();
Navigator.of(context).pushNamed('/notification');
},
onDismiss: () => entry.remove(),
),
);
overlay.insert(entry);
Future.delayed(const Duration(seconds: 5), () {
if (entry.mounted) entry.remove();
});
}
Future<void> _showSystemNotification(RemoteMessage message) async {
final title = message.notification?.title ?? '';
final body = message.notification?.body ?? '';
if (title.isEmpty) return;
const androidDetails = AndroidNotificationDetails(
'hris_channel',
'HRIS Notifications',
importance: Importance.max,
priority: Priority.high,
icon: '@mipmap/ic_launcher',
);
const details = NotificationDetails(android: androidDetails);
await _localNotifications.show(
id: message.hashCode,
title: title,
body: body,
notificationDetails: details,
);
}
void _handleTap(BuildContext context, RemoteMessage message) {
Navigator.of(context).pushNamedAndRemoveUntil('/home', (route) => false);
Navigator.of(context).pushNamed('/notification');
}
}
class _NotificationBanner extends StatefulWidget {
final String title;
final String body;
final IconData icon;
final Color color;
final VoidCallback onTap;
final VoidCallback onDismiss;
const _NotificationBanner({
required this.title,
required this.body,
required this.icon,
required this.color,
required this.onTap,
required this.onDismiss,
});
@override
State<_NotificationBanner> createState() => _NotificationBannerState();
}
class _NotificationBannerState extends State<_NotificationBanner>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _slideAnimation;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, -1),
end: Offset.zero,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));
_fadeAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final topPadding = MediaQuery.of(context).padding.top;
return Positioned(
top: topPadding + 8,
left: 12,
right: 12,
child: SlideTransition(
position: _slideAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: GestureDetector(
onTap: widget.onTap,
onVerticalDragEnd: (details) {
if (details.primaryVelocity != null && details.primaryVelocity! < 0) {
_controller.reverse().then((_) => widget.onDismiss());
}
},
child: Material(
elevation: 8,
shadowColor: Colors.black26,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: widget.color.withValues(alpha: 0.15)),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: widget.color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(widget.icon, color: widget.color, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: Color(0xFF1e293b),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
widget.body,
style: TextStyle(
fontSize: 12,
color: const Color(0xFF64748b),
height: 1.3,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: widget.color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Lihat',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: widget.color,
),
),
),
],
),
),
),
),
),
),
);
}
}

View File

@ -0,0 +1,171 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:timezone/timezone.dart' as tz;
class ReminderService {
static final ReminderService _instance = ReminderService._internal();
factory ReminderService() => _instance;
ReminderService._internal();
final FlutterLocalNotificationsPlugin _notifications =
FlutterLocalNotificationsPlugin();
static const String _keyJamMasuk = 'reminder_jam_masuk';
static const String _keyJamPulang = 'reminder_jam_pulang';
static const int _baseIdMasuk = 1001;
static const int _baseIdPulang = 2001;
static const int _baseIdLupa = 3001;
static const int _daysAhead = 7;
static const int _minutesBefore = 15;
static const int _minutesAfterForLupa = 30;
Future<void> initialize() async {
const androidSettings =
AndroidInitializationSettings('@mipmap/ic_launcher');
const initSettings = InitializationSettings(android: androidSettings);
await _notifications.initialize(settings: initSettings);
}
Future<void> scheduleWeeklyReminders({
required String jadwalJamMasuk,
required String jadwalJamPulang,
required bool sudahAbsenMasuk,
required bool sudahAbsenPulang,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyJamMasuk, jadwalJamMasuk);
await prefs.setString(_keyJamPulang, jadwalJamPulang);
await _cancelAllReminders();
final masukParts = _parseTime(jadwalJamMasuk);
final pulangParts = _parseTime(jadwalJamPulang);
if (masukParts == null || pulangParts == null) return;
final jakarta = tz.getLocation('Asia/Jakarta');
final now = tz.TZDateTime.now(jakarta);
for (int i = 0; i < _daysAhead; i++) {
final targetDate = now.add(Duration(days: i));
final isToday = i == 0;
if (targetDate.weekday == 6 || targetDate.weekday == 7) continue;
final reminderMasuk = tz.TZDateTime(
jakarta,
targetDate.year,
targetDate.month,
targetDate.day,
masukParts[0],
masukParts[1],
).subtract(Duration(minutes: _minutesBefore));
if (!isToday || (!sudahAbsenMasuk && reminderMasuk.isAfter(now))) {
if (reminderMasuk.isAfter(now)) {
await _scheduleNotification(
id: _baseIdMasuk + i,
title: '⏰ Pengingat Absen Masuk',
body: 'Jangan lupa absen masuk! Jadwal masuk: $jadwalJamMasuk',
scheduledDate: reminderMasuk,
);
}
}
final reminderPulang = tz.TZDateTime(
jakarta,
targetDate.year,
targetDate.month,
targetDate.day,
pulangParts[0],
pulangParts[1],
).subtract(Duration(minutes: _minutesBefore));
if (!isToday || (!sudahAbsenPulang && reminderPulang.isAfter(now))) {
if (reminderPulang.isAfter(now)) {
await _scheduleNotification(
id: _baseIdPulang + i,
title: '⏰ Pengingat Absen Pulang',
body: 'Jangan lupa absen pulang! Jadwal pulang: $jadwalJamPulang',
scheduledDate: reminderPulang,
);
}
}
final reminderLupa = tz.TZDateTime(
jakarta,
targetDate.year,
targetDate.month,
targetDate.day,
masukParts[0],
masukParts[1],
).add(Duration(minutes: _minutesAfterForLupa));
if (!isToday || (!sudahAbsenMasuk && reminderLupa.isAfter(now))) {
if (reminderLupa.isAfter(now)) {
await _scheduleNotification(
id: _baseIdLupa + i,
title: '⚠️ Anda Belum Absen Masuk',
body:
'Sudah $_minutesAfterForLupa menit lewat jadwal masuk ($jadwalJamMasuk). Segera lakukan absen!',
scheduledDate: reminderLupa,
);
}
}
}
}
Future<void> _scheduleNotification({
required int id,
required String title,
required String body,
required tz.TZDateTime scheduledDate,
}) async {
const androidDetails = AndroidNotificationDetails(
'attendance_reminder',
'Pengingat Absen',
channelDescription: 'Pengingat absen masuk dan pulang otomatis',
importance: Importance.high,
priority: Priority.high,
icon: '@mipmap/ic_launcher',
styleInformation: BigTextStyleInformation(''),
);
const details = NotificationDetails(android: androidDetails);
await _notifications.zonedSchedule(
id: id,
title: title,
body: body,
scheduledDate: scheduledDate,
notificationDetails: details,
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
);
}
Future<void> _cancelAllReminders() async {
for (int i = 0; i < _daysAhead; i++) {
await _notifications.cancel(id: _baseIdMasuk + i);
await _notifications.cancel(id: _baseIdPulang + i);
await _notifications.cancel(id: _baseIdLupa + i);
}
}
Future<void> cancelAll() async {
await _cancelAllReminders();
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyJamMasuk);
await prefs.remove(_keyJamPulang);
}
List<int>? _parseTime(String? timeStr) {
if (timeStr == null || timeStr.isEmpty || timeStr == '-') return null;
try {
final parts = timeStr.split(':');
if (parts.length < 2) return null;
return [int.parse(parts[0]), int.parse(parts[1])];
} catch (e) {
return null;
}
}
}

View File

@ -39,7 +39,6 @@ class FaceStatusBadge extends StatelessWidget {
text = "Verifikasi Ditolak";
break;
case FaceStatus.notEnrolled:
default:
bgColor = AppTheme.textSecondary.withOpacity(0.1);
textColor = AppTheme.textSecondary;
icon = Icons.face;

View File

@ -5,14 +5,14 @@ class QuickMenuCard extends StatelessWidget {
final VoidCallback onPresensiTap;
final VoidCallback onPengajuanTap;
final VoidCallback onLemburTap;
final VoidCallback onSlipTap;
final VoidCallback onJadwalTap;
const QuickMenuCard({
Key? key,
required this.onPresensiTap,
required this.onPengajuanTap,
required this.onLemburTap,
required this.onSlipTap,
required this.onJadwalTap,
}) : super(key: key);
@override
@ -25,7 +25,7 @@ class QuickMenuCard extends StatelessWidget {
_buildItem(context, Icons.fingerprint, "Presensi", AppTheme.primaryDark, onPresensiTap),
_buildItem(context, Icons.assignment_outlined, "Pengajuan", Colors.orange, onPengajuanTap),
_buildItem(context, Icons.access_time_filled, "Lembur", Colors.blue, onLemburTap),
_buildItem(context, Icons.receipt_long, "Slip Gaji", Colors.green, onSlipTap),
_buildItem(context, Icons.calendar_month_outlined, "Jadwal", Colors.teal, onJadwalTap),
],
),
);

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/theme.dart';
import '../../providers/notification_provider.dart';
class BottomNavbar extends StatelessWidget {
final int currentIndex;
@ -13,6 +15,8 @@ class BottomNavbar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final unreadCount = context.watch<NotificationProvider>().unreadCount;
return Container(
decoration: BoxDecoration(
color: AppTheme.primaryDark,
@ -37,26 +41,72 @@ class BottomNavbar extends StatelessWidget {
showSelectedLabels: false,
type: BottomNavigationBarType.fixed,
elevation: 0,
items: const [
BottomNavigationBarItem(
items: [
const BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home),
label: 'Home',
),
BottomNavigationBarItem(
const BottomNavigationBarItem(
icon: Icon(Icons.calendar_today_outlined),
activeIcon: Icon(Icons.calendar_today),
label: 'Calendar',
),
BottomNavigationBarItem(
const BottomNavigationBarItem(
icon: Icon(Icons.receipt_long_outlined),
activeIcon: Icon(Icons.receipt_long),
label: 'Document',
),
BottomNavigationBarItem(
icon: Icon(Icons.person_outline),
activeIcon: Icon(Icons.person),
label: 'Profile',
icon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.notifications_outlined),
if (unreadCount > 0)
Positioned(
right: -4,
top: -4,
child: Container(
padding: const EdgeInsets.all(2),
decoration: const BoxDecoration(
color: Color(0xFFef4444),
shape: BoxShape.circle,
),
constraints: const BoxConstraints(minWidth: 14, minHeight: 14),
child: Text(
unreadCount > 9 ? '9+' : '$unreadCount',
style: const TextStyle(color: Colors.white, fontSize: 8, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
),
),
],
),
activeIcon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.notifications),
if (unreadCount > 0)
Positioned(
right: -4,
top: -4,
child: Container(
padding: const EdgeInsets.all(2),
decoration: const BoxDecoration(
color: Color(0xFFef4444),
shape: BoxShape.circle,
),
constraints: const BoxConstraints(minWidth: 14, minHeight: 14),
child: Text(
unreadCount > 9 ? '9+' : '$unreadCount',
style: const TextStyle(color: Colors.white, fontSize: 8, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
),
),
],
),
label: 'Notifikasi',
),
],
),

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/error_handler.dart';
import '../../core/theme.dart';
import '../../providers/auth_provider.dart';
import '../atoms/custom_button.dart';
@ -29,9 +30,7 @@ class _ChangePasswordDialogState extends State<ChangePasswordDialog> {
Future<void> _handleSubmit() async {
if (_formKey.currentState!.validate()) {
if (_newPasswordController.text != _confirmPasswordController.text) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Password baru tidak cocok")),
);
ErrorHandler.showWarning('Password baru tidak cocok');
return;
}
@ -44,16 +43,12 @@ class _ChangePasswordDialogState extends State<ChangePasswordDialog> {
if (success) {
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Password berhasil diubah")),
);
ErrorHandler.showSuccess('Password berhasil diubah');
}
} else {
if (mounted) {
final error = context.read<AuthProvider>().errorMessage;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error ?? "Gagal mengubah password")),
);
ErrorHandler.showError(error ?? 'Gagal mengubah password');
}
}
}

View File

@ -1,15 +1,17 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/theme.dart';
import '../../models/user_model.dart';
import '../../providers/notification_provider.dart';
import '../atoms/custom_avatar.dart';
class HomeHeader extends StatelessWidget {
final UserModel user;
const HomeHeader({
Key? key,
super.key,
required this.user,
}) : super(key: key);
});
String _getGreeting() {
final hour = DateTime.now().hour;
@ -21,6 +23,8 @@ class HomeHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
final unreadCount = context.watch<NotificationProvider>().unreadCount;
return Container(
padding: const EdgeInsets.all(AppTheme.spacingLg),
decoration: BoxDecoration(
@ -87,13 +91,41 @@ class HomeHeader extends StatelessWidget {
shape: BoxShape.circle,
border: Border.all(color: AppTheme.glassWhite20, width: 1),
),
child: IconButton(
child: Stack(
clipBehavior: Clip.none,
children: [
IconButton(
icon: const Icon(Icons.notifications_none_rounded),
color: Colors.white,
onPressed: () {
Navigator.pushNamed(context, '/notification');
},
),
if (unreadCount > 0)
Positioned(
right: 4,
top: 4,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: const Color(0xFFef4444),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.primaryDark, width: 1.5),
),
constraints: const BoxConstraints(minWidth: 16, minHeight: 16),
child: Text(
unreadCount > 9 ? '9+' : '$unreadCount',
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
),
],
),
),
],
),

View File

@ -1,6 +1,7 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/error_handler.dart';
import '../../core/theme.dart';
import '../../models/presensi_model.dart';
import '../../providers/attendance_provider.dart';
@ -68,9 +69,7 @@ class _PresensiDetailSheetState extends State<PresensiDetailSheet> {
Future<void> _handleResubmit() async {
if (_keteranganController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Keterangan wajib diisi'), backgroundColor: Colors.red),
);
ErrorHandler.showWarning('Keterangan wajib diisi');
return;
}
@ -85,23 +84,11 @@ class _PresensiDetailSheetState extends State<PresensiDetailSheet> {
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Presensi berhasil diajukan ulang!'),
backgroundColor: AppTheme.statusGreen,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
);
ErrorHandler.showSuccess('Presensi berhasil diajukan ulang!');
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString().replaceAll('Exception: ', '')),
backgroundColor: AppTheme.statusRed,
),
);
ErrorHandler.showError(e.toString().replaceAll('Exception: ', ''));
}
} finally {
if (mounted) setState(() => _isSubmitting = false);

View File

@ -8,6 +8,9 @@ import Foundation
import device_info_plus
import file_picker
import file_selector_macos
import firebase_core
import firebase_messaging
import flutter_local_notifications
import flutter_secure_storage_darwin
import geolocator_apple
import package_info_plus
@ -19,6 +22,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))

View File

@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11
url: "https://pub.dev"
source: hosted
version: "1.3.59"
animations:
dependency: "direct main"
description:
@ -281,6 +289,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
firebase_core:
dependency: "direct main"
description:
name: firebase_core
sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5"
url: "https://pub.dev"
source: hosted
version: "3.15.2"
firebase_core_platform_interface:
dependency: transitive
description:
name: firebase_core_platform_interface
sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce"
url: "https://pub.dev"
source: hosted
version: "6.0.3"
firebase_core_web:
dependency: transitive
description:
name: firebase_core_web
sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37"
url: "https://pub.dev"
source: hosted
version: "2.24.1"
firebase_messaging:
dependency: "direct main"
description:
name: firebase_messaging
sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc"
url: "https://pub.dev"
source: hosted
version: "15.2.10"
firebase_messaging_platform_interface:
dependency: transitive
description:
name: firebase_messaging_platform_interface
sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754"
url: "https://pub.dev"
source: hosted
version: "4.6.10"
firebase_messaging_web:
dependency: transitive
description:
name: firebase_messaging_web
sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390"
url: "https://pub.dev"
source: hosted
version: "3.10.10"
fixnum:
dependency: transitive
description:
@ -318,6 +374,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "2b50e938a275e1ad77352d6a25e25770f4130baa61eaf02de7a9a884680954ad"
url: "https://pub.dev"
source: hosted
version: "20.1.0"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0
url: "https://pub.dev"
source: hosted
version: "7.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899"
url: "https://pub.dev"
source: hosted
version: "10.0.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: e97a1a3016512437d9c0b12fae7d1491c3c7b9aa7f03a69b974308840656b02a
url: "https://pub.dev"
source: hosted
version: "2.0.1"
flutter_map:
dependency: "direct main"
description:
@ -1189,6 +1277,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.6"
timezone:
dependency: "direct main"
description:
name: timezone
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
url: "https://pub.dev"
source: hosted
version: "0.10.1"
typed_data:
dependency: transitive
description:

View File

@ -64,6 +64,10 @@ dependencies:
haptic_feedback: ^0.6.4+3
animations: ^2.1.1
signature: ^6.3.0
firebase_core: ^3.6.0
firebase_messaging: ^15.1.3
flutter_local_notifications: ^20.1.0
timezone: ^0.10.0
dev_dependencies:
flutter_test:

View File

@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <geolocator_windows/geolocator_windows.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
@ -14,6 +15,8 @@
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
GeolocatorWindowsRegisterWithRegistrar(

View File

@ -4,12 +4,14 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
firebase_core
flutter_secure_storage_windows
geolocator_windows
permission_handler_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
flutter_local_notifications_windows
)
set(PLUGIN_BUNDLED_LIBRARIES)