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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
116
mitra_app/.maestro/flows/README_section_A.md
Normal file
116
mitra_app/.maestro/flows/README_section_A.md
Normal 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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
13
mitra_app/.maestro/scripts/peek_otp.js
Normal file
13
mitra_app/.maestro/scripts/peek_otp.js
Normal 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
|
||||||
12
mitra_app/.maestro/scripts/reset_phone.js
Normal file
12
mitra_app/.maestro/scripts/reset_phone.js
Normal 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}`)
|
||||||
|
}
|
||||||
17
mitra_app/.maestro/scripts/seed_mitra.js
Normal file
17
mitra_app/.maestro/scripts/seed_mitra.js
Normal 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}`)
|
||||||
|
}
|
||||||
BIN
mitra_app/assets/fonts/BricolageGrotesque-Variable.ttf
Normal file
BIN
mitra_app/assets/fonts/BricolageGrotesque-Variable.ttf
Normal file
Binary file not shown.
BIN
mitra_app/assets/fonts/JetBrainsMono-Variable.ttf
Normal file
BIN
mitra_app/assets/fonts/JetBrainsMono-Variable.ttf
Normal file
Binary file not shown.
BIN
mitra_app/assets/fonts/Poppins-Bold.ttf
Normal file
BIN
mitra_app/assets/fonts/Poppins-Bold.ttf
Normal file
Binary file not shown.
BIN
mitra_app/assets/fonts/Poppins-Medium.ttf
Normal file
BIN
mitra_app/assets/fonts/Poppins-Medium.ttf
Normal file
Binary file not shown.
BIN
mitra_app/assets/fonts/Poppins-Regular.ttf
Normal file
BIN
mitra_app/assets/fonts/Poppins-Regular.ttf
Normal file
Binary file not shown.
BIN
mitra_app/assets/fonts/Poppins-SemiBold.ttf
Normal file
BIN
mitra_app/assets/fonts/Poppins-SemiBold.ttf
Normal file
Binary file not shown.
15
mitra_app/assets/fonts/README.md
Normal file
15
mitra_app/assets/fonts/README.md
Normal 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`.
|
||||||
@@ -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.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
316
mitra_app/lib/core/theme/halo_theme.dart
Normal file
316
mitra_app/lib/core/theme/halo_theme.dart
Normal 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
132
mitra_app/lib/core/theme/halo_tokens.dart
Normal file
132
mitra_app/lib/core/theme/halo_tokens.dart
Normal 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,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
152
mitra_app/lib/core/theme/widgets/halo_button.dart
Normal file
152
mitra_app/lib/core/theme/widgets/halo_button.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
mitra_app/lib/core/theme/widgets/widgets.dart
Normal file
1
mitra_app/lib/core/theme/widgets/widgets.dart
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export 'halo_button.dart';
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/auth/auth_notifier.dart';
|
||||||
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
|
import '../../../core/theme/widgets/widgets.dart';
|
||||||
|
|
||||||
|
/// Terminal state shown when OTP verification succeeds but the mitra's
|
||||||
|
/// account is not yet approved (`ACCOUNT_INACTIVE` 403 from the backend).
|
||||||
|
///
|
||||||
|
/// Mitras are onboarded internally and reach out via their existing
|
||||||
|
/// internal channel — no public WhatsApp/Telegram CTAs here.
|
||||||
|
class AccountInactiveScreen extends ConsumerWidget {
|
||||||
|
const AccountInactiveScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return PopScope(
|
||||||
|
canPop: false,
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: HaloTokens.bg,
|
||||||
|
body: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(28, 8, 28, 28),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
'⏳',
|
||||||
|
style: TextStyle(fontSize: 56),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
'akun belum aktif',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: HaloTokens.fontDisplay,
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: HaloTokens.brandDark,
|
||||||
|
height: 1.15,
|
||||||
|
letterSpacing: -0.56,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'tim halobestie sedang memverifikasi akun kamu. '
|
||||||
|
'hubungi koordinator kalau butuh update.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: HaloTokens.fontBody,
|
||||||
|
fontSize: 14.5,
|
||||||
|
color: HaloTokens.inkSoft,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
HaloButton(
|
||||||
|
label: 'pakai nomor lain',
|
||||||
|
variant: HaloButtonVariant.secondary,
|
||||||
|
fullWidth: true,
|
||||||
|
onPressed: () async {
|
||||||
|
await ref.read(mitraAuthProvider.notifier).logout();
|
||||||
|
if (context.mounted) context.go('/login');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
67
requirement/flow_mitra.md
Normal 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
|
||||||
|
|
||||||
|
|
||||||
231
requirement/flow_mitra.mermaid.md
Normal file
231
requirement/flow_mitra.mermaid.md
Normal 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 < 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/>(> 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.
|
||||||
402
requirement/phase4-mitra-prehome-plan.md
Normal file
402
requirement/phase4-mitra-prehome-plan.md
Normal 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 A1–A4) and the
|
||||||
|
> "Implementation gaps (mitra_app)" table in
|
||||||
|
> [flow_mitra.mermaid.md §A](flow_mitra.mermaid.md).
|
||||||
|
|
||||||
|
> Spec sources:
|
||||||
|
> - PRD / flow: [flow_mitra.md §A](flow_mitra.md)
|
||||||
|
> - Diagrams: [flow_mitra.mermaid.md §A](flow_mitra.mermaid.md)
|
||||||
|
>
|
||||||
|
> Backend behavior is already correct — every error code, retry-after detail,
|
||||||
|
> and `ACCOUNT_INACTIVE` gate is in
|
||||||
|
> [`otp.service.js`](../backend/src/services/otp.service.js) and
|
||||||
|
> [`mitra.auth.routes.js`](../backend/src/routes/public/mitra.auth.routes.js).
|
||||||
|
> **No backend changes in this plan.** All work is mitra_app-side.
|
||||||
|
|
||||||
|
This document is the build sequence: **what** files change, **in what order**,
|
||||||
|
with **state-machine contracts** and **error-code routing** spelled out per
|
||||||
|
screen. The "why" is in the PRD — don't restate it here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build Order (5 stages — Stage 2 dropped during impl)
|
||||||
|
|
||||||
|
Stage 1 is foundation (typed error) that everything else builds on. Stages 3–4
|
||||||
|
are the two screens. Stage 5 adds the terminal state. Stage 6 is verification.
|
||||||
|
|
||||||
|
1. **Typed auth-error model** — replace string `AsyncError` with structured `MitraAuthError`
|
||||||
|
2. ~~**Admin contact helper** — single source of truth for WA / Telegram URLs + `url_launcher` wrapper~~ — **dropped**: mitras are internal-only, no public admin CTA needed on pre-home screens.
|
||||||
|
3. **S3a · Input WhatsApp** — replace generic snackbar with code-routed handling
|
||||||
|
4. **S3b · OTP verification** — resend timer, attempts hint, six new dialog states
|
||||||
|
5. **AccountInactiveScreen + router wiring** — terminal full-screen state for `ACCOUNT_INACTIVE`
|
||||||
|
6. **Manual verification** — drive every error path with `OTP_STATIC_CODE` + CC toggles
|
||||||
|
|
||||||
|
Each stage is independently mergeable. Stage 4 is the largest single change
|
||||||
|
(~150 LoC across one file).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Stage 1 — Typed auth-error model
|
||||||
|
|
||||||
|
The current
|
||||||
|
[`auth_notifier.dart`](../mitra_app/lib/core/auth/auth_notifier.dart)
|
||||||
|
flattens every error into `AsyncError(string)` via `_otpRequestMessage` /
|
||||||
|
`_otpVerifyMessage`. The screen can read the localized message but loses the
|
||||||
|
error **code** and the `retry_after_seconds` detail — both of which the UI
|
||||||
|
needs to pick between snackbar / dialog / inline / full-screen, and to render
|
||||||
|
countdowns.
|
||||||
|
|
||||||
|
## 1.1 New class
|
||||||
|
|
||||||
|
> **New:** in [`auth_notifier.dart`](../mitra_app/lib/core/auth/auth_notifier.dart),
|
||||||
|
> alongside the existing `MitraAuthData` sealed hierarchy.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class MitraAuthError implements Exception {
|
||||||
|
final String code; // e.g. 'OTP_COOLDOWN', 'ACCOUNT_INACTIVE'
|
||||||
|
final String message; // localized, ready to show as-is
|
||||||
|
final int? retryAfterSeconds;
|
||||||
|
|
||||||
|
const MitraAuthError(this.code, this.message, {this.retryAfterSeconds});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => message; // preserves existing snackbar fallback behavior
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `toString()` override means any screen that hasn't been updated yet will
|
||||||
|
keep working (just rendering the message string).
|
||||||
|
|
||||||
|
## 1.2 Parse helpers
|
||||||
|
|
||||||
|
Replace `_otpRequestMessage` / `_otpVerifyMessage` with a single
|
||||||
|
`_buildError(DioException e, Map<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; // 0–5, drives "Tersisa N percobaan"
|
||||||
|
String? _inlineError; // for CODE_INVALID
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.2 Cooldown timer
|
||||||
|
|
||||||
|
- Start in `initState` (cooldown begins the moment we land on this screen).
|
||||||
|
- `Timer.periodic(const Duration(seconds: 1), ...)` decrements `_cooldown`
|
||||||
|
until 0, then cancels itself.
|
||||||
|
- Cancel in `dispose()`. **Do not** touch ref in dispose — see
|
||||||
|
[`mitra_app/CLAUDE.md`](../mitra_app/CLAUDE.md) "no_ref_in_dispose" rule.
|
||||||
|
This timer doesn't need ref, so plain `dispose()` is fine.
|
||||||
|
|
||||||
|
## 4.3 Resend button
|
||||||
|
|
||||||
|
Below the OTP fields, above the verify button:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
TextButton(
|
||||||
|
onPressed: _cooldown > 0 ? null : _resend,
|
||||||
|
child: Text(_cooldown > 0 ? 'Kirim ulang dalam ${_cooldown}s' : 'Kirim ulang kode'),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`_resend` calls `ref.read(mitraAuthProvider.notifier).requestOtp(widget.phone)`
|
||||||
|
and on success listener fires (`MitraAuthOtpSentData` re-emitted with a new
|
||||||
|
`otp_request_id`), the screen:
|
||||||
|
- resets `_attemptsUsed = 0`
|
||||||
|
- resets `_cooldown = 60` and restarts the ticker
|
||||||
|
- clears the input fields
|
||||||
|
|
||||||
|
## 4.4 Attempts-remaining hint
|
||||||
|
|
||||||
|
A small text widget below the input row:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
if (_attemptsUsed > 0)
|
||||||
|
Text('Tersisa ${5 - _attemptsUsed} percobaan',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: ...))
|
||||||
|
```
|
||||||
|
|
||||||
|
Incremented in the `CODE_MISMATCH` branch (Stage 4.5).
|
||||||
|
|
||||||
|
## 4.5 `ref.listen` rewrite
|
||||||
|
|
||||||
|
```dart
|
||||||
|
if (next is AsyncError && next.error is MitraAuthError) {
|
||||||
|
final err = next.error as MitraAuthError;
|
||||||
|
switch (err.code) {
|
||||||
|
case 'CODE_INVALID':
|
||||||
|
setState(() => _inlineError = err.message);
|
||||||
|
break;
|
||||||
|
case 'CODE_MISMATCH':
|
||||||
|
setState(() {
|
||||||
|
_attemptsUsed += 1;
|
||||||
|
_inlineError = 'Kode salah. Tersisa ${5 - _attemptsUsed} percobaan';
|
||||||
|
});
|
||||||
|
_clearFieldsAndFocus();
|
||||||
|
break;
|
||||||
|
case 'OTP_ATTEMPTS_EXCEEDED':
|
||||||
|
await _showBlockedDialog();
|
||||||
|
break;
|
||||||
|
case 'OTP_EXPIRED':
|
||||||
|
case 'OTP_USED':
|
||||||
|
await _showResetDialog(err);
|
||||||
|
break;
|
||||||
|
case 'WRONG_FLOW':
|
||||||
|
await _showWrongFlowDialog(err);
|
||||||
|
break;
|
||||||
|
case 'ACCOUNT_INACTIVE':
|
||||||
|
if (mounted) context.go('/auth/inactive');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(err.message)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.6 Dialog helpers
|
||||||
|
|
||||||
|
- **`_showBlockedDialog`** — "Terlalu banyak percobaan". Single CTA: "Minta
|
||||||
|
kode baru" (`context.pop()` → returns to S3a). No admin CTA (Stage 2 note).
|
||||||
|
- **`_showResetDialog`** — used by `OTP_EXPIRED` and `OTP_USED`. Single CTA
|
||||||
|
"Minta kode baru" → `context.pop()`.
|
||||||
|
- **`_showWrongFlowDialog`** — "Bukan akun mitra. Pastikan kamu pakai app yang
|
||||||
|
benar." Single CTA → `context.pop()`.
|
||||||
|
|
||||||
|
All dialogs use `barrierDismissible: false` — the user must choose a recovery
|
||||||
|
path.
|
||||||
|
|
||||||
|
## 4.7 Acceptance
|
||||||
|
|
||||||
|
- 5× wrong code → hint decrements, blocked dialog on 5th, "Minta kode baru"
|
||||||
|
pops to S3a.
|
||||||
|
- Wait 5 min after request, then submit → expired dialog.
|
||||||
|
- Resend within 60s of first request → cooldown blocks the button.
|
||||||
|
- After cooldown, tap resend → fields clear, hint clears, timer restarts.
|
||||||
|
- Submit code for a mitra with `is_active=false` → lands on AccountInactive.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Stage 5 — AccountInactiveScreen + router
|
||||||
|
|
||||||
|
A terminal full-screen state. No back nav; the only way out is to contact
|
||||||
|
admin or sign in with a different phone.
|
||||||
|
|
||||||
|
## 5.1 New file
|
||||||
|
|
||||||
|
> **New:** `mitra_app/lib/features/auth/screens/account_inactive_screen.dart`
|
||||||
|
|
||||||
|
Wrapped in `PopScope(canPop: false)` so the system back button can't escape
|
||||||
|
the terminal state. Body: icon + title + body copy directing the mitra to
|
||||||
|
their internal coordinator, and a single "Pakai nomor lain" text button that
|
||||||
|
logs out and pops back to S3a. No public admin CTAs (Stage 2 note).
|
||||||
|
|
||||||
|
## 5.2 Router updates
|
||||||
|
|
||||||
|
In [`router.dart`](../mitra_app/lib/router.dart):
|
||||||
|
|
||||||
|
1. Add `GoRoute(path: '/auth/inactive', builder: (_, __) => const AccountInactiveScreen())`.
|
||||||
|
2. Update `redirect` to recognize `/auth/inactive` as an auth-route (so the
|
||||||
|
guard doesn't bounce the unauthenticated user away):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final isAuthRoute = state.matchedLocation.startsWith('/login') ||
|
||||||
|
state.matchedLocation.startsWith('/otp') ||
|
||||||
|
state.matchedLocation.startsWith('/auth');
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.3 Acceptance
|
||||||
|
|
||||||
|
- Verify with correct code against a mitra whose `is_active=false` →
|
||||||
|
AccountInactive renders. No back arrow, system back blocked by `PopScope`.
|
||||||
|
- Tap "Pakai nomor lain" → logged out, S3a shown.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Stage 6 — Manual verification
|
||||||
|
|
||||||
|
No automated tests this round — pre-home OTP isn't currently covered by
|
||||||
|
Maestro (existing `ts-customer-*` flows are client_app-side). One mitra-side
|
||||||
|
Maestro flow could be added later as a follow-up.
|
||||||
|
|
||||||
|
## 6.1 Pre-conditions
|
||||||
|
|
||||||
|
- Backend running with `OTP_STATIC_CODE=111111` in env so we can submit a
|
||||||
|
predictable code without round-tripping `/internal/_test/peek-otp`.
|
||||||
|
- Seed two test mitras via control center:
|
||||||
|
- **Mitra A** — `+628111111111`, `is_active=true`
|
||||||
|
- **Mitra B** — `+628222222222`, `is_active=false`
|
||||||
|
|
||||||
|
## 6.2 Scenarios
|
||||||
|
|
||||||
|
| # | Trigger | Expected UI |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | S3a, submit `12345` (invalid format) | Inline TextField error "Nomor HP tidak valid." |
|
||||||
|
| 2 | S3a, submit valid phone twice within 60s | 2nd submit → snackbar "Tunggu Ns…" |
|
||||||
|
| 3 | S3a, submit valid phone 4× within 1h | 4th submit → phone rate-limit dialog with retry-after |
|
||||||
|
| 4 | S3a, submit Mitra A's phone | Pushes to S3b, 60s countdown visible |
|
||||||
|
| 5 | S3b, submit wrong code 4× | "Tersisa N percobaan" decrements 4→1 |
|
||||||
|
| 6 | S3b, submit wrong code 5th time | Blocked dialog with two CTAs |
|
||||||
|
| 7 | S3b, wait 5+ min then submit | Expired dialog → "Minta kode baru" pops to S3a |
|
||||||
|
| 8 | S3b, wait full 60s | Resend button enables; tapping resets attempts + timer |
|
||||||
|
| 9 | S3b, submit `111111` for Mitra A | Authenticated → /home |
|
||||||
|
| 10 | S3b, submit `111111` for Mitra B | AccountInactive screen |
|
||||||
|
| 11 | AccountInactive, tap "Pakai nomor lain" | Logged out, S3a shown |
|
||||||
|
| 12 | AccountInactive, press system back | Blocked by `PopScope` |
|
||||||
|
|
||||||
|
## 6.3 Sign-off
|
||||||
|
|
||||||
|
All 12 scenarios pass on a physical Android device (API 28+).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **Resend countdown on backgrounding** — current plan uses elapsed-ticks not
|
||||||
|
wall-clock. If a mitra backgrounds the app for 90s, comes back, the timer
|
||||||
|
will still show "remaining" instead of immediately enabling. Acceptable for
|
||||||
|
v1; revisit if it causes confusion.
|
||||||
|
- **Maestro coverage** — out of scope here, but mitra-side onboarding has
|
||||||
|
zero E2E coverage today. Worth adding `ts-mitra-01-prehome-otp.yaml` once
|
||||||
|
the screens stabilize.
|
||||||
Reference in New Issue
Block a user