diff --git a/.gitignore b/.gitignore
index 1b5e7f5..2078943 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,6 @@ bugreport-*.zip
requirement/Figma.zip
requirement/Figma/
requirement/figma/
+
+# Mitra figma design dump (local reference only, do not check in)
+mitra_app/figma-bestie/
diff --git a/backend/src/routes/internal/_test.routes.js b/backend/src/routes/internal/_test.routes.js
index 4b6459c..a096c23 100644
--- a/backend/src/routes/internal/_test.routes.js
+++ b/backend/src/routes/internal/_test.routes.js
@@ -332,6 +332,28 @@ export const internalTestRoutes = async (fastify) => {
return { ok: true, ...row }
})
+ // Upsert a mitra with the given phone and is_active flag. Used by the
+ // mitra_app pre-home Maestro flows to ensure a known mitra exists (with
+ // the right active state) before driving the OTP verify path. Idempotent —
+ // safe to run on every test pass.
+ fastify.post('/seed-mitra', async (request, reply) => {
+ const phone = request.body?.phone
+ const display_name = request.body?.display_name
+ const is_active = request.body?.is_active !== false // default true
+ if (!phone || !display_name) {
+ return reply.code(400).send({ error: 'phone and display_name required in body' })
+ }
+ const [row] = await sql`
+ INSERT INTO mitras (phone, display_name, is_active)
+ VALUES (${phone}, ${display_name}, ${is_active})
+ ON CONFLICT (phone) DO UPDATE
+ SET display_name = EXCLUDED.display_name,
+ is_active = EXCLUDED.is_active
+ RETURNING id, phone, display_name, is_active
+ `
+ return { ok: true, ...row }
+ })
+
// Mark EVERY mitra row online. Used by Maestro flows as a setup step to
// ensure a clean known-good state regardless of what previous tests did
// (e.g. force-mitra-offline leaving the dev DB with no online mitras).
diff --git a/backend/src/routes/internal/mitra.routes.js b/backend/src/routes/internal/mitra.routes.js
index c9f3b96..f7b40c9 100644
--- a/backend/src/routes/internal/mitra.routes.js
+++ b/backend/src/routes/internal/mitra.routes.js
@@ -1,6 +1,6 @@
import { authenticate, requirePermission } from '../../plugins/auth.js'
import { getCcUserById } from '../../services/cc-user.service.js'
-import { createMitra, listMitras, updateMitraStatus } from '../../services/mitra.service.js'
+import { createMitra, listMitras, updateMitraStatus, updateMitraDisplayName } from '../../services/mitra.service.js'
import { getOnlineMitras, getOnlineLogs } from '../../services/mitra-status.service.js'
import { UserType } from '../../constants.js'
@@ -48,6 +48,17 @@ export const mitraManagementRoutes = async (app) => {
return reply.send({ success: true, data: mitra })
})
+ app.patch('/:id', {
+ preHandler: [authenticate, attachCcUser, requirePermission('mitra', 'update')],
+ }, async (request, reply) => {
+ const { display_name } = request.body ?? {}
+ if (typeof display_name !== 'string' || display_name.trim().length === 0) {
+ return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'display_name must be a non-empty string' } })
+ }
+ const mitra = await updateMitraDisplayName(request.params.id, display_name.trim())
+ return reply.send({ success: true, data: mitra })
+ })
+
app.get('/online', {
preHandler: [authenticate, attachCcUser, requirePermission('mitra', 'read')],
}, async (request, reply) => {
diff --git a/backend/src/services/mitra.service.js b/backend/src/services/mitra.service.js
index eb2e10c..2daf15c 100644
--- a/backend/src/services/mitra.service.js
+++ b/backend/src/services/mitra.service.js
@@ -39,6 +39,16 @@ export const updateMitraStatus = async (id, is_active) => {
return mitra
}
+export const updateMitraDisplayName = async (id, display_name) => {
+ const [mitra] = await sql`
+ UPDATE mitras SET display_name = ${display_name}
+ WHERE id = ${id}
+ RETURNING id, phone, display_name, is_active, created_at
+ `
+ if (!mitra) throw Object.assign(new Error('Mitra not found'), { code: 'NOT_FOUND', statusCode: 404 })
+ return mitra
+}
+
export const listMitras = async ({ page = 1, limit = 20, is_active }) => {
const offset = (page - 1) * limit
const conditions = is_active !== undefined
diff --git a/control_center/src/pages/mitras/MitrasPage.jsx b/control_center/src/pages/mitras/MitrasPage.jsx
index 1ba6fa2..1ecedd4 100644
--- a/control_center/src/pages/mitras/MitrasPage.jsx
+++ b/control_center/src/pages/mitras/MitrasPage.jsx
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { Fragment, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '../../core/api/api-client'
@@ -22,6 +22,11 @@ const updateMitraStatus = async ({ id, is_active }) => {
return res.data.data
}
+const updateMitraName = async ({ id, display_name }) => {
+ const res = await apiClient.patch(`/internal/mitras/${id}`, { display_name })
+ return res.data.data
+}
+
const fetchOnlineLogs = async (mitraId) => {
const res = await apiClient.get(`/internal/mitras/${mitraId}/online-logs`)
return res.data.data
@@ -39,6 +44,8 @@ export default function MitrasPage() {
const [form, setForm] = useState({ phone: '', display_name: '' })
const [showForm, setShowForm] = useState(false)
const [logsForMitra, setLogsForMitra] = useState(null)
+ const [editingId, setEditingId] = useState(null)
+ const [editName, setEditName] = useState('')
const { data: logsData, isLoading: logsLoading } = useQuery({
queryKey: ['mitra-online-logs', logsForMitra],
@@ -60,6 +67,29 @@ export default function MitrasPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['mitras'] }),
})
+ const nameMutation = useMutation({
+ mutationFn: updateMitraName,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['mitras'] })
+ setEditingId(null)
+ setEditName('')
+ },
+ })
+
+ const startEdit = (mitra) => {
+ setEditingId(mitra.id)
+ setEditName(mitra.display_name)
+ }
+ const cancelEdit = () => {
+ setEditingId(null)
+ setEditName('')
+ }
+ const saveEdit = (id) => {
+ const trimmed = editName.trim()
+ if (!trimmed) return
+ nameMutation.mutate({ id, display_name: trimmed })
+ }
+
if (isLoading) return
Loading...
// Build a set of online mitra IDs for quick lookup
@@ -106,9 +136,27 @@ export default function MitrasPage() {
{data?.items?.map((mitra) => {
const onlineInfo = onlineMitraMap.get(mitra.id)
+ const isEditing = editingId === mitra.id
return (
-
- | {mitra.display_name} |
+
+
+ |
+ {isEditing ? (
+ setEditName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') saveEdit(mitra.id)
+ if (e.key === 'Escape') cancelEdit()
+ }}
+ disabled={nameMutation.isPending}
+ style={{ width: '100%' }}
+ />
+ ) : (
+ mitra.display_name
+ )}
+ |
{mitra.phone} |
{mitra.is_active ? 'Aktif' : 'Nonaktif'} |
@@ -118,48 +166,69 @@ export default function MitrasPage() {
|
{onlineInfo ? onlineInfo.active_session_count : '-'} |
-
-
+ {isEditing ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+
+ >
+ )}
|
+ {logsForMitra === mitra.id && (
+
+
+
+ Log Online/Offline · {mitra.display_name}
+ {logsLoading ? (
+ Loading...
+ ) : logsData?.items?.length ? (
+
+
+
+ | Status |
+ Waktu |
+
+
+
+ {logsData.items.map((log) => (
+
+ |
+
+ {log.status === 'online' ? '● Online' : '○ Offline'}
+
+ |
+ {new Date(log.timestamp).toLocaleString('id-ID')} |
+
+ ))}
+
+
+ ) : (
+ Belum ada log.
+ )}
+
+ |
+
+ )}
+
)
})}
-
- {logsForMitra && (
-
-
Log Online/Offline
- {logsLoading ? (
-
Loading...
- ) : (
-
-
-
- | Status |
- Waktu |
-
-
-
- {logsData?.items?.map((log) => (
-
- |
-
- {log.status === 'online' ? '● Online' : '○ Offline'}
-
- |
- {new Date(log.timestamp).toLocaleString('id-ID')} |
-
- ))}
-
-
- )}
-
- )}
)
}
diff --git a/mitra_app/.maestro/flows/README_section_A.md b/mitra_app/.maestro/flows/README_section_A.md
new file mode 100644
index 0000000..d92d257
--- /dev/null
+++ b/mitra_app/.maestro/flows/README_section_A.md
@@ -0,0 +1,116 @@
+# §A — Mitra Pre-Home (auth) test plan
+
+Spec: [requirement/flow_mitra.mermaid.md §A](../../../requirement/flow_mitra.mermaid.md).
+
+Tests use the naming convention `ts-mitra---.yaml`:
+- `` — flow_mitra.mermaid section identifier (`A` for pre-home auth).
+- `` — sub-flow index within the section, zero-padded.
+- `` — snake_case summary of the branch under test.
+
+## Implemented
+
+| File | Branch (spec ref) | Expected destination |
+|---|---|---|
+| `ts-mitra-A-01-phone_invalid_inline_error.yaml` | §A.1 local CTA gate for short phones | "kirim kode" disabled for <9 subscriber digits, enables + navigates on valid input |
+| `ts-mitra-A-03-success_login_to_home.yaml` | §A.2 200 + `is_active=true` | `/home` (active sessions tab) |
+| `ts-mitra-A-04-account_inactive_screen.yaml` | §A.2 200 + `is_active=false` → 403 `ACCOUNT_INACTIVE` | `/auth/inactive` (AppBar back arrow omitted; system-back interception deferred) |
+| `ts-mitra-A-05-phone_format_variants.yaml` | §A.1 phone-format normalization | 5 variants (`8…`, `08…`, `628…`, `+628…`, `0628…`) all reach S3b with `+628200000501` shown |
+| `ts-mitra-A-06-back_to_login_and_retry.yaml` | §A.1 regression — back from S3b + re-submit | Second "kirim kode" tap still navigates to S3b after returning to S3a |
+
+## Test infrastructure
+
+JS scripts (live in [`../scripts/`](../scripts/)) called via `runScript:`:
+
+| Script | Purpose | Backend endpoint |
+|---|---|---|
+| `reset_phone.js` | Clear `otp_requests` rows for `TEST_PHONE` so cooldown / phone-rate-limit don't trip on re-runs. | `POST /internal/_test/reset-phone` |
+| `seed_mitra.js` | Upsert a mitra row with given phone + `is_active`. Idempotent. | `POST /internal/_test/seed-mitra` |
+| `peek_otp.js` | Read the latest stub-generated OTP code for `TEST_PHONE`. Writes to `output.OTP`. | `GET /internal/_test/peek-otp` |
+
+All three only exist on the **internal** listener (port 3001), so they're
+network-isolated from production traffic.
+
+### Phone-number convention
+
+Each test uses a unique `+628200000` phone to avoid cross-flow
+interference:
+- A-02 (deferred) → `+628200000201`
+- A-03 → `+628200000301`
+- A-04 → `+628200000401`
+- A-05 → `+628200000501` (one phone, 5 input formats)
+- A-06 → `+628200000601`
+
+If the same phone gets used across multiple flows in one run, the per-IP
+rate-limit (10 OTP requests / hour, default) can trip and break A-03 or A-04
+mid-suite. A-05 mitigates this by calling `reset_phone` between its 5
+variants (each variant is one OTP request).
+
+## Deferred (not yet implemented — see reasons)
+
+### `ts-mitra-A-02-wrong_code_attempts_then_blocked.yaml`
+**Branch:** §A.2 5× CODE_MISMATCH → OTP_ATTEMPTS_EXCEEDED → blocked dialog.
+
+**Why deferred:** the 6-separate-TextField OTP pattern with `maxLength: 1`
+per box doesn't play well with maestro's `inputText` on Android. Maestro
+uses uiautomator2's `setText` under the hood, which delivers chars
+non-deterministically across the per-box focus chain — even matching the
+customer-app's exact `inputText: "000000"` × 6 pattern. After the 5th
+auto-submit succeeds and the boxes clear, focus state races with the
+next `inputText` call and digits silently drop. Verified manually: the
+"Tersisa N percobaan" hint and blocked dialog both render correctly with
+real keyboard typing.
+
+**Possible future approach:** refactor S3b to use a single hidden
+`TextField` with custom box decorations (the `pin_code_fields` pattern).
+One `inputText: "000000"` per attempt would land all 6 chars in one IME
+commit, matching how real users paste OTPs from SMS. Worth doing for
+SMS-paste UX anyway.
+
+### `ts-mitra-A-05-otp_cooldown_snackbar.yaml`
+**Branch:** §A.1 second OTP request within 60s → `OTP_COOLDOWN` 429 + snackbar.
+
+**Why deferred:** Maestro's `extendedWaitUntil` can't reliably assert a
+floating snackbar that auto-dismisses in ~4s — the visible window is too
+short for the polling cadence. Possible workaround: drive two consecutive
+requests and rely on the CTA label switching to "coba lagi dalam Ns" (which
+is non-floating and stable). Worth adding once the resend-cooldown UX
+stabilizes.
+
+### `ts-mitra-A-06-rate_limit_phone_popup.yaml`
+**Branch:** §A.1 4th OTP request in 1h for same phone → `OTP_RATE_LIMIT_PHONE`
+429 popup with `retry_after_seconds`.
+
+**Why deferred:** The popup is asserted in manual testing (screenshot in
+phase4-mitra-prehome-plan.md). Driving 4 sequential requests within one
+maestro run is brittle if any earlier test bumped the counter. A backend
+`_test/reset-phone-rate-limit` helper would make this reliable; not added yet
+to keep the test-surface minimal.
+
+### `ts-mitra-A-07-resend_after_cooldown.yaml`
+**Branch:** §A.4 resend after 60s cooldown → fresh OTP, attempts counter resets.
+
+**Why deferred:** 60s wall-clock wait per pass is too slow for CI. Drive
+this manually until we have a `_test/expire-cooldown` helper that fast-forwards
+the cooldown clock.
+
+### `ts-mitra-A-08-otp_expired.yaml`
+**Branch:** §A.2 5-minute TTL elapses → `OTP_EXPIRED` 410 → dialog → S3a.
+
+**Why deferred:** Same wall-clock problem. Need a `_test/force-expire-otp`
+helper before this is automatable.
+
+## Running
+
+From `mitra_app/`:
+
+```bash
+# Single flow
+maestro test .maestro/flows/ts-mitra-A-01-phone_invalid_inline_error.yaml
+
+# Whole §A suite
+maestro test .maestro/flows/ts-mitra-A-*.yaml
+```
+
+The backend must be running with `OTP_STATIC_CODE` **unset** — peek_otp.js
+relies on the stub generator returning a fresh code per request, not a
+static one.
diff --git a/mitra_app/.maestro/flows/ts-mitra-A-01-phone_invalid_inline_error.yaml b/mitra_app/.maestro/flows/ts-mitra-A-01-phone_invalid_inline_error.yaml
new file mode 100644
index 0000000..d777055
--- /dev/null
+++ b/mitra_app/.maestro/flows/ts-mitra-A-01-phone_invalid_inline_error.yaml
@@ -0,0 +1,50 @@
+# ts-mitra-A-01 — §A.1 phone-input validation gates the CTA
+# Spec ref: requirement/flow_mitra.mermaid.md §A.1
+#
+# The local CTA gate (subscriberDigits.length >= 9) prevents short phones
+# from even reaching the backend — this test verifies the client-side
+# guard: "12345" leaves the button disabled, "8123456789" enables it.
+#
+# (The PHONE_INVALID server-side path is unreachable from the UI today
+# because the local gate is stricter than the backend regex. Kept as
+# defensive code in login_screen.dart's auth listener; not test-driven.)
+appId: com.mybestie.mitra
+env:
+ TEST_PHONE: "+628222222222"
+ BACKEND_INTERNAL_URL: http://localhost:3001
+---
+# Reset rate-limit history for the test phone so the kirim-kode tap below
+# never trips OTP_COOLDOWN / OTP_RATE_LIMIT_PHONE leftover from prior runs.
+- runScript:
+ file: ../scripts/reset_phone.js
+ env:
+ TEST_PHONE: ${TEST_PHONE}
+ BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
+
+- launchApp:
+ clearState: true
+- extendedWaitUntil:
+ visible:
+ text: "(?s).*Halo Mitra Bestie.*"
+ timeout: 10000
+
+# Short phone — 5 digits — CTA should remain disabled.
+- tapOn:
+ point: "60%, 47%"
+- inputText: "12345"
+- assertVisible: "(?s).*kirim kode.*"
+
+# Tapping the disabled CTA should NOT navigate forward.
+- tapOn: "(?s).*kirim kode.*"
+# Brief idle; if the CTA were active the screen would push to /otp.
+- assertNotVisible: "Masukkan OTP"
+- assertNotVisible: "(?s).*masukin 6 digit kode.*"
+
+# Erase, type a valid 10-digit subscriber → CTA enables and pushes to S3b.
+- eraseText
+- inputText: "8222222222"
+- tapOn: "(?s).*kirim kode.*"
+- extendedWaitUntil:
+ visible:
+ text: "(?s).*masukin 6 digit kode.*"
+ timeout: 10000
diff --git a/mitra_app/.maestro/flows/ts-mitra-A-03-success_login_to_home.yaml b/mitra_app/.maestro/flows/ts-mitra-A-03-success_login_to_home.yaml
new file mode 100644
index 0000000..0538538
--- /dev/null
+++ b/mitra_app/.maestro/flows/ts-mitra-A-03-success_login_to_home.yaml
@@ -0,0 +1,64 @@
+# ts-mitra-A-03 — §A.2 happy path → /home
+# Spec ref: requirement/flow_mitra.mermaid.md §A.2 (200 + is_active=true branch).
+#
+# End-to-end S3a → S3b → home for an active mitra:
+# 1. Seed mitra row with is_active=true
+# 2. Reset OTP requests (so cooldown/rate-limit doesn't trip)
+# 3. Drive S3a, request OTP
+# 4. Peek the stub OTP code from the backend
+# 5. Submit it on S3b → /home renders
+appId: com.mybestie.mitra
+env:
+ TEST_PHONE: "+628200000301"
+ MITRA_DISPLAY_NAME: "Maestro Active"
+ BACKEND_INTERNAL_URL: http://localhost:3001
+---
+- runScript:
+ file: ../scripts/seed_mitra.js
+ env:
+ TEST_PHONE: ${TEST_PHONE}
+ MITRA_DISPLAY_NAME: ${MITRA_DISPLAY_NAME}
+ IS_ACTIVE: "true"
+ BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
+- runScript:
+ file: ../scripts/reset_phone.js
+ env:
+ TEST_PHONE: ${TEST_PHONE}
+ BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
+
+- launchApp:
+ clearState: true
+- extendedWaitUntil:
+ visible:
+ text: "(?s).*Halo Mitra Bestie.*"
+ timeout: 10000
+
+# S3a — request OTP.
+- tapOn:
+ point: "60%, 47%"
+- inputText: "8200000301"
+- tapOn: "(?s).*kirim kode.*"
+
+- extendedWaitUntil:
+ visible:
+ text: "(?s).*masukin 6 digit kode.*"
+ timeout: 10000
+
+# Peek the stub OTP that the backend just generated. Stored in output.OTP.
+- runScript:
+ file: ../scripts/peek_otp.js
+ env:
+ TEST_PHONE: ${TEST_PHONE}
+ BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
+
+# Submit it. inputText delivers the chars one-at-a-time via adb shell input;
+# the Focus(canRequestFocus:false)-wrapped TextFields chain focus on each
+# digit and _submit() fires automatically when the 6th digit lands.
+- inputText: ${output.OTP}
+
+# Assert: home renders. Use any text we know lives on the home/active-sessions
+# tab to confirm we're past auth.
+- extendedWaitUntil:
+ visible:
+ text: "Sesi Aktif|Riwayat Chat"
+ timeout: 15000
diff --git a/mitra_app/.maestro/flows/ts-mitra-A-04-account_inactive_screen.yaml b/mitra_app/.maestro/flows/ts-mitra-A-04-account_inactive_screen.yaml
new file mode 100644
index 0000000..e3e7d82
--- /dev/null
+++ b/mitra_app/.maestro/flows/ts-mitra-A-04-account_inactive_screen.yaml
@@ -0,0 +1,72 @@
+# ts-mitra-A-04 — §A.2 ACCOUNT_INACTIVE → terminal full-screen state
+# Spec ref: requirement/flow_mitra.mermaid.md §A.2 (403 ACCOUNT_INACTIVE branch).
+#
+# Verifies the inactive-mitra flow:
+# 1. Seed mitra row with is_active=false
+# 2. Reset OTP requests
+# 3. Drive S3a, request OTP
+# 4. Submit the correct OTP code → backend returns 403 ACCOUNT_INACTIVE
+# AFTER OTP verification succeeds (mitra.auth.routes.js L54-57)
+# 5. Screen routes to /auth/inactive
+# 6. PopScope(canPop: false) blocks system back
+appId: com.mybestie.mitra
+env:
+ TEST_PHONE: "+628200000401"
+ MITRA_DISPLAY_NAME: "Maestro Inactive"
+ BACKEND_INTERNAL_URL: http://localhost:3001
+---
+- runScript:
+ file: ../scripts/seed_mitra.js
+ env:
+ TEST_PHONE: ${TEST_PHONE}
+ MITRA_DISPLAY_NAME: ${MITRA_DISPLAY_NAME}
+ IS_ACTIVE: "false"
+ BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
+- runScript:
+ file: ../scripts/reset_phone.js
+ env:
+ TEST_PHONE: ${TEST_PHONE}
+ BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
+
+- launchApp:
+ clearState: true
+- extendedWaitUntil:
+ visible:
+ text: "(?s).*Halo Mitra Bestie.*"
+ timeout: 10000
+
+# S3a — request OTP.
+- tapOn:
+ point: "60%, 47%"
+- inputText: "8200000401"
+- tapOn: "(?s).*kirim kode.*"
+
+- extendedWaitUntil:
+ visible:
+ text: "(?s).*masukin 6 digit kode.*"
+ timeout: 10000
+
+# Peek + submit correct code.
+- runScript:
+ file: ../scripts/peek_otp.js
+ env:
+ TEST_PHONE: ${TEST_PHONE}
+ BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
+- inputText: ${output.OTP}
+
+# AccountInactive screen renders.
+- extendedWaitUntil:
+ visible:
+ text: "(?s).*akun belum aktif.*"
+ timeout: 15000
+- assertVisible: "(?s).*memverifikasi akun kamu.*"
+- assertVisible: "(?s).*pakai nomor lain.*"
+
+# Negative assertion: the home screen should NOT have rendered (token storage
+# should be empty — backend returned 403 without tokens before is_active gate).
+- assertNotVisible: "Sesi Aktif"
+
+# Note: system-back interception is intentionally NOT tested here — PopScope
+# on a GoRouter root route doesn't currently block the Android back key
+# because there's no Navigator route to pop. Tracked as a follow-up; the
+# AppBar has no back arrow either way so the in-app UX is correct.
diff --git a/mitra_app/.maestro/flows/ts-mitra-A-05-phone_format_variants.yaml b/mitra_app/.maestro/flows/ts-mitra-A-05-phone_format_variants.yaml
new file mode 100644
index 0000000..2f5c98b
--- /dev/null
+++ b/mitra_app/.maestro/flows/ts-mitra-A-05-phone_format_variants.yaml
@@ -0,0 +1,136 @@
+# ts-mitra-A-05 — §A.1 phone-format normalization
+# Spec ref: requirement/flow_mitra.mermaid.md §A.1
+#
+# Indonesian users type phone numbers in many shapes. The login screen's
+# _subscriberDigits() in login_screen.dart must normalize ALL of these to
+# the same +628200000501 (subscriber digits = 8200000501):
+#
+# 8200000501 — subscriber only
+# 08200000501 — local format with leading 0
+# 628200000501 — country code without +
+# +628200000501 — full E.164
+# 0628200000501 — typo combo with leading 0 before country code
+#
+# Strategy: seed ONE mitra at +628200000501. For each variant, do a fresh
+# launchApp clearState, type the variant, tap "kirim kode", and assert the
+# S3b screen shows the correctly normalized phone "+628200000501". A fresh
+# launch per variant is more reliable than back-navigation across maestro
+# / IME / keyboard state.
+appId: com.mybestie.mitra
+env:
+ TEST_PHONE: "+628200000501"
+ MITRA_DISPLAY_NAME: "Maestro Variants"
+ BACKEND_INTERNAL_URL: http://localhost:3001
+---
+- runScript:
+ file: ../scripts/seed_mitra.js
+ env:
+ TEST_PHONE: ${TEST_PHONE}
+ MITRA_DISPLAY_NAME: ${MITRA_DISPLAY_NAME}
+ IS_ACTIVE: "true"
+ BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
+
+# ── Variant 1: 8xxxxxxxxx (subscriber only, 10 digits) ──
+- runScript:
+ file: ../scripts/reset_phone.js
+ env:
+ TEST_PHONE: ${TEST_PHONE}
+ BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
+- launchApp:
+ clearState: true
+- extendedWaitUntil:
+ visible:
+ text: "(?s).*Halo Mitra Bestie.*"
+ timeout: 10000
+- tapOn:
+ point: "60%, 47%"
+- inputText: "8200000501"
+- tapOn: "(?s).*kirim kode.*"
+- extendedWaitUntil:
+ visible:
+ text: "(?s).*\\+628200000501.*"
+ timeout: 10000
+
+# ── Variant 2: 08xxxxxxxxx (local format with leading 0) ──
+- runScript:
+ file: ../scripts/reset_phone.js
+ env:
+ TEST_PHONE: ${TEST_PHONE}
+ BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
+- launchApp:
+ clearState: true
+- extendedWaitUntil:
+ visible:
+ text: "(?s).*Halo Mitra Bestie.*"
+ timeout: 10000
+- tapOn:
+ point: "60%, 47%"
+- inputText: "08200000501"
+- tapOn: "(?s).*kirim kode.*"
+- extendedWaitUntil:
+ visible:
+ text: "(?s).*\\+628200000501.*"
+ timeout: 10000
+
+# ── Variant 3: 628xxxxxxxxx (country code without +) ──
+- runScript:
+ file: ../scripts/reset_phone.js
+ env:
+ TEST_PHONE: ${TEST_PHONE}
+ BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
+- launchApp:
+ clearState: true
+- extendedWaitUntil:
+ visible:
+ text: "(?s).*Halo Mitra Bestie.*"
+ timeout: 10000
+- tapOn:
+ point: "60%, 47%"
+- inputText: "628200000501"
+- tapOn: "(?s).*kirim kode.*"
+- extendedWaitUntil:
+ visible:
+ text: "(?s).*\\+628200000501.*"
+ timeout: 10000
+
+# ── Variant 4: +628xxxxxxxxx (full E.164 with +) ──
+- runScript:
+ file: ../scripts/reset_phone.js
+ env:
+ TEST_PHONE: ${TEST_PHONE}
+ BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
+- launchApp:
+ clearState: true
+- extendedWaitUntil:
+ visible:
+ text: "(?s).*Halo Mitra Bestie.*"
+ timeout: 10000
+- tapOn:
+ point: "60%, 47%"
+- inputText: "+628200000501"
+- tapOn: "(?s).*kirim kode.*"
+- extendedWaitUntil:
+ visible:
+ text: "(?s).*\\+628200000501.*"
+ timeout: 10000
+
+# ── Variant 5: 0628xxxxxxxxx (typo combo) ──
+- runScript:
+ file: ../scripts/reset_phone.js
+ env:
+ TEST_PHONE: ${TEST_PHONE}
+ BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
+- launchApp:
+ clearState: true
+- extendedWaitUntil:
+ visible:
+ text: "(?s).*Halo Mitra Bestie.*"
+ timeout: 10000
+- tapOn:
+ point: "60%, 47%"
+- inputText: "0628200000501"
+- tapOn: "(?s).*kirim kode.*"
+- extendedWaitUntil:
+ visible:
+ text: "(?s).*\\+628200000501.*"
+ timeout: 10000
diff --git a/mitra_app/.maestro/flows/ts-mitra-A-06-back_to_login_and_retry.yaml b/mitra_app/.maestro/flows/ts-mitra-A-06-back_to_login_and_retry.yaml
new file mode 100644
index 0000000..1eec666
--- /dev/null
+++ b/mitra_app/.maestro/flows/ts-mitra-A-06-back_to_login_and_retry.yaml
@@ -0,0 +1,72 @@
+# ts-mitra-A-06 — §A.1 navigate back from S3b, tap "kirim kode" again
+# Spec ref: requirement/flow_mitra.mermaid.md §A.1
+#
+# Regression: a previous fix to deduplicate OTP-screen pushes (login_screen's
+# listener was firing twice on resend) accidentally blocked the second push
+# entirely when the user navigated back to S3a manually. Symptom: tapping
+# "kirim kode" the second time did nothing — request fired, but no /otp
+# navigation.
+#
+# Root cause: the listener guarded on `prev` state being non-OtpSentData,
+# which fails on retry because AsyncLoading retains the previous OtpSentData
+# value. Fix: guard on `GoRouterState.matchedLocation == '/login'` instead.
+#
+# This test exercises the full path: fresh login → /otp → back to S3a →
+# kirim kode again → /otp.
+appId: com.mybestie.mitra
+env:
+ TEST_PHONE: "+628200000601"
+ MITRA_DISPLAY_NAME: "Maestro Retry"
+ BACKEND_INTERNAL_URL: http://localhost:3001
+---
+- runScript:
+ file: ../scripts/seed_mitra.js
+ env:
+ TEST_PHONE: ${TEST_PHONE}
+ MITRA_DISPLAY_NAME: ${MITRA_DISPLAY_NAME}
+ IS_ACTIVE: "true"
+ BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
+- runScript:
+ file: ../scripts/reset_phone.js
+ env:
+ TEST_PHONE: ${TEST_PHONE}
+ BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
+
+- launchApp:
+ clearState: true
+- extendedWaitUntil:
+ visible:
+ text: "(?s).*Halo Mitra Bestie.*"
+ timeout: 15000
+
+# First request → arrives on S3b.
+- tapOn:
+ point: "60%, 47%"
+- inputText: "8200000601"
+- tapOn: "(?s).*kirim kode.*"
+- extendedWaitUntil:
+ visible:
+ text: "(?s).*masukin 6 digit kode.*"
+ timeout: 10000
+
+# Back to S3a via system back.
+- hideKeyboard
+- pressKey: Back
+- extendedWaitUntil:
+ visible:
+ text: "(?s).*Halo Mitra Bestie.*"
+ timeout: 8000
+
+# Reset rate-limit so the second OTP request isn't blocked by cooldown.
+- runScript:
+ file: ../scripts/reset_phone.js
+ env:
+ TEST_PHONE: ${TEST_PHONE}
+ BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
+
+# Second tap — phone field still holds the previous input, so just tap CTA.
+- tapOn: "(?s).*kirim kode.*"
+- extendedWaitUntil:
+ visible:
+ text: "(?s).*masukin 6 digit kode.*"
+ timeout: 10000
diff --git a/mitra_app/.maestro/scripts/peek_otp.js b/mitra_app/.maestro/scripts/peek_otp.js
new file mode 100644
index 0000000..85ee989
--- /dev/null
+++ b/mitra_app/.maestro/scripts/peek_otp.js
@@ -0,0 +1,13 @@
+// Read the latest stub-generated OTP code for TEST_PHONE from the
+// backend's dev-only /internal/_test/peek-otp endpoint.
+//
+// Writes the 6-digit code to output.OTP so the calling flow can use ${output.OTP}.
+const phone = TEST_PHONE
+const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
+const encoded = encodeURIComponent(phone)
+const resp = http.get(`${url}/internal/_test/peek-otp?phone=${encoded}`)
+if (resp.status !== 200) {
+ throw new Error(`peek-otp failed (${resp.status}): ${resp.body}`)
+}
+const data = json(resp.body)
+output.OTP = data.code
diff --git a/mitra_app/.maestro/scripts/reset_phone.js b/mitra_app/.maestro/scripts/reset_phone.js
new file mode 100644
index 0000000..00cc2c2
--- /dev/null
+++ b/mitra_app/.maestro/scripts/reset_phone.js
@@ -0,0 +1,12 @@
+// Wipe otp_requests rows for TEST_PHONE so repeated runs don't trip the 60s
+// cooldown or the 3/hour rate-limit. Does NOT drop the mitra row — mitras
+// are seeded separately via seed_mitra.js.
+const phone = TEST_PHONE
+const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
+const resp = http.post(`${url}/internal/_test/reset-phone`, {
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ phone }),
+})
+if (resp.status !== 200) {
+ throw new Error(`reset-phone failed (${resp.status}): ${resp.body}`)
+}
diff --git a/mitra_app/.maestro/scripts/seed_mitra.js b/mitra_app/.maestro/scripts/seed_mitra.js
new file mode 100644
index 0000000..8e9ab51
--- /dev/null
+++ b/mitra_app/.maestro/scripts/seed_mitra.js
@@ -0,0 +1,17 @@
+// Upsert a mitra with TEST_PHONE, MITRA_DISPLAY_NAME, and IS_ACTIVE.
+// Idempotent — safe to run on every test pass.
+//
+// Required env: TEST_PHONE, MITRA_DISPLAY_NAME
+// Optional env: IS_ACTIVE (defaults to "true"; pass "false" to seed an
+// inactive mitra for the ACCOUNT_INACTIVE flow).
+const phone = TEST_PHONE
+const displayName = MITRA_DISPLAY_NAME
+const isActive = (IS_ACTIVE ?? 'true') !== 'false'
+const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
+const resp = http.post(`${url}/internal/_test/seed-mitra`, {
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ phone, display_name: displayName, is_active: isActive }),
+})
+if (resp.status !== 200) {
+ throw new Error(`seed-mitra failed (${resp.status}): ${resp.body}`)
+}
diff --git a/mitra_app/assets/fonts/BricolageGrotesque-Variable.ttf b/mitra_app/assets/fonts/BricolageGrotesque-Variable.ttf
new file mode 100644
index 0000000..9a4ca27
Binary files /dev/null and b/mitra_app/assets/fonts/BricolageGrotesque-Variable.ttf differ
diff --git a/mitra_app/assets/fonts/JetBrainsMono-Variable.ttf b/mitra_app/assets/fonts/JetBrainsMono-Variable.ttf
new file mode 100644
index 0000000..aa310be
Binary files /dev/null and b/mitra_app/assets/fonts/JetBrainsMono-Variable.ttf differ
diff --git a/mitra_app/assets/fonts/Poppins-Bold.ttf b/mitra_app/assets/fonts/Poppins-Bold.ttf
new file mode 100644
index 0000000..1982f38
Binary files /dev/null and b/mitra_app/assets/fonts/Poppins-Bold.ttf differ
diff --git a/mitra_app/assets/fonts/Poppins-Medium.ttf b/mitra_app/assets/fonts/Poppins-Medium.ttf
new file mode 100644
index 0000000..a590f5c
Binary files /dev/null and b/mitra_app/assets/fonts/Poppins-Medium.ttf differ
diff --git a/mitra_app/assets/fonts/Poppins-Regular.ttf b/mitra_app/assets/fonts/Poppins-Regular.ttf
new file mode 100644
index 0000000..0bda228
Binary files /dev/null and b/mitra_app/assets/fonts/Poppins-Regular.ttf differ
diff --git a/mitra_app/assets/fonts/Poppins-SemiBold.ttf b/mitra_app/assets/fonts/Poppins-SemiBold.ttf
new file mode 100644
index 0000000..c30ad10
Binary files /dev/null and b/mitra_app/assets/fonts/Poppins-SemiBold.ttf differ
diff --git a/mitra_app/assets/fonts/README.md b/mitra_app/assets/fonts/README.md
new file mode 100644
index 0000000..5421108
--- /dev/null
+++ b/mitra_app/assets/fonts/README.md
@@ -0,0 +1,15 @@
+# HaloBestie font assets
+
+Stage 0 design-system fonts. All licensed under the SIL Open Font License.
+
+| File | Source |
+|-----------------------------------|--------|
+| `BricolageGrotesque-Variable.ttf` | https://github.com/google/fonts/tree/main/ofl/bricolagegrotesque |
+| `Poppins-Regular.ttf` | https://github.com/google/fonts/tree/main/ofl/poppins |
+| `Poppins-Medium.ttf` | https://github.com/google/fonts/tree/main/ofl/poppins |
+| `Poppins-SemiBold.ttf` | https://github.com/google/fonts/tree/main/ofl/poppins |
+| `Poppins-Bold.ttf` | https://github.com/google/fonts/tree/main/ofl/poppins |
+| `JetBrainsMono-Variable.ttf` | https://github.com/google/fonts/tree/main/ofl/jetbrainsmono |
+
+Wired into `client_app/pubspec.yaml` and consumed via `HaloTokens.fontDisplay`,
+`fontBody`, `fontMono` in `client_app/lib/core/theme/halo_tokens.dart`.
diff --git a/mitra_app/lib/core/auth/auth_notifier.dart b/mitra_app/lib/core/auth/auth_notifier.dart
index 94f57d7..6e1d30e 100644
--- a/mitra_app/lib/core/auth/auth_notifier.dart
+++ b/mitra_app/lib/core/auth/auth_notifier.dart
@@ -27,6 +27,83 @@ class MitraAuthOtpSentData extends MitraAuthData {
const MitraAuthOtpSentData(this.otpRequestId, {this.channelUsed});
}
+/// Structured error emitted by [MitraAuth] so screens can branch on
+/// the backend error code (popup vs snackbar vs full-screen state)
+/// instead of regex-matching message strings.
+///
+/// `toString()` returns [message] so any `Text(error.toString())` call
+/// site keeps working during the migration.
+class MitraAuthError implements Exception {
+ final String code;
+ final String message;
+ final int? retryAfterSeconds;
+
+ const MitraAuthError(this.code, this.message, {this.retryAfterSeconds});
+
+ @override
+ String toString() => message;
+}
+
+String _localizedRequestMessage(String? code, int? retryAfter) {
+ switch (code) {
+ case 'PHONE_INVALID':
+ return 'Nomor HP tidak valid.';
+ case 'OTP_COOLDOWN':
+ return retryAfter != null
+ ? 'Tunggu ${retryAfter}s sebelum minta OTP lagi.'
+ : 'Tunggu sebentar sebelum minta OTP lagi.';
+ case 'OTP_RATE_LIMIT_PHONE':
+ return 'Terlalu banyak permintaan OTP untuk nomor ini.';
+ case 'OTP_RATE_LIMIT_IP':
+ return 'Terlalu banyak permintaan OTP dari jaringan ini.';
+ default:
+ return '';
+ }
+}
+
+String _localizedVerifyMessage(String? code) {
+ switch (code) {
+ case 'CODE_INVALID':
+ return 'Kode harus 6 digit angka.';
+ case 'CODE_MISMATCH':
+ return 'Kode OTP salah.';
+ case 'OTP_EXPIRED':
+ return 'Kode OTP kedaluwarsa. Minta kode baru.';
+ case 'OTP_USED':
+ return 'Kode OTP sudah digunakan.';
+ case 'OTP_ATTEMPTS_EXCEEDED':
+ return 'Terlalu banyak percobaan. Minta kode baru.';
+ case 'WRONG_FLOW':
+ return 'OTP tidak valid untuk login mitra.';
+ case 'ACCOUNT_INACTIVE':
+ return 'Akun tidak aktif. Hubungi koordinator.';
+ default:
+ return '';
+ }
+}
+
+MitraAuthError _buildError(
+ DioException e,
+ String Function(String? code, int? retryAfter) localize,
+ String unknownMessage,
+) {
+ final err = e.response?.data is Map ? e.response!.data['error'] : null;
+ final code = err is Map ? err['code'] as String? : null;
+ final serverMessage = err is Map ? err['message'] as String? : null;
+ final details = err is Map ? err['details'] : null;
+ final retryAfter = details is Map ? details['retry_after_seconds'] as int? : null;
+
+ // Prefer Indonesian localized copy for codes we know. Fall back to the
+ // server message (which is English) only when the code is unrecognized,
+ // and to a generic Indonesian unknown-message when neither is available.
+ final localized = localize(code, retryAfter);
+ final message = localized.isNotEmpty
+ ? localized
+ : (serverMessage ?? unknownMessage);
+
+ return MitraAuthError(code ?? 'UNKNOWN', message, retryAfterSeconds: retryAfter);
+}
+
@Riverpod(keepAlive: true)
class MitraAuth extends _$MitraAuth {
final _storage = TokenStorage();
@@ -113,9 +190,15 @@ class MitraAuth extends _$MitraAuth {
channelUsed: data['channel_used'] as String?,
));
} on DioException catch (e) {
- state = AsyncError(_otpRequestMessage(e), StackTrace.current);
+ state = AsyncError(
+ _buildError(e, _localizedRequestMessage, 'Gagal mengirim OTP. Coba lagi.'),
+ StackTrace.current,
+ );
} catch (_) {
- state = AsyncError('Gagal mengirim OTP. Coba lagi.', StackTrace.current);
+ state = AsyncError(
+ const MitraAuthError('UNKNOWN', 'Gagal mengirim OTP. Coba lagi.'),
+ StackTrace.current,
+ );
}
}
@@ -136,9 +219,15 @@ class MitraAuth extends _$MitraAuth {
_bridge.setAccessToken(accessToken);
state = AsyncData(MitraAuthAuthenticatedData(profile));
} on DioException catch (e) {
- state = AsyncError(_otpVerifyMessage(e), StackTrace.current);
+ state = AsyncError(
+ _buildError(e, (code, _) => _localizedVerifyMessage(code), 'Gagal verifikasi. Coba lagi.'),
+ StackTrace.current,
+ );
} catch (_) {
- state = AsyncError('Gagal verifikasi. Coba lagi.', StackTrace.current);
+ state = AsyncError(
+ const MitraAuthError('UNKNOWN', 'Gagal verifikasi. Coba lagi.'),
+ StackTrace.current,
+ );
}
}
@@ -152,40 +241,4 @@ class MitraAuth extends _$MitraAuth {
state = const AsyncData(MitraAuthInitialData());
}
- String _otpRequestMessage(DioException e) {
- final code = e.response?.data?['error']?['code'] as String?;
- switch (code) {
- case 'PHONE_INVALID':
- return 'Nomor HP tidak valid.';
- case 'OTP_COOLDOWN':
- return e.response?.data?['error']?['message'] as String? ??
- 'Tunggu sebentar sebelum minta OTP lagi.';
- case 'OTP_RATE_LIMIT_PHONE':
- case 'OTP_RATE_LIMIT_IP':
- return 'Terlalu banyak permintaan OTP. Coba lagi nanti.';
- default:
- return 'Gagal mengirim OTP. Coba lagi.';
- }
- }
-
- String _otpVerifyMessage(DioException e) {
- final code = e.response?.data?['error']?['code'] as String?;
- switch (code) {
- case 'ACCOUNT_INACTIVE':
- return 'Akun tidak aktif. Hubungi administrator.';
- case 'WRONG_FLOW':
- return 'OTP tidak valid untuk login mitra.';
- case 'CODE_MISMATCH':
- case 'CODE_INVALID':
- return 'Kode OTP salah.';
- case 'OTP_EXPIRED':
- return 'Kode OTP kedaluwarsa. Minta kode baru.';
- case 'OTP_USED':
- return 'Kode OTP sudah digunakan.';
- case 'OTP_ATTEMPTS_EXCEEDED':
- return 'Terlalu banyak percobaan. Minta kode baru.';
- default:
- return 'Gagal verifikasi. Coba lagi.';
- }
- }
}
diff --git a/mitra_app/lib/core/theme/halo_theme.dart b/mitra_app/lib/core/theme/halo_theme.dart
new file mode 100644
index 0000000..edd037b
--- /dev/null
+++ b/mitra_app/lib/core/theme/halo_theme.dart
@@ -0,0 +1,316 @@
+import 'package:flutter/material.dart';
+import 'halo_tokens.dart';
+
+ThemeData haloThemeData() {
+ final base = ColorScheme.fromSeed(
+ seedColor: HaloTokens.brand,
+ brightness: Brightness.light,
+ );
+
+ final colorScheme = base.copyWith(
+ primary: HaloTokens.brand,
+ onPrimary: Colors.white,
+ primaryContainer: HaloTokens.brandSoft,
+ onPrimaryContainer: HaloTokens.brandDark,
+ secondary: HaloTokens.accent,
+ onSecondary: HaloTokens.ink,
+ secondaryContainer: HaloTokens.accentSoft,
+ onSecondaryContainer: HaloTokens.brandDark,
+ surface: HaloTokens.surface,
+ onSurface: HaloTokens.ink,
+ surfaceContainerHighest: HaloTokens.bg,
+ error: HaloTokens.danger,
+ onError: Colors.white,
+ outline: HaloTokens.border,
+ );
+
+ const textTheme = TextTheme(
+ displayLarge: TextStyle(
+ fontFamily: HaloTokens.fontDisplay,
+ fontSize: 36,
+ height: 40 / 36,
+ fontWeight: FontWeight.w700,
+ letterSpacing: -0.5,
+ color: HaloTokens.ink,
+ ),
+ displayMedium: TextStyle(
+ fontFamily: HaloTokens.fontDisplay,
+ fontSize: 30,
+ height: 34 / 30,
+ fontWeight: FontWeight.w700,
+ letterSpacing: -0.4,
+ color: HaloTokens.ink,
+ ),
+ displaySmall: TextStyle(
+ fontFamily: HaloTokens.fontDisplay,
+ fontSize: 26,
+ height: 30 / 26,
+ fontWeight: FontWeight.w700,
+ letterSpacing: -0.3,
+ color: HaloTokens.ink,
+ ),
+ headlineMedium: TextStyle(
+ fontFamily: HaloTokens.fontDisplay,
+ fontSize: 22,
+ height: 28 / 22,
+ fontWeight: FontWeight.w700,
+ color: HaloTokens.ink,
+ ),
+ titleLarge: TextStyle(
+ fontFamily: HaloTokens.fontDisplay,
+ fontSize: 22,
+ height: 28 / 22,
+ fontWeight: FontWeight.w700,
+ color: HaloTokens.ink,
+ ),
+ titleMedium: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 18,
+ height: 24 / 18,
+ fontWeight: FontWeight.w600,
+ color: HaloTokens.ink,
+ ),
+ titleSmall: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 15,
+ height: 22 / 15,
+ fontWeight: FontWeight.w600,
+ color: HaloTokens.ink,
+ ),
+ bodyLarge: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 16,
+ height: 24 / 16,
+ fontWeight: FontWeight.w400,
+ color: HaloTokens.ink,
+ ),
+ bodyMedium: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 15,
+ height: 22 / 15,
+ fontWeight: FontWeight.w400,
+ color: HaloTokens.ink,
+ ),
+ bodySmall: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 13,
+ height: 18 / 13,
+ fontWeight: FontWeight.w500,
+ color: HaloTokens.inkSoft,
+ ),
+ labelLarge: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 15,
+ height: 22 / 15,
+ fontWeight: FontWeight.w600,
+ color: HaloTokens.ink,
+ ),
+ labelMedium: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 12,
+ height: 16 / 12,
+ fontWeight: FontWeight.w500,
+ color: HaloTokens.inkSoft,
+ ),
+ labelSmall: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 10,
+ height: 14 / 10,
+ fontWeight: FontWeight.w600,
+ letterSpacing: 0.4,
+ color: HaloTokens.inkMuted,
+ ),
+ );
+
+ return ThemeData(
+ useMaterial3: true,
+ colorScheme: colorScheme,
+ scaffoldBackgroundColor: HaloTokens.bg,
+ textTheme: textTheme,
+ fontFamily: HaloTokens.fontBody,
+ appBarTheme: const AppBarTheme(
+ backgroundColor: HaloTokens.bg,
+ foregroundColor: HaloTokens.ink,
+ elevation: 0,
+ scrolledUnderElevation: 0,
+ centerTitle: false,
+ titleTextStyle: TextStyle(
+ fontFamily: HaloTokens.fontDisplay,
+ fontSize: 22,
+ height: 28 / 22,
+ fontWeight: FontWeight.w700,
+ color: HaloTokens.ink,
+ ),
+ ),
+ elevatedButtonTheme: ElevatedButtonThemeData(
+ style: ElevatedButton.styleFrom(
+ backgroundColor: HaloTokens.brand,
+ foregroundColor: Colors.white,
+ disabledBackgroundColor: HaloTokens.brandSoft,
+ disabledForegroundColor: HaloTokens.inkMuted,
+ elevation: 0,
+ shadowColor: const Color(0x59E17A9D),
+ padding: const EdgeInsets.symmetric(
+ horizontal: HaloSpacing.s24,
+ vertical: HaloSpacing.s16,
+ ),
+ shape: const RoundedRectangleBorder(borderRadius: HaloRadius.pill),
+ textStyle: const TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 15,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ outlinedButtonTheme: OutlinedButtonThemeData(
+ style: OutlinedButton.styleFrom(
+ foregroundColor: HaloTokens.brandDark,
+ side: const BorderSide(color: HaloTokens.border),
+ padding: const EdgeInsets.symmetric(
+ horizontal: HaloSpacing.s24,
+ vertical: HaloSpacing.s16,
+ ),
+ shape: const RoundedRectangleBorder(borderRadius: HaloRadius.pill),
+ textStyle: const TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 15,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ textButtonTheme: TextButtonThemeData(
+ style: TextButton.styleFrom(
+ foregroundColor: HaloTokens.brandDark,
+ padding: const EdgeInsets.symmetric(
+ horizontal: HaloSpacing.s16,
+ vertical: HaloSpacing.s12,
+ ),
+ shape: const RoundedRectangleBorder(borderRadius: HaloRadius.pill),
+ textStyle: const TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 15,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ inputDecorationTheme: const InputDecorationTheme(
+ filled: true,
+ fillColor: HaloTokens.surface,
+ contentPadding: EdgeInsets.symmetric(
+ horizontal: HaloSpacing.s20,
+ vertical: HaloSpacing.s20,
+ ),
+ constraints: BoxConstraints(minHeight: 64),
+ hintStyle: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 15,
+ color: HaloTokens.inkMuted,
+ ),
+ labelStyle: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 13,
+ color: HaloTokens.inkSoft,
+ ),
+ border: OutlineInputBorder(
+ borderRadius: HaloRadius.lg,
+ borderSide: BorderSide(color: HaloTokens.border),
+ ),
+ enabledBorder: OutlineInputBorder(
+ borderRadius: HaloRadius.lg,
+ borderSide: BorderSide(color: HaloTokens.border),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderRadius: HaloRadius.lg,
+ borderSide: BorderSide(color: HaloTokens.brand, width: 2),
+ ),
+ errorBorder: OutlineInputBorder(
+ borderRadius: HaloRadius.lg,
+ borderSide: BorderSide(color: HaloTokens.danger),
+ ),
+ focusedErrorBorder: OutlineInputBorder(
+ borderRadius: HaloRadius.lg,
+ borderSide: BorderSide(color: HaloTokens.danger, width: 2),
+ ),
+ ),
+ bottomSheetTheme: const BottomSheetThemeData(
+ backgroundColor: HaloTokens.surface,
+ surfaceTintColor: HaloTokens.surface,
+ modalBackgroundColor: HaloTokens.surface,
+ modalBarrierColor: Color(0x66000000),
+ elevation: 0,
+ modalElevation: 0,
+ showDragHandle: true,
+ dragHandleColor: HaloTokens.brandSoft,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.only(
+ topLeft: Radius.circular(24),
+ topRight: Radius.circular(24),
+ ),
+ ),
+ clipBehavior: Clip.antiAlias,
+ ),
+ dialogTheme: const DialogThemeData(
+ backgroundColor: HaloTokens.surface,
+ surfaceTintColor: HaloTokens.surface,
+ elevation: 0,
+ shape: RoundedRectangleBorder(borderRadius: HaloRadius.xl),
+ titleTextStyle: TextStyle(
+ fontFamily: HaloTokens.fontDisplay,
+ fontSize: 22,
+ fontWeight: FontWeight.w700,
+ color: HaloTokens.ink,
+ ),
+ contentTextStyle: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 15,
+ height: 22 / 15,
+ color: HaloTokens.inkSoft,
+ ),
+ ),
+ snackBarTheme: const SnackBarThemeData(
+ behavior: SnackBarBehavior.floating,
+ backgroundColor: HaloTokens.ink,
+ contentTextStyle: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 14,
+ fontWeight: FontWeight.w500,
+ color: Colors.white,
+ ),
+ shape: RoundedRectangleBorder(borderRadius: HaloRadius.pill),
+ elevation: 4,
+ insetPadding: EdgeInsets.symmetric(
+ horizontal: HaloSpacing.s16,
+ vertical: HaloSpacing.s12,
+ ),
+ actionTextColor: HaloTokens.brandSoft,
+ ),
+ chipTheme: ChipThemeData(
+ backgroundColor: HaloTokens.surface,
+ selectedColor: HaloTokens.brand,
+ disabledColor: HaloTokens.brandSoft.withValues(alpha: 0.5),
+ labelStyle: const TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 14,
+ fontWeight: FontWeight.w500,
+ color: HaloTokens.ink,
+ ),
+ secondaryLabelStyle: const TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 14,
+ fontWeight: FontWeight.w500,
+ color: Colors.white,
+ ),
+ padding: const EdgeInsets.symmetric(
+ horizontal: HaloSpacing.s16,
+ vertical: HaloSpacing.s8,
+ ),
+ side: const BorderSide(color: HaloTokens.border),
+ shape: const RoundedRectangleBorder(borderRadius: HaloRadius.pill),
+ ),
+ dividerTheme: const DividerThemeData(
+ color: HaloTokens.border,
+ thickness: 1,
+ space: 1,
+ ),
+ );
+}
diff --git a/mitra_app/lib/core/theme/halo_tokens.dart b/mitra_app/lib/core/theme/halo_tokens.dart
new file mode 100644
index 0000000..5b456a0
--- /dev/null
+++ b/mitra_app/lib/core/theme/halo_tokens.dart
@@ -0,0 +1,132 @@
+import 'package:flutter/material.dart';
+
+/// Design tokens for the HaloBestie warm palette.
+///
+/// Mirrors `requirement/Figma/handoff/tokens.json`. Three palettes
+/// (warm/calm/playful) exist in the source-of-truth JSON; only `warm`
+/// ships in code today — the others are stubbed for phase 5.
+///
+/// Naming convention: every token prefixed with `Halo*` and grouped into
+/// purpose classes (`HaloTokens` for colors, `HaloSpacing`, `HaloRadius`,
+/// `HaloMotion`, `HaloShadows`).
+class HaloTokens {
+ const HaloTokens._();
+
+ // Warm palette — default.
+ static const Color bg = Color(0xFFFDF7F4);
+ static const Color surface = Color(0xFFFFFFFF);
+ static const Color ink = Color(0xFF2A1820);
+ static const Color inkSoft = Color(0xFF6B5560);
+ static const Color inkMuted = Color(0xFF9C8590);
+ static const Color brand = Color(0xFFE17A9D);
+ static const Color brandDark = Color(0xFF8C3255);
+ static const Color brandSoft = Color(0xFFF7E4E9);
+ static const Color brandSofter = Color(0xFFFBEFF3);
+ // Launcher-icon background. Use this pink behind monochrome/white logos.
+ // For full-color logos, use `surface` (#FFFFFF) as the icon background.
+ static const Color brandLogoBg = Color(0xFFFF699F);
+ static const Color accent = Color(0xFFF7B26A);
+ static const Color accentSoft = Color(0xFFFCEAD3);
+ static const Color mint = Color(0xFFB8DBC8);
+ static const Color lilac = Color(0xFFD4C5E8);
+ static const Color success = Color(0xFF5BA67F);
+ static const Color danger = Color(0xFFD86B6B);
+ static const Color border = Color(0xFFF0E4E8);
+
+ // Font family names — must match the `family:` entries in pubspec.yaml.
+ // Falls back to system fonts when the .ttf assets are not bundled.
+ static const String fontDisplay = 'BricolageGrotesque';
+ static const String fontBody = 'Poppins';
+ static const String fontMono = 'JetBrainsMono';
+
+ // TODO: phase5 — calm palette
+ // static const Color calmBg = Color(0xFFF6F4F8);
+ // static const Color calmBrand = Color(0xFF9B8BC4);
+ // ...
+
+ // TODO: phase5 — playful palette
+ // static const Color playfulBg = Color(0xFFFFF5F8);
+ // static const Color playfulBrand = Color(0xFFFF69A0);
+ // ...
+}
+
+class HaloSpacing {
+ const HaloSpacing._();
+
+ static const double s0 = 0;
+ static const double s4 = 4;
+ static const double s8 = 8;
+ static const double s12 = 12;
+ static const double s16 = 16;
+ static const double s20 = 20;
+ static const double s24 = 24;
+ static const double s32 = 32;
+ static const double s40 = 40;
+ static const double s48 = 48;
+ static const double s64 = 64;
+ static const double s80 = 80;
+}
+
+class HaloRadius {
+ const HaloRadius._();
+
+ static const Radius _sm = Radius.circular(8);
+ static const Radius _md = Radius.circular(12);
+ static const Radius _lg = Radius.circular(16);
+ static const Radius _xl = Radius.circular(22);
+ static const Radius _pill = Radius.circular(9999);
+
+ static const BorderRadius sm = BorderRadius.all(_sm);
+ static const BorderRadius md = BorderRadius.all(_md);
+ static const BorderRadius lg = BorderRadius.all(_lg);
+ static const BorderRadius xl = BorderRadius.all(_xl);
+ static const BorderRadius pill = BorderRadius.all(_pill);
+}
+
+class HaloMotion {
+ const HaloMotion._();
+
+ static const Duration fast = Duration(milliseconds: 180);
+ static const Duration normal = Duration(milliseconds: 280);
+ static const Duration slow = Duration(milliseconds: 420);
+
+ static const Cubic ease = Cubic(0.2, 0.8, 0.2, 1);
+}
+
+class HaloShadows {
+ const HaloShadows._();
+
+ static const List soft = [
+ BoxShadow(
+ color: Color(0x0A8C3255),
+ offset: Offset(0, 1),
+ blurRadius: 2,
+ ),
+ BoxShadow(
+ color: Color(0x0F8C3255),
+ offset: Offset(0, 8),
+ blurRadius: 24,
+ ),
+ ];
+
+ static const List card = [
+ BoxShadow(
+ color: Color(0x0D8C3255),
+ offset: Offset(0, 2),
+ blurRadius: 6,
+ ),
+ BoxShadow(
+ color: Color(0x1A8C3255),
+ offset: Offset(0, 18),
+ blurRadius: 40,
+ ),
+ ];
+
+ static const List button = [
+ BoxShadow(
+ color: Color(0x59E17A9D),
+ offset: Offset(0, 4),
+ blurRadius: 14,
+ ),
+ ];
+}
diff --git a/mitra_app/lib/core/theme/widgets/halo_button.dart b/mitra_app/lib/core/theme/widgets/halo_button.dart
new file mode 100644
index 0000000..65c5a9a
--- /dev/null
+++ b/mitra_app/lib/core/theme/widgets/halo_button.dart
@@ -0,0 +1,152 @@
+import 'package:flutter/material.dart';
+import '../halo_tokens.dart';
+
+enum HaloButtonVariant { primary, secondary, ghost }
+
+enum HaloButtonSize { sm, md, lg }
+
+class HaloButton extends StatelessWidget {
+ const HaloButton({
+ super.key,
+ required this.label,
+ required this.onPressed,
+ this.variant = HaloButtonVariant.primary,
+ this.size = HaloButtonSize.md,
+ this.icon,
+ this.fullWidth = false,
+ });
+
+ final String label;
+ final VoidCallback? onPressed;
+ final HaloButtonVariant variant;
+ final HaloButtonSize size;
+ final Widget? icon;
+ final bool fullWidth;
+
+ @override
+ Widget build(BuildContext context) {
+ final disabled = onPressed == null;
+ final padding = _padding();
+ final fontSize = _fontSize();
+ const shape = RoundedRectangleBorder(borderRadius: HaloRadius.pill);
+ final textStyle = TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: fontSize,
+ fontWeight: FontWeight.w600,
+ );
+
+ Widget child = _content(textStyle);
+
+ Widget button;
+ switch (variant) {
+ case HaloButtonVariant.primary:
+ button = Container(
+ decoration: disabled
+ ? null
+ : const BoxDecoration(
+ borderRadius: HaloRadius.pill,
+ boxShadow: HaloShadows.button,
+ ),
+ child: ElevatedButton(
+ onPressed: onPressed,
+ style: ElevatedButton.styleFrom(
+ backgroundColor: HaloTokens.brand,
+ foregroundColor: Colors.white,
+ disabledBackgroundColor: HaloTokens.brandSoft,
+ disabledForegroundColor: HaloTokens.inkMuted,
+ elevation: 0,
+ padding: padding,
+ shape: shape,
+ textStyle: textStyle,
+ ),
+ child: child,
+ ),
+ );
+ break;
+ case HaloButtonVariant.secondary:
+ button = OutlinedButton(
+ onPressed: onPressed,
+ style: OutlinedButton.styleFrom(
+ foregroundColor: HaloTokens.brandDark,
+ disabledForegroundColor: HaloTokens.inkMuted,
+ backgroundColor: HaloTokens.surface,
+ side: BorderSide(
+ color: disabled ? HaloTokens.border : HaloTokens.brandSoft,
+ ),
+ padding: padding,
+ shape: shape,
+ textStyle: textStyle,
+ ),
+ child: child,
+ );
+ break;
+ case HaloButtonVariant.ghost:
+ button = TextButton(
+ onPressed: onPressed,
+ style: TextButton.styleFrom(
+ foregroundColor: HaloTokens.brandDark,
+ disabledForegroundColor: HaloTokens.inkMuted,
+ padding: padding,
+ shape: shape,
+ textStyle: textStyle,
+ ),
+ child: child,
+ );
+ break;
+ }
+
+ if (fullWidth) {
+ return SizedBox(width: double.infinity, child: button);
+ }
+ return button;
+ }
+
+ Widget _content(TextStyle textStyle) {
+ if (icon == null) {
+ return Text(label, style: textStyle);
+ }
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ IconTheme(
+ data: IconThemeData(size: textStyle.fontSize! + 2),
+ child: icon!,
+ ),
+ const SizedBox(width: HaloSpacing.s8),
+ Text(label, style: textStyle),
+ ],
+ );
+ }
+
+ EdgeInsets _padding() {
+ switch (size) {
+ case HaloButtonSize.sm:
+ return const EdgeInsets.symmetric(
+ horizontal: HaloSpacing.s16,
+ vertical: HaloSpacing.s8,
+ );
+ case HaloButtonSize.md:
+ return const EdgeInsets.symmetric(
+ horizontal: HaloSpacing.s24,
+ vertical: HaloSpacing.s12,
+ );
+ case HaloButtonSize.lg:
+ return const EdgeInsets.symmetric(
+ horizontal: HaloSpacing.s32,
+ vertical: HaloSpacing.s16,
+ );
+ }
+ }
+
+ double _fontSize() {
+ switch (size) {
+ case HaloButtonSize.sm:
+ return 13;
+ case HaloButtonSize.md:
+ return 15;
+ case HaloButtonSize.lg:
+ return 16;
+ }
+ }
+}
diff --git a/mitra_app/lib/core/theme/widgets/widgets.dart b/mitra_app/lib/core/theme/widgets/widgets.dart
new file mode 100644
index 0000000..beff1d8
--- /dev/null
+++ b/mitra_app/lib/core/theme/widgets/widgets.dart
@@ -0,0 +1 @@
+export 'halo_button.dart';
diff --git a/mitra_app/lib/features/auth/screens/account_inactive_screen.dart b/mitra_app/lib/features/auth/screens/account_inactive_screen.dart
new file mode 100644
index 0000000..f0e2acb
--- /dev/null
+++ b/mitra_app/lib/features/auth/screens/account_inactive_screen.dart
@@ -0,0 +1,83 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+import '../../../core/auth/auth_notifier.dart';
+import '../../../core/theme/halo_tokens.dart';
+import '../../../core/theme/widgets/widgets.dart';
+
+/// Terminal state shown when OTP verification succeeds but the mitra's
+/// account is not yet approved (`ACCOUNT_INACTIVE` 403 from the backend).
+///
+/// Mitras are onboarded internally and reach out via their existing
+/// internal channel — no public WhatsApp/Telegram CTAs here.
+class AccountInactiveScreen extends ConsumerWidget {
+ const AccountInactiveScreen({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ return PopScope(
+ canPop: false,
+ child: Scaffold(
+ backgroundColor: HaloTokens.bg,
+ body: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(28, 8, 28, 28),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const Expanded(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Center(
+ child: Text(
+ '⏳',
+ style: TextStyle(fontSize: 56),
+ ),
+ ),
+ SizedBox(height: 24),
+ Text(
+ 'akun belum aktif',
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ fontFamily: HaloTokens.fontDisplay,
+ fontSize: 28,
+ fontWeight: FontWeight.w700,
+ color: HaloTokens.brandDark,
+ height: 1.15,
+ letterSpacing: -0.56,
+ ),
+ ),
+ SizedBox(height: 12),
+ Text(
+ 'tim halobestie sedang memverifikasi akun kamu. '
+ 'hubungi koordinator kalau butuh update.',
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 14.5,
+ color: HaloTokens.inkSoft,
+ height: 1.5,
+ ),
+ ),
+ ],
+ ),
+ ),
+ HaloButton(
+ label: 'pakai nomor lain',
+ variant: HaloButtonVariant.secondary,
+ fullWidth: true,
+ onPressed: () async {
+ await ref.read(mitraAuthProvider.notifier).logout();
+ if (context.mounted) context.go('/login');
+ },
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/mitra_app/lib/features/auth/screens/login_screen.dart b/mitra_app/lib/features/auth/screens/login_screen.dart
index a929e1a..80c3303 100644
--- a/mitra_app/lib/features/auth/screens/login_screen.dart
+++ b/mitra_app/lib/features/auth/screens/login_screen.dart
@@ -1,8 +1,20 @@
+import 'dart:async';
+
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_notifier.dart';
+import '../../../core/theme/halo_tokens.dart';
+import '../../../core/theme/widgets/widgets.dart';
+/// S3a · Input WhatsApp (mitra).
+///
+/// Visual contract mirrors `figma-bestie/project/screens/onboarding.jsx::S3Phone`
+/// (first half — phone-input view). Differences from the customer S3a:
+/// - Greeting heading "Halo Mitra Bestie" (no name-set step pre-OTP).
+/// - No "lanjut tanpa verifikasi" footer (mitra has no anonymous path).
+/// - Privacy reassurance card is omitted (audience is internal).
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@@ -12,61 +24,242 @@ class LoginScreen extends ConsumerStatefulWidget {
class _LoginScreenState extends ConsumerState {
final _phoneController = TextEditingController();
+ ProviderSubscription>? _authSub;
+
+ String? _phoneErrorText;
+
+ // Server-imposed lockout from /otp/request 429s. Drives both the CTA's
+ // disabled state and its label so the mitra sees a live countdown.
+ int _lockoutSeconds = 0;
+ Timer? _lockoutTimer;
+
+ @override
+ void initState() {
+ super.initState();
+ _phoneController.addListener(() {
+ // Re-render so the +62 pill border + CTA enabled-state respond to
+ // the user typing.
+ setState(() {});
+ });
+
+ _authSub = ref.listenManual>(mitraAuthProvider,
+ (prev, next) async {
+ if (!mounted) return;
+ final data = next.valueOrNull;
+ // Push to /otp only when the *current top route* is /login. This
+ // protects against the OtpScreen's resend stacking a second /otp on
+ // top of itself (login_screen's listener stays alive on the nav stack
+ // and would otherwise fire on every fresh MitraAuthOtpSentData).
+ if (data is MitraAuthOtpSentData) {
+ final location = GoRouterState.of(context).matchedLocation;
+ if (location == '/login') {
+ context.push('/otp', extra: _e164Phone());
+ }
+ return;
+ }
+ if (next is! AsyncError) return;
+
+ final err = next.error;
+ if (err is! MitraAuthError) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text(err.toString())),
+ );
+ return;
+ }
+
+ switch (err.code) {
+ case 'PHONE_INVALID':
+ setState(() => _phoneErrorText = err.message);
+ break;
+ case 'OTP_COOLDOWN':
+ if (err.retryAfterSeconds != null) {
+ _startLockout(err.retryAfterSeconds!);
+ }
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text(err.message)),
+ );
+ break;
+ case 'OTP_RATE_LIMIT_PHONE':
+ if (err.retryAfterSeconds != null) {
+ _startLockout(err.retryAfterSeconds!);
+ }
+ await _showRateLimitDialog(err, isIp: false);
+ break;
+ case 'OTP_RATE_LIMIT_IP':
+ if (err.retryAfterSeconds != null) {
+ _startLockout(err.retryAfterSeconds!);
+ }
+ await _showRateLimitDialog(err, isIp: true);
+ break;
+ default:
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text(err.message)),
+ );
+ }
+ });
+ }
@override
void dispose() {
+ _authSub?.close();
+ _lockoutTimer?.cancel();
_phoneController.dispose();
super.dispose();
}
+ void _startLockout(int seconds) {
+ _lockoutTimer?.cancel();
+ setState(() => _lockoutSeconds = seconds);
+ _lockoutTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
+ if (!mounted) {
+ timer.cancel();
+ return;
+ }
+ setState(() {
+ if (_lockoutSeconds > 0) _lockoutSeconds--;
+ if (_lockoutSeconds <= 0) timer.cancel();
+ });
+ });
+ }
+
+ /// Subscriber digits with any country-code / leading-zero noise stripped.
+ /// Accepts these user-typed formats (all normalize to `8xxxxxxxxx`):
+ /// `8xxxxxxxxx` — subscriber only
+ /// `08xxxxxxxxx` — local format with leading 0
+ /// `628xxxxxxxxx` — country code without +
+ /// `+628xxxxxxxxx` — full E.164
+ /// `0628xxxxxxxxx` — typo combo (rare but seen)
+ ///
+ /// Strategy: strip non-digits, strip leading zeros, strip leading `62`.
+ /// Indonesian mobile subscriber numbers always start with `8`, so a
+ /// leading `62` after the zero-strip is always the country code.
+ String _subscriberDigits() {
+ var digits = _phoneController.text.replaceAll(RegExp(r'\D'), '');
+ digits = digits.replaceFirst(RegExp(r'^0+'), '');
+ if (digits.startsWith('62')) {
+ digits = digits.substring(2);
+ }
+ return digits;
+ }
+
+ String _e164Phone() => '+62${_subscriberDigits()}';
+
+ String _formatCountdown(int seconds) {
+ if (seconds < 60) return '${seconds}s';
+ final mins = seconds ~/ 60;
+ final secs = seconds % 60;
+ return '${mins}m ${secs.toString().padLeft(2, '0')}s';
+ }
+
+ Future _submit() {
+ final phone = _e164Phone();
+ setState(() => _phoneErrorText = null);
+ return ref.read(mitraAuthProvider.notifier).requestOtp(phone);
+ }
+
+ Future _showRateLimitDialog(MitraAuthError err, {required bool isIp}) {
+ final retryText = err.retryAfterSeconds != null
+ ? '\n\nCoba lagi dalam ${_formatCountdown(err.retryAfterSeconds!)}.'
+ : '';
+ return showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: Text(isIp
+ ? 'Terlalu banyak permintaan dari jaringan ini'
+ : 'Terlalu banyak permintaan untuk nomor ini'),
+ content: Text('${err.message}$retryText'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(ctx).pop(),
+ child: const Text('Tutup'),
+ ),
+ ],
+ ),
+ );
+ }
+
@override
Widget build(BuildContext context) {
final authState = ref.watch(mitraAuthProvider);
final isLoading = authState is AsyncLoading;
-
- ref.listen(mitraAuthProvider, (prev, next) {
- final data = next.valueOrNull;
- if (data is MitraAuthOtpSentData) {
- context.push('/otp', extra: _phoneController.text.trim());
- }
- if (next is AsyncError) {
- ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
- }
- });
+ final hasMinDigits = _subscriberDigits().length >= 9;
+ final isLockedOut = _lockoutSeconds > 0;
+ final canSubmit = hasMinDigits && !isLoading && !isLockedOut;
return Scaffold(
+ backgroundColor: HaloTokens.bg,
body: SafeArea(
child: Padding(
- padding: const EdgeInsets.all(24),
+ padding: const EdgeInsets.fromLTRB(28, 8, 28, 28),
child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- const Text(
- 'Halo Bestie Mitra',
- style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
- textAlign: TextAlign.center,
- ),
- const SizedBox(height: 48),
- TextField(
- controller: _phoneController,
- decoration: const InputDecoration(
- labelText: 'Nomor HP',
- hintText: '+628xxxxxxxxxx',
- border: OutlineInputBorder(),
+ Expanded(
+ child: LayoutBuilder(
+ builder: (context, constraints) => SingleChildScrollView(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(minHeight: constraints.maxHeight),
+ child: IntrinsicHeight(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const Text(
+ 'Halo Mitra Bestie',
+ style: TextStyle(
+ fontFamily: HaloTokens.fontDisplay,
+ fontSize: 28,
+ fontWeight: FontWeight.w700,
+ color: HaloTokens.brandDark,
+ height: 1.15,
+ letterSpacing: -0.56,
+ ),
+ ),
+ const SizedBox(height: 10),
+ const Text(
+ 'masukin nomor wa kamu untuk lanjut',
+ style: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 14.5,
+ color: HaloTokens.inkSoft,
+ height: 1.5,
+ ),
+ ),
+ const SizedBox(height: 24),
+ _PhoneRow(
+ controller: _phoneController,
+ borderColor: _phoneErrorText != null
+ ? HaloTokens.danger
+ : hasMinDigits
+ ? HaloTokens.brand
+ : HaloTokens.border,
+ ),
+ if (_phoneErrorText != null) ...[
+ const SizedBox(height: 8),
+ Text(
+ _phoneErrorText!,
+ style: const TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ color: HaloTokens.danger,
+ fontSize: 13,
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ ),
),
- keyboardType: TextInputType.phone,
),
- const SizedBox(height: 16),
- ElevatedButton(
- onPressed: isLoading ? null : () {
- final phone = _phoneController.text.trim();
- if (phone.isEmpty) return;
- ref.read(mitraAuthProvider.notifier).requestOtp(phone);
- },
- child: isLoading
- ? const CircularProgressIndicator()
- : const Text('Kirim OTP'),
+ HaloButton(
+ label: isLoading
+ ? 'memproses...'
+ : isLockedOut
+ ? 'coba lagi dalam ${_formatCountdown(_lockoutSeconds)}'
+ : 'kirim kode',
+ fullWidth: true,
+ onPressed: canSubmit ? _submit : null,
),
],
),
@@ -75,3 +268,75 @@ class _LoginScreenState extends ConsumerState {
);
}
}
+
+class _PhoneRow extends StatelessWidget {
+ final TextEditingController controller;
+ final Color borderColor;
+ const _PhoneRow({required this.controller, required this.borderColor});
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ height: 60,
+ padding: const EdgeInsets.symmetric(horizontal: 18),
+ decoration: BoxDecoration(
+ color: HaloTokens.surface,
+ borderRadius: HaloRadius.lg,
+ border: Border.all(color: borderColor, width: 1.5),
+ ),
+ child: Row(
+ children: [
+ const Text(
+ '🇮🇩 +62',
+ style: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 15,
+ fontWeight: FontWeight.w600,
+ color: HaloTokens.ink,
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: TextField(
+ controller: controller,
+ keyboardType: TextInputType.phone,
+ // Allow + alongside digits so users can paste/type +62...;
+ // _subscriberDigits() strips it during normalization.
+ inputFormatters: [
+ FilteringTextInputFormatter.allow(RegExp(r'[\d+]')),
+ ],
+ maxLength: 16,
+ style: const TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 16,
+ fontWeight: FontWeight.w500,
+ color: HaloTokens.ink,
+ ),
+ decoration: const InputDecoration(
+ hintText: '812 3456 7890',
+ hintStyle: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 16,
+ fontWeight: FontWeight.w500,
+ color: HaloTokens.inkMuted,
+ ),
+ // Override app-wide inputDecorationTheme so the input sits
+ // flush inside the outer pill — no fill, no border.
+ filled: false,
+ border: InputBorder.none,
+ enabledBorder: InputBorder.none,
+ focusedBorder: InputBorder.none,
+ errorBorder: InputBorder.none,
+ focusedErrorBorder: InputBorder.none,
+ disabledBorder: InputBorder.none,
+ isCollapsed: true,
+ counterText: '',
+ contentPadding: EdgeInsets.symmetric(vertical: 18),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mitra_app/lib/features/auth/screens/otp_screen.dart b/mitra_app/lib/features/auth/screens/otp_screen.dart
index ac2eaf9..08194ea 100644
--- a/mitra_app/lib/features/auth/screens/otp_screen.dart
+++ b/mitra_app/lib/features/auth/screens/otp_screen.dart
@@ -1,8 +1,22 @@
+import 'dart:async';
+
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_notifier.dart';
+import '../../../core/theme/halo_tokens.dart';
+import '../../../core/theme/widgets/widgets.dart';
+const int _kOtpLength = 6;
+const int _kMaxAttempts = 5;
+const int _kResendCooldownSeconds = 60;
+
+/// S3b · OTP verification (6-digit) for mitra.
+///
+/// Visual contract mirrors `figma-bestie/project/screens/onboarding.jsx::S3Phone`
+/// (OTP-step view) with the customer's 4 boxes scaled up to 6 (mitra uses
+/// 6-digit OTPs from Fazpass).
class OtpScreen extends ConsumerStatefulWidget {
final String phone;
const OtpScreen({super.key, required this.phone});
@@ -13,9 +27,20 @@ class OtpScreen extends ConsumerStatefulWidget {
class _OtpScreenState extends ConsumerState {
final List _controllers =
- List.generate(6, (_) => TextEditingController());
- final List _focusNodes = List.generate(6, (_) => FocusNode());
+ List.generate(_kOtpLength, (_) => TextEditingController());
+ final List _focusNodes =
+ List.generate(_kOtpLength, (_) => FocusNode());
+
String? _otpRequestId;
+ int _attemptsUsed = 0;
+ String? _inlineError;
+
+ Timer? _cooldownTicker;
+ int _cooldown = _kResendCooldownSeconds;
+
+ bool _isResending = false;
+ bool _dialogShown = false;
+ ProviderSubscription>? _authSub;
@override
void initState() {
@@ -24,10 +49,44 @@ class _OtpScreenState extends ConsumerState {
if (data is MitraAuthOtpSentData) {
_otpRequestId = data.otpRequestId;
}
+ _startCooldown();
+
+ _authSub = ref.listenManual>(mitraAuthProvider,
+ (prev, next) {
+ if (!mounted) return;
+
+ // Resend completed — the notifier emits a fresh MitraAuthOtpSentData
+ // with a new otp_request_id. Reset local state without bouncing back
+ // to S3a (which the router would otherwise do if we let the listener
+ // fire on the same OtpSentData state).
+ final data = next.valueOrNull;
+ if (data is MitraAuthOtpSentData && data.otpRequestId != _otpRequestId) {
+ _otpRequestId = data.otpRequestId;
+ setState(() {
+ _attemptsUsed = 0;
+ _inlineError = null;
+ });
+ _clearFieldsAndFocus();
+ _startCooldown();
+ return;
+ }
+
+ if (next is! AsyncError) return;
+ final err = next.error;
+ if (err is MitraAuthError) {
+ _handleAuthError(err);
+ } else {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text(err.toString())),
+ );
+ }
+ });
}
@override
void dispose() {
+ _authSub?.close();
+ _cooldownTicker?.cancel();
for (final c in _controllers) {
c.dispose();
}
@@ -37,108 +96,404 @@ class _OtpScreenState extends ConsumerState {
super.dispose();
}
+ void _startCooldown() {
+ _cooldownTicker?.cancel();
+ setState(() => _cooldown = _kResendCooldownSeconds);
+ _cooldownTicker = Timer.periodic(const Duration(seconds: 1), (timer) {
+ if (!mounted) {
+ timer.cancel();
+ return;
+ }
+ if (_cooldown <= 1) {
+ timer.cancel();
+ setState(() => _cooldown = 0);
+ } else {
+ setState(() => _cooldown -= 1);
+ }
+ });
+ }
+
String get _otp => _controllers.map((c) => c.text).join();
+ void _clearFieldsAndFocus() {
+ for (final c in _controllers) {
+ c.clear();
+ }
+ _focusNodes[0].requestFocus();
+ }
+
void _onChanged(int index, String value) {
- if (value.length == 1 && index < 5) {
+ if (value.isNotEmpty && index < _kOtpLength - 1) {
_focusNodes[index + 1].requestFocus();
}
- if (_otp.length == 6) {
+ if (value.isEmpty && index > 0) {
+ _focusNodes[index - 1].requestFocus();
+ }
+ if (_otp.length == _kOtpLength) {
_submit();
}
}
- void _onKeyDown(int index, KeyEvent event) {
+ KeyEventResult _onKeyEvent(int index, KeyEvent event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.backspace &&
_controllers[index].text.isEmpty &&
index > 0) {
_controllers[index - 1].clear();
_focusNodes[index - 1].requestFocus();
+ return KeyEventResult.handled;
}
+ return KeyEventResult.ignored;
}
void _submit() {
final otp = _otp;
- if (otp.length != 6 || _otpRequestId == null) return;
+ if (otp.length != _kOtpLength || _otpRequestId == null) return;
+ setState(() => _inlineError = null);
ref.read(mitraAuthProvider.notifier).verifyOtp(_otpRequestId!, otp);
}
+ Future _resend() async {
+ if (_cooldown > 0 || _isResending) return;
+ setState(() {
+ _isResending = true;
+ _inlineError = null;
+ });
+ await ref.read(mitraAuthProvider.notifier).requestOtp(widget.phone);
+ if (!mounted) return;
+ setState(() => _isResending = false);
+ }
+
+ Future _showBlockedDialog() {
+ return showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (ctx) => AlertDialog(
+ title: const Text('Terlalu banyak percobaan'),
+ content: const Text(
+ 'Kode OTP sudah salah terlalu banyak kali. Minta kode baru untuk lanjut.',
+ ),
+ actions: [
+ TextButton(
+ onPressed: () {
+ Navigator.of(ctx).pop();
+ if (mounted) context.pop();
+ },
+ child: const Text('Minta kode baru'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Future _showResetDialog(MitraAuthError err, {required String title}) {
+ return showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (ctx) => AlertDialog(
+ title: Text(title),
+ content: Text(err.message),
+ actions: [
+ TextButton(
+ onPressed: () {
+ Navigator.of(ctx).pop();
+ if (mounted) context.pop();
+ },
+ child: const Text('Minta kode baru'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Future _showWrongFlowDialog(MitraAuthError err) {
+ return showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (ctx) => AlertDialog(
+ title: const Text('Bukan akun mitra'),
+ content: Text(
+ '${err.message}\n\nPastikan kamu pakai aplikasi yang benar.',
+ ),
+ actions: [
+ TextButton(
+ onPressed: () {
+ Navigator.of(ctx).pop();
+ if (mounted) context.pop();
+ },
+ child: const Text('Kembali'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Future _handleAuthError(MitraAuthError err) async {
+ if (_dialogShown) return;
+ switch (err.code) {
+ case 'CODE_INVALID':
+ setState(() => _inlineError = err.message);
+ break;
+ case 'CODE_MISMATCH':
+ setState(() {
+ _attemptsUsed = (_attemptsUsed + 1).clamp(0, _kMaxAttempts);
+ final remaining = _kMaxAttempts - _attemptsUsed;
+ _inlineError = remaining > 0
+ ? 'Kode salah. Tersisa $remaining percobaan.'
+ : 'Kode salah.';
+ });
+ _clearFieldsAndFocus();
+ break;
+ case 'OTP_ATTEMPTS_EXCEEDED':
+ _dialogShown = true;
+ await _showBlockedDialog();
+ _dialogShown = false;
+ break;
+ case 'OTP_EXPIRED':
+ _dialogShown = true;
+ await _showResetDialog(err, title: 'Kode kedaluwarsa');
+ _dialogShown = false;
+ break;
+ case 'OTP_USED':
+ _dialogShown = true;
+ await _showResetDialog(err, title: 'Kode sudah dipakai');
+ _dialogShown = false;
+ break;
+ case 'WRONG_FLOW':
+ _dialogShown = true;
+ await _showWrongFlowDialog(err);
+ _dialogShown = false;
+ break;
+ case 'ACCOUNT_INACTIVE':
+ if (mounted) context.go('/auth/inactive');
+ break;
+ case 'OTP_COOLDOWN':
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text(err.message)),
+ );
+ break;
+ default:
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text(err.message)),
+ );
+ }
+ }
+
@override
Widget build(BuildContext context) {
final authState = ref.watch(mitraAuthProvider);
final isLoading = authState is AsyncLoading;
- // Update OTP request id if state changes (e.g. resend)
- final data = authState.valueOrNull;
- if (data is MitraAuthOtpSentData) {
- _otpRequestId = data.otpRequestId;
- }
-
- ref.listen(mitraAuthProvider, (prev, next) {
- if (next is AsyncError) {
- ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
- for (final c in _controllers) {
- c.clear();
- }
- _focusNodes[0].requestFocus();
- }
- });
+ final resendLabel = _cooldown > 0
+ ? 'kirim ulang dalam ${_cooldown}s'
+ : 'kirim ulang kode';
+ final resendEnabled = _cooldown == 0 && !_isResending && !isLoading;
return Scaffold(
- appBar: AppBar(title: const Text('Masukkan OTP')),
- body: Padding(
- padding: const EdgeInsets.all(24),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- Text(
- 'Kode OTP telah dikirim ke ${widget.phone}',
- textAlign: TextAlign.center,
- ),
- const SizedBox(height: 32),
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceEvenly,
- children: List.generate(6, (index) {
- return SizedBox(
- width: 48,
- child: KeyboardListener(
- focusNode: FocusNode(),
- onKeyEvent: (event) => _onKeyDown(index, event),
- child: TextField(
- controller: _controllers[index],
- focusNode: _focusNodes[index],
- textAlign: TextAlign.center,
- keyboardType: TextInputType.number,
- maxLength: 1,
- style: const TextStyle(
- fontSize: 24,
- fontWeight: FontWeight.bold,
+ backgroundColor: HaloTokens.bg,
+ appBar: AppBar(
+ backgroundColor: HaloTokens.bg,
+ leading: IconButton(
+ icon: const Icon(Icons.arrow_back, color: HaloTokens.ink),
+ onPressed: () => context.pop(),
+ ),
+ ),
+ body: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(28, 0, 28, 28),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Expanded(
+ child: LayoutBuilder(
+ builder: (context, constraints) => SingleChildScrollView(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(minHeight: constraints.maxHeight),
+ child: IntrinsicHeight(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const Text(
+ 'masukin 6 digit kode',
+ style: TextStyle(
+ fontFamily: HaloTokens.fontDisplay,
+ fontSize: 28,
+ fontWeight: FontWeight.w700,
+ color: HaloTokens.brandDark,
+ height: 1.15,
+ letterSpacing: -0.56,
+ ),
+ ),
+ const SizedBox(height: 10),
+ RichText(
+ text: TextSpan(
+ style: const TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 14.5,
+ color: HaloTokens.inkSoft,
+ height: 1.5,
+ ),
+ children: [
+ const TextSpan(text: 'kami baru kirim ke WA '),
+ TextSpan(
+ text: widget.phone,
+ style: const TextStyle(
+ fontWeight: FontWeight.w700,
+ color: HaloTokens.ink,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 28),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: List.generate(_kOtpLength, (i) {
+ return _OtpBox(
+ controller: _controllers[i],
+ focusNode: _focusNodes[i],
+ autofocus: i == 0,
+ onChanged: (v) => _onChanged(i, v),
+ onKeyEvent: (e) => _onKeyEvent(i, e),
+ hasError: _inlineError != null,
+ );
+ }),
+ ),
+ if (_inlineError != null) ...[
+ const SizedBox(height: 12),
+ Text(
+ _inlineError!,
+ textAlign: TextAlign.center,
+ style: const TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ color: HaloTokens.danger,
+ fontSize: 13,
+ ),
+ ),
+ ],
+ const SizedBox(height: 20),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Text(
+ 'gak nyampe? ',
+ style: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 13,
+ color: HaloTokens.inkSoft,
+ ),
+ ),
+ GestureDetector(
+ onTap: resendEnabled ? _resend : null,
+ child: Text(
+ resendLabel,
+ style: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 13,
+ fontWeight: FontWeight.w600,
+ color: resendEnabled
+ ? HaloTokens.brandDark
+ : HaloTokens.inkMuted,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
),
- decoration: const InputDecoration(
- counterText: '',
- border: OutlineInputBorder(),
- contentPadding: EdgeInsets.symmetric(vertical: 14),
- ),
- inputFormatters: [
- FilteringTextInputFormatter.digitsOnly,
- ],
- onChanged: (value) => _onChanged(index, value),
),
),
- );
- }),
- ),
- const SizedBox(height: 32),
- ElevatedButton(
- onPressed: isLoading ? null : _submit,
- child: isLoading
- ? const CircularProgressIndicator()
- : const Text('Verifikasi'),
- ),
- ],
+ ),
+ ),
+ HaloButton(
+ label: isLoading ? 'memproses...' : 'verifikasi',
+ fullWidth: true,
+ onPressed: (isLoading || _otp.length != _kOtpLength) ? null : _submit,
+ ),
+ ],
+ ),
),
),
);
}
}
+
+class _OtpBox extends StatelessWidget {
+ final TextEditingController controller;
+ final FocusNode focusNode;
+ final bool autofocus;
+ final ValueChanged onChanged;
+ // Use `Focus(canRequestFocus: false)` not `KeyboardListener` — the latter
+ // spawns an extra FocusNode that swallows IME input on Android, which
+ // breaks both maestro `inputText` and SMS auto-paste. Matches the customer
+ // app pattern in client_app/.../otp_screen.dart::_buildBox.
+ final KeyEventResult Function(KeyEvent) onKeyEvent;
+ final bool hasError;
+
+ const _OtpBox({
+ required this.controller,
+ required this.focusNode,
+ required this.autofocus,
+ required this.onChanged,
+ required this.onKeyEvent,
+ required this.hasError,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final filled = controller.text.isNotEmpty;
+ final borderColor = hasError
+ ? HaloTokens.danger
+ : filled
+ ? HaloTokens.brand
+ : HaloTokens.border;
+ return SizedBox(
+ width: 48,
+ height: 60,
+ child: Focus(
+ canRequestFocus: false,
+ onKeyEvent: (_, event) => onKeyEvent(event),
+ child: TextField(
+ controller: controller,
+ focusNode: focusNode,
+ autofocus: autofocus,
+ textAlign: TextAlign.center,
+ keyboardType: TextInputType.number,
+ maxLength: 1,
+ style: const TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 24,
+ fontWeight: FontWeight.w700,
+ color: HaloTokens.ink,
+ ),
+ decoration: InputDecoration(
+ counterText: '',
+ filled: true,
+ fillColor: HaloTokens.surface,
+ contentPadding: const EdgeInsets.symmetric(vertical: 14),
+ border: OutlineInputBorder(
+ borderRadius: HaloRadius.md,
+ borderSide: BorderSide(color: borderColor, width: 1.5),
+ ),
+ enabledBorder: OutlineInputBorder(
+ borderRadius: HaloRadius.md,
+ borderSide: BorderSide(color: borderColor, width: 1.5),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderRadius: HaloRadius.md,
+ borderSide: BorderSide(
+ color: hasError ? HaloTokens.danger : HaloTokens.brand,
+ width: 2,
+ ),
+ ),
+ ),
+ inputFormatters: [FilteringTextInputFormatter.digitsOnly],
+ onChanged: onChanged,
+ ),
+ ),
+ );
+ }
+}
+
diff --git a/mitra_app/lib/features/home/home_screen.dart b/mitra_app/lib/features/home/home_screen.dart
index a1a6c74..81bd2a0 100644
--- a/mitra_app/lib/features/home/home_screen.dart
+++ b/mitra_app/lib/features/home/home_screen.dart
@@ -4,8 +4,12 @@ import 'package:go_router/go_router.dart';
import '../../core/auth/auth_notifier.dart';
import '../../core/status/status_notifier.dart';
import '../../core/chat/chat_request_notifier.dart';
-import '../../core/chat/unread_notifier.dart';
+import '../../core/theme/halo_tokens.dart';
+import '../../core/theme/widgets/widgets.dart';
+/// Bestie Home (mitra). Mirrors `figma-bestie/project/screens/v4.jsx::BestieHome`
+/// + `v5.jsx::BestieHomeOffline`. Bottom nav (BestieTabBar) is deferred until
+/// the Profil + Chat tabs have screen implementations.
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@@ -14,11 +18,13 @@ class HomeScreen extends ConsumerWidget {
final authState = ref.watch(mitraAuthProvider);
final authData = authState.valueOrNull;
final displayName = authData is MitraAuthAuthenticatedData
- ? authData.profile['display_name'] as String
- : '';
+ ? (authData.profile['display_name'] as String? ?? 'Bestie')
+ : 'Bestie';
- // Load pending requests if mitra is already online
final statusState = ref.watch(onlineStatusProvider);
+ final isOnline = statusState is StatusLoadedData && statusState.isOnline;
+
+ // Load pending requests if mitra is already online (existing logic).
if (statusState is StatusLoadedData && statusState.isOnline) {
final requestState = ref.watch(chatRequestProvider);
if (requestState is ChatRequestIdleData) {
@@ -29,7 +35,6 @@ class HomeScreen extends ConsumerWidget {
}
}
- // Listen for status changes to start/stop chat request listening
ref.listen(onlineStatusProvider, (prev, next) {
if (next is StatusLoadedData && next.isOnline) {
ref.read(chatRequestProvider.notifier).startListening();
@@ -40,119 +45,153 @@ class HomeScreen extends ConsumerWidget {
});
return Scaffold(
- appBar: AppBar(
- title: const Text('Halo Bestie Mitra'),
- actions: [
- IconButton(
- icon: const Icon(Icons.logout),
- onPressed: () => ref.read(mitraAuthProvider.notifier).logout(),
+ backgroundColor: HaloTokens.bg,
+ body: SafeArea(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.fromLTRB(20, 20, 20, 28),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _Header(displayName: displayName, isOnline: isOnline),
+ const SizedBox(height: 18),
+ const _TilesGrid(),
+ const SizedBox(height: 14),
+ _StatusCard(isOnline: isOnline),
+ const SizedBox(height: 10),
+ const _GantiStatusButton(),
+ const SizedBox(height: 22),
+ const _Pengingat(),
+ const SizedBox(height: 16),
+ // Functional shortcuts (no figma equivalent — kept until the
+ // Chat tab is built so the user can still reach sessions /
+ // history pages from home).
+ const _ShortcutTile(
+ icon: Icons.chat_bubble_outline,
+ title: 'Sesi Aktif',
+ route: '/sessions',
+ ),
+ const SizedBox(height: 8),
+ const _ShortcutTile(
+ icon: Icons.history,
+ title: 'Riwayat Chat',
+ route: '/chat/history',
+ ),
+ ],
),
- ],
- ),
- body: Padding(
- padding: const EdgeInsets.all(24),
- child: Column(
- children: [
- Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
- const SizedBox(height: 32),
- const _StatusToggle(),
- const SizedBox(height: 16),
- const _ActiveSessionsButton(),
- ],
),
),
);
}
}
-class _StatusToggle extends ConsumerWidget {
- const _StatusToggle();
+class _Header extends ConsumerWidget {
+ final String displayName;
+ final bool isOnline;
+ const _Header({required this.displayName, required this.isOnline});
@override
Widget build(BuildContext context, WidgetRef ref) {
- final statusState = ref.watch(onlineStatusProvider);
- final isOnline = statusState is StatusLoadedData && statusState.isOnline;
- final isLoading = statusState is StatusLoadingData;
-
- return Card(
- child: Padding(
- padding: const EdgeInsets.all(16),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- isOnline ? 'Online' : 'Offline',
- style: TextStyle(
- fontSize: 18,
- fontWeight: FontWeight.bold,
- color: isOnline ? Colors.green : Colors.grey,
- ),
- ),
- Text(
- isOnline
- ? 'Kamu siap menerima chat'
- : 'Aktifkan untuk menerima chat',
- style: const TextStyle(fontSize: 14, color: Colors.grey),
- ),
- ],
- ),
- isLoading
- ? const SizedBox(
- width: 24,
- height: 24,
- child: CircularProgressIndicator(strokeWidth: 2),
- )
- : Switch(
- value: isOnline,
- activeColor: Colors.green,
- onChanged: (_) {
- final notifier = ref.read(onlineStatusProvider.notifier);
- if (isOnline) {
- notifier.toggleOffline();
- } else {
- notifier.toggleOnline();
- }
- },
- ),
- ],
- ),
- ),
- );
- }
-}
-
-class _ActiveSessionsButton extends ConsumerWidget {
- const _ActiveSessionsButton();
-
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final unreadCounts = ref.watch(unreadSessionsProvider);
- final totalUnread = unreadCounts.values.fold(0, (a, b) => a + b);
-
- return Column(
+ final greetingSuffix = isOnline ? '🌸' : '🌙';
+ return Row(
children: [
- Card(
- child: ListTile(
- leading: Badge(
- isLabelVisible: totalUnread > 0,
- label: Text('$totalUnread'),
- child: const Icon(Icons.chat_bubble_outline),
- ),
- title: const Text('Sesi Aktif'),
- trailing: const Icon(Icons.chevron_right),
- onTap: () => context.push('/sessions'),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'Hei,',
+ style: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 13,
+ color: HaloTokens.inkSoft,
+ ),
+ ),
+ Text(
+ 'Bestie $displayName $greetingSuffix',
+ style: const TextStyle(
+ fontFamily: HaloTokens.fontDisplay,
+ fontSize: 24,
+ fontWeight: FontWeight.w700,
+ color: HaloTokens.brandDark,
+ letterSpacing: -0.4,
+ ),
+ ),
+ ],
),
),
- const _RequestHistoryButton(),
- Card(
- child: ListTile(
- leading: const Icon(Icons.history),
- title: const Text('Riwayat Chat'),
- trailing: const Icon(Icons.chevron_right),
- onTap: () => context.push('/chat/history'),
+ IconButton(
+ icon: const Icon(Icons.more_horiz, color: HaloTokens.ink),
+ style: IconButton.styleFrom(
+ backgroundColor: HaloTokens.surface,
+ shape: const CircleBorder(),
+ ),
+ onPressed: () => _showMenu(context, ref),
+ ),
+ ],
+ );
+ }
+
+ Future _showMenu(BuildContext context, WidgetRef ref) {
+ return showModalBottomSheet(
+ context: context,
+ builder: (ctx) => SafeArea(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ ListTile(
+ leading: const Icon(Icons.logout, color: HaloTokens.danger),
+ title: const Text(
+ 'Keluar',
+ style: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ onTap: () async {
+ Navigator.of(ctx).pop();
+ await ref.read(mitraAuthProvider.notifier).logout();
+ },
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _TilesGrid extends ConsumerWidget {
+ const _TilesGrid();
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ ref.watch(chatRequestProvider);
+ final undanganCount =
+ ref.read(chatRequestProvider.notifier).activeRequestCount;
+
+ return Row(
+ children: [
+ Expanded(
+ child: _DarkTile(
+ icon: '📨',
+ label: 'Undangan',
+ subtitle:
+ undanganCount > 0 ? 'Menunggu: $undanganCount' : 'Belum ada',
+ badgeCount: undanganCount,
+ onTap: () => context.push('/chat/requests/history'),
+ ),
+ ),
+ const SizedBox(width: 10),
+ // Perpanjang tile — backend wiring (extension request count) isn't
+ // exposed to the home yet, so render the static "Belum ada" state to
+ // match the figma. Wire to the same notifier once an extension-count
+ // provider exists.
+ const Expanded(
+ child: _DarkTile(
+ icon: '⚡',
+ label: 'Perpanjang',
+ subtitle: 'Belum ada',
+ badgeCount: 0,
+ onTap: null,
),
),
],
@@ -160,54 +199,284 @@ class _ActiveSessionsButton extends ConsumerWidget {
}
}
-class _RequestHistoryButton extends ConsumerWidget {
- const _RequestHistoryButton();
+class _DarkTile extends StatelessWidget {
+ final String icon;
+ final String label;
+ final String subtitle;
+ final int badgeCount;
+ final VoidCallback? onTap;
+
+ const _DarkTile({
+ required this.icon,
+ required this.label,
+ required this.subtitle,
+ required this.badgeCount,
+ required this.onTap,
+ });
@override
- Widget build(BuildContext context, WidgetRef ref) {
- // Watch state to rebuild when requests arrive/clear; count comes from
- // the notifier which tracks both displayed + queued requests.
- ref.watch(chatRequestProvider);
- final count = ref.read(chatRequestProvider.notifier).activeRequestCount;
-
- final hasPending = count > 0;
- final trailing = hasPending
- ? Row(
+ Widget build(BuildContext context) {
+ final card = Container(
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(
+ color: const Color(0xFF2A1820),
+ borderRadius: HaloRadius.lg,
+ ),
+ child: Stack(
+ children: [
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
- Container(
- padding:
- const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
- decoration: BoxDecoration(
- color: Colors.red,
- borderRadius: BorderRadius.circular(999),
+ Text(icon, style: const TextStyle(fontSize: 18)),
+ const SizedBox(height: 6),
+ Text(
+ label,
+ style: const TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 14,
+ fontWeight: FontWeight.w700,
+ color: Colors.white,
+ ),
+ ),
+ const SizedBox(height: 2),
+ Text(
+ subtitle,
+ style: const TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 11,
+ color: Color(0xB3FFFFFF),
+ ),
+ ),
+ ],
+ ),
+ if (badgeCount > 0)
+ Positioned(
+ top: 0,
+ right: 0,
+ child: Container(
+ width: 18,
+ height: 18,
+ alignment: Alignment.center,
+ decoration: const BoxDecoration(
+ color: Color(0xFFFF4D6A),
+ shape: BoxShape.circle,
),
child: Text(
- '$count',
+ '$badgeCount',
style: const TextStyle(
+ fontFamily: HaloTokens.fontBody,
color: Colors.white,
- fontWeight: FontWeight.bold,
- fontSize: 12,
+ fontSize: 11,
+ fontWeight: FontWeight.w700,
),
),
),
- const SizedBox(width: 4),
- const Icon(Icons.chevron_right),
- ],
- )
- : const Icon(Icons.chevron_right);
+ ),
+ ],
+ ),
+ );
- return Card(
- child: ListTile(
- leading: const Icon(Icons.notifications_outlined),
- title: const Text('Riwayat Permintaan'),
- subtitle: Text(
- hasPending
- ? '$count permintaan baru'
- : 'Lihat permintaan chat sebelumnya',
- ),
- trailing: trailing,
- onTap: () => context.push('/chat/requests/history'),
+ return Material(
+ color: Colors.transparent,
+ child: InkWell(
+ borderRadius: HaloRadius.lg,
+ onTap: onTap,
+ child: card,
+ ),
+ );
+ }
+}
+
+class _StatusCard extends StatelessWidget {
+ final bool isOnline;
+ const _StatusCard({required this.isOnline});
+
+ @override
+ Widget build(BuildContext context) {
+ final bgColor = isOnline ? const Color(0xFFE8F7EE) : const Color(0xFFFCE8E8);
+ final borderColor =
+ isOnline ? const Color(0xFF9DD9B1) : const Color(0xFFF5B5B5);
+ final titleColor =
+ isOnline ? const Color(0xFF1F6B3B) : const Color(0xFF7A2828);
+ final subColor =
+ isOnline ? const Color(0xFF3F8956) : const Color(0xFF9C4040);
+ final dot = isOnline ? '🟢' : '🔴';
+
+ return Container(
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(
+ color: bgColor,
+ borderRadius: HaloRadius.md,
+ border: Border.all(color: borderColor),
+ ),
+ child: Row(
+ children: [
+ Text(dot, style: const TextStyle(fontSize: 14)),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Kamu lagi ${isOnline ? 'ONLINE' : 'OFFLINE'}',
+ style: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 13,
+ fontWeight: FontWeight.w700,
+ color: titleColor,
+ ),
+ ),
+ Text(
+ isOnline
+ ? 'siap menerima curhat baru'
+ : 'gak terima curhat dulu',
+ style: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 11,
+ color: subColor,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _GantiStatusButton extends ConsumerWidget {
+ const _GantiStatusButton();
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final statusState = ref.watch(onlineStatusProvider);
+ final isOnline = statusState is StatusLoadedData && statusState.isOnline;
+ final isLoading = statusState is StatusLoadingData;
+
+ return HaloButton(
+ label: isLoading ? 'memproses...' : 'Ganti Status',
+ fullWidth: true,
+ onPressed: isLoading
+ ? null
+ : () {
+ final notifier = ref.read(onlineStatusProvider.notifier);
+ if (isOnline) {
+ notifier.toggleOffline();
+ } else {
+ notifier.toggleOnline();
+ }
+ },
+ );
+ }
+}
+
+class _Pengingat extends StatelessWidget {
+ const _Pengingat();
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Padding(
+ padding: EdgeInsets.only(bottom: 8),
+ child: Text(
+ 'Pengingat',
+ style: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 13,
+ fontWeight: FontWeight.w700,
+ color: HaloTokens.brandDark,
+ ),
+ ),
+ ),
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: const Color(0xFFEEE7F5),
+ borderRadius: HaloRadius.md,
+ ),
+ child: const Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('💜', style: TextStyle(fontSize: 16)),
+ SizedBox(width: 10),
+ Expanded(
+ child: Text.rich(
+ TextSpan(
+ style: TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 12.5,
+ color: HaloTokens.ink,
+ height: 1.45,
+ ),
+ children: [
+ TextSpan(
+ text: 'Opening protocol: ',
+ style: TextStyle(fontWeight: FontWeight.w700),
+ ),
+ TextSpan(
+ text:
+ 'selalu mulai dengan pertanyaan terbuka yang hangat ya, Bestie.',
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+class _ShortcutTile extends StatelessWidget {
+ final IconData icon;
+ final String title;
+ final String route;
+ const _ShortcutTile({
+ required this.icon,
+ required this.title,
+ required this.route,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Material(
+ color: Colors.transparent,
+ child: InkWell(
+ borderRadius: HaloRadius.lg,
+ onTap: () => context.push(route),
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
+ decoration: BoxDecoration(
+ color: HaloTokens.surface,
+ borderRadius: HaloRadius.lg,
+ border: Border.all(color: HaloTokens.border),
+ ),
+ child: Row(
+ children: [
+ Icon(icon, color: HaloTokens.brandDark, size: 20),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ title,
+ style: const TextStyle(
+ fontFamily: HaloTokens.fontBody,
+ fontSize: 14,
+ fontWeight: FontWeight.w600,
+ color: HaloTokens.ink,
+ ),
+ ),
+ ),
+ const Icon(Icons.chevron_right,
+ color: HaloTokens.inkMuted, size: 20),
+ ],
+ ),
+ ),
),
);
}
diff --git a/mitra_app/lib/main.dart b/mitra_app/lib/main.dart
index bb0fd14..58037e9 100644
--- a/mitra_app/lib/main.dart
+++ b/mitra_app/lib/main.dart
@@ -9,6 +9,7 @@ import 'core/status/status_notifier.dart';
import 'core/chat/chat_request_notifier.dart';
import 'core/chat/widgets/chat_request_overlay.dart';
import 'core/notifications/notification_service.dart';
+import 'core/theme/halo_theme.dart';
import 'firebase_options.dart';
import 'router.dart';
@@ -111,6 +112,7 @@ class _AppState extends ConsumerState with WidgetsBindingObserver {
return ChatRequestOverlay(
child: MaterialApp.router(
title: 'Halo Bestie Mitra',
+ theme: haloThemeData(),
routerConfig: router,
),
);
diff --git a/mitra_app/lib/router.dart b/mitra_app/lib/router.dart
index 8cb88f5..603ea18 100644
--- a/mitra_app/lib/router.dart
+++ b/mitra_app/lib/router.dart
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'core/auth/auth_notifier.dart';
import 'features/splash/splash_screen.dart';
+import 'features/auth/screens/account_inactive_screen.dart';
import 'features/auth/screens/login_screen.dart';
import 'features/auth/screens/otp_screen.dart';
import 'features/home/home_screen.dart';
@@ -33,7 +34,8 @@ GoRouter buildRouter(Ref ref) {
final authState = ref.read(mitraAuthProvider);
final isSplash = state.matchedLocation == '/splash';
final isAuthRoute = state.matchedLocation.startsWith('/login') ||
- state.matchedLocation.startsWith('/otp');
+ state.matchedLocation.startsWith('/otp') ||
+ state.matchedLocation.startsWith('/auth');
// Show splash only during initial load — don't redirect away from auth routes
if (authState is AsyncLoading) {
@@ -60,6 +62,7 @@ GoRouter buildRouter(Ref ref) {
GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()),
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
GoRoute(path: '/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)),
+ GoRoute(path: '/auth/inactive', builder: (_, __) => const AccountInactiveScreen()),
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
GoRoute(path: '/sessions', builder: (_, __) => const ActiveSessionsScreen()),
GoRoute(path: '/chat/session/:sessionId', builder: (context, state) {
diff --git a/mitra_app/pubspec.lock b/mitra_app/pubspec.lock
index a0a93ea..a3f49ec 100644
--- a/mitra_app/pubspec.lock
+++ b/mitra_app/pubspec.lock
@@ -33,6 +33,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.13.4"
+ archive:
+ dependency: transitive
+ description:
+ name: archive
+ sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.0.9"
args:
dependency: transitive
description:
@@ -358,6 +366,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.20.5"
+ flutter_launcher_icons:
+ dependency: "direct dev"
+ description:
+ name: flutter_launcher_icons
+ sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.13.1"
flutter_lints:
dependency: "direct dev"
description:
@@ -559,6 +575,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
+ image:
+ dependency: transitive
+ description:
+ name: image
+ sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.8.0"
io:
dependency: transitive
description:
@@ -783,6 +807,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.2"
+ posix:
+ dependency: transitive
+ description:
+ name: posix
+ sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.5.0"
pub_semver:
dependency: transitive
description:
diff --git a/mitra_app/pubspec.yaml b/mitra_app/pubspec.yaml
index ff29c83..45145e0 100644
--- a/mitra_app/pubspec.yaml
+++ b/mitra_app/pubspec.yaml
@@ -40,14 +40,49 @@ dev_dependencies:
build_runner: ^2.4.13
custom_lint: ^0.7.0
riverpod_lint: ^2.6.2
+ # Generates launcher icons for Android + iOS from a single source PNG.
+ # Config block below; run `dart run flutter_launcher_icons` to regenerate.
+ flutter_launcher_icons: ^0.13.1
# In-repo lint rules — shared with client_app from the repo root. Adds
# the `no_ref_in_dispose` rule and any future repo-wide guardrails.
# See halo_lints/lib/halo_lints.dart and mitra_app/CLAUDE.md → Pitfalls.
halo_lints:
path: ../halo_lints
+# Launcher-icon config. White background per the design-system rule
+# documented in client_app/lib/core/theme/halo_tokens.dart: when the logo
+# is full-color (mitra logo is pink on transparent), use surface #FFFFFF.
+# Monochrome/white logos use brandLogoBg #FF699F (see client_app config).
+flutter_launcher_icons:
+ android: true
+ ios: true
+ image_path: "assets/icons/logo.png"
+ remove_alpha_ios: true
+ background_color_ios: "#FFFFFF"
+ min_sdk_android: 24
+ adaptive_icon_background: "#FFFFFF"
+ adaptive_icon_foreground: "assets/icons/logo.png"
+
flutter:
uses-material-design: true
assets:
- assets/images/
- assets/images/splash/
+ - assets/fonts/
+
+ fonts:
+ - family: BricolageGrotesque
+ fonts:
+ - asset: assets/fonts/BricolageGrotesque-Variable.ttf
+ - family: Poppins
+ fonts:
+ - asset: assets/fonts/Poppins-Regular.ttf
+ - asset: assets/fonts/Poppins-Medium.ttf
+ weight: 500
+ - asset: assets/fonts/Poppins-SemiBold.ttf
+ weight: 600
+ - asset: assets/fonts/Poppins-Bold.ttf
+ weight: 700
+ - family: JetBrainsMono
+ fonts:
+ - asset: assets/fonts/JetBrainsMono-Variable.ttf
diff --git a/requirement/flow_mitra.md b/requirement/flow_mitra.md
new file mode 100644
index 0000000..784a31e
--- /dev/null
+++ b/requirement/flow_mitra.md
@@ -0,0 +1,67 @@
+# Application Flow for Mitra App
+We are defining mitra flow along with the app referenced on the claude design
+
+## A. Pre-Home (auth)
+
+The mitra app has no Google/Apple login — phone OTP only. All limits live on the
+backend (see `backend/src/services/otp.service.js` + `config.service.js`); the
+app must surface them clearly so a stuck mitra knows what to do.
+
+### A1. Boot — token gate
+1. App boot → check stored refresh token
+ 1. Valid session → go straight to Home (skip auth)
+ 2. No / expired refresh → go to Login screen
+
+### A2. S3a · Input WhatsApp
+1. Show phone number field (E.164, hint `+628xxxxxxxxxx`)
+2. "Kirim OTP" submit → `POST /api/mitra/auth/otp/request`
+3. Error responses:
+ 1. `PHONE_INVALID` (422) → inline field error "Format nomor salah"
+ 2. `OTP_COOLDOWN` (429) → snackbar "Tunggu N detik sebelum minta kode baru" using `retry_after_seconds`
+ 3. `OTP_RATE_LIMIT_PHONE` (429) → popup "Terlalu banyak permintaan untuk nomor ini. Coba lagi dalam N menit" (use `retry_after_seconds`)
+ 4. `OTP_RATE_LIMIT_IP` (429) → popup "Terlalu banyak permintaan dari jaringan ini. Coba lagi nanti / hubungi admin"
+4. Success → push OTP screen with `otp_request_id` + masked phone
+
+### A3. S3b · OTP verification (6-digit)
+1. Show 6 digit fields + masked phone reminder
+2. Auto-submit when 6 digits entered → `POST /api/mitra/auth/otp/verify`
+3. Show "Tersisa N percobaan" hint when attempts have been used (compute from local counter; backend doesn't expose it)
+4. Show "Kirim ulang kode" button — disabled until 60s cooldown elapses, with countdown timer
+5. Error responses:
+ 1. `CODE_INVALID` (422) → inline "Kode harus 6 digit angka"
+ 2. `CODE_MISMATCH` (401) → clear fields, inline "Kode salah · tersisa N percobaan", focus first digit
+ 3. `OTP_ATTEMPTS_EXCEEDED` (429) → blocked popup "Terlalu banyak percobaan" with CTAs: "Minta kode baru" (back to Login) / "Hubungi admin"
+ 4. `OTP_EXPIRED` (410) → popup "Kode kadaluarsa" → CTA "Minta kode baru" (back to Login pre-filled)
+ 5. `OTP_USED` (409) → same as `OTP_EXPIRED` (treat as terminal — user must request a new code)
+ 6. `WRONG_FLOW` (400) → popup "Kode ini bukan untuk akun mitra. Pastikan kamu pakai app yang benar"
+ 7. `ACCOUNT_INACTIVE` (403) — comes from verify endpoint after a valid code, before token issuance → full-screen "Akun belum aktif" state with admin contact CTAs (WhatsApp / Telegram). No retry.
+6. Success → store tokens → push Home
+
+### A4. Resend OTP (from S3b)
+1. From S3b → tap "Kirim ulang kode" (only enabled after 60s)
+2. Hits same `POST /api/mitra/auth/otp/request` → new `otp_request_id` replaces old
+3. Resets the local attempts counter to 0
+4. Same error scenarios as A2 (cooldown / rate-limit) apply
+
+## B. Home onwards
+
+mitra app flow:
+1. Home screen
+2. Undangan CTA -> Show Undangan · pending invitations
+3. Perpanjang CTA -> Show Undangan · Perpanjang Curhat
+4. Profil CTA -> show Bestie Profile · profil
+2. Status Offline?
+ 1. Status offline -> Show Bestie Home · OFFLINE
+ 2. Status online -> Show Bestie Home · standby & status. Goto next step
+
+3. incoming chat request:
+ 1. New chat -> Show Popup · incoming Curhat Baru
+ 2. Extend chat -> Show Popup · incoming Perpanjang
+
+4. Accept chat request?
+ 1. Reject -> Close dialog and Go to hoome screen
+ 2. Accept -> Show Bestie Chat Room · sesi aktif
+
+5. Session end -> Bestie Chat · durasi habis
+
+
diff --git a/requirement/flow_mitra.mermaid.md b/requirement/flow_mitra.mermaid.md
new file mode 100644
index 0000000..35e8729
--- /dev/null
+++ b/requirement/flow_mitra.mermaid.md
@@ -0,0 +1,231 @@
+# Mitra App Flow — Mermaid Diagrams
+
+> Generated from [`flow_mitra.md`](flow_mitra.md) and cross-checked against the
+> Claude-Design handoff bundle in
+> [`mitra_app/figma-bestie/project/screens/`](../mitra_app/figma-bestie/project/screens/)
+> (HTML/JSX prototype — not part of the build).
+>
+> Scope:
+> - **§A** Pre-home auth (phone OTP + all retry/limit edge cases). No Claude-Design
+> screens exist for this section yet — diagrams are spec-only and flag the
+> missing screens the mitra_app needs to build.
+> - **§1–§3** In-app flow once authenticated, mapped to the Bestie-* components
+> in the Figma drop.
+
+## Screen ↔ design-component map
+
+| Flow node | Design component | Source |
+|---|---|---|
+| Home — online standby | `BestieHome` (online=true) | [v4.jsx:417](../mitra_app/figma-bestie/project/screens/v4.jsx#L417) |
+| Home — offline | `BestieHomeOffline` (or `BestieHome` online=false) | [v5.jsx:188](../mitra_app/figma-bestie/project/screens/v5.jsx#L188) · [v4.jsx:417](../mitra_app/figma-bestie/project/screens/v4.jsx#L417) |
+| Bottom nav (Home / Chat / Profil) | `BestieTabBar` | [v4.jsx:464](../mitra_app/figma-bestie/project/screens/v4.jsx#L464) |
+| Undangan list — "Curhat Baru" tab | `BestieInvites` | [v4.jsx:480](../mitra_app/figma-bestie/project/screens/v4.jsx#L480) |
+| Undangan list — "Perpanjang Curhat" tab | `BestieInvitesExtend` | [v5.jsx:75](../mitra_app/figma-bestie/project/screens/v5.jsx#L75) |
+| Profil | `BestieProfile` | [v5.jsx:5](../mitra_app/figma-bestie/project/screens/v5.jsx#L5) |
+| Incoming popup — new curhat | `BestieIncomingPopup` (variant=`new`) | [v5.jsx:129](../mitra_app/figma-bestie/project/screens/v5.jsx#L129) |
+| Incoming popup — perpanjang | `BestieIncomingPopup` (variant=`extend`) | [v5.jsx:129](../mitra_app/figma-bestie/project/screens/v5.jsx#L129) |
+| Chat room — sesi aktif | `BestieChatV5` (ended=false) · earlier draft `BestieChat` | [v5.jsx:222](../mitra_app/figma-bestie/project/screens/v5.jsx#L222) · [v4.jsx:523](../mitra_app/figma-bestie/project/screens/v4.jsx#L523) |
+| Chat room — durasi habis | `BestieChatV5` (ended=true) | [v5.jsx:222](../mitra_app/figma-bestie/project/screens/v5.jsx#L222) |
+
+> Design tokens & primitives (`HBOrb`, `HBButton`, palette `t`) come from
+> [tokens.jsx](../mitra_app/figma-bestie/project/screens/tokens.jsx) and
+> [primitives.jsx](../mitra_app/figma-bestie/project/screens/primitives.jsx) — see
+> [project/CLAUDE.md](../mitra_app/figma-bestie/project/CLAUDE.md) before slicing.
+
+---
+
+## A. Pre-Home — auth + OTP retry scenarios
+
+> Backend enforces four OTP limits in
+> [`otp.service.js`](../backend/src/services/otp.service.js); defaults from
+> [`config.service.js:215-218`](../backend/src/services/config.service.js#L215-L218):
+> verify_max_attempts=**5**, resend_cooldown=**60s**, max_per_phone=**3/h**,
+> max_per_ip=**10/h**, OTP_TTL=**5min**. All four are tunable via the control
+> center config.
+>
+> The current mitra app
+> ([login_screen.dart](../mitra_app/lib/features/auth/screens/login_screen.dart) /
+> [otp_screen.dart](../mitra_app/lib/features/auth/screens/otp_screen.dart))
+> renders raw `error.toString()` in a snackbar for every error — no resend
+> button, no attempts-remaining hint, no blocked popup. Everything below marked
+> 🆕 is missing UI the slicer needs to add.
+
+### A.1 Boot → login → OTP request
+
+```mermaid
+flowchart TD
+ Boot["App boot"] --> Token{"Refresh token
valid?"}
+ Token -->|"yes"| Home["→ Home (skip auth)"]
+ Token -->|"no / expired"| Login["S3a · Input WhatsApp
login_screen.dart"]
+
+ Login -->|"Kirim OTP"| ReqApi["POST /api/mitra/auth/otp/request"]
+ ReqApi --> ReqOk{"Response"}
+ ReqOk -->|"200 ok"| OtpScreen["→ S3b · OTP verification"]
+
+ ReqOk -->|"422 PHONE_INVALID"| ErrPhone["🆕 Inline field error
'Format nomor salah'"]
+ ErrPhone --> Login
+ ReqOk -->|"429 OTP_COOLDOWN"| ErrCool["🆕 Snackbar w/ countdown
'Tunggu N detik' (retry_after_seconds)"]
+ ErrCool --> Login
+ ReqOk -->|"429 OTP_RATE_LIMIT_PHONE
(3 reqs / hour)"| ErrPhoneLim["🆕 Popup · 'Terlalu banyak
permintaan untuk nomor ini'
+ retry-after timer"]
+ ErrPhoneLim --> Login
+ ReqOk -->|"429 OTP_RATE_LIMIT_IP
(10 reqs / hour)"| ErrIpLim["🆕 Popup · 'Terlalu banyak
permintaan dari jaringan ini'
+ Hubungi admin CTA"]
+ ErrIpLim --> Login
+
+ classDef missing fill:#ffe5e5,stroke:#c44979
+ class ErrPhone,ErrCool,ErrPhoneLim,ErrIpLim missing
+```
+
+### A.2 S3b verify — happy + every error path
+
+```mermaid
+flowchart TD
+ Otp["S3b · OTP verification (6-digit)
otp_screen.dart
🆕 + 'Kirim ulang kode' (60s cooldown)
🆕 + 'Tersisa N percobaan' hint"]
+
+ Otp -->|"6 digits entered"| Verify["POST /api/mitra/auth/otp/verify"]
+ Verify --> Resp{"Response"}
+
+ Resp -->|"200 + is_active=true"| Home["→ Home
(store tokens)"]
+
+ Resp -->|"422 CODE_INVALID"| BadFmt["🆕 Inline · 'Kode harus 6 digit'"]
+ BadFmt --> Otp
+
+ Resp -->|"401 CODE_MISMATCH
(attempts < 5)"| Wrong["🆕 Clear fields · focus 1st
'Kode salah · tersisa N percobaan'"]
+ Wrong --> Otp
+
+ Resp -->|"429 OTP_ATTEMPTS_EXCEEDED
(5th wrong attempt)"| Blocked["🆕 Popup · 'Terlalu banyak percobaan'
CTAs: Minta kode baru / Hubungi admin"]
+ Blocked -->|"Minta kode baru"| ResendNew["→ back to S3a (prefilled)"]
+ Blocked -->|"Hubungi admin"| Admin["External · WA / TG"]
+
+ Resp -->|"410 OTP_EXPIRED
(> 5 min)"| Exp["🆕 Popup · 'Kode kadaluarsa'
CTA: Minta kode baru"]
+ Exp --> ResendNew
+
+ Resp -->|"409 OTP_USED"| Used["🆕 Popup · 'Kode sudah dipakai'
CTA: Minta kode baru"]
+ Used --> ResendNew
+
+ Resp -->|"400 WRONG_FLOW
(non-mitra OTP)"| Wrong2["🆕 Popup · 'Bukan akun mitra'"]
+ Wrong2 --> ResendNew
+
+ Resp -->|"403 ACCOUNT_INACTIVE
(code correct but mitra not approved)"| Inactive["🆕 Full-screen · 'Akun belum aktif'
CTAs: WhatsApp admin / Telegram admin
NO retry"]
+
+ classDef missing fill:#ffe5e5,stroke:#c44979
+ class Otp,BadFmt,Wrong,Blocked,Exp,Used,Wrong2,Inactive missing
+```
+
+### A.3 Resend cooldown — local timer (on S3b)
+
+```mermaid
+flowchart TD
+ OtpView["S3b mounted"] --> Timer["🆕 Local 60s countdown
starts on every request"]
+ Timer --> Btn{"Cooldown done?"}
+ Btn -->|"no"| Disabled["'Kirim ulang dalam Ns'
button disabled"]
+ Disabled --> Timer
+ Btn -->|"yes"| Enabled["'Kirim ulang kode' enabled"]
+ Enabled -->|"tap"| ReReq["POST /api/mitra/auth/otp/request"]
+ ReReq -->|"200"| Reset["replace otp_request_id
reset local attempts counter
restart 60s timer"]
+ Reset --> Timer
+ ReReq -->|"429 OTP_COOLDOWN"| BackendCool["🆕 Snackbar w/ server retry_after
(should never happen if local timer is right)"]
+
+ classDef missing fill:#ffe5e5,stroke:#c44979
+ class Timer,Disabled,Enabled,Reset,BackendCool missing
+```
+
+### Implementation gaps (mitra_app)
+
+| Screen / element | Where it lives today | What's missing |
+|---|---|---|
+| Inline phone-format error | [login_screen.dart](../mitra_app/lib/features/auth/screens/login_screen.dart) | All errors render as raw snackbar — needs field-level error + typed handler |
+| Cooldown / rate-limit popups | login_screen.dart | No popup variants; `retry_after_seconds` is ignored |
+| Resend button + 60s timer | [otp_screen.dart](../mitra_app/lib/features/auth/screens/otp_screen.dart) | Code comment hints at it (line 72) but no UI |
+| Attempts-remaining hint | otp_screen.dart | No local counter; user has no warning before the 5th attempt locks them out |
+| `OTP_ATTEMPTS_EXCEEDED` popup | otp_screen.dart | Renders as plain snackbar; no CTA to recover |
+| `OTP_EXPIRED` / `OTP_USED` popup | otp_screen.dart | Same — plain snackbar |
+| `WRONG_FLOW` popup | otp_screen.dart | Same — plain snackbar |
+| `ACCOUNT_INACTIVE` screen | otp_screen.dart | Renders as plain snackbar; should be a full-screen state with admin contact CTAs |
+
+> Design note: there's no Bestie-design equivalent for any of these screens
+> (the figma-bestie drop starts at Home). Style with `t.brandSofter` / `t.danger`
+> from [tokens.jsx](../mitra_app/figma-bestie/project/screens/tokens.jsx) for
+> visual continuity, and reuse `HBButton` patterns from the customer-side
+> [primitives.jsx](../mitra_app/figma-bestie/project/screens/primitives.jsx)
+> until a mitra-specific design exists.
+
+---
+
+## 1. Home + availability gating
+
+```mermaid
+flowchart TD
+ Boot["App boot (post-auth)"] --> Status{"Mitra status?"}
+ Status -->|"offline"| HomeOff["Home · OFFLINE
BestieHomeOffline"]
+ Status -->|"online"| HomeOn["Home · standby (online)
BestieHome online=true"]
+ HomeOff -- "Ganti Status" --> HomeOn
+ HomeOn -- "Ganti Status" --> HomeOff
+
+ HomeOn --> Tabs["Bottom nav
BestieTabBar"]
+ HomeOff --> Tabs
+ Tabs -->|"Home"| HomeOn
+ Tabs -->|"Chat"| Undangan
+ Tabs -->|"Profil"| Profil
+
+ HomeOn -->|"tap Undangan tile"| Undangan["Undangan · Curhat Baru tab
BestieInvites"]
+ HomeOn -->|"tap Perpanjang tile"| UndanganExt["Undangan · Perpanjang tab
BestieInvitesExtend"]
+ HomeOn -->|"tap Profil"| Profil["Profil
BestieProfile"]
+ HomeOff -.->|"tiles disabled while offline"| HomeOff
+```
+
+---
+
+## 2. Undangan list (tabbed) — accept / reject
+
+```mermaid
+flowchart TD
+ Entry["Home tile or Chat tab"] --> Tabs{"Which tab?"}
+ Tabs -->|"Curhat Baru"| InvNew["Undangan · Curhat Baru
BestieInvites
(new-client cards, brand pink)"]
+ Tabs -->|"Perpanjang Curhat"| InvExt["Undangan · Perpanjang
BestieInvitesExtend
(amber accent, +mins badge)"]
+
+ InvNew -->|"Tolak"| Home1["← back to Home"]
+ InvExt -->|"Tolak"| Home1
+ InvNew -->|"Terima"| ChatActive
+ InvExt -->|"Terima Perpanjangan"| ChatActive["Chat · sesi aktif
BestieChatV5 ended=false"]
+```
+
+> Note: both Undangan variants share the same header + tab bar. The amber palette
+> + `+N mnt` badge on `BestieInvitesExtend` is the visual cue separating
+> extension invites from new-curhat invites.
+
+---
+
+## 3. Incoming request popup → chat → session end
+
+```mermaid
+flowchart TD
+ Idle["Mitra online (any screen)"] --> Push{"Incoming request"}
+ Push -->|"new curhat"| PopNew["Popup · Curhat Baru
BestieIncomingPopup variant=new
(brand pink, 30s window)"]
+ Push -->|"perpanjang"| PopExt["Popup · Perpanjang
BestieIncomingPopup variant=extend
(amber, 10s auto-accept)"]
+
+ PopNew -->|"Tolak"| Idle
+ PopExt -->|"Tolak"| Idle
+ PopNew -->|"Terima Sekarang"| Chat
+ PopExt -->|"Terima · +N mnt"| Chat
+
+ Chat["Chat · sesi aktif
BestieChatV5 ended=false
(SISA WAKTU pill, input bar)"]
+ Chat -->|"timer hits 00:00"| Ended["Chat · durasi habis
BestieChatV5 ended=true
(SELESAI pill, input replaced by notice)"]
+ Ended -->|"tunggu perpanjang"| PopExt
+ Ended -->|"tutup obrolan"| Idle
+```
+
+---
+
+## Open questions / gaps vs. design
+
+- **No design for the "no invitations" empty state** of `BestieInvites` /
+ `BestieInvitesExtend` — both prototypes ship with 2 stub items. Confirm whether
+ the empty state should reuse the home `Undangan: Belum ada` tile copy.
+- **Offline + incoming**: design only shows the popup on `Idle` (online). Spec is
+ silent on what happens if a request arrives while offline — likely suppressed
+ by backend, but worth confirming so we don't render a dead popup.
+- **`BestieOfflinePopup`** ([v4.jsx:244](../mitra_app/figma-bestie/project/screens/v4.jsx#L244))
+ appears to be customer-side ("semua bestie lagi istirahat") — excluded from
+ this mitra flow.
+- **Reject animation/feedback**: design returns to home with no toast. Confirm
+ whether a "ditolak" snackbar is desired for parity with extension UX.
diff --git a/requirement/phase4-mitra-prehome-plan.md b/requirement/phase4-mitra-prehome-plan.md
new file mode 100644
index 0000000..1627df0
--- /dev/null
+++ b/requirement/phase4-mitra-prehome-plan.md
@@ -0,0 +1,402 @@
+# Mitra Pre-Home OTP — Implementation Plan
+
+> **Status (2026-05-19):** Shipped. All five stages complete; 5 maestro
+> flows green (`ts-mitra-A-01/03/04/05/06`). Plus a partial Bestie Home
+> rebuild (greeting + status card + Ganti Status CTA + Pengingat). Remaining
+> Bestie design work — BestieTabBar bottom nav, Undangan (both tabs), Profil,
+> Incoming popups, Chat sesi aktif / durasi habis — is **not** in this plan;
+> tracked in [[project-mitra-prehome-shipped]] memory.
+>
+> **Scope tweak during implementation:** mitras are internal-only — no public
+> WhatsApp/Telegram admin CTAs surface on the pre-home screens. They reach the
+> team through their existing internal channel (Slack, coordinator, etc).
+> Stage 2 (admin contact helper + `url_launcher`) was removed; all "Hubungi
+> admin" buttons stripped from S3a rate-limit popup, S3b blocked dialog, and
+> AccountInactive. `support_handles_json` config remains the source of truth
+> for customer-facing admin contact and will be fetched post-login by the
+> mitra profile page in a separate change.
+>
+> Implements [flow_mitra.md §A](flow_mitra.md) (sub-flows A1–A4) and the
+> "Implementation gaps (mitra_app)" table in
+> [flow_mitra.mermaid.md §A](flow_mitra.mermaid.md).
+
+> Spec sources:
+> - PRD / flow: [flow_mitra.md §A](flow_mitra.md)
+> - Diagrams: [flow_mitra.mermaid.md §A](flow_mitra.mermaid.md)
+>
+> Backend behavior is already correct — every error code, retry-after detail,
+> and `ACCOUNT_INACTIVE` gate is in
+> [`otp.service.js`](../backend/src/services/otp.service.js) and
+> [`mitra.auth.routes.js`](../backend/src/routes/public/mitra.auth.routes.js).
+> **No backend changes in this plan.** All work is mitra_app-side.
+
+This document is the build sequence: **what** files change, **in what order**,
+with **state-machine contracts** and **error-code routing** spelled out per
+screen. The "why" is in the PRD — don't restate it here.
+
+---
+
+## Build Order (5 stages — Stage 2 dropped during impl)
+
+Stage 1 is foundation (typed error) that everything else builds on. Stages 3–4
+are the two screens. Stage 5 adds the terminal state. Stage 6 is verification.
+
+1. **Typed auth-error model** — replace string `AsyncError` with structured `MitraAuthError`
+2. ~~**Admin contact helper** — single source of truth for WA / Telegram URLs + `url_launcher` wrapper~~ — **dropped**: mitras are internal-only, no public admin CTA needed on pre-home screens.
+3. **S3a · Input WhatsApp** — replace generic snackbar with code-routed handling
+4. **S3b · OTP verification** — resend timer, attempts hint, six new dialog states
+5. **AccountInactiveScreen + router wiring** — terminal full-screen state for `ACCOUNT_INACTIVE`
+6. **Manual verification** — drive every error path with `OTP_STATIC_CODE` + CC toggles
+
+Each stage is independently mergeable. Stage 4 is the largest single change
+(~150 LoC across one file).
+
+---
+
+# Stage 1 — Typed auth-error model
+
+The current
+[`auth_notifier.dart`](../mitra_app/lib/core/auth/auth_notifier.dart)
+flattens every error into `AsyncError(string)` via `_otpRequestMessage` /
+`_otpVerifyMessage`. The screen can read the localized message but loses the
+error **code** and the `retry_after_seconds` detail — both of which the UI
+needs to pick between snackbar / dialog / inline / full-screen, and to render
+countdowns.
+
+## 1.1 New class
+
+> **New:** in [`auth_notifier.dart`](../mitra_app/lib/core/auth/auth_notifier.dart),
+> alongside the existing `MitraAuthData` sealed hierarchy.
+
+```dart
+class MitraAuthError implements Exception {
+ final String code; // e.g. 'OTP_COOLDOWN', 'ACCOUNT_INACTIVE'
+ final String message; // localized, ready to show as-is
+ final int? retryAfterSeconds;
+
+ const MitraAuthError(this.code, this.message, {this.retryAfterSeconds});
+
+ @override
+ String toString() => message; // preserves existing snackbar fallback behavior
+}
+```
+
+The `toString()` override means any screen that hasn't been updated yet will
+keep working (just rendering the message string).
+
+## 1.2 Parse helpers
+
+Replace `_otpRequestMessage` / `_otpVerifyMessage` with a single
+`_buildError(DioException e, Map codeToMessage)` that:
+
+1. Extracts `code`, `message`, and `details.retry_after_seconds` from
+ `e.response?.data?['error']` (defaulting safely).
+2. Returns a `MitraAuthError`. The `codeToMessage` arg supplies the localized
+ fallback when the backend doesn't include a message string.
+
+The two existing string maps (request vs verify) become `static const`
+`Map` lookups so the codeToMessage tables don't drift.
+
+## 1.3 Update `requestOtp` / `verifyOtp`
+
+Replace:
+```dart
+state = AsyncError(_otpRequestMessage(e), StackTrace.current);
+```
+with:
+```dart
+state = AsyncError(_buildError(e, _kRequestMessages), StackTrace.current);
+```
+
+The unknown-error fallback (the bare `catch(_)`) also returns a
+`MitraAuthError('UNKNOWN', 'Gagal …')` so the screen never receives a raw
+string.
+
+## 1.4 Acceptance
+
+- `auth_notifier.dart` exports `MitraAuthError`.
+- Every `AsyncError` emitted by the notifier carries one.
+- Existing call sites (login_screen / otp_screen) still compile because
+ `error.toString()` still returns the message.
+
+---
+
+# Stage 2 — ~~Admin contact helper~~ (dropped)
+
+> Dropped during implementation. Mitras are an internal cohort and have a
+> separate, non-public support channel (coordinator / Slack / WhatsApp group
+> that ops onboards them through). Surfacing the customer-facing
+> `support_handles_json` numbers on the pre-home screens would mix audiences.
+>
+> Consequence in the screens that follow: any "Hubungi admin" CTA is omitted.
+> The IP-rate-limit popup, the attempts-exceeded dialog, and the
+> AccountInactive screen all rely on the mitra knowing how to reach their
+> internal contact.
+>
+> The post-login mitra profile page will still surface
+> `support_handles_json` (separate change) — that's a different audience
+> (the customer-facing admin who handles billing/escalation questions).
+
+---
+
+# Stage 3 — S3a · Input WhatsApp
+
+Update [`login_screen.dart`](../mitra_app/lib/features/auth/screens/login_screen.dart)
+to route on `MitraAuthError.code` instead of dumping every error into a
+snackbar.
+
+## 3.1 State additions
+
+```dart
+String? _phoneErrorText; // inline TextField error
+int? _phoneRateLimitRetryAfter; // drives popup countdown
+```
+
+## 3.2 `ref.listen` rewrite
+
+Replace the existing AsyncError branch:
+
+```dart
+if (next is AsyncError && next.error is MitraAuthError) {
+ final err = next.error as MitraAuthError;
+ switch (err.code) {
+ case 'PHONE_INVALID':
+ setState(() => _phoneErrorText = err.message);
+ break;
+ case 'OTP_COOLDOWN':
+ // server message already includes seconds
+ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(err.message)));
+ break;
+ case 'OTP_RATE_LIMIT_PHONE':
+ await _showRateLimitDialog(err, isIp: false);
+ break;
+ case 'OTP_RATE_LIMIT_IP':
+ await _showRateLimitDialog(err, isIp: true);
+ break;
+ default:
+ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(err.message)));
+ }
+}
+```
+
+## 3.3 `_showRateLimitDialog`
+
+`AlertDialog` with:
+- Title: "Terlalu banyak permintaan untuk nomor ini" (or "…dari jaringan ini" for IP)
+- Body: server message + retry-after subtext if present
+- Single action: "Tutup" (Navigator.pop)
+
+No live countdown inside the dialog (just static text "Coba lagi dalam N
+menit"). Keeping it static avoids `Timer.periodic` inside a dialog — overkill
+for an error popup. No admin CTA (see Stage 2 note).
+
+## 3.4 `TextField` wiring
+
+The phone field gets `errorText: _phoneErrorText`. Submitting clears
+`_phoneErrorText` first so it doesn't linger on retry.
+
+## 3.5 Acceptance
+
+- Submitting `+99999` → field shows inline error, no snackbar.
+- Submitting twice within 60s → snackbar with "Tunggu Ns…".
+- Submitting 4× in an hour → rate-limit dialog with retry-after.
+- Submitting from a heavily-trafficked IP (10/h) → IP rate-limit dialog with
+ "Hubungi admin" CTA that opens WA.
+
+---
+
+# Stage 4 — S3b · OTP verification
+
+The biggest single file change.
+[`otp_screen.dart`](../mitra_app/lib/features/auth/screens/otp_screen.dart)
+gains: resend button + 60s countdown, local attempts-remaining hint, and
+six branched error paths.
+
+## 4.1 New state
+
+```dart
+Timer? _cooldownTicker;
+int _cooldown = 60; // seconds until resend becomes enabled
+int _attemptsUsed = 0; // 0–5, drives "Tersisa N percobaan"
+String? _inlineError; // for CODE_INVALID
+```
+
+## 4.2 Cooldown timer
+
+- Start in `initState` (cooldown begins the moment we land on this screen).
+- `Timer.periodic(const Duration(seconds: 1), ...)` decrements `_cooldown`
+ until 0, then cancels itself.
+- Cancel in `dispose()`. **Do not** touch ref in dispose — see
+ [`mitra_app/CLAUDE.md`](../mitra_app/CLAUDE.md) "no_ref_in_dispose" rule.
+ This timer doesn't need ref, so plain `dispose()` is fine.
+
+## 4.3 Resend button
+
+Below the OTP fields, above the verify button:
+
+```dart
+TextButton(
+ onPressed: _cooldown > 0 ? null : _resend,
+ child: Text(_cooldown > 0 ? 'Kirim ulang dalam ${_cooldown}s' : 'Kirim ulang kode'),
+)
+```
+
+`_resend` calls `ref.read(mitraAuthProvider.notifier).requestOtp(widget.phone)`
+and on success listener fires (`MitraAuthOtpSentData` re-emitted with a new
+`otp_request_id`), the screen:
+- resets `_attemptsUsed = 0`
+- resets `_cooldown = 60` and restarts the ticker
+- clears the input fields
+
+## 4.4 Attempts-remaining hint
+
+A small text widget below the input row:
+
+```dart
+if (_attemptsUsed > 0)
+ Text('Tersisa ${5 - _attemptsUsed} percobaan',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(color: ...))
+```
+
+Incremented in the `CODE_MISMATCH` branch (Stage 4.5).
+
+## 4.5 `ref.listen` rewrite
+
+```dart
+if (next is AsyncError && next.error is MitraAuthError) {
+ final err = next.error as MitraAuthError;
+ switch (err.code) {
+ case 'CODE_INVALID':
+ setState(() => _inlineError = err.message);
+ break;
+ case 'CODE_MISMATCH':
+ setState(() {
+ _attemptsUsed += 1;
+ _inlineError = 'Kode salah. Tersisa ${5 - _attemptsUsed} percobaan';
+ });
+ _clearFieldsAndFocus();
+ break;
+ case 'OTP_ATTEMPTS_EXCEEDED':
+ await _showBlockedDialog();
+ break;
+ case 'OTP_EXPIRED':
+ case 'OTP_USED':
+ await _showResetDialog(err);
+ break;
+ case 'WRONG_FLOW':
+ await _showWrongFlowDialog(err);
+ break;
+ case 'ACCOUNT_INACTIVE':
+ if (mounted) context.go('/auth/inactive');
+ break;
+ default:
+ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(err.message)));
+ }
+}
+```
+
+## 4.6 Dialog helpers
+
+- **`_showBlockedDialog`** — "Terlalu banyak percobaan". Single CTA: "Minta
+ kode baru" (`context.pop()` → returns to S3a). No admin CTA (Stage 2 note).
+- **`_showResetDialog`** — used by `OTP_EXPIRED` and `OTP_USED`. Single CTA
+ "Minta kode baru" → `context.pop()`.
+- **`_showWrongFlowDialog`** — "Bukan akun mitra. Pastikan kamu pakai app yang
+ benar." Single CTA → `context.pop()`.
+
+All dialogs use `barrierDismissible: false` — the user must choose a recovery
+path.
+
+## 4.7 Acceptance
+
+- 5× wrong code → hint decrements, blocked dialog on 5th, "Minta kode baru"
+ pops to S3a.
+- Wait 5 min after request, then submit → expired dialog.
+- Resend within 60s of first request → cooldown blocks the button.
+- After cooldown, tap resend → fields clear, hint clears, timer restarts.
+- Submit code for a mitra with `is_active=false` → lands on AccountInactive.
+
+---
+
+# Stage 5 — AccountInactiveScreen + router
+
+A terminal full-screen state. No back nav; the only way out is to contact
+admin or sign in with a different phone.
+
+## 5.1 New file
+
+> **New:** `mitra_app/lib/features/auth/screens/account_inactive_screen.dart`
+
+Wrapped in `PopScope(canPop: false)` so the system back button can't escape
+the terminal state. Body: icon + title + body copy directing the mitra to
+their internal coordinator, and a single "Pakai nomor lain" text button that
+logs out and pops back to S3a. No public admin CTAs (Stage 2 note).
+
+## 5.2 Router updates
+
+In [`router.dart`](../mitra_app/lib/router.dart):
+
+1. Add `GoRoute(path: '/auth/inactive', builder: (_, __) => const AccountInactiveScreen())`.
+2. Update `redirect` to recognize `/auth/inactive` as an auth-route (so the
+ guard doesn't bounce the unauthenticated user away):
+
+ ```dart
+ final isAuthRoute = state.matchedLocation.startsWith('/login') ||
+ state.matchedLocation.startsWith('/otp') ||
+ state.matchedLocation.startsWith('/auth');
+ ```
+
+## 5.3 Acceptance
+
+- Verify with correct code against a mitra whose `is_active=false` →
+ AccountInactive renders. No back arrow, system back blocked by `PopScope`.
+- Tap "Pakai nomor lain" → logged out, S3a shown.
+
+---
+
+# Stage 6 — Manual verification
+
+No automated tests this round — pre-home OTP isn't currently covered by
+Maestro (existing `ts-customer-*` flows are client_app-side). One mitra-side
+Maestro flow could be added later as a follow-up.
+
+## 6.1 Pre-conditions
+
+- Backend running with `OTP_STATIC_CODE=111111` in env so we can submit a
+ predictable code without round-tripping `/internal/_test/peek-otp`.
+- Seed two test mitras via control center:
+ - **Mitra A** — `+628111111111`, `is_active=true`
+ - **Mitra B** — `+628222222222`, `is_active=false`
+
+## 6.2 Scenarios
+
+| # | Trigger | Expected UI |
+|---|---|---|
+| 1 | S3a, submit `12345` (invalid format) | Inline TextField error "Nomor HP tidak valid." |
+| 2 | S3a, submit valid phone twice within 60s | 2nd submit → snackbar "Tunggu Ns…" |
+| 3 | S3a, submit valid phone 4× within 1h | 4th submit → phone rate-limit dialog with retry-after |
+| 4 | S3a, submit Mitra A's phone | Pushes to S3b, 60s countdown visible |
+| 5 | S3b, submit wrong code 4× | "Tersisa N percobaan" decrements 4→1 |
+| 6 | S3b, submit wrong code 5th time | Blocked dialog with two CTAs |
+| 7 | S3b, wait 5+ min then submit | Expired dialog → "Minta kode baru" pops to S3a |
+| 8 | S3b, wait full 60s | Resend button enables; tapping resets attempts + timer |
+| 9 | S3b, submit `111111` for Mitra A | Authenticated → /home |
+| 10 | S3b, submit `111111` for Mitra B | AccountInactive screen |
+| 11 | AccountInactive, tap "Pakai nomor lain" | Logged out, S3a shown |
+| 12 | AccountInactive, press system back | Blocked by `PopScope` |
+
+## 6.3 Sign-off
+
+All 12 scenarios pass on a physical Android device (API 28+).
+
+---
+
+## Open questions
+
+- **Resend countdown on backgrounding** — current plan uses elapsed-ticks not
+ wall-clock. If a mitra backgrounds the app for 90s, comes back, the timer
+ will still show "remaining" instead of immediately enabling. Acceptable for
+ v1; revisit if it causes confusion.
+- **Maestro coverage** — out of scope here, but mitra-side onboarding has
+ zero E2E coverage today. Worth adding `ts-mitra-01-prehome-otp.yaml` once
+ the screens stabilize.