# Sprint — Pengembangan Fitur Notifikasi Mobile

**Aplikasi:** LPK TIP v2 (Flutter `lpk-mobile` + Laravel API v1)
**Tanggal:** 2026-08-24 · **Status:** Rencana (draft sprint M-N1 s/d M-N3)
**Dasar:** Backend notifikasi **sudah siap** (Fase N1 web selesai) — sisi **mobile belum berfungsi** (KPI menampilkan "Fitur notifikasi segera hadir").

---

## 1. Ringkasan & Tujuan

Menjadikan fitur notifikasi **berfungsi penuh di aplikasi mobile** untuk **dua peran**:

| Peran | Kebutuhan utama |
|---|---|
| **Karyawan** | Tahu hasil absensi (diterima/ditolak + alasan), pengingat masuk/keluar, pesan personal dari admin, perubahan shift/jadwal |
| **Admin** | Tahu peristiwa bisnis (SPB/transfer/BKK/stok menipis), **anomali absensi** (mock GPS, tanpa verifikasi wajah, wajah tidak cocok), notifikasi broadcast |

**Prinsip:** maksimalkan backend yang sudah ada (tabel `notifications`, `Notifier`, API `index/markRead/markAllRead`) → fokus kerja di **Flutter** + API kecil tambahan. Push FCM (real-time) dibuat **opsional fase akhir**, bukan prasyarat.

---

## 2. Kondisi Saat Ini (Audit)

### 2.1 Backend — SUDAH SIAP ✅
| Komponen | Status |
|---|---|
| Tabel `notifications` | `user_id` (null=broadcast, int=personal), `message`, `type` (info/success/warning/danger), `link`, `icon`, `priority`, `expires_at`, `is_read`, `read_at`, `created_by` |
| `App\Support\Notifier::send()` | Sudah dipakai 7+ peristiwa: simpan transaksi (PR/PO/GR/MT/BKK), **stok menipis**, absen tanpa verifikasi wajah, registrasi wajah, pesan personal absensi |
| API v1 | `GET /notifications` (paginate + `unread_only`), `POST /notifications/{id}/read`, `POST /notifications/read-all` — **dilindungi Sanctum** |
| Dashboard API | KPI `notifications_unread` + 3 item notif terbaru (role karyawan) |

### 2.2 Mobile — BELUM BERFUNGSI ⚠️
| Temuan | Detail |
|---|---|
| M1 | Kartu KPI "Notifikasi" di dashboard menampilkan jumlah unread, tetapi **klik → SnackBar "Fitur notifikasi segera hadir"** |
| M2 | Tidak ada **halaman daftar notifikasi** (list/tabs/pagination) |
| M3 | API `markRead` / `markAllRead` **tidak pernah dipanggil** → badge unread tidak pernah berkurang |
| M4 | Tidak ada **badge unread di AppBar/bottom-nav**; hanya ada di dalam dashboard |
| M5 | Tidak ada **polling/refresh otomatis** — angka unread statis sampai user pull-to-refresh dashboard |
| M6 | Kolom `link` diabaikan → notif tidak bisa **tap-to-navigate** |
| M7 | **Role admin** tidak melihat notifikasi sama sekali (tab admin: Beranda/Material/Scan/Rekap/Profil) |
| M8 | Tidak ada filter, grouping, relative time, empty/error state yang profesional |

---

## 3. Desain Fitur

### 3.1 Arsitektur target

```
┌───────────────────────── Flutter ─────────────────────────┐
│  AppBar Badge (🔔 unread)  ← polling 30s + resume refresh │
│  ┌ Halaman Notifikasi ─────────────────────────────────┐  │
│  │ Tabs: Semua | Belum dibaca  ·  Σ unread             │  │
│  │ [item] icon-type · pesan · waktu relatif · [•]      │  │
│  │        tap → tandai dibaca + buka target (deep link)│  │
│  │ aksi: Tandai semua dibaca · pull-to-refresh · load  │  │
│  │        lebih (pagination)                           │  │
│  └─────────────────────────────────────────────────────┘  │
│  Service: NotifService (dio) + unread stream (ValueNotifier│
└─────────────────────────┬─────────────────────────────────┘
                          │ REST (Sanctum)
┌─────────────────────────▼─────────────────────────────────┐
│  Backend: GET /notifications · POST /{id}/read ·          │
│          POST /read-all · (baru) GET /unread-count        │
│          Notifier::send(...) [sudah ada, ditambah trigger] │
└────────────────────────────────────────────────────────────┘
```

### 3.2 Peristiwa → notifikasi (matriks trigger)

#### Untuk KARYAWAN (personal, `user_id = employee.user_id`)
| Peristiwa | Pesan | Type | Link |
|---|---|---|---|
| Absen masuk diterima | "Absen masuk diterima (07:30)" | success | riwayat hari itu |
| Absen keluar diterima | "Absen keluar diterima (16:35)" | success | riwayat hari itu |
| Absen **ditolak** (wajah tidak cocok / luar radius / mock) | "Absen ditolak: verifikasi wajah tidak cocok" | danger | riwayat |
| Absen tanpa verifikasi wajah (fallback) | "Absen diterima tanpa verifikasi wajah" | warning | riwayat |
| Pengingat masuk (07:00, shift aktif) | "Jangan lupa absen masuk hari ini" | info | halaman absen |
| Pengingat keluar (sesuai shift) | "Jangan lupa absen keluar" | info | halaman absen |
| Pesan personal dari admin | teks admin | info | — |
| Perubahan shift / proyek | "Shift Anda diubah menjadi …" | warning | profil |

#### Untuk ADMIN (broadcast `user_id = null` + anomali)
| Peristiwa | Pesan | Type | Link |
|---|---|---|---|
| Transaksi disimpan (PR/PO/GR/MT/BKK/Solar) | "Material transfer disimpan (MT-…) " | success | detail dokumen (web) |
| **Stok menipis** | "Stok menipis: BESI-10 … (stok 3 ≤ min 5)" | danger | material |
| **Absen anomali**: mock GPS | "Mock! Absen karyawan X pukul …" | danger | log absensi |
| **Absen tanpa verifikasi wajah** | "Absen tanpa verifikasi wajah: …" | warning | log absensi |
| Karyawan registrasi wajah baru | "Wajah karyawan X terdaftar" | info | relasi staff |
| Rekap harian (opsional, 17:00) | "Σ hari ini: X masuk · Y anomali" | info | log absensi |

> Pemicu anomali sudah ada di backend (`AttendanceController`) — hanya perlu memastikan `user_id` target (admin) atau broadcast.

### 3.3 Layar & interaksi

| Elemen | Spesifikasi |
|---|---|
| **Masuk notifikasi** | ① AppBar icon 🔔 + badge unread · ② KPI dashboard · ③ tap notif di section "Pengumuman" |
| **Halaman list** | AppBar "Notifikasi", tabs `Semua` / `Belum dibaca`, indikator jumlah unread |
| **Item** | Icon lingkaran per `type` (info=biru, success=hijau, warning=kuning, danger=merah), pesan (2 baris max), **waktu relatif** ("5 mnt", "2 jam", "kemarin"), dot • unread |
| **Aksi item** | **Tap** = tandai dibaca (optimistic) + buka `link` (WebView utk link web; layar mobile utk target yang dikenal) · **swipe/ikon** = hapus (fase N2) |
| **Bulk** | Tombol "Tandai semua dibaca" (ikon check-double) — tampil jika ada unread |
| **Refresh** | Pull-to-refresh + **polling 30 detik** saat halaman terbuka + refresh saat app resume (`AppLifecycleListener`) |
| **Empty state** | "Tidak ada notifikasi" + ikon; tab Belum dibaca kosong → "Semua sudah dibaca 🎉" |
| **Error state** | Ikon + "Gagal memuat" + tombol coba lagi |

### 3.4 Badge unread global

- `NotifService` dengan `ValueNotifier<int> unreadCount` (singleton, didaftarkan di `main.dart`).
- Di-update oleh: login, polling, aksi mark-read, dashboard load.
- Ditampilkan di **AppBar `MainShell`** (badge merah) + halaman notifikasi.
- Untuk karyawan: badge juga di **tab Absen** bila ada pengingat/gagal.

### 3.5 Deep link / navigasi target

| `link` (dari backend) | Aksi mobile |
|---|---|
| `/payroll/log-absensi` / `/absen/...` | Buka riwayat absensi (rekap) |
| `/material`, `/material/{code}` | Buka detail material |
| `/transfer/spb/…` / dokumen web lain | **WebView** internal (URL absolut `trocon-lpk.aplikasigo.com` + link) |
| Tidak ada `link` | Buka detail notifikasi (dialog) |

> v1: WebView cukup (pakai `webview_flutter` atau `url_launcher`). Mapping native penuh = fase lanjutan.

---

## 4. Perubahan API (backend — kecil)

| Endpoint | Status | Keterangan |
|---|---|---|
| `GET /notifications` | ✅ ada | Tambah response `icon`, `priority`, `read_at` |
| `POST /notifications/{id}/read` | ✅ ada | — |
| `POST /notifications/read-all` | ✅ ada | — |
| `GET /notifications/unread-count` | ➕ baru | Cepat utk polling ringan (`{unread}`) |
| `DELETE /notifications/{id}` | ➕ baru | Hapus per item (fase N2) |
| `POST /notifications/device-token` | ➕ baru (N3) | Daftarkan token FCM utk push |

Tambahan trigger di backend (semua lewat `Notifier::send` — tanpa perubahan tabel):
- `AttendanceController@store` → kirim notif personal ke karyawan (diterima/ditolak/alasan).
- `AbsensiLog` edit/hapus oleh admin → notif warning ke karyawan bila datanya diubah.
- Command/scheduler `lpk:notif-reminder` → pengingat masuk (07:00) & keluar (sesuai shift) + rekap admin (17:00) — daftar di `routes/console.php`.

---

## 5. Rencana Fase Implementasi

### Fase M-N1 — Fondasi mobile (P0) — estimasi 3–4 hari — ✅ SELESAI (2026-08-25)
- [x] `NotifService` (fetch, markRead optimistik + rollback, markAllRead, unread stream `ValueNotifier`) + model `AppNotification` (dengan `copyWith`)
- [x] Halaman **NotifikasiScreen**: tabs Semua/Belum dibaca (badge di tab), pagination (infinite scroll), pull-to-refresh, relative time (`waktuRelatifId`), empty/error state
- [x] Badge 🔔 unread di AppBar `MainShell` (polling 30s + refresh saat app resume via `AppLifecycleListener`; tombol refresh ikut menyegarkan unread)
- [x] KPI dashboard "Notifikasi" → halaman; ikon bel header → halaman; section "Pengumuman" + tombol **Lihat semua**
- [x] API baru `GET /notifications/unread-count` + response index diperkaya (`icon`, `priority`, `read_at`)
- [x] Test: backend `ApiNotificationTest` (5) · mobile `notification_test.dart` (8: model, waktu relatif, render layar) · full suite backend 144/145 (1 pre-existing)
- [x] Build APK `app-release-v1.0.2.apk` (versi 1.0.2+3)

### Fase M-N2 — Kontekstual per peran & deep link (P1) — ✅ SELESAI (2026-08-25)
- [x] **Trigger notif personal karyawan** di `AttendanceController@store`: absen diterima (success, link `/absen`) / ditolak — akurasi & radius (warning), mock & wajah tidak cocok (danger) + **`Notifier::sendToAdmins()`** untuk anomali (mock, wajah tidak terdeteksi/tidak cocok)
- [x] **Pesan personal admin → karyawan**: form web Notifikasi kini punya **select Penerima** (Semua/Broadcast atau karyawan tertentu) + kolom **Penerima** di tabel
- [x] **Pengingat otomatis** `lpk:notif-reminder` (pagi 06–10: ingatkan absen masuk; sore 16–20: ingatkan absen keluar) — dijadwalkan tiap jam di `routes/console.php`
- [x] **Deep link**: `NotifLinkResolver` — link absensi/material → layar native; link web lain → browser eksternal; tap notif = tandai dibaca + buka target
- [x] **Hapus item**: swipe kiri (Dismissible) → `DELETE /notifications/{id}` (API baru) + `NotifService.delete` (rollback saat gagal)
- [x] **Badge per-tab**: badge unread di tombol **Absen** (role karyawan)
- [x] Test: `ApiNotificationTest` + DELETE (6) · `ApiAttendanceTest` + notif personal/mock (7) · `LpkNotifReminderTest` (3) · `NotificationN1Test` + personal (4) · mobile `notification_test.dart` (11: + resolver) · full suite backend 150/152 (1 pre-existing)
- [x] Build APK `app-release-v1.0.3.apk` (versi 1.0.3+4)

### Fase M-N3 — Push real-time (opsional, P2) — ✅ KODE SELESAI (2026-08-25) — menunggu setup Firebase dari user
- [x] **Backend**: tabel `notification_devices` + `notification_preferences` + kolom `category` di `notifications`; `FcmService` (HTTP v1, JWT service account, cache token, nonaktifkan token invalid 404, batch per perangkat); `Notifier::send` kini otomatis push FCM + kategori (absen/stok/transaksi/pengingat/pesan) + `sendToAdmins`; API `POST/DELETE device-token` + `GET/PUT preferences`
- [x] **Mobile**: `firebase_core`+`firebase_messaging` (plugin google-services **kondisional** — build tetap jalan tanpa google-services.json); `FcmService` (init graceful, izin POST_NOTIFICATIONS, token refresh, foreground → refresh badge, tap background/terminated → tandai dibaca + buka target via `FcmNavigator`); daftar token saat login, hapus saat logout; **Preferensi Notifikasi** di tab Profil (5 kategori toggle)
- [x] Test: `ApiNotificationTest` + device-token & preferensi (8) · full suite backend 152/154 (1 pre-existing) · mobile 12 lulus · analyze 0 error
- [x] Build APK `app-release-v1.0.4.apk` (versi 1.0.4+5) — **push nonaktif sampai google-services.json dipasang**
- [x] ⏳ **Tunggu user**: setup Firebase — ✅ SELESAI (2026-08-25): service account & `google-services.json` dipasang; `FcmService` terverifikasi (access token OAuth berhasil, jalur HTTPS ke FCM berfungsi); APK `app-release-v1.0.4-firebase.apk` (push AKTIF)

---

## 6. Test Plan

| Area | Skenario |
|---|---|
| API | index (paginate/unread_only), markRead, markAllRead, unread-count; RBAC Sanctum 401 tanpa token |
| Badge | Login dgn unread>0 → badge tampil; tandai semua → badge hilang; polling 30s memperbarui |
| List | Tabs filter benar; scroll memuat halaman berikutnya; relative time benar |
| Aksi | Tap item → is_read=true + navigasi; optimistic update + rollback saat gagal |
| Peran karyawan | Hanya lihat notif personal + broadcast; tap → layar absen/riwayat |
| Peran admin | Lihat broadcast + anomali; tap → WebView dokumen |
| Push (N3) | Foreground badge, background notification, terminated → target screen; token multi-device |
| Regresi | Dashboard KPI, login/logout, build release APK |

---

## 7. Risiko & Keputusan

| Risiko | Mitigasi |
|---|---|
| Polling membebani API | Interval 30–60s + `unread-count` ringan; hentikan polling saat app background (lifecycle) |
| Notifikasi web `link` tidak relevan di mobile | WebView internal (bukan keluar app); mapping native bertahap |
| Broadcast admin ke semua user berisiko spam | Otorisasi kirim (web) tetap di menu admin; pesan personal hanya ke `user_id` tertentu |
| FCM memerlukan akun Firebase & keystore | **Opsional (N3)** — fitur tetap berfungsi via polling tanpa FCM |
| Karyawan tanpa akun user | Notif personal hanya utk user dgn `employee_id`; sisanya broadcast saja |
| Duplikat notif (ganda polling+push) | `expires_at` + dedup `(user_id, message, created_at)` saat trigger |

---

## 8. Ringkasan

| Item | Nilai |
|---|---|
| Backend | ✅ siap (tabel + Notifier + API dasar) — tambah 2–3 endpoint kecil + trigger |
| Kerja utama | Flutter: halaman notifikasi, badge, polling, aksi, deep link |
| Fase | M-N1 (fondasi) → M-N2 (peran & deep link) → M-N3 (push FCM opsional) |
| Estimasi | 3–4 + 3–4 + 4–6 hari kerja |
| Tanpa FCM pun | Fitur sudah produktif via polling (30s) |
