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 ? ( + + + + + + + + + {logsData.items.map((log) => ( + + + + + ))} + +
StatusWaktu
+ + {log.status === 'online' ? '● Online' : '○ Offline'} + + {new Date(log.timestamp).toLocaleString('id-ID')}
+ ) : ( +

Belum ada log.

+ )} +
+ + + )} +
) })} - - {logsForMitra && ( -
-

Log Online/Offline

- {logsLoading ? ( -

Loading...

- ) : ( - - - - - - - - - {logsData?.items?.map((log) => ( - - - - - ))} - -
StatusWaktu
- - {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.