From e7369080049d8e3ec8a252c32a792c8241bca40c Mon Sep 17 00:00:00 2001 From: jouel88 Date: Sat, 28 Mar 2026 15:38:22 +0700 Subject: [PATCH] notifikasi+snack bar --- analyze.txt | Bin 0 -> 1356 bytes analyze_fcm.txt | Bin 0 -> 754 bytes android/app/build.gradle.kts | 10 +- android/app/src/main/AndroidManifest.xml | 13 + .../mpg_mobile => mp/hris}/MainActivity.kt | 2 +- android/app/src/main/res/values/colors.xml | 4 + android/build.gradle.kts | 1 + android/settings.gradle.kts | 1 + lib/core/error_handler.dart | 97 ++++- lib/core/utils/camera_utils.dart | 1 - lib/main.dart | 19 +- lib/models/notifikasi_model.dart | 31 ++ lib/providers/auth_provider.dart | 2 + lib/providers/calendar_provider.dart | 3 - lib/providers/home_provider.dart | 17 + lib/providers/notification_provider.dart | 53 ++- lib/providers/pengajuan_provider.dart | 28 +- lib/repositories/face_repository.dart | 1 - lib/repositories/notifikasi_repository.dart | 52 +++ lib/screens/api_settings_screen.dart | 333 --------------- lib/screens/auth/login_screen.dart | 12 +- lib/screens/documents/documents_screen.dart | 101 +---- lib/screens/home/home_screen.dart | 8 +- lib/screens/main_screen.dart | 29 +- .../notification/notification_screen.dart | 402 ++++++++++++------ .../pengajuan/forms/cuti_form_screen.dart | 9 +- .../pengajuan/forms/izin_form_screen.dart | 60 ++- .../pengajuan/forms/lembur_form_screen.dart | 5 +- .../pengajuan/forms/sakit_form_screen.dart | 21 +- lib/screens/poin/point_usage_screen.dart | 23 +- lib/screens/presensi/presensi_map_screen.dart | 67 ++- lib/screens/profile/edit_profile_screen.dart | 19 +- lib/screens/profile/profile_screen.dart | 9 +- lib/screens/profile/signature_screen.dart | 48 +-- .../salary/salary_estimator_screen.dart | 153 ------- lib/services/api_client.dart | 10 - lib/services/fcm_service.dart | 294 +++++++++++++ lib/services/reminder_service.dart | 171 ++++++++ lib/widgets/atoms/face_status_badge.dart | 1 - lib/widgets/molecules/quick_menu_card.dart | 6 +- lib/widgets/organisms/bottom_navbar.dart | 64 ++- .../organisms/change_password_dialog.dart | 13 +- lib/widgets/organisms/home_header.dart | 48 ++- .../organisms/presensi_detail_sheet.dart | 21 +- macos/Flutter/GeneratedPluginRegistrant.swift | 6 + pubspec.lock | 96 +++++ pubspec.yaml | 4 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 2 + 49 files changed, 1404 insertions(+), 969 deletions(-) create mode 100644 analyze.txt create mode 100644 analyze_fcm.txt rename android/app/src/main/kotlin/com/{example/mpg_mobile => mp/hris}/MainActivity.kt (75%) create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 lib/models/notifikasi_model.dart create mode 100644 lib/repositories/notifikasi_repository.dart delete mode 100644 lib/screens/api_settings_screen.dart delete mode 100644 lib/screens/salary/salary_estimator_screen.dart create mode 100644 lib/services/fcm_service.dart create mode 100644 lib/services/reminder_service.dart diff --git a/analyze.txt b/analyze.txt new file mode 100644 index 0000000000000000000000000000000000000000..7ca583e2b0c32f5766f53762339160e842ac291c GIT binary patch literal 1356 zcmdUt%}&EW420*5#5?Sb3n2arz3~nlk)r&yL~0VqrU-a=;ImVNP^dtn9IBN!US~ZX z&-(54T8&aI^sEHL=#PQs$*~rt9IUTRWTXyPevTp ztJiuzH5m=>YudGNtXwDS%iew5Q-n{@5mxGnoDxM$>95)u-x+vI*Jt4VHytjiI|0>H zu*yBXr%d5@tj8)uZraN&>8Zfaz(~BuVRF{1^xGM$z}=p?y3+Yp&ehq5=jua;i+`Og Go33|*PV0jJ literal 0 HcmV?d00001 diff --git a/analyze_fcm.txt b/analyze_fcm.txt new file mode 100644 index 0000000000000000000000000000000000000000..e1f58a88ffb6216dc4ec48bfada83b99170f5b56 GIT binary patch literal 754 zcmb`F%}&EG5QOK9#5?%L1uA|HMZNJ394kfnYpK-45IYEXc;MSj5kdiR09os-C-&^@ z*l({l>Q!p5XT4}gzE#jhwK^3_YBlH)ye3 + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/mpg_mobile/MainActivity.kt b/android/app/src/main/kotlin/com/mp/hris/MainActivity.kt similarity index 75% rename from android/app/src/main/kotlin/com/example/mpg_mobile/MainActivity.kt rename to android/app/src/main/kotlin/com/mp/hris/MainActivity.kt index 957e16d..350f7a8 100644 --- a/android/app/src/main/kotlin/com/example/mpg_mobile/MainActivity.kt +++ b/android/app/src/main/kotlin/com/mp/hris/MainActivity.kt @@ -1,4 +1,4 @@ -package com.example.mpg_mobile +package com.mp.hris import io.flutter.embedding.android.FlutterActivity diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..336a57f --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #130F26 + diff --git a/android/build.gradle.kts b/android/build.gradle.kts index dbee657..ccbfce8 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -22,3 +22,4 @@ subprojects { tasks.register("clean") { delete(rootProject.layout.buildDirectory) } + diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index fb605bc..fa3d8af 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -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") diff --git a/lib/core/error_handler.dart b/lib/core/error_handler.dart index 3c4e096..6c93bb2 100644 --- a/lib/core/error_handler.dart +++ b/lib/core/error_handler.dart @@ -4,31 +4,92 @@ class ErrorHandler { static final GlobalKey scaffoldMessengerKey = GlobalKey(); - 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, ), ); } diff --git a/lib/core/utils/camera_utils.dart b/lib/core/utils/camera_utils.dart index d593507..60251ad 100644 --- a/lib/core/utils/camera_utils.dart +++ b/lib/core/utils/camera_utils.dart @@ -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'; diff --git a/lib/main.dart b/lib/main.dart index cc6c43c..d918da5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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(), diff --git a/lib/models/notifikasi_model.dart b/lib/models/notifikasi_model.dart new file mode 100644 index 0000000..b82949c --- /dev/null +++ b/lib/models/notifikasi_model.dart @@ -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? 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 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.from(json['data']) : null, + ); + } +} diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index fdb08eb..8f1fcca 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -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(); } } diff --git a/lib/providers/calendar_provider.dart b/lib/providers/calendar_provider.dart index 5e8cfbd..5e65d72 100644 --- a/lib/providers/calendar_provider.dart +++ b/lib/providers/calendar_provider.dart @@ -15,7 +15,6 @@ class CalendarProvider extends ChangeNotifier { String? get errorMessage => _errorMessage; Future 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 { diff --git a/lib/providers/home_provider.dart b/lib/providers/home_provider.dart index 3e0f450..10e7a88 100644 --- a/lib/providers/home_provider.dart +++ b/lib/providers/home_provider.dart @@ -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, + ); + } } diff --git a/lib/providers/notification_provider.dart b/lib/providers/notification_provider.dart index ae61548..ce1f989 100644 --- a/lib/providers/notification_provider.dart +++ b/lib/providers/notification_provider.dart @@ -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 notifications = []; + List 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 fetchUnreadCount() async { + try { + unreadCount = await _repository.getUnreadCount(); + notifyListeners(); + } catch (_) {} + } + + Future 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 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(); + } } diff --git a/lib/providers/pengajuan_provider.dart b/lib/providers/pengajuan_provider.dart index 6ee9256..3260a89 100644 --- a/lib/providers/pengajuan_provider.dart +++ b/lib/providers/pengajuan_provider.dart @@ -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 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(); diff --git a/lib/repositories/face_repository.dart b/lib/repositories/face_repository.dart index dfa2dbb..0e5a000 100644 --- a/lib/repositories/face_repository.dart +++ b/lib/repositories/face_repository.dart @@ -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> enrollFace({ diff --git a/lib/repositories/notifikasi_repository.dart b/lib/repositories/notifikasi_repository.dart new file mode 100644 index 0000000..38ebe07 --- /dev/null +++ b/lib/repositories/notifikasi_repository.dart @@ -0,0 +1,52 @@ +import '../services/api_client.dart'; +import '../../models/notifikasi_model.dart'; + +class NotifikasiRepository { + final ApiClient _apiClient = ApiClient(); + + Future> 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 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 markAsRead(int id) async { + try { + await _apiClient.dio.post('/notifikasi/$id/read'); + } catch (_) {} + } + + Future markAllAsRead() async { + try { + await _apiClient.dio.post('/notifikasi/read-all'); + } catch (_) {} + } + + Future saveDeviceToken(String token, {String deviceType = 'android'}) async { + try { + await _apiClient.dio.post('/device-token', data: { + 'fcm_token': token, + 'device_type': deviceType, + }); + } catch (_) {} + } +} diff --git a/lib/screens/api_settings_screen.dart b/lib/screens/api_settings_screen.dart deleted file mode 100644 index 0fab6b0..0000000 --- a/lib/screens/api_settings_screen.dart +++ /dev/null @@ -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 createState() => _ApiSettingsScreenState(); -} - -class _ApiSettingsScreenState extends State { - String _selectedPreset = 'hostname'; - final TextEditingController _customUrlController = TextEditingController(); - bool _isLoading = false; - String? _currentUrl; - - @override - void initState() { - super.initState(); - _loadCurrentConfig(); - } - - Future _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 _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(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 _showConfirmDialog(String message) { - return showDialog( - 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( - 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(); - } -} diff --git a/lib/screens/auth/login_screen.dart b/lib/screens/auth/login_screen.dart index 8b31123..757aeb3 100644 --- a/lib/screens/auth/login_screen.dart +++ b/lib/screens/auth/login_screen.dart @@ -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 { ); 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().errorMessage ?? "Login Failed"), - backgroundColor: AppTheme.statusRed, - ), - ); + ErrorHandler.showError(context.read().errorMessage ?? 'Login Failed'); } } } diff --git a/lib/screens/documents/documents_screen.dart b/lib/screens/documents/documents_screen.dart index bdbc92e..06a99c4 100644 --- a/lib/screens/documents/documents_screen.dart +++ b/lib/screens/documents/documents_screen.dart @@ -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 createState() => _DocumentsScreenState(); -} - -class _DocumentsScreenState extends State 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(); } } diff --git a/lib/screens/home/home_screen.dart b/lib/screens/home/home_screen.dart index dc140c2..9f46b32 100644 --- a/lib/screens/home/home_screen.dart +++ b/lib/screens/home/home_screen.dart @@ -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 { ).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'); } }); }, diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 525e380..1073c4b 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -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 createState() => _MainScreenState(); } -class _MainScreenState extends State { +class _MainScreenState extends State with WidgetsBindingObserver { int _currentIndex = 0; final List _screens = [ @@ -24,8 +27,32 @@ class _MainScreenState extends State { const ProfileScreen(), ]; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().fetchUnreadCount(); + FcmService().initialize(context); + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + context.read().fetchUnreadCount(); + } + } + @override Widget build(BuildContext context) { + return Scaffold( extendBody: true, body: PageTransitionSwitcher( diff --git a/lib/screens/notification/notification_screen.dart b/lib/screens/notification/notification_screen.dart index 2dffee3..5ec5eeb 100644 --- a/lib/screens/notification/notification_screen.dart +++ b/lib/screens/notification/notification_screen.dart @@ -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,139 +21,286 @@ class _NotificationScreenState extends State { }); } + 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( - backgroundColor: AppTheme.bgLight, - appBar: AppBar( - title: Text("Notifikasi", style: AppTheme.heading3), + return PopScope( + onPopInvokedWithResult: (didPop, _) { + if (didPop) { + context.read().fetchUnreadCount(); + } + }, + child: Scaffold( 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), - ), - actions: [ - IconButton( - icon: const Icon(Icons.refresh, color: AppTheme.textPrimary), - onPressed: () => context.read().fetchNotifications(), - ) - ], - ), - body: Consumer( - builder: (context, provider, child) { - if (provider.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - - if (provider.errorMessage != null) { - return Center( - child: Text(provider.errorMessage!, style: AppTheme.bodyMedium.copyWith(color: AppTheme.statusRed)), - ); - } - - if (provider.notifications.isEmpty) { - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.notifications_off_outlined, - size: 64, - color: AppTheme.textTertiary.withValues(alpha: 0.3), - ), - const SizedBox(height: 16), - Text( - "Belum ada notifikasi", - style: AppTheme.heading3.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 8), - Text( - "Semua pengumuman terbaru akan muncul di sini.", - style: AppTheme.bodySmall.copyWith(color: AppTheme.textTertiary), - textAlign: TextAlign.center, - ), - ], + appBar: AppBar( + title: Text("Notifikasi", 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), + ), + actions: [ + Consumer( + 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, + ), ), ), - ); - } - - return RefreshIndicator( - onRefresh: () => provider.fetchNotifications(), - child: ListView.separated( - 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( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppTheme.primaryBlue.withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: const Icon(Icons.campaign, color: AppTheme.primaryBlue, 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), - Text( - item.jabatan, - style: AppTheme.bodySmall.copyWith(color: AppTheme.textTertiary), - ), - ], - ), - ], - ), - ), - ); - }, ), - ); - }, + ], + ), + body: Consumer( + builder: (context, provider, child) { + if (provider.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (provider.errorMessage != null) { + return Center( + child: Text( + provider.errorMessage!, + style: AppTheme.bodyMedium.copyWith(color: AppTheme.statusRed), + ), + ); + } + + if (provider.notifications.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + 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: 52, + color: AppTheme.textTertiary.withValues(alpha: 0.3), + ), + ), + const SizedBox(height: 20), + Text( + "Belum ada notifikasi", + style: AppTheme.heading3.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 8), + Text( + "Semua notifikasi terbaru akan muncul di sini.", + style: AppTheme.bodySmall.copyWith(color: AppTheme.textTertiary), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + final grouped = >{}; + 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.builder( + padding: const EdgeInsets.all(AppTheme.spacingMd), + itemCount: orderedKeys.length, + itemBuilder: (context, sectionIndex) { + final key = orderedKeys[sectionIndex]; + final items = grouped[key]!; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(_iconForTipe(item.tipe), color: color, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + 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.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, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + }), + ], + ); + }, + ), + ); + }, + ), ), ); } diff --git a/lib/screens/pengajuan/forms/cuti_form_screen.dart b/lib/screens/pengajuan/forms/cuti_form_screen.dart index fa50cae..0262e00 100644 --- a/lib/screens/pengajuan/forms/cuti_form_screen.dart +++ b/lib/screens/pengajuan/forms/cuti_form_screen.dart @@ -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 { 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 { if (success && mounted) { Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("Pengajuan Cuti Berhasil")), - ); + ErrorHandler.showSuccess('Pengajuan Cuti Berhasil'); } } diff --git a/lib/screens/pengajuan/forms/izin_form_screen.dart b/lib/screens/pengajuan/forms/izin_form_screen.dart index e353494..f0792fd 100644 --- a/lib/screens/pengajuan/forms/izin_form_screen.dart +++ b/lib/screens/pengajuan/forms/izin_form_screen.dart @@ -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 { 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 { }); 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 { final pengajuanProvider = context.watch(); final hasSignature = pengajuanProvider.hasSignature; final isLoading = pengajuanProvider.isLoading; + final sisaCuti = context.watch().sisaCuti; return Scaffold( backgroundColor: AppTheme.bgLight, @@ -151,6 +148,51 @@ class _IzinFormScreenState extends State { ), ), + // 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, diff --git a/lib/screens/pengajuan/forms/lembur_form_screen.dart b/lib/screens/pengajuan/forms/lembur_form_screen.dart index 3324c85..5a0c393 100644 --- a/lib/screens/pengajuan/forms/lembur_form_screen.dart +++ b/lib/screens/pengajuan/forms/lembur_form_screen.dart @@ -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 { if (success && mounted) { Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("Pengajuan Lembur Berhasil Disimpan")), - ); + ErrorHandler.showSuccess('Pengajuan Lembur Berhasil Disimpan'); } } } diff --git a/lib/screens/pengajuan/forms/sakit_form_screen.dart b/lib/screens/pengajuan/forms/sakit_form_screen.dart index cb5174f..796f8e6 100644 --- a/lib/screens/pengajuan/forms/sakit_form_screen.dart +++ b/lib/screens/pengajuan/forms/sakit_form_screen.dart @@ -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 { 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 { 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 { }); 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'); } } } diff --git a/lib/screens/poin/point_usage_screen.dart b/lib/screens/poin/point_usage_screen.dart index 5ef0adf..e61f710 100644 --- a/lib/screens/poin/point_usage_screen.dart +++ b/lib/screens/poin/point_usage_screen.dart @@ -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 { }); 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 { 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(); 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 { 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'); } } diff --git a/lib/screens/presensi/presensi_map_screen.dart b/lib/screens/presensi/presensi_map_screen.dart index 66462a0..4561492 100644 --- a/lib/screens/presensi/presensi_map_screen.dart +++ b/lib/screens/presensi/presensi_map_screen.dart @@ -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,7 +65,11 @@ class _PresensiMapScreenState extends State { if (errorMessage.startsWith("Exception: ")) { errorMessage = errorMessage.substring(11); } - _showErrorSnackBar(errorMessage); + if (errorMessage == 'Layanan lokasi (GPS) tidak aktif. Mohon nyalakan GPS Anda.') { + _showGpsDisabledDialog(); + } else { + _showErrorSnackBar(errorMessage); + } } } finally { if (mounted) { @@ -569,12 +575,7 @@ class _PresensiMapScreenState extends State { 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 { 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 { ); } - 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); + } } diff --git a/lib/screens/profile/edit_profile_screen.dart b/lib/screens/profile/edit_profile_screen.dart index 0a64a49..6d0d702 100644 --- a/lib/screens/profile/edit_profile_screen.dart +++ b/lib/screens/profile/edit_profile_screen.dart @@ -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 { 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'); } } diff --git a/lib/screens/profile/profile_screen.dart b/lib/screens/profile/profile_screen.dart index a6557b7..0a0bb74 100644 --- a/lib/screens/profile/profile_screen.dart +++ b/lib/screens/profile/profile_screen.dart @@ -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 { 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) { diff --git a/lib/screens/profile/signature_screen.dart b/lib/screens/profile/signature_screen.dart index f7f3c1a..907b979 100644 --- a/lib/screens/profile/signature_screen.dart +++ b/lib/screens/profile/signature_screen.dart @@ -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 { Future _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 { 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 { 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 { } 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 { if (confirmed == true && mounted) { final success = await context.read().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'); + } } } } diff --git a/lib/screens/salary/salary_estimator_screen.dart b/lib/screens/salary/salary_estimator_screen.dart deleted file mode 100644 index 84a47e9..0000000 --- a/lib/screens/salary/salary_estimator_screen.dart +++ /dev/null @@ -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(); - 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), - ), - ], - ), - ); - } -} diff --git a/lib/services/api_client.dart b/lib/services/api_client.dart index 866fb66..3d9a447 100644 --- a/lib/services/api_client.dart +++ b/lib/services/api_client.dart @@ -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); }, )); diff --git a/lib/services/fcm_service.dart b/lib/services/fcm_service.dart new file mode 100644 index 0000000..b4a4ca3 --- /dev/null +++ b/lib/services/fcm_service.dart @@ -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 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 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().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 _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 _slideAnimation; + late Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 400), + vsync: this, + ); + _slideAnimation = Tween( + begin: const Offset(0, -1), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); + _fadeAnimation = Tween(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, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/services/reminder_service.dart b/lib/services/reminder_service.dart new file mode 100644 index 0000000..30fba80 --- /dev/null +++ b/lib/services/reminder_service.dart @@ -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 initialize() async { + const androidSettings = + AndroidInitializationSettings('@mipmap/ic_launcher'); + const initSettings = InitializationSettings(android: androidSettings); + await _notifications.initialize(settings: initSettings); + } + + Future 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 _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 _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 cancelAll() async { + await _cancelAllReminders(); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_keyJamMasuk); + await prefs.remove(_keyJamPulang); + } + + List? _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; + } + } +} diff --git a/lib/widgets/atoms/face_status_badge.dart b/lib/widgets/atoms/face_status_badge.dart index b57f162..9261197 100644 --- a/lib/widgets/atoms/face_status_badge.dart +++ b/lib/widgets/atoms/face_status_badge.dart @@ -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; diff --git a/lib/widgets/molecules/quick_menu_card.dart b/lib/widgets/molecules/quick_menu_card.dart index 49cf57a..b18eedb 100644 --- a/lib/widgets/molecules/quick_menu_card.dart +++ b/lib/widgets/molecules/quick_menu_card.dart @@ -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), ], ), ); diff --git a/lib/widgets/organisms/bottom_navbar.dart b/lib/widgets/organisms/bottom_navbar.dart index 32615c0..06d7ad6 100644 --- a/lib/widgets/organisms/bottom_navbar.dart +++ b/lib/widgets/organisms/bottom_navbar.dart @@ -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().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', ), ], ), diff --git a/lib/widgets/organisms/change_password_dialog.dart b/lib/widgets/organisms/change_password_dialog.dart index a097844..6d54d9c 100644 --- a/lib/widgets/organisms/change_password_dialog.dart +++ b/lib/widgets/organisms/change_password_dialog.dart @@ -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 { Future _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 { 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().errorMessage; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(error ?? "Gagal mengubah password")), - ); + ErrorHandler.showError(error ?? 'Gagal mengubah password'); } } } diff --git a/lib/widgets/organisms/home_header.dart b/lib/widgets/organisms/home_header.dart index 8a7def0..49ac781 100644 --- a/lib/widgets/organisms/home_header.dart +++ b/lib/widgets/organisms/home_header.dart @@ -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().unreadCount; + return Container( padding: const EdgeInsets.all(AppTheme.spacingLg), decoration: BoxDecoration( @@ -87,12 +91,40 @@ class HomeHeader extends StatelessWidget { shape: BoxShape.circle, border: Border.all(color: AppTheme.glassWhite20, width: 1), ), - child: IconButton( - icon: const Icon(Icons.notifications_none_rounded), - color: Colors.white, - onPressed: () { - Navigator.pushNamed(context, '/notification'); - }, + 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, + ), + ), + ), + ], ), ), ], diff --git a/lib/widgets/organisms/presensi_detail_sheet.dart b/lib/widgets/organisms/presensi_detail_sheet.dart index 2d262d0..b2b40fd 100644 --- a/lib/widgets/organisms/presensi_detail_sheet.dart +++ b/lib/widgets/organisms/presensi_detail_sheet.dart @@ -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 { Future _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 { 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); diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a883720..207b65d 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) diff --git a/pubspec.lock b/pubspec.lock index 98a6c61..f551d91 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index cebf4c4..62cfcf0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 6a1b9be..41dfcd2 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -14,6 +15,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); GeolocatorWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index e55e969..9f724e6 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -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)