This commit is contained in:
jouel88 2026-07-01 12:06:50 +07:00
parent a4f964bb20
commit 8b5dee7b65
48 changed files with 1896 additions and 731 deletions

3
.gitignore vendored
View File

@ -51,3 +51,6 @@ app.*.map.json
# Firebase configuration (if you want to keep them private) # Firebase configuration (if you want to keep them private)
android/app/google-services.json android/app/google-services.json
ios/Runner/GoogleService-Info.plist ios/Runner/GoogleService-Info.plist
# File testing sementara
test_user.dart

588
DESIGN.md Normal file
View File

@ -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

View File

@ -4,6 +4,10 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<application <application
android:label="HRIS MP" android:label="HRIS MP"
@ -47,6 +51,11 @@
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<!-- Diperlukan agar ketika notifikasi FCM (dari background) di-klik, aplikasi bisa terbuka -->
<intent-filter>
<action android:name="FLUTTER_NOTIFICATION_CLICK" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity> </activity>
<!-- image_cropper: UCrop Activity --> <!-- image_cropper: UCrop Activity -->
@ -54,6 +63,17 @@
android:name="com.yalantis.ucrop.UCropActivity" android:name="com.yalantis.ucrop.UCropActivity"
android:theme="@style/Theme.AppCompat.Light.NoActionBar" /> android:theme="@style/Theme.AppCompat.Light.NoActionBar" />
<!-- flutter_local_notifications receivers -->
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
</intent-filter>
</receiver>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@ -1,4 +1,7 @@
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=384m -XX:+UseG1GC
kotlin.daemon.jvmargs=-Xmx1024m -XX:MaxMetaspaceSize=512m kotlin.daemon.jvmargs=-Xmx768m -XX:MaxMetaspaceSize=256m
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configureondemand=true

View File

@ -16,7 +16,7 @@ class ApiUrl {
return _cachedImageBaseUrl!; return _cachedImageBaseUrl!;
} }
static String get baseUrl => _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 String get imageBaseUrl => _cachedImageBaseUrl ?? baseUrl.replaceAll('/api', '/storage/');
static Future<void> initialize() async { static Future<void> initialize() async {

View File

@ -6,8 +6,8 @@ class ApiConfigService {
static const String _selectedPresetKey = 'selected_preset'; static const String _selectedPresetKey = 'selected_preset';
static const Map<String, String> presets = { static const Map<String, String> presets = {
'cloudflare': 'https://exams-rhode-agency-outsourcing.trycloudflare.com/api', 'hosting': 'https://ta.myhost.id/E31230910/api',
'current_ip': 'http://192.168.1.98:8000/api', 'current_ip': 'http://192.168.137.1:8000/api',
'hostname': 'http://LAPTOP-I0SUKSKL:8000/api', 'hostname': 'http://LAPTOP-I0SUKSKL:8000/api',
'emulator': 'http://10.0.2.2:8000/api', 'emulator': 'http://10.0.2.2:8000/api',
'custom': '', 'custom': '',
@ -34,7 +34,7 @@ class ApiConfigService {
} }
} }
return presets['hostname']!; return presets['hosting']!;
} }
static Future<void> setCustomUrl(String url) async { static Future<void> setCustomUrl(String url) async {
@ -88,8 +88,8 @@ class ApiConfigService {
static String getPresetDisplayName(String presetKey) { static String getPresetDisplayName(String presetKey) {
switch (presetKey) { switch (presetKey) {
case 'cloudflare': case 'ngrok':
return 'Cloudflare Tunnel (Internet)'; return 'Ngrok Tunnel (Internet)';
case 'current_ip': case 'current_ip':
return 'IP Laptop Saat Ini - Recommended'; return 'IP Laptop Saat Ini - Recommended';
case 'hostname': case 'hostname':

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'services/fcm_service.dart'; import 'services/fcm_service.dart';
import 'core/theme.dart'; import 'core/theme.dart';
@ -63,11 +64,30 @@ void main() async {
tz.initializeTimeZones(); tz.initializeTimeZones();
await ReminderService().initialize(); 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 { class MyApp extends StatelessWidget {
const MyApp({super.key}); final bool launchedFromNotification;
const MyApp({super.key, this.launchedFromNotification = false});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -100,7 +120,7 @@ class MyApp extends StatelessWidget {
), ),
initialRoute: '/', initialRoute: '/',
routes: { routes: {
'/': (context) => const SplashScreen(), '/': (context) => SplashScreen(launchedFromNotification: launchedFromNotification),
'/login': (context) => const LoginScreen(), '/login': (context) => const LoginScreen(),
'/home': (context) => const MainScreen(), '/home': (context) => const MainScreen(),
'/presensi': (context) => const PresensiScreen(), '/presensi': (context) => const PresensiScreen(),

View File

@ -15,7 +15,7 @@ class AnnouncementModel {
required this.description, required this.description,
required this.date, required this.date,
required this.jabatan, required this.jabatan,
this.namaPembuat = 'Admin', this.namaPembuat = 'HRD',
this.avatarUrl, this.avatarUrl,
this.fotoUrl, this.fotoUrl,
this.lampiranUrl, this.lampiranUrl,
@ -28,7 +28,7 @@ class AnnouncementModel {
description: json['description']?.toString() ?? '', description: json['description']?.toString() ?? '',
date: json['tanggal']?.toString() ?? '', date: json['tanggal']?.toString() ?? '',
jabatan: json['jabatan']?.toString() ?? '', jabatan: json['jabatan']?.toString() ?? '',
namaPembuat: json['nama_pembuat']?.toString() ?? 'Admin', namaPembuat: json['nama_pembuat']?.toString() ?? 'HRD',
avatarUrl: json['avatar_url']?.toString(), avatarUrl: json['avatar_url']?.toString(),
fotoUrl: json['foto_url']?.toString(), fotoUrl: json['foto_url']?.toString(),
lampiranUrl: json['lampiran_url']?.toString(), lampiranUrl: json['lampiran_url']?.toString(),

View File

@ -15,6 +15,7 @@ class AttendanceProvider extends ChangeNotifier {
LatLng? currentLocation; LatLng? currentLocation;
bool isWithinRadius = false; bool isWithinRadius = false;
double distanceToOffice = 0.0; double distanceToOffice = 0.0;
bool isMocked = false;
int selectedMonth = DateTime.now().month; int selectedMonth = DateTime.now().month;
int selectedYear = DateTime.now().year; int selectedYear = DateTime.now().year;
@ -54,6 +55,7 @@ class AttendanceProvider extends ChangeNotifier {
), ),
); );
isMocked = position.isMocked;
await updateLocation(position.latitude, position.longitude); await updateLocation(position.latitude, position.longitude);
isLoading = false; isLoading = false;
@ -186,9 +188,18 @@ class AttendanceProvider extends ChangeNotifier {
} }
void reset() { void reset() {
historyList = [];
isLoading = false;
currentLocation = null;
isWithinRadius = false;
distanceToOffice = 0.0;
isMocked = false;
selectedMonth = DateTime.now().month;
selectedYear = DateTime.now().year;
reasonOutsideRadius = null; reasonOutsideRadius = null;
reasonEarlyCheckout = null; reasonEarlyCheckout = null;
reasonLateCheckin = null; reasonLateCheckin = null;
_hasLoaded = false;
notifyListeners(); notifyListeners();
} }
} }

View File

@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../repositories/auth_repository.dart'; import '../../repositories/auth_repository.dart';
import '../../models/user_model.dart'; import '../../models/user_model.dart';
import '../../core/cache_manager.dart'; import '../../core/cache_manager.dart';
@ -128,9 +129,33 @@ class AuthProvider extends ChangeNotifier {
} }
} }
void logout() { Future<void> 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; _user = null;
_errorMessage = null;
// Langkah 4: Batalkan semua reminder lokal
ReminderService().cancelAll(); ReminderService().cancelAll();
notifyListeners(); notifyListeners();
} }
} }

View File

@ -47,4 +47,11 @@ class CalendarProvider extends ChangeNotifier {
_schedules = []; _schedules = [];
notifyListeners(); notifyListeners();
} }
void reset() {
_schedules = [];
_isLoading = false;
_errorMessage = null;
notifyListeners();
}
} }

View File

@ -46,14 +46,15 @@ class FaceProvider with ChangeNotifier {
String _abortReason = ''; String _abortReason = '';
Timer? _maxDurationTimer; Timer? _maxDurationTimer;
Timer? _uiRefreshTimer;
static const int _maxRecordingSeconds = 45; static const int _maxRecordingSeconds = 45;
DateTime? _recordingStartTime; DateTime? _recordingStartTime;
static const int _warmupMs = 2000; static const int _warmupMs = 2000;
static const double _turnThreshold = 10.0; static const double _turnThreshold = 10.0;
static const int _holdDurationMs = 5000;
int _consecutiveBadFrames = 0; int _consecutiveBadFrames = 0;
static const int _badFrameThreshold = 30; static const int _badFrameThreshold = 30;
static const int _holdDurationMs = 4000;
VideoRecordingState get recordingState => _recordingState; VideoRecordingState get recordingState => _recordingState;
FaceDetectionStatus get faceDetectionStatus => _faceDetectionStatus; FaceDetectionStatus get faceDetectionStatus => _faceDetectionStatus;
@ -70,17 +71,22 @@ class FaceProvider with ChangeNotifier {
bool get isBrightnessOk => _isBrightnessOk; bool get isBrightnessOk => _isBrightnessOk;
double get lightingUniformity => _lightingUniformity; double get lightingUniformity => _lightingUniformity;
bool get isLightingUniform => _isLightingUniform; bool get isLightingUniform => _isLightingUniform;
double get stepProgress { 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; int completed = 0;
if (_currentStep == EnrollmentStep.right) completed = 1; if (_currentStep == EnrollmentStep.right) completed = 1;
if (_currentStep == EnrollmentStep.left) completed = 2; if (_currentStep == EnrollmentStep.left) completed = 2;
if (_currentStep == EnrollmentStep.done) completed = 3; if (_currentStep == EnrollmentStep.done) completed = 3;
return ((completed + stepProgress) / 3.0).clamp(0.0, 1.0);
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;
} }
bool get needsAbort => _needsAbort; bool get needsAbort => _needsAbort;
@ -95,7 +101,8 @@ class FaceProvider with ChangeNotifier {
int secondsLeft = (_holdDurationMs / 1000).ceil(); int secondsLeft = (_holdDurationMs / 1000).ceil();
if (_poseStartTime != null) { if (_poseStartTime != null) {
int elapsed = DateTime.now().difference(_poseStartTime!).inMilliseconds; 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) { switch (_currentStep) {
@ -142,14 +149,16 @@ class FaceProvider with ChangeNotifier {
_isLightingUniform = false; _isLightingUniform = false;
_maxDurationTimer?.cancel(); _maxDurationTimer?.cancel();
_maxDurationTimer = null; _maxDurationTimer = null;
_uiRefreshTimer?.cancel();
_uiRefreshTimer = null;
notifyListeners(); notifyListeners();
} }
Future<void> startRecording( Future<bool> startRecording(
CameraController controller, { CameraController controller, {
void Function(CameraImage)? onAvailable, void Function(CameraImage)? onAvailable,
}) async { }) async {
if (_recordingState == VideoRecordingState.recording) return; if (_recordingState == VideoRecordingState.recording) return false;
try { try {
await controller.startVideoRecording(onAvailable: onAvailable); await controller.startVideoRecording(onAvailable: onAvailable);
@ -163,13 +172,29 @@ class FaceProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
_startWatchdogTimer(controller); _startWatchdogTimer(controller);
_startUiRefreshTimer();
return true;
} catch (e) { } catch (e) {
debugPrint('[FaceProvider] startVideoRecording error: $e');
_recordingState = VideoRecordingState.error; _recordingState = VideoRecordingState.error;
_errorMessage = 'Gagal memulai rekaman: $e'; _errorMessage = 'Gagal memulai rekaman: $e';
notifyListeners(); 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) { void _startWatchdogTimer(CameraController controller) {
_maxDurationTimer?.cancel(); _maxDurationTimer?.cancel();
_maxDurationTimer = Timer(const Duration(seconds: _maxRecordingSeconds), () { _maxDurationTimer = Timer(const Duration(seconds: _maxRecordingSeconds), () {
@ -187,6 +212,8 @@ class FaceProvider with ChangeNotifier {
Future<void> abortRecording(CameraController controller) async { Future<void> abortRecording(CameraController controller) async {
_maxDurationTimer?.cancel(); _maxDurationTimer?.cancel();
_uiRefreshTimer?.cancel();
_uiRefreshTimer = null;
if (controller.value.isRecordingVideo) { if (controller.value.isRecordingVideo) {
try { try {
await controller.stopVideoRecording(); await controller.stopVideoRecording();
@ -200,10 +227,10 @@ class FaceProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> stopRecording(CameraController controller) async { Future<void> stopRecording(CameraController controller) async {
_maxDurationTimer?.cancel(); _maxDurationTimer?.cancel();
_uiRefreshTimer?.cancel();
_uiRefreshTimer = null;
if (!controller.value.isRecordingVideo) return; if (!controller.value.isRecordingVideo) return;
@ -278,8 +305,6 @@ class FaceProvider with ChangeNotifier {
_headEulerAngleY = face.headEulerAngleY ?? 0.0; _headEulerAngleY = face.headEulerAngleY ?? 0.0;
// Tentukan dimensi efektif berdasarkan rotasi sensor
// Sensor landscape (270°/90°) dimensi display-nya DIBALIK
final bool isRotated90or270 = final bool isRotated90or270 =
rotation == InputImageRotation.rotation90deg || rotation == InputImageRotation.rotation90deg ||
rotation == InputImageRotation.rotation270deg; rotation == InputImageRotation.rotation270deg;
@ -292,10 +317,8 @@ class FaceProvider with ChangeNotifier {
final box = face.boundingBox; final box = face.boundingBox;
// faceRatio dihitung dari lebar display yang sebenarnya
_faceRatio = box.width / effectiveWidth; _faceRatio = box.width / effectiveWidth;
// Validasi: Wajah harus berada SEPENUHNYA di dalam frame (tidak terpotong)
final double marginW = effectiveWidth * 0.03; final double marginW = effectiveWidth * 0.03;
final double marginH = effectiveHeight * 0.03; final double marginH = effectiveHeight * 0.03;
final bool isFaceInsideFrame = final bool isFaceInsideFrame =
@ -312,6 +335,7 @@ class FaceProvider with ChangeNotifier {
_isPoseValid = _headEulerAngleY.abs() < 18.0; _isPoseValid = _headEulerAngleY.abs() < 18.0;
// === LOGIKA SELAMA RECORDING ===
if (_recordingState == VideoRecordingState.recording) { if (_recordingState == VideoRecordingState.recording) {
final bool isWarmup = _recordingStartTime != null && final bool isWarmup = _recordingStartTime != null &&
DateTime.now().difference(_recordingStartTime!).inMilliseconds < _warmupMs; DateTime.now().difference(_recordingStartTime!).inMilliseconds < _warmupMs;
@ -356,7 +380,6 @@ class FaceProvider with ChangeNotifier {
} else if (_currentStep == EnrollmentStep.left) { } else if (_currentStep == EnrollmentStep.left) {
_currentStep = EnrollmentStep.done; _currentStep = EnrollmentStep.done;
_poseStartTime = null; _poseStartTime = null;
notifyListeners();
} }
} }
} else { } else {
@ -370,6 +393,7 @@ class FaceProvider with ChangeNotifier {
_isDistanceOk = false; _isDistanceOk = false;
_faceRatio = 0.0; _faceRatio = 0.0;
// Wajah hilang selama recording hitung bad frames
if (_recordingState == VideoRecordingState.recording) { if (_recordingState == VideoRecordingState.recording) {
final bool isWarmup = _recordingStartTime != null && final bool isWarmup = _recordingStartTime != null &&
DateTime.now().difference(_recordingStartTime!).inMilliseconds < _warmupMs; DateTime.now().difference(_recordingStartTime!).inMilliseconds < _warmupMs;
@ -379,6 +403,7 @@ class FaceProvider with ChangeNotifier {
_triggerAbort("Perekaman batal: Wajah tidak ditemukan dalam bingkai."); _triggerAbort("Perekaman batal: Wajah tidak ditemukan dalam bingkai.");
} }
} }
_poseStartTime = null;
} }
} }
@ -406,6 +431,7 @@ class FaceProvider with ChangeNotifier {
@override @override
void dispose() { void dispose() {
_maxDurationTimer?.cancel(); _maxDurationTimer?.cancel();
_uiRefreshTimer?.cancel();
_faceDetector.close(); _faceDetector.close();
super.dispose(); super.dispose();
} }

View File

@ -64,4 +64,18 @@ class HomeProvider extends ChangeNotifier {
sudahAbsenPulang: presensiToday!.sudahAbsenPulang, 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();
}
} }

View File

@ -68,4 +68,12 @@ class NotificationProvider extends ChangeNotifier {
unreadCount = 0; unreadCount = 0;
notifyListeners(); notifyListeners();
} }
void reset() {
notifications = [];
unreadCount = 0;
_isLoading = false;
_errorMessage = null;
notifyListeners();
}
} }

View File

@ -11,6 +11,7 @@ class PengajuanProvider extends ChangeNotifier {
List<PengajuanModel> listPengajuan = []; List<PengajuanModel> listPengajuan = [];
bool isLoading = false; bool isLoading = false;
String? errorMessage;
int selectedTabIndex = 0; int selectedTabIndex = 0;
bool hasSignature = false; bool hasSignature = false;
@ -82,8 +83,7 @@ class PengajuanProvider extends ChangeNotifier {
isLoading = true; isLoading = true;
notifyListeners(); notifyListeners();
try { try {
final dateParts = (data['date'] as String).split('/'); final tanggalLembur = data['date'] as String;
final tanggalLembur = '${dateParts[2]}-${dateParts[1].padLeft(2, '0')}-${dateParts[0].padLeft(2, '0')}';
final result = await _lemburRepository.submitLembur( final result = await _lemburRepository.submitLembur(
tanggalLembur: tanggalLembur, tanggalLembur: tanggalLembur,
@ -103,33 +103,73 @@ class PengajuanProvider extends ChangeNotifier {
} }
} }
String _convertTo24Hour(String time12h) { String _convertTo24Hour(String? timeStr) {
if (!time12h.contains('AM') && !time12h.contains('PM')) { if (timeStr == null || timeStr.isEmpty) return "00:00";
return time12h;
// 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(' '); 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);
final timeParts = parts[0].split(':'); final timeParts = parts[0].split(':');
int hour = int.parse(timeParts[0]); if (timeParts.length < 2) return "00:00";
int hour = int.tryParse(timeParts[0]) ?? 0;
final minute = timeParts[1]; final minute = timeParts[1];
final period = parts[1]; final period = parts[1];
if (period == 'PM' && hour != 12) hour += 12; if (period == 'PM' && hour != 12) hour += 12;
if (period == 'AM' && hour == 12) hour = 0; if (period == 'AM' && hour == 12) hour = 0;
return '${hour.toString().padLeft(2, '0')}:$minute'; 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<bool> _submitGeneric(String type, Map<String, dynamic> data) async { Future<bool> _submitGeneric(String type, Map<String, dynamic> data) async {
isLoading = true; isLoading = true;
errorMessage = null;
notifyListeners(); notifyListeners();
try { try {
return await _repository.submitPengajuan(type, data); return await _repository.submitPengajuan(type, data);
} catch(e) { } 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 { } finally {
isLoading = false; isLoading = false;
notifyListeners(); notifyListeners();
} }
} }
void reset() {
listPengajuan = [];
isLoading = false;
errorMessage = null;
selectedTabIndex = 0;
hasSignature = false;
notifyListeners();
}
} }

View File

@ -43,14 +43,17 @@ class PoinProvider extends ChangeNotifier {
final result = await _repository.getExpiringPoints(); final result = await _repository.getExpiringPoints();
if (result['success']) { if (result['success']) {
_totalPoin = result['data']['total_poin'] as int?; _totalPoin = int.tryParse(result['data']['total_poin'].toString());
final expiring = result['data']['expiring_points']; final expiring = result['data']['expiring_points'];
if (expiring != null) { if (expiring != null) {
_expiringPoints = expiring['poin'] as int?; _expiringPoints = int.tryParse(expiring['poin'].toString());
if (expiring['tanggal_kadaluarsa'] != null) { if (expiring['tanggal_kadaluarsa'] != null) {
_expiryDate = DateTime.tryParse(expiring['tanggal_kadaluarsa']); _expiryDate = DateTime.tryParse(expiring['tanggal_kadaluarsa']);
} }
} else {
_expiringPoints = null;
_expiryDate = null;
} }
_errorMessage = null; _errorMessage = null;
_hasLoadedExpiring = true; _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();
}
} }

View File

@ -73,4 +73,11 @@ class SignatureProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
} }
void reset() {
currentSignature = null;
isLoading = false;
errorMessage = null;
notifyListeners();
}
} }

View File

@ -65,4 +65,13 @@ class SuratIzinProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
} }
void reset() {
listSurat = [];
selectedSurat = null;
isLoading = false;
isLoadingDetail = false;
errorMessage = null;
notifyListeners();
}
} }

View File

@ -79,4 +79,10 @@ class AuthRepository {
throw 'Terjadi kesalahan koneksi'; throw 'Terjadi kesalahan koneksi';
} }
} }
Future<void> logout() async {
try {
await _apiClient.dio.post('/logout');
} catch (_) {}
}
} }

View File

@ -25,6 +25,8 @@ class FaceRepository {
request.headers.addAll({ request.headers.addAll({
'Authorization': 'Bearer $token', 'Authorization': 'Bearer $token',
'Accept': 'application/json', 'Accept': 'application/json',
'ngrok-skip-browser-warning': 'true',
'Bypass-Tunnel-Reminder': 'true',
}); });
request.files.add(await http.MultipartFile.fromPath( request.files.add(await http.MultipartFile.fromPath(
@ -68,7 +70,7 @@ class FaceRepository {
} }
} }
Future<Map<String, dynamic>> verifyFace(File videoFile, {String tipe = 'presensi'}) async { Future<Map<String, dynamic>> verifyFace(File photoFile, {String tipe = 'presensi'}) async {
final token = CacheManager.authBox.get('auth_token'); final token = CacheManager.authBox.get('auth_token');
var request = http.MultipartRequest( var request = http.MultipartRequest(
'POST', 'POST',
@ -78,12 +80,14 @@ class FaceRepository {
request.headers.addAll({ request.headers.addAll({
'Authorization': 'Bearer $token', 'Authorization': 'Bearer $token',
'Accept': 'application/json', 'Accept': 'application/json',
'ngrok-skip-browser-warning': 'true',
'Bypass-Tunnel-Reminder': 'true',
}); });
request.files.add(await http.MultipartFile.fromPath( request.files.add(await http.MultipartFile.fromPath(
'file_wajah', 'file_wajah',
videoFile.path, photoFile.path,
contentType: MediaType('video', 'mp4'), contentType: MediaType('image', 'jpeg'),
)); ));
request.fields['tipe'] = tipe; request.fields['tipe'] = tipe;
@ -109,6 +113,8 @@ class FaceRepository {
headers: { headers: {
'Authorization': 'Bearer $token', 'Authorization': 'Bearer $token',
'Accept': 'application/json', 'Accept': 'application/json',
'ngrok-skip-browser-warning': 'true',
'Bypass-Tunnel-Reminder': 'true',
}, },
); );

View File

@ -4,6 +4,7 @@ import '../../core/error_handler.dart';
import '../../core/theme.dart'; import '../../core/theme.dart';
import '../../providers/home_provider.dart'; import '../../providers/home_provider.dart';
import '../../providers/poin_provider.dart'; import '../../providers/poin_provider.dart';
import '../../providers/face_provider.dart';
import '../../widgets/organisms/home_header.dart'; import '../../widgets/organisms/home_header.dart';
import '../../widgets/organisms/poin_lembur_card.dart'; import '../../widgets/organisms/poin_lembur_card.dart';
import '../../widgets/organisms/presensi_section.dart'; import '../../widgets/organisms/presensi_section.dart';
@ -38,7 +39,7 @@ class _HomeScreenState extends State<HomeScreen> {
if (provider.isLoading) { if (provider.isLoading) {
return Scaffold( return Scaffold(
backgroundColor: AppTheme.bgLight, backgroundColor: AppTheme.bgLight,
body: const SafeArea(child: HomeShimmer()), body: const SafeArea(bottom: false, child: HomeShimmer()),
); );
} }
@ -46,6 +47,7 @@ class _HomeScreenState extends State<HomeScreen> {
return Scaffold( return Scaffold(
backgroundColor: AppTheme.bgLight, backgroundColor: AppTheme.bgLight,
body: SafeArea( body: SafeArea(
bottom: false,
child: Center( child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
@ -89,6 +91,7 @@ class _HomeScreenState extends State<HomeScreen> {
return Scaffold( return Scaffold(
backgroundColor: AppTheme.bgLight, backgroundColor: AppTheme.bgLight,
body: SafeArea( body: SafeArea(
bottom: false,
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () => provider.fetchDashboardData(force: true), onRefresh: () => provider.fetchDashboardData(force: true),
child: SingleChildScrollView( child: SingleChildScrollView(
@ -121,7 +124,55 @@ class _HomeScreenState extends State<HomeScreen> {
PresensiSection( PresensiSection(
presensiToday: provider.presensiToday, presensiToday: provider.presensiToday,
onPresensi: () { onPresensi: () async {
try {
final faceProvider = context.read<FaceProvider>();
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 sudahMasuk = provider.presensiToday?.sudahAbsenMasuk ?? false;
final sudahPulang = provider.presensiToday?.sudahAbsenPulang ?? false; final sudahPulang = provider.presensiToday?.sudahAbsenPulang ?? false;
String type = 'masuk'; String type = 'masuk';
@ -139,6 +190,10 @@ class _HomeScreenState extends State<HomeScreen> {
ErrorHandler.showSuccess(result['message'] ?? 'Presensi berhasil'); 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<HomeScreen> {
) )
else else
SizedBox( SizedBox(
height: 170, height: 190, // Ditingkatkan untuk memberi ruang bayangan
child: ListView.builder( child: ListView.builder(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(vertical: 10), // Padding vertikal untuk bayangan
clipBehavior: Clip.none, // Mencegah pemotongan shadow
itemCount: provider.pengumumanList.length, itemCount: provider.pengumumanList.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return FadeInUp( return FadeInUp(
@ -220,7 +277,7 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 100), // Memberi ruang untuk floating bottom bar
], ],
), ),
), ),

View File

@ -107,9 +107,8 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
index: _currentIndex, index: _currentIndex,
children: _screens, children: _screens,
), ),
bottomNavigationBar: SafeArea( bottomNavigationBar: Container(
child: Container( margin: EdgeInsets.fromLTRB(AppTheme.spacingMd, 0, AppTheme.spacingMd, MediaQuery.of(context).padding.bottom + AppTheme.spacingSm),
margin: const EdgeInsets.fromLTRB(AppTheme.spacingMd, 0, AppTheme.spacingMd, AppTheme.spacingMd),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.bgCard.withOpacity(0.85), color: AppTheme.bgCard.withOpacity(0.85),
borderRadius: BorderRadius.circular(AppTheme.radiusXl), borderRadius: BorderRadius.circular(AppTheme.radiusXl),
@ -119,6 +118,9 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
borderRadius: BorderRadius.circular(AppTheme.radiusXl), borderRadius: BorderRadius.circular(AppTheme.radiusXl),
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: MediaQuery.removePadding(
context: context,
removeBottom: true,
child: BottomNavigationBar( child: BottomNavigationBar(
currentIndex: _currentIndex, currentIndex: _currentIndex,
onTap: (index) { onTap: (index) {

View File

@ -311,7 +311,7 @@ class NotifikasiDetailScreen extends StatelessWidget {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
...notifikasi.data!.entries ...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( .map((e) => Padding(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 8),
child: Row( child: Row(

View File

@ -74,8 +74,8 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
_cameraController!.startImageStream((CameraImage image) { _cameraController!.startImageStream((CameraImage image) {
if (!mounted) return; if (!mounted) return;
final provider = context.read<FaceProvider>(); final provider = context.read<FaceProvider>();
var isIdle = provider.recordingState == VideoRecordingState.idle; final isIdle = provider.recordingState == VideoRecordingState.idle;
var isRecording = provider.recordingState == VideoRecordingState.recording; final isRecording = provider.recordingState == VideoRecordingState.recording;
if (isIdle || isRecording) { if (isIdle || isRecording) {
provider.processCameraImage( provider.processCameraImage(
image, image,
@ -117,14 +117,17 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
), ),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () async {
Navigator.pop(ctx); Navigator.pop(ctx);
context.read<AuthProvider>().logout(); await context.read<AuthProvider>().logout();
if (context.mounted) {
context.read<FaceProvider>().reset();
Navigator.pushNamedAndRemoveUntil( Navigator.pushNamedAndRemoveUntil(
context, context,
'/login', '/login',
(route) => false, (route) => false,
); );
}
}, },
child: Text( child: Text(
"Keluar", "Keluar",
@ -144,25 +147,27 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
// Matikan stream gambar sementara (aturan wajib Android camera plugin) // Wajib stop image stream sebelum startVideoRecording (camera 0.10.x)
try {
if (_cameraController!.value.isStreamingImages) { if (_cameraController!.value.isStreamingImages) {
await _cameraController!.stopImageStream(); await _cameraController!.stopImageStream();
} }
} catch (_) {}
// Beri waktu kamera settle (fix layar gelap di beberapa HP) // Gunakan onAvailable agar frame tetap diterima selama recording
await Future.delayed(const Duration(milliseconds: 500));
// Mulai rekam video dan oper callback onAvailable
await provider.startRecording( await provider.startRecording(
_cameraController!, _cameraController!,
onAvailable: (CameraImage image) { onAvailable: (CameraImage image) {
if (!mounted) return; if (!mounted) return;
provider.processCameraImage( final p = context.read<FaceProvider>();
if (p.recordingState == VideoRecordingState.recording) {
p.processCameraImage(
image, image,
_frontCamera!, _frontCamera!,
CameraUtils.rotationIntToImageRotation( CameraUtils.rotationIntToImageRotation(
_frontCamera!.sensorOrientation), _frontCamera!.sensorOrientation),
); );
}
}, },
); );
} }
@ -288,7 +293,7 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6), horizontal: 12, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red.withOpacity(0.8), color: AppTheme.primaryBlue.withOpacity(0.8),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Row( child: Row(
@ -304,7 +309,7 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
'REC', 'SCAN',
style: AppTheme.bodySmall style: AppTheme.bodySmall
.copyWith(color: Colors.white, fontWeight: FontWeight.bold), .copyWith(color: Colors.white, fontWeight: FontWeight.bold),
), ),
@ -460,7 +465,7 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: provider.stepProgress.clamp(0.0, 1.0), value: provider.totalProgress.clamp(0.0, 1.0),
minHeight: 8, minHeight: 8,
backgroundColor: backgroundColor:
Colors.white.withOpacity(0.2), Colors.white.withOpacity(0.2),
@ -476,7 +481,7 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
'Progres Pose (${(provider.stepProgress * 100).toInt()}%)', 'Progres Pose (${(provider.totalProgress * 100).toInt()}%)',
style: AppTheme.bodySmall.copyWith( style: AppTheme.bodySmall.copyWith(
color: Colors.white70, color: Colors.white70,
), ),
@ -594,9 +599,9 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
), ),
CustomButton( CustomButton(
text: provider.allChecksValid text: provider.allChecksValid
? "Mulai Rekam Wajah" ? "Mulai Pindai Wajah"
: "Penuhi Syarat di Atas", : "Penuhi Syarat di Atas",
icon: Icons.videocam, icon: Icons.face_retouching_natural,
backgroundColor: provider.allChecksValid backgroundColor: provider.allChecksValid
? AppTheme.statusGreen ? AppTheme.statusGreen
: AppTheme.primaryDark.withOpacity(0.5), : AppTheme.primaryDark.withOpacity(0.5),
@ -679,10 +684,10 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
text: "Coba Lagi", text: "Coba Lagi",
icon: Icons.refresh, icon: Icons.refresh,
backgroundColor: AppTheme.primaryOrange, backgroundColor: AppTheme.primaryOrange,
onPressed: () { onPressed: () async {
setState(() => _successHandled = false); setState(() => _successHandled = false);
provider.reset(); provider.reset();
_restartCameraStream(); await _restartCameraStream();
}, },
), ),
], ],
@ -738,24 +743,21 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
); );
} }
void _restartCameraStream() { Future<void> _restartCameraStream() async {
if (_cameraController == null || // Dispose controller lama dulu agar tidak ada konflik
!_cameraController!.value.isInitialized) return; setState(() => _isCameraInitialized = false);
try { try {
if (!_cameraController!.value.isStreamingImages) { if (_cameraController != null) {
_cameraController!.startImageStream((CameraImage image) { if (_cameraController!.value.isStreamingImages) {
if (!mounted) return; await _cameraController!.stopImageStream();
final provider = context.read<FaceProvider>(); }
provider.processCameraImage( await _cameraController!.dispose();
image, _cameraController = null;
_frontCamera!,
CameraUtils.rotationIntToImageRotation(
_frontCamera!.sensorOrientation),
);
});
} }
} catch (_) {} } catch (_) {}
// Reinisialisasi penuh dengan NV21 agar deteksi wajah berfungsi kembali
await _initializeCamera();
} }
String _getDistanceLabel(FaceProvider provider) { String _getDistanceLabel(FaceProvider provider) {
@ -885,7 +887,7 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
case VideoRecordingState.idle: case VideoRecordingState.idle:
return Icons.face; return Icons.face;
case VideoRecordingState.recording: case VideoRecordingState.recording:
return Icons.videocam; return Icons.camera_alt;
case VideoRecordingState.uploading: case VideoRecordingState.uploading:
return Icons.cloud_upload; return Icons.cloud_upload;
case VideoRecordingState.success: case VideoRecordingState.success:

View File

@ -60,7 +60,8 @@ class _CutiFormScreenState extends State<CutiFormScreen> {
Navigator.pop(context); Navigator.pop(context);
ErrorHandler.showSuccess('Pengajuan Cuti Berhasil'); ErrorHandler.showSuccess('Pengajuan Cuti Berhasil');
} else if (mounted) { } else if (mounted) {
ErrorHandler.showError('Pengajuan gagal. Periksa kembali form atau ukuran file (maksimal 10MB)'); final errorMsg = context.read<PengajuanProvider>().errorMessage;
ErrorHandler.showError(errorMsg ?? 'Pengajuan gagal. Periksa kembali form.');
} }
} }

View File

@ -62,10 +62,13 @@ class _IzinFormScreenState extends State<IzinFormScreen> {
if (success && mounted) { if (success && mounted) {
Navigator.pop(context); Navigator.pop(context);
ErrorHandler.showSuccess('Pengajuan Izin Berhasil'); ErrorHandler.showSuccess('Pengajuan Izin Berhasil');
} else if (mounted) {
final errorMsg = context.read<PengajuanProvider>().errorMessage;
ErrorHandler.showError(errorMsg ?? 'Pengajuan gagal. Periksa kembali form atau ukuran file.');
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ErrorHandler.showError(e.toString().replaceAll('Exception: ', '')); ErrorHandler.showError('Terjadi kesalahan sistem.');
} }
} }
} }
@ -239,11 +242,13 @@ class _IzinFormScreenState extends State<IzinFormScreen> {
), ),
if (_uploadedFileName != null) if (_uploadedFileName != null)
IconButton( IconButton(
icon: const Icon(Icons.close, size: 24), icon: const Icon(Icons.close, size: 18),
onPressed: () => setState(() { onPressed: () => setState(() {
_uploadedFileName = null; _uploadedFileName = null;
_uploadedFilePath = null; _uploadedFilePath = null;
}), }),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
), ),
], ],
), ),

View File

@ -4,8 +4,10 @@ import '../../../core/theme.dart';
import '../../../widgets/atoms/custom_text_field.dart'; import '../../../widgets/atoms/custom_text_field.dart';
import '../../../widgets/atoms/custom_button.dart'; import '../../../widgets/atoms/custom_button.dart';
import '../../../providers/pengajuan_provider.dart'; import '../../../providers/pengajuan_provider.dart';
import '../../../providers/calendar_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../../services/api_client.dart'; import '../../../services/api_client.dart';
import 'package:intl/intl.dart';
class LemburFormScreen extends StatefulWidget { class LemburFormScreen extends StatefulWidget {
const LemburFormScreen({Key? key}) : super(key: key); const LemburFormScreen({Key? key}) : super(key: key);
@ -21,6 +23,10 @@ class _LemburFormScreenState extends State<LemburFormScreen> {
final _reasonController = TextEditingController(); final _reasonController = TextEditingController();
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
DateTime? _selectedDate;
TimeOfDay? _selectedStartTime;
TimeOfDay? _selectedEndTime;
int? _selectedKompensasi; int? _selectedKompensasi;
List<Map<String, dynamic>> _jenisKompensasi = []; List<Map<String, dynamic>> _jenisKompensasi = [];
@ -37,6 +43,10 @@ class _LemburFormScreenState extends State<LemburFormScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_fetchKompensasi(); _fetchKompensasi();
WidgetsBinding.instance.addPostFrameCallback((_) {
final now = DateTime.now();
context.read<CalendarProvider>().fetchMonthlySchedule(now.month, now.year);
});
} }
Future<void> _fetchKompensasi() async { Future<void> _fetchKompensasi() async {
@ -56,29 +66,94 @@ class _LemburFormScreenState extends State<LemburFormScreen> {
} }
} }
Future<void> _selectDate(TextEditingController controller) async { void _updateTimeFromSchedule(DateTime date) {
final now = DateTime.now(); final schedule = context.read<CalendarProvider>().getScheduleByDate(date);
final DateTime? picked = await showDatePicker( if (schedule != null && schedule.jamPulang != '-' && schedule.jamPulang.contains(':')) {
context: context,
initialDate: now,
firstDate: DateTime(now.year, now.month - 1, 1),
lastDate: DateTime(2101),
);
if (picked != null) {
setState(() { 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<void> _selectTime(TextEditingController controller) async { String _formatTimeOfDay(TimeOfDay time) {
final TimeOfDay? picked = await showTimePicker( final now = DateTime.now();
final dt = DateTime(now.year, now.month, now.day, time.hour, time.minute);
return DateFormat('HH:mm').format(dt);
}
Future<void> _selectDate(TextEditingController controller) async {
final now = DateTime.now();
final DateTime? picked = await showDatePicker(
context: context, 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) { if (picked != null) {
setState(() { setState(() {
controller.text = picked.format(context); _selectedDate = picked;
controller.text = DateFormat('yyyy-MM-dd').format(picked);
});
if (picked.month != now.month) {
await context.read<CalendarProvider>().fetchMonthlySchedule(picked.month, picked.year);
}
_updateTimeFromSchedule(picked);
}
}
Future<void> _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<void> _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<LemburFormScreen> {
controller: _dateController, controller: _dateController,
readOnly: true, readOnly: true,
onTap: () => _selectDate(_dateController), 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, validator: (v) => v!.isEmpty ? "Wajib diisi" : null,
), ),
const SizedBox(height: AppTheme.spacingMd), const SizedBox(height: AppTheme.spacingMd),
@ -137,8 +213,9 @@ class _LemburFormScreenState extends State<LemburFormScreen> {
label: "Jam Mulai", label: "Jam Mulai",
controller: _startTimeController, controller: _startTimeController,
readOnly: true, readOnly: true,
onTap: () => _selectTime(_startTimeController), onTap: _selectStartTime,
suffixIcon: const Icon(Icons.access_time), suffixIcon: const Icon(Icons.access_time, color: AppTheme.primaryBlue, size: 20),
hint: "00:00",
validator: (v) => v!.isEmpty ? "Wajib" : null, validator: (v) => v!.isEmpty ? "Wajib" : null,
), ),
), ),
@ -148,8 +225,9 @@ class _LemburFormScreenState extends State<LemburFormScreen> {
label: "Jam Selesai", label: "Jam Selesai",
controller: _endTimeController, controller: _endTimeController,
readOnly: true, readOnly: true,
onTap: () => _selectTime(_endTimeController), onTap: _selectEndTime,
suffixIcon: const Icon(Icons.access_time), suffixIcon: const Icon(Icons.access_time, color: AppTheme.primaryBlue, size: 20),
hint: "00:00",
validator: (v) => v!.isEmpty ? "Wajib" : null, validator: (v) => v!.isEmpty ? "Wajib" : null,
), ),
), ),
@ -160,6 +238,7 @@ class _LemburFormScreenState extends State<LemburFormScreen> {
label: "Keterangan Tugas", label: "Keterangan Tugas",
controller: _reasonController, controller: _reasonController,
maxLines: 3, maxLines: 3,
hint: "Penjelasan pekerjaan yang dilakukan...",
validator: (v) => v!.isEmpty ? "Wajib diisi" : null, validator: (v) => v!.isEmpty ? "Wajib diisi" : null,
), ),
const SizedBox(height: AppTheme.spacingMd), const SizedBox(height: AppTheme.spacingMd),
@ -218,3 +297,4 @@ class _LemburFormScreenState extends State<LemburFormScreen> {
); );
} }
} }

View File

@ -70,7 +70,8 @@ class _SakitFormScreenState extends State<SakitFormScreen> {
Navigator.pop(context); Navigator.pop(context);
ErrorHandler.showSuccess('Pengajuan Sakit Berhasil'); ErrorHandler.showSuccess('Pengajuan Sakit Berhasil');
} else if (mounted) { } else if (mounted) {
ErrorHandler.showError('Pengajuan gagal. Periksa kembali form atau ukuran file (maksimal 10MB)'); final errorMsg = context.read<PengajuanProvider>().errorMessage;
ErrorHandler.showError(errorMsg ?? 'Pengajuan gagal. Periksa kembali form atau ukuran file.');
} }
} }
@ -78,8 +79,8 @@ class _SakitFormScreenState extends State<SakitFormScreen> {
final DateTime? picked = await showDatePicker( final DateTime? picked = await showDatePicker(
context: context, context: context,
initialDate: _startDate ?? DateTime.now(), initialDate: _startDate ?? DateTime.now(),
firstDate: DateTime.now().subtract(const Duration(days: 7)), firstDate: DateTime.now(),
lastDate: DateTime.now(), lastDate: DateTime.now().add(const Duration(days: 30)),
helpText: "Pilih Tanggal Mulai Sakit", helpText: "Pilih Tanggal Mulai Sakit",
); );
if (picked != null) { if (picked != null) {
@ -178,6 +179,7 @@ class _SakitFormScreenState extends State<SakitFormScreen> {
), ),
), ),
Row( Row(
children: [ children: [
Expanded( Expanded(

View File

@ -41,7 +41,7 @@ class _PointUsageScreenState extends State<PointUsageScreen> {
}); });
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<PoinProvider>().loadExpiringPoints(); context.read<PoinProvider>().loadExpiringPoints(force: true);
}); });
} }

View File

@ -27,33 +27,32 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen>
bool _isFaceDetected = false; bool _isFaceDetected = false;
bool _isDistanceOk = false; bool _isDistanceOk = false;
bool _isBrightnessOk = false; bool _isBrightnessOk = false;
bool _isLightingUniform = false;
bool _isPoseValid = false;
double _faceRatio = 0.0; double _faceRatio = 0.0;
double _brightness = 0.0; double _brightness = 0.0;
double _lightingUniformity = 0.0;
double _headEulerAngleY = 0.0;
// State rekam video // State capture foto
bool _isRecording = false; bool _isCaptureDone = false;
bool _isRecordingDone = false;
XFile? _recordedVideo;
XFile? _capturedPhoto; XFile? _capturedPhoto;
// Countdown
int _countdown = 1;
int _recordingSecondsLeft = 1;
late FaceDetector _faceDetector; late FaceDetector _faceDetector;
late AnimationController _pulseController; late AnimationController _pulseController;
bool get _allChecksValid => bool get _allChecksValid =>
_isFaceDetected && _isDistanceOk && _isBrightnessOk; _isFaceDetected && _isDistanceOk && _isBrightnessOk && _isLightingUniform && _isPoseValid;
String get _qualityMessage { String get _qualityMessage {
if (_isRecordingDone) return 'Video berhasil direkam ✓'; if (_isCaptureDone) return 'Pemotretan berhasil ✓';
if (_isRecording) return 'Tetap hadap kamera... ($_recordingSecondsLeft detik)';
if (!_isFaceDetected) return 'Arahkan wajah ke dalam bingkai'; if (!_isFaceDetected) return 'Arahkan wajah ke dalam bingkai';
if (_faceRatio < 0.20) return 'Terlalu jauh, dekatkan wajah'; if (_faceRatio < 0.25) return 'Terlalu jauh, dekatkan wajah';
if (_faceRatio > 0.70) return 'Terlalu dekat, mundur sedikit'; if (_faceRatio > 0.75) return 'Terlalu dekat, mundur sedikit';
if (!_isBrightnessOk) return 'Pencahayaan kurang terang'; 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 @override
@ -67,10 +66,10 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen>
_faceDetector = FaceDetector( _faceDetector = FaceDetector(
options: FaceDetectorOptions( options: FaceDetectorOptions(
enableClassification: false, enableClassification: false,
enableLandmarks: false, enableLandmarks: true,
enableContours: false, enableContours: false,
enableTracking: false, enableTracking: true,
performanceMode: FaceDetectorMode.fast, performanceMode: FaceDetectorMode.accurate,
), ),
); );
_initializeCamera(); _initializeCamera();
@ -97,7 +96,7 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen>
if (!mounted) return; if (!mounted) return;
_controller!.startImageStream((CameraImage image) { _controller!.startImageStream((CameraImage image) {
if (!mounted || _isRecording || _isRecordingDone) return; if (!mounted || _isCaptureDone) return;
_processFrame(image); _processFrame(image);
}); });
@ -111,8 +110,11 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen>
_isProcessingFrame = true; _isProcessingFrame = true;
try { try {
_brightness = BrightnessUtils.calculateBrightness(image); _brightness = BrightnessUtils.calculateBrightness(image, centerOnly: true);
_isBrightnessOk = _brightness > 60; _isBrightnessOk = _brightness > 70;
_lightingUniformity = BrightnessUtils.calculateFaceUniformity(image);
_isLightingUniform = _lightingUniformity > 0.45;
final inputImage = CameraUtils.inputImageFromCameraImage( final inputImage = CameraUtils.inputImageFromCameraImage(
image: image, image: image,
@ -125,13 +127,25 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen>
if (faces.isNotEmpty) { if (faces.isNotEmpty) {
final face = faces.first; 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; _isFaceDetected = true;
_isDistanceOk = _faceRatio >= 0.20 && _faceRatio <= 0.70; _isDistanceOk = _faceRatio >= 0.25 && _faceRatio <= 0.75;
} else { } else {
_isFaceDetected = false; _isFaceDetected = false;
_isDistanceOk = false; _isDistanceOk = false;
_isPoseValid = false;
_faceRatio = 0.0; _faceRatio = 0.0;
} }
@ -143,8 +157,8 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen>
} }
} }
Future<void> _startRecording() async { Future<void> _capturePhoto() async {
if (!_allChecksValid || _isRecording) return; if (!_allChecksValid || _isCaptureDone) return;
try { try {
await _controller!.stopImageStream(); await _controller!.stopImageStream();
@ -155,61 +169,42 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen>
photo = await _controller!.takePicture(); photo = await _controller!.takePicture();
} catch (e) { } catch (e) {
debugPrint("Gagal mengambil foto: $e"); debugPrint("Gagal mengambil foto: $e");
} _controller!.startImageStream((CameraImage image) {
if (!mounted || _isCaptureDone) return;
setState(() { _processFrame(image);
_isRecording = true;
_recordingSecondsLeft = 1;
}); });
return;
}
HapticFeedback.mediumImpact(); 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(() { setState(() {
_isRecording = false; _isCaptureDone = true;
_isRecordingDone = true;
_recordedVideo = video;
_capturedPhoto = photo; _capturedPhoto = photo;
}); });
} }
void _retake() { void _retake() {
setState(() { setState(() {
_isRecording = false; _isCaptureDone = false;
_isRecordingDone = false;
_recordedVideo = null;
_capturedPhoto = null; _capturedPhoto = null;
_isFaceDetected = false; _isFaceDetected = false;
_isDistanceOk = false; _isDistanceOk = false;
_isPoseValid = false;
_isLightingUniform = false;
_isBrightnessOk = false; _isBrightnessOk = false;
}); });
_controller!.startImageStream((CameraImage image) { _controller!.startImageStream((CameraImage image) {
if (!mounted || _isRecording || _isRecordingDone) return; if (!mounted || _isCaptureDone) return;
_processFrame(image); _processFrame(image);
}); });
} }
void _useVideo() { void _usePhoto() {
if (_recordedVideo != null) { if (_capturedPhoto != null) {
Navigator.pop(context, { Navigator.pop(context, {
'video': File(_recordedVideo!.path), 'photo': File(_capturedPhoto!.path),
'photo': _capturedPhoto != null ? File(_capturedPhoto!.path) : null,
}); });
} }
} }
@ -244,8 +239,15 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen>
), ),
body: Stack( body: Stack(
children: [ children: [
// Preview kamera // Preview kamera atau Foto yang diambil
if (_isCameraInitialized) if (_isCaptureDone && _capturedPhoto != null)
SizedBox.expand(
child: Image.file(
File(_capturedPhoto!.path),
fit: BoxFit.cover,
),
)
else if (_isCameraInitialized)
SizedBox.expand( SizedBox.expand(
child: FittedBox( child: FittedBox(
fit: BoxFit.cover, fit: BoxFit.cover,
@ -261,65 +263,17 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen>
else else
const Center(child: CircularProgressIndicator(color: Colors.white)), 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 // Scanner overlay
ScannerOverlay( ScannerOverlay(
borderColor: _isRecordingDone borderColor: _isCaptureDone
? AppTheme.statusGreen ? AppTheme.statusGreen
: _isRecording
? Colors.red
: _allChecksValid : _allChecksValid
? AppTheme.statusGreen ? AppTheme.statusGreen
: _isFaceDetected : _isFaceDetected
? AppTheme.statusYellow ? AppTheme.statusYellow
: AppTheme.primaryBlue, : AppTheme.primaryBlue,
isScanning: !_isRecordingDone, isScanning: !_isCaptureDone,
), ),
// Header info // Header info
@ -330,11 +284,9 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen>
child: Column( child: Column(
children: [ children: [
Text( Text(
_isRecording _isCaptureDone
? 'Merekam Video...' ? 'Pemotretan Selesai'
: _isRecordingDone : 'Ambil Foto Wajah',
? 'Video Siap'
: 'Rekam Video Wajah',
style: AppTheme.heading3.copyWith(color: Colors.white), style: AppTheme.heading3.copyWith(color: Colors.white),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
@ -355,10 +307,8 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen>
_qualityMessage, _qualityMessage,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: AppTheme.bodyLarge.copyWith( style: AppTheme.bodyLarge.copyWith(
color: _isRecordingDone color: _isCaptureDone
? AppTheme.statusGreen ? AppTheme.statusGreen
: _isRecording
? Colors.red.shade200
: _allChecksValid : _allChecksValid
? AppTheme.statusGreen ? AppTheme.statusGreen
: Colors.white, : Colors.white,
@ -370,44 +320,47 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen>
), ),
// Quality chips (sebelum rekam) // Quality chips (sebelum rekam)
if (!_isRecording && !_isRecordingDone) ...[ if (!_isCaptureDone) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
Row( Wrap(
mainAxisAlignment: MainAxisAlignment.center, spacing: 6,
runSpacing: 6,
alignment: WrapAlignment.center,
children: [ children: [
_buildChip(Icons.face, 'Wajah', _isFaceDetected), _buildQualityChip(
const SizedBox(width: 6), icon: Icons.straighten,
_buildChip(Icons.straighten, 'Jarak', _isDistanceOk), label: _isFaceDetected
const SizedBox(width: 6), ? (_faceRatio < 0.25
_buildChip( ? 'Terlalu Jauh'
Icons.wb_sunny_outlined, 'Cahaya', _isBrightnessOk), : _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<CameraCaptureScreen>
bottom: 40, bottom: 40,
left: 20, left: 20,
right: 20, right: 20,
child: _isRecordingDone child: _isCaptureDone
? Row( ? Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
Expanded( Expanded(
child: CustomButton( child: CustomButton(
text: "Rekam Ulang", text: "Pindai Ulang",
type: ButtonType.secondary, type: ButtonType.secondary,
onPressed: _retake, onPressed: _retake,
icon: Icons.refresh, icon: Icons.refresh,
@ -434,18 +387,16 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen>
child: CustomButton( child: CustomButton(
text: "Gunakan", text: "Gunakan",
type: ButtonType.primary, type: ButtonType.primary,
onPressed: _useVideo, onPressed: _usePhoto,
icon: Icons.check, icon: Icons.check,
backgroundColor: AppTheme.statusGreen, backgroundColor: AppTheme.statusGreen,
), ),
), ),
], ],
) )
: _isRecording
? const SizedBox.shrink()
: Center( : Center(
child: GestureDetector( child: GestureDetector(
onTap: _allChecksValid ? _startRecording : null, onTap: _allChecksValid ? _capturePhoto : null,
child: Container( child: Container(
width: 76, width: 76,
height: 76, height: 76,
@ -458,21 +409,21 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen>
width: 6, width: 6,
), ),
color: _allChecksValid color: _allChecksValid
? Colors.red.withOpacity(0.8) ? AppTheme.primaryBlue.withOpacity(0.8)
: Colors.white.withOpacity(0.05), : Colors.white.withOpacity(0.05),
boxShadow: _allChecksValid boxShadow: _allChecksValid
? [ ? [
BoxShadow( BoxShadow(
color: Colors.red.withOpacity(0.4), color: AppTheme.primaryBlue.withOpacity(0.4),
blurRadius: 16, blurRadius: 16,
) )
] ]
: null, : null,
), ),
child: const Icon( child: const Icon(
Icons.videocam, Icons.face_retouching_natural,
color: Colors.white, color: Colors.white,
size: 30, size: 36,
), ),
), ),
), ),
@ -483,23 +434,42 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen>
); );
} }
Widget _buildChip(IconData icon, String label, bool isOk) { Widget _buildQualityChip({
final color = isOk ? AppTheme.statusGreen : Colors.white.withOpacity(0.4); 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( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withOpacity(0.15), color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(20),
border: Border.all(color: color.withOpacity(0.4)), border: Border.all(color: color.withOpacity(0.4)),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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), const SizedBox(width: 4),
Text(label, Text(
label,
style: TextStyle( style: TextStyle(
color: color, fontSize: 10, fontWeight: FontWeight.w600)), color: color,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
], ],
), ),
); );

View File

@ -13,6 +13,7 @@ import '../../repositories/face_repository.dart';
import '../../widgets/atoms/custom_button.dart'; import '../../widgets/atoms/custom_button.dart';
import '../../widgets/organisms/map_view_organism.dart'; import '../../widgets/organisms/map_view_organism.dart';
import 'camera_capture_screen.dart'; import 'camera_capture_screen.dart';
import '../../widgets/organisms/mock_location_blocker.dart';
class PresensiMapScreen extends StatefulWidget { class PresensiMapScreen extends StatefulWidget {
final String type; final String type;
@ -86,6 +87,14 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
final attendanceProvider = context.watch<AttendanceProvider>(); final attendanceProvider = context.watch<AttendanceProvider>();
final user = homeProvider.user; final user = homeProvider.user;
if (attendanceProvider.isMocked) {
return MockLocationBlocker(
onRetry: () {
_fetchLocation();
},
);
}
final currentLocation = attendanceProvider.currentLocation ?? _officeLocation; final currentLocation = attendanceProvider.currentLocation ?? _officeLocation;
final distance = attendanceProvider.currentLocation != null final distance = attendanceProvider.currentLocation != null
@ -463,7 +472,10 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
parts.length > 2 ? int.parse(parts[2]) : 0 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); _showEarlyCheckoutDialog(context);
return; return;
} }
@ -487,7 +499,8 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
parts.length > 2 ? int.parse(parts[2]) : 0 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)) { if (now.isAfter(batasToleransi)) {
_showLateCheckinDialog(context, isWithinRadius); _showLateCheckinDialog(context, isWithinRadius);
@ -875,6 +888,9 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
} }
Future<void> _startFaceRecognitionFlow(BuildContext context) async { Future<void> _startFaceRecognitionFlow(BuildContext context) async {
// Capture provider di awal sebelum SEMUA async gap (fix use_build_context_synchronously)
final provider = context.read<AttendanceProvider>();
final Map<String, dynamic>? captureResult = await Navigator.push( final Map<String, dynamic>? captureResult = await Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@ -884,56 +900,44 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
if (captureResult == null || !mounted) return; if (captureResult == null || !mounted) return;
final File? videoFile = captureResult['video'];
final File? photoFile = captureResult['photo']; final File? photoFile = captureResult['photo'];
if (videoFile == null || photoFile == null) { if (photoFile == null) {
_showErrorSnackBar("Gagal mengambil data kamera."); _showErrorSnackBar("Gagal mengambil data kamera.");
return; return;
} }
// MB-10: Tampilkan overlay loading SEGERA setelah foto diterima,
// sebelum proses verifikasi ke Flask dimulai agar tidak ada layar kosong/freeze.
setState(() { setState(() {
_isProcessing = true; _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 { 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; 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 (!isVerified) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_isProcessing = false; _isProcessing = false;
}); });
_showFaceFailedDialog( _showFaceFailedDialog();
confidence: confidence,
framesTotal: framesTotal,
framesApproved: framesApproved,
message: faceResult['message']?.toString() ?? "Wajah tidak cocok.",
);
return; return;
} }
setState(() { if (!mounted) return;
_processingStatus = "Mengirim data presensi..."; setState(() => _processingStatus = "Mengirim data presensi...");
});
final provider = context.read<AttendanceProvider>();
final success = await provider.submitPresensi(widget.type, photoFile); final success = await provider.submitPresensi(widget.type, photoFile);
if (!mounted) return; if (!mounted) return;
@ -944,7 +948,7 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
if (success) { if (success) {
HapticFeedback.heavyImpact(); HapticFeedback.heavyImpact();
Navigator.pop(context, { Navigator.pop(this.context, {
'success': true, 'success': true,
'message': widget.type == 'masuk' 'message': widget.type == 'masuk'
? 'Absen masuk berhasil!' ? 'Absen masuk berhasil!'
@ -967,12 +971,7 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
} }
} }
void _showFaceFailedDialog({ void _showFaceFailedDialog() {
double? confidence,
int? framesTotal,
int? framesApproved,
required String message,
}) {
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
@ -988,24 +987,14 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(message, style: AppTheme.bodyMedium),
if (framesTotal != null) ...[
const SizedBox(height: 12),
Text( Text(
"Frame Cocok: $framesApproved/$framesTotal", "Wajah Anda tidak dikenali oleh sistem.",
style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary), style: AppTheme.bodyMedium,
), ),
] else if (confidence != null && confidence < 1.0) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
"Confidence: ${(confidence * 100).toStringAsFixed(1)}%", "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), style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary, height: 1.4),
),
],
const SizedBox(height: 12),
Text(
"Pastikan pencahayaan cukup dan wajah terlihat jelas.",
style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary),
), ),
], ],
), ),

View File

@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../../core/theme.dart'; import '../../core/theme.dart';
import '../../providers/home_provider.dart'; import '../../providers/home_provider.dart';
import '../../providers/attendance_provider.dart'; import '../../providers/attendance_provider.dart';
import '../../providers/face_provider.dart';
import '../../widgets/organisms/presensi_section.dart'; import '../../widgets/organisms/presensi_section.dart';
import '../../widgets/molecules/stat_card.dart'; import '../../widgets/molecules/stat_card.dart';
import '../../widgets/molecules/attendance_time_card.dart'; import '../../widgets/molecules/attendance_time_card.dart';
@ -63,6 +64,8 @@ class _PresensiScreenState extends State<PresensiScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final homeProvider = context.watch<HomeProvider>(); final homeProvider = context.watch<HomeProvider>();
final attendanceProvider = context.watch<AttendanceProvider>(); final attendanceProvider = context.watch<AttendanceProvider>();
final faceProvider = context.watch<FaceProvider>();
final faceStatus = faceProvider.faceStatus2['status'];
return Scaffold( return Scaffold(
backgroundColor: AppTheme.bgLight, backgroundColor: AppTheme.bgLight,
@ -87,12 +90,7 @@ class _PresensiScreenState extends State<PresensiScreen> {
children: [ children: [
Text(homeProvider.presensiToday?.shift ?? "-", style: AppTheme.labelMedium.copyWith(color: AppTheme.primaryDark)), Text(homeProvider.presensiToday?.shift ?? "-", style: AppTheme.labelMedium.copyWith(color: AppTheme.primaryDark)),
const SizedBox(width: 8), const SizedBox(width: 8),
InkWell( const Icon(Icons.calendar_month, color: AppTheme.primaryDark, size: 20),
onTap: () {
_showScheduleModal(context);
},
child: const Icon(Icons.calendar_month, color: AppTheme.primaryDark, size: 20),
),
], ],
), ),
), ),
@ -103,12 +101,59 @@ class _PresensiScreenState extends State<PresensiScreen> {
onRefresh: () async { onRefresh: () async {
await homeProvider.fetchDashboardData(force: true); await homeProvider.fetchDashboardData(force: true);
await attendanceProvider.fetchHistory(force: true); await attendanceProvider.fetchHistory(force: true);
faceProvider.loadFaceStatus();
}, },
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(AppTheme.spacingMd), padding: const EdgeInsets.all(AppTheme.spacingMd),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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( PresensiSection(
presensiToday: homeProvider.presensiToday, presensiToday: homeProvider.presensiToday,
onPresensi: () { onPresensi: () {
@ -145,8 +190,11 @@ class _PresensiScreenState extends State<PresensiScreen> {
child: const Icon(Icons.chevron_left, size: 20, color: AppTheme.primaryDark), child: const Icon(Icons.chevron_left, size: 20, color: AppTheme.primaryDark),
), ),
), ),
GestureDetector( InkWell(
onTap: () => _showMonthYearPicker(context, attendanceProvider), onTap: () => _showMonthYearPicker(context, attendanceProvider),
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -159,6 +207,7 @@ class _PresensiScreenState extends State<PresensiScreen> {
], ],
), ),
), ),
),
InkWell( InkWell(
onTap: _isCurrentMonth(attendanceProvider) ? null : () => _goToNextMonth(attendanceProvider), onTap: _isCurrentMonth(attendanceProvider) ? null : () => _goToNextMonth(attendanceProvider),
borderRadius: BorderRadius.circular(AppTheme.radiusFull), borderRadius: BorderRadius.circular(AppTheme.radiusFull),
@ -270,26 +319,15 @@ class _PresensiScreenState extends State<PresensiScreen> {
), ),
], ],
), ),
if ((history.jamMasuk != null && history.jamMasuk != '-') || (history.jamPulang != null && history.jamPulang != '-')) ...[
const SizedBox(height: AppTheme.spacingMd), const SizedBox(height: AppTheme.spacingMd),
Row( AttendanceTimeCard(
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 ?? '-', jamMasuk: history.jamMasuk ?? '-',
jamPulang: history.jamPulang ?? '-', jamPulang: history.jamPulang ?? '-',
), totalJam: history.totalJam,
),
],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
],
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
@ -498,6 +536,54 @@ class _PresensiScreenState extends State<PresensiScreen> {
Future<void> _handlePresensi(String type) async { Future<void> _handlePresensi(String type) async {
if (!mounted) return; if (!mounted) return;
try {
final faceProvider = context.read<FaceProvider>();
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 result = await Navigator.push<Map<String, dynamic>>( final result = await Navigator.push<Map<String, dynamic>>(
context, context,
MaterialPageRoute( MaterialPageRoute(
@ -512,6 +598,12 @@ class _PresensiScreenState extends State<PresensiScreen> {
await context.read<AttendanceProvider>().fetchHistory(force: true); await context.read<AttendanceProvider>().fetchHistory(force: true);
_showResultDialog(true, "Absensi Berhasil!", result['message'] ?? "Data absensi Anda telah tercatat."); _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.")),
);
}
} }
void _showResultDialog(bool success, String title, String message) { void _showResultDialog(bool success, String title, String message) {

View File

@ -29,8 +29,12 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
bool _isFaceDetected = false; bool _isFaceDetected = false;
bool _isDistanceOk = false; bool _isDistanceOk = false;
bool _isBrightnessOk = false; bool _isBrightnessOk = false;
bool _isLightingUniform = false;
bool _isPoseValid = false;
double _faceRatio = 0.0; double _faceRatio = 0.0;
double _brightness = 0.0; double _brightness = 0.0;
double _lightingUniformity = 0.0;
double _headEulerAngleY = 0.0;
bool? _verified; bool? _verified;
double? _confidence; double? _confidence;
@ -44,7 +48,7 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
late FaceDetector _faceDetector; late FaceDetector _faceDetector;
bool get _allChecksValid => bool get _allChecksValid =>
_isFaceDetected && _isDistanceOk && _isBrightnessOk; _isFaceDetected && _isDistanceOk && _isBrightnessOk && _isLightingUniform && _isPoseValid;
// State recording // State recording
bool _isRecording = false; bool _isRecording = false;
@ -53,15 +57,18 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
String get _qualityMessage { String get _qualityMessage {
if (_isRecording) return 'Tetap hadap kamera... ($_recordingSecondsLeft detik)'; if (_isRecording) return 'Tetap hadap kamera... ($_recordingSecondsLeft detik)';
if (!_isFaceDetected) return 'Arahkan wajah ke dalam bingkai'; if (!_isFaceDetected) return 'Arahkan wajah ke dalam bingkai';
if (_faceRatio < 0.20) return 'Terlalu jauh, dekatkan wajah'; if (_faceRatio < 0.25) return 'Terlalu jauh, dekatkan wajah';
if (_faceRatio > 0.70) return 'Terlalu dekat, mundur sedikit'; if (_faceRatio > 0.75) return 'Terlalu dekat, mundur sedikit';
if (!_isBrightnessOk) return 'Pencahayaan kurang terang'; if (!_isBrightnessOk) return 'Pencahayaan kurang terang';
if (!_isLightingUniform) return 'Ada bayangan, hadap cahaya';
if (!_isPoseValid) return 'Hadap lurus ke kamera';
return 'Siap — mulai verifikasi'; return 'Siap — mulai verifikasi';
} }
Color get _resultColor { Color get _resultColor {
if (_statusText == 'APPROVED') return AppTheme.statusGreen; final s = _statusText;
if (_statusText == 'PENDING') return AppTheme.statusYellow; if (s == 'APPROVED' || s == 'MATCH') return AppTheme.statusGreen;
if (s == 'PENDING' || s == 'MISMATCH') return AppTheme.statusYellow;
return AppTheme.statusRed; return AppTheme.statusRed;
} }
@ -71,10 +78,10 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
_faceDetector = FaceDetector( _faceDetector = FaceDetector(
options: FaceDetectorOptions( options: FaceDetectorOptions(
enableClassification: false, enableClassification: false,
enableLandmarks: false, enableLandmarks: true,
enableContours: false, enableContours: false,
enableTracking: false, enableTracking: true,
performanceMode: FaceDetectorMode.fast, performanceMode: FaceDetectorMode.accurate,
), ),
); );
_initializeCamera(); _initializeCamera();
@ -123,8 +130,11 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
_isProcessingFrame = true; _isProcessingFrame = true;
try { try {
_brightness = BrightnessUtils.calculateBrightness(image); _brightness = BrightnessUtils.calculateBrightness(image, centerOnly: true);
_isBrightnessOk = _brightness > 60; _isBrightnessOk = _brightness > 70;
_lightingUniformity = BrightnessUtils.calculateFaceUniformity(image);
_isLightingUniform = _lightingUniformity > 0.45;
final inputImage = CameraUtils.inputImageFromCameraImage( final inputImage = CameraUtils.inputImageFromCameraImage(
image: image, image: image,
@ -137,13 +147,25 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
if (faces.isNotEmpty) { if (faces.isNotEmpty) {
final face = faces.first; 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; _isFaceDetected = true;
_isDistanceOk = _faceRatio >= 0.20 && _faceRatio <= 0.70; _isDistanceOk = _faceRatio >= 0.25 && _faceRatio <= 0.75;
} else { } else {
_isFaceDetected = false; _isFaceDetected = false;
_isDistanceOk = false; _isDistanceOk = false;
_isPoseValid = false;
_faceRatio = 0.0; _faceRatio = 0.0;
} }
@ -248,6 +270,8 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
_isFaceDetected = false; _isFaceDetected = false;
_isDistanceOk = false; _isDistanceOk = false;
_isBrightnessOk = false; _isBrightnessOk = false;
_isLightingUniform = false;
_isPoseValid = false;
}); });
_startQualityStream(); _startQualityStream();
@ -340,9 +364,9 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
_isVerifying _isVerifying
? "Memverifikasi wajah..." ? "Memverifikasi wajah..."
: _showResult : _showResult
? (_statusText == 'APPROVED' ? (_statusText == 'MATCH'
? "Wajah Terverifikasi ✓" ? "Wajah Terverifikasi ✓"
: _statusText == 'PENDING' : _statusText == 'MISMATCH'
? "Verifikasi Meragukan ⚠️" ? "Verifikasi Meragukan ⚠️"
: "Wajah Tidak Cocok ✗") : "Wajah Tidak Cocok ✗")
: _qualityMessage, : _qualityMessage,
@ -363,15 +387,16 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
// Quality chips // Quality chips
if (!_showResult && !_isVerifying) ...[ if (!_showResult && !_isVerifying) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
Row( Wrap(
mainAxisAlignment: MainAxisAlignment.center, spacing: 6,
runSpacing: 6,
alignment: WrapAlignment.center,
children: [ children: [
_buildChip(Icons.face, 'Wajah', _isFaceDetected), _buildChip(Icons.face, 'Wajah', _isFaceDetected),
const SizedBox(width: 6),
_buildChip(Icons.straighten, 'Jarak', _isDistanceOk), _buildChip(Icons.straighten, 'Jarak', _isDistanceOk),
const SizedBox(width: 6), _buildChip(Icons.wb_sunny_outlined, 'Cahaya', _isBrightnessOk),
_buildChip( _buildChip(Icons.blur_on, 'Bayangan', _isLightingUniform),
Icons.wb_sunny_outlined, 'Cahaya', _isBrightnessOk), _buildChip(Icons.face, 'Hadap Depan', _isPoseValid),
], ],
), ),
], ],
@ -417,9 +442,9 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon( child: Icon(
_statusText == 'APPROVED' (_statusText == 'APPROVED' || _statusText == 'MATCH')
? Icons.check_circle ? Icons.check_circle
: _statusText == 'PENDING' : (_statusText == 'PENDING' || _statusText == 'MISMATCH')
? Icons.warning_amber_rounded ? Icons.warning_amber_rounded
: Icons.cancel, : Icons.cancel,
color: _resultColor, color: _resultColor,
@ -428,9 +453,9 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
_statusText == 'APPROVED' (_statusText == 'APPROVED' || _statusText == 'MATCH')
? "Verifikasi Berhasil" ? "Verifikasi Berhasil"
: _statusText == 'PENDING' : (_statusText == 'PENDING' || _statusText == 'MISMATCH')
? "Verifikasi Diragukan" ? "Verifikasi Diragukan"
: "Verifikasi Gagal", : "Verifikasi Gagal",
style: AppTheme.heading3.copyWith( style: AppTheme.heading3.copyWith(
@ -617,8 +642,8 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: CustomButton( child: CustomButton(
text: "Mulai Rekam & Verifikasi", text: "Mulai Pindai & Verifikasi",
icon: Icons.videocam_rounded, icon: Icons.face_retouching_natural,
onPressed: _allChecksValid && _isCameraInitialized onPressed: _allChecksValid && _isCameraInitialized
? _recordAndVerify ? _recordAndVerify
: null, : null,
@ -635,11 +660,11 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 10), horizontal: 20, vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red.withOpacity(0.8), color: AppTheme.primaryBlue.withOpacity(0.8),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Text( child: Text(
'Merekam... $_recordingSecondsLeft detik', 'Memindai... $_recordingSecondsLeft detik',
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold), fontWeight: FontWeight.bold),

View File

@ -6,6 +6,14 @@ import '../../widgets/organisms/change_password_dialog.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../providers/face_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:device_info_plus/device_info_plus.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -71,7 +79,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
void _handleLogout(BuildContext context) { void _handleLogout(BuildContext context) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (dialogContext) => AlertDialog(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusMd), borderRadius: BorderRadius.circular(AppTheme.radiusMd),
), ),
@ -79,17 +87,33 @@ class _ProfileScreenState extends State<ProfileScreen> {
content: const Text("Apakah Anda yakin ingin keluar dari aplikasi?"), content: const Text("Apakah Anda yakin ingin keluar dari aplikasi?"),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(dialogContext),
child: Text( child: Text(
"Batal", "Batal",
style: AppTheme.labelLarge.copyWith(color: AppTheme.textSecondary), style: AppTheme.labelLarge.copyWith(color: AppTheme.textSecondary),
), ),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () async {
Navigator.pop(context); // Tutup pop-up menggunakan dialogContext
context.read<AuthProvider>().logout(); Navigator.pop(dialogContext);
Navigator.pushReplacementNamed(context, '/login');
await context.read<AuthProvider>().logout();
// Gunakan context asli dari ProfileScreen untuk bernavigasi
if (context.mounted) {
context.read<HomeProvider>().reset();
context.read<PengajuanProvider>().reset();
context.read<AttendanceProvider>().reset();
context.read<PoinProvider>().reset();
context.read<NotificationProvider>().reset();
context.read<SignatureProvider>().reset();
context.read<FaceProvider>().reset();
context.read<SuratIzinProvider>().reset();
context.read<CalendarProvider>().reset();
Navigator.pushNamedAndRemoveUntil(context, '/login', (route) => false);
}
}, },
child: Text( child: Text(
"Keluar", "Keluar",
@ -324,7 +348,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
_buildActionItem( _buildActionItem(
icon: Icons.face, icon: Icons.face,
label: "Registrasi Wajah", label: "Registrasi Wajah",
onTap: () => Navigator.pushNamed(context, '/onboarding/face-enrollment'), onTap: null,
trailingText: (() { trailingText: (() {
final status = context.watch<FaceProvider>().faceStatus2['status']; final status = context.watch<FaceProvider>().faceStatus2['status'];
if (status == 'verified') return 'Terverifikasi'; if (status == 'verified') return 'Terverifikasi';
@ -340,12 +364,6 @@ class _ProfileScreenState extends State<ProfileScreen> {
return AppTheme.textSecondary; return AppTheme.textSecondary;
})(), })(),
), ),
if (context.watch<FaceProvider>().faceStatus2['status'] == 'verified')
_buildActionItem(
icon: Icons.face_retouching_natural,
label: "Test Pengenalan Wajah",
onTap: () => Navigator.pushNamed(context, '/profile/face-test'),
),
_buildActionItem( _buildActionItem(
icon: Icons.lock_outline, icon: Icons.lock_outline,
label: "Ubah Kata Sandi", label: "Ubah Kata Sandi",
@ -492,7 +510,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
Widget _buildActionItem({ Widget _buildActionItem({
required IconData icon, required IconData icon,
required String label, required String label,
required VoidCallback onTap, VoidCallback? onTap,
bool isDestructive = false, bool isDestructive = false,
String? trailingText, String? trailingText,
Color? trailingColor, Color? trailingColor,
@ -523,6 +541,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
color: trailingColor ?? AppTheme.textSecondary, color: trailingColor ?? AppTheme.textSecondary,
), ),
), ),
if (onTap != null) ...[
if (trailingText != null) const SizedBox(width: 8), if (trailingText != null) const SizedBox(width: 8),
Icon( Icon(
Icons.chevron_right, Icons.chevron_right,
@ -530,6 +549,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
size: 20, size: 20,
), ),
], ],
],
), ),
); );
} }

View File

@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
import '../../core/cache_manager.dart'; import '../../core/cache_manager.dart';
class SplashScreen extends StatefulWidget { class SplashScreen extends StatefulWidget {
const SplashScreen({super.key}); final bool launchedFromNotification;
const SplashScreen({super.key, this.launchedFromNotification = false});
@override @override
State<SplashScreen> createState() => _SplashScreenState(); State<SplashScreen> createState() => _SplashScreenState();
@ -43,6 +45,16 @@ class _SplashScreenState extends State<SplashScreen> with SingleTickerProviderSt
final token = CacheManager.authBox.get('auth_token'); final token = CacheManager.authBox.get('auth_token');
if (token != null && token.toString().isNotEmpty) { if (token != null && token.toString().isNotEmpty) {
Navigator.pushReplacementNamed(context, '/onboarding/check'); 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 { } else {
Navigator.pushReplacementNamed(context, '/login'); Navigator.pushReplacementNamed(context, '/login');
} }
@ -68,7 +80,7 @@ class _SplashScreenState extends State<SplashScreen> with SingleTickerProviderSt
children: [ children: [
Image.asset( Image.asset(
'assets/images/logo.png', 'assets/images/logo.png',
height: 120, // slightly larger since it's not in a box height: 120,
width: 250, width: 250,
fit: BoxFit.contain, fit: BoxFit.contain,
), ),

View File

@ -13,6 +13,8 @@ class ApiClient {
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Bypass-Tunnel-Reminder': 'true',
'ngrok-skip-browser-warning': 'true',
}, },
)); ));
@ -38,13 +40,19 @@ class ApiClient {
requestOptions: response.requestOptions, requestOptions: response.requestOptions,
response: response, response: response,
type: DioExceptionType.badResponse, type: DioExceptionType.badResponse,
error: 'Sesi habis, silakan login ulang.', error: 'Sesi habis atau server mengembalikan HTML.',
), ),
); );
} }
return handler.next(response); return handler.next(response);
}, },
onError: (DioException e, handler) { 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); return handler.next(e);
}, },
)); ));

View File

@ -5,25 +5,33 @@ import 'package:provider/provider.dart';
import '../core/theme.dart'; import '../core/theme.dart';
import '../providers/notification_provider.dart'; import '../providers/notification_provider.dart';
import '../repositories/notifikasi_repository.dart'; import '../repositories/notifikasi_repository.dart';
import '../providers/pengajuan_provider.dart';
import '../providers/poin_provider.dart';
import '../providers/attendance_provider.dart';
import '../main.dart'; import '../main.dart';
@pragma('vm:entry-point') @pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async { Future<void> 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; if (message.notification != null) return;
// Hanya tampilkan manual untuk data-only messages
final title = message.data['title'] ?? ''; final title = message.data['title'] ?? '';
final body = message.data['body'] ?? ''; final body = message.data['body'] ?? '';
if (title.isEmpty) return; if (title.isEmpty) return;
final plugin = FlutterLocalNotificationsPlugin(); final plugin = FlutterLocalNotificationsPlugin();
const channel = AndroidNotificationChannel(
'hris_channel',
'HRIS Notifications',
importance: Importance.max,
);
final androidPlugin = plugin.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
await androidPlugin?.createNotificationChannel(channel);
await plugin.initialize( await plugin.initialize(
settings: const InitializationSettings( settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'), android: AndroidInitializationSettings('ic_launcher'),
), ),
); );
@ -37,9 +45,11 @@ Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
'HRIS Notifications', 'HRIS Notifications',
importance: Importance.max, importance: Importance.max,
priority: Priority.high, 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, sound: true,
); );
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); const androidSettings = AndroidInitializationSettings('ic_launcher');
const initSettings = InitializationSettings(android: androidSettings); const initSettings = InitializationSettings(android: androidSettings);
await _localNotifications.initialize( await _localNotifications.initialize(
settings: initSettings, settings: initSettings,
@ -98,7 +108,29 @@ class FcmService {
final currentContext = navigatorKey.currentContext; final currentContext = navigatorKey.currentContext;
if (currentContext != null && currentContext.mounted) { if (currentContext != null && currentContext.mounted) {
_showBanner(currentContext, msg); _showBanner(currentContext, msg);
// --- AUTO-REFRESH REALTIME LOGIC ---
// 1. Selalu update badge dan list notifikasi
currentContext.read<NotificationProvider>().fetchUnreadCount(); currentContext.read<NotificationProvider>().fetchUnreadCount();
currentContext.read<NotificationProvider>().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<PengajuanProvider>().fetchPengajuan();
}
// 3. Jika notif ttg Poin, refresh saldo Poin & Riwayat Poin
if (tipe.contains('poin')) {
currentContext.read<PoinProvider>().loadExpiringPoints(force: true);
currentContext.read<PoinProvider>().loadPointHistory();
}
// 4. Jika notif ttg Presensi, refresh history presensi
if (tipe.contains('presensi') || tipe.contains('absen')) {
currentContext.read<AttendanceProvider>().fetchHistory(force: true);
}
} }
_showSystemNotification(msg); _showSystemNotification(msg);
}); });
@ -210,7 +242,7 @@ class FcmService {
'HRIS Notifications', 'HRIS Notifications',
importance: Importance.max, importance: Importance.max,
priority: Priority.high, priority: Priority.high,
icon: '@mipmap/ic_launcher', icon: 'ic_launcher',
); );
const details = NotificationDetails(android: androidDetails); const details = NotificationDetails(android: androidDetails);
await _localNotifications.show( await _localNotifications.show(
@ -218,7 +250,8 @@ class FcmService {
title: title, title: title,
body: body, body: body,
notificationDetails: details, notificationDetails: details,
payload: 'notification', // MB-07: Payload konsisten agar onDidReceiveNotificationResponse bisa routing ke /notification
payload: 'notification:${message.data['tipe'] ?? ''}',
); );
} }
} }

View File

@ -51,7 +51,6 @@ class ReminderService {
final targetDate = now.add(Duration(days: i)); final targetDate = now.add(Duration(days: i));
final isToday = i == 0; final isToday = i == 0;
if (targetDate.weekday == 6 || targetDate.weekday == 7) continue;
final reminderMasuk = tz.TZDateTime( final reminderMasuk = tz.TZDateTime(
jakarta, jakarta,
@ -126,9 +125,11 @@ class ReminderService {
'attendance_reminder', 'attendance_reminder',
'Pengingat Absen', 'Pengingat Absen',
channelDescription: 'Pengingat absen masuk dan pulang otomatis', channelDescription: 'Pengingat absen masuk dan pulang otomatis',
importance: Importance.high, importance: Importance.max,
priority: Priority.high, priority: Priority.max,
icon: '@mipmap/ic_launcher', icon: '@mipmap/ic_launcher',
category: AndroidNotificationCategory.alarm,
fullScreenIntent: true,
styleInformation: BigTextStyleInformation(''), styleInformation: BigTextStyleInformation(''),
); );
const details = NotificationDetails(android: androidDetails); const details = NotificationDetails(android: androidDetails);
@ -139,7 +140,7 @@ class ReminderService {
body: body, body: body,
scheduledDate: scheduledDate, scheduledDate: scheduledDate,
notificationDetails: details, notificationDetails: details,
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
); );
} }

View File

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart';
import '../core/cache_manager.dart'; import '../core/cache_manager.dart';
import '../core/constants/api_url.dart'; import '../core/constants/api_url.dart';
@ -14,6 +15,7 @@ class WebSocketService extends ChangeNotifier {
Timer? _reconnectTimer; Timer? _reconnectTimer;
bool _isConnected = false; bool _isConnected = false;
bool _disposed = false; bool _disposed = false;
String? _socketId;
final _eventController = StreamController<Map<String, dynamic>>.broadcast(); final _eventController = StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get eventStream => _eventController.stream; Stream<Map<String, dynamic>> get eventStream => _eventController.stream;
@ -46,18 +48,18 @@ class WebSocketService extends ChangeNotifier {
onDone: () { onDone: () {
debugPrint('[WS] Koneksi terputus'); debugPrint('[WS] Koneksi terputus');
_isConnected = false; _isConnected = false;
_socketId = null;
notifyListeners(); notifyListeners();
_scheduleReconnect(); _scheduleReconnect();
}, },
onError: (error) { onError: (error) {
debugPrint('[WS] Error: $error'); debugPrint('[WS] Error: $error');
_isConnected = false; _isConnected = false;
_socketId = null;
notifyListeners(); notifyListeners();
_scheduleReconnect(); _scheduleReconnect();
}, },
); );
_subscribeToChannels(token);
} catch (e) { } catch (e) {
debugPrint('[WS] Gagal connect: $e'); debugPrint('[WS] Gagal connect: $e');
_isConnected = false; _isConnected = false;
@ -70,10 +72,21 @@ class WebSocketService extends ChangeNotifier {
final uri = Uri.parse(apiBaseUrl); final uri = Uri.parse(apiBaseUrl);
final scheme = uri.scheme == 'https' ? 'wss' : 'ws'; 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')) { if (uri.host.contains('trycloudflare.com')) {
return '$scheme://${uri.host}'; 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'; return '$scheme://${uri.host}:8080';
} catch (e) { } catch (e) {
debugPrint('[WS] URL invalid: $e'); debugPrint('[WS] URL invalid: $e');
@ -81,24 +94,81 @@ class WebSocketService extends ChangeNotifier {
} }
} }
void _subscribeToChannels(String token) { Future<void> _subscribeToChannels() async {
final userId = CacheManager.authBox.get('user_id'); final userId = CacheManager.authBox.get('user_id');
if (userId == null) return; if (userId == null) return;
_sendSubscribe('private-user.$userId', token); await _sendSubscribe('private-user.$userId');
_sendSubscribe('pengumuman', null); _sendSubscribePublic('pengumuman');
}
Future<void> _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;
} }
void _sendSubscribe(String channel, String? token) {
final data = { final data = {
'event': 'pusher:subscribe', 'event': 'pusher:subscribe',
'data': { 'data': {
'channel': channel, 'channel': channel,
if (token != null) 'auth': token, 'auth': authSignature,
}, },
}; };
_channel?.sink.add(jsonEncode(data)); _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<String?> _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) { void _handleMessage(dynamic rawMessage, String token) {
@ -107,8 +177,10 @@ class WebSocketService extends ChangeNotifier {
final event = message['event'] as String?; final event = message['event'] as String?;
if (event == 'pusher:connection_established') { if (event == 'pusher:connection_established') {
debugPrint('[WS] Connection established'); final connData = jsonDecode(message['data'] as String);
_subscribeToChannels(token); _socketId = connData['socket_id'] as String?;
debugPrint('[WS] Connection established, socketId=$_socketId');
_subscribeToChannels();
return; return;
} }
@ -117,6 +189,11 @@ class WebSocketService extends ChangeNotifier {
return; return;
} }
if (event == 'pusher:error') {
debugPrint('[WS] Pusher error: ${message['data']}');
return;
}
if (event != null && !event.startsWith('pusher')) { if (event != null && !event.startsWith('pusher')) {
final dataRaw = message['data']; final dataRaw = message['data'];
final data = dataRaw is String ? jsonDecode(dataRaw) : dataRaw; final data = dataRaw is String ? jsonDecode(dataRaw) : dataRaw;
@ -148,6 +225,7 @@ class WebSocketService extends ChangeNotifier {
_channel?.sink.close(); _channel?.sink.close();
_channel = null; _channel = null;
_isConnected = false; _isConnected = false;
_socketId = null;
notifyListeners(); notifyListeners();
debugPrint('[WS] Disconnected'); debugPrint('[WS] Disconnected');
} }

View File

@ -4,20 +4,24 @@ import '../../core/theme.dart';
class AttendanceTimeCard extends StatelessWidget { class AttendanceTimeCard extends StatelessWidget {
final String jamMasuk; final String jamMasuk;
final String jamPulang; final String jamPulang;
final String totalJam;
const AttendanceTimeCard({ const AttendanceTimeCard({
Key? key, Key? key,
required this.jamMasuk, required this.jamMasuk,
required this.jamPulang, required this.jamPulang,
required this.totalJam,
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Row(
children: [ children: [
_buildTimeRow("Datang", jamMasuk, AppTheme.statusGreen), Expanded(child: _buildTimeRow("Total Jam", totalJam, AppTheme.primaryDark)),
const SizedBox(height: 8), const SizedBox(width: 8),
_buildTimeRow("Pulang", jamPulang, AppTheme.statusRed), Expanded(child: _buildTimeRow("Datang", jamMasuk, AppTheme.statusGreen)),
const SizedBox(width: 8),
Expanded(child: _buildTimeRow("Pulang", jamPulang, AppTheme.statusRed)),
], ],
); );
} }

View File

@ -19,22 +19,9 @@ class PengumumanCard extends StatelessWidget {
width: 300, width: 300,
margin: const EdgeInsets.only(right: AppTheme.spacingMd), margin: const EdgeInsets.only(right: AppTheme.spacingMd),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient( color: Colors.white,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF1E293B),
Color(0xFF334155),
],
),
borderRadius: BorderRadius.circular(AppTheme.radiusXl), borderRadius: BorderRadius.circular(AppTheme.radiusXl),
boxShadow: [ boxShadow: AppTheme.shadowMd,
BoxShadow(
color: const Color(0xFF1E293B).withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
), ),
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
@ -59,16 +46,16 @@ class PengumumanCard extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.primaryOrange.withValues(alpha: 0.2), color: AppTheme.primaryOrange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(12),
), ),
child: const Icon( child: const Icon(
Icons.campaign_rounded, Icons.campaign_rounded,
color: AppTheme.primaryOrange, color: AppTheme.primaryOrange,
size: 18, size: 20,
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -76,7 +63,8 @@ class PengumumanCard extends StatelessWidget {
Text( Text(
pengumuman.title, pengumuman.title,
style: AppTheme.labelLarge.copyWith( style: AppTheme.labelLarge.copyWith(
color: Colors.white, color: AppTheme.textPrimary,
fontWeight: FontWeight.bold,
fontSize: 14, fontSize: 14,
), ),
maxLines: 1, maxLines: 1,
@ -86,7 +74,7 @@ class PengumumanCard extends StatelessWidget {
Text( Text(
pengumuman.namaPembuat, pengumuman.namaPembuat,
style: AppTheme.bodySmall.copyWith( style: AppTheme.bodySmall.copyWith(
color: Colors.white60, color: AppTheme.textTertiary,
fontSize: 11, fontSize: 11,
), ),
), ),
@ -95,12 +83,12 @@ class PengumumanCard extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 10), const SizedBox(height: 12),
Text( Text(
pengumuman.description, pengumuman.description,
style: AppTheme.bodySmall.copyWith( style: AppTheme.bodySmall.copyWith(
color: Colors.white70, color: AppTheme.textSecondary,
height: 1.4, height: 1.5,
), ),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@ -112,29 +100,30 @@ class PengumumanCard extends StatelessWidget {
Text( Text(
pengumuman.date, pengumuman.date,
style: AppTheme.bodySmall.copyWith( style: AppTheme.bodySmall.copyWith(
color: Colors.white38, color: AppTheme.textTertiary,
fontSize: 10, fontSize: 10,
), ),
), ),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.primaryOrange.withValues(alpha: 0.2), color: AppTheme.primaryBlue.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(AppTheme.radiusFull), borderRadius: BorderRadius.circular(AppTheme.radiusFull),
border: Border.all(color: AppTheme.primaryBlue.withValues(alpha: 0.1)),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
"Baca", "Detail",
style: AppTheme.bodySmall.copyWith( style: AppTheme.bodySmall.copyWith(
color: AppTheme.primaryOrange, color: AppTheme.primaryBlue,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 10, fontSize: 10,
), ),
), ),
const SizedBox(width: 2), const SizedBox(width: 4),
const Icon(Icons.arrow_forward_ios, color: AppTheme.primaryOrange, size: 9), const Icon(Icons.arrow_forward_ios, color: AppTheme.primaryBlue, size: 8),
], ],
), ),
), ),

View File

@ -73,6 +73,7 @@ class _ChangePasswordDialogState extends State<ChangePasswordDialog> {
), ),
child: Form( child: Form(
key: _formKey, key: _formKey,
child: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -125,6 +126,7 @@ class _ChangePasswordDialogState extends State<ChangePasswordDialog> {
), ),
), ),
), ),
),
); );
} }
} }

View File

@ -25,100 +25,85 @@ class HomeHeader extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final unreadCount = context.watch<NotificationProvider>().unreadCount; final unreadCount = context.watch<NotificationProvider>().unreadCount;
return Container( return Padding(
padding: const EdgeInsets.all(AppTheme.spacingLg), padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 4.0),
decoration: BoxDecoration( child: Row(
color: AppTheme.primaryDark, crossAxisAlignment: CrossAxisAlignment.center,
borderRadius: BorderRadius.circular(AppTheme.radiusXl),
boxShadow: AppTheme.glowPrimary,
),
child: Stack(
children: [
Positioned(
right: -20,
top: -20,
child: Container(
width: 100,
height: 100,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppTheme.glassWhite10,
),
),
),
Positioned(
left: 50,
bottom: -30,
child: Container(
width: 80,
height: 80,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppTheme.glassWhite10,
),
),
),
Row(
children: [ children: [
// Avatar Profil tanpa border putih
CustomAvatar( CustomAvatar(
imageUrl: user.foto, imageUrl: user.foto,
name: user.namaLengkap, name: user.namaLengkap,
size: 48, size: 48,
), ),
const SizedBox(width: AppTheme.spacingMd), const SizedBox(width: AppTheme.spacingMd),
// Greeting & Name dengan teks gelap (menyatu dengan background terang)
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
_getGreeting(), _getGreeting(),
style: AppTheme.bodySmall.copyWith(color: Colors.white70), style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
user.namaLengkap.split(' ').first, user.namaLengkap.split(' ').first,
style: AppTheme.heading2.copyWith(color: Colors.white), style: AppTheme.heading2.copyWith(color: AppTheme.textPrimary),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
], ],
), ),
), ),
// Notification Button minimalis
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.glassWhite10, color: Colors.white,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: AppTheme.glassWhite20, width: 1), boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
), ),
child: Stack( child: Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
IconButton( Material(
icon: const Icon(Icons.notifications_none_rounded), color: Colors.transparent,
color: Colors.white, child: InkWell(
onPressed: () { customBorder: const CircleBorder(),
Navigator.pushNamed(context, '/notification'); onTap: () => Navigator.pushNamed(context, '/notification'),
}, child: const Padding(
padding: EdgeInsets.all(12.0),
child: Icon(Icons.notifications_none_rounded, color: AppTheme.primaryDark, size: 24),
),
),
), ),
if (unreadCount > 0) if (unreadCount > 0)
Positioned( Positioned(
right: 4, right: 8,
top: 4, top: 8,
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFef4444), color: const Color(0xFFef4444),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.primaryDark, width: 1.5),
), ),
constraints: const BoxConstraints(minWidth: 16, minHeight: 16), constraints: const BoxConstraints(minWidth: 16, minHeight: 16),
child: Text( child: Text(
unreadCount > 9 ? '9+' : '$unreadCount', unreadCount > 9 ? '9+' : '$unreadCount',
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 9, fontSize: 10,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
height: 1.1,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@ -129,8 +114,6 @@ class HomeHeader extends StatelessWidget {
), ),
], ],
), ),
],
),
); );
} }
} }