---
url: https://docs.nemo12.com/architecture/sdd-055-nemo-ielts-app.md
description: >-
  Bản nháp NEMO IELTS (SDD-055): app Flutter trên backend chung, công nghệ, giao
  diện, token và các đợt code theo PRD-010.
---

# SDD-055 - NEMO IELTS (Flutter)

Yêu cầu: [PRD-010](../product/prd-010-nemo-ielts-app.md). Trạng thái **draft 0.2**: đợt 0 đã code
ngày 28.09.2026 ở `apps/ielts-mobile/` (mã Dart, test, workflow `ielts-mobile.yml`) và
`workers/api/src/modules/auth/`; chưa build trên máy thật vì thư mục `ios/` `android/` chỉ sinh
được bằng `flutter create` trên máy có Flutter + Xcode (README của app). Thiết kế này ưu tiên đường ngắn nhất tới TestFlight (PRD-010 §3, đợt 0).

## 1. Hình dạng tổng thể

```
apps/ielts-mobile/              Flutter (Dart), một codebase
  ├─ ios/  android/             project native do `flutter create` sinh
  └─ lib/
       api/                     client HTTP gõ tay theo OpenAPI của workers/api
       auth/                    Google + Apple → POST /v1/auth/* → token → secure storage
       features/home|reading|listening|speaking|writing|progress|account
       design/                  token DS-001 dịch sang ThemeData
        │  HTTPS, Authorization: Bearer <token>
        ▼
api.nemo12.com/v1/**            workers/api (Hono) - KHÔNG có module "mobile" riêng
        │
D1 nemo12-platform · R2 (audio) - không bảng mới cho nội dung học
```

Nguyên tắc chọn:

* **Không có backend thứ hai** (REQ-MOB-02). App gọi đúng các route learn đang gọi:
  `/v1/learners/{id}/ielts-*`, `/v1/public/ielts/reading|listening/*`, `/v1/me`. Mọi quyền truy
  cập vẫn đi qua `canAccessLearner`, nên app không mở thêm cửa nào.
* **Đổi backend chỉ ở bốn chỗ**: đăng nhập Apple (§4), nhận nhiều Google client id (§4), token
  mới khi xoay phiên cho client Bearer (§4), và bảng token thiết bị cho push (§8, đợt 1). Mỗi chỗ có test trong `workers/api` theo skill `api-test`.
* **Thư mục `apps/ielts-mobile/` không có `package.json`**, nên các cổng Node (`check-design-stack`,
  build CI của các app web) tự bỏ qua nó. Cổng riêng của Flutter ở §10.

## 2. Công nghệ (REQ-MOB-01)

| Lớp | Chọn | Vì sao |
| --- | --- | --- |
| Framework | Flutter stable mới nhất; `pubspec.lock` commit sau lần `pub get` đầu tiên trên Mac | Chỉ đạo chủ dự án; "bản mới nhất" = nâng rồi ghim (luật SRC-650 áp tinh thần) |
| State | `flutter_riverpod` | Ít boilerplate, test được không cần widget |
| Điều hướng | `go_router` | Deep link `nemoielts://` và universal link về sau |
| HTTP | `dio` + interceptor gắn Bearer và xử lý 401 | Một chỗ duy nhất đổi token |
| Lưu an toàn | `flutter_secure_storage` (Keychain / Keystore) | REQ-MOB-04 |
| Đăng nhập | `google_sign_in`, `sign_in_with_apple` | §4 |
| Audio | `just_audio` + `audio_session` (phát khi khoá màn), `record` (ghi) | REQ-MOB-06, REQ-MOB-09 |
| Lưu offline | `drift` (SQLite) | Đợt 2, REQ-MOB-12 |
| Push | `firebase_messaging` | Đợt 1, REQ-MOB-11; FCM gửi được cả APNs |

Không thêm thư viện UI bên thứ ba (không `getwidget`, không bộ component trả phí): widget Material
3 của Flutter + theme dịch từ token là đủ, đúng tinh thần "không thêm thứ tư" của DS-001.

## 3. Giao diện và token (REQ-MOB-16)

* Một script (chưa dựng, dự định đặt ở `scripts/tokens-to-dart.mjs`) đọc token CSS của `packages/design-system` và sinh
  `lib/design/tokens.g.dart` (màu sáng/tối, cỡ chữ, bo góc, khoảng cách, thời lượng chuyển động).
  Sinh bằng máy để web và app không lệch; file sinh có dòng "không sửa tay".
* Chuyển động dùng `AnimatedSwitcher` / implicit animation của Flutter với đúng thời lượng và
  easing của preset `Motion.tsx`.
* Luật tối giản SRC-048: full-width, ít element, Home chỉ một việc.

## 4. Đăng nhập (REQ-MOB-03, REQ-MOB-04)

**Chọn Google + Sign in with Apple, vì đây là đường ngắn nhất:**

1. Backend **đã có** `POST /v1/auth/google` với `client: "mobile"` trả `token`, và middleware đã
   nhận `Authorization: Bearer` (`workers/api/src/shared/middleware.ts`). Google trên app gần như
   không cần sửa backend.
2. App Store Review 4.8 bắt buộc: app có đăng nhập Google thì phải có Sign in with Apple. Không
   có Apple là bị từ chối, nên đây không phải lựa chọn mà là điều kiện.
3. Phương án thay thế (email + OTP) tránh được 4.8 nhưng phải dựng luồng mới, gửi thư mỗi lần
   đăng nhập, và learner đã quen nút Google trên web. Dài hơn.

Việc ở backend (đã code 28.09.2026, `workers/api/src/modules/auth/`):

| Thay đổi | Chi tiết |
| --- | --- |
| Nhận nhiều Google audience | `GOOGLE_CLIENT_ID` là một chuỗi. Đã thêm `GOOGLE_MOBILE_CLIENT_IDS` (iOS client id, Android client id); `verifyGoogleIdToken` nhận `aud` thuộc tập web ∪ mobile **chỉ khi `client: "mobile"`**; `client: "web"` vẫn chỉ nhận client id web, để token phát cho app không đổi được lấy cookie trình duyệt (rà soát 05.10.2026). Vẫn kiểm `iss`, `exp`, `email_verified` như cũ |
| `POST /v1/auth/apple` | Nhận `identity_token` (JWT) + `name` (Apple chỉ gửi tên ở lần đầu). Kiểm chữ ký bằng JWKS `https://appleid.apple.com/auth/keys` (jose, đã có), `iss = https://appleid.apple.com`, `aud = APPLE_BUNDLE_ID` (`com.nemo12.ielts`), ghim RS256, `exp`, và **nonce bắt buộc**: app gửi chuỗi gốc trong trường `nonce`, Apple ký SHA-256 của nó vào token, server đối chiếu, nên token bị lấy cắp không phát lại được. Thiếu `APPLE_BUNDLE_ID` thì trả 503, không bao giờ xác thực. Rate limit + `audit_log 'auth.apple.rejected'` y như Google |
| Tài khoản | Khớp theo `(provider, subject)` trong `auth_identities` có sẵn (cột `provider` nhận `'apple'`), **không cần migration**. Luật chống chiếm tài khoản (RISK-001) giữ nguyên: email đã thuộc một user mà danh tính Apple chưa từng gắn thì trả `CONFLICT`, không tự gộp. Nên người đã học trên web bằng Google phải chọn Google trên app; màn đăng nhập iOS đặt nút Google lên trên kèm dòng "Đã học trên web bằng Google? Chọn Google để giữ tiến độ." Apple "Hide My Email" thì thành tài khoản mới |
| Xoay phiên | Phiên xoay sau 7 ngày (REQ-ACC-02) trước đây chỉ gửi token mới qua `Set-Cookie`, nên client Bearer sẽ bị đăng xuất sau 7 ngày + 60 giây ân hạn. Nay `requireSession` trả thêm header `X-Session-Token` khi request dùng Bearer (token lấy từ Bearer, nên người nhận đã cầm token cũ); app thay token trong secure storage. Header KHÔNG nằm trong `Access-Control-Expose-Headers`, nên JS chéo origin trên trình duyệt không đọc được nó |
| Xoá tài khoản (REQ-MOB-15) | App gửi `POST /v1/privacy/deletion-requests` (`subject_type: 'user'`), luồng xoá có sẵn xử lý. Còn nợ: với tài khoản Apple phải gọi thêm `https://appleid.apple.com/auth/revoke`, cần khoá `.p8` của Sign in with Apple, làm trước khi nộp App Review (đợt 2) |

Phiên: token mobile giữ trong secure storage. Hạn phiên theo `SESSION_MAX_AGE_S` hiện có, gia hạn
trượt khi app gọi API (cùng cơ chế cookie web đang làm). 401 → xoá token, về màn đăng nhập.

## 5. Home và luồng học (REQ-MOB-05..08)

| Màn | API (đã có) |
| --- | --- |
| Home, một việc tiếp theo | Journey Engine (SDD-045) + `/v1/learners/{id}/ielts-learning-plan` |
| Setup lần đầu | `/v1/learners/{id}/ielts-setup` |
| Reading / Listening | `/v1/public/ielts/reading|listening/{cluster}/{seq}` (+ `/audio`), nộp `POST .../ielts-practice-attempts` |
| Progress | `/v1/learners/{id}/ielts-model`, `ielts-effort`, `ielts-journey` |
| Membership (chỉ đọc) | `/v1/learners/{id}/ielts-membership` |

Audio đang gác bằng `requireSession`; `just_audio` gửi header `Authorization` được, nên không
cần URL ký. Thẻ `<audio>` trên web cần cookie (SRC-885), app thì không.

Mọi request từ app gắn header `X-Nemo12-Client: ios|android/<version>` để đo PRD-010 §7 và để
backend từ chối được một bản app quá cũ về sau (426 + màn "cập nhật app").

## 6. Speaking và Writing (REQ-MOB-09, REQ-MOB-10, đợt 1)

* Ghi âm AAC 64 kbps mono vào file tạm trong thư mục app, rồi upload lên
  `/v1/learners/{id}/ielts-recordings`. App bị đưa xuống nền thì file vẫn còn; lần mở sau hỏi
  "Nộp bài ghi dở?".
* Writing tự lưu nháp mỗi 5 giây vào bộ nhớ máy, nộp qua `ielts-productions/writing`.

## 7. Offline và chế độ thi thật (REQ-MOB-12, REQ-MOB-13, đợt 2)

Tải trước một đề vào `drift` + file audio. Bài làm offline xếp hàng với một `client_attempt_id`
(UUID sinh trên máy); backend nhận thêm trường này và bỏ qua bản trùng (idempotent). Đây là thay
đổi backend thứ tư, chỉ làm ở đợt 2.

## 8. Push (REQ-MOB-11, đợt 1)

Bảng `device_tokens (user_id, platform, token, updated_at)`. Cron Cloudflare mỗi 15 phút chọn
learner đến giờ nhắc và chưa học hôm nay, gửi qua FCM HTTP v1. Tối đa một thư mỗi ngày, cùng tinh
thần trần thư của luật thư gửi phụ huynh.

## 9. Build và phát hành

**Đợt 0, đường nhanh nhất lên TestFlight:**

1. Chủ dự án đăng ký **Apple Developer Program cá nhân** (99 USD/năm, duyệt thường 1-2 ngày) và
   tạo app `com.nemo12.ielts` trên App Store Connect. **Việc làm tay, chặn mọi bước sau.**
2. Tạo iOS OAuth client trên Google Cloud (cùng project với client web hiện có), bật Sign in with
   Apple cho App ID.
3. Build bằng Xcode trên máy Mac của chủ dự án: `flutter build ipa`, tải lên bằng Transporter
   hoặc `xcrun altool`. Không dựng CI macOS ở đợt 0 vì runner macOS của GitHub Actions tốn phút
   gấp 10 lần Linux (xem skill `cost-audit`).
4. TestFlight **internal testing** (tối đa 100 người trong team App Store Connect) không cần Apple
   duyệt, cài được ngay sau khi build xử lý xong.

**Đợt 1-2:** workflow `ielts-mobile.yml` chỉ chạy khi có thay đổi trong `apps/ielts-mobile/`:
`flutter analyze` + `flutter test` trên Linux cho mọi PR; build và tải lên TestFlight trên macOS
chỉ khi chạy tay (`workflow_dispatch`). Android: tài khoản Google Play cá nhân, 25 USD một lần,
internal testing → closed testing 12 người × 14 ngày → production.

Theo dõi lỗi: Sentry (gói miễn phí đủ cho giai đoạn beta) hoặc Firebase Crashlytics nếu đợt 1 đã
dùng Firebase cho push. Chọn một, không cả hai.

## 10. Kiểm chứng

| QG | Kiểm gì |
| --- | --- |
| QG-005 | Test backend cho `/v1/auth/apple`, nhiều Google audience, `client_attempt_id` idempotent |
| QG-007 | `flutter analyze` sạch + widget test cho Home, Reading, đăng nhập; CI đỏ là chặn gộp |
| QG-008 | Luồng học trên máy thật: đăng nhập, làm một bài Reading, thấy kết quả trên cả app lẫn web |

## Trace

| REQ | Section |
| --- | --- |
| REQ-MOB-01 | §2, §9 |
| REQ-MOB-02 | §1, §5 |
| REQ-MOB-03 | §4 |
| REQ-MOB-04 | §4 |
| REQ-MOB-05 | §5 |
| REQ-MOB-06 | §5 |
| REQ-MOB-07 | §5 |
| REQ-MOB-08 | §5 |
| REQ-MOB-09 | §6 |
| REQ-MOB-10 | §6 |
| REQ-MOB-11 | §8 |
| REQ-MOB-12 | §7 |
| REQ-MOB-13 | §7 |
| REQ-MOB-14 | §5 |
| REQ-MOB-15 | §4 |
| REQ-MOB-16 | §3 |
