notifikasi+snack bar
This commit is contained in:
parent
664acb389e
commit
e736908004
Binary file not shown.
Binary file not shown.
|
|
@ -3,16 +3,18 @@ plugins {
|
|||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
id("com.google.gms.google-services")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.mpg_mobile"
|
||||
namespace = "com.mp.hris"
|
||||
|
||||
// UBAH 1: Hardcode ke 34 agar support ML Kit terbaru
|
||||
compileSdk = 36
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
|
@ -23,7 +25,7 @@ android {
|
|||
|
||||
defaultConfig {
|
||||
// Pastikan applicationId ini sesuai dengan yang Anda inginkan
|
||||
applicationId = "com.example.mpg_mobile"
|
||||
applicationId = "com.mp.hris"
|
||||
|
||||
// UBAH 2: Ubah minSdk ke 23 (Android 6.0) minimal untuk AI
|
||||
minSdk = flutter.minSdkVersion
|
||||
|
|
@ -48,5 +50,9 @@ flutter {
|
|||
|
||||
// UBAH 4: Tambahkan blok dependencies ini manual di paling bawah
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
implementation("androidx.multidex:multidex:2.0.1")
|
||||
implementation(platform("com.google.firebase:firebase-bom:34.11.0"))
|
||||
implementation("com.google.firebase:firebase-analytics")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,25 @@
|
|||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<application
|
||||
android:label="mpg_mobile"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<!-- FCM: Channel default notifikasi (Android 8+) -->
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="hris_channel" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@mipmap/ic_launcher" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_color"
|
||||
android:resource="@color/notification_color" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.geo.API_KEY"
|
||||
android:value="YOUR_GOOGLE_MAPS_API_KEY_HERE"/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package com.example.mpg_mobile
|
||||
package com.mp.hris
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="notification_color">#130F26</color>
|
||||
</resources>
|
||||
|
|
@ -22,3 +22,4 @@ subprojects {
|
|||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -4,31 +4,92 @@ class ErrorHandler {
|
|||
static final GlobalKey<ScaffoldMessengerState> scaffoldMessengerKey =
|
||||
GlobalKey<ScaffoldMessengerState>();
|
||||
|
||||
static void showError(String message, {int durationSeconds = 3}) {
|
||||
scaffoldMessengerKey.currentState?.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.red.shade600,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: Duration(seconds: durationSeconds),
|
||||
action: SnackBarAction(
|
||||
label: 'Tutup',
|
||||
textColor: Colors.white,
|
||||
onPressed: () {
|
||||
scaffoldMessengerKey.currentState?.hideCurrentSnackBar();
|
||||
},
|
||||
),
|
||||
),
|
||||
static void showSuccess(String message, {int durationSeconds = 3}) {
|
||||
_show(
|
||||
message: message,
|
||||
icon: Icons.check_circle_rounded,
|
||||
bgColor: const Color(0xFF10b981),
|
||||
durationSeconds: durationSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
static void showSuccess(String message, {int durationSeconds = 2}) {
|
||||
static void showError(String message, {int durationSeconds = 4}) {
|
||||
_show(
|
||||
message: message,
|
||||
icon: Icons.error_rounded,
|
||||
bgColor: const Color(0xFFef4444),
|
||||
durationSeconds: durationSeconds,
|
||||
showClose: true,
|
||||
);
|
||||
}
|
||||
|
||||
static void showWarning(String message, {int durationSeconds = 3}) {
|
||||
_show(
|
||||
message: message,
|
||||
icon: Icons.warning_amber_rounded,
|
||||
bgColor: const Color(0xFFf59e0b),
|
||||
durationSeconds: durationSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
static void showInfo(String message, {int durationSeconds = 3}) {
|
||||
_show(
|
||||
message: message,
|
||||
icon: Icons.info_rounded,
|
||||
bgColor: const Color(0xFF3b82f6),
|
||||
durationSeconds: durationSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
static void _show({
|
||||
required String message,
|
||||
required IconData icon,
|
||||
required Color bgColor,
|
||||
int durationSeconds = 3,
|
||||
bool showClose = false,
|
||||
}) {
|
||||
scaffoldMessengerKey.currentState?.hideCurrentSnackBar();
|
||||
scaffoldMessengerKey.currentState?.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.green.shade600,
|
||||
content: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, color: Colors.white, size: 18),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: bgColor,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
elevation: 6,
|
||||
duration: Duration(seconds: durationSeconds),
|
||||
action: showClose
|
||||
? SnackBarAction(
|
||||
label: 'Tutup',
|
||||
textColor: Colors.white.withValues(alpha: 0.9),
|
||||
onPressed: () {
|
||||
scaffoldMessengerKey.currentState?.hideCurrentSnackBar();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
class NotifikasiModel {
|
||||
final int id;
|
||||
final String judul;
|
||||
final String pesan;
|
||||
final String tipe;
|
||||
final bool isRead;
|
||||
final String createdAt;
|
||||
final Map<String, dynamic>? data;
|
||||
|
||||
const NotifikasiModel({
|
||||
required this.id,
|
||||
required this.judul,
|
||||
required this.pesan,
|
||||
required this.tipe,
|
||||
required this.isRead,
|
||||
required this.createdAt,
|
||||
this.data,
|
||||
});
|
||||
|
||||
factory NotifikasiModel.fromJson(Map<String, dynamic> json) {
|
||||
return NotifikasiModel(
|
||||
id: json['id'] ?? 0,
|
||||
judul: json['judul'] ?? '',
|
||||
pesan: json['pesan'] ?? '',
|
||||
tipe: json['tipe'] ?? '',
|
||||
isRead: json['is_read'] == true || json['is_read'] == 1,
|
||||
createdAt: json['created_at'] ?? '',
|
||||
data: json['data'] is Map ? Map<String, dynamic>.from(json['data']) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ class CalendarProvider extends ChangeNotifier {
|
|||
String? get errorMessage => _errorMessage;
|
||||
|
||||
Future<void> fetchMonthlySchedule(int month, int year) async {
|
||||
print("--- FETCHING JADWAL BULAN $month TAHUN $year ---");
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
|
|
@ -23,9 +22,7 @@ class CalendarProvider extends ChangeNotifier {
|
|||
try {
|
||||
final result = await _repository.getMonthlySchedule(month, year);
|
||||
_schedules = result;
|
||||
print("--- SUKSES: DAPAT ${_schedules.length} DATA ---");
|
||||
} catch (e) {
|
||||
print("--- ERROR: $e ---");
|
||||
_errorMessage = e.toString();
|
||||
_schedules = [];
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../repositories/home_repository.dart';
|
||||
import '../models/announcement_model.dart';
|
||||
import '../repositories/notifikasi_repository.dart';
|
||||
import '../../models/notifikasi_model.dart';
|
||||
|
||||
class NotificationProvider extends ChangeNotifier {
|
||||
final HomeRepository _repository = HomeRepository();
|
||||
final NotifikasiRepository _repository = NotifikasiRepository();
|
||||
|
||||
List<AnnouncementModel> notifications = [];
|
||||
List<NotifikasiModel> notifications = [];
|
||||
int unreadCount = 0;
|
||||
bool _isLoading = false;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
|
|
@ -18,8 +19,8 @@ class NotificationProvider extends ChangeNotifier {
|
|||
notifyListeners();
|
||||
|
||||
try {
|
||||
final data = await _repository.getPengumuman();
|
||||
notifications = data;
|
||||
notifications = await _repository.getNotifikasi();
|
||||
unreadCount = notifications.where((n) => !n.isRead).length;
|
||||
_isLoading = false;
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
|
|
@ -27,4 +28,44 @@ class NotificationProvider extends ChangeNotifier {
|
|||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> fetchUnreadCount() async {
|
||||
try {
|
||||
unreadCount = await _repository.getUnreadCount();
|
||||
notifyListeners();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> markAsRead(int id) async {
|
||||
await _repository.markAsRead(id);
|
||||
final idx = notifications.indexWhere((n) => n.id == id);
|
||||
if (idx != -1) {
|
||||
notifications[idx] = NotifikasiModel(
|
||||
id: notifications[idx].id,
|
||||
judul: notifications[idx].judul,
|
||||
pesan: notifications[idx].pesan,
|
||||
tipe: notifications[idx].tipe,
|
||||
isRead: true,
|
||||
createdAt: notifications[idx].createdAt,
|
||||
data: notifications[idx].data,
|
||||
);
|
||||
unreadCount = notifications.where((n) => !n.isRead).length;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> markAllAsRead() async {
|
||||
await _repository.markAllAsRead();
|
||||
notifications = notifications.map((n) => NotifikasiModel(
|
||||
id: n.id,
|
||||
judul: n.judul,
|
||||
pesan: n.pesan,
|
||||
tipe: n.tipe,
|
||||
isRead: true,
|
||||
createdAt: n.createdAt,
|
||||
data: n.data,
|
||||
)).toList();
|
||||
unreadCount = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,52 +36,30 @@ class PengajuanProvider extends ChangeNotifier {
|
|||
if (selectedTabIndex == 2) status = 'rejected';
|
||||
|
||||
final submissions = await _repository.getPengajuan(status: status);
|
||||
print('📋 Submissions count: ${submissions.length}');
|
||||
|
||||
final lemburResult = await _lemburRepository.getLemburHistory();
|
||||
print('🔥 Lembur API result: $lemburResult');
|
||||
|
||||
List<PengajuanModel> lemburList = [];
|
||||
if (lemburResult['success'] == true && lemburResult['data'] != null) {
|
||||
print('✅ Lembur success = true');
|
||||
final dynamic lemburData = lemburResult['data'];
|
||||
print('📦 Lembur data type: ${lemburData.runtimeType}');
|
||||
print('📦 Lembur data: $lemburData');
|
||||
|
||||
List rawData = [];
|
||||
|
||||
if (lemburData is List) {
|
||||
rawData = lemburData;
|
||||
print('✅ Detected as List, count: ${rawData.length}');
|
||||
} else if (lemburData is Map && lemburData['data'] != null) {
|
||||
rawData = lemburData['data'];
|
||||
print('✅ Detected as Map with data, count: ${rawData.length}');
|
||||
}
|
||||
|
||||
print('🔍 Raw lembur count before filter: ${rawData.length}');
|
||||
|
||||
lemburList = rawData
|
||||
.map((json) => PengajuanModel.fromLemburJson(json))
|
||||
.where((item) {
|
||||
print('🔍 Lembur item status: ${item.status}, target: $status');
|
||||
return item.status == status;
|
||||
})
|
||||
.where((item) => item.status == status)
|
||||
.toList();
|
||||
|
||||
print('📊 Lembur count after filter: ${lemburList.length}');
|
||||
} else {
|
||||
print('❌ Lembur failed or no data');
|
||||
print(' success: ${lemburResult['success']}');
|
||||
print(' data: ${lemburResult['data']}');
|
||||
}
|
||||
|
||||
listPengajuan = [...submissions, ...lemburList];
|
||||
print('📊 Total pengajuan count: ${listPengajuan.length}');
|
||||
listPengajuan.sort((a, b) => b.tanggalPengajuan.compareTo(a.tanggalPengajuan));
|
||||
|
||||
} catch (e, stack) {
|
||||
print('❌ Error in fetchPengajuan: $e');
|
||||
print(stack);
|
||||
} catch (e) {
|
||||
// Gagal fetch pengajuan — abaikan error silently
|
||||
} finally {
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import 'dart:convert';
|
|||
import 'package:http/http.dart' as http;
|
||||
import '../core/constants/api_url.dart';
|
||||
import '../core/cache_manager.dart';
|
||||
import '../core/error_handler.dart';
|
||||
|
||||
class FaceRepository {
|
||||
Future<Map<String, dynamic>> enrollFace({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import '../services/api_client.dart';
|
||||
import '../../models/notifikasi_model.dart';
|
||||
|
||||
class NotifikasiRepository {
|
||||
final ApiClient _apiClient = ApiClient();
|
||||
|
||||
Future<List<NotifikasiModel>> getNotifikasi() async {
|
||||
try {
|
||||
final response = await _apiClient.dio.get('/notifikasi');
|
||||
if (response.data['success'] == true) {
|
||||
final List items = response.data['data']['data'] ?? [];
|
||||
return items.map((e) => NotifikasiModel.fromJson(e)).toList();
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> getUnreadCount() async {
|
||||
try {
|
||||
final response = await _apiClient.dio.get('/notifikasi/unread-count');
|
||||
if (response.data['success'] == true) {
|
||||
return response.data['data']['count'] ?? 0;
|
||||
}
|
||||
return 0;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> markAsRead(int id) async {
|
||||
try {
|
||||
await _apiClient.dio.post('/notifikasi/$id/read');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> markAllAsRead() async {
|
||||
try {
|
||||
await _apiClient.dio.post('/notifikasi/read-all');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> saveDeviceToken(String token, {String deviceType = 'android'}) async {
|
||||
try {
|
||||
await _apiClient.dio.post('/device-token', data: {
|
||||
'fcm_token': token,
|
||||
'device_type': deviceType,
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,333 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../core/services/api_config_service.dart';
|
||||
import '../core/constants/api_url.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../widgets/atoms/custom_button.dart';
|
||||
|
||||
class ApiSettingsScreen extends StatefulWidget {
|
||||
const ApiSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ApiSettingsScreen> createState() => _ApiSettingsScreenState();
|
||||
}
|
||||
|
||||
class _ApiSettingsScreenState extends State<ApiSettingsScreen> {
|
||||
String _selectedPreset = 'hostname';
|
||||
final TextEditingController _customUrlController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
String? _currentUrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCurrentConfig();
|
||||
}
|
||||
|
||||
Future<void> _loadCurrentConfig() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final preset = await ApiConfigService.getCurrentPreset();
|
||||
final url = await ApiConfigService.getSavedUrl();
|
||||
setState(() {
|
||||
_selectedPreset = preset;
|
||||
_currentUrl = url;
|
||||
if (preset == 'custom' && url != null) {
|
||||
_customUrlController.text = url;
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveConfig() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
if (_selectedPreset == 'custom') {
|
||||
final customUrl = _customUrlController.text.trim();
|
||||
if (customUrl.isEmpty) {
|
||||
_showMessage('Masukkan URL custom terlebih dahulu', isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!customUrl.startsWith('http://') && !customUrl.startsWith('https://')) {
|
||||
_showMessage('URL harus dimulai dengan http:// atau https://', isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
await ApiConfigService.setCustomUrl(customUrl);
|
||||
} else {
|
||||
await ApiConfigService.setPreset(_selectedPreset);
|
||||
}
|
||||
|
||||
await ApiUrl.reload();
|
||||
|
||||
_showMessage('Konfigurasi API berhasil disimpan!');
|
||||
|
||||
if (mounted) {
|
||||
final shouldRelogin = await _showConfirmDialog(
|
||||
'Koneksi API telah diubah. Apakah Anda ingin logout dan login kembali untuk memastikan koneksi baru berfungsi?',
|
||||
);
|
||||
|
||||
if (shouldRelogin == true && mounted) {
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
authProvider.logout();
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacementNamed('/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_showMessage('Gagal menyimpan konfigurasi: $e', isError: true);
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool?> _showConfirmDialog(String message) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Konfirmasi'),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Tidak'),
|
||||
),
|
||||
CustomButton(
|
||||
text: 'Ya',
|
||||
type: ButtonType.primary,
|
||||
isFullWidth: false,
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showMessage(String message, {bool isError = false}) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: isError ? Colors.red : Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Pengaturan API Server'),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
elevation: 0,
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.blue.shade200),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.blue.shade700),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'URL Server Saat Ini',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue.shade900,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_currentUrl ?? 'Belum dikonfigurasi',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
const Text(
|
||||
'Pilih Konfigurasi',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_buildPresetOption(
|
||||
value: 'hostname',
|
||||
title: 'Hostname (Recommended)',
|
||||
subtitle: 'LAPTOP-I0SUKSKL:8000\nTidak perlu ganti saat pindah WiFi',
|
||||
icon: Icons.computer,
|
||||
iconColor: Colors.green,
|
||||
),
|
||||
|
||||
_buildPresetOption(
|
||||
value: 'emulator',
|
||||
title: 'Android Emulator',
|
||||
subtitle: '10.0.2.2:8000\nUntuk testing di emulator',
|
||||
icon: Icons.phone_android,
|
||||
iconColor: Colors.orange,
|
||||
),
|
||||
|
||||
_buildPresetOption(
|
||||
value: 'custom',
|
||||
title: 'Custom URL',
|
||||
subtitle: 'Masukkan URL sendiri',
|
||||
icon: Icons.edit,
|
||||
iconColor: Colors.blue,
|
||||
),
|
||||
|
||||
if (_selectedPreset == 'custom') ...[
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _customUrlController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'URL Server',
|
||||
hintText: 'http://192.168.1.100:8000/api',
|
||||
prefixIcon: const Icon(Icons.link),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
helperText: 'Contoh: http://192.168.1.6:8000/api',
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.amber.shade200),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.lightbulb_outline, color: Colors.amber.shade700),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Tips',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'• Gunakan "Hostname" agar tidak perlu ganti setting saat pindah WiFi\n'
|
||||
'• Pastikan laptop dan HP terhubung ke WiFi yang sama\n'
|
||||
'• Server Laravel harus running dengan: php artisan serve --host=0.0.0.0',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: CustomButton(
|
||||
text: 'Simpan Konfigurasi',
|
||||
onPressed: _isLoading ? null : _saveConfig,
|
||||
isLoading: _isLoading,
|
||||
type: ButtonType.primary,
|
||||
backgroundColor: Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPresetOption({
|
||||
required String value,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required IconData icon,
|
||||
required Color iconColor,
|
||||
}) {
|
||||
final isSelected = _selectedPreset == value;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isSelected ? Colors.black87 : Colors.grey.shade300,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: isSelected ? Colors.grey.shade50 : Colors.white,
|
||||
),
|
||||
child: RadioListTile<String>(
|
||||
value: value,
|
||||
groupValue: _selectedPreset,
|
||||
onChanged: (newValue) {
|
||||
setState(() => _selectedPreset = newValue!);
|
||||
},
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(icon, color: iconColor, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
),
|
||||
activeColor: Colors.black87,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_customUrlController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../core/error_handler.dart';
|
||||
import '../../core/theme.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../services/fcm_service.dart';
|
||||
import '../../widgets/atoms/custom_text_field.dart';
|
||||
import '../../widgets/atoms/custom_button.dart';
|
||||
|
||||
|
|
@ -32,14 +34,10 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
);
|
||||
|
||||
if (success && mounted) {
|
||||
Navigator.pushReplacementNamed(context, '/onboarding/check');
|
||||
await FcmService().initialize(context);
|
||||
if (mounted) Navigator.pushReplacementNamed(context, '/onboarding/check');
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.read<AuthProvider>().errorMessage ?? "Login Failed"),
|
||||
backgroundColor: AppTheme.statusRed,
|
||||
),
|
||||
);
|
||||
ErrorHandler.showError(context.read<AuthProvider>().errorMessage ?? 'Login Failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,106 +1,11 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../core/theme.dart';
|
||||
import '../../widgets/atoms/fade_in_up.dart';
|
||||
import '../documents/surat_izin_screen.dart';
|
||||
|
||||
class DocumentsScreen extends StatefulWidget {
|
||||
class DocumentsScreen extends StatelessWidget {
|
||||
const DocumentsScreen({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<DocumentsScreen> createState() => _DocumentsScreenState();
|
||||
}
|
||||
|
||||
class _DocumentsScreenState extends State<DocumentsScreen> with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.bgLight,
|
||||
appBar: AppBar(
|
||||
title: Text("Dokumen", style: AppTheme.heading3),
|
||||
backgroundColor: AppTheme.bgLight,
|
||||
elevation: 0,
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: AppTheme.primaryDark,
|
||||
unselectedLabelColor: AppTheme.textSecondary,
|
||||
indicatorColor: AppTheme.primaryDark,
|
||||
tabs: const [
|
||||
Tab(text: "Slip Gaji"),
|
||||
Tab(text: "Surat & Dokumen"),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildSlipGajiList(),
|
||||
_buildSuratList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSlipGajiList() {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingMd),
|
||||
itemCount: 5,
|
||||
itemBuilder: (context, index) {
|
||||
return FadeInUp(
|
||||
delayMs: index * 50,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: AppTheme.spacingMd),
|
||||
padding: const EdgeInsets.all(AppTheme.spacingMd),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||
boxShadow: AppTheme.shadowSm,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.receipt_long, color: AppTheme.primaryDark),
|
||||
const SizedBox(width: AppTheme.spacingMd),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("Slip Gaji - Juli 2025", style: AppTheme.labelLarge),
|
||||
const SizedBox(height: 4),
|
||||
Text("Diterbitkan 25 Jul 2025", style: AppTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.download_rounded, color: AppTheme.textSecondary),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSuratList() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.folder_open, size: 64, color: AppTheme.textTertiary),
|
||||
const SizedBox(height: AppTheme.spacingMd),
|
||||
Text("Belum ada dokumen", style: AppTheme.bodyMedium.copyWith(color: AppTheme.textSecondary)),
|
||||
],
|
||||
),
|
||||
);
|
||||
return const SuratIzinScreen();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../core/error_handler.dart';
|
||||
import '../../core/theme.dart';
|
||||
import '../../providers/home_provider.dart';
|
||||
import '../../providers/poin_provider.dart';
|
||||
|
|
@ -135,12 +136,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
).then((result) {
|
||||
if (result != null && result['success'] == true) {
|
||||
provider.fetchDashboardData();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(result['message'] ?? 'Presensi berhasil'),
|
||||
backgroundColor: AppTheme.statusGreen,
|
||||
),
|
||||
);
|
||||
ErrorHandler.showSuccess(result['message'] ?? 'Presensi berhasil');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../core/theme.dart';
|
||||
import '../providers/notification_provider.dart';
|
||||
import '../services/fcm_service.dart';
|
||||
import 'home/home_screen.dart';
|
||||
import 'profile/profile_screen.dart';
|
||||
import 'presensi/presensi_screen.dart';
|
||||
|
|
@ -14,7 +17,7 @@ class MainScreen extends StatefulWidget {
|
|||
State<MainScreen> createState() => _MainScreenState();
|
||||
}
|
||||
|
||||
class _MainScreenState extends State<MainScreen> {
|
||||
class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
|
||||
int _currentIndex = 0;
|
||||
|
||||
final List<Widget> _screens = [
|
||||
|
|
@ -24,8 +27,32 @@ class _MainScreenState extends State<MainScreen> {
|
|||
const ProfileScreen(),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<NotificationProvider>().fetchUnreadCount();
|
||||
FcmService().initialize(context);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
context.read<NotificationProvider>().fetchUnreadCount();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
return Scaffold(
|
||||
extendBody: true,
|
||||
body: PageTransitionSwitcher(
|
||||
|
|
|
|||
|
|
@ -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<NotificationScreen> {
|
|||
});
|
||||
}
|
||||
|
||||
IconData _iconForTipe(String tipe) {
|
||||
if (tipe.contains('disetujui')) return Icons.check_circle_rounded;
|
||||
if (tipe.contains('ditolak')) return Icons.cancel_rounded;
|
||||
if (tipe.contains('proses')) return Icons.hourglass_top_rounded;
|
||||
if (tipe.contains('pengumuman')) return Icons.campaign_rounded;
|
||||
if (tipe.contains('lembur')) return Icons.schedule_rounded;
|
||||
if (tipe.contains('presensi')) return Icons.fingerprint_rounded;
|
||||
if (tipe.contains('izin')) return Icons.description_rounded;
|
||||
if (tipe.contains('poin')) return Icons.stars_rounded;
|
||||
return Icons.notifications_outlined;
|
||||
}
|
||||
|
||||
Color _colorForTipe(String tipe) {
|
||||
if (tipe.contains('disetujui')) return const Color(0xFF10b981);
|
||||
if (tipe.contains('ditolak')) return const Color(0xFFef4444);
|
||||
if (tipe.contains('pengumuman')) return AppTheme.primaryBlue;
|
||||
if (tipe.contains('lembur')) return const Color(0xFFf59e0b);
|
||||
if (tipe.contains('poin')) return const Color(0xFFe11d48);
|
||||
if (tipe.contains('izin')) return const Color(0xFF8b5cf6);
|
||||
if (tipe.contains('presensi')) return const Color(0xFF06b6d4);
|
||||
return const Color(0xFF6366f1);
|
||||
}
|
||||
|
||||
String _relativeTime(String createdAt) {
|
||||
try {
|
||||
final dt = DateTime.parse(createdAt).toLocal();
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(dt);
|
||||
|
||||
if (diff.inSeconds < 60) return 'Baru saja';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes} menit lalu';
|
||||
if (diff.inHours < 24) return '${diff.inHours} jam lalu';
|
||||
if (diff.inDays == 1) return 'Kemarin';
|
||||
if (diff.inDays < 7) return '${diff.inDays} hari lalu';
|
||||
if (diff.inDays < 30) return '${(diff.inDays / 7).floor()} minggu lalu';
|
||||
|
||||
return '${dt.day.toString().padLeft(2, '0')}/'
|
||||
'${dt.month.toString().padLeft(2, '0')}/'
|
||||
'${dt.year}';
|
||||
} catch (_) {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
String _groupLabel(String createdAt) {
|
||||
try {
|
||||
final dt = DateTime.parse(createdAt).toLocal();
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final yesterday = today.subtract(const Duration(days: 1));
|
||||
final dateOnly = DateTime(dt.year, dt.month, dt.day);
|
||||
|
||||
if (dateOnly == today) return 'Hari Ini';
|
||||
if (dateOnly == yesterday) return 'Kemarin';
|
||||
return 'Lebih Lama';
|
||||
} catch (_) {
|
||||
return 'Lainnya';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.bgLight,
|
||||
appBar: AppBar(
|
||||
title: Text("Notifikasi", style: AppTheme.heading3),
|
||||
return PopScope(
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) {
|
||||
context.read<NotificationProvider>().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<NotificationProvider>().fetchNotifications(),
|
||||
)
|
||||
],
|
||||
),
|
||||
body: Consumer<NotificationProvider>(
|
||||
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<NotificationProvider>(
|
||||
builder: (_, provider, __) => TextButton(
|
||||
onPressed: provider.notifications.any((n) => !n.isRead)
|
||||
? () => provider.markAllAsRead()
|
||||
: null,
|
||||
child: Text(
|
||||
'Baca Semua',
|
||||
style: AppTheme.bodySmall.copyWith(
|
||||
color: provider.notifications.any((n) => !n.isRead)
|
||||
? AppTheme.primaryBlue
|
||||
: AppTheme.textTertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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<NotificationProvider>(
|
||||
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 = <String, List<NotifikasiModel>>{};
|
||||
for (final item in provider.notifications) {
|
||||
final label = _groupLabel(item.createdAt);
|
||||
grouped.putIfAbsent(label, () => []).add(item);
|
||||
}
|
||||
|
||||
final orderedKeys = ['Hari Ini', 'Kemarin', 'Lebih Lama']
|
||||
.where((k) => grouped.containsKey(k))
|
||||
.toList();
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => provider.fetchNotifications(),
|
||||
child: ListView.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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../../core/error_handler.dart';
|
||||
import '../../../core/theme.dart';
|
||||
import '../../../widgets/atoms/custom_button.dart';
|
||||
import '../../../widgets/atoms/custom_text_field.dart';
|
||||
|
|
@ -39,9 +40,7 @@ class _CutiFormScreenState extends State<CutiFormScreen> {
|
|||
|
||||
void _handleSubmit() async {
|
||||
if (_startDate == null || _endDate == null || _reasonController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Semua field wajib diisi")),
|
||||
);
|
||||
ErrorHandler.showWarning('Semua field wajib diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -53,9 +52,7 @@ class _CutiFormScreenState extends State<CutiFormScreen> {
|
|||
|
||||
if (success && mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Pengajuan Cuti Berhasil")),
|
||||
);
|
||||
ErrorHandler.showSuccess('Pengajuan Cuti Berhasil');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../../core/error_handler.dart';
|
||||
import '../../../core/theme.dart';
|
||||
import '../../../widgets/atoms/custom_button.dart';
|
||||
import '../../../widgets/atoms/custom_text_field.dart';
|
||||
import '../../../providers/pengajuan_provider.dart';
|
||||
import '../../../providers/home_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
|
|
@ -58,9 +60,7 @@ class _IzinFormScreenState extends State<IzinFormScreen> {
|
|||
|
||||
if (success && mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Pengajuan Izin Berhasil")),
|
||||
);
|
||||
ErrorHandler.showSuccess('Pengajuan Izin Berhasil');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -95,16 +95,12 @@ class _IzinFormScreenState extends State<IzinFormScreen> {
|
|||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('File dipilih: ${result.files.single.name}')),
|
||||
);
|
||||
ErrorHandler.showInfo('File dipilih: ${result.files.single.name}');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Gagal memilih file')),
|
||||
);
|
||||
ErrorHandler.showError('Gagal memilih file');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -114,6 +110,7 @@ class _IzinFormScreenState extends State<IzinFormScreen> {
|
|||
final pengajuanProvider = context.watch<PengajuanProvider>();
|
||||
final hasSignature = pengajuanProvider.hasSignature;
|
||||
final isLoading = pengajuanProvider.isLoading;
|
||||
final sisaCuti = context.watch<HomeProvider>().sisaCuti;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.bgLight,
|
||||
|
|
@ -151,6 +148,51 @@ class _IzinFormScreenState extends State<IzinFormScreen> {
|
|||
),
|
||||
),
|
||||
|
||||
// Banner sisa cuti
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: sisaCuti > 0
|
||||
? AppTheme.statusGreen.withValues(alpha: 0.08)
|
||||
: AppTheme.statusRed.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||
border: Border.all(
|
||||
color: sisaCuti > 0
|
||||
? AppTheme.statusGreen.withValues(alpha: 0.3)
|
||||
: AppTheme.statusRed.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
sisaCuti > 0 ? Icons.event_available : Icons.event_busy,
|
||||
color: sisaCuti > 0 ? AppTheme.statusGreen : AppTheme.statusRed,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
style: AppTheme.bodySmall.copyWith(
|
||||
color: sisaCuti > 0 ? AppTheme.statusGreen : AppTheme.statusRed,
|
||||
),
|
||||
children: [
|
||||
const TextSpan(text: 'Sisa cuti Anda: '),
|
||||
TextSpan(
|
||||
text: '$sisaCuti hari',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (sisaCuti <= 0)
|
||||
const TextSpan(text: '. Kuota cuti Anda sudah habis.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
CustomTextField(
|
||||
label: "Tanggal Izin",
|
||||
controller: _dateController,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../../core/error_handler.dart';
|
||||
import '../../../core/theme.dart';
|
||||
import '../../../widgets/atoms/custom_text_field.dart';
|
||||
import '../../../widgets/atoms/custom_button.dart';
|
||||
|
|
@ -94,9 +95,7 @@ class _LemburFormScreenState extends State<LemburFormScreen> {
|
|||
|
||||
if (success && mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Pengajuan Lembur Berhasil Disimpan")),
|
||||
);
|
||||
ErrorHandler.showSuccess('Pengajuan Lembur Berhasil Disimpan');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../../core/error_handler.dart';
|
||||
import '../../../core/theme.dart';
|
||||
import '../../../widgets/atoms/custom_button.dart';
|
||||
import '../../../widgets/atoms/custom_text_field.dart';
|
||||
|
|
@ -49,16 +50,12 @@ class _SakitFormScreenState extends State<SakitFormScreen> {
|
|||
|
||||
void _handleSubmit() async {
|
||||
if (_startDate == null || _endDate == null || _diagnosisController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Tanggal dan diagnosis wajib diisi")),
|
||||
);
|
||||
ErrorHandler.showWarning('Tanggal dan diagnosis wajib diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isFileRequired && _uploadedFilePath == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Surat dokter wajib untuk sakit ≥ 2 hari")),
|
||||
);
|
||||
ErrorHandler.showWarning('Surat dokter wajib untuk sakit ≥ 2 hari');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -71,9 +68,7 @@ class _SakitFormScreenState extends State<SakitFormScreen> {
|
|||
|
||||
if (success && mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Pengajuan Sakit Berhasil")),
|
||||
);
|
||||
ErrorHandler.showSuccess('Pengajuan Sakit Berhasil');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -129,16 +124,12 @@ class _SakitFormScreenState extends State<SakitFormScreen> {
|
|||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('File dipilih: ${result.files.single.name}')),
|
||||
);
|
||||
ErrorHandler.showInfo('File dipilih: ${result.files.single.name}');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Gagal memilih file')),
|
||||
);
|
||||
ErrorHandler.showError('Gagal memilih file');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
|||
import 'package:intl/intl.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import '../../providers/poin_provider.dart';
|
||||
import '../../core/error_handler.dart';
|
||||
import '../../core/theme.dart';
|
||||
import '../../widgets/atoms/custom_button.dart';
|
||||
import '../../widgets/atoms/custom_text_field.dart';
|
||||
|
|
@ -94,12 +95,7 @@ class _PointUsageScreenState extends State<PointUsageScreen> {
|
|||
});
|
||||
|
||||
if (!hasSchedule && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(provider.errorMessage ?? 'Jadwal tidak ditemukan'),
|
||||
backgroundColor: AppTheme.statusRed,
|
||||
),
|
||||
);
|
||||
ErrorHandler.showError(provider.errorMessage ?? 'Jadwal tidak ditemukan');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -205,17 +201,13 @@ class _PointUsageScreenState extends State<PointUsageScreen> {
|
|||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
if (_estimasiPoin <= 0 && _selectedJenisId != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Estimasi poin 0 atau tidak valid. Cek jam input.'), backgroundColor: AppTheme.statusRed),
|
||||
);
|
||||
ErrorHandler.showWarning('Estimasi poin 0 atau tidak valid. Cek jam input.');
|
||||
return;
|
||||
}
|
||||
|
||||
final provider = context.read<PoinProvider>();
|
||||
if ((provider.totalPoin ?? 0) < _estimasiPoin) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Saldo poin tidak mencukupi'), backgroundColor: AppTheme.statusRed),
|
||||
);
|
||||
ErrorHandler.showWarning('Saldo poin tidak mencukupi');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -256,12 +248,7 @@ class _PointUsageScreenState extends State<PointUsageScreen> {
|
|||
if (result['success']) {
|
||||
_showSuccessDialog();
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(result['message'] ?? 'Gagal memproses permintaan'),
|
||||
backgroundColor: AppTheme.statusRed,
|
||||
),
|
||||
);
|
||||
ErrorHandler.showError(result['message'] ?? 'Gagal memproses permintaan');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<PresensiMapScreen> {
|
|||
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<PresensiMapScreen> {
|
|||
onPressed: () {
|
||||
final reason = reasonController.text.trim();
|
||||
if (reason.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Alasan wajib diisi!"),
|
||||
backgroundColor: AppTheme.statusRed,
|
||||
),
|
||||
);
|
||||
ErrorHandler.showWarning('Alasan wajib diisi!');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -690,12 +691,7 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
|
|||
onPressed: () {
|
||||
final reason = reasonController.text.trim();
|
||||
if (reason.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Alasan wajib diisi!"),
|
||||
backgroundColor: AppTheme.statusRed,
|
||||
),
|
||||
);
|
||||
ErrorHandler.showWarning('Alasan wajib diisi!');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -847,12 +843,47 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
void _showErrorSnackBar(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: AppTheme.statusRed,
|
||||
void _showGpsDisabledDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.location_off_rounded, color: AppTheme.statusRed, size: 28),
|
||||
const SizedBox(width: 8),
|
||||
Text("GPS Tidak Aktif", style: AppTheme.heading3),
|
||||
],
|
||||
),
|
||||
content: Text(
|
||||
"Layanan lokasi (GPS) tidak aktif. Mohon nyalakan GPS Anda di Pengaturan agar dapat mengambil lokasi kantor.",
|
||||
style: AppTheme.bodyMedium,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
Navigator.pop(context); // Kembali ke menu presensi
|
||||
},
|
||||
child: const Text("Batal"),
|
||||
),
|
||||
CustomButton(
|
||||
text: "Buka Pengaturan",
|
||||
type: ButtonType.primary,
|
||||
isFullWidth: false,
|
||||
onPressed: () async {
|
||||
Navigator.pop(ctx);
|
||||
await Geolocator.openLocationSettings();
|
||||
// Jika user menyalakan dan kembali ke app, fetch locator lagi bisa dipicu manual dgn pencet reload
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showErrorSnackBar(String message) {
|
||||
ErrorHandler.showError(message);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:io';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../../core/error_handler.dart';
|
||||
import '../../core/theme.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../widgets/atoms/custom_avatar.dart';
|
||||
|
|
@ -67,24 +68,10 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
|||
setState(() => _isSaving = false);
|
||||
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Profil berhasil diperbarui'),
|
||||
backgroundColor: AppTheme.statusGreen,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
ErrorHandler.showSuccess('Profil berhasil diperbarui');
|
||||
Navigator.pop(context, true);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(authProvider.errorMessage ?? 'Gagal memperbarui profil'),
|
||||
backgroundColor: AppTheme.statusRed,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
ErrorHandler.showError(authProvider.errorMessage ?? 'Gagal memperbarui profil');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../core/error_handler.dart';
|
||||
import '../../core/theme.dart';
|
||||
import '../../widgets/atoms/custom_avatar.dart';
|
||||
import '../../widgets/organisms/change_password_dialog.dart';
|
||||
|
|
@ -109,13 +110,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||
|
||||
void _copyToClipboard(String text, String label) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('$label berhasil disalin'),
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
ErrorHandler.showInfo('$label berhasil disalin');
|
||||
}
|
||||
|
||||
String _formatTanggalIndonesia(String? dateStr) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:flutter/rendering.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:hand_signature/signature.dart';
|
||||
import '../../core/error_handler.dart';
|
||||
import '../../core/theme.dart';
|
||||
import '../../providers/signature_provider.dart';
|
||||
import '../../widgets/atoms/custom_button.dart';
|
||||
|
|
@ -77,12 +78,7 @@ class _SignatureScreenState extends State<SignatureScreen> {
|
|||
|
||||
Future<void> _saveSignature() async {
|
||||
if (!_hasDrawing) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Silakan gambar tanda tangan terlebih dahulu'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
ErrorHandler.showWarning('Silakan gambar tanda tangan terlebih dahulu');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -90,12 +86,7 @@ class _SignatureScreenState extends State<SignatureScreen> {
|
|||
final imageBytes = await _captureCanvas();
|
||||
if (imageBytes == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Gagal mengkonversi tanda tangan'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
ErrorHandler.showError('Gagal mengkonversi tanda tangan');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -107,15 +98,11 @@ class _SignatureScreenState extends State<SignatureScreen> {
|
|||
final success = await provider.uploadSignature(imageBytes);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(success
|
||||
? 'Tanda tangan berhasil disimpan!'
|
||||
: provider.errorMessage ?? 'Gagal menyimpan'),
|
||||
backgroundColor: success ? AppTheme.statusGreen : AppTheme.statusRed,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
if (success) {
|
||||
ErrorHandler.showSuccess('Tanda tangan berhasil disimpan!');
|
||||
} else {
|
||||
ErrorHandler.showError(provider.errorMessage ?? 'Gagal menyimpan');
|
||||
}
|
||||
if (success) {
|
||||
if (widget.isOnboarding && mounted) {
|
||||
Navigator.pushReplacementNamed(context, '/home');
|
||||
|
|
@ -128,13 +115,7 @@ class _SignatureScreenState extends State<SignatureScreen> {
|
|||
} catch (e) {
|
||||
print('[Signature] Error: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error: $e'),
|
||||
backgroundColor: AppTheme.statusRed,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
ErrorHandler.showError('Error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -162,12 +143,11 @@ class _SignatureScreenState extends State<SignatureScreen> {
|
|||
if (confirmed == true && mounted) {
|
||||
final success = await context.read<SignatureProvider>().deleteSignature();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(success ? 'Tanda tangan berhasil dihapus' : 'Gagal menghapus'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
if (success) {
|
||||
ErrorHandler.showSuccess('Tanda tangan berhasil dihapus');
|
||||
} else {
|
||||
ErrorHandler.showError('Gagal menghapus');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,153 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../core/theme.dart';
|
||||
import '../../providers/home_provider.dart';
|
||||
import '../../widgets/atoms/fade_in_up.dart';
|
||||
|
||||
class SalaryEstimatorScreen extends StatelessWidget {
|
||||
const SalaryEstimatorScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final homeProvider = context.watch<HomeProvider>();
|
||||
final totalAbsence = homeProvider.izinCount + homeProvider.alphaCount;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.bgLight,
|
||||
appBar: AppBar(
|
||||
title: Text("Estimasi Gaji", style: AppTheme.heading3),
|
||||
backgroundColor: AppTheme.bgLight,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, color: AppTheme.textPrimary, size: 20),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingMd),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FadeInUp(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [AppTheme.primaryDark, AppTheme.primaryBlue],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||
boxShadow: AppTheme.shadowMd,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
"Potongan Bulan Ini",
|
||||
style: AppTheme.bodyMedium.copyWith(color: Colors.white.withValues(alpha: 0.8)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"$totalAbsence Hari",
|
||||
style: AppTheme.heading1.copyWith(color: Colors.white, fontSize: 36),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
|
||||
),
|
||||
child: Text(
|
||||
"Berdasarkan data presensi Anda",
|
||||
style: AppTheme.bodySmall.copyWith(color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppTheme.spacingLg),
|
||||
Text("Detail Ketidakhadiran", style: AppTheme.heading3),
|
||||
const SizedBox(height: AppTheme.spacingMd),
|
||||
|
||||
FadeInUp(
|
||||
delayMs: 100,
|
||||
child: _buildDetailItem(
|
||||
Icons.event_busy,
|
||||
"Izin & Sakit",
|
||||
"${homeProvider.izinCount} Hari",
|
||||
AppTheme.statusOrange,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingMd),
|
||||
FadeInUp(
|
||||
delayMs: 200,
|
||||
child: _buildDetailItem(
|
||||
Icons.warning_amber_rounded,
|
||||
"Alpha / Mangkir",
|
||||
"${homeProvider.alphaCount} Hari",
|
||||
AppTheme.statusRed,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBlue.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||
border: Border.all(color: AppTheme.primaryBlue.withValues(alpha: 0.1)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: AppTheme.primaryBlue, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
"Estimasi ini bersifat sementara dan dapat berubah sesuai dengan kebijakan payroll kantor.",
|
||||
style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailItem(IconData icon, String title, String value, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||
boxShadow: AppTheme.shadowSm,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 24),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(title, style: AppTheme.labelLarge),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: AppTheme.heading3.copyWith(color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,294 @@
|
|||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../core/theme.dart';
|
||||
import '../providers/notification_provider.dart';
|
||||
import '../repositories/notifikasi_repository.dart';
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {}
|
||||
|
||||
class FcmService {
|
||||
static final FcmService _instance = FcmService._internal();
|
||||
factory FcmService() => _instance;
|
||||
FcmService._internal();
|
||||
|
||||
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
|
||||
final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin();
|
||||
final NotifikasiRepository _repository = NotifikasiRepository();
|
||||
|
||||
Future<void> initialize(BuildContext context) async {
|
||||
final settings = await _messaging.requestPermission(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
|
||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const initSettings = InitializationSettings(android: androidSettings);
|
||||
await _localNotifications.initialize(
|
||||
settings: initSettings,
|
||||
onDidReceiveNotificationResponse: (details) {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pushNamedAndRemoveUntil('/home', (route) => false);
|
||||
Navigator.of(context).pushNamed('/notification');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (settings.authorizationStatus == AuthorizationStatus.denied) return;
|
||||
|
||||
final token = await _messaging.getToken();
|
||||
if (token != null) await _repository.saveDeviceToken(token);
|
||||
|
||||
_messaging.onTokenRefresh.listen((t) => _repository.saveDeviceToken(t));
|
||||
|
||||
FirebaseMessaging.onMessage.listen((msg) {
|
||||
if (context.mounted) {
|
||||
_showBanner(context, msg);
|
||||
context.read<NotificationProvider>().fetchUnreadCount();
|
||||
}
|
||||
_showSystemNotification(msg);
|
||||
});
|
||||
|
||||
FirebaseMessaging.onMessageOpenedApp.listen((msg) {
|
||||
if (context.mounted) _handleTap(context, msg);
|
||||
});
|
||||
|
||||
final initial = await _messaging.getInitialMessage();
|
||||
if (initial != null) {
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
if (context.mounted) _handleTap(context, initial);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
IconData _iconForTipe(String? tipe) {
|
||||
if (tipe == null) return Icons.notifications_outlined;
|
||||
if (tipe.contains('disetujui')) return Icons.check_circle_rounded;
|
||||
if (tipe.contains('ditolak')) return Icons.cancel_rounded;
|
||||
if (tipe.contains('pengumuman')) return Icons.campaign_rounded;
|
||||
if (tipe.contains('lembur')) return Icons.schedule_rounded;
|
||||
if (tipe.contains('presensi')) return Icons.fingerprint_rounded;
|
||||
if (tipe.contains('izin')) return Icons.description_rounded;
|
||||
if (tipe.contains('poin')) return Icons.stars_rounded;
|
||||
return Icons.notifications_outlined;
|
||||
}
|
||||
|
||||
Color _colorForTipe(String? tipe) {
|
||||
if (tipe == null) return AppTheme.primaryBlue;
|
||||
if (tipe.contains('disetujui')) return const Color(0xFF10b981);
|
||||
if (tipe.contains('ditolak')) return const Color(0xFFef4444);
|
||||
if (tipe.contains('pengumuman')) return AppTheme.primaryBlue;
|
||||
if (tipe.contains('lembur')) return const Color(0xFFf59e0b);
|
||||
if (tipe.contains('poin')) return const Color(0xFFe11d48);
|
||||
return const Color(0xFF6366f1);
|
||||
}
|
||||
|
||||
void _showBanner(BuildContext context, RemoteMessage message) {
|
||||
final title = message.notification?.title ?? '';
|
||||
final body = message.notification?.body ?? '';
|
||||
final tipe = message.data['tipe'] as String?;
|
||||
final color = _colorForTipe(tipe);
|
||||
final icon = _iconForTipe(tipe);
|
||||
|
||||
if (title.isEmpty) return;
|
||||
|
||||
final overlay = Overlay.of(context);
|
||||
late OverlayEntry entry;
|
||||
|
||||
entry = OverlayEntry(
|
||||
builder: (context) => _NotificationBanner(
|
||||
title: title,
|
||||
body: body,
|
||||
icon: icon,
|
||||
color: color,
|
||||
onTap: () {
|
||||
entry.remove();
|
||||
Navigator.of(context).pushNamed('/notification');
|
||||
},
|
||||
onDismiss: () => entry.remove(),
|
||||
),
|
||||
);
|
||||
|
||||
overlay.insert(entry);
|
||||
|
||||
Future.delayed(const Duration(seconds: 5), () {
|
||||
if (entry.mounted) entry.remove();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showSystemNotification(RemoteMessage message) async {
|
||||
final title = message.notification?.title ?? '';
|
||||
final body = message.notification?.body ?? '';
|
||||
if (title.isEmpty) return;
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'hris_channel',
|
||||
'HRIS Notifications',
|
||||
importance: Importance.max,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
const details = NotificationDetails(android: androidDetails);
|
||||
await _localNotifications.show(
|
||||
id: message.hashCode,
|
||||
title: title,
|
||||
body: body,
|
||||
notificationDetails: details,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTap(BuildContext context, RemoteMessage message) {
|
||||
Navigator.of(context).pushNamedAndRemoveUntil('/home', (route) => false);
|
||||
Navigator.of(context).pushNamed('/notification');
|
||||
}
|
||||
}
|
||||
|
||||
class _NotificationBanner extends StatefulWidget {
|
||||
final String title;
|
||||
final String body;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
const _NotificationBanner({
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_NotificationBanner> createState() => _NotificationBannerState();
|
||||
}
|
||||
|
||||
class _NotificationBannerState extends State<_NotificationBanner>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
late Animation<double> _fadeAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
vsync: this,
|
||||
);
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, -1),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));
|
||||
_fadeAnimation = Tween<double>(begin: 0, end: 1).animate(
|
||||
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
|
||||
);
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Positioned(
|
||||
top: topPadding + 8,
|
||||
left: 12,
|
||||
right: 12,
|
||||
child: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
onVerticalDragEnd: (details) {
|
||||
if (details.primaryVelocity != null && details.primaryVelocity! < 0) {
|
||||
_controller.reverse().then((_) => widget.onDismiss());
|
||||
}
|
||||
},
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black26,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: widget.color.withValues(alpha: 0.15)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(widget.icon, color: widget.color, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF1e293b),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.body,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: const Color(0xFF64748b),
|
||||
height: 1.3,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'Lihat',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: widget.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
|
||||
class ReminderService {
|
||||
static final ReminderService _instance = ReminderService._internal();
|
||||
factory ReminderService() => _instance;
|
||||
ReminderService._internal();
|
||||
|
||||
final FlutterLocalNotificationsPlugin _notifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
static const String _keyJamMasuk = 'reminder_jam_masuk';
|
||||
static const String _keyJamPulang = 'reminder_jam_pulang';
|
||||
|
||||
static const int _baseIdMasuk = 1001;
|
||||
static const int _baseIdPulang = 2001;
|
||||
static const int _baseIdLupa = 3001;
|
||||
static const int _daysAhead = 7;
|
||||
static const int _minutesBefore = 15;
|
||||
static const int _minutesAfterForLupa = 30;
|
||||
|
||||
Future<void> initialize() async {
|
||||
const androidSettings =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const initSettings = InitializationSettings(android: androidSettings);
|
||||
await _notifications.initialize(settings: initSettings);
|
||||
}
|
||||
|
||||
Future<void> scheduleWeeklyReminders({
|
||||
required String jadwalJamMasuk,
|
||||
required String jadwalJamPulang,
|
||||
required bool sudahAbsenMasuk,
|
||||
required bool sudahAbsenPulang,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyJamMasuk, jadwalJamMasuk);
|
||||
await prefs.setString(_keyJamPulang, jadwalJamPulang);
|
||||
|
||||
await _cancelAllReminders();
|
||||
|
||||
final masukParts = _parseTime(jadwalJamMasuk);
|
||||
final pulangParts = _parseTime(jadwalJamPulang);
|
||||
|
||||
if (masukParts == null || pulangParts == null) return;
|
||||
|
||||
final jakarta = tz.getLocation('Asia/Jakarta');
|
||||
final now = tz.TZDateTime.now(jakarta);
|
||||
|
||||
for (int i = 0; i < _daysAhead; i++) {
|
||||
final targetDate = now.add(Duration(days: i));
|
||||
final isToday = i == 0;
|
||||
|
||||
if (targetDate.weekday == 6 || targetDate.weekday == 7) continue;
|
||||
|
||||
final reminderMasuk = tz.TZDateTime(
|
||||
jakarta,
|
||||
targetDate.year,
|
||||
targetDate.month,
|
||||
targetDate.day,
|
||||
masukParts[0],
|
||||
masukParts[1],
|
||||
).subtract(Duration(minutes: _minutesBefore));
|
||||
|
||||
if (!isToday || (!sudahAbsenMasuk && reminderMasuk.isAfter(now))) {
|
||||
if (reminderMasuk.isAfter(now)) {
|
||||
await _scheduleNotification(
|
||||
id: _baseIdMasuk + i,
|
||||
title: '⏰ Pengingat Absen Masuk',
|
||||
body: 'Jangan lupa absen masuk! Jadwal masuk: $jadwalJamMasuk',
|
||||
scheduledDate: reminderMasuk,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final reminderPulang = tz.TZDateTime(
|
||||
jakarta,
|
||||
targetDate.year,
|
||||
targetDate.month,
|
||||
targetDate.day,
|
||||
pulangParts[0],
|
||||
pulangParts[1],
|
||||
).subtract(Duration(minutes: _minutesBefore));
|
||||
|
||||
if (!isToday || (!sudahAbsenPulang && reminderPulang.isAfter(now))) {
|
||||
if (reminderPulang.isAfter(now)) {
|
||||
await _scheduleNotification(
|
||||
id: _baseIdPulang + i,
|
||||
title: '⏰ Pengingat Absen Pulang',
|
||||
body: 'Jangan lupa absen pulang! Jadwal pulang: $jadwalJamPulang',
|
||||
scheduledDate: reminderPulang,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final reminderLupa = tz.TZDateTime(
|
||||
jakarta,
|
||||
targetDate.year,
|
||||
targetDate.month,
|
||||
targetDate.day,
|
||||
masukParts[0],
|
||||
masukParts[1],
|
||||
).add(Duration(minutes: _minutesAfterForLupa));
|
||||
|
||||
if (!isToday || (!sudahAbsenMasuk && reminderLupa.isAfter(now))) {
|
||||
if (reminderLupa.isAfter(now)) {
|
||||
await _scheduleNotification(
|
||||
id: _baseIdLupa + i,
|
||||
title: '⚠️ Anda Belum Absen Masuk',
|
||||
body:
|
||||
'Sudah $_minutesAfterForLupa menit lewat jadwal masuk ($jadwalJamMasuk). Segera lakukan absen!',
|
||||
scheduledDate: reminderLupa,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _scheduleNotification({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
required tz.TZDateTime scheduledDate,
|
||||
}) async {
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'attendance_reminder',
|
||||
'Pengingat Absen',
|
||||
channelDescription: 'Pengingat absen masuk dan pulang otomatis',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
styleInformation: BigTextStyleInformation(''),
|
||||
);
|
||||
const details = NotificationDetails(android: androidDetails);
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
id: id,
|
||||
title: title,
|
||||
body: body,
|
||||
scheduledDate: scheduledDate,
|
||||
notificationDetails: details,
|
||||
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _cancelAllReminders() async {
|
||||
for (int i = 0; i < _daysAhead; i++) {
|
||||
await _notifications.cancel(id: _baseIdMasuk + i);
|
||||
await _notifications.cancel(id: _baseIdPulang + i);
|
||||
await _notifications.cancel(id: _baseIdLupa + i);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelAll() async {
|
||||
await _cancelAllReminders();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_keyJamMasuk);
|
||||
await prefs.remove(_keyJamPulang);
|
||||
}
|
||||
|
||||
List<int>? _parseTime(String? timeStr) {
|
||||
if (timeStr == null || timeStr.isEmpty || timeStr == '-') return null;
|
||||
try {
|
||||
final parts = timeStr.split(':');
|
||||
if (parts.length < 2) return null;
|
||||
return [int.parse(parts[0]), int.parse(parts[1])];
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../core/theme.dart';
|
||||
import '../../providers/notification_provider.dart';
|
||||
|
||||
class BottomNavbar extends StatelessWidget {
|
||||
final int currentIndex;
|
||||
|
|
@ -13,6 +15,8 @@ class BottomNavbar extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final unreadCount = context.watch<NotificationProvider>().unreadCount;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryDark,
|
||||
|
|
@ -37,26 +41,72 @@ class BottomNavbar extends StatelessWidget {
|
|||
showSelectedLabels: false,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
elevation: 0,
|
||||
items: const [
|
||||
BottomNavigationBarItem(
|
||||
items: [
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
activeIcon: Icon(Icons.home),
|
||||
label: 'Home',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.calendar_today_outlined),
|
||||
activeIcon: Icon(Icons.calendar_today),
|
||||
label: 'Calendar',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.receipt_long_outlined),
|
||||
activeIcon: Icon(Icons.receipt_long),
|
||||
label: 'Document',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.person_outline),
|
||||
activeIcon: Icon(Icons.person),
|
||||
label: 'Profile',
|
||||
icon: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
const Icon(Icons.notifications_outlined),
|
||||
if (unreadCount > 0)
|
||||
Positioned(
|
||||
right: -4,
|
||||
top: -4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFef4444),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
constraints: const BoxConstraints(minWidth: 14, minHeight: 14),
|
||||
child: Text(
|
||||
unreadCount > 9 ? '9+' : '$unreadCount',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 8, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
activeIcon: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
const Icon(Icons.notifications),
|
||||
if (unreadCount > 0)
|
||||
Positioned(
|
||||
right: -4,
|
||||
top: -4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFef4444),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
constraints: const BoxConstraints(minWidth: 14, minHeight: 14),
|
||||
child: Text(
|
||||
unreadCount > 9 ? '9+' : '$unreadCount',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 8, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
label: 'Notifikasi',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../core/error_handler.dart';
|
||||
import '../../core/theme.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../atoms/custom_button.dart';
|
||||
|
|
@ -29,9 +30,7 @@ class _ChangePasswordDialogState extends State<ChangePasswordDialog> {
|
|||
Future<void> _handleSubmit() async {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
if (_newPasswordController.text != _confirmPasswordController.text) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Password baru tidak cocok")),
|
||||
);
|
||||
ErrorHandler.showWarning('Password baru tidak cocok');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -44,16 +43,12 @@ class _ChangePasswordDialogState extends State<ChangePasswordDialog> {
|
|||
if (success) {
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Password berhasil diubah")),
|
||||
);
|
||||
ErrorHandler.showSuccess('Password berhasil diubah');
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
final error = context.read<AuthProvider>().errorMessage;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(error ?? "Gagal mengubah password")),
|
||||
);
|
||||
ErrorHandler.showError(error ?? 'Gagal mengubah password');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../core/theme.dart';
|
||||
import '../../models/user_model.dart';
|
||||
import '../../providers/notification_provider.dart';
|
||||
import '../atoms/custom_avatar.dart';
|
||||
|
||||
class HomeHeader extends StatelessWidget {
|
||||
final UserModel user;
|
||||
|
||||
const HomeHeader({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.user,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
String _getGreeting() {
|
||||
final hour = DateTime.now().hour;
|
||||
|
|
@ -21,6 +23,8 @@ class HomeHeader extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final unreadCount = context.watch<NotificationProvider>().unreadCount;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingLg),
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -87,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../core/error_handler.dart';
|
||||
import '../../core/theme.dart';
|
||||
import '../../models/presensi_model.dart';
|
||||
import '../../providers/attendance_provider.dart';
|
||||
|
|
@ -68,9 +69,7 @@ class _PresensiDetailSheetState extends State<PresensiDetailSheet> {
|
|||
|
||||
Future<void> _handleResubmit() async {
|
||||
if (_keteranganController.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Keterangan wajib diisi'), backgroundColor: Colors.red),
|
||||
);
|
||||
ErrorHandler.showWarning('Keterangan wajib diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -85,23 +84,11 @@ class _PresensiDetailSheetState extends State<PresensiDetailSheet> {
|
|||
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Presensi berhasil diajukan ulang!'),
|
||||
backgroundColor: AppTheme.statusGreen,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
);
|
||||
ErrorHandler.showSuccess('Presensi berhasil diajukan ulang!');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e.toString().replaceAll('Exception: ', '')),
|
||||
backgroundColor: AppTheme.statusRed,
|
||||
),
|
||||
);
|
||||
ErrorHandler.showError(e.toString().replaceAll('Exception: ', ''));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
96
pubspec.lock
96
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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <geolocator_windows/geolocator_windows.h>
|
||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
|
|
@ -14,6 +15,8 @@
|
|||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
GeolocatorWindowsRegisterWithRegistrar(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue