perbaikan

This commit is contained in:
jouel88 2026-03-29 11:14:09 +07:00
parent e736908004
commit e526b8ff1a
19 changed files with 1979 additions and 640 deletions

View File

@ -38,7 +38,6 @@ import 'screens/notification/notification_screen.dart';
import 'screens/profile/face_test_screen.dart';
import 'screens/profile/signature_screen.dart';
import 'screens/profile/edit_profile_screen.dart';
import 'screens/documents/surat_izin_screen.dart';
import 'screens/onboarding/onboarding_check_screen.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
@ -49,6 +48,8 @@ import 'package:intl/date_symbol_data_local.dart';
import 'package:timezone/data/latest_all.dart' as tz;
import 'services/reminder_service.dart';
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await dotenv.load(fileName: ".env");
@ -85,6 +86,7 @@ class MyApp extends StatelessWidget {
ChangeNotifierProvider(create: (_) => SetupCheckProvider()),
],
child: MaterialApp(
navigatorKey: navigatorKey,
title: 'MPG HRIS',
scaffoldMessengerKey: ErrorHandler.scaffoldMessengerKey,
debugShowCheckedModeBanner: false,
@ -117,7 +119,6 @@ class MyApp extends StatelessWidget {
'/profile/face-test': (context) => const FaceTestScreen(),
'/profile/signature': (context) => const SignatureScreen(),
'/profile/edit': (context) => const EditProfileScreen(),
'/surat-izin': (context) => const SuratIzinScreen(),
},
),
);

View File

@ -1,24 +1,30 @@
class AnnouncementModel {
final int id;
final String title;
final String description;
final String date;
final String jabatan;
final String namaPembuat;
final String? avatarUrl;
AnnouncementModel({
required this.id,
required this.title,
required this.description,
required this.date,
required this.jabatan,
this.namaPembuat = 'Admin',
this.avatarUrl,
});
factory AnnouncementModel.fromJson(Map<String, dynamic> json) {
return AnnouncementModel(
id: json['id'] ?? 0,
title: json['title']?.toString() ?? '',
description: json['description']?.toString() ?? '',
date: json['tanggal']?.toString() ?? '',
jabatan: json['jabatan']?.toString() ?? '',
namaPembuat: json['nama_pembuat']?.toString() ?? 'Admin',
avatarUrl: json['avatar_url']?.toString(),
);
}

View File

@ -10,6 +10,12 @@ class PengajuanModel {
final String? approvedAt;
final String? jamMulai;
final String? jamSelesai;
final String? lampiranUrl;
final bool hasSurat;
final String? idSurat;
final String? namaKompensasi;
final int? durasiMenit;
final int? jumlahPoin;
PengajuanModel({
required this.id,
@ -23,6 +29,12 @@ class PengajuanModel {
this.approvedAt,
this.jamMulai,
this.jamSelesai,
this.lampiranUrl,
this.hasSurat = false,
this.idSurat,
this.namaKompensasi,
this.durasiMenit,
this.jumlahPoin,
});
factory PengajuanModel.fromJson(Map<String, dynamic> json) {
@ -40,6 +52,9 @@ class PengajuanModel {
keterangan: json['alasan'] ?? '',
status: _mapStatus(json['id_status']),
approvedAt: json['approved_at'],
lampiranUrl: json['bukti_file_url'],
hasSurat: json['has_surat'] == true,
idSurat: json['id_surat']?.toString(),
);
}
@ -58,6 +73,15 @@ class PengajuanModel {
approvedAt: json['approved_at'],
jamMulai: json['jam_mulai'],
jamSelesai: json['jam_selesai'],
namaKompensasi: json['kompensasi'] != null
? json['kompensasi']['nama_kompensasi']
: null,
durasiMenit: json['durasi_menit'] != null
? int.tryParse(json['durasi_menit'].toString())
: null,
jumlahPoin: json['jumlah_poin'] != null
? int.tryParse(json['jumlah_poin'].toString())
: null,
);
}

View File

@ -48,7 +48,9 @@ class AttendanceProvider extends ChangeNotifier {
}
Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.bestForNavigation,
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.bestForNavigation,
),
);
await updateLocation(position.latitude, position.longitude);
@ -94,7 +96,10 @@ class AttendanceProvider extends ChangeNotifier {
isLoading = true;
notifyListeners();
try {
historyList = await _repository.getHistory();
historyList = await _repository.getHistory(
month: selectedMonth,
year: selectedYear,
);
} catch (e) {
print('Error fetching history: $e');
} finally {
@ -103,6 +108,18 @@ class AttendanceProvider extends ChangeNotifier {
}
}
void setMonth(int month) {
selectedMonth = month;
notifyListeners();
fetchHistory();
}
void setYear(int year) {
selectedYear = year;
notifyListeners();
fetchHistory();
}
Future<bool> submitPresensi(String type, File photoFile) async {
if (currentLocation == null) {
print('Location not available');
@ -137,11 +154,6 @@ class AttendanceProvider extends ChangeNotifier {
}
}
void setMonth(int month) {
selectedMonth = month;
fetchHistory();
notifyListeners();
}
Future<bool> resubmitPresensi(int idPresensi, String keterangan) async {
isLoading = true;

View File

@ -84,9 +84,12 @@ class AttendanceRepository {
}
}
Future<List<PresensiHistoryModel>> getHistory() async {
Future<List<PresensiHistoryModel>> getHistory({required int month, required int year}) async {
try {
final response = await _client.dio.get('${ApiUrl.baseUrl}/presensi/history');
final response = await _client.dio.get(
'${ApiUrl.baseUrl}/presensi/history',
queryParameters: {'month': month, 'year': year},
);
if (response.statusCode == 200 && response.data['success'] == true) {
final List data = response.data['data']['data'] ?? [];

View File

@ -0,0 +1,232 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../core/theme.dart';
import '../../models/announcement_model.dart';
import '../../widgets/atoms/custom_avatar.dart';
import '../../widgets/atoms/fade_in_up.dart';
class PengumumanDetailScreen extends StatelessWidget {
final AnnouncementModel pengumuman;
const PengumumanDetailScreen({super.key, required this.pengumuman});
String _formatTanggal(String date) {
try {
final dt = DateTime.parse(date);
return DateFormat('EEEE, d MMMM yyyy', 'id_ID').format(dt);
} catch (_) {
return date;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.bgLight,
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 200,
pinned: true,
backgroundColor: AppTheme.primaryBlue,
leading: Padding(
padding: const EdgeInsets.all(8),
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 18),
onPressed: () => Navigator.pop(context),
),
),
),
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF1E3A8A),
Color(0xFF3B82F6),
Color(0xFF6366F1),
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 56, 20, 20),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.campaign_rounded, color: Colors.white, size: 14),
const SizedBox(width: 4),
Text(
'Pengumuman',
style: AppTheme.bodySmall.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(height: 10),
Text(
pengumuman.title,
style: AppTheme.heading2.copyWith(
color: Colors.white,
fontSize: 20,
height: 1.3,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
),
collapseMode: CollapseMode.parallax,
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(AppTheme.spacingMd),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FadeInUp(
delayMs: 0,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
boxShadow: AppTheme.shadowSm,
),
child: Row(
children: [
CustomAvatar(
name: pengumuman.namaPembuat,
imageUrl: pengumuman.avatarUrl,
size: 44,
backgroundColor: AppTheme.primaryBlue.withValues(alpha: 0.1),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pengumuman.namaPembuat,
style: AppTheme.labelLarge.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
pengumuman.jabatan,
style: AppTheme.bodySmall.copyWith(
color: AppTheme.textTertiary,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: AppTheme.primaryBlue.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.calendar_today_rounded, size: 12, color: AppTheme.primaryBlue),
const SizedBox(width: 4),
Text(
_formatTanggal(pengumuman.date),
style: AppTheme.bodySmall.copyWith(
color: AppTheme.primaryBlue,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
],
),
),
],
),
),
),
const SizedBox(height: 16),
FadeInUp(
delayMs: 80,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
boxShadow: AppTheme.shadowSm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 4,
height: 20,
decoration: BoxDecoration(
color: AppTheme.primaryBlue,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Text(
'Isi Pengumuman',
style: AppTheme.heading3.copyWith(fontSize: 14),
),
],
),
const SizedBox(height: 16),
Text(
pengumuman.description,
style: AppTheme.bodyLarge.copyWith(
color: AppTheme.textSecondary,
height: 1.7,
fontSize: 14,
),
),
],
),
),
),
const SizedBox(height: 80),
],
),
),
),
],
),
);
}
}

View File

@ -4,6 +4,7 @@ import '../../core/theme.dart';
import '../../providers/notification_provider.dart';
import '../../models/notifikasi_model.dart';
import '../../widgets/atoms/fade_in_up.dart';
import 'notifikasi_detail_screen.dart';
class NotificationScreen extends StatefulWidget {
const NotificationScreen({super.key});
@ -81,6 +82,18 @@ class _NotificationScreenState extends State<NotificationScreen> {
}
}
void _openDetail(NotifikasiModel item, NotificationProvider provider) {
if (!item.isRead) {
provider.markAsRead(item.id);
}
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => NotifikasiDetailScreen(notifikasi: item),
),
);
}
@override
Widget build(BuildContext context) {
return PopScope(
@ -214,11 +227,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
child: Padding(
padding: const EdgeInsets.only(bottom: AppTheme.spacingSm),
child: GestureDetector(
onTap: () {
if (!item.isRead) {
provider.markAsRead(item.id);
}
},
onTap: () => _openDetail(item, provider),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
@ -277,13 +286,24 @@ class _NotificationScreenState extends State<NotificationScreen> {
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 5),
Text(
Row(
children: [
Expanded(
child: Text(
_relativeTime(item.createdAt),
style: AppTheme.bodySmall.copyWith(
color: AppTheme.textTertiary,
fontSize: 11,
),
),
),
Icon(
Icons.chevron_right_rounded,
size: 16,
color: AppTheme.textTertiary.withValues(alpha: 0.5),
),
],
),
],
),
),
@ -305,3 +325,5 @@ class _NotificationScreenState extends State<NotificationScreen> {
);
}
}

View File

@ -0,0 +1,397 @@
import 'package:flutter/material.dart';
import '../../core/theme.dart';
import '../../models/notifikasi_model.dart';
import '../../widgets/atoms/fade_in_up.dart';
class NotifikasiDetailScreen extends StatelessWidget {
final NotifikasiModel notifikasi;
const NotifikasiDetailScreen({super.key, required this.notifikasi});
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('cuti')) return Icons.beach_access_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('cuti')) return const Color(0xFF06b6d4);
if (tipe.contains('presensi')) return const Color(0xFF06b6d4);
return const Color(0xFF6366f1);
}
String _labelForTipe(String tipe) {
if (tipe.contains('disetujui')) return 'Disetujui';
if (tipe.contains('ditolak')) return 'Ditolak';
if (tipe.contains('proses')) return 'Dalam Proses';
if (tipe.contains('pengumuman')) return 'Pengumuman';
if (tipe.contains('lembur')) return 'Lembur';
if (tipe.contains('presensi')) return 'Presensi';
if (tipe.contains('izin')) return 'Izin';
if (tipe.contains('cuti')) return 'Cuti';
if (tipe.contains('poin')) return 'Poin';
return 'Notifikasi';
}
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 _fullDateTime(String createdAt) {
try {
final dt = DateTime.parse(createdAt).toLocal();
final hari = ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Minggu'];
final bulan = [
'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'
];
return '${hari[dt.weekday - 1]}, ${dt.day} ${bulan[dt.month - 1]} ${dt.year}'
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
} catch (_) {
return createdAt;
}
}
String? _actionRoute(String tipe) {
if (tipe.contains('pengumuman')) return null;
if (tipe.contains('izin') || tipe.contains('cuti') || tipe.contains('lembur') || tipe.contains('sakit')) {
return '/pengajuan';
}
if (tipe.contains('presensi')) return '/presensi';
return null;
}
String? _actionLabel(String tipe) {
if (tipe.contains('izin') || tipe.contains('cuti') || tipe.contains('lembur') || tipe.contains('sakit')) {
return 'Lihat Pengajuan';
}
if (tipe.contains('presensi')) return 'Lihat Riwayat Presensi';
return null;
}
@override
Widget build(BuildContext context) {
final color = _colorForTipe(notifikasi.tipe);
final icon = _iconForTipe(notifikasi.tipe);
final label = _labelForTipe(notifikasi.tipe);
final actionRoute = _actionRoute(notifikasi.tipe);
final actionLabel = _actionLabel(notifikasi.tipe);
return Scaffold(
backgroundColor: AppTheme.bgLight,
appBar: AppBar(
title: Text("Detail Notifikasi", style: AppTheme.heading3),
backgroundColor: AppTheme.bgLight,
elevation: 0,
centerTitle: true,
leading: Padding(
padding: const EdgeInsets.all(8),
child: Container(
decoration: BoxDecoration(
color: AppTheme.bgInput,
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, color: AppTheme.textPrimary, size: 18),
onPressed: () => Navigator.pop(context),
),
),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(AppTheme.spacingMd),
child: Column(
children: [
FadeInUp(
delayMs: 0,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.radiusXl),
boxShadow: AppTheme.shadowMd,
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
color.withValues(alpha: 0.15),
color.withValues(alpha: 0.05),
],
),
shape: BoxShape.circle,
),
child: Icon(icon, color: color, size: 32),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
label,
style: AppTheme.bodySmall.copyWith(
color: color,
fontWeight: FontWeight.w700,
fontSize: 11,
),
),
),
const SizedBox(height: 16),
Text(
notifikasi.judul,
style: AppTheme.heading2.copyWith(fontSize: 18),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.access_time_rounded, size: 13, color: AppTheme.textTertiary),
const SizedBox(width: 4),
Text(
_relativeTime(notifikasi.createdAt),
style: AppTheme.bodySmall.copyWith(
color: AppTheme.textTertiary,
fontSize: 12,
),
),
],
),
],
),
),
),
const SizedBox(height: 16),
FadeInUp(
delayMs: 60,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
boxShadow: AppTheme.shadowSm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 4,
height: 20,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Text(
'Detail Pesan',
style: AppTheme.heading3.copyWith(fontSize: 14),
),
],
),
const SizedBox(height: 16),
Text(
notifikasi.pesan,
style: AppTheme.bodyLarge.copyWith(
color: AppTheme.textSecondary,
height: 1.7,
fontSize: 14,
),
),
const SizedBox(height: 20),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.bgInput,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(Icons.schedule_rounded, size: 14, color: AppTheme.textTertiary),
const SizedBox(width: 8),
Expanded(
child: Text(
_fullDateTime(notifikasi.createdAt),
style: AppTheme.bodySmall.copyWith(
color: AppTheme.textSecondary,
fontSize: 12,
),
),
),
],
),
),
],
),
),
),
if (notifikasi.data != null && notifikasi.data!.isNotEmpty) ...[
const SizedBox(height: 16),
FadeInUp(
delayMs: 120,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
boxShadow: AppTheme.shadowSm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 4,
height: 20,
decoration: BoxDecoration(
color: AppTheme.textTertiary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Text(
'Informasi Tambahan',
style: AppTheme.heading3.copyWith(fontSize: 14),
),
],
),
const SizedBox(height: 12),
...notifikasi.data!.entries
.where((e) => e.key != 'tipe' && e.value != null && e.value.toString().isNotEmpty)
.map((e) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
_formatDataKey(e.key),
style: AppTheme.bodySmall.copyWith(
color: AppTheme.textTertiary,
fontSize: 12,
),
),
),
Expanded(
child: Text(
e.value.toString(),
style: AppTheme.bodyMedium.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],
),
)),
],
),
),
),
],
if (actionRoute != null && actionLabel != null) ...[
const SizedBox(height: 24),
FadeInUp(
delayMs: 160,
child: SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
Navigator.pop(context);
Navigator.pushNamed(context, actionRoute);
},
icon: Icon(
notifikasi.tipe.contains('presensi')
? Icons.fingerprint_rounded
: Icons.description_rounded,
size: 18,
),
label: Text(actionLabel),
style: ElevatedButton.styleFrom(
backgroundColor: color,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
),
elevation: 0,
textStyle: AppTheme.labelLarge.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
),
),
),
],
const SizedBox(height: 40),
],
),
),
);
}
String _formatDataKey(String key) {
return key
.replaceAll('_', ' ')
.split(' ')
.map((w) => w.isNotEmpty ? '${w[0].toUpperCase()}${w.substring(1)}' : '')
.join(' ');
}
}

View File

@ -44,6 +44,12 @@ class _CutiFormScreenState extends State<CutiFormScreen> {
return;
}
final jumlahHari = _endDate!.difference(_startDate!).inDays + 1;
if (jumlahHari > 3) {
ErrorHandler.showWarning('Pengajuan cuti maksimal 3 hari berturut-turut');
return;
}
final success = await context.read<PengajuanProvider>().submitCuti({
'tanggal_mulai': DateFormat('yyyy-MM-dd').format(_startDate!),
'tanggal_selesai': DateFormat('yyyy-MM-dd').format(_endDate!),
@ -79,12 +85,13 @@ class _CutiFormScreenState extends State<CutiFormScreen> {
Future<void> _selectEndDate(DateTime minDate) async {
final DateTime initialDate = _startDate ?? minDate;
final DateTime maxEndDate = initialDate.add(const Duration(days: 2));
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _endDate ?? initialDate,
initialDate: _endDate != null && _endDate!.isAfter(maxEndDate) ? initialDate : (_endDate ?? initialDate),
firstDate: initialDate,
lastDate: DateTime(2100),
helpText: "Pilih Tanggal Selesai",
lastDate: maxEndDate,
helpText: "Pilih Tanggal Selesai (maks 3 hari)",
);
if (picked != null) {
setState(() {
@ -149,7 +156,7 @@ class _CutiFormScreenState extends State<CutiFormScreen> {
const SizedBox(width: 12),
Expanded(
child: Text(
"Cuti harus diajukan minimal 7 hari sebelum tanggal mulai",
"Cuti harus diajukan minimal H-7 dan maksimal 3 hari berturut-turut",
style: AppTheme.bodySmall.copyWith(
color: AppTheme.primaryBlue,
fontWeight: FontWeight.w500,

View File

@ -4,7 +4,7 @@ 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';
@ -110,7 +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,
@ -148,50 +148,6 @@ 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",

View File

@ -317,7 +317,12 @@ class _PointUsageScreenState extends State<PointUsageScreen> {
),
],
),
body: SingleChildScrollView(
body: RefreshIndicator(
onRefresh: () async {
await context.read<PoinProvider>().loadExpiringPoints();
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(AppTheme.spacingMd),
child: Form(
key: _formKey,
@ -568,6 +573,7 @@ class _PointUsageScreenState extends State<PointUsageScreen> {
),
),
),
),
);
}
}

View File

@ -27,6 +27,38 @@ class _PresensiScreenState extends State<PresensiScreen> {
});
}
static const List<String> _monthNames = [
'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember',
];
String _getMonthName(int month) => _monthNames[month - 1];
void _goToPrevMonth(AttendanceProvider p) {
if (p.selectedMonth == 1) {
p.setYear(p.selectedYear - 1);
p.setMonth(12);
} else {
p.setMonth(p.selectedMonth - 1);
}
}
void _goToNextMonth(AttendanceProvider p) {
final now = DateTime.now();
if (p.selectedYear == now.year && p.selectedMonth == now.month) return;
if (p.selectedMonth == 12) {
p.setYear(p.selectedYear + 1);
p.setMonth(1);
} else {
p.setMonth(p.selectedMonth + 1);
}
}
bool _isCurrentMonth(AttendanceProvider p) {
final now = DateTime.now();
return p.selectedYear == now.year && p.selectedMonth == now.month;
}
@override
Widget build(BuildContext context) {
final homeProvider = context.watch<HomeProvider>();
@ -88,10 +120,80 @@ class _PresensiScreenState extends State<PresensiScreen> {
const SizedBox(height: AppTheme.spacingXl),
Text("History Presensi", style: AppTheme.heading3),
const SizedBox(height: 12),
// Month/Year Picker
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.bgWhite,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
boxShadow: AppTheme.shadowSm,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
InkWell(
onTap: () => _goToPrevMonth(attendanceProvider),
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryDark.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: const Icon(Icons.chevron_left, size: 20, color: AppTheme.primaryDark),
),
),
GestureDetector(
onTap: () => _showMonthYearPicker(context, attendanceProvider),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.calendar_month, size: 18, color: AppTheme.primaryDark),
const SizedBox(width: 8),
Text(
"${_getMonthName(attendanceProvider.selectedMonth)} ${attendanceProvider.selectedYear}",
style: AppTheme.labelLarge.copyWith(fontSize: 16, color: AppTheme.primaryDark),
),
],
),
),
InkWell(
onTap: _isCurrentMonth(attendanceProvider) ? null : () => _goToNextMonth(attendanceProvider),
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _isCurrentMonth(attendanceProvider)
? AppTheme.textTertiary.withValues(alpha: 0.1)
: AppTheme.primaryDark.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: Icon(
Icons.chevron_right,
size: 20,
color: _isCurrentMonth(attendanceProvider)
? AppTheme.textTertiary
: AppTheme.primaryDark,
),
),
),
],
),
),
const SizedBox(height: AppTheme.spacingMd),
attendanceProvider.historyList.isEmpty
? Center(
// History List
if (attendanceProvider.isLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: CircularProgressIndicator(),
),
)
else if (attendanceProvider.historyList.isEmpty)
Center(
child: Container(
padding: const EdgeInsets.all(AppTheme.spacingXl),
child: Column(
@ -99,14 +201,15 @@ class _PresensiScreenState extends State<PresensiScreen> {
Icon(Icons.calendar_today, size: 48, color: AppTheme.textTertiary),
const SizedBox(height: AppTheme.spacingMd),
Text(
"Tidak ada data presensi",
"Tidak ada data presensi bulan ini",
style: AppTheme.bodyMedium.copyWith(color: AppTheme.textSecondary),
),
],
),
),
)
: ListView.builder(
else
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: attendanceProvider.historyList.length,
@ -149,104 +252,20 @@ class _PresensiScreenState extends State<PresensiScreen> {
runSpacing: 6,
children: [
if (history.statusValidasi == 3)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.statusRed.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
),
child: Text(
"Ditolak",
style: AppTheme.bodySmall.copyWith(
color: AppTheme.statusRed,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
),
_buildBadge("Ditolak", AppTheme.statusRed),
if (history.statusValidasi == 2)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.statusYellow.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
),
child: Text(
"Menunggu Validasi",
style: AppTheme.bodySmall.copyWith(
color: AppTheme.statusOrange,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
),
_buildBadge("Menunggu Validasi", AppTheme.statusOrange, bgColor: AppTheme.statusYellow),
if (!history.verifikasiWajah && history.statusMasuk != 'Izin' && history.statusMasuk != 'Cuti' && history.statusMasuk != 'Sakit' && history.statusMasuk != 'Alpha')
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.statusOrange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
),
child: Text(
"Wajah Tidak Terverifikasi",
style: AppTheme.bodySmall.copyWith(
color: AppTheme.statusOrange,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
),
_buildBadge("Wajah Tidak Terverifikasi", AppTheme.statusOrange),
if (history.keteranganLuarRadius != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.primaryDark.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
),
child: Text(
"Luar Radius",
style: AppTheme.bodySmall.copyWith(
color: AppTheme.primaryDark,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
),
_buildBadge("Luar Radius", AppTheme.primaryDark),
if (history.statusMasuk != null && history.statusMasuk != '-')
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: _getStatusColor(history.statusMasuk!).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
),
child: Text(
history.statusMasuk!,
style: AppTheme.bodySmall.copyWith(
color: _getStatusColor(history.statusMasuk!),
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
),
_buildBadge(history.statusMasuk!, _getStatusColor(history.statusMasuk!)),
if (history.statusPulang != null &&
history.statusPulang != '-' &&
history.statusPulang != 'Tepat Waktu' &&
history.statusPulang != history.statusMasuk)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: _getStatusColor(history.statusPulang!).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
),
child: Text(
history.statusPulang!,
style: AppTheme.bodySmall.copyWith(
color: _getStatusColor(history.statusPulang!),
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
),
_buildBadge(history.statusPulang!, _getStatusColor(history.statusPulang!)),
],
),
],
@ -289,6 +308,7 @@ class _PresensiScreenState extends State<PresensiScreen> {
);
},
),
const SizedBox(height: 16),
],
),
@ -297,6 +317,152 @@ class _PresensiScreenState extends State<PresensiScreen> {
);
}
Widget _buildBadge(String label, Color color, {Color? bgColor}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: (bgColor ?? color).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
),
child: Text(
label,
style: AppTheme.bodySmall.copyWith(
color: color,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
);
}
void _showMonthYearPicker(BuildContext context, AttendanceProvider provider) {
final now = DateTime.now();
int tempMonth = provider.selectedMonth;
int tempYear = provider.selectedYear;
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setModalState) {
return Container(
decoration: const BoxDecoration(
color: AppTheme.bgWhite,
borderRadius: BorderRadius.vertical(top: Radius.circular(AppTheme.radiusXl)),
),
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40, height: 4,
decoration: BoxDecoration(
color: AppTheme.textTertiary.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 20),
Text("Pilih Bulan", style: AppTheme.heading3),
const SizedBox(height: 16),
// Year selector row
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: () => setModalState(() => tempYear--),
icon: const Icon(Icons.chevron_left),
),
Text("$tempYear", style: AppTheme.labelLarge.copyWith(fontSize: 18)),
IconButton(
onPressed: tempYear >= now.year ? null : () => setModalState(() => tempYear++),
icon: const Icon(Icons.chevron_right),
),
],
),
const SizedBox(height: 12),
// Month grid
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
childAspectRatio: 2.5,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
itemCount: 12,
itemBuilder: (ctx, idx) {
final m = idx + 1;
final isSelected = m == tempMonth && tempYear == provider.selectedYear;
final isFuture = tempYear == now.year && m > now.month;
return GestureDetector(
onTap: isFuture ? null : () {
setModalState(() => tempMonth = m);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: isSelected
? AppTheme.primaryDark
: isFuture
? AppTheme.textTertiary.withValues(alpha: 0.05)
: m == tempMonth
? AppTheme.primaryDark.withValues(alpha: 0.1)
: AppTheme.bgCard,
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
),
alignment: Alignment.center,
child: Text(
_monthNames[idx].substring(0, 3),
style: AppTheme.labelMedium.copyWith(
color: isSelected
? Colors.white
: isFuture
? AppTheme.textTertiary
: m == tempMonth
? AppTheme.primaryDark
: AppTheme.textPrimary,
),
),
),
);
},
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
provider.setYear(tempYear);
provider.setMonth(tempMonth);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryDark,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
),
),
child: Text("Terapkan", style: AppTheme.labelLarge.copyWith(color: Colors.white)),
),
),
],
),
);
},
);
},
);
}
void _showScheduleModal(BuildContext context) {
Navigator.pushNamed(context, '/schedule');
}

View File

@ -348,11 +348,6 @@ class _ProfileScreenState extends State<ProfileScreen> {
label: "Tanda Tangan Digital",
onTap: () => Navigator.pushNamed(context, '/profile/signature'),
),
_buildActionItem(
icon: Icons.description_outlined,
label: "Surat Izin",
onTap: () => Navigator.pushNamed(context, '/surat-izin'),
),
_buildActionItem(
icon: Icons.settings_ethernet,
label: "Pengaturan Server",

View File

@ -57,19 +57,18 @@ class _SignatureScreenState extends State<SignatureScreen> {
Future<Uint8List?> _captureCanvas() async {
try {
// If we want PNG, hand_signature has a way to export to image
final picture = _signatureController.toPicture(
final byteData = await _signatureController.toImage(
width: 1080,
height: 720,
color: const Color(0xFF1E293B),
strokeWidth: 4.5,
background: Colors.white,
fit: true,
border: 20,
format: ImageByteFormat.png,
);
final image = await picture?.toImage(1080, 720);
if (image == null) return null;
final byteData = await image.toByteData(format: ImageByteFormat.png);
return byteData?.buffer.asUint8List();
if (byteData == null) return null;
return byteData.buffer.asUint8List();
} catch (e) {
print('[Signature] Capture error: $e');
return null;

View File

@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import '../core/theme.dart';
import '../providers/notification_provider.dart';
import '../repositories/notifikasi_repository.dart';
import '../main.dart';
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {}
@ -18,7 +19,12 @@ class FcmService {
final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin();
final NotifikasiRepository _repository = NotifikasiRepository();
bool _initialized = false;
Future<void> initialize(BuildContext context) async {
if (_initialized) return;
_initialized = true;
final settings = await _messaging.requestPermission(
alert: true,
badge: true,
@ -29,12 +35,7 @@ class FcmService {
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');
}
},
onDidReceiveNotificationResponse: _onNotificationTap,
);
if (settings.authorizationStatus == AuthorizationStatus.denied) return;
@ -53,17 +54,29 @@ class FcmService {
});
FirebaseMessaging.onMessageOpenedApp.listen((msg) {
if (context.mounted) _handleTap(context, msg);
_navigateToNotification();
});
final initial = await _messaging.getInitialMessage();
if (initial != null) {
Future.delayed(const Duration(seconds: 1), () {
if (context.mounted) _handleTap(context, initial);
_navigateToNotification();
});
}
}
void _onNotificationTap(NotificationResponse details) {
_navigateToNotification();
}
void _navigateToNotification() {
final nav = navigatorKey.currentState;
if (nav != null) {
nav.pushNamedAndRemoveUntil('/home', (route) => false);
nav.pushNamed('/notification');
}
}
IconData _iconForTipe(String? tipe) {
if (tipe == null) return Icons.notifications_outlined;
if (tipe.contains('disetujui')) return Icons.check_circle_rounded;
@ -106,7 +119,7 @@ class FcmService {
color: color,
onTap: () {
entry.remove();
Navigator.of(context).pushNamed('/notification');
_navigateToNotification();
},
onDismiss: () => entry.remove(),
),
@ -137,13 +150,9 @@ class FcmService {
title: title,
body: body,
notificationDetails: details,
payload: 'notification',
);
}
void _handleTap(BuildContext context, RemoteMessage message) {
Navigator.of(context).pushNamedAndRemoveUntil('/home', (route) => false);
Navigator.of(context).pushNamed('/notification');
}
}
class _NotificationBanner extends StatefulWidget {

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../core/theme.dart';
import '../../models/announcement_model.dart';
import '../../screens/home/pengumuman_detail_screen.dart';
import '../atoms/custom_avatar.dart';
class PengumumanCard extends StatelessWidget {
@ -27,7 +28,14 @@ class PengumumanCard extends StatelessWidget {
color: Colors.transparent,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
child: InkWell(
onTap: onTap ?? () {},
onTap: onTap ?? () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PengumumanDetailScreen(pengumuman: pengumuman),
),
);
},
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
child: Padding(
padding: const EdgeInsets.all(AppTheme.spacingMd),
@ -35,7 +43,7 @@ class PengumumanCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomAvatar(
name: "HR",
name: pengumuman.namaPembuat,
imageUrl: pengumuman.avatarUrl,
size: 40,
backgroundColor: AppTheme.primaryOrange.withOpacity(0.1),

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../../core/theme.dart';
import '../../models/pengajuan_model.dart';
import '../atoms/badge_widget.dart';
import 'surat_izin_detail_sheet.dart';
import 'package:intl/intl.dart';
class PengajuanDetailSheet extends StatelessWidget {
@ -81,13 +82,15 @@ class PengajuanDetailSheet extends StatelessWidget {
return ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(AppTheme.radiusXl)),
child: Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
padding: const EdgeInsets.fromLTRB(24, 12, 24, 32),
decoration: const BoxDecoration(
color: AppTheme.bgWhite,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
@ -100,7 +103,12 @@ class PengajuanDetailSheet extends StatelessWidget {
),
),
const SizedBox(height: 20),
Flexible(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
@ -209,6 +217,185 @@ class PengajuanDetailSheet extends StatelessWidget {
),
const SizedBox(height: 8),
],
// Lampiran Section
if (pengajuan.lampiranUrl != null && pengajuan.lampiranUrl!.isNotEmpty) ...[
const Divider(height: 1, color: AppTheme.bgCard),
const SizedBox(height: 14),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.attach_file, size: 20, color: _jenisColor),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Lampiran / Bukti",
style: AppTheme.bodySmall.copyWith(color: AppTheme.textTertiary),
),
const SizedBox(height: 8),
GestureDetector(
onTap: () => _showFullImage(context, pengajuan.lampiranUrl!),
child: Container(
width: double.infinity,
constraints: const BoxConstraints(maxHeight: 200),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(color: AppTheme.textTertiary.withValues(alpha: 0.2)),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
child: Image.network(
pengajuan.lampiranUrl!,
fit: BoxFit.cover,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return Container(
height: 120,
alignment: Alignment.center,
child: CircularProgressIndicator(
value: progress.expectedTotalBytes != null
? progress.cumulativeBytesLoaded / progress.expectedTotalBytes!
: null,
strokeWidth: 2,
),
);
},
errorBuilder: (context, error, stackTrace) {
return Container(
height: 80,
alignment: Alignment.center,
color: AppTheme.bgCard,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.broken_image_outlined, size: 32, color: AppTheme.textTertiary),
const SizedBox(height: 4),
Text(
"Gagal memuat lampiran",
style: AppTheme.bodySmall.copyWith(color: AppTheme.textTertiary),
),
],
),
);
},
),
),
),
),
const SizedBox(height: 6),
Text(
"Ketuk untuk memperbesar",
style: AppTheme.bodySmall.copyWith(
color: AppTheme.primaryBlue,
fontSize: 11,
),
),
],
),
),
],
),
const SizedBox(height: 8),
],
if (pengajuan.hasSurat && pengajuan.idSurat != null) ...[
const Divider(height: 1, color: AppTheme.bgCard),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
Navigator.pop(context);
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => SuratIzinDetailSheet(idSurat: pengajuan.idSurat!),
);
},
icon: const Icon(Icons.description_outlined, size: 20),
label: const Text("Lihat Surat Izin"),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryDark,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
),
elevation: 0,
textStyle: AppTheme.labelLarge.copyWith(fontSize: 14),
),
),
),
const SizedBox(height: 8),
],
],
),
),
),
],
),
),
);
}
void _showFullImage(BuildContext context, String imageUrl) {
showDialog(
context: context,
builder: (ctx) => Dialog(
backgroundColor: Colors.black,
insetPadding: EdgeInsets.zero,
child: Stack(
children: [
InteractiveViewer(
minScale: 0.5,
maxScale: 4.0,
child: Center(
child: Image.network(
imageUrl,
fit: BoxFit.contain,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
},
errorBuilder: (context, error, stackTrace) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.broken_image, color: Colors.white54, size: 48),
const SizedBox(height: 8),
Text(
"Gagal memuat gambar",
style: AppTheme.bodyMedium.copyWith(color: Colors.white54),
),
],
),
);
},
),
),
),
Positioned(
top: MediaQuery.of(ctx).padding.top + 8,
right: 8,
child: IconButton(
onPressed: () => Navigator.pop(ctx),
icon: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
shape: BoxShape.circle,
),
child: const Icon(Icons.close, color: Colors.white, size: 22),
),
),
),
],
),
),

View File

@ -102,14 +102,17 @@ class _PresensiDetailSheetState extends State<PresensiDetailSheet> {
return ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(AppTheme.radiusXl)),
child: Container(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 32),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
padding: EdgeInsets.fromLTRB(24, 12, 24, MediaQuery.of(context).viewInsets.bottom + 32),
decoration: const BoxDecoration(
color: AppTheme.bgWhite,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Drag handle
Center(
child: Container(
width: 40,
@ -122,6 +125,13 @@ class _PresensiDetailSheetState extends State<PresensiDetailSheet> {
),
const SizedBox(height: 20),
// Scrollable content
Flexible(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
@ -479,6 +489,10 @@ class _PresensiDetailSheetState extends State<PresensiDetailSheet> {
],
),
),
),
],
),
),
);
}

View File

@ -0,0 +1,295 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../core/theme.dart';
import '../../providers/surat_izin_provider.dart';
class SuratIzinDetailSheet extends StatefulWidget {
final String idSurat;
const SuratIzinDetailSheet({super.key, required this.idSurat});
@override
State<SuratIzinDetailSheet> createState() => _SuratIzinDetailSheetState();
}
class _SuratIzinDetailSheetState extends State<SuratIzinDetailSheet> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SuratIzinProvider>().fetchDetail(widget.idSurat);
});
}
Color _statusColor(String status) {
switch (status) {
case 'disetujui': return AppTheme.statusGreen;
case 'ditolak': return AppTheme.statusRed;
case 'menunggu_hrd': return AppTheme.primaryDark;
default: return AppTheme.statusYellow;
}
}
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.85,
maxChildSize: 0.95,
minChildSize: 0.5,
builder: (_, scrollController) => Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(AppTheme.radiusXl)),
),
child: Consumer<SuratIzinProvider>(
builder: (context, provider, _) {
final detail = provider.selectedSurat;
return Column(
children: [
Container(
margin: const EdgeInsets.only(top: 12),
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppTheme.textTertiary,
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.all(AppTheme.spacingMd),
child: Row(
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.bgCard,
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
),
child: const Icon(Icons.arrow_back, size: 20, color: AppTheme.textPrimary),
),
),
const SizedBox(width: 12),
Expanded(
child: Text("Detail Surat Izin", style: AppTheme.heading3),
),
if (detail != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: _statusColor(detail.statusSurat).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
),
child: Text(
detail.statusLabel,
style: AppTheme.labelMedium.copyWith(
color: _statusColor(detail.statusSurat),
fontSize: 11,
),
),
),
],
),
),
if (provider.isLoadingDetail)
const Expanded(
child: Center(child: CircularProgressIndicator()),
)
else if (detail == null)
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.description_outlined, size: 48, color: AppTheme.textTertiary),
const SizedBox(height: 12),
Text(
"Gagal memuat detail surat",
style: AppTheme.bodyMedium.copyWith(color: AppTheme.textTertiary),
),
],
),
),
)
else
Expanded(
child: SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: AppTheme.spacingMd),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailSection("Informasi Surat", [
_buildDetailRow("No. Surat", detail.nomorSurat),
_buildDetailRow("Jenis Izin", detail.jenisIzin),
if (detail.tanggalMulai != null) _buildDetailRow("Tanggal Mulai", detail.tanggalMulai!),
if (detail.tanggalSelesai != null) _buildDetailRow("Tanggal Selesai", detail.tanggalSelesai!),
if (detail.alasan != null) _buildDetailRow("Alasan", detail.alasan!),
]),
const SizedBox(height: AppTheme.spacingMd),
if (detail.isiSurat != null) ...[
_buildDetailSection("Isi Surat", []),
Container(
width: double.infinity,
padding: const EdgeInsets.all(AppTheme.spacingMd),
decoration: BoxDecoration(
color: AppTheme.bgCard,
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
),
child: Text(
detail.isiSurat!,
style: AppTheme.bodyMedium.copyWith(height: 1.6),
),
),
const SizedBox(height: AppTheme.spacingMd),
],
_buildDetailSection("Tanda Tangan", []),
_buildSignatureArea("Yang Mengajukan", detail.pengajuNama ?? '-', detail.ttdPengaju),
...detail.approvals.where((a) => a.tahap == 1).map((a) =>
_buildSignatureArea("Manajer", a.approverNama, a.ttdApprover,
status: a.status, catatan: a.catatan),
),
if (!detail.approvals.any((a) => a.tahap == 1))
_buildSignatureArea("Manajer", "Menunggu Approval", null, isPending: true),
...detail.approvals.where((a) => a.tahap == 2).map((a) =>
_buildSignatureArea("HRD", a.approverNama, a.ttdApprover,
status: a.status, catatan: a.catatan),
),
if (!detail.approvals.any((a) => a.tahap == 2))
_buildSignatureArea("HRD", "Menunggu Approval", null, isPending: true),
const SizedBox(height: AppTheme.spacingXl),
],
),
),
),
],
);
},
),
),
);
}
Widget _buildDetailSection(String title, List<Widget> children) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title.toUpperCase(),
style: AppTheme.labelMedium.copyWith(
color: AppTheme.textTertiary,
letterSpacing: 0.5,
),
),
const SizedBox(height: AppTheme.spacingSm),
if (children.isNotEmpty)
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Column(children: children),
),
],
);
}
Widget _buildDetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppTheme.spacingMd, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(label, style: AppTheme.bodySmall.copyWith(color: AppTheme.textTertiary)),
),
const SizedBox(width: 8),
Expanded(
child: Text(value, style: AppTheme.bodyMedium.copyWith(fontWeight: FontWeight.w500)),
),
],
),
);
}
Widget _buildSignatureArea(String label, String name, String? ttdUrl, {
String? status,
String? catatan,
bool isPending = false,
}) {
return Container(
margin: const EdgeInsets.only(bottom: AppTheme.spacingSm),
padding: const EdgeInsets.all(AppTheme.spacingMd),
decoration: BoxDecoration(
color: isPending ? AppTheme.bgCard : Colors.white,
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Column(
children: [
Text(
label,
style: AppTheme.labelMedium.copyWith(color: AppTheme.textTertiary, letterSpacing: 0.3),
),
const SizedBox(height: AppTheme.spacingSm),
SizedBox(
height: 80,
child: ttdUrl != null
? CachedNetworkImage(
imageUrl: ttdUrl,
fit: BoxFit.contain,
placeholder: (_, __) => const SizedBox.shrink(),
errorWidget: (_, __, ___) => Icon(Icons.error_outline, color: AppTheme.textTertiary),
)
: isPending
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.hourglass_empty, color: AppTheme.textTertiary, size: 28),
const SizedBox(height: 4),
Text("Menunggu", style: AppTheme.bodySmall.copyWith(color: AppTheme.textTertiary)),
],
)
: status == 'ditolak'
? Text("DITOLAK", style: AppTheme.labelLarge.copyWith(color: AppTheme.statusRed))
: const SizedBox.shrink(),
),
const SizedBox(height: AppTheme.spacingSm),
Container(
padding: const EdgeInsets.only(top: AppTheme.spacingSm),
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: Color(0xFFE2E8F0))),
),
child: Text(name, style: AppTheme.labelLarge.copyWith(fontSize: 13)),
),
if (catatan != null && catatan.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
"Catatan: $catatan",
style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary, fontStyle: FontStyle.italic),
),
],
],
),
);
}
}