From 8b5dee7b6505c3b86a8e6cbea77d080dea574a8a Mon Sep 17 00:00:00 2001 From: jouel88 Date: Wed, 1 Jul 2026 12:06:50 +0700 Subject: [PATCH] last --- .gitignore | 3 + DESIGN.md | 588 ++++++++++++++++++ android/app/src/main/AndroidManifest.xml | 20 + .../main/res/drawable-hdpi/ic_launcher.png | Bin 0 -> 2499 bytes .../main/res/drawable-mdpi/ic_launcher.png | Bin 0 -> 1427 bytes .../main/res/drawable-xhdpi/ic_launcher.png | Bin 0 -> 3463 bytes .../main/res/drawable-xxhdpi/ic_launcher.png | Bin 0 -> 5645 bytes .../main/res/drawable-xxxhdpi/ic_launcher.png | Bin 0 -> 7864 bytes android/gradle.properties | 7 +- lib/core/constants/api_url.dart | 2 +- lib/core/services/api_config_service.dart | 10 +- lib/main.dart | 26 +- lib/models/announcement_model.dart | 4 +- lib/providers/attendance_provider.dart | 11 + lib/providers/auth_provider.dart | 27 +- lib/providers/calendar_provider.dart | 7 + lib/providers/face_provider.dart | 76 ++- lib/providers/home_provider.dart | 14 + lib/providers/notification_provider.dart | 8 + lib/providers/pengajuan_provider.dart | 68 +- lib/providers/poin_provider.dart | 21 +- lib/providers/signature_provider.dart | 7 + lib/providers/surat_izin_provider.dart | 9 + lib/repositories/auth_repository.dart | 6 + lib/repositories/face_repository.dart | 12 +- lib/screens/home/home_screen.dart | 97 ++- lib/screens/main_screen.dart | 84 +-- .../notifikasi_detail_screen.dart | 2 +- .../onboarding/face_enrollment_screen.dart | 96 +-- .../pengajuan/forms/cuti_form_screen.dart | 3 +- .../pengajuan/forms/izin_form_screen.dart | 11 +- .../pengajuan/forms/lembur_form_screen.dart | 122 +++- .../pengajuan/forms/sakit_form_screen.dart | 8 +- lib/screens/poin/point_usage_screen.dart | 2 +- .../presensi/camera_capture_screen.dart | 324 +++++----- lib/screens/presensi/presensi_map_screen.dart | 91 ++- lib/screens/presensi/presensi_screen.dart | 188 ++++-- lib/screens/profile/face_test_screen.dart | 91 ++- lib/screens/profile/profile_screen.dart | 60 +- lib/screens/splash_screen.dart | 18 +- lib/services/api_client.dart | 10 +- lib/services/fcm_service.dart | 51 +- lib/services/reminder_service.dart | 9 +- lib/services/websocket_service.dart | 98 ++- .../molecules/attendance_time_card.dart | 12 +- lib/widgets/molecules/pengumuman_card.dart | 51 +- .../organisms/change_password_dialog.dart | 98 +-- lib/widgets/organisms/home_header.dart | 175 +++--- 48 files changed, 1896 insertions(+), 731 deletions(-) create mode 100644 DESIGN.md create mode 100644 android/app/src/main/res/drawable-hdpi/ic_launcher.png create mode 100644 android/app/src/main/res/drawable-mdpi/ic_launcher.png create mode 100644 android/app/src/main/res/drawable-xhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/drawable-xxxhdpi/ic_launcher.png diff --git a/.gitignore b/.gitignore index 5b658da..5cd6750 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ app.*.map.json # Firebase configuration (if you want to keep them private) android/app/google-services.json ios/Runner/GoogleService-Info.plist + +# File testing sementara +test_user.dart diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..c4afb48 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,588 @@ +# DESIGN.md — HRIS MP Mobile App (Flutter) + +> Employee Self-Service Mobile Application for Menjadi Pengaruh Group HRIS System + +## ⚠️ CRITICAL: This is a LIGHT THEME mobile application. All backgrounds are white (#FFFFFF) or very light gray (#F8FAFC). Do NOT use dark backgrounds. The dark navy (#0F172A) is ONLY used for small accents: buttons, active tab indicators, and text emphasis. The orange (#E65100) is ONLY used for the active bottom navigation icon. + +--- + +## Brand Identity + +- **App Name**: HRIS MP +- **Company**: Menjadi Pengaruh Group +- **Platform**: Flutter (Android & iOS) +- **Design Mood**: Clean, modern, minimal, professional. Premium SaaS mobile app feel. +- **Inspiration**: Like banking apps or HR apps — clean cards, rounded corners, smooth animations. +- **Frame Size**: 390x844 (iPhone 14 / standard Android) + +--- + +## Typography + +- **Font**: Inter (via Google Fonts package) +- **Heading 1**: 28px, Extra Bold (w800), letter-spacing -1.0, color #0F172A +- **Heading 2**: 22px, Bold (w700), letter-spacing -0.5, color #0F172A +- **Heading 3**: 16px, Bold (w700), letter-spacing -0.3, color #0F172A +- **Body Large**: 15px, Medium (w500), color #0F172A +- **Body Medium**: 13px, Medium (w500), color #0F172A +- **Body Small**: 11px, Medium (w500), color #475569 (secondary gray) +- **Label Large**: 13px, Semibold (w600), color #0F172A +- **Label Medium**: 11px, Medium (w500), color #0F172A + +--- + +## Color Palette + +### IMPORTANT: Light Theme + +**Scaffold/Page background**: #FFFFFF (pure white) +**Content area background**: #F8FAFC (very light gray) +**Card background**: #FFFFFF (white) +**Input field background**: #F1F5F9 (light gray) + +### Primary Colors (used sparingly as accents) +| Name | Hex | Usage | +|------|-----|-------| +| Primary Dark | #0F172A | Buttons, active tabs, text emphasis, avatar bg | +| Primary Orange | #E65100 | Active bottom nav icon color ONLY | +| Primary Blue | #1E3A8A | Action buttons, links, "Lihat Detail" text | +| Secondary Blue | #3B82F6 | Info badges, secondary highlights | + +### Text Colors +| Name | Hex | Usage | +|------|-----|-------| +| Text Primary | #0F172A | Headings, main text, important labels | +| Text Secondary | #475569 | Subtitle, description text | +| Text Tertiary | #94A3B8 | Placeholder, hint text, disabled, captions | + +### Status/Semantic Colors +| Name | Hex | Usage | +|------|-----|-------| +| Status Green | #10B981 | Hadir, Tepat Waktu, Approved, Aktif, sisa cuti | +| Status Yellow | #F59E0B | Menunggu Validasi, Pending | +| Status Red | #EF4444 | Rejected, Alpha, Terlambat, Ditolak | +| Status Orange | #FF5722 | Izin, Sakit, alternative warning | + +### Badge Colors (pastel background + colored text) +| Type | Background | Text | +|------|-----------|------| +| Cuti | #FEE2E2 | #DC2626 | +| Sakit | #FFEDD5 | #EA580C | +| Izin | #DBEAFE | #2563EB | +| Lembur | #F3E8FF | #9333EA | + +### Glass/Overlay Colors +| Name | Value | Usage | +|------|-------|-------| +| Glass White 10% | rgba(255,255,255,0.10) | Frosted glass elements | +| Glass White 20% | rgba(255,255,255,0.20) | Header overlays | +| Glass White 50% | rgba(255,255,255,0.50) | Semi-transparent cards | +| Glass White 70% | rgba(255,255,255,0.70) | Bottom nav background | +| Glass Dark 10% | rgba(0,0,0,0.10) | Bottom sheet barrier | +| Glass Dark 40% | rgba(0,0,0,0.40) | Modal overlay | + +--- + +## Spacing System + +| Token | Value | Usage | +|-------|-------|-------| +| XS | 4px | Tiny gaps | +| SM | 8px | Between related elements | +| MD | 14px | Standard section gap, card padding | +| LG | 20px | Between sections | +| XL | 28px | Major section dividers | +| XXL | 40px | Top/bottom page padding | + +--- + +## Border Radius + +| Token | Value | Usage | +|-------|-------|-------| +| SM | 12px | Input fields, small buttons, tab pills | +| MD | 16px | Cards, dialogs, bottom sheets top corners | +| LG | 20px | Profile cards, attendance cards | +| XL | 24px | Bottom navigation bar, large cards | +| Full | 9999px | Badges, status pills, circular buttons, avatar rings | + +--- + +## Shadows + +### Shadow Small (cards, list items) +- Color: #0F172A at 4% opacity +- Blur: 16px, Offset: 0,4 + +### Shadow Medium (elevated cards, modals) +- Layer 1: #0F172A at 6% opacity, blur 24px, offset 0,12 +- Layer 2: #0F172A at 3% opacity, blur 8px, offset 0,4 + +### Shadow Large (bottom nav, floating elements) +- Color: #0F172A at 8% opacity +- Blur: 40px, Offset: 0,20 + +### Glow Effects (special buttons) +- Primary Glow: #0F172A at 30%, blur 20, offset 0,8 +- Blue Glow: #1E3A8A at 40%, blur 24, offset 0,8 +- Green Glow: #10B981 at 40%, blur 24, offset 0,8 +- Red Glow: #EF4444 at 40%, blur 24, offset 0,8 +- Orange Glow: #FF5722 at 40%, blur 24, offset 0,8 + +--- + +## Navigation Structure + +### Bottom Navigation Bar +- **Position**: Floating at bottom with margin (14px sides, 8px bottom from safe area) +- **Background**: White at 85% opacity with backdrop blur (10px) +- **Border Radius**: 24px (fully rounded pill shape) +- **Shadow**: Large shadow +- **Items** (left to right): + 1. **Beranda** — icon: grid_view_rounded (4-square grid), 24px + 2. **Presensi** — icon: fingerprint, 24px + 3. **Pengajuan** — icon: insert_page_break (document), 24px + 4. **Profil** — icon: person_outline / person_rounded, 24px +- **Active icon color**: #E65100 (orange) +- **Active icon size**: 26px (slightly larger) +- **Inactive icon color**: #94A3B8 (light gray) +- **Show labels**: Only for selected/active item +- **Selected label style**: 12px, Bold (w700) + +### Screen Routing (all screens in the app) +- `/login` → Login Screen +- `/onboarding/check` → Onboarding Check (face enrollment redirect) +- `/onboarding/face` → Face Enrollment Screen +- **Main Screen** (with bottom nav): + - Tab 0: Home Screen (Beranda) + - Tab 1: Presensi Screen (Attendance history) + - Tab 2: Pengajuan Screen (Leave/overtime requests) + - Tab 3: Profile Screen (Profil) +- `/presensi/map` → Presensi Map + Camera (attendance with location) +- `/presensi/camera` → Camera Capture (face photo) +- `/pengajuan/cuti` → Cuti Form +- `/pengajuan/sakit` → Sakit Form +- `/pengajuan/izin` → Izin Form +- `/pengajuan/lembur` → Lembur Form +- `/schedule` → Jadwal/Schedule Screen +- `/notification` → Notification List +- `/notification/detail` → Notification Detail +- `/poin/usage` → Poin Usage (redeem points) +- `/poin/history` → Poin History +- `/profile/edit` → Edit Profile +- `/profile/signature` → Digital Signature +- `/profile/face-test` → Face Test/Re-enrollment +- `/documents/surat-izin` → Surat Izin List +- `/home/pengumuman/detail` → Pengumuman Detail + +--- + +## Components + +### Custom Button +- **Primary**: bg #0F172A (dark navy), text white, rounded 12px, full width, height ~48px +- **Outline**: transparent bg, border 1px #0F172A, text #0F172A +- **Danger**: bg #EF4444, text white +- **Loading state**: Shows CircularProgressIndicator instead of text +- Hover/press: subtle scale animation + +### Custom Text Field +- **Background**: #F1F5F9 (light gray) +- **Border**: none by default, on focus shows #0F172A border +- **Radius**: 12px +- **Label**: above field, 11px semibold, #0F172A +- **Hint text**: #94A3B8 +- **Height**: ~48px +- **Password**: eye icon toggle for visibility +- **Error state**: red border + red error text below + +### Custom Avatar +- Circular image with fallback to initials +- Default fallback: navy gradient circle with white letter +- Border: white ring (3px) +- Sizes: 40px (list), 80px (profile), variable + +### Badge Widget +- Rounded full pill shape +- Pastel colored background with darker text +- Padding: 8px horizontal, 3px vertical +- Text: 10-11px, semibold + +### Menu Card (Pengajuan section) +- Rounded 16px white card with shadow +- Colored icon in rounded square +- Title (bold) + subtitle (gray) text +- Tap animation (bounce/scale) + +### Stat Card +- White card, rounded 20px, shadow +- Icon + label + value layout +- Used for attendance summary stats + +### Bottom Sheet (Detail views) +- Background: transparent (so rounded corners show) +- Sheet: white, top corners rounded 24px +- Drag handle: 40px wide, 4px tall, centered, gray pill +- Barrier: black at 10% opacity (very subtle) + +### Attendance Time Card +- Horizontal card showing Jam Masuk + Jam Keluar + Total Jam +- Green indicators for check-in, red for out +- White card, rounded corners + +--- + +## All Screens (Detailed) + +### 1. SPLASH SCREEN +- Full white background +- Company logo centered +- Loading indicator below logo + +### 2. LOGIN SCREEN +- White background (#FFFFFF) +- Centered vertically, padded 20px horizontal +- Content top to bottom: + 1. Company logo image (height 80px, width 200px, centered) + 2. Gap 40px + 3. Title "Masuk ke Akun" — 16px bold, left-aligned + 4. Gap 20px + 5. Email text field (label "Email", hint "Masukkan Email Anda") + 6. Gap 14px + 7. Password text field (label "Password", hint "Masukkan Password", eye icon toggle) + 8. Gap 28px + 9. "Masuk" button — full width, primary dark navy, rounded 12px +- No bottom navigation bar on this screen + +### 3. ONBOARDING CHECK SCREEN +- Checks if user needs face enrollment +- If not registered → redirects to Face Enrollment +- If ok → redirects to Main Screen + +### 4. FACE ENROLLMENT SCREEN +- Full screen camera for face registration +- Captures 3 poses: front, left, right +- Oval face guide overlay in center (dashed border) +- Step indicator at top: "Pose 1 of 3" +- Instruction text: "Hadap Depan" / "Hadap Kiri" / "Hadap Kanan" +- Capture button at bottom (large circle) +- Preview of captured photos at bottom +- "Kirim untuk Verifikasi" button after all 3 captured + +### 5. HOME SCREEN (Tab: Beranda) +- Background: #F8FAFC +- AppBar: none (custom header) +- Pull-to-refresh enabled +- Content (scrollable, top to bottom): + 1. **Home Header**: User greeting card + - "Selamat Pagi/Siang/Sore," + user name (bold white text) + - Job title + division below (white, smaller) + - Right side: user avatar (circular photo or initial) + - Card: dark navy gradient background (#0F172A), rounded 20px + - Notification bell icon at top-right (with red badge count) + 2. **Poin Expiry Card** (conditional — if points expiring soon): + - Orange/amber warning card + - "X poin akan kadaluarsa pada [date]" + - "Gunakan Sekarang" button + 3. **Poin Lembur Card**: + - White card, rounded 24px, shadow + - Shows current overtime points balance + - "Gunakan" button + 4. **Presensi Section**: + - White card showing today's attendance status + - Jam Masuk + Jam Keluar time display + - Shift info pill badge + - Large "Absen Masuk" or "Absen Keluar" button + - Button color: navy (masuk) or green (pulang) + - If already done both: shows completed state with green checkmark + 5. **Sisa Cuti Card**: + - White card, rounded 24px + - Beach/umbrella icon in light gray-blue rounded square + - "Sisa Cuti Tahunan" label + - Number in green bold (e.g. "12 Hari") + 6. **Pengumuman Terbaru** section: + - "Pengumuman Terbaru" heading (16px bold) + - Horizontal scrolling card list + - Each card: white, rounded 20px, shadow, width ~280px + - Shows: title (bold), content preview (gray, 2 lines), date, priority badge + - If empty: placeholder card with megaphone icon + "Belum ada pengumuman" + 7. Bottom spacer (100px for floating nav bar) +- Bottom Navigation Bar: floating, Beranda active (orange) + +### 6. PRESENSI SCREEN (Tab: Presensi) +- Background: #F8FAFC +- AppBar: "Presensi Karyawan" title (left), shift badge + calendar icon button (right) +- Pull-to-refresh +- Content: + 1. **Face status banner** (conditional): + - If pending: blue info banner "Data wajah sedang proses verifikasi HRD" + - If rejected: red warning banner "Registrasi wajah ditolak, lakukan ulang" + 2. **Presensi Section** (same as Home — today's attendance card with action button) + 3. **"History Presensi"** heading + 4. **Month/Year Picker Bar**: + - White card, rounded 16px, shadow + - Left: ← arrow circle button + - Center: calendar icon + "Mei 2025" (tappable → opens bottom sheet month picker) + - Right: → arrow circle button (disabled if current month) + 5. **History List** (vertical scrolling): + - Each item: white card, rounded 20px, shadow, margin-bottom 14px + - Content: Date with calendar icon → status badges (pills) → Attendance time card (Jam Masuk/Keluar/Total) → "Lihat Detail" text link (blue) + - Tapping opens bottom sheet detail view + 6. **Month Picker Bottom Sheet** (when tapped): + - White, top rounded 24px + - Drag handle pill + - "Pilih Bulan" title + - Year selector: ← [2025] → + - 3x4 grid of month buttons (Jan-Des) + - Active month: navy bg, white text + - Future months: disabled/grayed out + - "Terapkan" button at bottom (full width, navy) +- Bottom Nav: Presensi active (orange fingerprint icon) + +### 7. PRESENSI MAP SCREEN (Attendance with Location) +- Full screen map showing user location + office radius +- Top: Back arrow + "Absen Masuk/Pulang" title +- Map: Google Maps with user marker + office circle radius +- Bottom card (white, rounded top 24px): + - Current location address + - Distance from office + - Status: "Dalam Radius" (green) or "Luar Radius" (red) + - "Lanjut Foto Wajah" button (navy, full width) + +### 8. CAMERA CAPTURE SCREEN (Face Recognition) +- Full screen camera viewfinder +- Oval face guide overlay (scanner overlay) in center with animated border +- Instruction text overlay: "Posisikan wajah di dalam area" +- Top: back arrow + title +- Bottom: Large capture button (circle) +- After capture: preview + confirm/retake buttons + +### 9. PENGAJUAN SCREEN (Tab: Pengajuan) +- Background: #F8FAFC +- AppBar: "Pengajuan" title (centered) +- Pull-to-refresh +- Content: + 1. **Quick Action Menu Cards** (top section): + - Full width card: "Cuti / Time Off" — beach icon, bg #FEE2E2, icon #DC2626 + - 3-column row below: + - "Sakit" — hospital icon, bg #FFEDD5, icon #EA580C + - "Izin" — event-busy icon, bg #DBEAFE, icon #2563EB + - "Lembur" — timer icon, bg #F3E8FF, icon #9333EA + - Each card: colored bg, matching icon, title + subtitle, rounded 16px + 2. **Tab Selector** (pill-shaped segmented control): + - White container, rounded-full, padding 4px + - 3 tabs: "Approved" | "Pending" | "Rejected" + - Active tab: navy bg (#0F172A), white text + - Inactive tab: transparent, gray text + 3. **Pengajuan List**: + - Cards for each submission + - Each card shows: type icon + type label, date range, status badge, description + - Tapping opens bottom sheet detail + - Empty state: inbox icon + "Belum ada pengajuan" text +- Bottom Nav: Pengajuan active (orange document icon) + +### 10. FORM — Pengajuan Cuti +- AppBar: back arrow + "Pengajuan Cuti" title +- White background +- Form fields: + - Tanggal Mulai (date picker) + - Tanggal Selesai (date picker) + - Durasi (auto-calculated, read-only) + - Alasan (textarea, multi-line) +- "Ajukan Cuti" button (full width, navy) + +### 11. FORM — Pengajuan Sakit +- AppBar: back arrow + "Pengajuan Sakit" title +- Form fields: + - Tanggal Mulai (date picker) + - Tanggal Selesai (date picker) + - Keluhan/Diagnosa (textarea) + - Upload Surat Dokter (file/image picker with preview) +- "Ajukan Izin Sakit" button + +### 12. FORM — Pengajuan Izin +- AppBar: back arrow + "Pengajuan Izin" title +- Form fields: + - Jenis Izin (dropdown) + - Tanggal (date picker) + - Alasan (textarea) + - Upload Bukti (optional file picker) +- "Ajukan Izin" button + +### 13. FORM — Pengajuan Lembur +- AppBar: back arrow + "Pengajuan Lembur" title +- Form fields: + - Tanggal (date picker) + - Jam Mulai (time picker) + - Jam Selesai (time picker) + - Keterangan (textarea) +- "Ajukan Lembur" button + +### 14. SCHEDULE SCREEN (Jadwal Kerja) +- AppBar: back arrow + "Jadwal Saya" title +- Month/Year picker (same component as Presensi history) +- Calendar month grid view: + - Day headers: Sen-Min + - Each day cell: date number + shift pill badge (colored) + - Today: highlighted circle + - Tapping day shows shift detail +- Legend: colored dots with shift names +- Bottom: Upcoming schedule list + +### 15. NOTIFICATION SCREEN +- AppBar: back arrow + "Notifikasi" title +- "Tandai Semua Dibaca" button at top +- Notification list: + - Each item: colored icon in rounded square + title (bold) + message preview + timestamp + - Unread: light blue tint background + blue dot + - Read: white background + - Tapping goes to detail or relevant screen + - Types: Presensi, Pengajuan Izin, Lembur, Pengumuman, Face Enrollment + +### 16. NOTIFICATION DETAIL +- AppBar: back arrow + "Detail Notifikasi" title +- White card: + - Icon + type badge + - Title (heading 3) + - Full message content + - Timestamp + - Action button to navigate to related screen + +### 17. POIN USAGE SCREEN (Tukar Poin Lembur) +- AppBar: back arrow + "Gunakan Poin" title +- Current points balance card (large number, green) +- Usage options/form: + - Jenis penggunaan + - Jumlah poin + - Keterangan +- "Gunakan Poin" submit button +- History section below + +### 18. POIN HISTORY SCREEN +- AppBar: back arrow + "Riwayat Poin" title +- List of point transactions +- Each item: date, description, +/- points, status badge + +### 19. PROFILE SCREEN (Tab: Profil) +- Background: #FFFFFF +- AppBar: "Profil Karyawan" (centered) +- Pull-to-refresh +- Content: + 1. **Profile Card** (white, rounded 20px, shadow): + - Avatar (80px, circular photo or initials) + - Name (20px bold, centered) + - "Divisi • Jabatan" (13px gray, centered) + - Status badge: "Karyawan Aktif" green pill with dot + 2. **"INFORMASI PRIBADI"** section label (11px uppercase, gray, letter-spaced) + 3. **Info Card** (white, rounded 20px, shadow): + - Rows of: icon (24px, navy) + label (11px gray) / value (13px dark) + - Items: Email, No. Telepon, ID Karyawan (with copy button), Divisi, Jabatan, Kantor, Alamat, Bergabung Sejak, Sisa Cuti, Peran (role badges) + 4. **"PENGATURAN AKUN"** section label + 5. **Actions Card** (white, rounded 20px, shadow): + - ListTile items with icon + label + chevron: + - "Edit Profil" (edit icon) + - "Registrasi Wajah" (face icon) + status text (Terverifikasi green / Menunggu HRD yellow / Ditolak red / Belum Terdaftar gray) + - "Ubah Kata Sandi" (lock icon) + - "Tanda Tangan Digital" (draw icon) + - "Keluar" (logout icon, RED text + RED icon) + 6. **Footer**: "Perangkat: Samsung Galaxy S21" + "App Version 1.0.0+1" (tiny gray text, centered) + 7. Bottom spacer (80px for nav bar) +- Bottom Nav: Profil active (orange person icon) + +### 20. EDIT PROFILE SCREEN +- AppBar: back arrow + "Edit Profil" title +- Form fields: + - Nama Lengkap + - No. Telepon + - Alamat (textarea) + - Photo upload (avatar picker) +- "Simpan Perubahan" button + +### 21. SIGNATURE SCREEN (Tanda Tangan Digital) +- AppBar: back arrow + "Tanda Tangan Digital" title +- Canvas drawing area (white card with border) +- Clear button +- "Simpan Tanda Tangan" button +- Preview of existing signature if already saved + +### 22. FACE TEST SCREEN (Re-registration) +- Similar to Face Enrollment +- Camera with face overlay +- 3 pose capture +- Submit for verification + +### 23. PENGUMUMAN DETAIL SCREEN +- AppBar: back arrow + "Detail Pengumuman" +- White card: + - Priority badge (Tinggi red/Sedang orange/Rendah blue) + - Title (heading 2, bold) + - Date published + - Full content (rendered text, may include images) + - Author info + +### 24. SURAT IZIN SCREEN (Document List) +- AppBar: back arrow + "Surat Izin" title +- List of surat izin documents +- Each card: type, date, status badge, tappable for detail +- Bottom sheet detail view on tap + +### 25. CHANGE PASSWORD DIALOG +- Modal dialog (AlertDialog) +- Rounded 16px +- Fields: Password Lama, Password Baru, Konfirmasi Password +- "Ubah Password" button (navy) + "Batal" (gray text) + +### 26. PRESENSI DETAIL BOTTOM SHEET +- Bottom sheet with rounded top 24px +- Drag handle +- Full attendance detail: + - Date + day name + - Shift info + - Jam Masuk + Jam Keluar (large time display) + - Face verification status (photo thumbnails if available) + - Location info (map thumbnail) + - Status badges + - Validation status + +### 27. PENGAJUAN DETAIL BOTTOM SHEET +- Bottom sheet, rounded top 24px +- Submission details: + - Type + status badge + - Date range + - Full description/reason + - Attachment preview (if any) + - Submitted date + - Approval status timeline + +--- + +## Animations & Interactions + +- **FadeInUp**: List items animate in with stagger delay (50ms per item), opacity + translateY +- **BouncyTap**: Cards scale down slightly on tap, spring back +- **Pull-to-refresh**: Standard Material indicator +- **Bottom sheet**: Slide up with drag handle +- **Tab switching**: Smooth color transition on segmented control +- **Shimmer loading**: Animated gradient placeholder for all loading states (Home, Profile, Lists, Schedule) +- **Page transitions**: Standard Material page route transitions +- **Badge count**: Animated appearance on notification bell + +--- + +## Loading States + +Every screen has a shimmer loading placeholder: +- **Home Shimmer**: Mimics header card + stat cards + list items with gray animated blocks +- **Profile Shimmer**: Avatar circle + text lines + card shapes +- **List Shimmer**: Repeated card-shaped blocks +- **Schedule Shimmer**: Calendar grid placeholder + +--- + +## Empty States + +Each list has an empty state with: +- Large gray icon (48-56px, 50% opacity) +- Descriptive title text (13px, medium gray) +- Subtitle text (11px, light gray) +- Optional action button diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index bfdd304..8fdcfd0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,10 @@ + + + + + + + + + @@ -54,6 +63,17 @@ android:name="com.yalantis.ucrop.UCropActivity" android:theme="@style/Theme.AppCompat.Light.NoActionBar" /> + + + + + + + + + + + %z|sIW8lX1Cq(h2R`urKZ%a+V9-b~*qE`6j8*1rq zBYMpoR(i97AFtJEO=V@UV50b|^J45WfgismbRiu75zbM{&W-$OOVs_FnUbOeGxC-_ zj5NSai_;aWwJ?*CJ9ZU?DAZ2}H2{w`$BQesTDHeNad!!xicj!fvW}+qwRMAg)F0bN zkcv8X#pRZkzJCON9e6ZmctvLsAA?aLpSS#-#ec&3x&FEuw%%`~IaZ69Yz$0p;8a&5eig#EgJhv!a%KazjurkSu-)I4Thpb&F;`1#; z`K8mZ!!TB(BoFA6pQaQsU-O>k6yn8{X)d2XUq%bLjjdm0QY0|^9+6k;0@ne&*W;*k zZLt8BBs5zj-^Saw$|$Av68ah3unFjPEXfhU5l`rSd-u3yV>iUpZD z@IGAL*_1<1*l74UW0;;TlX6~Ez;9}0*n3#kY`*=VFZ|?Bia;bUmrPio<_XCB6trySgm_aD+d9=>#3z32Fo=sYL~mW?*l|E(C)79D;y_MPjo<){I2!b(Kn zh6YD>z9y^F`)y&mHC63z@W`56)sVIiAp3>RxS^8`z_2^j%P4%~%0A7okTP2FPIK$= zDx@dT`B@A#1>Gya_RG)g4LG(KE{^Sq?>>6fyTw|S@zFF`9P3cfDAY`I?8Mu))cXz( zy2L#e3ezyMlwfrAMwgSD-XtC0kid40avp|`P#hpWFW!!A?)h56wG2`jB#+YP1W!}D zfW3(YLJ$E4McwV04mRgEsbRUZyLsFp{)_{O@Njf6p60i$0=HPi@NkO9tD+5XBUE22 zu$nA#XaU(NmNw7cQDM_|-w!w4S+#axso91_!Mt72*pMRmVw&_O(zLK*P@E-RpXzeE zXZ9wc^snXw>{Tl$uYCJR+>xwO_1L*0d$u@RNH6~(+I{0%ran!WG%9E@tIZu-Cli<< zl|DX>LkX2PdRrfp)lEedv#(3kP5LA80qEDpd#y!CuHqskk(u^u^uRS+b_KcnFTH=~ zms3b{399Jqlr*Vz|G0i;zno%@{=yTeAkwX#w}jvUyq0+oSe;Ts_5ah+#xkrl9p=>S z>S@zPk5Wz5umj}>Ftz$7Sq+{fj))=GiMpb3X1Tg@>e6bl(7vteii-{(k2H3Si`Uwv z@Cr?N8?0YIEVtiR))_rlh-@NUI-SZ~P=HJd;CgQKWRxu44-By=*FUc9YhR5$AFnx) z+BC4tPTqz?V;Y{-OZVqaIk9p6R|u%WP}7M#G^Ec8<8f(IRo48SJg+f4E(%U+J98=Y z;%It?@8-HzdHuL@-gCEg|4>L049pR9HnGg=#WJS=iaELLmK@^)Lp|I&JACy7%dKy^ zt_;?196NJa{%ojleh>|}6C+huxq>L;2QAM;{dlJNF@;k4&#KAPz_xAZ@I<#ru-{M? z>Bh;qpAo=l%DDF1vqG)Q6uaufblDca=U=?@j_*Dex?m1EAMmDLX0JTgeye3Z%B8 zKEqH%xh;Q3=aAQpMoycf>nWSQ-(Y5Qz+DuN2NiaP;(422!LQI=d)M1rjB6!Xm@(Qo z(kZzioQOK*j8Up1)^l3K`v$=_l%lS*y)9UK1v-OE&NSOeihVtlOR5YSpdoz$D{GQZ zUCld2aCYZ?Q#{n5yt3!yAW13yCYKF_otQ>nnlYv*=4rgXPw{7QZ?0&!Yl*&9g$?gC zp@}+A4IU8I&ex{Sso<_tJrs5E(iGQ2W~+??|vMjlJU0B4!E$@YJ8Rw%aJeDfVwSeB!#T@)jyy29#N q4BK#9D{I?ze-$bH^gp77$pPA`znTyS{_*FW0gUxc_2@dcqy7)G^`tid literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher.png b/android/app/src/main/res/drawable-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..7a0bee48483d746da923a05a2e210a2ccb8e66ae GIT binary patch literal 1427 zcmV;E1#J3>P)>kZ_S&^;dw0FN$J}IODN{NqslZ&43tE}=3f|jW^#(QnT+ORyg?>OUk!W>usC@fjudr3t$kamz7%CED#&2OSOmy_m zY>Qe?S0$yI;pRDXb(F)Yj0O<_i*?>YX}qdK+VE(M&GRdRAJ#d=x0~8M>raZM2LRAW ztK1)ep<1{2#&Xb2_ABhAp}vh)9p*8r4MGTDj1CY3xEv|@dFc<&aE3pp zH1z&EuI?RWpg*>{;^zKWCWKe)ZmnG7jdK?tDgXkEs>k)$1pVd!qu`R8A-2=%QcG4k z{JUm!CKrMV&{IWSYqFJp@MWNKxdn7PgaDYwRf-ogWqk@AGICNX86TQE!I7M`rMCUt ziGbs){Z~DkV+!4ySAd#$qCU-#vFVkuSHI~MK9OlM1@vt*4KQAmr!qnxZ=@+9lX6k! zkAitfKsSaux41-=2_36Qt7cQvR1A_~5ojQZBoL5wqr`^iDq;%{cM4mYedV74fRY!0 zI7x-9Zk0V9upg1xVKyBGKml@Kh>S!o%SQ;rIMcm(y!RMN185jYi#sN>hPAxr#;!UL z8wRJcw9YSiqI=t9SE3#O>hl6nmQlupx)>j)Y7SuU_FHJ%5kGGl_$BH`WMqIz$21_w z)Tg7%I9+q5Ohkl8-@Q?FjMU-Q9B)s~$fa9O0O#%exBuq*M3@*BY|VqmiJ&|eYnZXP znB((OkxgZKW^A*qbZcBAdj?7aQk*8G0wEv-i(H+Zm(CDT;-${Y(%o$3oJi&>sJjYt%a`lV&6th)&kq*PR`UX>~$~({bX0^?xwcNO)-TIZp@)uP5_LRYGV*+7AJj7^vmy2 z3=zZtM2Wy0(iH5}F)&(2=Q9cx7#7_qahndRl2&d!{h#txZ)N8h-+cDDt0MR7-qy;s zopJ6$E}cdQK(-opo}^LK?qM1ey~=Bhh4f+oillL`l+-2}o{2;$nkD?{zwf{=nu@vP zEoqU>Z92fwT1hpZoNnYjTNa-?m>o{hzNR+M`cplnU*FHmEGK|Ox4Z}=gxNIpyOdC$ zrp-hk8j#=R9MlOHm7XL>moiqWl`sgx#&Zz zojRL!*`{Hl(zic(Q%(Tu8dkOZgJryxKh9^Nk?Q~PkL#p6002ovPDHLkV1igkr{Dkp literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..c938c95aa7a89f7ce11c6c861d2959b1fae47c28 GIT binary patch literal 3463 zcmcJSRag@M-^I5%S_v6~4gnF6u8mIVkRgI1ATp$sMg}7tlG4qPkZvTUB?K8gL^=m3 zC7lC_y?&S9{rg^=bDrm%oB!Sa`Gr45Xx^Z{Lk$1`ZfI+%J^7c&{}UzoKVMEoOaJQ; z+G-DtyfU^kXk3lP=Q~Jib3F_GO0|8?lGBuG98?f*6aL(%VI%fVW$%8Z;KO>2?YDbQ zi|Y$=>n3Qw$g=-fzV0+m&ENeq*FvaQH3De~c?{+R-%QwAt()db_n+JP)rOaXUS~8^ zx%sxwseOSkh~N6;yMrOEU3d<+5;5o$p5TBO&mX1__I6{0m90>~J9#ueSj+=(>maxW zbGBGBAbN!Cb<}@vDy5=qkSbx)k%&riM7h1nhINI$rM@*5Wp4UBSBG^RENFHaV47pJ zgsOJxW4I=~_+SpXNv)PhIbi;g zLY6`5>082YVvoZu9!1&OrR~JKDchA}U^bdVTp9zNhRu})Td+w!3&s2gfZqNAy8K{l zkid9QK5M$^+pL*;CZ<=#ZY8`}|8u>eRU^mI!F;6ji<oLH0H{=Qaa~YxA>ePxg?sDn^hF-YS@_vr z1?>cz+xr(Y?enw|F523gE-s(u_B#ZMBH3jYYzR`#@i9@=3;}?h%(<_B)#iK_FFn^R z4)it=NkXj!`R;vNP2s+ZZ{RMmD^tCXEh~;a3Gc?OuS}&wf!zCNI8}I@%*{9-5C>hG z%Ohmo2z+)?+(t*FUI96vXC~J2ZdY@=-YFfyj8RHhQo$-MF1dG;J=V&x#fJ|k4kHIW z10C2IsllOt=yEAMSS5jtWfN4lpa^q!P4bpV42$#FMl*+Z1fq?PyMah8#tNxf#E z2PmWe1&rM!?ri{f+ad}jG-IWS65^`*S7cn^b(S07O8`my0SiSyZOQ=X>J{a;dU8UV z4P&Q+F`~p*cRg3B)(-*=?vn{zzMSP%d;W-Fw4gHvZM&){QFIcdDijlqk^Y7G3bT6+ zJxL8!mH_&bqzyV&K#>omNXsFA5-0=`H{?1L4xSf7uc3Yo;JGuAHw}g6(Q!{ejWjFR zlZBIu-y)^y%Ugto&W~w_*530u_uN$iDrvrB2YGcC>i+DVUL>C`4Irx6OWCi90%lyr zPnBb}xW{4?a^-#K<1CH*#SeN9e!uQ+%g^-|0{B@%Hq4bhfZHajE+&v~y`&HuskJ$z zF&ClZgt2@jbD)om%6PTo_7}Sf1A!EK5Ve|^w8%tj#hkoEboHv z+%Bz5GL(k|y`sFo67MyN4 zJbXcEl_bO~r2u=Lr6timVKC?%HD+t<+uyh*_|h|0H9kTp8*+SH&Dg|CZsgUtJFuub z7A+iD9IKAr)csB5H^oAuwDj%%G*1S%yC*bd?<>|k(=r2T|L)k zZNLhQ+ZRV|A>p0hUD4jNhFwGZFB&#lv5HiJN?15M`;MY&PFg@kt%81G;(^3QK77{v z)v1R#{}shO1g61_cu_7=pIXVUZ6w%zEk0sN2fN9Rj^+sEjE>aht&Q@)}%T0j<#iB#`i^L{6!e&Fhx3Jw)>gioL`&vViAO7O_y2vmkqG4gqB+Hvq=`7*8WPTj0T|sFO?6Xvl z+1tpLba!3eJ6>sBLf^Dk!&KM{uTn4~2Eib8pd`r;+gEp$lrTGs)x2SIfvjS^ZErmg z&6PJ86B=mOFwXQD&u?Ulf}z}u%md3qwj^t@{#cvrg(t74_y&+eTm=r_%f%7>{8y+K zX#&5K_SJwm>w*}T%t5hQvzanVoo^w!{vcF7Ppd{RUjfepcjdu^>@Jt7*Ht4DJP%sY zi@t2Xw_HFS>Gm-}^cwzAWbNppJ2znZODJBg;38&)TMZV-xr>?(&t!?IQa&=6e~7F>P;~ zPnQzKW(k!?zTckT4bJQiWmaJ?SU_kLcMFE8lKpDTqMN|3xLOHj_&*-xeV7+1v$$7V z31`#Wj&86f3C!jh9qp#;YOiG0ZDcX&H~9NYdpABGQJRue)Kn*VO*FjHPh2B{f23X} z=i7r=x#wJXZ=o4Nlnn}OOxlKiTVFYg8a|ML`k$0e*LzPVy?C=P>U)soGYlT>_hXO5 z`qMyExMxDhSn{IK31<|3Mvddh&N}e@klLRT#zXby4+>Yr`?Vxstj;GUQ4X8;W`jf?F37&XeWJJ4e)vB?W zhhsdW6|3J18`bu%Xc^m6vZeDCT}U#Y8Hw}n{FBo_QE0;a<c1!?&_MW)Pl-c1KvZ9BZ(i@i@LXI7sGBHoGp@ZP;qFi)h57niwRcX7O8_&&_+ zC(hk|s7uS>-|y|{U~xMPE=k#6%Iiayv#nN^6uVC5L+_Hsr+upP3lp{AM7}+rW_|(p zQM`q}Y!IXu8Csm8c{k(msM+Va}NVT<=d32#qZ5 z0hlqgBuf|w@i5nl+-7Ryq~`i@YH1%U$j>&RlQl@JX0DlqC>O($&f8K`ddz528@xiC zw#k3t{lrzC@kM9sn21n)w>yNsyn^p+M#VS3rkb+OqlK2Egx0d@7!2H2W=mVj&z-4p zDRf)uw_GRdn{$giL#ZXTz`DlkYy%%2KUI#w3Y5RBF<*~=AmhIk`7z&5R(jMkQjni^ zCctWcVlLr=Oo(IsvjmK%RQTV4ww|z-GsHfvCd0XpvH$9R2V@`+g>`j6fL&aW;5CeX zu)(uI26o_E%Ri!jd*r@_apI-;p=TaaWGyLc1HHikN6u4qmWL*!M85cqXYPY}>Q8~@ zIa~MpB$v`ZXng$~Je%<$f(E*v&zj7=d`GEA(Fuu3Csc7y3(py%Mcr`7PP@a+ns1LX z?d1|*iX8Q>NVYH4U0r@=v-MrKdMAgA%jWI9@rf#skjd}AzKsh!ey8jCO4>&LcAslk z1zj|EDK&4J^-FwJage(xJ4diqA8K<_e14gk<^H(VZCA8gvJJy4QGFl>rr=DG1I(-k zSLin_h7I+c8CL@v&PoLdeW*xxmir#GqP%B^&?tjlYq^2OV+}cv;MHOtZ7u{(D@4IK z{aqZvmRMbx2b6M7AH_9as1AvhMwf!wYWG|AxaM4?{!#_2H>9!^pvX_t$U$-L2(u@?~L{TvWUi{odZb~|0|sM ce|&O?Id6fQT9$-6{sSz4wmL$sLIoN4A9N(IG5`Po literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..b03c7e2f04d4d0b5c88a5a4e976e9342b57004bd GIT binary patch literal 5645 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..8a1ebc8a11fee415ad814d41f2eb34036fe310da GIT binary patch literal 7864 zcmd^kRaYEL(B?2W!6C>HULZ&yxVyW1u;6aN-3BMP2Y1&bc+dbD+y-~I5F7@bVfpsE z+@G))-F;KtU3Ka_a!!5KP?N{O0$~9F031aH8Lii|?|&8s+Uu^h=7bIa{1>7qBdOz? zcbbnLsH58ho)ThK#VsU=9Vyr>BP+7u8QrFc+qT{JM z__On4?2-W~LG}Ch_)7lyRt#y!9w&b1Y6>b+LQH9E*YK@x$gpy*1YP*;K810?71kuYkaFeEg1|9=Z%*haZW z9M*?Z_9p`@0CHrd9g1kd*Z_$-Y`c(#MEe?a;f97RUh@O9=;_!9qOITaW9Y&W5w;HQ z16^Z~Ph!Qq*V_F6iG5LKw4Xz@FBYnjkEvl``zf=bC4xfH>&ySW2A&oqJ=9ruL+IaW z;{$8v(eaBkM^aGn#>Kco93yWtTB4y;Rl7Vb;Uq`+=K7c%O+DK?9yQ%}wDFzYQzX&* zx9VE&)Nk}u&=h&{h%Pr|%xmZPc@3} z`A@TR-~V?zYUmm#*$4W-e@pM=v z@{oQ~r%ZGHwA{Y`OL}1A-xv~P3aJ-=WT`m~)Qkjt5I!MYYLS9OaUD7cZbZ&upatnl z9;bVI^kowQK!B~3v-~A%KtOQC+0GJF5&|%~P=O=2SU8v5D-`fz^`% zjPiisVPMe^V2Tvfh$39Ac4Dby48L42Wjp!EjWz7rGuCwR_vWu26QD&jCQVd$waIDn z=<#GzJl%cMc7Jlw$b>bTc|Z$*!qH{p^f2Jj^w-7DLH*;;%DH3tTxy%U zU~-ZWLiQ>DN4oDQmim|g0zjJ@n;-ouf#5?itDgs!bQJv5`GW;ruhE{ryL`PDJct&n zO_a0EY*QE=N)n{GtsnkWWnlNp7S)R?Rr=O%2YK#Mk&7t2-uaLLzlVO+i{8;Kkr6zU z0^3;KHwts|x^552=Z%SfRD7UPtN67(BS1{X2eeBr8xi8qgDmaGqxVq3Yya9UvVzKy z){CKc*z~!Fxl|pVpXZxAKKC%|osYG*2v`X;a~$;qM7?`<_PK8St#X-`S;wqWXV>lh z`GH7V3*P&3uP`bd+U+#YTfD++e2Qm@51t|ehp>X}EBz*4EGlnN@fPp&$~XF;Oa9yX zcSP4eyZGxww+y?MosGmluOvlB0vHTA*s~4qkrFuS{It(I*tF(#L*wwov>|IwYOM{A zH6M@H^Sv_J8%7%g10Nc3Pal5LJych(mf8vnTn?T=wt#CRq~&@+0SCTQ;TcG$Ny@#H z!W|+d%uq*J%E;dn9Kq<*IITbtzgCyCpV`s9#y`04>VAIjY47B`;rH$qcw9(R5*M2_ zx?83||9tljm+TJLTV-f0nUq?UV(ha}_eC1M`aJ8pn9V1~I9wP*K3eE){WH@c6Hf&UE;jWq+yl##g^ms zGEV!_fNKw{U*f&msZ#lq$Uo67cD>Nae>kO|t@(XbtS}dxL=Etn2E-1TT>WsTA!kPl zEJ2aL1mZns`AButH41K!(Sge{0^GlJarV|B&W>eG#ag%z>qKueS%-4Jn3jyoXKi1Z zhFBWU7G}+U_6?T;18`I9dxAZjhE%`=DINTWWqlo6lz^<*9QD?>}ha+E)C%l7W+){Wo4Z^_NBXb z;bP_lor?r7!+a6*1DJU8nh7w9 z-#04*o+1KNg##LFL8#;aF(!3C)^Y7y{yYuDr>M{|_S)ID)vnc4CugT;Fv=oGV3J(y zd}c4)_6v)m;lpIg%|6W!pF1n$*Gj2}URtJrdZ*TUYBhoTg-==}`$6o_|HYpT^=}^J z00l9&Y|yrXxBy~HpWlKQTm76)TpAk6rbg=n@EN4RBF011$kb6By34vNXogC%7>*pP z{G?hDVDI3DyQwBFP{U`Rr4AMHh^(rC1|l_Zq5%I`&?)_HO+kQ{Iz*R(Ic=kpBK@Vu zl>1+i6V9ALFDp+!hAhn+X8h#OT*(B0DiY6+p<4HiqFli;diJJ;rm*{Gf{lPjAsb}o za$}($|M|wxJ!4eMoPT>kgsy~>$@p6(1oLgr-YOH8Q}E=VZJ!nx&iCE8!SCo-_!_E& zWdQJ6zVb9>Ebx&M;QPb^X|*!2^f(i&zJNcqB^2NZb@bz*NlO7(>B$RLQUVsv{Ksru zJvFNkXt;ecp&P(RIZP{~%|S-wrSnLx#sBhsevNrNyu3Whg&fWkMMVG{vA91q2PU8w zJtm;PPurES_bd5JC=6-MOEn1LB`Crpi(!_)*AxZ8hmnpFXq1k6W1_^C4~{UyDW#2~ z$^ghSfa@xz6N^rk3QwOWd>I_gSd3|Mj9XoP^UV}jpay^g-_qKx3b)Oiqry$x5x=}j zpI$ZtX8Rx32e(k|#+%NN6sky(mtdfj~dBu{@l4LW@t6%(W^LW87 zuY^FRc}3>HP{E}&_DlucNev>F=75L+8N;eR>cm_zN=rj<7*Kl&ZfKcmW%y3)(4GW5^#~FU9UFm z8B*SnIViGr6B~BaG>Hv!IUsm!+1NQ!ZIS(vF_N7Vo&ON^^dxpQ$S_{~Hz`)Dz*t}@ z^nX$%bNXIaXA{%X?na(!qSy32hBZ*8fd2C8Y1Gl6Kk8nc{LZT65xh0+YV4g!iO2ch zyeM4y%TIN%@A?nXhU7<-q(C*LhW}!`Ksj(ryICG;W2;y7-fjs$n=N^*R9$=KYc^ zfmz5yCwgQS*>!dnPR4YRt8&LnX$(;@EXuinZcazv!sKIhiJe-4>qM{KnS>9wJ{I|vMMSDvv8 zhGMPF?RF)GlO>5zQM$1`Rq^cqxd*flvOc*HssRkpRwMvZIFS~XR^(-TJ#muD8yD!U z4ri)`^1mAFjeH31uw_gAPcV$Td^oj+CYT;L>xdifpI6Pg1%!Umas=$LjD2lRtRX^m z(u3ks#l)x5ZE3X<2w;D_i+=fSH#72R#V2;lL6%<06erKvdmU_UHF7rHwsn-cuOuC! zO5l^qVqtluziXOtS7H)uIFhv8$cc9O*xjo}Ms&61VYDHIA)=;r)p~WsjC?7|aPq+O z$_AmsZ+H;v7P+JUrn&vm)8l@2z<^}fpLO&;|GzJ-Hg+#xR7&_GgLZEIh7F$-xFgg@ z{H{vDQmId7dzj@H%$F-adDtUP{&bx6AI+`{PI4JurzwPjYQlrTjB+&me{KRoWE#IN zUgMmYh$p-0E#qgS%%(=X1zVd-uE^|803^pj$H<`*h`98|Ro;>IWKO6q+J`5XUZZw8 zPznQ*VpO31=vUdxuM0W{eoe}qYKpY>&E~Rwv;|akyHm=jMFxQG>_@2JB$-R>oKd#% zZwcF)0)xljGx_jBUlZub!g9!bK1#^p7hB5DUye)=j|m)3LGAD} zAS_KlsL!6q$Py%=k&KqCxqYBV(NXT^OyPt<(eCzC9Ox$bS7)w{}y7-J3E3StrnHth$ytBf9f+LDe)MO&}n zhjpB-ZCcnrZ1-sp6-j&A_>%(kG5BL_1%^PGG*}QLw-OTsN`C&sk2bG4eQaB1@rmP= zHhrQXIz&Z8u903d!y^Di;BVtTySF=(tE@O|)QsMg-pARLa@A}nZW?`4FssK(y9GYb zW5~~&@@n@rglaaNDi=}zmd7czp^LSZ{cwpRU|KX9fiqclZ3DG`!DctgYRx4Bi)$rx z|0&gfT_LL3pThNzyw3T^J1QC>nElXqPQcE(OmGj$sW70t#Jqkl`Ow?|#nt^Y$?_!l zlfkhL_Q^66vA!|5NPwO0HPqD05`GjVHAMTusA9yqMl$F;@|{3}#=6ej%~Re?j)xI0 z1d2z1cow<^WqzA9lG!TF*Og*f&Le~_u|o32O-{`jHRu$m+w3WZ?BGxhX)_a+u*x}N z$j%)Wh;?n`&&Av%6p{dcI|6ui4}t3CrJi**>j_(cLmGI4@8v?3?H5@7p0pDtyz)C2 zqnWC0;mm55i&2k@(qbJNkAv5>gNg;;Gfv|sh) zE4o4y*F<(ssvk9>HWQw;{&h^cO(bzYe{}mYvg#Ic4~Wbf5gO4!_IRYZt~=zAnV(JE zkm@BbX-4Z(*v9hh3l3PfzYx9OQRvQCwoow_zsQ7qS=7%m9_0fe>sIZlQXC<7!@s1X zhh#DRUNVp!>{D9cZaVo=kiXwqUwN!|&%3%n(td{cZHe%%;&X(hzt&-2{dj{3kyUN{ z5s|{hvz+Y`SasS8V>!*0fVhla%E#~_ey9IuI;1asLW4|(Y%oadXpUQEv@Gj9>$Z1X zn2DMlt3Id+P5o|M8aYwR#f&QA9CoQ@O(7$xXrIbe7Rqi|@%!j_bk6ODkY&ilrx+SC zeI?+@cVL^JAjok3$vl=zD<{|p_lI4-#Mj3m8&W4PvdFLbA>pDQ1~qkGgwlWAT)PkD zUh2Z;G-11Rc>s5fVU`V&r7zz01q6P1CmwIUF?V;i@*K>k2M&lPOI_MW>qDkJ7~LD& z!a9vJLsea*a-ko{T&z?*yZE8q{P&*26Q-$L8?Js6+eb}-*h!W=uBH&*0o7ktu+EC- z_pb_~Drr?dt~k4i@L2bb06XOjtcpL)==9K7AnT{vyO`go_k2Y7hYpqE0}J+4ysfY7 z253hOw05j(tkN3C3#58ke>6{NPp)t0Sc#az+?qu8pLgnr65eFdO%#+49ejLE-rj5a zoL;qcZH73UECV{)`m`7cDnrFU>ZO(MWU((pJY;Svx6-68E=HOrn_blIlvTdyX2Gl! zN&@DU_~sM^kT?Lu9|m;PQF~D=&5-4zfs1;WKEQX7Y9iIkCOde_u&9^y+JO9GcFHT9 z7Rx)s3KR02v&+WY39aljp-eA=&ti~iY|s!~g=LlKQE%hjiNatB2Nki0atH>KrLT&% zyI|g7%%oQ9n;~kG-T{f?OF)D}yN9E4`++W#6}f<;^20uHoP(3#qh5wK?}veO!#3Y) zI^_w`U+<&j=tJT|ve8#Pox-qLSd%(x(oNA4#irFVLkEn>o^m%-P8S4uCZ=MKcdxm` zG#vHsu6o8-YfFAbysHq=c$WA2!Ad4HGzaC5 z-f0$=5wYLx5|$G5E$Fl%w>p?U4`Y^k%kH-mmS;>LwApK%-cq}x#x*LFiJ^;oGuDtq zdg-TvO2%J0OwV!@Eo|vK?T5#dk1QIbpAolg6Fe?iXrn9y;oP7>0WR8%R2+o zH?c(ZWWA&}7>5!V&*W+GdW@%ct0#5Q5k=ds9sK%L;zBNOe&`4|W?GAh80tIGR?Qc1 zolV*itM_Te{RyYWur9O`%7to*npjNajd$gXD82NKo>xB{C8GHZSbtV<&|SyG@(g<7 zF6)#}mv5mW6{13v%{^#!8KkAd1u&VxztvnKj=sGeyYaT6Wjv7JVcBmYjA>hn=;|k* zDVlpGqeTfFm*1OVot-f{K4_}qs>0<-MB}wEfhrLy@a9bS?&j;so2#fWsfK?lWq&A2 zKWyA-0Cer4TwdZWHPC%f%yI_t)$NkAY?xQ{FAjf{)3hW`r6obx2_^s zp>}aRh9WYw8_+P$FNeZ?MSgZ%i`4P#YXoa(u7Pzat~UkY_QJoAW`{TTCA~C~Y#H(G zuNOF#-+t^2*7_ItH`Q%^stsOOx`yj_QwR?Vfs~Xa7Z>F|RVBXqt|WyHR#Rn8CNZ~M zb8xhU+4J#N^4*)mzxht9l@*?~4cG>QGsLfv`)OraR^`n)&;N#>B1JTT*(}IAsK1I+ zJC&H8k4u{uSDSbmX)s(zV10|B)MppuXjokF>NR?D@j1Cs#qJ_%r)Jk*{&e-sb^MLk z>kA9Cz3&xTog^(zs#`7$9`gAjTz%*x*^h;PrD%&KNfXV<`Dc{!M`Kv0q@SrvaU0aI zEN|aUnl~h$$+(GT<4YoEOnZm+P4$ZEx}vFp?^xEjlb!1uejPZmKetyMLQQk9A_uN8F{!FW0z-zcYi-kZ~j z!v=;ojk#NQW;&K{+a#C#%Xu}!eyhi&QU&yKoF3m_Llu_KU+pTu(u{F~;CV8qzw7Or zkzJ|aW2dOlKM-PX!;DE5ucYGd?oKqCCUcM}z%=FUKvC3cuLe7!IJaNjt^I6l;K{;Wt4Er$OvfuuY-E zf#is{5~cD+_fCF_0u!Hsvna+QJbo%~Q zu~8+jqga+HdsJrI=#k14#NPig0I_~l9+}-V@}j;H5i^Wb9Gz>p(!1n;Tph0{|$n!&KgTz0yh@qyWC*kKr2eX@v^MMz#7u58?9;wxK#DXSw4> zL*rJ%+}|1VeUF6fjnct^R=a9of?mZ_RYf@sCS0i>3jliKr*}OQ9Er7g;pg6?%-NS0m^P;BaxY{X}klU+jRyLcXF`671$c$2j!{bszG8 zjE@3^9J$!ACWwwlY^(NTUTitz>Bu#vUXkNmaXtozyf4)v6<5WM&7NWW51nS~#>U|T zlv+12UWXR*L*Gc6Efaqx^16pX@Qt{n^{eQi^h-$ne?Q>-f2X<#yt(jsZ}40$%j-lM OpeU;*(;#IQ_J07b _cachedBaseUrl ?? dotenv.env['API_BASE_URL'] ?? 'http://192.168.110.18:8000/api'; + static String get baseUrl => _cachedBaseUrl ?? dotenv.env['API_BASE_URL'] ?? 'https://ta.myhost.id/E31230910/api'; static String get imageBaseUrl => _cachedImageBaseUrl ?? baseUrl.replaceAll('/api', '/storage/'); static Future initialize() async { diff --git a/lib/core/services/api_config_service.dart b/lib/core/services/api_config_service.dart index f6f134e..231c222 100644 --- a/lib/core/services/api_config_service.dart +++ b/lib/core/services/api_config_service.dart @@ -6,8 +6,8 @@ class ApiConfigService { static const String _selectedPresetKey = 'selected_preset'; static const Map presets = { - 'cloudflare': 'https://exams-rhode-agency-outsourcing.trycloudflare.com/api', - 'current_ip': 'http://192.168.1.98:8000/api', + 'hosting': 'https://ta.myhost.id/E31230910/api', + 'current_ip': 'http://192.168.137.1:8000/api', 'hostname': 'http://LAPTOP-I0SUKSKL:8000/api', 'emulator': 'http://10.0.2.2:8000/api', 'custom': '', @@ -34,7 +34,7 @@ class ApiConfigService { } } - return presets['hostname']!; + return presets['hosting']!; } static Future setCustomUrl(String url) async { @@ -88,8 +88,8 @@ class ApiConfigService { static String getPresetDisplayName(String presetKey) { switch (presetKey) { - case 'cloudflare': - return 'Cloudflare Tunnel (Internet)'; + case 'ngrok': + return 'Ngrok Tunnel (Internet)'; case 'current_ip': return 'IP Laptop Saat Ini - Recommended'; case 'hostname': diff --git a/lib/main.dart b/lib/main.dart index 55138b6..5eed3f7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ 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 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'services/fcm_service.dart'; import 'core/theme.dart'; @@ -63,11 +64,30 @@ void main() async { tz.initializeTimeZones(); await ReminderService().initialize(); - runApp(const MyApp()); + final localNotifications = FlutterLocalNotificationsPlugin(); + const androidSettings = AndroidInitializationSettings('ic_launcher'); + const initSettings = InitializationSettings(android: androidSettings); + final launchDetails = await localNotifications.getNotificationAppLaunchDetails(); + + await localNotifications.initialize( + settings: initSettings, + onDidReceiveNotificationResponse: (details) { + Future.delayed(const Duration(seconds: 2), () { + final nav = navigatorKey.currentState; + if (nav != null) { + nav.pushNamedAndRemoveUntil('/home', (route) => false); + nav.pushNamed('/notification'); + } + }); + }, + ); + + runApp(MyApp(launchedFromNotification: launchDetails?.didNotificationLaunchApp ?? false)); } class MyApp extends StatelessWidget { - const MyApp({super.key}); + final bool launchedFromNotification; + const MyApp({super.key, this.launchedFromNotification = false}); @override Widget build(BuildContext context) { @@ -100,7 +120,7 @@ class MyApp extends StatelessWidget { ), initialRoute: '/', routes: { - '/': (context) => const SplashScreen(), + '/': (context) => SplashScreen(launchedFromNotification: launchedFromNotification), '/login': (context) => const LoginScreen(), '/home': (context) => const MainScreen(), '/presensi': (context) => const PresensiScreen(), diff --git a/lib/models/announcement_model.dart b/lib/models/announcement_model.dart index e23e1ad..0f3ce5c 100644 --- a/lib/models/announcement_model.dart +++ b/lib/models/announcement_model.dart @@ -15,7 +15,7 @@ class AnnouncementModel { required this.description, required this.date, required this.jabatan, - this.namaPembuat = 'Admin', + this.namaPembuat = 'HRD', this.avatarUrl, this.fotoUrl, this.lampiranUrl, @@ -28,7 +28,7 @@ class AnnouncementModel { description: json['description']?.toString() ?? '', date: json['tanggal']?.toString() ?? '', jabatan: json['jabatan']?.toString() ?? '', - namaPembuat: json['nama_pembuat']?.toString() ?? 'Admin', + namaPembuat: json['nama_pembuat']?.toString() ?? 'HRD', avatarUrl: json['avatar_url']?.toString(), fotoUrl: json['foto_url']?.toString(), lampiranUrl: json['lampiran_url']?.toString(), diff --git a/lib/providers/attendance_provider.dart b/lib/providers/attendance_provider.dart index 9cf68da..62ae274 100644 --- a/lib/providers/attendance_provider.dart +++ b/lib/providers/attendance_provider.dart @@ -15,6 +15,7 @@ class AttendanceProvider extends ChangeNotifier { LatLng? currentLocation; bool isWithinRadius = false; double distanceToOffice = 0.0; + bool isMocked = false; int selectedMonth = DateTime.now().month; int selectedYear = DateTime.now().year; @@ -54,6 +55,7 @@ class AttendanceProvider extends ChangeNotifier { ), ); + isMocked = position.isMocked; await updateLocation(position.latitude, position.longitude); isLoading = false; @@ -186,9 +188,18 @@ class AttendanceProvider extends ChangeNotifier { } void reset() { + historyList = []; + isLoading = false; + currentLocation = null; + isWithinRadius = false; + distanceToOffice = 0.0; + isMocked = false; + selectedMonth = DateTime.now().month; + selectedYear = DateTime.now().year; reasonOutsideRadius = null; reasonEarlyCheckout = null; reasonLateCheckin = null; + _hasLoaded = false; notifyListeners(); } } diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index 8f1fcca..d67c2fa 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../../repositories/auth_repository.dart'; import '../../models/user_model.dart'; import '../../core/cache_manager.dart'; @@ -128,9 +129,33 @@ class AuthProvider extends ChangeNotifier { } } - void logout() { + Future logout() async { + // Langkah 1: Beritahu server untuk invalidasi token API dan hapus FCM token + try { + final token = CacheManager.authBox.get('auth_token'); + if (token != null) { + await _repository.logout(); + } + } catch (_) {} + + // MB-23: Langkah 2: Bersihkan SELURUH data lokal secara menyeluruh + // Hive boxes + await CacheManager.authBox.clear(); + if (CacheManager.settingsBox.isOpen) { + await CacheManager.settingsBox.clear(); + } + + // SharedPreferences – pastikan semua key terhapus + final prefs = await SharedPreferences.getInstance(); + await prefs.clear(); + + // Langkah 3: Reset state provider _user = null; + _errorMessage = null; + + // Langkah 4: Batalkan semua reminder lokal ReminderService().cancelAll(); + notifyListeners(); } } diff --git a/lib/providers/calendar_provider.dart b/lib/providers/calendar_provider.dart index 5e65d72..69cfc8f 100644 --- a/lib/providers/calendar_provider.dart +++ b/lib/providers/calendar_provider.dart @@ -47,4 +47,11 @@ class CalendarProvider extends ChangeNotifier { _schedules = []; notifyListeners(); } + + void reset() { + _schedules = []; + _isLoading = false; + _errorMessage = null; + notifyListeners(); + } } diff --git a/lib/providers/face_provider.dart b/lib/providers/face_provider.dart index 26fe2d2..97e6886 100644 --- a/lib/providers/face_provider.dart +++ b/lib/providers/face_provider.dart @@ -44,16 +44,17 @@ class FaceProvider with ChangeNotifier { DateTime? _poseStartTime; bool _needsAbort = false; String _abortReason = ''; - + Timer? _maxDurationTimer; + Timer? _uiRefreshTimer; static const int _maxRecordingSeconds = 45; DateTime? _recordingStartTime; static const int _warmupMs = 2000; static const double _turnThreshold = 10.0; + static const int _holdDurationMs = 5000; int _consecutiveBadFrames = 0; static const int _badFrameThreshold = 30; - static const int _holdDurationMs = 4000; VideoRecordingState get recordingState => _recordingState; FaceDetectionStatus get faceDetectionStatus => _faceDetectionStatus; @@ -70,19 +71,24 @@ class FaceProvider with ChangeNotifier { bool get isBrightnessOk => _isBrightnessOk; double get lightingUniformity => _lightingUniformity; bool get isLightingUniform => _isLightingUniform; + double get stepProgress { + if (_poseStartTime == null || _currentStep == EnrollmentStep.done) { + return 0.0; + } + return (DateTime.now().difference(_poseStartTime!).inMilliseconds / + _holdDurationMs.toDouble()) + .clamp(0.0, 1.0); + } + + double get totalProgress { int completed = 0; if (_currentStep == EnrollmentStep.right) completed = 1; if (_currentStep == EnrollmentStep.left) completed = 2; if (_currentStep == EnrollmentStep.done) completed = 3; - - double holdProgress = 0.0; - if (_poseStartTime != null && _currentStep != EnrollmentStep.done) { - holdProgress = (DateTime.now().difference(_poseStartTime!).inMilliseconds / _holdDurationMs.toDouble()).clamp(0.0, 1.0) / 3.0; - } - return (completed / 3.0) + holdProgress; + return ((completed + stepProgress) / 3.0).clamp(0.0, 1.0); } - + bool get needsAbort => _needsAbort; String get abortReason => _abortReason; bool get isChecklistComplete => _currentStep == EnrollmentStep.done; @@ -95,9 +101,10 @@ class FaceProvider with ChangeNotifier { int secondsLeft = (_holdDurationMs / 1000).ceil(); if (_poseStartTime != null) { int elapsed = DateTime.now().difference(_poseStartTime!).inMilliseconds; - secondsLeft = ((_holdDurationMs / 1000).ceil() - (elapsed / 1000).floor()).clamp(1, (_holdDurationMs / 1000).ceil()); + secondsLeft = ((_holdDurationMs / 1000).ceil() - (elapsed / 1000).floor()) + .clamp(1, (_holdDurationMs / 1000).ceil()); } - + switch (_currentStep) { case EnrollmentStep.front: if (_poseStartTime == null) return 'Tatap lurus ke kamera...'; @@ -142,14 +149,16 @@ class FaceProvider with ChangeNotifier { _isLightingUniform = false; _maxDurationTimer?.cancel(); _maxDurationTimer = null; + _uiRefreshTimer?.cancel(); + _uiRefreshTimer = null; notifyListeners(); } - Future startRecording( - CameraController controller, { - void Function(CameraImage)? onAvailable, + Future startRecording( + CameraController controller, { + void Function(CameraImage)? onAvailable, }) async { - if (_recordingState == VideoRecordingState.recording) return; + if (_recordingState == VideoRecordingState.recording) return false; try { await controller.startVideoRecording(onAvailable: onAvailable); @@ -163,13 +172,29 @@ class FaceProvider with ChangeNotifier { notifyListeners(); _startWatchdogTimer(controller); + _startUiRefreshTimer(); + return true; } catch (e) { + debugPrint('[FaceProvider] startVideoRecording error: $e'); _recordingState = VideoRecordingState.error; _errorMessage = 'Gagal memulai rekaman: $e'; notifyListeners(); + return false; } } + void _startUiRefreshTimer() { + _uiRefreshTimer?.cancel(); + _uiRefreshTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (_recordingState == VideoRecordingState.recording) { + notifyListeners(); + } else { + _uiRefreshTimer?.cancel(); + _uiRefreshTimer = null; + } + }); + } + void _startWatchdogTimer(CameraController controller) { _maxDurationTimer?.cancel(); _maxDurationTimer = Timer(const Duration(seconds: _maxRecordingSeconds), () { @@ -187,23 +212,25 @@ class FaceProvider with ChangeNotifier { Future abortRecording(CameraController controller) async { _maxDurationTimer?.cancel(); + _uiRefreshTimer?.cancel(); + _uiRefreshTimer = null; if (controller.value.isRecordingVideo) { try { await controller.stopVideoRecording(); } catch (_) {} } await Future.delayed(const Duration(milliseconds: 500)); - + _recordingState = VideoRecordingState.error; _errorMessage = _abortReason.isNotEmpty ? _abortReason : "Perekaman dibatalkan."; _needsAbort = false; notifyListeners(); } - - Future stopRecording(CameraController controller) async { _maxDurationTimer?.cancel(); + _uiRefreshTimer?.cancel(); + _uiRefreshTimer = null; if (!controller.value.isRecordingVideo) return; @@ -278,8 +305,6 @@ class FaceProvider with ChangeNotifier { _headEulerAngleY = face.headEulerAngleY ?? 0.0; - // Tentukan dimensi efektif berdasarkan rotasi sensor - // Sensor landscape (270°/90°) → dimensi display-nya DIBALIK final bool isRotated90or270 = rotation == InputImageRotation.rotation90deg || rotation == InputImageRotation.rotation270deg; @@ -292,10 +317,8 @@ class FaceProvider with ChangeNotifier { final box = face.boundingBox; - // faceRatio dihitung dari lebar display yang sebenarnya _faceRatio = box.width / effectiveWidth; - // Validasi: Wajah harus berada SEPENUHNYA di dalam frame (tidak terpotong) final double marginW = effectiveWidth * 0.03; final double marginH = effectiveHeight * 0.03; final bool isFaceInsideFrame = @@ -312,6 +335,7 @@ class FaceProvider with ChangeNotifier { _isPoseValid = _headEulerAngleY.abs() < 18.0; + // === LOGIKA SELAMA RECORDING === if (_recordingState == VideoRecordingState.recording) { final bool isWarmup = _recordingStartTime != null && DateTime.now().difference(_recordingStartTime!).inMilliseconds < _warmupMs; @@ -327,7 +351,7 @@ class FaceProvider with ChangeNotifier { } else { _consecutiveBadFrames = 0; bool isHoldingCorrectPose = false; - + switch (_currentStep) { case EnrollmentStep.front: isHoldingCorrectPose = _headEulerAngleY.abs() < 18.0; @@ -356,7 +380,6 @@ class FaceProvider with ChangeNotifier { } else if (_currentStep == EnrollmentStep.left) { _currentStep = EnrollmentStep.done; _poseStartTime = null; - notifyListeners(); } } } else { @@ -369,7 +392,8 @@ class FaceProvider with ChangeNotifier { _isPoseValid = false; _isDistanceOk = false; _faceRatio = 0.0; - + + // Wajah hilang selama recording → hitung bad frames if (_recordingState == VideoRecordingState.recording) { final bool isWarmup = _recordingStartTime != null && DateTime.now().difference(_recordingStartTime!).inMilliseconds < _warmupMs; @@ -379,6 +403,7 @@ class FaceProvider with ChangeNotifier { _triggerAbort("Perekaman batal: Wajah tidak ditemukan dalam bingkai."); } } + _poseStartTime = null; } } @@ -406,6 +431,7 @@ class FaceProvider with ChangeNotifier { @override void dispose() { _maxDurationTimer?.cancel(); + _uiRefreshTimer?.cancel(); _faceDetector.close(); super.dispose(); } diff --git a/lib/providers/home_provider.dart b/lib/providers/home_provider.dart index 30e3c62..ffa655f 100644 --- a/lib/providers/home_provider.dart +++ b/lib/providers/home_provider.dart @@ -64,4 +64,18 @@ class HomeProvider extends ChangeNotifier { sudahAbsenPulang: presensiToday!.sudahAbsenPulang, ); } + + void reset() { + user = null; + poinLembur = 0; + presensiToday = null; + pengumumanList = []; + sisaCuti = 0; + izinCount = 0; + alphaCount = 0; + _isLoading = false; + _hasLoaded = false; + _errorMessage = null; + notifyListeners(); + } } diff --git a/lib/providers/notification_provider.dart b/lib/providers/notification_provider.dart index ce1f989..5f64ad6 100644 --- a/lib/providers/notification_provider.dart +++ b/lib/providers/notification_provider.dart @@ -68,4 +68,12 @@ class NotificationProvider extends ChangeNotifier { unreadCount = 0; notifyListeners(); } + + void reset() { + notifications = []; + unreadCount = 0; + _isLoading = false; + _errorMessage = null; + notifyListeners(); + } } diff --git a/lib/providers/pengajuan_provider.dart b/lib/providers/pengajuan_provider.dart index 0ec0d65..bb2eed1 100644 --- a/lib/providers/pengajuan_provider.dart +++ b/lib/providers/pengajuan_provider.dart @@ -11,6 +11,7 @@ class PengajuanProvider extends ChangeNotifier { List listPengajuan = []; bool isLoading = false; + String? errorMessage; int selectedTabIndex = 0; bool hasSignature = false; @@ -82,8 +83,7 @@ class PengajuanProvider extends ChangeNotifier { isLoading = true; notifyListeners(); try { - final dateParts = (data['date'] as String).split('/'); - final tanggalLembur = '${dateParts[2]}-${dateParts[1].padLeft(2, '0')}-${dateParts[0].padLeft(2, '0')}'; + final tanggalLembur = data['date'] as String; final result = await _lemburRepository.submitLembur( tanggalLembur: tanggalLembur, @@ -103,33 +103,73 @@ class PengajuanProvider extends ChangeNotifier { } } - String _convertTo24Hour(String time12h) { - if (!time12h.contains('AM') && !time12h.contains('PM')) { - return time12h; + String _convertTo24Hour(String? timeStr) { + if (timeStr == null || timeStr.isEmpty) return "00:00"; + + // Jika sudah format HH:mm atau HH:mm:ss tanpa AM/PM + if (!timeStr.toUpperCase().contains('AM') && !timeStr.toUpperCase().contains('PM')) { + return timeStr.substring(0, 5); // Ambil HH:mm saja } - final parts = time12h.split(' '); - final timeParts = parts[0].split(':'); - int hour = int.parse(timeParts[0]); - final minute = timeParts[1]; - final period = parts[1]; + try { + // Bersihkan string dan pastikan ada spasi sebelum AM/PM jika menempel + String cleaned = timeStr.toUpperCase().replaceAll('AM', ' AM').replaceAll('PM', ' PM').replaceAll(RegExp(r'\s+'), ' ').trim(); + + final parts = cleaned.split(' '); + if (parts.length < 2) return cleaned.substring(0, 5); - if (period == 'PM' && hour != 12) hour += 12; - if (period == 'AM' && hour == 12) hour = 0; + final timeParts = parts[0].split(':'); + if (timeParts.length < 2) return "00:00"; - return '${hour.toString().padLeft(2, '0')}:$minute'; + int hour = int.tryParse(timeParts[0]) ?? 0; + final minute = timeParts[1]; + final period = parts[1]; + + if (period == 'PM' && hour != 12) hour += 12; + if (period == 'AM' && hour == 12) hour = 0; + + return '${hour.toString().padLeft(2, '0')}:${minute.padLeft(2, '0')}'; + } catch (e) { + print('Error converting time: $e'); + return timeStr.length >= 5 ? timeStr.substring(0, 5) : "00:00"; + } } Future _submitGeneric(String type, Map data) async { isLoading = true; + errorMessage = null; notifyListeners(); try { return await _repository.submitPengajuan(type, data); } catch(e) { - rethrow; + if (e.toString().contains('DioException')) { + try { + final dioError = e as dynamic; + if (dioError.response?.data != null && dioError.response?.data['message'] != null) { + errorMessage = dioError.response?.data['message']; + } else { + errorMessage = 'Pengajuan gagal: Kesalahan server.'; + } + } catch(_) { + errorMessage = 'Pengajuan gagal.'; + } + } else { + errorMessage = e.toString().replaceAll('Exception: ', ''); + } + print('Error submitting $type: $errorMessage'); + return false; } finally { isLoading = false; notifyListeners(); } } + + void reset() { + listPengajuan = []; + isLoading = false; + errorMessage = null; + selectedTabIndex = 0; + hasSignature = false; + notifyListeners(); + } } diff --git a/lib/providers/poin_provider.dart b/lib/providers/poin_provider.dart index 2ccaea2..4e9638c 100644 --- a/lib/providers/poin_provider.dart +++ b/lib/providers/poin_provider.dart @@ -43,14 +43,17 @@ class PoinProvider extends ChangeNotifier { final result = await _repository.getExpiringPoints(); if (result['success']) { - _totalPoin = result['data']['total_poin'] as int?; + _totalPoin = int.tryParse(result['data']['total_poin'].toString()); final expiring = result['data']['expiring_points']; if (expiring != null) { - _expiringPoints = expiring['poin'] as int?; + _expiringPoints = int.tryParse(expiring['poin'].toString()); if (expiring['tanggal_kadaluarsa'] != null) { _expiryDate = DateTime.tryParse(expiring['tanggal_kadaluarsa']); } + } else { + _expiringPoints = null; + _expiryDate = null; } _errorMessage = null; _hasLoadedExpiring = true; @@ -184,4 +187,18 @@ class PoinProvider extends ChangeNotifier { }; } } + + void reset() { + _isLoading = false; + _errorMessage = null; + _totalPoin = null; + _expiringPoints = null; + _expiryDate = null; + _pointHistory = []; + _shiftStart = null; + _shiftEnd = null; + _namaShift = null; + _hasLoadedExpiring = false; + notifyListeners(); + } } diff --git a/lib/providers/signature_provider.dart b/lib/providers/signature_provider.dart index f9ddba7..4248663 100644 --- a/lib/providers/signature_provider.dart +++ b/lib/providers/signature_provider.dart @@ -73,4 +73,11 @@ class SignatureProvider extends ChangeNotifier { notifyListeners(); } } + + void reset() { + currentSignature = null; + isLoading = false; + errorMessage = null; + notifyListeners(); + } } diff --git a/lib/providers/surat_izin_provider.dart b/lib/providers/surat_izin_provider.dart index c36daa0..d1debcf 100644 --- a/lib/providers/surat_izin_provider.dart +++ b/lib/providers/surat_izin_provider.dart @@ -65,4 +65,13 @@ class SuratIzinProvider extends ChangeNotifier { notifyListeners(); } } + + void reset() { + listSurat = []; + selectedSurat = null; + isLoading = false; + isLoadingDetail = false; + errorMessage = null; + notifyListeners(); + } } diff --git a/lib/repositories/auth_repository.dart b/lib/repositories/auth_repository.dart index 8ec2915..fb6d2e3 100644 --- a/lib/repositories/auth_repository.dart +++ b/lib/repositories/auth_repository.dart @@ -79,4 +79,10 @@ class AuthRepository { throw 'Terjadi kesalahan koneksi'; } } + + Future logout() async { + try { + await _apiClient.dio.post('/logout'); + } catch (_) {} + } } diff --git a/lib/repositories/face_repository.dart b/lib/repositories/face_repository.dart index d3b1010..086c36a 100644 --- a/lib/repositories/face_repository.dart +++ b/lib/repositories/face_repository.dart @@ -25,6 +25,8 @@ class FaceRepository { request.headers.addAll({ 'Authorization': 'Bearer $token', 'Accept': 'application/json', + 'ngrok-skip-browser-warning': 'true', + 'Bypass-Tunnel-Reminder': 'true', }); request.files.add(await http.MultipartFile.fromPath( @@ -68,7 +70,7 @@ class FaceRepository { } } - Future> verifyFace(File videoFile, {String tipe = 'presensi'}) async { + Future> verifyFace(File photoFile, {String tipe = 'presensi'}) async { final token = CacheManager.authBox.get('auth_token'); var request = http.MultipartRequest( 'POST', @@ -78,12 +80,14 @@ class FaceRepository { request.headers.addAll({ 'Authorization': 'Bearer $token', 'Accept': 'application/json', + 'ngrok-skip-browser-warning': 'true', + 'Bypass-Tunnel-Reminder': 'true', }); request.files.add(await http.MultipartFile.fromPath( 'file_wajah', - videoFile.path, - contentType: MediaType('video', 'mp4'), + photoFile.path, + contentType: MediaType('image', 'jpeg'), )); request.fields['tipe'] = tipe; @@ -109,6 +113,8 @@ class FaceRepository { headers: { 'Authorization': 'Bearer $token', 'Accept': 'application/json', + 'ngrok-skip-browser-warning': 'true', + 'Bypass-Tunnel-Reminder': 'true', }, ); diff --git a/lib/screens/home/home_screen.dart b/lib/screens/home/home_screen.dart index a3f8c2b..e5a070b 100644 --- a/lib/screens/home/home_screen.dart +++ b/lib/screens/home/home_screen.dart @@ -4,6 +4,7 @@ import '../../core/error_handler.dart'; import '../../core/theme.dart'; import '../../providers/home_provider.dart'; import '../../providers/poin_provider.dart'; +import '../../providers/face_provider.dart'; import '../../widgets/organisms/home_header.dart'; import '../../widgets/organisms/poin_lembur_card.dart'; import '../../widgets/organisms/presensi_section.dart'; @@ -38,7 +39,7 @@ class _HomeScreenState extends State { if (provider.isLoading) { return Scaffold( backgroundColor: AppTheme.bgLight, - body: const SafeArea(child: HomeShimmer()), + body: const SafeArea(bottom: false, child: HomeShimmer()), ); } @@ -46,6 +47,7 @@ class _HomeScreenState extends State { return Scaffold( backgroundColor: AppTheme.bgLight, body: SafeArea( + bottom: false, child: Center( child: Padding( padding: const EdgeInsets.all(32), @@ -89,6 +91,7 @@ class _HomeScreenState extends State { return Scaffold( backgroundColor: AppTheme.bgLight, body: SafeArea( + bottom: false, child: RefreshIndicator( onRefresh: () => provider.fetchDashboardData(force: true), child: SingleChildScrollView( @@ -121,24 +124,76 @@ class _HomeScreenState extends State { PresensiSection( presensiToday: provider.presensiToday, - onPresensi: () { - final sudahMasuk = provider.presensiToday?.sudahAbsenMasuk ?? false; - final sudahPulang = provider.presensiToday?.sudahAbsenPulang ?? false; - String type = 'masuk'; - if (sudahMasuk && !sudahPulang) { - type = 'pulang'; - } - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => PresensiMapScreen(type: type), - ), - ).then((result) { - if (result != null && result['success'] == true) { - provider.fetchDashboardData(force: true); - ErrorHandler.showSuccess(result['message'] ?? 'Presensi berhasil'); + onPresensi: () async { + try { + final faceProvider = context.read(); + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const Center(child: CircularProgressIndicator()), + ); + + await faceProvider.loadFaceStatus(); + + if (!mounted) return; + Navigator.pop(context); // Close loading dialog + + final faceStatus = faceProvider.faceStatus2['status']; + if (faceStatus != 'verified') { + String msg = 'Wajah Anda belum terverifikasi oleh HRD.'; + if (faceStatus == 'not_registered') { + msg = 'Anda belum melakukan registrasi wajah.'; + } else if (faceStatus == 'pending') { + msg = 'Registrasi wajah Anda sedang menunggu verifikasi/training oleh HRD.'; + } else if (faceStatus == 'rejected') { + msg = 'Registrasi wajah Anda ditolak oleh HRD. Harap lakukan registrasi ulang.'; + } + + showDialog( + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Row( + children: [ + Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 28), + SizedBox(width: 8), + Text("Presensi Ditolak", style: TextStyle(fontWeight: FontWeight.bold)), + ], + ), + content: Text(msg), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text("Tutup"), + ), + ], + ), + ); + return; } - }); + + final sudahMasuk = provider.presensiToday?.sudahAbsenMasuk ?? false; + final sudahPulang = provider.presensiToday?.sudahAbsenPulang ?? false; + String type = 'masuk'; + if (sudahMasuk && !sudahPulang) { + type = 'pulang'; + } + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PresensiMapScreen(type: type), + ), + ).then((result) { + if (result != null && result['success'] == true) { + provider.fetchDashboardData(force: true); + ErrorHandler.showSuccess(result['message'] ?? 'Presensi berhasil'); + } + }); + } catch (e) { + if (mounted) Navigator.pop(context); + ErrorHandler.showError("Gagal memeriksa status verifikasi wajah. Coba lagi."); + } }, ), @@ -207,9 +262,11 @@ class _HomeScreenState extends State { ) else SizedBox( - height: 170, + height: 190, // Ditingkatkan untuk memberi ruang bayangan child: ListView.builder( scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(vertical: 10), // Padding vertikal untuk bayangan + clipBehavior: Clip.none, // Mencegah pemotongan shadow itemCount: provider.pengumumanList.length, itemBuilder: (context, index) { return FadeInUp( @@ -220,7 +277,7 @@ class _HomeScreenState extends State { ), ), - const SizedBox(height: 16), + const SizedBox(height: 100), // Memberi ruang untuk floating bottom bar ], ), ), diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 65f4ced..ce1ac48 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -107,9 +107,8 @@ class _MainScreenState extends State with WidgetsBindingObserver { index: _currentIndex, children: _screens, ), - bottomNavigationBar: SafeArea( - child: Container( - margin: const EdgeInsets.fromLTRB(AppTheme.spacingMd, 0, AppTheme.spacingMd, AppTheme.spacingMd), + bottomNavigationBar: Container( + margin: EdgeInsets.fromLTRB(AppTheme.spacingMd, 0, AppTheme.spacingMd, MediaQuery.of(context).padding.bottom + AppTheme.spacingSm), decoration: BoxDecoration( color: AppTheme.bgCard.withOpacity(0.85), borderRadius: BorderRadius.circular(AppTheme.radiusXl), @@ -119,48 +118,51 @@ class _MainScreenState extends State with WidgetsBindingObserver { borderRadius: BorderRadius.circular(AppTheme.radiusXl), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: BottomNavigationBar( - currentIndex: _currentIndex, - onTap: (index) { - setState(() { - _currentIndex = index; - }); - }, - backgroundColor: Colors.transparent, - selectedItemColor: AppTheme.primaryOrange, - unselectedItemColor: AppTheme.textTertiary, - showUnselectedLabels: false, - showSelectedLabels: true, - selectedLabelStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), - type: BottomNavigationBarType.fixed, - elevation: 0, - items: [ - BottomNavigationBarItem( - icon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.grid_view_rounded, size: 24)), - activeIcon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.grid_view_rounded, size: 26)), - label: 'Beranda', - ), - BottomNavigationBarItem( - icon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.fingerprint, size: 24)), - activeIcon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.fingerprint, size: 26)), - label: 'Presensi', - ), - BottomNavigationBarItem( - icon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.insert_page_break_outlined, size: 24)), - activeIcon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.insert_page_break_rounded, size: 26)), - label: 'Pengajuan', - ), - BottomNavigationBarItem( - icon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.person_outline, size: 24)), - activeIcon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.person_rounded, size: 26)), - label: 'Profil', - ), - ], + child: MediaQuery.removePadding( + context: context, + removeBottom: true, + child: BottomNavigationBar( + currentIndex: _currentIndex, + onTap: (index) { + setState(() { + _currentIndex = index; + }); + }, + backgroundColor: Colors.transparent, + selectedItemColor: AppTheme.primaryOrange, + unselectedItemColor: AppTheme.textTertiary, + showUnselectedLabels: false, + showSelectedLabels: true, + selectedLabelStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), + type: BottomNavigationBarType.fixed, + elevation: 0, + items: [ + BottomNavigationBarItem( + icon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.grid_view_rounded, size: 24)), + activeIcon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.grid_view_rounded, size: 26)), + label: 'Beranda', + ), + BottomNavigationBarItem( + icon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.fingerprint, size: 24)), + activeIcon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.fingerprint, size: 26)), + label: 'Presensi', + ), + BottomNavigationBarItem( + icon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.insert_page_break_outlined, size: 24)), + activeIcon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.insert_page_break_rounded, size: 26)), + label: 'Pengajuan', + ), + BottomNavigationBarItem( + icon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.person_outline, size: 24)), + activeIcon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.person_rounded, size: 26)), + label: 'Profil', + ), + ], + ), ), ), ), ), - ), ); } } diff --git a/lib/screens/notification/notifikasi_detail_screen.dart b/lib/screens/notification/notifikasi_detail_screen.dart index 5fd9eff..c5d5045 100644 --- a/lib/screens/notification/notifikasi_detail_screen.dart +++ b/lib/screens/notification/notifikasi_detail_screen.dart @@ -311,7 +311,7 @@ class NotifikasiDetailScreen extends StatelessWidget { ), const SizedBox(height: 12), ...notifikasi.data!.entries - .where((e) => e.key != 'tipe' && e.value != null && e.value.toString().isNotEmpty) + .where((e) => e.key != 'tipe' && !e.key.toLowerCase().startsWith('id') && e.value != null && e.value.toString().isNotEmpty) .map((e) => Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( diff --git a/lib/screens/onboarding/face_enrollment_screen.dart b/lib/screens/onboarding/face_enrollment_screen.dart index 8baa2c7..0d4ff3f 100644 --- a/lib/screens/onboarding/face_enrollment_screen.dart +++ b/lib/screens/onboarding/face_enrollment_screen.dart @@ -74,8 +74,8 @@ class _FaceEnrollmentScreenState extends State _cameraController!.startImageStream((CameraImage image) { if (!mounted) return; final provider = context.read(); - var isIdle = provider.recordingState == VideoRecordingState.idle; - var isRecording = provider.recordingState == VideoRecordingState.recording; + final isIdle = provider.recordingState == VideoRecordingState.idle; + final isRecording = provider.recordingState == VideoRecordingState.recording; if (isIdle || isRecording) { provider.processCameraImage( image, @@ -117,14 +117,17 @@ class _FaceEnrollmentScreenState extends State ), ), TextButton( - onPressed: () { + onPressed: () async { Navigator.pop(ctx); - context.read().logout(); - Navigator.pushNamedAndRemoveUntil( - context, - '/login', - (route) => false, - ); + await context.read().logout(); + if (context.mounted) { + context.read().reset(); + Navigator.pushNamedAndRemoveUntil( + context, + '/login', + (route) => false, + ); + } }, child: Text( "Keluar", @@ -143,26 +146,28 @@ class _FaceEnrollmentScreenState extends State } HapticFeedback.mediumImpact(); - - // Matikan stream gambar sementara (aturan wajib Android camera plugin) - if (_cameraController!.value.isStreamingImages) { - await _cameraController!.stopImageStream(); - } - // Beri waktu kamera settle (fix layar gelap di beberapa HP) - await Future.delayed(const Duration(milliseconds: 500)); + // Wajib stop image stream sebelum startVideoRecording (camera 0.10.x) + try { + if (_cameraController!.value.isStreamingImages) { + await _cameraController!.stopImageStream(); + } + } catch (_) {} - // Mulai rekam video dan oper callback onAvailable + // Gunakan onAvailable agar frame tetap diterima selama recording await provider.startRecording( _cameraController!, onAvailable: (CameraImage image) { if (!mounted) return; - provider.processCameraImage( - image, - _frontCamera!, - CameraUtils.rotationIntToImageRotation( - _frontCamera!.sensorOrientation), - ); + final p = context.read(); + if (p.recordingState == VideoRecordingState.recording) { + p.processCameraImage( + image, + _frontCamera!, + CameraUtils.rotationIntToImageRotation( + _frontCamera!.sensorOrientation), + ); + } }, ); } @@ -288,7 +293,7 @@ class _FaceEnrollmentScreenState extends State padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6), decoration: BoxDecoration( - color: Colors.red.withOpacity(0.8), + color: AppTheme.primaryBlue.withOpacity(0.8), borderRadius: BorderRadius.circular(20), ), child: Row( @@ -304,7 +309,7 @@ class _FaceEnrollmentScreenState extends State ), const SizedBox(width: 6), Text( - 'REC', + 'SCAN', style: AppTheme.bodySmall .copyWith(color: Colors.white, fontWeight: FontWeight.bold), ), @@ -460,7 +465,7 @@ class _FaceEnrollmentScreenState extends State ClipRRect( borderRadius: BorderRadius.circular(8), child: LinearProgressIndicator( - value: provider.stepProgress.clamp(0.0, 1.0), + value: provider.totalProgress.clamp(0.0, 1.0), minHeight: 8, backgroundColor: Colors.white.withOpacity(0.2), @@ -476,7 +481,7 @@ class _FaceEnrollmentScreenState extends State mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'Progres Pose (${(provider.stepProgress * 100).toInt()}%)', + 'Progres Pose (${(provider.totalProgress * 100).toInt()}%)', style: AppTheme.bodySmall.copyWith( color: Colors.white70, ), @@ -594,9 +599,9 @@ class _FaceEnrollmentScreenState extends State ), CustomButton( text: provider.allChecksValid - ? "Mulai Rekam Wajah" + ? "Mulai Pindai Wajah" : "Penuhi Syarat di Atas", - icon: Icons.videocam, + icon: Icons.face_retouching_natural, backgroundColor: provider.allChecksValid ? AppTheme.statusGreen : AppTheme.primaryDark.withOpacity(0.5), @@ -679,10 +684,10 @@ class _FaceEnrollmentScreenState extends State text: "Coba Lagi", icon: Icons.refresh, backgroundColor: AppTheme.primaryOrange, - onPressed: () { + onPressed: () async { setState(() => _successHandled = false); provider.reset(); - _restartCameraStream(); + await _restartCameraStream(); }, ), ], @@ -738,24 +743,21 @@ class _FaceEnrollmentScreenState extends State ); } - void _restartCameraStream() { - if (_cameraController == null || - !_cameraController!.value.isInitialized) return; - + Future _restartCameraStream() async { + // Dispose controller lama dulu agar tidak ada konflik + setState(() => _isCameraInitialized = false); try { - if (!_cameraController!.value.isStreamingImages) { - _cameraController!.startImageStream((CameraImage image) { - if (!mounted) return; - final provider = context.read(); - provider.processCameraImage( - image, - _frontCamera!, - CameraUtils.rotationIntToImageRotation( - _frontCamera!.sensorOrientation), - ); - }); + if (_cameraController != null) { + if (_cameraController!.value.isStreamingImages) { + await _cameraController!.stopImageStream(); + } + await _cameraController!.dispose(); + _cameraController = null; } } catch (_) {} + + // Reinisialisasi penuh dengan NV21 agar deteksi wajah berfungsi kembali + await _initializeCamera(); } String _getDistanceLabel(FaceProvider provider) { @@ -885,7 +887,7 @@ class _FaceEnrollmentScreenState extends State case VideoRecordingState.idle: return Icons.face; case VideoRecordingState.recording: - return Icons.videocam; + return Icons.camera_alt; case VideoRecordingState.uploading: return Icons.cloud_upload; case VideoRecordingState.success: diff --git a/lib/screens/pengajuan/forms/cuti_form_screen.dart b/lib/screens/pengajuan/forms/cuti_form_screen.dart index 06bb6f6..af6db0e 100644 --- a/lib/screens/pengajuan/forms/cuti_form_screen.dart +++ b/lib/screens/pengajuan/forms/cuti_form_screen.dart @@ -60,7 +60,8 @@ class _CutiFormScreenState extends State { Navigator.pop(context); ErrorHandler.showSuccess('Pengajuan Cuti Berhasil'); } else if (mounted) { - ErrorHandler.showError('Pengajuan gagal. Periksa kembali form atau ukuran file (maksimal 10MB)'); + final errorMsg = context.read().errorMessage; + ErrorHandler.showError(errorMsg ?? 'Pengajuan gagal. Periksa kembali form.'); } } diff --git a/lib/screens/pengajuan/forms/izin_form_screen.dart b/lib/screens/pengajuan/forms/izin_form_screen.dart index 7399a81..886de26 100644 --- a/lib/screens/pengajuan/forms/izin_form_screen.dart +++ b/lib/screens/pengajuan/forms/izin_form_screen.dart @@ -62,10 +62,13 @@ class _IzinFormScreenState extends State { if (success && mounted) { Navigator.pop(context); ErrorHandler.showSuccess('Pengajuan Izin Berhasil'); + } else if (mounted) { + final errorMsg = context.read().errorMessage; + ErrorHandler.showError(errorMsg ?? 'Pengajuan gagal. Periksa kembali form atau ukuran file.'); } } catch (e) { if (mounted) { - ErrorHandler.showError(e.toString().replaceAll('Exception: ', '')); + ErrorHandler.showError('Terjadi kesalahan sistem.'); } } } @@ -237,13 +240,15 @@ class _IzinFormScreenState extends State { overflow: TextOverflow.ellipsis, ), ), - if (_uploadedFileName != null) + if (_uploadedFileName != null) IconButton( - icon: const Icon(Icons.close, size: 24), + icon: const Icon(Icons.close, size: 18), onPressed: () => setState(() { _uploadedFileName = null; _uploadedFilePath = null; }), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), ), ], ), diff --git a/lib/screens/pengajuan/forms/lembur_form_screen.dart b/lib/screens/pengajuan/forms/lembur_form_screen.dart index 2c904f4..8a3f0f5 100644 --- a/lib/screens/pengajuan/forms/lembur_form_screen.dart +++ b/lib/screens/pengajuan/forms/lembur_form_screen.dart @@ -4,8 +4,10 @@ import '../../../core/theme.dart'; import '../../../widgets/atoms/custom_text_field.dart'; import '../../../widgets/atoms/custom_button.dart'; import '../../../providers/pengajuan_provider.dart'; +import '../../../providers/calendar_provider.dart'; import 'package:provider/provider.dart'; import '../../../services/api_client.dart'; +import 'package:intl/intl.dart'; class LemburFormScreen extends StatefulWidget { const LemburFormScreen({Key? key}) : super(key: key); @@ -21,6 +23,10 @@ class _LemburFormScreenState extends State { final _reasonController = TextEditingController(); final _formKey = GlobalKey(); + DateTime? _selectedDate; + TimeOfDay? _selectedStartTime; + TimeOfDay? _selectedEndTime; + int? _selectedKompensasi; List> _jenisKompensasi = []; @@ -37,6 +43,10 @@ class _LemburFormScreenState extends State { void initState() { super.initState(); _fetchKompensasi(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final now = DateTime.now(); + context.read().fetchMonthlySchedule(now.month, now.year); + }); } Future _fetchKompensasi() async { @@ -56,29 +66,94 @@ class _LemburFormScreenState extends State { } } - Future _selectDate(TextEditingController controller) async { - final now = DateTime.now(); - final DateTime? picked = await showDatePicker( - context: context, - initialDate: now, - firstDate: DateTime(now.year, now.month - 1, 1), - lastDate: DateTime(2101), - ); - if (picked != null) { + void _updateTimeFromSchedule(DateTime date) { + final schedule = context.read().getScheduleByDate(date); + if (schedule != null && schedule.jamPulang != '-' && schedule.jamPulang.contains(':')) { setState(() { - controller.text = "${picked.day}/${picked.month}/${picked.year}"; + final timeParts = schedule.jamPulang.split(':'); + if (timeParts.length >= 2) { + _selectedStartTime = TimeOfDay(hour: int.parse(timeParts[0]), minute: int.parse(timeParts[1])); + _startTimeController.text = _formatTimeOfDay(_selectedStartTime!); + } }); } } - Future _selectTime(TextEditingController controller) async { - final TimeOfDay? picked = await showTimePicker( + String _formatTimeOfDay(TimeOfDay time) { + final now = DateTime.now(); + final dt = DateTime(now.year, now.month, now.day, time.hour, time.minute); + return DateFormat('HH:mm').format(dt); + } + + Future _selectDate(TextEditingController controller) async { + final now = DateTime.now(); + final DateTime? picked = await showDatePicker( context: context, - initialTime: TimeOfDay.now(), + initialDate: _selectedDate ?? now, + firstDate: DateTime(now.year, now.month - 1, 1), + lastDate: DateTime(now.year, now.month + 2, 0), ); if (picked != null) { setState(() { - controller.text = picked.format(context); + _selectedDate = picked; + controller.text = DateFormat('yyyy-MM-dd').format(picked); + }); + + if (picked.month != now.month) { + await context.read().fetchMonthlySchedule(picked.month, picked.year); + } + _updateTimeFromSchedule(picked); + } + } + + Future _selectStartTime() async { + final TimeOfDay? picked = await showTimePicker( + context: context, + initialTime: _selectedStartTime ?? TimeOfDay.now(), + helpText: 'Pilih Jam Mulai Lembur', + ); + if (picked != null) { + setState(() { + _selectedStartTime = picked; + _startTimeController.text = _formatTimeOfDay(picked); + + // Reset end time if it's before start time + if (_selectedEndTime != null) { + final startMins = _selectedStartTime!.hour * 60 + _selectedStartTime!.minute; + final endMins = _selectedEndTime!.hour * 60 + _selectedEndTime!.minute; + if (endMins <= startMins) { + _selectedEndTime = null; + _endTimeController.clear(); + } + } + }); + } + } + + Future _selectEndTime() async { + if (_selectedStartTime == null) { + ErrorHandler.showWarning('Silakan pilih jam mulai terlebih dahulu'); + return; + } + + final TimeOfDay? picked = await showTimePicker( + context: context, + initialTime: _selectedEndTime ?? _selectedStartTime ?? TimeOfDay.now(), + helpText: 'Pilih Jam Selesai Lembur', + ); + + if (picked != null) { + final startMins = _selectedStartTime!.hour * 60 + _selectedStartTime!.minute; + final pickedMins = picked.hour * 60 + picked.minute; + + if (pickedMins <= startMins) { + ErrorHandler.showWarning('Jam selesai harus melewati jam mulai lembur'); + return; + } + + setState(() { + _selectedEndTime = picked; + _endTimeController.text = _formatTimeOfDay(picked); }); } } @@ -126,7 +201,8 @@ class _LemburFormScreenState extends State { controller: _dateController, readOnly: true, onTap: () => _selectDate(_dateController), - suffixIcon: const Icon(Icons.calendar_today), + suffixIcon: const Icon(Icons.calendar_today, color: AppTheme.primaryBlue, size: 20), + hint: "Pilih Tanggal", validator: (v) => v!.isEmpty ? "Wajib diisi" : null, ), const SizedBox(height: AppTheme.spacingMd), @@ -137,9 +213,10 @@ class _LemburFormScreenState extends State { label: "Jam Mulai", controller: _startTimeController, readOnly: true, - onTap: () => _selectTime(_startTimeController), - suffixIcon: const Icon(Icons.access_time), - validator: (v) => v!.isEmpty ? "Wajib" : null, + onTap: _selectStartTime, + suffixIcon: const Icon(Icons.access_time, color: AppTheme.primaryBlue, size: 20), + hint: "00:00", + validator: (v) => v!.isEmpty ? "Wajib" : null, ), ), const SizedBox(width: AppTheme.spacingMd), @@ -148,9 +225,10 @@ class _LemburFormScreenState extends State { label: "Jam Selesai", controller: _endTimeController, readOnly: true, - onTap: () => _selectTime(_endTimeController), - suffixIcon: const Icon(Icons.access_time), - validator: (v) => v!.isEmpty ? "Wajib" : null, + onTap: _selectEndTime, + suffixIcon: const Icon(Icons.access_time, color: AppTheme.primaryBlue, size: 20), + hint: "00:00", + validator: (v) => v!.isEmpty ? "Wajib" : null, ), ), ], @@ -160,6 +238,7 @@ class _LemburFormScreenState extends State { label: "Keterangan Tugas", controller: _reasonController, maxLines: 3, + hint: "Penjelasan pekerjaan yang dilakukan...", validator: (v) => v!.isEmpty ? "Wajib diisi" : null, ), const SizedBox(height: AppTheme.spacingMd), @@ -218,3 +297,4 @@ class _LemburFormScreenState extends State { ); } } + diff --git a/lib/screens/pengajuan/forms/sakit_form_screen.dart b/lib/screens/pengajuan/forms/sakit_form_screen.dart index 21a9672..9cd249f 100644 --- a/lib/screens/pengajuan/forms/sakit_form_screen.dart +++ b/lib/screens/pengajuan/forms/sakit_form_screen.dart @@ -70,7 +70,8 @@ class _SakitFormScreenState extends State { Navigator.pop(context); ErrorHandler.showSuccess('Pengajuan Sakit Berhasil'); } else if (mounted) { - ErrorHandler.showError('Pengajuan gagal. Periksa kembali form atau ukuran file (maksimal 10MB)'); + final errorMsg = context.read().errorMessage; + ErrorHandler.showError(errorMsg ?? 'Pengajuan gagal. Periksa kembali form atau ukuran file.'); } } @@ -78,8 +79,8 @@ class _SakitFormScreenState extends State { final DateTime? picked = await showDatePicker( context: context, initialDate: _startDate ?? DateTime.now(), - firstDate: DateTime.now().subtract(const Duration(days: 7)), - lastDate: DateTime.now(), + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 30)), helpText: "Pilih Tanggal Mulai Sakit", ); if (picked != null) { @@ -178,6 +179,7 @@ class _SakitFormScreenState extends State { ), ), + Row( children: [ Expanded( diff --git a/lib/screens/poin/point_usage_screen.dart b/lib/screens/poin/point_usage_screen.dart index 5cd3ea0..3f6eeeb 100644 --- a/lib/screens/poin/point_usage_screen.dart +++ b/lib/screens/poin/point_usage_screen.dart @@ -41,7 +41,7 @@ class _PointUsageScreenState extends State { }); WidgetsBinding.instance.addPostFrameCallback((_) { - context.read().loadExpiringPoints(); + context.read().loadExpiringPoints(force: true); }); } diff --git a/lib/screens/presensi/camera_capture_screen.dart b/lib/screens/presensi/camera_capture_screen.dart index 1c1b8b8..a8cadc6 100644 --- a/lib/screens/presensi/camera_capture_screen.dart +++ b/lib/screens/presensi/camera_capture_screen.dart @@ -27,33 +27,32 @@ class _CameraCaptureScreenState extends State bool _isFaceDetected = false; bool _isDistanceOk = false; bool _isBrightnessOk = false; + bool _isLightingUniform = false; + bool _isPoseValid = false; double _faceRatio = 0.0; double _brightness = 0.0; + double _lightingUniformity = 0.0; + double _headEulerAngleY = 0.0; - // State rekam video - bool _isRecording = false; - bool _isRecordingDone = false; - XFile? _recordedVideo; + // State capture foto + bool _isCaptureDone = false; XFile? _capturedPhoto; - // Countdown - int _countdown = 1; - int _recordingSecondsLeft = 1; - late FaceDetector _faceDetector; late AnimationController _pulseController; bool get _allChecksValid => - _isFaceDetected && _isDistanceOk && _isBrightnessOk; + _isFaceDetected && _isDistanceOk && _isBrightnessOk && _isLightingUniform && _isPoseValid; String get _qualityMessage { - if (_isRecordingDone) return 'Video berhasil direkam ✓'; - if (_isRecording) return 'Tetap hadap kamera... ($_recordingSecondsLeft detik)'; + if (_isCaptureDone) return 'Pemotretan berhasil ✓'; if (!_isFaceDetected) return 'Arahkan wajah ke dalam bingkai'; - if (_faceRatio < 0.20) return 'Terlalu jauh, dekatkan wajah'; - if (_faceRatio > 0.70) return 'Terlalu dekat, mundur sedikit'; + if (_faceRatio < 0.25) return 'Terlalu jauh, dekatkan wajah'; + if (_faceRatio > 0.75) return 'Terlalu dekat, mundur sedikit'; if (!_isBrightnessOk) return 'Pencahayaan kurang terang'; - return 'Siap — tekan tombol untuk merekam'; + if (!_isLightingUniform) return 'Ada bayangan, hadap cahaya'; + if (!_isPoseValid) return 'Hadap lurus ke kamera'; + return 'Siap — ambil foto'; } @override @@ -67,10 +66,10 @@ class _CameraCaptureScreenState extends State _faceDetector = FaceDetector( options: FaceDetectorOptions( enableClassification: false, - enableLandmarks: false, + enableLandmarks: true, enableContours: false, - enableTracking: false, - performanceMode: FaceDetectorMode.fast, + enableTracking: true, + performanceMode: FaceDetectorMode.accurate, ), ); _initializeCamera(); @@ -97,7 +96,7 @@ class _CameraCaptureScreenState extends State if (!mounted) return; _controller!.startImageStream((CameraImage image) { - if (!mounted || _isRecording || _isRecordingDone) return; + if (!mounted || _isCaptureDone) return; _processFrame(image); }); @@ -111,8 +110,11 @@ class _CameraCaptureScreenState extends State _isProcessingFrame = true; try { - _brightness = BrightnessUtils.calculateBrightness(image); - _isBrightnessOk = _brightness > 60; + _brightness = BrightnessUtils.calculateBrightness(image, centerOnly: true); + _isBrightnessOk = _brightness > 70; + + _lightingUniformity = BrightnessUtils.calculateFaceUniformity(image); + _isLightingUniform = _lightingUniformity > 0.45; final inputImage = CameraUtils.inputImageFromCameraImage( image: image, @@ -125,13 +127,25 @@ class _CameraCaptureScreenState extends State if (faces.isNotEmpty) { final face = faces.first; - final imageWidth = image.width.toDouble(); - _faceRatio = face.boundingBox.width / imageWidth; + + _headEulerAngleY = face.headEulerAngleY ?? 0.0; + _isPoseValid = _headEulerAngleY.abs() < 18.0; + + final rotation = CameraUtils.rotationIntToImageRotation(_frontCamera!.sensorOrientation); + final bool isRotated90or270 = + rotation == InputImageRotation.rotation90deg || + rotation == InputImageRotation.rotation270deg; + final double effectiveWidth = isRotated90or270 + ? image.height.toDouble() + : image.width.toDouble(); + + _faceRatio = face.boundingBox.width / effectiveWidth; _isFaceDetected = true; - _isDistanceOk = _faceRatio >= 0.20 && _faceRatio <= 0.70; + _isDistanceOk = _faceRatio >= 0.25 && _faceRatio <= 0.75; } else { _isFaceDetected = false; _isDistanceOk = false; + _isPoseValid = false; _faceRatio = 0.0; } @@ -143,8 +157,8 @@ class _CameraCaptureScreenState extends State } } - Future _startRecording() async { - if (!_allChecksValid || _isRecording) return; + Future _capturePhoto() async { + if (!_allChecksValid || _isCaptureDone) return; try { await _controller!.stopImageStream(); @@ -155,61 +169,42 @@ class _CameraCaptureScreenState extends State photo = await _controller!.takePicture(); } catch (e) { debugPrint("Gagal mengambil foto: $e"); + _controller!.startImageStream((CameraImage image) { + if (!mounted || _isCaptureDone) return; + _processFrame(image); + }); + return; } - setState(() { - _isRecording = true; - _recordingSecondsLeft = 1; - }); - HapticFeedback.mediumImpact(); - await _controller!.prepareForVideoRecording(); - await _controller!.startVideoRecording(); - - // Countdown 1 detik - for (int i = 1; i >= 1; i--) { - if (!mounted) return; - setState(() => _recordingSecondsLeft = i); - await Future.delayed(const Duration(seconds: 1)); - } - - if (!mounted) return; - - final video = await _controller!.stopVideoRecording(); - - HapticFeedback.heavyImpact(); - setState(() { - _isRecording = false; - _isRecordingDone = true; - _recordedVideo = video; + _isCaptureDone = true; _capturedPhoto = photo; }); } void _retake() { setState(() { - _isRecording = false; - _isRecordingDone = false; - _recordedVideo = null; + _isCaptureDone = false; _capturedPhoto = null; _isFaceDetected = false; _isDistanceOk = false; + _isPoseValid = false; + _isLightingUniform = false; _isBrightnessOk = false; }); _controller!.startImageStream((CameraImage image) { - if (!mounted || _isRecording || _isRecordingDone) return; + if (!mounted || _isCaptureDone) return; _processFrame(image); }); } - void _useVideo() { - if (_recordedVideo != null) { + void _usePhoto() { + if (_capturedPhoto != null) { Navigator.pop(context, { - 'video': File(_recordedVideo!.path), - 'photo': _capturedPhoto != null ? File(_capturedPhoto!.path) : null, + 'photo': File(_capturedPhoto!.path), }); } } @@ -244,8 +239,15 @@ class _CameraCaptureScreenState extends State ), body: Stack( children: [ - // Preview kamera - if (_isCameraInitialized) + // Preview kamera atau Foto yang diambil + if (_isCaptureDone && _capturedPhoto != null) + SizedBox.expand( + child: Image.file( + File(_capturedPhoto!.path), + fit: BoxFit.cover, + ), + ) + else if (_isCameraInitialized) SizedBox.expand( child: FittedBox( fit: BoxFit.cover, @@ -261,65 +263,17 @@ class _CameraCaptureScreenState extends State else const Center(child: CircularProgressIndicator(color: Colors.white)), - // Overlay sambil recording: REC badge + countdown besar - if (_isRecording) - Positioned.fill( - child: Container( - color: Colors.black.withOpacity(0.15), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 80), - AnimatedBuilder( - animation: _pulseController, - builder: (context, child) => Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: Colors.red.withOpacity( - 0.7 + 0.3 * _pulseController.value), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 10, - height: 10, - decoration: const BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - const Text('REC', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 14)), - ], - ), - ), - ), - ], - ), - ), - ), - ), // Scanner overlay ScannerOverlay( - borderColor: _isRecordingDone + borderColor: _isCaptureDone ? AppTheme.statusGreen - : _isRecording - ? Colors.red - : _allChecksValid - ? AppTheme.statusGreen - : _isFaceDetected - ? AppTheme.statusYellow - : AppTheme.primaryBlue, - isScanning: !_isRecordingDone, + : _allChecksValid + ? AppTheme.statusGreen + : _isFaceDetected + ? AppTheme.statusYellow + : AppTheme.primaryBlue, + isScanning: !_isCaptureDone, ), // Header info @@ -330,11 +284,9 @@ class _CameraCaptureScreenState extends State child: Column( children: [ Text( - _isRecording - ? 'Merekam Video...' - : _isRecordingDone - ? 'Video Siap' - : 'Rekam Video Wajah', + _isCaptureDone + ? 'Pemotretan Selesai' + : 'Ambil Foto Wajah', style: AppTheme.heading3.copyWith(color: Colors.white), ), const SizedBox(height: 10), @@ -355,13 +307,11 @@ class _CameraCaptureScreenState extends State _qualityMessage, textAlign: TextAlign.center, style: AppTheme.bodyLarge.copyWith( - color: _isRecordingDone + color: _isCaptureDone ? AppTheme.statusGreen - : _isRecording - ? Colors.red.shade200 - : _allChecksValid - ? AppTheme.statusGreen - : Colors.white, + : _allChecksValid + ? AppTheme.statusGreen + : Colors.white, fontWeight: FontWeight.bold, ), ), @@ -370,44 +320,47 @@ class _CameraCaptureScreenState extends State ), // Quality chips (sebelum rekam) - if (!_isRecording && !_isRecordingDone) ...[ + if (!_isCaptureDone) ...[ const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.center, + Wrap( + spacing: 6, + runSpacing: 6, + alignment: WrapAlignment.center, children: [ - _buildChip(Icons.face, 'Wajah', _isFaceDetected), - const SizedBox(width: 6), - _buildChip(Icons.straighten, 'Jarak', _isDistanceOk), - const SizedBox(width: 6), - _buildChip( - Icons.wb_sunny_outlined, 'Cahaya', _isBrightnessOk), + _buildQualityChip( + icon: Icons.straighten, + label: _isFaceDetected + ? (_faceRatio < 0.25 + ? 'Terlalu Jauh' + : _faceRatio > 0.75 + ? 'Terlalu Dekat' + : 'Jarak ✓') + : 'Jarak', + isOk: _isDistanceOk, + isDetected: _isFaceDetected, + ), + _buildQualityChip( + icon: Icons.wb_sunny_outlined, + label: 'Cahaya', + isOk: _isBrightnessOk, + isDetected: _isFaceDetected, + ), + _buildQualityChip( + icon: Icons.blur_on, + label: 'Bayangan', + isOk: _isLightingUniform, + isDetected: _isFaceDetected, + ), + _buildQualityChip( + icon: Icons.face, + label: 'Hadap Depan', + isOk: _isPoseValid, + isDetected: _isFaceDetected, + ), ], ), ], - // Countdown bundar besar saat merekam - if (_isRecording) ...[ - const SizedBox(height: 20), - Container( - width: 72, - height: 72, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: Colors.red, width: 3), - color: Colors.black.withOpacity(0.4), - ), - child: Center( - child: Text( - '$_recordingSecondsLeft', - style: const TextStyle( - color: Colors.white, - fontSize: 36, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ], ], ), ), @@ -417,13 +370,13 @@ class _CameraCaptureScreenState extends State bottom: 40, left: 20, right: 20, - child: _isRecordingDone + child: _isCaptureDone ? Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Expanded( child: CustomButton( - text: "Rekam Ulang", + text: "Pindai Ulang", type: ButtonType.secondary, onPressed: _retake, icon: Icons.refresh, @@ -434,18 +387,16 @@ class _CameraCaptureScreenState extends State child: CustomButton( text: "Gunakan", type: ButtonType.primary, - onPressed: _useVideo, + onPressed: _usePhoto, icon: Icons.check, backgroundColor: AppTheme.statusGreen, ), ), ], ) - : _isRecording - ? const SizedBox.shrink() - : Center( - child: GestureDetector( - onTap: _allChecksValid ? _startRecording : null, + : Center( + child: GestureDetector( + onTap: _allChecksValid ? _capturePhoto : null, child: Container( width: 76, height: 76, @@ -458,21 +409,21 @@ class _CameraCaptureScreenState extends State width: 6, ), color: _allChecksValid - ? Colors.red.withOpacity(0.8) + ? AppTheme.primaryBlue.withOpacity(0.8) : Colors.white.withOpacity(0.05), boxShadow: _allChecksValid ? [ BoxShadow( - color: Colors.red.withOpacity(0.4), + color: AppTheme.primaryBlue.withOpacity(0.4), blurRadius: 16, ) ] : null, ), child: const Icon( - Icons.videocam, + Icons.face_retouching_natural, color: Colors.white, - size: 30, + size: 36, ), ), ), @@ -483,23 +434,42 @@ class _CameraCaptureScreenState extends State ); } - Widget _buildChip(IconData icon, String label, bool isOk) { - final color = isOk ? AppTheme.statusGreen : Colors.white.withOpacity(0.4); + Widget _buildQualityChip({ + required IconData icon, + required String label, + required bool isOk, + required bool isDetected, + }) { + final color = !isDetected + ? Colors.white.withOpacity(0.4) + : isOk + ? AppTheme.statusGreen + : AppTheme.statusYellow; + return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: color.withOpacity(0.15), - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(20), border: Border.all(color: color.withOpacity(0.4)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(isOk ? Icons.check_circle : icon, color: color, size: 12), + Icon( + isOk && isDetected ? Icons.check_circle : icon, + color: color, + size: 14, + ), const SizedBox(width: 4), - Text(label, - style: TextStyle( - color: color, fontSize: 10, fontWeight: FontWeight.w600)), + Text( + label, + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), ], ), ); diff --git a/lib/screens/presensi/presensi_map_screen.dart b/lib/screens/presensi/presensi_map_screen.dart index 9033809..afac4c1 100644 --- a/lib/screens/presensi/presensi_map_screen.dart +++ b/lib/screens/presensi/presensi_map_screen.dart @@ -13,6 +13,7 @@ import '../../repositories/face_repository.dart'; import '../../widgets/atoms/custom_button.dart'; import '../../widgets/organisms/map_view_organism.dart'; import 'camera_capture_screen.dart'; +import '../../widgets/organisms/mock_location_blocker.dart'; class PresensiMapScreen extends StatefulWidget { final String type; @@ -86,6 +87,14 @@ class _PresensiMapScreenState extends State { final attendanceProvider = context.watch(); final user = homeProvider.user; + if (attendanceProvider.isMocked) { + return MockLocationBlocker( + onRetry: () { + _fetchLocation(); + }, + ); + } + final currentLocation = attendanceProvider.currentLocation ?? _officeLocation; final distance = attendanceProvider.currentLocation != null @@ -463,7 +472,10 @@ class _PresensiMapScreenState extends State { parts.length > 2 ? int.parse(parts[2]) : 0 ); - if (now.isBefore(scheduled)) { + // Toleransi pulang 15 menit lebih awal + final batasAwalPulang = scheduled.subtract(const Duration(minutes: 15)); + + if (now.isBefore(batasAwalPulang)) { _showEarlyCheckoutDialog(context); return; } @@ -487,7 +499,8 @@ class _PresensiMapScreenState extends State { parts.length > 2 ? int.parse(parts[2]) : 0 ); - final batasToleransi = scheduled.add(const Duration(minutes: 10)); + // Toleransi keterlambatan 15 menit + final batasToleransi = scheduled.add(const Duration(minutes: 15)); if (now.isAfter(batasToleransi)) { _showLateCheckinDialog(context, isWithinRadius); @@ -875,6 +888,9 @@ class _PresensiMapScreenState extends State { } Future _startFaceRecognitionFlow(BuildContext context) async { + // Capture provider di awal sebelum SEMUA async gap (fix use_build_context_synchronously) + final provider = context.read(); + final Map? captureResult = await Navigator.push( context, MaterialPageRoute( @@ -884,56 +900,44 @@ class _PresensiMapScreenState extends State { if (captureResult == null || !mounted) return; - final File? videoFile = captureResult['video']; final File? photoFile = captureResult['photo']; - if (videoFile == null || photoFile == null) { + if (photoFile == null) { _showErrorSnackBar("Gagal mengambil data kamera."); return; } + // MB-10: Tampilkan overlay loading SEGERA setelah foto diterima, + // sebelum proses verifikasi ke Flask dimulai agar tidak ada layar kosong/freeze. setState(() { _isProcessing = true; - _processingStatus = "Memproses video wajah..."; + _processingStatus = "Mempersiapkan data wajah..."; }); + // Beri sedikit jeda agar UI sempat render overlay sebelum operasi berat dimulai + await Future.delayed(const Duration(milliseconds: 100)); + try { - final faceResult = await FaceRepository().verifyFace(videoFile); + if (!mounted) return; + setState(() => _processingStatus = "Memverifikasi wajah..."); + + final faceResult = await FaceRepository().verifyFace(photoFile); final bool isVerified = faceResult['verified'] == true; - // Mode video: gunakan approved_ratio (lebih representatif dari SVM proba) - // Mode foto: fallback ke confidence biasa - final double? approvedRatio = (faceResult['approved_ratio'] is num) - ? (faceResult['approved_ratio'] as num).toDouble() - : null; - final double? rawConf = (faceResult['confidence'] is num) - ? (faceResult['confidence'] as num).toDouble() - : null; - final double? confidence = approvedRatio ?? rawConf; - final int? framesTotal = (faceResult['frames_total'] as num?)?.toInt(); - final int? framesApproved = (faceResult['frames_approved'] as num?)?.toInt(); - if (!isVerified) { if (!mounted) return; setState(() { _isProcessing = false; }); - _showFaceFailedDialog( - confidence: confidence, - framesTotal: framesTotal, - framesApproved: framesApproved, - message: faceResult['message']?.toString() ?? "Wajah tidak cocok.", - ); + _showFaceFailedDialog(); return; } - setState(() { - _processingStatus = "Mengirim data presensi..."; - }); + if (!mounted) return; + setState(() => _processingStatus = "Mengirim data presensi..."); - final provider = context.read(); final success = await provider.submitPresensi(widget.type, photoFile); if (!mounted) return; @@ -944,7 +948,7 @@ class _PresensiMapScreenState extends State { if (success) { HapticFeedback.heavyImpact(); - Navigator.pop(context, { + Navigator.pop(this.context, { 'success': true, 'message': widget.type == 'masuk' ? 'Absen masuk berhasil!' @@ -967,12 +971,7 @@ class _PresensiMapScreenState extends State { } } - void _showFaceFailedDialog({ - double? confidence, - int? framesTotal, - int? framesApproved, - required String message, - }) { + void _showFaceFailedDialog() { showDialog( context: context, builder: (ctx) => AlertDialog( @@ -988,24 +987,14 @@ class _PresensiMapScreenState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(message, style: AppTheme.bodyMedium), - if (framesTotal != null) ...[ - const SizedBox(height: 12), - Text( - "Frame Cocok: $framesApproved/$framesTotal", - style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary), - ), - ] else if (confidence != null && confidence < 1.0) ...[ - const SizedBox(height: 12), - Text( - "Confidence: ${(confidence * 100).toStringAsFixed(1)}%", - style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary), - ), - ], + Text( + "Wajah Anda tidak dikenali oleh sistem.", + style: AppTheme.bodyMedium, + ), const SizedBox(height: 12), Text( - "Pastikan pencahayaan cukup dan wajah terlihat jelas.", - style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary), + "Tips:\n• Pastikan wajah terlihat jelas tanpa masker/kacamata.\n• Pastikan pencahayaan terang dan tidak ada bayangan gelap di wajah.\n• Hadap lurus ke arah kamera.", + style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary, height: 1.4), ), ], ), diff --git a/lib/screens/presensi/presensi_screen.dart b/lib/screens/presensi/presensi_screen.dart index 7ab94c2..cd72435 100644 --- a/lib/screens/presensi/presensi_screen.dart +++ b/lib/screens/presensi/presensi_screen.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../../core/theme.dart'; import '../../providers/home_provider.dart'; import '../../providers/attendance_provider.dart'; +import '../../providers/face_provider.dart'; import '../../widgets/organisms/presensi_section.dart'; import '../../widgets/molecules/stat_card.dart'; import '../../widgets/molecules/attendance_time_card.dart'; @@ -63,6 +64,8 @@ class _PresensiScreenState extends State { Widget build(BuildContext context) { final homeProvider = context.watch(); final attendanceProvider = context.watch(); + final faceProvider = context.watch(); + final faceStatus = faceProvider.faceStatus2['status']; return Scaffold( backgroundColor: AppTheme.bgLight, @@ -87,12 +90,7 @@ class _PresensiScreenState extends State { children: [ Text(homeProvider.presensiToday?.shift ?? "-", style: AppTheme.labelMedium.copyWith(color: AppTheme.primaryDark)), const SizedBox(width: 8), - InkWell( - onTap: () { - _showScheduleModal(context); - }, - child: const Icon(Icons.calendar_month, color: AppTheme.primaryDark, size: 20), - ), + const Icon(Icons.calendar_month, color: AppTheme.primaryDark, size: 20), ], ), ), @@ -103,12 +101,59 @@ class _PresensiScreenState extends State { onRefresh: () async { await homeProvider.fetchDashboardData(force: true); await attendanceProvider.fetchHistory(force: true); + faceProvider.loadFaceStatus(); }, child: SingleChildScrollView( padding: const EdgeInsets.all(AppTheme.spacingMd), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (faceStatus == 'pending') + Container( + margin: const EdgeInsets.only(bottom: AppTheme.spacingMd), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.primaryBlue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + border: Border.all(color: AppTheme.primaryBlue.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + const Icon(Icons.hourglass_top_rounded, color: AppTheme.primaryBlue, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + "Data wajah Anda sedang dalam proses verifikasi HRD. Presensi belum dapat dilakukan.", + style: AppTheme.bodySmall.copyWith(color: AppTheme.primaryBlue, fontWeight: FontWeight.w500), + ), + ), + ], + ), + ), + + if (faceStatus == 'rejected') + Container( + margin: const EdgeInsets.only(bottom: AppTheme.spacingMd), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.statusRed.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + border: Border.all(color: AppTheme.statusRed.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + const Icon(Icons.cancel_rounded, color: AppTheme.statusRed, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + "Registrasi wajah ditolak oleh HRD. Silakan lakukan registrasi ulang di menu Profil.", + style: AppTheme.bodySmall.copyWith(color: AppTheme.statusRed, fontWeight: FontWeight.w500), + ), + ), + ], + ), + ), + PresensiSection( presensiToday: homeProvider.presensiToday, onPresensi: () { @@ -145,18 +190,22 @@ class _PresensiScreenState extends State { child: const Icon(Icons.chevron_left, size: 20, color: AppTheme.primaryDark), ), ), - GestureDetector( + InkWell( 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), - ), - ], + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + 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( @@ -270,26 +319,15 @@ class _PresensiScreenState extends State { ), ], ), - const SizedBox(height: AppTheme.spacingMd), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: StatCard( - label: "Total Jam Kerja", - value: history.totalJam, - ), - ), - const SizedBox(width: AppTheme.spacingMd), - Expanded( - child: AttendanceTimeCard( - jamMasuk: history.jamMasuk ?? '-', - jamPulang: history.jamPulang ?? '-', - ), - ), - ], - ), - const SizedBox(height: 8), + if ((history.jamMasuk != null && history.jamMasuk != '-') || (history.jamPulang != null && history.jamPulang != '-')) ...[ + const SizedBox(height: AppTheme.spacingMd), + AttendanceTimeCard( + jamMasuk: history.jamMasuk ?? '-', + jamPulang: history.jamPulang ?? '-', + totalJam: history.totalJam, + ), + const SizedBox(height: 8), + ], Row( mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -498,19 +536,73 @@ class _PresensiScreenState extends State { Future _handlePresensi(String type) async { if (!mounted) return; - final result = await Navigator.push>( - context, - MaterialPageRoute( - builder: (context) => PresensiMapScreen(type: type), - ), - ); + try { + final faceProvider = context.read(); + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const Center(child: CircularProgressIndicator()), + ); + + await faceProvider.loadFaceStatus(); + + if (!mounted) return; + Navigator.pop(context); // Close loading dialog + + final faceStatus = faceProvider.faceStatus2['status']; + if (faceStatus != 'verified') { + String msg = 'Wajah Anda belum terverifikasi oleh HRD.'; + if (faceStatus == 'not_registered') { + msg = 'Anda belum melakukan registrasi wajah.'; + } else if (faceStatus == 'pending') { + msg = 'Registrasi wajah Anda sedang menunggu verifikasi/training oleh HRD.'; + } else if (faceStatus == 'rejected') { + msg = 'Registrasi wajah Anda ditolak oleh HRD. Harap lakukan registrasi ulang.'; + } + + showDialog( + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Row( + children: [ + Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 28), + SizedBox(width: 8), + Text("Presensi Ditolak", style: TextStyle(fontWeight: FontWeight.bold)), + ], + ), + content: Text(msg), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text("Tutup"), + ), + ], + ), + ); + return; + } - if (!mounted) return; + final result = await Navigator.push>( + context, + MaterialPageRoute( + builder: (context) => PresensiMapScreen(type: type), + ), + ); - if (result != null && result['success'] == true) { - await context.read().fetchDashboardData(force: true); - await context.read().fetchHistory(force: true); - _showResultDialog(true, "Absensi Berhasil!", result['message'] ?? "Data absensi Anda telah tercatat."); + if (!mounted) return; + + if (result != null && result['success'] == true) { + await context.read().fetchDashboardData(force: true); + await context.read().fetchHistory(force: true); + _showResultDialog(true, "Absensi Berhasil!", result['message'] ?? "Data absensi Anda telah tercatat."); + } + } catch (e) { + if (mounted) Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Gagal memeriksa status verifikasi wajah. Coba lagi.")), + ); } } diff --git a/lib/screens/profile/face_test_screen.dart b/lib/screens/profile/face_test_screen.dart index 75a8196..d719737 100644 --- a/lib/screens/profile/face_test_screen.dart +++ b/lib/screens/profile/face_test_screen.dart @@ -29,8 +29,12 @@ class _FaceTestScreenState extends State { bool _isFaceDetected = false; bool _isDistanceOk = false; bool _isBrightnessOk = false; + bool _isLightingUniform = false; + bool _isPoseValid = false; double _faceRatio = 0.0; double _brightness = 0.0; + double _lightingUniformity = 0.0; + double _headEulerAngleY = 0.0; bool? _verified; double? _confidence; @@ -44,7 +48,7 @@ class _FaceTestScreenState extends State { late FaceDetector _faceDetector; bool get _allChecksValid => - _isFaceDetected && _isDistanceOk && _isBrightnessOk; + _isFaceDetected && _isDistanceOk && _isBrightnessOk && _isLightingUniform && _isPoseValid; // State recording bool _isRecording = false; @@ -53,15 +57,18 @@ class _FaceTestScreenState extends State { String get _qualityMessage { if (_isRecording) return 'Tetap hadap kamera... ($_recordingSecondsLeft detik)'; if (!_isFaceDetected) return 'Arahkan wajah ke dalam bingkai'; - if (_faceRatio < 0.20) return 'Terlalu jauh, dekatkan wajah'; - if (_faceRatio > 0.70) return 'Terlalu dekat, mundur sedikit'; + if (_faceRatio < 0.25) return 'Terlalu jauh, dekatkan wajah'; + if (_faceRatio > 0.75) return 'Terlalu dekat, mundur sedikit'; if (!_isBrightnessOk) return 'Pencahayaan kurang terang'; + if (!_isLightingUniform) return 'Ada bayangan, hadap cahaya'; + if (!_isPoseValid) return 'Hadap lurus ke kamera'; return 'Siap — mulai verifikasi'; } Color get _resultColor { - if (_statusText == 'APPROVED') return AppTheme.statusGreen; - if (_statusText == 'PENDING') return AppTheme.statusYellow; + final s = _statusText; + if (s == 'APPROVED' || s == 'MATCH') return AppTheme.statusGreen; + if (s == 'PENDING' || s == 'MISMATCH') return AppTheme.statusYellow; return AppTheme.statusRed; } @@ -71,10 +78,10 @@ class _FaceTestScreenState extends State { _faceDetector = FaceDetector( options: FaceDetectorOptions( enableClassification: false, - enableLandmarks: false, + enableLandmarks: true, enableContours: false, - enableTracking: false, - performanceMode: FaceDetectorMode.fast, + enableTracking: true, + performanceMode: FaceDetectorMode.accurate, ), ); _initializeCamera(); @@ -123,8 +130,11 @@ class _FaceTestScreenState extends State { _isProcessingFrame = true; try { - _brightness = BrightnessUtils.calculateBrightness(image); - _isBrightnessOk = _brightness > 60; + _brightness = BrightnessUtils.calculateBrightness(image, centerOnly: true); + _isBrightnessOk = _brightness > 70; + + _lightingUniformity = BrightnessUtils.calculateFaceUniformity(image); + _isLightingUniform = _lightingUniformity > 0.45; final inputImage = CameraUtils.inputImageFromCameraImage( image: image, @@ -137,13 +147,25 @@ class _FaceTestScreenState extends State { if (faces.isNotEmpty) { final face = faces.first; - final imageWidth = image.width.toDouble(); - _faceRatio = face.boundingBox.width / imageWidth; + + _headEulerAngleY = face.headEulerAngleY ?? 0.0; + _isPoseValid = _headEulerAngleY.abs() < 18.0; + + final rotation = CameraUtils.rotationIntToImageRotation(_frontCamera!.sensorOrientation); + final bool isRotated90or270 = + rotation == InputImageRotation.rotation90deg || + rotation == InputImageRotation.rotation270deg; + final double effectiveWidth = isRotated90or270 + ? image.height.toDouble() + : image.width.toDouble(); + + _faceRatio = face.boundingBox.width / effectiveWidth; _isFaceDetected = true; - _isDistanceOk = _faceRatio >= 0.20 && _faceRatio <= 0.70; + _isDistanceOk = _faceRatio >= 0.25 && _faceRatio <= 0.75; } else { _isFaceDetected = false; _isDistanceOk = false; + _isPoseValid = false; _faceRatio = 0.0; } @@ -244,10 +266,12 @@ class _FaceTestScreenState extends State { _statusText = null; _errorMessage = null; _predictedName = null; - _isRecording = false; - _isFaceDetected = false; - _isDistanceOk = false; - _isBrightnessOk = false; + _isRecording = false; + _isFaceDetected = false; + _isDistanceOk = false; + _isBrightnessOk = false; + _isLightingUniform = false; + _isPoseValid = false; }); _startQualityStream(); @@ -340,9 +364,9 @@ class _FaceTestScreenState extends State { _isVerifying ? "Memverifikasi wajah..." : _showResult - ? (_statusText == 'APPROVED' + ? (_statusText == 'MATCH' ? "Wajah Terverifikasi ✓" - : _statusText == 'PENDING' + : _statusText == 'MISMATCH' ? "Verifikasi Meragukan ⚠️" : "Wajah Tidak Cocok ✗") : _qualityMessage, @@ -363,15 +387,16 @@ class _FaceTestScreenState extends State { // Quality chips if (!_showResult && !_isVerifying) ...[ const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.center, + Wrap( + spacing: 6, + runSpacing: 6, + alignment: WrapAlignment.center, children: [ _buildChip(Icons.face, 'Wajah', _isFaceDetected), - const SizedBox(width: 6), _buildChip(Icons.straighten, 'Jarak', _isDistanceOk), - const SizedBox(width: 6), - _buildChip( - Icons.wb_sunny_outlined, 'Cahaya', _isBrightnessOk), + _buildChip(Icons.wb_sunny_outlined, 'Cahaya', _isBrightnessOk), + _buildChip(Icons.blur_on, 'Bayangan', _isLightingUniform), + _buildChip(Icons.face, 'Hadap Depan', _isPoseValid), ], ), ], @@ -417,9 +442,9 @@ class _FaceTestScreenState extends State { shape: BoxShape.circle, ), child: Icon( - _statusText == 'APPROVED' + (_statusText == 'APPROVED' || _statusText == 'MATCH') ? Icons.check_circle - : _statusText == 'PENDING' + : (_statusText == 'PENDING' || _statusText == 'MISMATCH') ? Icons.warning_amber_rounded : Icons.cancel, color: _resultColor, @@ -428,9 +453,9 @@ class _FaceTestScreenState extends State { ), const SizedBox(height: 12), Text( - _statusText == 'APPROVED' + (_statusText == 'APPROVED' || _statusText == 'MATCH') ? "Verifikasi Berhasil" - : _statusText == 'PENDING' + : (_statusText == 'PENDING' || _statusText == 'MISMATCH') ? "Verifikasi Diragukan" : "Verifikasi Gagal", style: AppTheme.heading3.copyWith( @@ -617,8 +642,8 @@ class _FaceTestScreenState extends State { SizedBox( width: double.infinity, child: CustomButton( - text: "Mulai Rekam & Verifikasi", - icon: Icons.videocam_rounded, + text: "Mulai Pindai & Verifikasi", + icon: Icons.face_retouching_natural, onPressed: _allChecksValid && _isCameraInitialized ? _recordAndVerify : null, @@ -635,11 +660,11 @@ class _FaceTestScreenState extends State { padding: const EdgeInsets.symmetric( horizontal: 20, vertical: 10), decoration: BoxDecoration( - color: Colors.red.withOpacity(0.8), + color: AppTheme.primaryBlue.withOpacity(0.8), borderRadius: BorderRadius.circular(12), ), child: Text( - 'Merekam... $_recordingSecondsLeft detik', + 'Memindai... $_recordingSecondsLeft detik', style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold), diff --git a/lib/screens/profile/profile_screen.dart b/lib/screens/profile/profile_screen.dart index b860658..90f80fa 100644 --- a/lib/screens/profile/profile_screen.dart +++ b/lib/screens/profile/profile_screen.dart @@ -6,6 +6,14 @@ import '../../widgets/organisms/change_password_dialog.dart'; import 'package:provider/provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/face_provider.dart'; +import '../../providers/home_provider.dart'; +import '../../providers/pengajuan_provider.dart'; +import '../../providers/attendance_provider.dart'; +import '../../providers/poin_provider.dart'; +import '../../providers/notification_provider.dart'; +import '../../providers/signature_provider.dart'; +import '../../providers/surat_izin_provider.dart'; +import '../../providers/calendar_provider.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:flutter/services.dart'; @@ -71,7 +79,7 @@ class _ProfileScreenState extends State { void _handleLogout(BuildContext context) { showDialog( context: context, - builder: (context) => AlertDialog( + builder: (dialogContext) => AlertDialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppTheme.radiusMd), ), @@ -79,17 +87,33 @@ class _ProfileScreenState extends State { content: const Text("Apakah Anda yakin ingin keluar dari aplikasi?"), actions: [ TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () => Navigator.pop(dialogContext), child: Text( "Batal", style: AppTheme.labelLarge.copyWith(color: AppTheme.textSecondary), ), ), TextButton( - onPressed: () { - Navigator.pop(context); - context.read().logout(); - Navigator.pushReplacementNamed(context, '/login'); + onPressed: () async { + // Tutup pop-up menggunakan dialogContext + Navigator.pop(dialogContext); + + await context.read().logout(); + + // Gunakan context asli dari ProfileScreen untuk bernavigasi + if (context.mounted) { + context.read().reset(); + context.read().reset(); + context.read().reset(); + context.read().reset(); + context.read().reset(); + context.read().reset(); + context.read().reset(); + context.read().reset(); + context.read().reset(); + + Navigator.pushNamedAndRemoveUntil(context, '/login', (route) => false); + } }, child: Text( "Keluar", @@ -324,7 +348,7 @@ class _ProfileScreenState extends State { _buildActionItem( icon: Icons.face, label: "Registrasi Wajah", - onTap: () => Navigator.pushNamed(context, '/onboarding/face-enrollment'), + onTap: null, trailingText: (() { final status = context.watch().faceStatus2['status']; if (status == 'verified') return 'Terverifikasi'; @@ -340,12 +364,6 @@ class _ProfileScreenState extends State { return AppTheme.textSecondary; })(), ), - if (context.watch().faceStatus2['status'] == 'verified') - _buildActionItem( - icon: Icons.face_retouching_natural, - label: "Test Pengenalan Wajah", - onTap: () => Navigator.pushNamed(context, '/profile/face-test'), - ), _buildActionItem( icon: Icons.lock_outline, label: "Ubah Kata Sandi", @@ -492,7 +510,7 @@ class _ProfileScreenState extends State { Widget _buildActionItem({ required IconData icon, required String label, - required VoidCallback onTap, + VoidCallback? onTap, bool isDestructive = false, String? trailingText, Color? trailingColor, @@ -523,12 +541,14 @@ class _ProfileScreenState extends State { color: trailingColor ?? AppTheme.textSecondary, ), ), - if (trailingText != null) const SizedBox(width: 8), - Icon( - Icons.chevron_right, - color: AppTheme.textTertiary, - size: 20, - ), + if (onTap != null) ...[ + if (trailingText != null) const SizedBox(width: 8), + Icon( + Icons.chevron_right, + color: AppTheme.textTertiary, + size: 20, + ), + ], ], ), ); diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index 391897a..698de1e 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -3,7 +3,9 @@ import 'package:flutter/material.dart'; import '../../core/cache_manager.dart'; class SplashScreen extends StatefulWidget { - const SplashScreen({super.key}); + final bool launchedFromNotification; + + const SplashScreen({super.key, this.launchedFromNotification = false}); @override State createState() => _SplashScreenState(); @@ -39,10 +41,20 @@ class _SplashScreenState extends State with SingleTickerProviderSt void _checkAuthAndNavigate() { if (!mounted) return; - + final token = CacheManager.authBox.get('auth_token'); if (token != null && token.toString().isNotEmpty) { Navigator.pushReplacementNamed(context, '/onboarding/check'); + + // MB-07: Jika app dibuka dari push notification (terminated state), + // langsung arahkan ke halaman notifikasi setelah onboarding selesai. + if (widget.launchedFromNotification) { + Future.delayed(const Duration(milliseconds: 800), () { + if (mounted) { + Navigator.pushNamed(context, '/notification'); + } + }); + } } else { Navigator.pushReplacementNamed(context, '/login'); } @@ -68,7 +80,7 @@ class _SplashScreenState extends State with SingleTickerProviderSt children: [ Image.asset( 'assets/images/logo.png', - height: 120, // slightly larger since it's not in a box + height: 120, width: 250, fit: BoxFit.contain, ), diff --git a/lib/services/api_client.dart b/lib/services/api_client.dart index aa7c626..5054954 100644 --- a/lib/services/api_client.dart +++ b/lib/services/api_client.dart @@ -13,6 +13,8 @@ class ApiClient { headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', + 'Bypass-Tunnel-Reminder': 'true', + 'ngrok-skip-browser-warning': 'true', }, )); @@ -38,13 +40,19 @@ class ApiClient { requestOptions: response.requestOptions, response: response, type: DioExceptionType.badResponse, - error: 'Sesi habis, silakan login ulang.', + error: 'Sesi habis atau server mengembalikan HTML.', ), ); } return handler.next(response); }, onError: (DioException e, handler) { + // Cegah error "type 'String' is not a subtype of type 'int'" di repository + if (e.response != null && e.response!.data is String) { + e.response!.data = { + 'message': 'Terjadi kesalahan server (${e.response!.statusCode}).', + }; + } return handler.next(e); }, )); diff --git a/lib/services/fcm_service.dart b/lib/services/fcm_service.dart index f268d3a..86d1342 100644 --- a/lib/services/fcm_service.dart +++ b/lib/services/fcm_service.dart @@ -5,25 +5,33 @@ import 'package:provider/provider.dart'; import '../core/theme.dart'; import '../providers/notification_provider.dart'; import '../repositories/notifikasi_repository.dart'; +import '../providers/pengajuan_provider.dart'; +import '../providers/poin_provider.dart'; +import '../providers/attendance_provider.dart'; import '../main.dart'; @pragma('vm:entry-point') Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { - // Jika message sudah punya notification payload, - // FCM otomatis menampilkan system notification. - // Kita TIDAK perlu tampilkan lagi via local notification (mencegah dobel). if (message.notification != null) return; - // Hanya tampilkan manual untuk data-only messages final title = message.data['title'] ?? ''; final body = message.data['body'] ?? ''; if (title.isEmpty) return; final plugin = FlutterLocalNotificationsPlugin(); + const channel = AndroidNotificationChannel( + 'hris_channel', + 'HRIS Notifications', + importance: Importance.max, + ); + + final androidPlugin = plugin.resolvePlatformSpecificImplementation(); + await androidPlugin?.createNotificationChannel(channel); + await plugin.initialize( settings: const InitializationSettings( - android: AndroidInitializationSettings('@mipmap/ic_launcher'), + android: AndroidInitializationSettings('ic_launcher'), ), ); @@ -37,9 +45,11 @@ Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { 'HRIS Notifications', importance: Importance.max, priority: Priority.high, - icon: '@mipmap/ic_launcher', + icon: 'ic_launcher', ), ), + // MB-07: Simpan payload agar onDidReceiveNotificationResponse bisa routing ke /notification + payload: 'notification:${message.data['tipe'] ?? ''}', ); } @@ -67,7 +77,7 @@ class FcmService { sound: true, ); - const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); + const androidSettings = AndroidInitializationSettings('ic_launcher'); const initSettings = InitializationSettings(android: androidSettings); await _localNotifications.initialize( settings: initSettings, @@ -98,7 +108,29 @@ class FcmService { final currentContext = navigatorKey.currentContext; if (currentContext != null && currentContext.mounted) { _showBanner(currentContext, msg); + + // --- ⚡ AUTO-REFRESH REALTIME LOGIC ⚡ --- + // 1. Selalu update badge dan list notifikasi currentContext.read().fetchUnreadCount(); + currentContext.read().fetchNotifications(); + + final tipe = (msg.data['tipe'] as String?)?.toLowerCase() ?? ''; + + // 2. Jika notif ttg Izin/Cuti/Lembur, refresh tabel Riwayat Pengajuan + if (tipe.contains('izin') || tipe.contains('cuti') || tipe.contains('lembur') || tipe.contains('pengajuan')) { + currentContext.read().fetchPengajuan(); + } + + // 3. Jika notif ttg Poin, refresh saldo Poin & Riwayat Poin + if (tipe.contains('poin')) { + currentContext.read().loadExpiringPoints(force: true); + currentContext.read().loadPointHistory(); + } + + // 4. Jika notif ttg Presensi, refresh history presensi + if (tipe.contains('presensi') || tipe.contains('absen')) { + currentContext.read().fetchHistory(force: true); + } } _showSystemNotification(msg); }); @@ -210,7 +242,7 @@ class FcmService { 'HRIS Notifications', importance: Importance.max, priority: Priority.high, - icon: '@mipmap/ic_launcher', + icon: 'ic_launcher', ); const details = NotificationDetails(android: androidDetails); await _localNotifications.show( @@ -218,7 +250,8 @@ class FcmService { title: title, body: body, notificationDetails: details, - payload: 'notification', + // MB-07: Payload konsisten agar onDidReceiveNotificationResponse bisa routing ke /notification + payload: 'notification:${message.data['tipe'] ?? ''}', ); } } diff --git a/lib/services/reminder_service.dart b/lib/services/reminder_service.dart index 30fba80..23ed1c1 100644 --- a/lib/services/reminder_service.dart +++ b/lib/services/reminder_service.dart @@ -51,7 +51,6 @@ class ReminderService { final targetDate = now.add(Duration(days: i)); final isToday = i == 0; - if (targetDate.weekday == 6 || targetDate.weekday == 7) continue; final reminderMasuk = tz.TZDateTime( jakarta, @@ -126,9 +125,11 @@ class ReminderService { 'attendance_reminder', 'Pengingat Absen', channelDescription: 'Pengingat absen masuk dan pulang otomatis', - importance: Importance.high, - priority: Priority.high, + importance: Importance.max, + priority: Priority.max, icon: '@mipmap/ic_launcher', + category: AndroidNotificationCategory.alarm, + fullScreenIntent: true, styleInformation: BigTextStyleInformation(''), ); const details = NotificationDetails(android: androidDetails); @@ -139,7 +140,7 @@ class ReminderService { body: body, scheduledDate: scheduledDate, notificationDetails: details, - androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, ); } diff --git a/lib/services/websocket_service.dart b/lib/services/websocket_service.dart index 24f69ca..5391758 100644 --- a/lib/services/websocket_service.dart +++ b/lib/services/websocket_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; import 'package:web_socket_channel/web_socket_channel.dart'; import '../core/cache_manager.dart'; import '../core/constants/api_url.dart'; @@ -14,6 +15,7 @@ class WebSocketService extends ChangeNotifier { Timer? _reconnectTimer; bool _isConnected = false; bool _disposed = false; + String? _socketId; final _eventController = StreamController>.broadcast(); Stream> get eventStream => _eventController.stream; @@ -46,18 +48,18 @@ class WebSocketService extends ChangeNotifier { onDone: () { debugPrint('[WS] Koneksi terputus'); _isConnected = false; + _socketId = null; notifyListeners(); _scheduleReconnect(); }, onError: (error) { debugPrint('[WS] Error: $error'); _isConnected = false; + _socketId = null; notifyListeners(); _scheduleReconnect(); }, ); - - _subscribeToChannels(token); } catch (e) { debugPrint('[WS] Gagal connect: $e'); _isConnected = false; @@ -70,10 +72,21 @@ class WebSocketService extends ChangeNotifier { final uri = Uri.parse(apiBaseUrl); final scheme = uri.scheme == 'https' ? 'wss' : 'ws'; + // Tunnel (ngrok / localtunnel): WebSocket langsung via tunnel + if (uri.host.contains('ngrok-free.dev') || uri.host.contains('loca.lt')) { + return 'wss://icy-bugs-hear.loca.lt'; + } + if (uri.host.contains('trycloudflare.com')) { return '$scheme://${uri.host}'; } + // Hosting: Reverb lewat Nginx proxy /app + if (uri.host.contains('hris-mp.web.id')) { + return 'wss://hris-mp.web.id'; + } + + // Lokal: Reverb langsung di port 8080 return '$scheme://${uri.host}:8080'; } catch (e) { debugPrint('[WS] URL invalid: $e'); @@ -81,24 +94,81 @@ class WebSocketService extends ChangeNotifier { } } - void _subscribeToChannels(String token) { + Future _subscribeToChannels() async { final userId = CacheManager.authBox.get('user_id'); if (userId == null) return; - _sendSubscribe('private-user.$userId', token); - _sendSubscribe('pengumuman', null); + await _sendSubscribe('private-user.$userId'); + _sendSubscribePublic('pengumuman'); } - void _sendSubscribe(String channel, String? token) { + Future _sendSubscribe(String channel) async { + if (_socketId == null) { + debugPrint('[WS] Tidak bisa subscribe $channel: socketId null'); + return; + } + + final token = CacheManager.authBox.get('auth_token') ?? ''; + final authSignature = await _getChannelAuth(channel, token); + + if (authSignature == null) { + debugPrint('[WS] Auth gagal untuk channel: $channel'); + return; + } + final data = { 'event': 'pusher:subscribe', 'data': { 'channel': channel, - if (token != null) 'auth': token, + 'auth': authSignature, }, }; _channel?.sink.add(jsonEncode(data)); - debugPrint('[WS] Subscribe: $channel'); + debugPrint('[WS] Subscribe (private): $channel'); + } + + void _sendSubscribePublic(String channel) { + final data = { + 'event': 'pusher:subscribe', + 'data': { + 'channel': channel, + }, + }; + _channel?.sink.add(jsonEncode(data)); + debugPrint('[WS] Subscribe (public): $channel'); + } + + Future _getChannelAuth(String channel, String token) async { + try { + final baseUrl = ApiUrl.baseUrl; + final authUrl = '$baseUrl/broadcasting/auth'; + + final response = await http.post( + Uri.parse(authUrl), + headers: { + 'Authorization': 'Bearer $token', + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: { + 'socket_id': _socketId!, + 'channel_name': channel, + }, + ); + + if (response.statusCode == 200) { + final json = jsonDecode(response.body); + final auth = json['auth'] as String?; + debugPrint('[WS] Auth berhasil untuk $channel'); + return auth; + } else { + debugPrint('[WS] Auth error ${response.statusCode}: ${response.body}'); + return null; + } + } catch (e) { + debugPrint('[WS] Auth exception: $e'); + return null; + } } void _handleMessage(dynamic rawMessage, String token) { @@ -107,8 +177,10 @@ class WebSocketService extends ChangeNotifier { final event = message['event'] as String?; if (event == 'pusher:connection_established') { - debugPrint('[WS] Connection established'); - _subscribeToChannels(token); + final connData = jsonDecode(message['data'] as String); + _socketId = connData['socket_id'] as String?; + debugPrint('[WS] Connection established, socketId=$_socketId'); + _subscribeToChannels(); return; } @@ -117,6 +189,11 @@ class WebSocketService extends ChangeNotifier { return; } + if (event == 'pusher:error') { + debugPrint('[WS] Pusher error: ${message['data']}'); + return; + } + if (event != null && !event.startsWith('pusher')) { final dataRaw = message['data']; final data = dataRaw is String ? jsonDecode(dataRaw) : dataRaw; @@ -148,6 +225,7 @@ class WebSocketService extends ChangeNotifier { _channel?.sink.close(); _channel = null; _isConnected = false; + _socketId = null; notifyListeners(); debugPrint('[WS] Disconnected'); } diff --git a/lib/widgets/molecules/attendance_time_card.dart b/lib/widgets/molecules/attendance_time_card.dart index 408a1ee..0c7060b 100644 --- a/lib/widgets/molecules/attendance_time_card.dart +++ b/lib/widgets/molecules/attendance_time_card.dart @@ -4,20 +4,24 @@ import '../../core/theme.dart'; class AttendanceTimeCard extends StatelessWidget { final String jamMasuk; final String jamPulang; + final String totalJam; const AttendanceTimeCard({ Key? key, required this.jamMasuk, required this.jamPulang, + required this.totalJam, }) : super(key: key); @override Widget build(BuildContext context) { - return Column( + return Row( children: [ - _buildTimeRow("Datang", jamMasuk, AppTheme.statusGreen), - const SizedBox(height: 8), - _buildTimeRow("Pulang", jamPulang, AppTheme.statusRed), + Expanded(child: _buildTimeRow("Total Jam", totalJam, AppTheme.primaryDark)), + const SizedBox(width: 8), + Expanded(child: _buildTimeRow("Datang", jamMasuk, AppTheme.statusGreen)), + const SizedBox(width: 8), + Expanded(child: _buildTimeRow("Pulang", jamPulang, AppTheme.statusRed)), ], ); } diff --git a/lib/widgets/molecules/pengumuman_card.dart b/lib/widgets/molecules/pengumuman_card.dart index 862f2e1..c96c5d4 100644 --- a/lib/widgets/molecules/pengumuman_card.dart +++ b/lib/widgets/molecules/pengumuman_card.dart @@ -19,22 +19,9 @@ class PengumumanCard extends StatelessWidget { width: 300, margin: const EdgeInsets.only(right: AppTheme.spacingMd), decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Color(0xFF1E293B), - Color(0xFF334155), - ], - ), + color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.radiusXl), - boxShadow: [ - BoxShadow( - color: const Color(0xFF1E293B).withValues(alpha: 0.3), - blurRadius: 12, - offset: const Offset(0, 6), - ), - ], + boxShadow: AppTheme.shadowMd, ), child: Material( color: Colors.transparent, @@ -59,16 +46,16 @@ class PengumumanCard extends StatelessWidget { Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: AppTheme.primaryOrange.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(10), + color: AppTheme.primaryOrange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), ), child: const Icon( Icons.campaign_rounded, color: AppTheme.primaryOrange, - size: 18, + size: 20, ), ), - const SizedBox(width: 10), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -76,7 +63,8 @@ class PengumumanCard extends StatelessWidget { Text( pengumuman.title, style: AppTheme.labelLarge.copyWith( - color: Colors.white, + color: AppTheme.textPrimary, + fontWeight: FontWeight.bold, fontSize: 14, ), maxLines: 1, @@ -86,7 +74,7 @@ class PengumumanCard extends StatelessWidget { Text( pengumuman.namaPembuat, style: AppTheme.bodySmall.copyWith( - color: Colors.white60, + color: AppTheme.textTertiary, fontSize: 11, ), ), @@ -95,12 +83,12 @@ class PengumumanCard extends StatelessWidget { ), ], ), - const SizedBox(height: 10), + const SizedBox(height: 12), Text( pengumuman.description, style: AppTheme.bodySmall.copyWith( - color: Colors.white70, - height: 1.4, + color: AppTheme.textSecondary, + height: 1.5, ), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -112,29 +100,30 @@ class PengumumanCard extends StatelessWidget { Text( pengumuman.date, style: AppTheme.bodySmall.copyWith( - color: Colors.white38, + color: AppTheme.textTertiary, fontSize: 10, ), ), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( - color: AppTheme.primaryOrange.withValues(alpha: 0.2), + color: AppTheme.primaryBlue.withValues(alpha: 0.05), borderRadius: BorderRadius.circular(AppTheme.radiusFull), + border: Border.all(color: AppTheme.primaryBlue.withValues(alpha: 0.1)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( - "Baca", + "Detail", style: AppTheme.bodySmall.copyWith( - color: AppTheme.primaryOrange, + color: AppTheme.primaryBlue, fontWeight: FontWeight.w600, fontSize: 10, ), ), - const SizedBox(width: 2), - const Icon(Icons.arrow_forward_ios, color: AppTheme.primaryOrange, size: 9), + const SizedBox(width: 4), + const Icon(Icons.arrow_forward_ios, color: AppTheme.primaryBlue, size: 8), ], ), ), diff --git a/lib/widgets/organisms/change_password_dialog.dart b/lib/widgets/organisms/change_password_dialog.dart index 6d54d9c..79ff56c 100644 --- a/lib/widgets/organisms/change_password_dialog.dart +++ b/lib/widgets/organisms/change_password_dialog.dart @@ -73,55 +73,57 @@ class _ChangePasswordDialogState extends State { ), child: Form( key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("Ubah Password", style: AppTheme.heading3), - const SizedBox(height: AppTheme.spacingMd), - CustomTextField( - controller: _currentPasswordController, - label: "Password Saat Ini", - isPassword: true, - validator: (value) => - value == null || value.isEmpty ? "Wajib diisi" : null, - ), - const SizedBox(height: AppTheme.spacingSm), - CustomTextField( - controller: _newPasswordController, - label: "Password Baru", - isPassword: true, - validator: (value) => - value == null || value.length < 8 ? "Minimal 8 karakter" : null, - ), - const SizedBox(height: AppTheme.spacingSm), - CustomTextField( - controller: _confirmPasswordController, - label: "Konfirmasi Password Baru", - isPassword: true, - validator: (value) => - value == null || value.isEmpty ? "Wajib diisi" : null, - ), - const SizedBox(height: AppTheme.spacingLg), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: isLoading ? null : () => Navigator.pop(context), - child: Text("Batal", style: AppTheme.bodyMedium.copyWith(color: AppTheme.textSecondary)), - ), - const SizedBox(width: AppTheme.spacingSm), - Flexible( - child: CustomButton( - text: "Simpan", - isLoading: isLoading, - onPressed: _handleSubmit, - isFullWidth: false, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Ubah Password", style: AppTheme.heading3), + const SizedBox(height: AppTheme.spacingMd), + CustomTextField( + controller: _currentPasswordController, + label: "Password Saat Ini", + isPassword: true, + validator: (value) => + value == null || value.isEmpty ? "Wajib diisi" : null, + ), + const SizedBox(height: AppTheme.spacingSm), + CustomTextField( + controller: _newPasswordController, + label: "Password Baru", + isPassword: true, + validator: (value) => + value == null || value.length < 8 ? "Minimal 8 karakter" : null, + ), + const SizedBox(height: AppTheme.spacingSm), + CustomTextField( + controller: _confirmPasswordController, + label: "Konfirmasi Password Baru", + isPassword: true, + validator: (value) => + value == null || value.isEmpty ? "Wajib diisi" : null, + ), + const SizedBox(height: AppTheme.spacingLg), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: isLoading ? null : () => Navigator.pop(context), + child: Text("Batal", style: AppTheme.bodyMedium.copyWith(color: AppTheme.textSecondary)), ), - ), - ], - ), - ], + const SizedBox(width: AppTheme.spacingSm), + Flexible( + child: CustomButton( + text: "Simpan", + isLoading: isLoading, + onPressed: _handleSubmit, + isFullWidth: false, + ), + ), + ], + ), + ], + ), ), ), ), diff --git a/lib/widgets/organisms/home_header.dart b/lib/widgets/organisms/home_header.dart index 49ac781..f40b0ae 100644 --- a/lib/widgets/organisms/home_header.dart +++ b/lib/widgets/organisms/home_header.dart @@ -25,109 +25,92 @@ class HomeHeader extends StatelessWidget { Widget build(BuildContext context) { final unreadCount = context.watch().unreadCount; - return Container( - padding: const EdgeInsets.all(AppTheme.spacingLg), - decoration: BoxDecoration( - color: AppTheme.primaryDark, - borderRadius: BorderRadius.circular(AppTheme.radiusXl), - boxShadow: AppTheme.glowPrimary, - ), - child: Stack( + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 4.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Positioned( - right: -20, - top: -20, - child: Container( - width: 100, - height: 100, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: AppTheme.glassWhite10, - ), + // Avatar Profil tanpa border putih + CustomAvatar( + imageUrl: user.foto, + name: user.namaLengkap, + size: 48, + ), + const SizedBox(width: AppTheme.spacingMd), + + // Greeting & Name dengan teks gelap (menyatu dengan background terang) + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _getGreeting(), + style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + user.namaLengkap.split(' ').first, + style: AppTheme.heading2.copyWith(color: AppTheme.textPrimary), + overflow: TextOverflow.ellipsis, + ), + ], ), ), - Positioned( - left: 50, - bottom: -30, - child: Container( - width: 80, - height: 80, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: AppTheme.glassWhite10, - ), + + // Notification Button minimalis + Container( + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), - ), - Row( - children: [ - CustomAvatar( - imageUrl: user.foto, - name: user.namaLengkap, - size: 48, - ), - const SizedBox(width: AppTheme.spacingMd), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _getGreeting(), - style: AppTheme.bodySmall.copyWith(color: Colors.white70), - overflow: TextOverflow.ellipsis, + child: Stack( + clipBehavior: Clip.none, + children: [ + Material( + color: Colors.transparent, + child: InkWell( + customBorder: const CircleBorder(), + onTap: () => Navigator.pushNamed(context, '/notification'), + child: const Padding( + padding: EdgeInsets.all(12.0), + child: Icon(Icons.notifications_none_rounded, color: AppTheme.primaryDark, size: 24), ), - const SizedBox(height: 4), - Text( - user.namaLengkap.split(' ').first, - style: AppTheme.heading2.copyWith(color: Colors.white), - overflow: TextOverflow.ellipsis, - ), - ], + ), ), - ), - - Container( - decoration: BoxDecoration( - color: AppTheme.glassWhite10, - shape: BoxShape.circle, - border: Border.all(color: AppTheme.glassWhite20, width: 1), - ), - 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, - ), - ), + if (unreadCount > 0) + Positioned( + right: 8, + top: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFef4444), + borderRadius: BorderRadius.circular(10), ), - ], - ), - ), - ], + constraints: const BoxConstraints(minWidth: 16, minHeight: 16), + child: Text( + unreadCount > 9 ? '9+' : '$unreadCount', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + height: 1.1, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), ), ], ),