Skip to content

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ọ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ớpChọnVì sao
FrameworkFlutter stable mới nhất; pubspec.lock commit sau lần pub get đầu tiên trên MacChỉ đạo chủ dự án; "bản mới nhất" = nâng rồi ghim (luật SRC-650 áp tinh thần)
Stateflutter_riverpodÍt boilerplate, test được không cần widget
Điều hướnggo_routerDeep link nemoielts:// và universal link về sau
HTTPdio + interceptor gắn Bearer và xử lý 401Một chỗ duy nhất đổi token
Lưu an toànflutter_secure_storage (Keychain / Keystore)REQ-MOB-04
Đăng nhậpgoogle_sign_in, sign_in_with_apple§4
Audiojust_audio + audio_session (phát khi khoá màn), record (ghi)REQ-MOB-06, REQ-MOB-09
Lưu offlinedrift (SQLite)Đợt 2, REQ-MOB-12
Pushfirebase_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 đổiChi tiết
Nhận nhiều Google audienceGOOGLE_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/appleNhậ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ảnKhớ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ênPhiê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ànAPI (đã có)
Home, một việc tiếp theoJourney 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:

  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 ​

QGKiểm gì
QG-005Test backend cho /v1/auth/apple, nhiều Google audience, client_attempt_id idempotent
QG-007flutter analyze sạch + widget test cho Home, Reading, đăng nhập; CI đỏ là chặn gộp
QG-008Luồ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 ​

REQSection
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