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

3
.gitignore vendored
View File

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

588
DESIGN.md Normal file
View File

@ -0,0 +1,588 @@
# DESIGN.md — HRIS MP Mobile App (Flutter)
> Employee Self-Service Mobile Application for Menjadi Pengaruh Group HRIS System
## ⚠️ CRITICAL: This is a LIGHT THEME mobile application. All backgrounds are white (#FFFFFF) or very light gray (#F8FAFC). Do NOT use dark backgrounds. The dark navy (#0F172A) is ONLY used for small accents: buttons, active tab indicators, and text emphasis. The orange (#E65100) is ONLY used for the active bottom navigation icon.
---
## Brand Identity
- **App Name**: HRIS MP
- **Company**: Menjadi Pengaruh Group
- **Platform**: Flutter (Android & iOS)
- **Design Mood**: Clean, modern, minimal, professional. Premium SaaS mobile app feel.
- **Inspiration**: Like banking apps or HR apps — clean cards, rounded corners, smooth animations.
- **Frame Size**: 390x844 (iPhone 14 / standard Android)
---
## Typography
- **Font**: Inter (via Google Fonts package)
- **Heading 1**: 28px, Extra Bold (w800), letter-spacing -1.0, color #0F172A
- **Heading 2**: 22px, Bold (w700), letter-spacing -0.5, color #0F172A
- **Heading 3**: 16px, Bold (w700), letter-spacing -0.3, color #0F172A
- **Body Large**: 15px, Medium (w500), color #0F172A
- **Body Medium**: 13px, Medium (w500), color #0F172A
- **Body Small**: 11px, Medium (w500), color #475569 (secondary gray)
- **Label Large**: 13px, Semibold (w600), color #0F172A
- **Label Medium**: 11px, Medium (w500), color #0F172A
---
## Color Palette
### IMPORTANT: Light Theme
**Scaffold/Page background**: #FFFFFF (pure white)
**Content area background**: #F8FAFC (very light gray)
**Card background**: #FFFFFF (white)
**Input field background**: #F1F5F9 (light gray)
### Primary Colors (used sparingly as accents)
| Name | Hex | Usage |
|------|-----|-------|
| Primary Dark | #0F172A | Buttons, active tabs, text emphasis, avatar bg |
| Primary Orange | #E65100 | Active bottom nav icon color ONLY |
| Primary Blue | #1E3A8A | Action buttons, links, "Lihat Detail" text |
| Secondary Blue | #3B82F6 | Info badges, secondary highlights |
### Text Colors
| Name | Hex | Usage |
|------|-----|-------|
| Text Primary | #0F172A | Headings, main text, important labels |
| Text Secondary | #475569 | Subtitle, description text |
| Text Tertiary | #94A3B8 | Placeholder, hint text, disabled, captions |
### Status/Semantic Colors
| Name | Hex | Usage |
|------|-----|-------|
| Status Green | #10B981 | Hadir, Tepat Waktu, Approved, Aktif, sisa cuti |
| Status Yellow | #F59E0B | Menunggu Validasi, Pending |
| Status Red | #EF4444 | Rejected, Alpha, Terlambat, Ditolak |
| Status Orange | #FF5722 | Izin, Sakit, alternative warning |
### Badge Colors (pastel background + colored text)
| Type | Background | Text |
|------|-----------|------|
| Cuti | #FEE2E2 | #DC2626 |
| Sakit | #FFEDD5 | #EA580C |
| Izin | #DBEAFE | #2563EB |
| Lembur | #F3E8FF | #9333EA |
### Glass/Overlay Colors
| Name | Value | Usage |
|------|-------|-------|
| Glass White 10% | rgba(255,255,255,0.10) | Frosted glass elements |
| Glass White 20% | rgba(255,255,255,0.20) | Header overlays |
| Glass White 50% | rgba(255,255,255,0.50) | Semi-transparent cards |
| Glass White 70% | rgba(255,255,255,0.70) | Bottom nav background |
| Glass Dark 10% | rgba(0,0,0,0.10) | Bottom sheet barrier |
| Glass Dark 40% | rgba(0,0,0,0.40) | Modal overlay |
---
## Spacing System
| Token | Value | Usage |
|-------|-------|-------|
| XS | 4px | Tiny gaps |
| SM | 8px | Between related elements |
| MD | 14px | Standard section gap, card padding |
| LG | 20px | Between sections |
| XL | 28px | Major section dividers |
| XXL | 40px | Top/bottom page padding |
---
## Border Radius
| Token | Value | Usage |
|-------|-------|-------|
| SM | 12px | Input fields, small buttons, tab pills |
| MD | 16px | Cards, dialogs, bottom sheets top corners |
| LG | 20px | Profile cards, attendance cards |
| XL | 24px | Bottom navigation bar, large cards |
| Full | 9999px | Badges, status pills, circular buttons, avatar rings |
---
## Shadows
### Shadow Small (cards, list items)
- Color: #0F172A at 4% opacity
- Blur: 16px, Offset: 0,4
### Shadow Medium (elevated cards, modals)
- Layer 1: #0F172A at 6% opacity, blur 24px, offset 0,12
- Layer 2: #0F172A at 3% opacity, blur 8px, offset 0,4
### Shadow Large (bottom nav, floating elements)
- Color: #0F172A at 8% opacity
- Blur: 40px, Offset: 0,20
### Glow Effects (special buttons)
- Primary Glow: #0F172A at 30%, blur 20, offset 0,8
- Blue Glow: #1E3A8A at 40%, blur 24, offset 0,8
- Green Glow: #10B981 at 40%, blur 24, offset 0,8
- Red Glow: #EF4444 at 40%, blur 24, offset 0,8
- Orange Glow: #FF5722 at 40%, blur 24, offset 0,8
---
## Navigation Structure
### Bottom Navigation Bar
- **Position**: Floating at bottom with margin (14px sides, 8px bottom from safe area)
- **Background**: White at 85% opacity with backdrop blur (10px)
- **Border Radius**: 24px (fully rounded pill shape)
- **Shadow**: Large shadow
- **Items** (left to right):
1. **Beranda** — icon: grid_view_rounded (4-square grid), 24px
2. **Presensi** — icon: fingerprint, 24px
3. **Pengajuan** — icon: insert_page_break (document), 24px
4. **Profil** — icon: person_outline / person_rounded, 24px
- **Active icon color**: #E65100 (orange)
- **Active icon size**: 26px (slightly larger)
- **Inactive icon color**: #94A3B8 (light gray)
- **Show labels**: Only for selected/active item
- **Selected label style**: 12px, Bold (w700)
### Screen Routing (all screens in the app)
- `/login` → Login Screen
- `/onboarding/check` → Onboarding Check (face enrollment redirect)
- `/onboarding/face` → Face Enrollment Screen
- **Main Screen** (with bottom nav):
- Tab 0: Home Screen (Beranda)
- Tab 1: Presensi Screen (Attendance history)
- Tab 2: Pengajuan Screen (Leave/overtime requests)
- Tab 3: Profile Screen (Profil)
- `/presensi/map` → Presensi Map + Camera (attendance with location)
- `/presensi/camera` → Camera Capture (face photo)
- `/pengajuan/cuti` → Cuti Form
- `/pengajuan/sakit` → Sakit Form
- `/pengajuan/izin` → Izin Form
- `/pengajuan/lembur` → Lembur Form
- `/schedule` → Jadwal/Schedule Screen
- `/notification` → Notification List
- `/notification/detail` → Notification Detail
- `/poin/usage` → Poin Usage (redeem points)
- `/poin/history` → Poin History
- `/profile/edit` → Edit Profile
- `/profile/signature` → Digital Signature
- `/profile/face-test` → Face Test/Re-enrollment
- `/documents/surat-izin` → Surat Izin List
- `/home/pengumuman/detail` → Pengumuman Detail
---
## Components
### Custom Button
- **Primary**: bg #0F172A (dark navy), text white, rounded 12px, full width, height ~48px
- **Outline**: transparent bg, border 1px #0F172A, text #0F172A
- **Danger**: bg #EF4444, text white
- **Loading state**: Shows CircularProgressIndicator instead of text
- Hover/press: subtle scale animation
### Custom Text Field
- **Background**: #F1F5F9 (light gray)
- **Border**: none by default, on focus shows #0F172A border
- **Radius**: 12px
- **Label**: above field, 11px semibold, #0F172A
- **Hint text**: #94A3B8
- **Height**: ~48px
- **Password**: eye icon toggle for visibility
- **Error state**: red border + red error text below
### Custom Avatar
- Circular image with fallback to initials
- Default fallback: navy gradient circle with white letter
- Border: white ring (3px)
- Sizes: 40px (list), 80px (profile), variable
### Badge Widget
- Rounded full pill shape
- Pastel colored background with darker text
- Padding: 8px horizontal, 3px vertical
- Text: 10-11px, semibold
### Menu Card (Pengajuan section)
- Rounded 16px white card with shadow
- Colored icon in rounded square
- Title (bold) + subtitle (gray) text
- Tap animation (bounce/scale)
### Stat Card
- White card, rounded 20px, shadow
- Icon + label + value layout
- Used for attendance summary stats
### Bottom Sheet (Detail views)
- Background: transparent (so rounded corners show)
- Sheet: white, top corners rounded 24px
- Drag handle: 40px wide, 4px tall, centered, gray pill
- Barrier: black at 10% opacity (very subtle)
### Attendance Time Card
- Horizontal card showing Jam Masuk + Jam Keluar + Total Jam
- Green indicators for check-in, red for out
- White card, rounded corners
---
## All Screens (Detailed)
### 1. SPLASH SCREEN
- Full white background
- Company logo centered
- Loading indicator below logo
### 2. LOGIN SCREEN
- White background (#FFFFFF)
- Centered vertically, padded 20px horizontal
- Content top to bottom:
1. Company logo image (height 80px, width 200px, centered)
2. Gap 40px
3. Title "Masuk ke Akun" — 16px bold, left-aligned
4. Gap 20px
5. Email text field (label "Email", hint "Masukkan Email Anda")
6. Gap 14px
7. Password text field (label "Password", hint "Masukkan Password", eye icon toggle)
8. Gap 28px
9. "Masuk" button — full width, primary dark navy, rounded 12px
- No bottom navigation bar on this screen
### 3. ONBOARDING CHECK SCREEN
- Checks if user needs face enrollment
- If not registered → redirects to Face Enrollment
- If ok → redirects to Main Screen
### 4. FACE ENROLLMENT SCREEN
- Full screen camera for face registration
- Captures 3 poses: front, left, right
- Oval face guide overlay in center (dashed border)
- Step indicator at top: "Pose 1 of 3"
- Instruction text: "Hadap Depan" / "Hadap Kiri" / "Hadap Kanan"
- Capture button at bottom (large circle)
- Preview of captured photos at bottom
- "Kirim untuk Verifikasi" button after all 3 captured
### 5. HOME SCREEN (Tab: Beranda)
- Background: #F8FAFC
- AppBar: none (custom header)
- Pull-to-refresh enabled
- Content (scrollable, top to bottom):
1. **Home Header**: User greeting card
- "Selamat Pagi/Siang/Sore," + user name (bold white text)
- Job title + division below (white, smaller)
- Right side: user avatar (circular photo or initial)
- Card: dark navy gradient background (#0F172A), rounded 20px
- Notification bell icon at top-right (with red badge count)
2. **Poin Expiry Card** (conditional — if points expiring soon):
- Orange/amber warning card
- "X poin akan kadaluarsa pada [date]"
- "Gunakan Sekarang" button
3. **Poin Lembur Card**:
- White card, rounded 24px, shadow
- Shows current overtime points balance
- "Gunakan" button
4. **Presensi Section**:
- White card showing today's attendance status
- Jam Masuk + Jam Keluar time display
- Shift info pill badge
- Large "Absen Masuk" or "Absen Keluar" button
- Button color: navy (masuk) or green (pulang)
- If already done both: shows completed state with green checkmark
5. **Sisa Cuti Card**:
- White card, rounded 24px
- Beach/umbrella icon in light gray-blue rounded square
- "Sisa Cuti Tahunan" label
- Number in green bold (e.g. "12 Hari")
6. **Pengumuman Terbaru** section:
- "Pengumuman Terbaru" heading (16px bold)
- Horizontal scrolling card list
- Each card: white, rounded 20px, shadow, width ~280px
- Shows: title (bold), content preview (gray, 2 lines), date, priority badge
- If empty: placeholder card with megaphone icon + "Belum ada pengumuman"
7. Bottom spacer (100px for floating nav bar)
- Bottom Navigation Bar: floating, Beranda active (orange)
### 6. PRESENSI SCREEN (Tab: Presensi)
- Background: #F8FAFC
- AppBar: "Presensi Karyawan" title (left), shift badge + calendar icon button (right)
- Pull-to-refresh
- Content:
1. **Face status banner** (conditional):
- If pending: blue info banner "Data wajah sedang proses verifikasi HRD"
- If rejected: red warning banner "Registrasi wajah ditolak, lakukan ulang"
2. **Presensi Section** (same as Home — today's attendance card with action button)
3. **"History Presensi"** heading
4. **Month/Year Picker Bar**:
- White card, rounded 16px, shadow
- Left: ← arrow circle button
- Center: calendar icon + "Mei 2025" (tappable → opens bottom sheet month picker)
- Right: → arrow circle button (disabled if current month)
5. **History List** (vertical scrolling):
- Each item: white card, rounded 20px, shadow, margin-bottom 14px
- Content: Date with calendar icon → status badges (pills) → Attendance time card (Jam Masuk/Keluar/Total) → "Lihat Detail" text link (blue)
- Tapping opens bottom sheet detail view
6. **Month Picker Bottom Sheet** (when tapped):
- White, top rounded 24px
- Drag handle pill
- "Pilih Bulan" title
- Year selector: ← [2025] →
- 3x4 grid of month buttons (Jan-Des)
- Active month: navy bg, white text
- Future months: disabled/grayed out
- "Terapkan" button at bottom (full width, navy)
- Bottom Nav: Presensi active (orange fingerprint icon)
### 7. PRESENSI MAP SCREEN (Attendance with Location)
- Full screen map showing user location + office radius
- Top: Back arrow + "Absen Masuk/Pulang" title
- Map: Google Maps with user marker + office circle radius
- Bottom card (white, rounded top 24px):
- Current location address
- Distance from office
- Status: "Dalam Radius" (green) or "Luar Radius" (red)
- "Lanjut Foto Wajah" button (navy, full width)
### 8. CAMERA CAPTURE SCREEN (Face Recognition)
- Full screen camera viewfinder
- Oval face guide overlay (scanner overlay) in center with animated border
- Instruction text overlay: "Posisikan wajah di dalam area"
- Top: back arrow + title
- Bottom: Large capture button (circle)
- After capture: preview + confirm/retake buttons
### 9. PENGAJUAN SCREEN (Tab: Pengajuan)
- Background: #F8FAFC
- AppBar: "Pengajuan" title (centered)
- Pull-to-refresh
- Content:
1. **Quick Action Menu Cards** (top section):
- Full width card: "Cuti / Time Off" — beach icon, bg #FEE2E2, icon #DC2626
- 3-column row below:
- "Sakit" — hospital icon, bg #FFEDD5, icon #EA580C
- "Izin" — event-busy icon, bg #DBEAFE, icon #2563EB
- "Lembur" — timer icon, bg #F3E8FF, icon #9333EA
- Each card: colored bg, matching icon, title + subtitle, rounded 16px
2. **Tab Selector** (pill-shaped segmented control):
- White container, rounded-full, padding 4px
- 3 tabs: "Approved" | "Pending" | "Rejected"
- Active tab: navy bg (#0F172A), white text
- Inactive tab: transparent, gray text
3. **Pengajuan List**:
- Cards for each submission
- Each card shows: type icon + type label, date range, status badge, description
- Tapping opens bottom sheet detail
- Empty state: inbox icon + "Belum ada pengajuan" text
- Bottom Nav: Pengajuan active (orange document icon)
### 10. FORM — Pengajuan Cuti
- AppBar: back arrow + "Pengajuan Cuti" title
- White background
- Form fields:
- Tanggal Mulai (date picker)
- Tanggal Selesai (date picker)
- Durasi (auto-calculated, read-only)
- Alasan (textarea, multi-line)
- "Ajukan Cuti" button (full width, navy)
### 11. FORM — Pengajuan Sakit
- AppBar: back arrow + "Pengajuan Sakit" title
- Form fields:
- Tanggal Mulai (date picker)
- Tanggal Selesai (date picker)
- Keluhan/Diagnosa (textarea)
- Upload Surat Dokter (file/image picker with preview)
- "Ajukan Izin Sakit" button
### 12. FORM — Pengajuan Izin
- AppBar: back arrow + "Pengajuan Izin" title
- Form fields:
- Jenis Izin (dropdown)
- Tanggal (date picker)
- Alasan (textarea)
- Upload Bukti (optional file picker)
- "Ajukan Izin" button
### 13. FORM — Pengajuan Lembur
- AppBar: back arrow + "Pengajuan Lembur" title
- Form fields:
- Tanggal (date picker)
- Jam Mulai (time picker)
- Jam Selesai (time picker)
- Keterangan (textarea)
- "Ajukan Lembur" button
### 14. SCHEDULE SCREEN (Jadwal Kerja)
- AppBar: back arrow + "Jadwal Saya" title
- Month/Year picker (same component as Presensi history)
- Calendar month grid view:
- Day headers: Sen-Min
- Each day cell: date number + shift pill badge (colored)
- Today: highlighted circle
- Tapping day shows shift detail
- Legend: colored dots with shift names
- Bottom: Upcoming schedule list
### 15. NOTIFICATION SCREEN
- AppBar: back arrow + "Notifikasi" title
- "Tandai Semua Dibaca" button at top
- Notification list:
- Each item: colored icon in rounded square + title (bold) + message preview + timestamp
- Unread: light blue tint background + blue dot
- Read: white background
- Tapping goes to detail or relevant screen
- Types: Presensi, Pengajuan Izin, Lembur, Pengumuman, Face Enrollment
### 16. NOTIFICATION DETAIL
- AppBar: back arrow + "Detail Notifikasi" title
- White card:
- Icon + type badge
- Title (heading 3)
- Full message content
- Timestamp
- Action button to navigate to related screen
### 17. POIN USAGE SCREEN (Tukar Poin Lembur)
- AppBar: back arrow + "Gunakan Poin" title
- Current points balance card (large number, green)
- Usage options/form:
- Jenis penggunaan
- Jumlah poin
- Keterangan
- "Gunakan Poin" submit button
- History section below
### 18. POIN HISTORY SCREEN
- AppBar: back arrow + "Riwayat Poin" title
- List of point transactions
- Each item: date, description, +/- points, status badge
### 19. PROFILE SCREEN (Tab: Profil)
- Background: #FFFFFF
- AppBar: "Profil Karyawan" (centered)
- Pull-to-refresh
- Content:
1. **Profile Card** (white, rounded 20px, shadow):
- Avatar (80px, circular photo or initials)
- Name (20px bold, centered)
- "Divisi • Jabatan" (13px gray, centered)
- Status badge: "Karyawan Aktif" green pill with dot
2. **"INFORMASI PRIBADI"** section label (11px uppercase, gray, letter-spaced)
3. **Info Card** (white, rounded 20px, shadow):
- Rows of: icon (24px, navy) + label (11px gray) / value (13px dark)
- Items: Email, No. Telepon, ID Karyawan (with copy button), Divisi, Jabatan, Kantor, Alamat, Bergabung Sejak, Sisa Cuti, Peran (role badges)
4. **"PENGATURAN AKUN"** section label
5. **Actions Card** (white, rounded 20px, shadow):
- ListTile items with icon + label + chevron:
- "Edit Profil" (edit icon)
- "Registrasi Wajah" (face icon) + status text (Terverifikasi green / Menunggu HRD yellow / Ditolak red / Belum Terdaftar gray)
- "Ubah Kata Sandi" (lock icon)
- "Tanda Tangan Digital" (draw icon)
- "Keluar" (logout icon, RED text + RED icon)
6. **Footer**: "Perangkat: Samsung Galaxy S21" + "App Version 1.0.0+1" (tiny gray text, centered)
7. Bottom spacer (80px for nav bar)
- Bottom Nav: Profil active (orange person icon)
### 20. EDIT PROFILE SCREEN
- AppBar: back arrow + "Edit Profil" title
- Form fields:
- Nama Lengkap
- No. Telepon
- Alamat (textarea)
- Photo upload (avatar picker)
- "Simpan Perubahan" button
### 21. SIGNATURE SCREEN (Tanda Tangan Digital)
- AppBar: back arrow + "Tanda Tangan Digital" title
- Canvas drawing area (white card with border)
- Clear button
- "Simpan Tanda Tangan" button
- Preview of existing signature if already saved
### 22. FACE TEST SCREEN (Re-registration)
- Similar to Face Enrollment
- Camera with face overlay
- 3 pose capture
- Submit for verification
### 23. PENGUMUMAN DETAIL SCREEN
- AppBar: back arrow + "Detail Pengumuman"
- White card:
- Priority badge (Tinggi red/Sedang orange/Rendah blue)
- Title (heading 2, bold)
- Date published
- Full content (rendered text, may include images)
- Author info
### 24. SURAT IZIN SCREEN (Document List)
- AppBar: back arrow + "Surat Izin" title
- List of surat izin documents
- Each card: type, date, status badge, tappable for detail
- Bottom sheet detail view on tap
### 25. CHANGE PASSWORD DIALOG
- Modal dialog (AlertDialog)
- Rounded 16px
- Fields: Password Lama, Password Baru, Konfirmasi Password
- "Ubah Password" button (navy) + "Batal" (gray text)
### 26. PRESENSI DETAIL BOTTOM SHEET
- Bottom sheet with rounded top 24px
- Drag handle
- Full attendance detail:
- Date + day name
- Shift info
- Jam Masuk + Jam Keluar (large time display)
- Face verification status (photo thumbnails if available)
- Location info (map thumbnail)
- Status badges
- Validation status
### 27. PENGAJUAN DETAIL BOTTOM SHEET
- Bottom sheet, rounded top 24px
- Submission details:
- Type + status badge
- Date range
- Full description/reason
- Attachment preview (if any)
- Submitted date
- Approval status timeline
---
## Animations & Interactions
- **FadeInUp**: List items animate in with stagger delay (50ms per item), opacity + translateY
- **BouncyTap**: Cards scale down slightly on tap, spring back
- **Pull-to-refresh**: Standard Material indicator
- **Bottom sheet**: Slide up with drag handle
- **Tab switching**: Smooth color transition on segmented control
- **Shimmer loading**: Animated gradient placeholder for all loading states (Home, Profile, Lists, Schedule)
- **Page transitions**: Standard Material page route transitions
- **Badge count**: Animated appearance on notification bell
---
## Loading States
Every screen has a shimmer loading placeholder:
- **Home Shimmer**: Mimics header card + stat cards + list items with gray animated blocks
- **Profile Shimmer**: Avatar circle + text lines + card shapes
- **List Shimmer**: Repeated card-shaped blocks
- **Schedule Shimmer**: Calendar grid placeholder
---
## Empty States
Each list has an empty state with:
- Large gray icon (48-56px, 50% opacity)
- Descriptive title text (13px, medium gray)
- Subtitle text (11px, light gray)
- Optional action button

View File

@ -4,6 +4,10 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.CAMERA"/>
<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
android:label="HRIS MP"
@ -47,6 +51,11 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</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>
<!-- image_cropper: UCrop Activity -->
@ -54,6 +63,17 @@
android:name="com.yalantis.ucrop.UCropActivity"
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.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

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

View File

@ -16,7 +16,7 @@ class ApiUrl {
return _cachedImageBaseUrl!;
}
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 Future<void> initialize() async {

View File

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

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'services/fcm_service.dart';
import 'core/theme.dart';
@ -63,11 +64,30 @@ void main() async {
tz.initializeTimeZones();
await ReminderService().initialize();
runApp(const MyApp());
final localNotifications = FlutterLocalNotificationsPlugin();
const androidSettings = AndroidInitializationSettings('ic_launcher');
const initSettings = InitializationSettings(android: androidSettings);
final launchDetails = await localNotifications.getNotificationAppLaunchDetails();
await localNotifications.initialize(
settings: initSettings,
onDidReceiveNotificationResponse: (details) {
Future.delayed(const Duration(seconds: 2), () {
final nav = navigatorKey.currentState;
if (nav != null) {
nav.pushNamedAndRemoveUntil('/home', (route) => false);
nav.pushNamed('/notification');
}
});
},
);
runApp(MyApp(launchedFromNotification: launchDetails?.didNotificationLaunchApp ?? false));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
final bool launchedFromNotification;
const MyApp({super.key, this.launchedFromNotification = false});
@override
Widget build(BuildContext context) {
@ -100,7 +120,7 @@ class MyApp extends StatelessWidget {
),
initialRoute: '/',
routes: {
'/': (context) => const SplashScreen(),
'/': (context) => SplashScreen(launchedFromNotification: launchedFromNotification),
'/login': (context) => const LoginScreen(),
'/home': (context) => const MainScreen(),
'/presensi': (context) => const PresensiScreen(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -64,4 +64,18 @@ class HomeProvider extends ChangeNotifier {
sudahAbsenPulang: presensiToday!.sudahAbsenPulang,
);
}
void reset() {
user = null;
poinLembur = 0;
presensiToday = null;
pengumumanList = [];
sisaCuti = 0;
izinCount = 0;
alphaCount = 0;
_isLoading = false;
_hasLoaded = false;
_errorMessage = null;
notifyListeners();
}
}

View File

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

View File

@ -11,6 +11,7 @@ class PengajuanProvider extends ChangeNotifier {
List<PengajuanModel> listPengajuan = [];
bool isLoading = false;
String? errorMessage;
int selectedTabIndex = 0;
bool hasSignature = false;
@ -82,8 +83,7 @@ class PengajuanProvider extends ChangeNotifier {
isLoading = true;
notifyListeners();
try {
final dateParts = (data['date'] as String).split('/');
final tanggalLembur = '${dateParts[2]}-${dateParts[1].padLeft(2, '0')}-${dateParts[0].padLeft(2, '0')}';
final tanggalLembur = data['date'] as String;
final result = await _lemburRepository.submitLembur(
tanggalLembur: tanggalLembur,
@ -103,33 +103,73 @@ class PengajuanProvider extends ChangeNotifier {
}
}
String _convertTo24Hour(String time12h) {
if (!time12h.contains('AM') && !time12h.contains('PM')) {
return time12h;
String _convertTo24Hour(String? timeStr) {
if (timeStr == null || timeStr.isEmpty) return "00:00";
// Jika sudah format HH:mm atau HH:mm:ss tanpa AM/PM
if (!timeStr.toUpperCase().contains('AM') && !timeStr.toUpperCase().contains('PM')) {
return timeStr.substring(0, 5); // Ambil HH:mm saja
}
final parts = time12h.split(' ');
final timeParts = parts[0].split(':');
int hour = int.parse(timeParts[0]);
final minute = timeParts[1];
final period = parts[1];
try {
// Bersihkan string dan pastikan ada spasi sebelum AM/PM jika menempel
String cleaned = timeStr.toUpperCase().replaceAll('AM', ' AM').replaceAll('PM', ' PM').replaceAll(RegExp(r'\s+'), ' ').trim();
if (period == 'PM' && hour != 12) hour += 12;
if (period == 'AM' && hour == 12) hour = 0;
final parts = cleaned.split(' ');
if (parts.length < 2) return cleaned.substring(0, 5);
return '${hour.toString().padLeft(2, '0')}:$minute';
final timeParts = parts[0].split(':');
if (timeParts.length < 2) return "00:00";
int hour = int.tryParse(timeParts[0]) ?? 0;
final minute = timeParts[1];
final period = parts[1];
if (period == 'PM' && hour != 12) hour += 12;
if (period == 'AM' && hour == 12) hour = 0;
return '${hour.toString().padLeft(2, '0')}:${minute.padLeft(2, '0')}';
} catch (e) {
print('Error converting time: $e');
return timeStr.length >= 5 ? timeStr.substring(0, 5) : "00:00";
}
}
Future<bool> _submitGeneric(String type, Map<String, dynamic> data) async {
isLoading = true;
errorMessage = null;
notifyListeners();
try {
return await _repository.submitPengajuan(type, data);
} catch(e) {
rethrow;
if (e.toString().contains('DioException')) {
try {
final dioError = e as dynamic;
if (dioError.response?.data != null && dioError.response?.data['message'] != null) {
errorMessage = dioError.response?.data['message'];
} else {
errorMessage = 'Pengajuan gagal: Kesalahan server.';
}
} catch(_) {
errorMessage = 'Pengajuan gagal.';
}
} else {
errorMessage = e.toString().replaceAll('Exception: ', '');
}
print('Error submitting $type: $errorMessage');
return false;
} finally {
isLoading = false;
notifyListeners();
}
}
void reset() {
listPengajuan = [];
isLoading = false;
errorMessage = null;
selectedTabIndex = 0;
hasSignature = false;
notifyListeners();
}
}

View File

@ -43,14 +43,17 @@ class PoinProvider extends ChangeNotifier {
final result = await _repository.getExpiringPoints();
if (result['success']) {
_totalPoin = result['data']['total_poin'] as int?;
_totalPoin = int.tryParse(result['data']['total_poin'].toString());
final expiring = result['data']['expiring_points'];
if (expiring != null) {
_expiringPoints = expiring['poin'] as int?;
_expiringPoints = int.tryParse(expiring['poin'].toString());
if (expiring['tanggal_kadaluarsa'] != null) {
_expiryDate = DateTime.tryParse(expiring['tanggal_kadaluarsa']);
}
} else {
_expiringPoints = null;
_expiryDate = null;
}
_errorMessage = null;
_hasLoadedExpiring = true;
@ -184,4 +187,18 @@ class PoinProvider extends ChangeNotifier {
};
}
}
void reset() {
_isLoading = false;
_errorMessage = null;
_totalPoin = null;
_expiringPoints = null;
_expiryDate = null;
_pointHistory = [];
_shiftStart = null;
_shiftEnd = null;
_namaShift = null;
_hasLoadedExpiring = false;
notifyListeners();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -107,9 +107,8 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
index: _currentIndex,
children: _screens,
),
bottomNavigationBar: SafeArea(
child: Container(
margin: const EdgeInsets.fromLTRB(AppTheme.spacingMd, 0, AppTheme.spacingMd, AppTheme.spacingMd),
bottomNavigationBar: Container(
margin: EdgeInsets.fromLTRB(AppTheme.spacingMd, 0, AppTheme.spacingMd, MediaQuery.of(context).padding.bottom + AppTheme.spacingSm),
decoration: BoxDecoration(
color: AppTheme.bgCard.withOpacity(0.85),
borderRadius: BorderRadius.circular(AppTheme.radiusXl),
@ -119,48 +118,51 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
borderRadius: BorderRadius.circular(AppTheme.radiusXl),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
backgroundColor: Colors.transparent,
selectedItemColor: AppTheme.primaryOrange,
unselectedItemColor: AppTheme.textTertiary,
showUnselectedLabels: false,
showSelectedLabels: true,
selectedLabelStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700),
type: BottomNavigationBarType.fixed,
elevation: 0,
items: [
BottomNavigationBarItem(
icon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.grid_view_rounded, size: 24)),
activeIcon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.grid_view_rounded, size: 26)),
label: 'Beranda',
),
BottomNavigationBarItem(
icon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.fingerprint, size: 24)),
activeIcon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.fingerprint, size: 26)),
label: 'Presensi',
),
BottomNavigationBarItem(
icon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.insert_page_break_outlined, size: 24)),
activeIcon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.insert_page_break_rounded, size: 26)),
label: 'Pengajuan',
),
BottomNavigationBarItem(
icon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.person_outline, size: 24)),
activeIcon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.person_rounded, size: 26)),
label: 'Profil',
),
],
child: MediaQuery.removePadding(
context: context,
removeBottom: true,
child: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
backgroundColor: Colors.transparent,
selectedItemColor: AppTheme.primaryOrange,
unselectedItemColor: AppTheme.textTertiary,
showUnselectedLabels: false,
showSelectedLabels: true,
selectedLabelStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700),
type: BottomNavigationBarType.fixed,
elevation: 0,
items: [
BottomNavigationBarItem(
icon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.grid_view_rounded, size: 24)),
activeIcon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.grid_view_rounded, size: 26)),
label: 'Beranda',
),
BottomNavigationBarItem(
icon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.fingerprint, size: 24)),
activeIcon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.fingerprint, size: 26)),
label: 'Presensi',
),
BottomNavigationBarItem(
icon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.insert_page_break_outlined, size: 24)),
activeIcon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.insert_page_break_rounded, size: 26)),
label: 'Pengajuan',
),
BottomNavigationBarItem(
icon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.person_outline, size: 24)),
activeIcon: const Padding(padding: EdgeInsets.only(bottom: 4), child: Icon(Icons.person_rounded, size: 26)),
label: 'Profil',
),
],
),
),
),
),
),
),
);
}
}

View File

@ -311,7 +311,7 @@ class NotifikasiDetailScreen extends StatelessWidget {
),
const SizedBox(height: 12),
...notifikasi.data!.entries
.where((e) => e.key != 'tipe' && e.value != null && e.value.toString().isNotEmpty)
.where((e) => e.key != 'tipe' && !e.key.toLowerCase().startsWith('id') && e.value != null && e.value.toString().isNotEmpty)
.map((e) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(

View File

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

View File

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

View File

@ -62,10 +62,13 @@ class _IzinFormScreenState extends State<IzinFormScreen> {
if (success && mounted) {
Navigator.pop(context);
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) {
if (mounted) {
ErrorHandler.showError(e.toString().replaceAll('Exception: ', ''));
ErrorHandler.showError('Terjadi kesalahan sistem.');
}
}
}
@ -237,13 +240,15 @@ class _IzinFormScreenState extends State<IzinFormScreen> {
overflow: TextOverflow.ellipsis,
),
),
if (_uploadedFileName != null)
if (_uploadedFileName != null)
IconButton(
icon: const Icon(Icons.close, size: 24),
icon: const Icon(Icons.close, size: 18),
onPressed: () => setState(() {
_uploadedFileName = null;
_uploadedFilePath = null;
}),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),

View File

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

View File

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

View File

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

View File

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

View File

@ -13,6 +13,7 @@ import '../../repositories/face_repository.dart';
import '../../widgets/atoms/custom_button.dart';
import '../../widgets/organisms/map_view_organism.dart';
import 'camera_capture_screen.dart';
import '../../widgets/organisms/mock_location_blocker.dart';
class PresensiMapScreen extends StatefulWidget {
final String type;
@ -86,6 +87,14 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
final attendanceProvider = context.watch<AttendanceProvider>();
final user = homeProvider.user;
if (attendanceProvider.isMocked) {
return MockLocationBlocker(
onRetry: () {
_fetchLocation();
},
);
}
final currentLocation = attendanceProvider.currentLocation ?? _officeLocation;
final distance = attendanceProvider.currentLocation != null
@ -463,7 +472,10 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
parts.length > 2 ? int.parse(parts[2]) : 0
);
if (now.isBefore(scheduled)) {
// Toleransi pulang 15 menit lebih awal
final batasAwalPulang = scheduled.subtract(const Duration(minutes: 15));
if (now.isBefore(batasAwalPulang)) {
_showEarlyCheckoutDialog(context);
return;
}
@ -487,7 +499,8 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
parts.length > 2 ? int.parse(parts[2]) : 0
);
final batasToleransi = scheduled.add(const Duration(minutes: 10));
// Toleransi keterlambatan 15 menit
final batasToleransi = scheduled.add(const Duration(minutes: 15));
if (now.isAfter(batasToleransi)) {
_showLateCheckinDialog(context, isWithinRadius);
@ -875,6 +888,9 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
}
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(
context,
MaterialPageRoute(
@ -884,56 +900,44 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
if (captureResult == null || !mounted) return;
final File? videoFile = captureResult['video'];
final File? photoFile = captureResult['photo'];
if (videoFile == null || photoFile == null) {
if (photoFile == null) {
_showErrorSnackBar("Gagal mengambil data kamera.");
return;
}
// MB-10: Tampilkan overlay loading SEGERA setelah foto diterima,
// sebelum proses verifikasi ke Flask dimulai agar tidak ada layar kosong/freeze.
setState(() {
_isProcessing = true;
_processingStatus = "Memproses video wajah...";
_processingStatus = "Mempersiapkan data wajah...";
});
// Beri sedikit jeda agar UI sempat render overlay sebelum operasi berat dimulai
await Future.delayed(const Duration(milliseconds: 100));
try {
final faceResult = await FaceRepository().verifyFace(videoFile);
if (!mounted) return;
setState(() => _processingStatus = "Memverifikasi wajah...");
final faceResult = await FaceRepository().verifyFace(photoFile);
final bool isVerified = faceResult['verified'] == true;
// Mode video: gunakan approved_ratio (lebih representatif dari SVM proba)
// Mode foto: fallback ke confidence biasa
final double? approvedRatio = (faceResult['approved_ratio'] is num)
? (faceResult['approved_ratio'] as num).toDouble()
: null;
final double? rawConf = (faceResult['confidence'] is num)
? (faceResult['confidence'] as num).toDouble()
: null;
final double? confidence = approvedRatio ?? rawConf;
final int? framesTotal = (faceResult['frames_total'] as num?)?.toInt();
final int? framesApproved = (faceResult['frames_approved'] as num?)?.toInt();
if (!isVerified) {
if (!mounted) return;
setState(() {
_isProcessing = false;
});
_showFaceFailedDialog(
confidence: confidence,
framesTotal: framesTotal,
framesApproved: framesApproved,
message: faceResult['message']?.toString() ?? "Wajah tidak cocok.",
);
_showFaceFailedDialog();
return;
}
setState(() {
_processingStatus = "Mengirim data presensi...";
});
if (!mounted) return;
setState(() => _processingStatus = "Mengirim data presensi...");
final provider = context.read<AttendanceProvider>();
final success = await provider.submitPresensi(widget.type, photoFile);
if (!mounted) return;
@ -944,7 +948,7 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
if (success) {
HapticFeedback.heavyImpact();
Navigator.pop(context, {
Navigator.pop(this.context, {
'success': true,
'message': widget.type == 'masuk'
? 'Absen masuk berhasil!'
@ -967,12 +971,7 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
}
}
void _showFaceFailedDialog({
double? confidence,
int? framesTotal,
int? framesApproved,
required String message,
}) {
void _showFaceFailedDialog() {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
@ -988,24 +987,14 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(message, style: AppTheme.bodyMedium),
if (framesTotal != null) ...[
const SizedBox(height: 12),
Text(
"Frame Cocok: $framesApproved/$framesTotal",
style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary),
),
] else if (confidence != null && confidence < 1.0) ...[
const SizedBox(height: 12),
Text(
"Confidence: ${(confidence * 100).toStringAsFixed(1)}%",
style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary),
),
],
Text(
"Wajah Anda tidak dikenali oleh sistem.",
style: AppTheme.bodyMedium,
),
const SizedBox(height: 12),
Text(
"Pastikan pencahayaan cukup dan wajah terlihat jelas.",
style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary),
"Tips:\n• Pastikan wajah terlihat jelas tanpa masker/kacamata.\n• Pastikan pencahayaan terang dan tidak ada bayangan gelap di wajah.\n• Hadap lurus ke arah kamera.",
style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary, height: 1.4),
),
],
),

View File

@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../../core/theme.dart';
import '../../providers/home_provider.dart';
import '../../providers/attendance_provider.dart';
import '../../providers/face_provider.dart';
import '../../widgets/organisms/presensi_section.dart';
import '../../widgets/molecules/stat_card.dart';
import '../../widgets/molecules/attendance_time_card.dart';
@ -63,6 +64,8 @@ class _PresensiScreenState extends State<PresensiScreen> {
Widget build(BuildContext context) {
final homeProvider = context.watch<HomeProvider>();
final attendanceProvider = context.watch<AttendanceProvider>();
final faceProvider = context.watch<FaceProvider>();
final faceStatus = faceProvider.faceStatus2['status'];
return Scaffold(
backgroundColor: AppTheme.bgLight,
@ -87,12 +90,7 @@ class _PresensiScreenState extends State<PresensiScreen> {
children: [
Text(homeProvider.presensiToday?.shift ?? "-", style: AppTheme.labelMedium.copyWith(color: AppTheme.primaryDark)),
const SizedBox(width: 8),
InkWell(
onTap: () {
_showScheduleModal(context);
},
child: const Icon(Icons.calendar_month, color: AppTheme.primaryDark, size: 20),
),
const Icon(Icons.calendar_month, color: AppTheme.primaryDark, size: 20),
],
),
),
@ -103,12 +101,59 @@ class _PresensiScreenState extends State<PresensiScreen> {
onRefresh: () async {
await homeProvider.fetchDashboardData(force: true);
await attendanceProvider.fetchHistory(force: true);
faceProvider.loadFaceStatus();
},
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppTheme.spacingMd),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (faceStatus == 'pending')
Container(
margin: const EdgeInsets.only(bottom: AppTheme.spacingMd),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.primaryBlue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(color: AppTheme.primaryBlue.withValues(alpha: 0.3)),
),
child: Row(
children: [
const Icon(Icons.hourglass_top_rounded, color: AppTheme.primaryBlue, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
"Data wajah Anda sedang dalam proses verifikasi HRD. Presensi belum dapat dilakukan.",
style: AppTheme.bodySmall.copyWith(color: AppTheme.primaryBlue, fontWeight: FontWeight.w500),
),
),
],
),
),
if (faceStatus == 'rejected')
Container(
margin: const EdgeInsets.only(bottom: AppTheme.spacingMd),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.statusRed.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(color: AppTheme.statusRed.withValues(alpha: 0.3)),
),
child: Row(
children: [
const Icon(Icons.cancel_rounded, color: AppTheme.statusRed, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
"Registrasi wajah ditolak oleh HRD. Silakan lakukan registrasi ulang di menu Profil.",
style: AppTheme.bodySmall.copyWith(color: AppTheme.statusRed, fontWeight: FontWeight.w500),
),
),
],
),
),
PresensiSection(
presensiToday: homeProvider.presensiToday,
onPresensi: () {
@ -145,18 +190,22 @@ class _PresensiScreenState extends State<PresensiScreen> {
child: const Icon(Icons.chevron_left, size: 20, color: AppTheme.primaryDark),
),
),
GestureDetector(
InkWell(
onTap: () => _showMonthYearPicker(context, attendanceProvider),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.calendar_month, size: 18, color: AppTheme.primaryDark),
const SizedBox(width: 8),
Text(
"${_getMonthName(attendanceProvider.selectedMonth)} ${attendanceProvider.selectedYear}",
style: AppTheme.labelLarge.copyWith(fontSize: 16, color: AppTheme.primaryDark),
),
],
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.calendar_month, size: 18, color: AppTheme.primaryDark),
const SizedBox(width: 8),
Text(
"${_getMonthName(attendanceProvider.selectedMonth)} ${attendanceProvider.selectedYear}",
style: AppTheme.labelLarge.copyWith(fontSize: 16, color: AppTheme.primaryDark),
),
],
),
),
),
InkWell(
@ -270,26 +319,15 @@ class _PresensiScreenState extends State<PresensiScreen> {
),
],
),
const SizedBox(height: AppTheme.spacingMd),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: StatCard(
label: "Total Jam Kerja",
value: history.totalJam,
),
),
const SizedBox(width: AppTheme.spacingMd),
Expanded(
child: AttendanceTimeCard(
jamMasuk: history.jamMasuk ?? '-',
jamPulang: history.jamPulang ?? '-',
),
),
],
),
const SizedBox(height: 8),
if ((history.jamMasuk != null && history.jamMasuk != '-') || (history.jamPulang != null && history.jamPulang != '-')) ...[
const SizedBox(height: AppTheme.spacingMd),
AttendanceTimeCard(
jamMasuk: history.jamMasuk ?? '-',
jamPulang: history.jamPulang ?? '-',
totalJam: history.totalJam,
),
const SizedBox(height: 8),
],
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
@ -498,19 +536,73 @@ class _PresensiScreenState extends State<PresensiScreen> {
Future<void> _handlePresensi(String type) async {
if (!mounted) return;
final result = await Navigator.push<Map<String, dynamic>>(
context,
MaterialPageRoute(
builder: (context) => PresensiMapScreen(type: type),
),
);
try {
final faceProvider = context.read<FaceProvider>();
if (!mounted) return;
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(child: CircularProgressIndicator()),
);
if (result != null && result['success'] == true) {
await context.read<HomeProvider>().fetchDashboardData(force: true);
await context.read<AttendanceProvider>().fetchHistory(force: true);
_showResultDialog(true, "Absensi Berhasil!", result['message'] ?? "Data absensi Anda telah tercatat.");
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>>(
context,
MaterialPageRoute(
builder: (context) => PresensiMapScreen(type: type),
),
);
if (!mounted) return;
if (result != null && result['success'] == true) {
await context.read<HomeProvider>().fetchDashboardData(force: true);
await context.read<AttendanceProvider>().fetchHistory(force: true);
_showResultDialog(true, "Absensi Berhasil!", result['message'] ?? "Data absensi Anda telah tercatat.");
}
} catch (e) {
if (mounted) Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Gagal memeriksa status verifikasi wajah. Coba lagi.")),
);
}
}

View File

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

View File

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

View File

@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
import '../../core/cache_manager.dart';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
final bool launchedFromNotification;
const SplashScreen({super.key, this.launchedFromNotification = false});
@override
State<SplashScreen> createState() => _SplashScreenState();
@ -43,6 +45,16 @@ class _SplashScreenState extends State<SplashScreen> with SingleTickerProviderSt
final token = CacheManager.authBox.get('auth_token');
if (token != null && token.toString().isNotEmpty) {
Navigator.pushReplacementNamed(context, '/onboarding/check');
// MB-07: Jika app dibuka dari push notification (terminated state),
// langsung arahkan ke halaman notifikasi setelah onboarding selesai.
if (widget.launchedFromNotification) {
Future.delayed(const Duration(milliseconds: 800), () {
if (mounted) {
Navigator.pushNamed(context, '/notification');
}
});
}
} else {
Navigator.pushReplacementNamed(context, '/login');
}
@ -68,7 +80,7 @@ class _SplashScreenState extends State<SplashScreen> with SingleTickerProviderSt
children: [
Image.asset(
'assets/images/logo.png',
height: 120, // slightly larger since it's not in a box
height: 120,
width: 250,
fit: BoxFit.contain,
),

View File

@ -13,6 +13,8 @@ class ApiClient {
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Bypass-Tunnel-Reminder': 'true',
'ngrok-skip-browser-warning': 'true',
},
));
@ -38,13 +40,19 @@ class ApiClient {
requestOptions: response.requestOptions,
response: response,
type: DioExceptionType.badResponse,
error: 'Sesi habis, silakan login ulang.',
error: 'Sesi habis atau server mengembalikan HTML.',
),
);
}
return handler.next(response);
},
onError: (DioException e, handler) {
// Cegah error "type 'String' is not a subtype of type 'int'" di repository
if (e.response != null && e.response!.data is String) {
e.response!.data = {
'message': 'Terjadi kesalahan server (${e.response!.statusCode}).',
};
}
return handler.next(e);
},
));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -73,55 +73,57 @@ class _ChangePasswordDialogState extends State<ChangePasswordDialog> {
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Ubah Password", style: AppTheme.heading3),
const SizedBox(height: AppTheme.spacingMd),
CustomTextField(
controller: _currentPasswordController,
label: "Password Saat Ini",
isPassword: true,
validator: (value) =>
value == null || value.isEmpty ? "Wajib diisi" : null,
),
const SizedBox(height: AppTheme.spacingSm),
CustomTextField(
controller: _newPasswordController,
label: "Password Baru",
isPassword: true,
validator: (value) =>
value == null || value.length < 8 ? "Minimal 8 karakter" : null,
),
const SizedBox(height: AppTheme.spacingSm),
CustomTextField(
controller: _confirmPasswordController,
label: "Konfirmasi Password Baru",
isPassword: true,
validator: (value) =>
value == null || value.isEmpty ? "Wajib diisi" : null,
),
const SizedBox(height: AppTheme.spacingLg),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: isLoading ? null : () => Navigator.pop(context),
child: Text("Batal", style: AppTheme.bodyMedium.copyWith(color: AppTheme.textSecondary)),
),
const SizedBox(width: AppTheme.spacingSm),
Flexible(
child: CustomButton(
text: "Simpan",
isLoading: isLoading,
onPressed: _handleSubmit,
isFullWidth: false,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Ubah Password", style: AppTheme.heading3),
const SizedBox(height: AppTheme.spacingMd),
CustomTextField(
controller: _currentPasswordController,
label: "Password Saat Ini",
isPassword: true,
validator: (value) =>
value == null || value.isEmpty ? "Wajib diisi" : null,
),
const SizedBox(height: AppTheme.spacingSm),
CustomTextField(
controller: _newPasswordController,
label: "Password Baru",
isPassword: true,
validator: (value) =>
value == null || value.length < 8 ? "Minimal 8 karakter" : null,
),
const SizedBox(height: AppTheme.spacingSm),
CustomTextField(
controller: _confirmPasswordController,
label: "Konfirmasi Password Baru",
isPassword: true,
validator: (value) =>
value == null || value.isEmpty ? "Wajib diisi" : null,
),
const SizedBox(height: AppTheme.spacingLg),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: isLoading ? null : () => Navigator.pop(context),
child: Text("Batal", style: AppTheme.bodyMedium.copyWith(color: AppTheme.textSecondary)),
),
),
],
),
],
const SizedBox(width: AppTheme.spacingSm),
Flexible(
child: CustomButton(
text: "Simpan",
isLoading: isLoading,
onPressed: _handleSubmit,
isFullWidth: false,
),
),
],
),
],
),
),
),
),

View File

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