Mitra §A: pre-home (S3a/S3b/AccountInactive) + design system + Bestie Home

- Port halo_tokens + halo_theme + HaloButton to mitra_app (rose palette,
  Bricolage display, Poppins body, JetBrainsMono).
- Build S3a Input WhatsApp (figma-bestie BestieS3 first half) with
  +62 chip, leading-zero/62 normalization, allow '+' in input.
- Build S3b OTP verification (6-digit, 60s resend timer, attempts hint,
  Focus(canRequestFocus:false) for maestro inputText compat) with full
  error branching (CODE_MISMATCH, OTP_EXPIRED, OTP_USED, ATTEMPTS_EXCEEDED,
  WRONG_FLOW, ACCOUNT_INACTIVE).
- Add AccountInactive terminal screen for is_active=false mitras.
- Typed MitraAuthError with Indonesian-first localized messages +
  retryAfterSeconds passthrough.
- Rebuild home_screen.dart to match figma BestieHome (greeting + status
  card + Ganti Status CTA + Pengingat + 2-tile dark grid).
- Backend: POST /internal/_test/seed-mitra (idempotent) and
  PATCH /internal/mitras/:id (display_name update).
- Control center: inline Edit Nama on mitras row + expandable inline log
  table under clicked mitra (vs old below-table panel).
- 5 maestro flows ts-mitra-A-01/03/04/05/06 covering invalid input, happy
  path, account inactive, phone-format normalization, and the back-to-S3a
  regression. All green.

Plan + memory documented in:
- requirement/phase4-mitra-prehome-plan.md
- requirement/flow_mitra.md / flow_mitra.mermaid.md §A

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 22:01:28 +08:00
parent ad02ee252d
commit 9696eadeaf
37 changed files with 3406 additions and 326 deletions

3
.gitignore vendored
View File

@@ -15,3 +15,6 @@ bugreport-*.zip
requirement/Figma.zip requirement/Figma.zip
requirement/Figma/ requirement/Figma/
requirement/figma/ requirement/figma/
# Mitra figma design dump (local reference only, do not check in)
mitra_app/figma-bestie/

View File

@@ -332,6 +332,28 @@ export const internalTestRoutes = async (fastify) => {
return { ok: true, ...row } 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 // 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 // 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). // (e.g. force-mitra-offline leaving the dev DB with no online mitras).

View File

@@ -1,6 +1,6 @@
import { authenticate, requirePermission } from '../../plugins/auth.js' import { authenticate, requirePermission } from '../../plugins/auth.js'
import { getCcUserById } from '../../services/cc-user.service.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 { getOnlineMitras, getOnlineLogs } from '../../services/mitra-status.service.js'
import { UserType } from '../../constants.js' import { UserType } from '../../constants.js'
@@ -48,6 +48,17 @@ export const mitraManagementRoutes = async (app) => {
return reply.send({ success: true, data: mitra }) 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', { app.get('/online', {
preHandler: [authenticate, attachCcUser, requirePermission('mitra', 'read')], preHandler: [authenticate, attachCcUser, requirePermission('mitra', 'read')],
}, async (request, reply) => { }, async (request, reply) => {

View File

@@ -39,6 +39,16 @@ export const updateMitraStatus = async (id, is_active) => {
return mitra 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 }) => { export const listMitras = async ({ page = 1, limit = 20, is_active }) => {
const offset = (page - 1) * limit const offset = (page - 1) * limit
const conditions = is_active !== undefined const conditions = is_active !== undefined

View File

@@ -1,4 +1,4 @@
import { useState } from 'react' import { Fragment, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '../../core/api/api-client' import { apiClient } from '../../core/api/api-client'
@@ -22,6 +22,11 @@ const updateMitraStatus = async ({ id, is_active }) => {
return res.data.data 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 fetchOnlineLogs = async (mitraId) => {
const res = await apiClient.get(`/internal/mitras/${mitraId}/online-logs`) const res = await apiClient.get(`/internal/mitras/${mitraId}/online-logs`)
return res.data.data return res.data.data
@@ -39,6 +44,8 @@ export default function MitrasPage() {
const [form, setForm] = useState({ phone: '', display_name: '' }) const [form, setForm] = useState({ phone: '', display_name: '' })
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
const [logsForMitra, setLogsForMitra] = useState(null) const [logsForMitra, setLogsForMitra] = useState(null)
const [editingId, setEditingId] = useState(null)
const [editName, setEditName] = useState('')
const { data: logsData, isLoading: logsLoading } = useQuery({ const { data: logsData, isLoading: logsLoading } = useQuery({
queryKey: ['mitra-online-logs', logsForMitra], queryKey: ['mitra-online-logs', logsForMitra],
@@ -60,6 +67,29 @@ export default function MitrasPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['mitras'] }), 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 <div>Loading...</div> if (isLoading) return <div>Loading...</div>
// Build a set of online mitra IDs for quick lookup // Build a set of online mitra IDs for quick lookup
@@ -106,9 +136,27 @@ export default function MitrasPage() {
<tbody> <tbody>
{data?.items?.map((mitra) => { {data?.items?.map((mitra) => {
const onlineInfo = onlineMitraMap.get(mitra.id) const onlineInfo = onlineMitraMap.get(mitra.id)
const isEditing = editingId === mitra.id
return ( return (
<tr key={mitra.id}> <Fragment key={mitra.id}>
<td style={{ padding: 8 }}>{mitra.display_name}</td> <tr>
<td style={{ padding: 8 }}>
{isEditing ? (
<input
autoFocus
value={editName}
onChange={(e) => 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
)}
</td>
<td style={{ padding: 8 }}>{mitra.phone}</td> <td style={{ padding: 8 }}>{mitra.phone}</td>
<td style={{ padding: 8 }}>{mitra.is_active ? 'Aktif' : 'Nonaktif'}</td> <td style={{ padding: 8 }}>{mitra.is_active ? 'Aktif' : 'Nonaktif'}</td>
<td style={{ padding: 8 }}> <td style={{ padding: 8 }}>
@@ -118,26 +166,38 @@ export default function MitrasPage() {
</td> </td>
<td style={{ padding: 8 }}>{onlineInfo ? onlineInfo.active_session_count : '-'}</td> <td style={{ padding: 8 }}>{onlineInfo ? onlineInfo.active_session_count : '-'}</td>
<td style={{ padding: 8, display: 'flex', gap: 8 }}> <td style={{ padding: 8, display: 'flex', gap: 8 }}>
{isEditing ? (
<>
<button
onClick={() => saveEdit(mitra.id)}
disabled={nameMutation.isPending || !editName.trim() || editName.trim() === mitra.display_name}
>
{nameMutation.isPending ? 'Menyimpan...' : 'Simpan'}
</button>
<button onClick={cancelEdit} disabled={nameMutation.isPending}>Batal</button>
</>
) : (
<>
<button onClick={() => startEdit(mitra)}>Edit Nama</button>
<button onClick={() => statusMutation.mutate({ id: mitra.id, is_active: !mitra.is_active })}> <button onClick={() => statusMutation.mutate({ id: mitra.id, is_active: !mitra.is_active })}>
{mitra.is_active ? 'Nonaktifkan' : 'Aktifkan'} {mitra.is_active ? 'Nonaktifkan' : 'Aktifkan'}
</button> </button>
<button onClick={() => setLogsForMitra(logsForMitra === mitra.id ? null : mitra.id)}> <button onClick={() => setLogsForMitra(logsForMitra === mitra.id ? null : mitra.id)}>
{logsForMitra === mitra.id ? 'Tutup Log' : 'Log Online'} {logsForMitra === mitra.id ? 'Tutup Log' : 'Log Online'}
</button> </button>
</>
)}
</td> </td>
</tr> </tr>
) {logsForMitra === mitra.id && (
})} <tr>
</tbody> <td colSpan={6} style={{ padding: 0, background: '#fafafa', borderBottom: '1px solid #eee' }}>
</table> <div style={{ padding: 16 }}>
<h4 style={{ margin: '0 0 12px' }}>Log Online/Offline · {mitra.display_name}</h4>
{logsForMitra && (
<div style={{ marginTop: 16, padding: 16, border: '1px solid #eee' }}>
<h3>Log Online/Offline</h3>
{logsLoading ? ( {logsLoading ? (
<p>Loading...</p> <p style={{ margin: 0 }}>Loading...</p>
) : ( ) : logsData?.items?.length ? (
<table style={{ width: '100%', borderCollapse: 'collapse' }}> <table style={{ width: '100%', borderCollapse: 'collapse', background: '#fff' }}>
<thead> <thead>
<tr> <tr>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Status</th> <th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Status</th>
@@ -145,7 +205,7 @@ export default function MitrasPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{logsData?.items?.map((log) => ( {logsData.items.map((log) => (
<tr key={log.id}> <tr key={log.id}>
<td style={{ padding: 8 }}> <td style={{ padding: 8 }}>
<span style={{ color: log.status === 'online' ? 'green' : 'grey' }}> <span style={{ color: log.status === 'online' ? 'green' : 'grey' }}>
@@ -157,9 +217,18 @@ export default function MitrasPage() {
))} ))}
</tbody> </tbody>
</table> </table>
) : (
<p style={{ margin: 0, color: '#888' }}>Belum ada log.</p>
)} )}
</div> </div>
</td>
</tr>
)} )}
</Fragment>
)
})}
</tbody>
</table>
</div> </div>
) )
} }

View File

@@ -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-<section>-<sub>-<description>.yaml`:
- `<section>` — flow_mitra.mermaid section identifier (`A` for pre-home auth).
- `<sub>` — sub-flow index within the section, zero-padded.
- `<description>` — 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<NN><SS>` 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.

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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}`)
}

View File

@@ -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}`)
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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`.

View File

@@ -27,6 +27,83 @@ class MitraAuthOtpSentData extends MitraAuthData {
const MitraAuthOtpSentData(this.otpRequestId, {this.channelUsed}); 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) @Riverpod(keepAlive: true)
class MitraAuth extends _$MitraAuth { class MitraAuth extends _$MitraAuth {
final _storage = TokenStorage(); final _storage = TokenStorage();
@@ -113,9 +190,15 @@ class MitraAuth extends _$MitraAuth {
channelUsed: data['channel_used'] as String?, channelUsed: data['channel_used'] as String?,
)); ));
} on DioException catch (e) { } on DioException catch (e) {
state = AsyncError(_otpRequestMessage(e), StackTrace.current); state = AsyncError(
_buildError(e, _localizedRequestMessage, 'Gagal mengirim OTP. Coba lagi.'),
StackTrace.current,
);
} catch (_) { } 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); _bridge.setAccessToken(accessToken);
state = AsyncData(MitraAuthAuthenticatedData(profile)); state = AsyncData(MitraAuthAuthenticatedData(profile));
} on DioException catch (e) { } on DioException catch (e) {
state = AsyncError(_otpVerifyMessage(e), StackTrace.current); state = AsyncError(
_buildError(e, (code, _) => _localizedVerifyMessage(code), 'Gagal verifikasi. Coba lagi.'),
StackTrace.current,
);
} catch (_) { } 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()); 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.';
}
}
} }

View File

@@ -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,
),
);
}

View File

@@ -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<BoxShadow> soft = [
BoxShadow(
color: Color(0x0A8C3255),
offset: Offset(0, 1),
blurRadius: 2,
),
BoxShadow(
color: Color(0x0F8C3255),
offset: Offset(0, 8),
blurRadius: 24,
),
];
static const List<BoxShadow> card = [
BoxShadow(
color: Color(0x0D8C3255),
offset: Offset(0, 2),
blurRadius: 6,
),
BoxShadow(
color: Color(0x1A8C3255),
offset: Offset(0, 18),
blurRadius: 40,
),
];
static const List<BoxShadow> button = [
BoxShadow(
color: Color(0x59E17A9D),
offset: Offset(0, 4),
blurRadius: 14,
),
];
}

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1 @@
export 'halo_button.dart';

View File

@@ -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');
},
),
],
),
),
),
),
);
}
}

View File

@@ -1,8 +1,20 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_notifier.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 { class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key}); const LoginScreen({super.key});
@@ -12,61 +24,242 @@ class LoginScreen extends ConsumerStatefulWidget {
class _LoginScreenState extends ConsumerState<LoginScreen> { class _LoginScreenState extends ConsumerState<LoginScreen> {
final _phoneController = TextEditingController(); final _phoneController = TextEditingController();
ProviderSubscription<AsyncValue<MitraAuthData>>? _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<AsyncValue<MitraAuthData>>(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 @override
void dispose() { void dispose() {
_authSub?.close();
_lockoutTimer?.cancel();
_phoneController.dispose(); _phoneController.dispose();
super.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<void> _submit() {
final phone = _e164Phone();
setState(() => _phoneErrorText = null);
return ref.read(mitraAuthProvider.notifier).requestOtp(phone);
}
Future<void> _showRateLimitDialog(MitraAuthError err, {required bool isIp}) {
final retryText = err.retryAfterSeconds != null
? '\n\nCoba lagi dalam ${_formatCountdown(err.retryAfterSeconds!)}.'
: '';
return showDialog<void>(
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final authState = ref.watch(mitraAuthProvider); final authState = ref.watch(mitraAuthProvider);
final isLoading = authState is AsyncLoading; final isLoading = authState is AsyncLoading;
final hasMinDigits = _subscriberDigits().length >= 9;
ref.listen(mitraAuthProvider, (prev, next) { final isLockedOut = _lockoutSeconds > 0;
final data = next.valueOrNull; final canSubmit = hasMinDigits && !isLoading && !isLockedOut;
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())));
}
});
return Scaffold( return Scaffold(
backgroundColor: HaloTokens.bg,
body: SafeArea( body: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.fromLTRB(28, 8, 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( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Text( const Text(
'Halo Bestie Mitra', 'Halo Mitra Bestie',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), style: TextStyle(
textAlign: TextAlign.center, fontFamily: HaloTokens.fontDisplay,
fontSize: 28,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
height: 1.15,
letterSpacing: -0.56,
), ),
const SizedBox(height: 48), ),
TextField( 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, controller: _phoneController,
decoration: const InputDecoration( borderColor: _phoneErrorText != null
labelText: 'Nomor HP', ? HaloTokens.danger
hintText: '+628xxxxxxxxxx', : hasMinDigits
border: OutlineInputBorder(), ? HaloTokens.brand
: HaloTokens.border,
), ),
keyboardType: TextInputType.phone, if (_phoneErrorText != null) ...[
const SizedBox(height: 8),
Text(
_phoneErrorText!,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.danger,
fontSize: 13,
), ),
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<LoginScreen> {
); );
} }
} }
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),
),
),
),
],
),
);
}
}

View File

@@ -1,8 +1,22 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_notifier.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 { class OtpScreen extends ConsumerStatefulWidget {
final String phone; final String phone;
const OtpScreen({super.key, required this.phone}); const OtpScreen({super.key, required this.phone});
@@ -13,9 +27,20 @@ class OtpScreen extends ConsumerStatefulWidget {
class _OtpScreenState extends ConsumerState<OtpScreen> { class _OtpScreenState extends ConsumerState<OtpScreen> {
final List<TextEditingController> _controllers = final List<TextEditingController> _controllers =
List.generate(6, (_) => TextEditingController()); List.generate(_kOtpLength, (_) => TextEditingController());
final List<FocusNode> _focusNodes = List.generate(6, (_) => FocusNode()); final List<FocusNode> _focusNodes =
List.generate(_kOtpLength, (_) => FocusNode());
String? _otpRequestId; String? _otpRequestId;
int _attemptsUsed = 0;
String? _inlineError;
Timer? _cooldownTicker;
int _cooldown = _kResendCooldownSeconds;
bool _isResending = false;
bool _dialogShown = false;
ProviderSubscription<AsyncValue<MitraAuthData>>? _authSub;
@override @override
void initState() { void initState() {
@@ -24,10 +49,44 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
if (data is MitraAuthOtpSentData) { if (data is MitraAuthOtpSentData) {
_otpRequestId = data.otpRequestId; _otpRequestId = data.otpRequestId;
} }
_startCooldown();
_authSub = ref.listenManual<AsyncValue<MitraAuthData>>(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 @override
void dispose() { void dispose() {
_authSub?.close();
_cooldownTicker?.cancel();
for (final c in _controllers) { for (final c in _controllers) {
c.dispose(); c.dispose();
} }
@@ -37,108 +96,404 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
super.dispose(); 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(); 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) { void _onChanged(int index, String value) {
if (value.length == 1 && index < 5) { if (value.isNotEmpty && index < _kOtpLength - 1) {
_focusNodes[index + 1].requestFocus(); _focusNodes[index + 1].requestFocus();
} }
if (_otp.length == 6) { if (value.isEmpty && index > 0) {
_focusNodes[index - 1].requestFocus();
}
if (_otp.length == _kOtpLength) {
_submit(); _submit();
} }
} }
void _onKeyDown(int index, KeyEvent event) { KeyEventResult _onKeyEvent(int index, KeyEvent event) {
if (event is KeyDownEvent && if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.backspace && event.logicalKey == LogicalKeyboardKey.backspace &&
_controllers[index].text.isEmpty && _controllers[index].text.isEmpty &&
index > 0) { index > 0) {
_controllers[index - 1].clear(); _controllers[index - 1].clear();
_focusNodes[index - 1].requestFocus(); _focusNodes[index - 1].requestFocus();
return KeyEventResult.handled;
} }
return KeyEventResult.ignored;
} }
void _submit() { void _submit() {
final otp = _otp; 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); ref.read(mitraAuthProvider.notifier).verifyOtp(_otpRequestId!, otp);
} }
Future<void> _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<void> _showBlockedDialog() {
return showDialog<void>(
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<void> _showResetDialog(MitraAuthError err, {required String title}) {
return showDialog<void>(
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<void> _showWrongFlowDialog(MitraAuthError err) {
return showDialog<void>(
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<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final authState = ref.watch(mitraAuthProvider); final authState = ref.watch(mitraAuthProvider);
final isLoading = authState is AsyncLoading; final isLoading = authState is AsyncLoading;
// Update OTP request id if state changes (e.g. resend) final resendLabel = _cooldown > 0
final data = authState.valueOrNull; ? 'kirim ulang dalam ${_cooldown}s'
if (data is MitraAuthOtpSentData) { : 'kirim ulang kode';
_otpRequestId = data.otpRequestId; final resendEnabled = _cooldown == 0 && !_isResending && !isLoading;
}
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();
}
});
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Masukkan OTP')), backgroundColor: HaloTokens.bg,
body: Padding( appBar: AppBar(
padding: const EdgeInsets.all(24), 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Expanded(
'Kode OTP telah dikirim ke ${widget.phone}', child: LayoutBuilder(
textAlign: TextAlign.center, 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: 32), ),
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( Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(6, (index) { 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,
),
),
),
],
),
],
),
),
),
),
),
),
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<String> 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( return SizedBox(
width: 48, width: 48,
child: KeyboardListener( height: 60,
focusNode: FocusNode(), child: Focus(
onKeyEvent: (event) => _onKeyDown(index, event), canRequestFocus: false,
onKeyEvent: (_, event) => onKeyEvent(event),
child: TextField( child: TextField(
controller: _controllers[index], controller: controller,
focusNode: _focusNodes[index], focusNode: focusNode,
autofocus: autofocus,
textAlign: TextAlign.center, textAlign: TextAlign.center,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
maxLength: 1, maxLength: 1,
style: const TextStyle( style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w700,
color: HaloTokens.ink,
), ),
decoration: const InputDecoration( decoration: InputDecoration(
counterText: '', counterText: '',
border: OutlineInputBorder(), filled: true,
contentPadding: EdgeInsets.symmetric(vertical: 14), fillColor: HaloTokens.surface,
contentPadding: const EdgeInsets.symmetric(vertical: 14),
border: OutlineInputBorder(
borderRadius: HaloRadius.md,
borderSide: BorderSide(color: borderColor, width: 1.5),
), ),
inputFormatters: [ enabledBorder: OutlineInputBorder(
FilteringTextInputFormatter.digitsOnly, borderRadius: HaloRadius.md,
], borderSide: BorderSide(color: borderColor, width: 1.5),
onChanged: (value) => _onChanged(index, value), ),
focusedBorder: OutlineInputBorder(
borderRadius: HaloRadius.md,
borderSide: BorderSide(
color: hasError ? HaloTokens.danger : HaloTokens.brand,
width: 2,
), ),
), ),
);
}),
), ),
const SizedBox(height: 32), inputFormatters: [FilteringTextInputFormatter.digitsOnly],
ElevatedButton( onChanged: onChanged,
onPressed: isLoading ? null : _submit,
child: isLoading
? const CircularProgressIndicator()
: const Text('Verifikasi'),
),
],
), ),
), ),
); );
} }
} }

View File

@@ -4,8 +4,12 @@ import 'package:go_router/go_router.dart';
import '../../core/auth/auth_notifier.dart'; import '../../core/auth/auth_notifier.dart';
import '../../core/status/status_notifier.dart'; import '../../core/status/status_notifier.dart';
import '../../core/chat/chat_request_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 { class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@@ -14,11 +18,13 @@ class HomeScreen extends ConsumerWidget {
final authState = ref.watch(mitraAuthProvider); final authState = ref.watch(mitraAuthProvider);
final authData = authState.valueOrNull; final authData = authState.valueOrNull;
final displayName = authData is MitraAuthAuthenticatedData 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 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) { if (statusState is StatusLoadedData && statusState.isOnline) {
final requestState = ref.watch(chatRequestProvider); final requestState = ref.watch(chatRequestProvider);
if (requestState is ChatRequestIdleData) { 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) { ref.listen(onlineStatusProvider, (prev, next) {
if (next is StatusLoadedData && next.isOnline) { if (next is StatusLoadedData && next.isOnline) {
ref.read(chatRequestProvider.notifier).startListening(); ref.read(chatRequestProvider.notifier).startListening();
@@ -40,81 +45,111 @@ class HomeScreen extends ConsumerWidget {
}); });
return Scaffold( return Scaffold(
appBar: AppBar( backgroundColor: HaloTokens.bg,
title: const Text('Halo Bestie Mitra'), body: SafeArea(
actions: [ child: SingleChildScrollView(
IconButton( padding: const EdgeInsets.fromLTRB(20, 20, 20, 28),
icon: const Icon(Icons.logout),
onPressed: () => ref.read(mitraAuthProvider.notifier).logout(),
),
],
),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)), _Header(displayName: displayName, isOnline: isOnline),
const SizedBox(height: 32), const SizedBox(height: 18),
const _StatusToggle(), 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), const SizedBox(height: 16),
const _ActiveSessionsButton(), // 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',
),
], ],
), ),
), ),
),
); );
} }
} }
class _StatusToggle extends ConsumerWidget { class _Header extends ConsumerWidget {
const _StatusToggle(); final String displayName;
final bool isOnline;
const _Header({required this.displayName, required this.isOnline});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final statusState = ref.watch(onlineStatusProvider); final greetingSuffix = isOnline ? '🌸' : '🌙';
final isOnline = statusState is StatusLoadedData && statusState.isOnline; return Row(
final isLoading = statusState is StatusLoadingData;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Column( Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( const Text(
isOnline ? 'Online' : 'Offline', 'Hei,',
style: TextStyle( style: TextStyle(
fontSize: 18, fontFamily: HaloTokens.fontBody,
fontWeight: FontWeight.bold, fontSize: 13,
color: isOnline ? Colors.green : Colors.grey, color: HaloTokens.inkSoft,
), ),
), ),
Text( Text(
isOnline 'Bestie $displayName $greetingSuffix',
? 'Kamu siap menerima chat' style: const TextStyle(
: 'Aktifkan untuk menerima chat', fontFamily: HaloTokens.fontDisplay,
style: const TextStyle(fontSize: 14, color: Colors.grey), fontSize: 24,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
letterSpacing: -0.4,
),
), ),
], ],
), ),
isLoading ),
? const SizedBox( IconButton(
width: 24, icon: const Icon(Icons.more_horiz, color: HaloTokens.ink),
height: 24, style: IconButton.styleFrom(
child: CircularProgressIndicator(strokeWidth: 2), backgroundColor: HaloTokens.surface,
) shape: const CircleBorder(),
: Switch( ),
value: isOnline, onPressed: () => _showMenu(context, ref),
activeColor: Colors.green, ),
onChanged: (_) { ],
final notifier = ref.read(onlineStatusProvider.notifier); );
if (isOnline) {
notifier.toggleOffline();
} else {
notifier.toggleOnline();
} }
Future<void> _showMenu(BuildContext context, WidgetRef ref) {
return showModalBottomSheet<void>(
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();
}, },
), ),
], ],
@@ -124,91 +159,325 @@ class _StatusToggle extends ConsumerWidget {
} }
} }
class _ActiveSessionsButton extends ConsumerWidget { class _TilesGrid extends ConsumerWidget {
const _ActiveSessionsButton(); const _TilesGrid();
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final unreadCounts = ref.watch(unreadSessionsProvider);
final totalUnread = unreadCounts.values.fold(0, (a, b) => a + b);
return Column(
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'),
),
),
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'),
),
),
],
);
}
}
class _RequestHistoryButton extends ConsumerWidget {
const _RequestHistoryButton();
@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); ref.watch(chatRequestProvider);
final count = ref.read(chatRequestProvider.notifier).activeRequestCount; final undanganCount =
ref.read(chatRequestProvider.notifier).activeRequestCount;
final hasPending = count > 0; return Row(
final trailing = hasPending
? Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( Expanded(
padding: child: _DarkTile(
const EdgeInsets.symmetric(horizontal: 8, vertical: 2), icon: '📨',
decoration: BoxDecoration( label: 'Undangan',
color: Colors.red, subtitle:
borderRadius: BorderRadius.circular(999), undanganCount > 0 ? 'Menunggu: $undanganCount' : 'Belum ada',
), badgeCount: undanganCount,
child: Text(
'$count',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
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'), 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,
),
),
],
);
}
}
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) {
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: [
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(
'$badgeCount',
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
);
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),
],
),
),
),
); );
} }
} }

View File

@@ -9,6 +9,7 @@ import 'core/status/status_notifier.dart';
import 'core/chat/chat_request_notifier.dart'; import 'core/chat/chat_request_notifier.dart';
import 'core/chat/widgets/chat_request_overlay.dart'; import 'core/chat/widgets/chat_request_overlay.dart';
import 'core/notifications/notification_service.dart'; import 'core/notifications/notification_service.dart';
import 'core/theme/halo_theme.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
import 'router.dart'; import 'router.dart';
@@ -111,6 +112,7 @@ class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
return ChatRequestOverlay( return ChatRequestOverlay(
child: MaterialApp.router( child: MaterialApp.router(
title: 'Halo Bestie Mitra', title: 'Halo Bestie Mitra',
theme: haloThemeData(),
routerConfig: router, routerConfig: router,
), ),
); );

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'core/auth/auth_notifier.dart'; import 'core/auth/auth_notifier.dart';
import 'features/splash/splash_screen.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/login_screen.dart';
import 'features/auth/screens/otp_screen.dart'; import 'features/auth/screens/otp_screen.dart';
import 'features/home/home_screen.dart'; import 'features/home/home_screen.dart';
@@ -33,7 +34,8 @@ GoRouter buildRouter(Ref ref) {
final authState = ref.read(mitraAuthProvider); final authState = ref.read(mitraAuthProvider);
final isSplash = state.matchedLocation == '/splash'; final isSplash = state.matchedLocation == '/splash';
final isAuthRoute = state.matchedLocation.startsWith('/login') || 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 // Show splash only during initial load — don't redirect away from auth routes
if (authState is AsyncLoading) { if (authState is AsyncLoading) {
@@ -60,6 +62,7 @@ GoRouter buildRouter(Ref ref) {
GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()), GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()),
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
GoRoute(path: '/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)), 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: '/home', builder: (_, __) => const HomeScreen()),
GoRoute(path: '/sessions', builder: (_, __) => const ActiveSessionsScreen()), GoRoute(path: '/sessions', builder: (_, __) => const ActiveSessionsScreen()),
GoRoute(path: '/chat/session/:sessionId', builder: (context, state) { GoRoute(path: '/chat/session/:sessionId', builder: (context, state) {

View File

@@ -33,6 +33,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.4" version: "0.13.4"
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -358,6 +366,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.20.5" 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: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -559,6 +575,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
io: io:
dependency: transitive dependency: transitive
description: description:
@@ -783,6 +807,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.2" version: "1.5.2"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:

View File

@@ -40,14 +40,49 @@ dev_dependencies:
build_runner: ^2.4.13 build_runner: ^2.4.13
custom_lint: ^0.7.0 custom_lint: ^0.7.0
riverpod_lint: ^2.6.2 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 # 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. # 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. # See halo_lints/lib/halo_lints.dart and mitra_app/CLAUDE.md → Pitfalls.
halo_lints: halo_lints:
path: ../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: flutter:
uses-material-design: true uses-material-design: true
assets: assets:
- assets/images/ - assets/images/
- assets/images/splash/ - 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

67
requirement/flow_mitra.md Normal file
View File

@@ -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

View File

@@ -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<br/>valid?"}
Token -->|"yes"| Home["→ Home (skip auth)"]
Token -->|"no / expired"| Login["S3a · Input WhatsApp<br/><i>login_screen.dart</i>"]
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<br/>'Format nomor salah'"]
ErrPhone --> Login
ReqOk -->|"429 OTP_COOLDOWN"| ErrCool["🆕 Snackbar w/ countdown<br/>'Tunggu N detik' (retry_after_seconds)"]
ErrCool --> Login
ReqOk -->|"429 OTP_RATE_LIMIT_PHONE<br/>(3 reqs / hour)"| ErrPhoneLim["🆕 Popup · 'Terlalu banyak<br/>permintaan untuk nomor ini'<br/>+ retry-after timer"]
ErrPhoneLim --> Login
ReqOk -->|"429 OTP_RATE_LIMIT_IP<br/>(10 reqs / hour)"| ErrIpLim["🆕 Popup · 'Terlalu banyak<br/>permintaan dari jaringan ini'<br/>+ 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)<br/><i>otp_screen.dart</i><br/>🆕 + 'Kirim ulang kode' (60s cooldown)<br/>🆕 + '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<br/>(store tokens)"]
Resp -->|"422 CODE_INVALID"| BadFmt["🆕 Inline · 'Kode harus 6 digit'"]
BadFmt --> Otp
Resp -->|"401 CODE_MISMATCH<br/>(attempts &lt; 5)"| Wrong["🆕 Clear fields · focus 1st<br/>'Kode salah · tersisa N percobaan'"]
Wrong --> Otp
Resp -->|"429 OTP_ATTEMPTS_EXCEEDED<br/>(5th wrong attempt)"| Blocked["🆕 Popup · 'Terlalu banyak percobaan'<br/>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<br/>(&gt; 5 min)"| Exp["🆕 Popup · 'Kode kadaluarsa'<br/>CTA: Minta kode baru"]
Exp --> ResendNew
Resp -->|"409 OTP_USED"| Used["🆕 Popup · 'Kode sudah dipakai'<br/>CTA: Minta kode baru"]
Used --> ResendNew
Resp -->|"400 WRONG_FLOW<br/>(non-mitra OTP)"| Wrong2["🆕 Popup · 'Bukan akun mitra'"]
Wrong2 --> ResendNew
Resp -->|"403 ACCOUNT_INACTIVE<br/>(code correct but mitra not approved)"| Inactive["🆕 Full-screen · 'Akun belum aktif'<br/>CTAs: WhatsApp admin / Telegram admin<br/>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<br/>starts on every request"]
Timer --> Btn{"Cooldown done?"}
Btn -->|"no"| Disabled["'Kirim ulang dalam Ns'<br/>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<br/>reset local attempts counter<br/>restart 60s timer"]
Reset --> Timer
ReReq -->|"429 OTP_COOLDOWN"| BackendCool["🆕 Snackbar w/ server retry_after<br/>(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<br/><i>BestieHomeOffline</i>"]
Status -->|"online"| HomeOn["Home · standby (online)<br/><i>BestieHome online=true</i>"]
HomeOff -- "Ganti Status" --> HomeOn
HomeOn -- "Ganti Status" --> HomeOff
HomeOn --> Tabs["Bottom nav<br/><i>BestieTabBar</i>"]
HomeOff --> Tabs
Tabs -->|"Home"| HomeOn
Tabs -->|"Chat"| Undangan
Tabs -->|"Profil"| Profil
HomeOn -->|"tap Undangan tile"| Undangan["Undangan · Curhat Baru tab<br/><i>BestieInvites</i>"]
HomeOn -->|"tap Perpanjang tile"| UndanganExt["Undangan · Perpanjang tab<br/><i>BestieInvitesExtend</i>"]
HomeOn -->|"tap Profil"| Profil["Profil<br/><i>BestieProfile</i>"]
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<br/><i>BestieInvites</i><br/>(new-client cards, brand pink)"]
Tabs -->|"Perpanjang Curhat"| InvExt["Undangan · Perpanjang<br/><i>BestieInvitesExtend</i><br/>(amber accent, +mins badge)"]
InvNew -->|"Tolak"| Home1["← back to Home"]
InvExt -->|"Tolak"| Home1
InvNew -->|"Terima"| ChatActive
InvExt -->|"Terima Perpanjangan"| ChatActive["Chat · sesi aktif<br/><i>BestieChatV5 ended=false</i>"]
```
> 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<br/><i>BestieIncomingPopup variant=new</i><br/>(brand pink, 30s window)"]
Push -->|"perpanjang"| PopExt["Popup · Perpanjang<br/><i>BestieIncomingPopup variant=extend</i><br/>(amber, 10s auto-accept)"]
PopNew -->|"Tolak"| Idle
PopExt -->|"Tolak"| Idle
PopNew -->|"Terima Sekarang"| Chat
PopExt -->|"Terima · +N mnt"| Chat
Chat["Chat · sesi aktif<br/><i>BestieChatV5 ended=false</i><br/>(SISA WAKTU pill, input bar)"]
Chat -->|"timer hits 00:00"| Ended["Chat · durasi habis<br/><i>BestieChatV5 ended=true</i><br/>(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.

View File

@@ -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 A1A4) 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 34
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<String, String> 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<String, String>` 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; // 05, 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.