last
This commit is contained in:
parent
a4f964bb20
commit
8b5dee7b65
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 |
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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':
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,4 +47,11 @@ class CalendarProvider extends ChangeNotifier {
|
||||||
_schedules = [];
|
_schedules = [];
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
_schedules = [];
|
||||||
|
_isLoading = false;
|
||||||
|
_errorMessage = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,4 +68,12 @@ class NotificationProvider extends ChangeNotifier {
|
||||||
unreadCount = 0;
|
unreadCount = 0;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
notifications = [];
|
||||||
|
unreadCount = 0;
|
||||||
|
_isLoading = false;
|
||||||
|
_errorMessage = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,4 +73,11 @@ class SignatureProvider extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
currentSignature = null;
|
||||||
|
isLoading = false;
|
||||||
|
errorMessage = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,4 +65,13 @@ class SuratIzinProvider extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
listSurat = [];
|
||||||
|
selectedSurat = null;
|
||||||
|
isLoading = false;
|
||||||
|
isLoadingDetail = false;
|
||||||
|
errorMessage = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (_) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
|
||||||
|
|
@ -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'] ?? ''}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue