SDD-055 - NEMO IELTS (Flutter)
Yêu cầu: PRD-010. 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ọcNguyê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 quacanAccessLearner, 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/apitheo skillapi-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ủapackages/design-systemvà sinhlib/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 presetMotion.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:
- Backend đã có
POST /v1/auth/googlevớiclient: "mobile"trảtoken, và middleware đã nhậnAuthorization: Bearer(workers/api/src/shared/middleware.ts). Google trên app gần như không cần sửa backend. - 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.
- 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 |
| 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:
- 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.ieltstrên App Store Connect. Việc làm tay, chặn mọi bước sau. - 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.
- 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ặcxcrun 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 skillcost-audit). - 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 |