Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6319386a2f | |||
| 4f3eb280aa | |||
| 2bd7056dde | |||
| d505806f6e | |||
| 41309ff6ee | |||
| fd370f7833 | |||
| d4c6cdb013 | |||
| c3e6d66f43 | |||
| 66613857e8 |
@@ -1,33 +0,0 @@
|
|||||||
---
|
|
||||||
name: "API Contract Agent"
|
|
||||||
description: "Use when: định nghĩa hoặc cập nhật API contract (Swagger/OpenAPI), kiểm soát breaking changes và đồng bộ cập nhật cho web/mobile khi backend thay đổi. Keywords: openapi, swagger, api contract, schema, backward compatibility, breaking change"
|
|
||||||
tools: [read, search, edit, execute]
|
|
||||||
argument-hint: "Nêu endpoint/schema cần thêm hoặc thay đổi, và client nào bị ảnh hưởng"
|
|
||||||
user-invocable: true
|
|
||||||
---
|
|
||||||
Bạn là API Contract Agent, chuyên quản lý hợp đồng API và đồng bộ đa nền tảng.
|
|
||||||
|
|
||||||
## Mục tiêu
|
|
||||||
- Tạo/cập nhật OpenAPI spec REST-only rõ ràng, versioned và có thể kiểm chứng.
|
|
||||||
- Cảnh báo sớm breaking changes để web/mobile cập nhật kịp thời.
|
|
||||||
- Giảm lỗi integration do đổi tên field hoặc thay đổi schema không đồng bộ.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
- KHÔNG thay đổi contract mà không nêu tác động tương thích ngược.
|
|
||||||
- LUÔN dùng một nguồn spec chuẩn tại reader-api/docs/openapi.yaml.
|
|
||||||
- KHÔNG bỏ qua phần error model, auth requirements và example payloads.
|
|
||||||
- LUÔN liệt kê ảnh hưởng tới reader (web) và reader-app (mobile).
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
1. So sánh contract hiện có với thay đổi đề xuất và xác định loại thay đổi (non-breaking/breaking).
|
|
||||||
2. Cập nhật spec OpenAPI nhất quán tại reader-api/docs/openapi.yaml (path, params, request/response schema, errors, security).
|
|
||||||
3. Sinh change log contract + migration note cho client teams.
|
|
||||||
4. Đề xuất checklist cập nhật web/mobile và cách verify end-to-end.
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
- Contract summary
|
|
||||||
- OpenAPI spec file: reader-api/docs/openapi.yaml
|
|
||||||
- Breaking-change assessment
|
|
||||||
- Client impact matrix (web/mobile)
|
|
||||||
- Required client updates
|
|
||||||
- Verification checklist
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
---
|
|
||||||
name: "Backend & Security Agent"
|
|
||||||
description: "Use when: phát triển API backend, xử lý auth JWT/OAuth2, hardening middleware bảo mật, tối ưu truy vấn DB và giảm rủi ro bảo mật. Keywords: backend, fastapi, jwt, oauth2, sql injection, rate limiting, encryption, middleware"
|
|
||||||
tools: [read, search, edit, execute]
|
|
||||||
argument-hint: "Nêu endpoint/luồng auth cần làm, ràng buộc bảo mật, và kỳ vọng hiệu năng"
|
|
||||||
user-invocable: true
|
|
||||||
---
|
|
||||||
Bạn là Backend & Security Agent, tập trung phát triển backend an toàn và hiệu quả.
|
|
||||||
|
|
||||||
## Mục tiêu
|
|
||||||
- Viết/sửa API đúng chuẩn dự án, ưu tiên tính đúng đắn, bảo mật và khả năng vận hành.
|
|
||||||
- Thiết kế auth/authorization rõ ràng cho web và mobile clients.
|
|
||||||
- Tối ưu truy vấn và giảm bề mặt tấn công trong request path.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
- KHÔNG đưa logic bảo mật theo kiểu hình thức; phải có cơ chế kiểm chứng.
|
|
||||||
- KHÔNG bỏ qua kiểm tra đầu vào, phân quyền, và xử lý lỗi có chủ đích.
|
|
||||||
- KHÔNG thực hiện thay đổi phá vỡ contract mà không mô tả migration path.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
1. Xác định threat model ngắn cho phạm vi thay đổi (input abuse, auth bypass, data exposure).
|
|
||||||
2. Thiết kế API/auth flow với validation và permission checks rõ ràng.
|
|
||||||
3. Áp dụng hardening: chống SQL injection, rate limiting strategy, bảo vệ dữ liệu nhạy cảm.
|
|
||||||
4. Tối ưu truy vấn và theo dõi tác động hiệu năng.
|
|
||||||
5. Đề xuất test bảo mật + regression checklist.
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
- Scope and threat model
|
|
||||||
- Files changed
|
|
||||||
- Security controls added/updated
|
|
||||||
- Query/performance notes
|
|
||||||
- Validation and auth checks
|
|
||||||
- Verification commands/tests
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: "Cross Platform Delivery Agent"
|
||||||
|
description: "Use when: trien khai 1 tinh nang can dong bo web + mobile + api, can checklist contract, auth va rollout. Keywords: parity, rollout, cross-platform, sync, delivery"
|
||||||
|
tools: [read, search, edit, todo]
|
||||||
|
argument-hint: "Mo ta feature, endpoint lien quan, va impact len web/mobile"
|
||||||
|
user-invocable: true
|
||||||
|
---
|
||||||
|
Ban la Cross Platform Delivery Agent, dam bao moi thay doi feature co tinh lien mach giua 3 repo.
|
||||||
|
|
||||||
|
## Muc tieu
|
||||||
|
- Dong bo business flow giua `reader`, `reader-app`, `reader-api`.
|
||||||
|
- Tranh regression contract va auth behavior giua web/mobile.
|
||||||
|
- Tao checklist rollout theo thu tu API -> Web/Mobile -> QA.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
1. Xac dinh API contract canonic va data ownership.
|
||||||
|
2. Liet ke delta can cap nhat cho tung repo.
|
||||||
|
3. Kiem tra auth matrix (web cookie, mobile JWT).
|
||||||
|
4. De xuat test plan E2E toi thieu cho 2 clients.
|
||||||
|
5. Tong hop release note ngan gon.
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
---
|
|
||||||
name: "Mobile App Agent"
|
|
||||||
description: "Use when: phát triển tính năng mobile, xử lý local storage, lifecycle, push notifications và tối ưu hiệu năng thiết bị thật; ưu tiên Flutter, dùng Android/Kotlin module khi cần native. Keywords: flutter, mobile, lifecycle, local storage, push notification, performance, android, kotlin"
|
|
||||||
tools: [read, search, edit, execute]
|
|
||||||
argument-hint: "Nêu feature mobile cần làm, màn hình liên quan, và tiêu chí hiệu năng/ổn định"
|
|
||||||
user-invocable: true
|
|
||||||
---
|
|
||||||
Bạn là Mobile App Agent, tập trung phát triển và tối ưu ứng dụng mobile trong reader-suite.
|
|
||||||
|
|
||||||
## Mục tiêu
|
|
||||||
- Triển khai tính năng mobile theo kiến trúc hiện có, ưu tiên Flutter-first.
|
|
||||||
- Quản lý tốt local storage, lifecycle và thông báo đẩy.
|
|
||||||
- Tối ưu pin, bộ nhớ và độ mượt trên thiết bị thật.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
- KHÔNG tách luồng nghiệp vụ khỏi API contract chung nếu không có lý do rõ.
|
|
||||||
- CHỦ ĐỘNG đề xuất Android/Kotlin native module khi có lợi ích rõ ràng về hiệu năng, pin, hoặc capability mà Flutter khó đáp ứng.
|
|
||||||
- KHÔNG bỏ qua kiểm tra hành vi offline/network fluctuation.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
1. Xác định user flow và dữ liệu cần lưu cục bộ (cache/session/preferences).
|
|
||||||
2. Triển khai theo patterns của dự án (Flutter + provider/notifier + networking layer hiện có).
|
|
||||||
3. Xử lý lifecycle, background/resume và push notifications có kiểm soát.
|
|
||||||
4. Đánh giá hiệu năng trên thiết bị thật và đề xuất tối ưu (CPU/memory/battery) không phụ thuộc SSH/homelab.
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
- Feature scope
|
|
||||||
- Files changed
|
|
||||||
- State/storage/lifecycle decisions
|
|
||||||
- Performance notes (real-device oriented)
|
|
||||||
- Verification steps
|
|
||||||
- Risks and follow-ups
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
---
|
|
||||||
name: "SDET Agent"
|
|
||||||
description: "Use when: viết integration test API (ưu tiên Postman/Newman), viết E2E test web/mobile, chống regression sau khi sửa API, cần test coverage cho luồng đăng nhập/đọc truyện/tủ sách. Keywords: test, integration test, e2e, playwright, flutter integration_test, postman, newman, qa"
|
|
||||||
tools: [read, search, edit, execute]
|
|
||||||
argument-hint: "Nêu module cần test, loại test (integration/e2e), và tiêu chí pass/fail mong muốn"
|
|
||||||
user-invocable: true
|
|
||||||
---
|
|
||||||
Bạn là SDET Agent chuyên kiểm soát chất lượng cho hệ sinh thái reader-suite (reader, reader-app, reader-api).
|
|
||||||
|
|
||||||
## Mục tiêu
|
|
||||||
- Thiết kế và triển khai test tự động để giảm lỗi hồi quy khi thay đổi API hoặc logic ứng dụng.
|
|
||||||
- Ưu tiên integration test cho API bằng Postman/Newman và E2E/integration flow cho web/mobile theo yêu cầu.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
- CHỈ tập trung vào test, fixtures, test config, test data và lệnh chạy test.
|
|
||||||
- KHÔNG refactor logic production ngoài phạm vi tối thiểu cần thiết để test chạy được.
|
|
||||||
- KHÔNG đánh dấu hoàn tất nếu chưa nêu rõ cách chạy test và kết quả pass/fail.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
1. Xác định phạm vi test: endpoint/user flow, điều kiện thành công/thất bại, dữ liệu đầu vào quan trọng.
|
|
||||||
2. Chọn chiến lược phù hợp:
|
|
||||||
- API: ưu tiên Postman collection + Newman runner; chỉ dùng Jest khi có yêu cầu đặc biệt.
|
|
||||||
- Web: ưu tiên Playwright E2E cho luồng người dùng chính.
|
|
||||||
- Mobile: ưu tiên Flutter `integration_test` (và `flutter test integration_test`), tránh phụ thuộc Espresso trừ khi có yêu cầu rõ.
|
|
||||||
3. Viết test theo cấu trúc hiện có của repo, thêm mock/seed tối thiểu và tránh coupling mong manh.
|
|
||||||
4. Chạy test hoặc hướng dẫn lệnh chạy chuẩn trong repo hiện hành.
|
|
||||||
5. Báo cáo coverage thực tế: ca pass/fail, lỗ hổng chưa bao phủ, và đề xuất test tiếp theo.
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
- Test scope
|
|
||||||
- Files created/updated
|
|
||||||
- Commands run
|
|
||||||
- Results (pass/fail + lỗi chính nếu có)
|
|
||||||
- Residual risks
|
|
||||||
- Next tests to add
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
---
|
|
||||||
name: "Sleuth Debugger Agent"
|
|
||||||
description: "Use when: debug lỗi runtime từ log server hoặc log mobile (Flutter/Logcat), phân tích stack trace, truy ra root cause, đề xuất patch sửa nhanh. Keywords: debug, stack trace, flutter, logcat, server log, crash, exception, traceback"
|
|
||||||
tools: [read, search, execute]
|
|
||||||
argument-hint: "Dán log lỗi/stack trace hoặc mô tả bước tái hiện để phân tích nguyên nhân"
|
|
||||||
user-invocable: true
|
|
||||||
---
|
|
||||||
Bạn là Sleuth Debugger Agent chuyên điều tra lỗi và khoanh vùng nguyên nhân gốc trong hệ sinh thái reader-suite.
|
|
||||||
|
|
||||||
## Mục tiêu
|
|
||||||
- Đọc log (Flutter app logs/Logcat và server logs/API), trích xuất stack trace quan trọng.
|
|
||||||
- Giải thích nguyên nhân gốc theo luồng dữ liệu và đề xuất đoạn sửa có thể áp dụng ngay.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
- KHÔNG kết luận khi chưa chỉ ra bằng chứng từ log hoặc code path.
|
|
||||||
- KHÔNG đề xuất sửa mơ hồ; phải nêu file/khối code liên quan và lý do.
|
|
||||||
- KHÔNG mở rộng sang refactor lớn nếu không cần để xử lý lỗi hiện tại.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
1. Chuẩn hóa log đầu vào: tách error chính, timestamp, request context, stack trace khung gần lỗi nhất.
|
|
||||||
2. Map stack trace sang file/symbol trong codebase và xác định trigger condition.
|
|
||||||
3. Nêu root cause theo chuỗi nhân quả (input -> xử lý -> điểm nổ).
|
|
||||||
4. Đưa fix proposal ngắn gọn, ưu tiên thay đổi nhỏ và an toàn.
|
|
||||||
5. Đề xuất bước verify sau fix (lệnh chạy, request mẫu, expected log/response).
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
- Error signature
|
|
||||||
- Reproduction assumptions
|
|
||||||
- Root cause
|
|
||||||
- Proposed fix snippet
|
|
||||||
- Verification steps
|
|
||||||
- Follow-up guardrails (logging/test cần thêm)
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
---
|
|
||||||
name: "System Architect Agent"
|
|
||||||
description: "Use when: thiết kế kiến trúc hệ thống, database schema, API endpoint strategy (REST), chọn tech stack và giữ single source of truth giữa web/mobile/api. Keywords: architecture, system design, database, endpoint, plan, single source of truth"
|
|
||||||
tools: [read, search, edit, todo]
|
|
||||||
argument-hint: "Nêu bài toán kiến trúc, phạm vi module, và ràng buộc kỹ thuật/non-functional"
|
|
||||||
user-invocable: true
|
|
||||||
---
|
|
||||||
Bạn là System Architect Agent, chịu trách nhiệm định hướng kiến trúc toàn reader-suite.
|
|
||||||
|
|
||||||
## Mục tiêu
|
|
||||||
- Thiết kế nhất quán giữa Web, Mobile và API, tránh trùng lặp logic nghiệp vụ.
|
|
||||||
- Giữ một nguồn sự thật dữ liệu (Single Source of Truth) cho entity và luồng nghiệp vụ cốt lõi.
|
|
||||||
- Đưa ra blueprint có thể triển khai dần theo milestone.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
- KHÔNG viết implementation chi tiết vượt quá phạm vi kiến trúc trừ khi được yêu cầu.
|
|
||||||
- KHÔNG đề xuất kiến trúc mâu thuẫn conventions đã có trong workspace.
|
|
||||||
- LUÔN nêu trade-off và lý do chọn phương án.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
1. Thu thập bối cảnh từ workspace và chuẩn hóa mục tiêu kỹ thuật/non-functional.
|
|
||||||
2. Lập phương án kiến trúc bằng kế hoạch theo pha (plan-first, ưu tiên todo/plan workflow).
|
|
||||||
3. Xác định ranh giới domain, nguồn dữ liệu chuẩn, ownership của từng layer.
|
|
||||||
4. Định nghĩa chiến lược API contract REST-only và versioning để tránh phá vỡ web/mobile.
|
|
||||||
5. Đề xuất roadmap triển khai + kiểm soát rủi ro kỹ thuật.
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
- Problem framing
|
|
||||||
- Architecture decision
|
|
||||||
- Data model and API boundary
|
|
||||||
- Single source of truth mapping
|
|
||||||
- Migration/rollout plan
|
|
||||||
- Risks and mitigations
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
---
|
|
||||||
name: "Web Frontend Agent"
|
|
||||||
description: "Use when: xây dựng hoặc tối ưu giao diện web React/Next.js, cải thiện SEO và Core Web Vitals, đồng bộ UI với API contract. Keywords: nextjs, react, seo, core web vitals, ssr, app router"
|
|
||||||
tools: [read, search, edit, execute]
|
|
||||||
argument-hint: "Nêu màn hình/feature web cần làm và mục tiêu UX/SEO/performance"
|
|
||||||
user-invocable: true
|
|
||||||
---
|
|
||||||
Bạn là Web Frontend Agent, chuyên phát triển web trên React/Next.js cho reader-suite.
|
|
||||||
|
|
||||||
## Mục tiêu
|
|
||||||
- Xây dựng UI/UX web nhất quán, hiệu năng tốt và thân thiện SEO.
|
|
||||||
- Tôn trọng boundary Server/Client Components và conventions của App Router.
|
|
||||||
- Đảm bảo web đồng bộ contract với backend, giảm lỗi runtime phía client.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
- KHÔNG làm lệch naming/conventions route tiếng Việt của dự án.
|
|
||||||
- KHÔNG thêm client-side state/effect không cần thiết nếu Server Component giải quyết được.
|
|
||||||
- KHÔNG đánh đổi SEO/performance cho giải pháp nhanh tạm bợ.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
1. Phân tích yêu cầu giao diện + dữ liệu và chọn rendering strategy phù hợp.
|
|
||||||
2. Triển khai component theo pattern hiện có, tối ưu tải và trải nghiệm tương tác.
|
|
||||||
3. Kiểm tra SEO metadata, semantics và Core Web Vitals impact.
|
|
||||||
4. Xác nhận hành vi với API contract hiện tại.
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
- Feature scope
|
|
||||||
- Files changed
|
|
||||||
- Rendering/data strategy
|
|
||||||
- SEO/Core Web Vitals notes
|
|
||||||
- QA checklist
|
|
||||||
- Follow-up improvements
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Project Guidelines
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
- Dùng Dart/Flutter theo lint mặc định trong `analysis_options.yaml` (flutter_lints).
|
|
||||||
- Tổ chức theo feature-first: code mới đặt đúng module trong `lib/features/**`, phần dùng chung đặt ở `lib/core/**` hoặc `lib/shared/**`.
|
|
||||||
- Tránh logic nghiệp vụ trong widget build; chuyển sang provider/notifier.
|
|
||||||
- Giữ naming nhất quán: file snake_case, class PascalCase, provider rõ nghĩa theo tính năng.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
- `lib/main.dart`: bootstrap app + ProviderScope.
|
|
||||||
- `lib/app/`: app shell, router và route names.
|
|
||||||
- `lib/core/`: config, network, storage, theme, models dùng toàn app.
|
|
||||||
- `lib/features/`: từng domain (auth, home, search, novel, reader, bookshelf, comments, profile, settings...).
|
|
||||||
- `lib/shared/`: widgets dùng chung.
|
|
||||||
- State management chính: Riverpod; networking: Dio; routing: go_router.
|
|
||||||
|
|
||||||
## Build and Test
|
|
||||||
- Cài dependencies: `flutter pub get`
|
|
||||||
- Chạy app: `flutter run`
|
|
||||||
- Khuyến nghị local: `bash scripts/flutter_run_with_env.sh`
|
|
||||||
- Phân tích lint/static: `flutter analyze`
|
|
||||||
- Chạy test hiện có: `flutter test`
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
- Cấu hình API base URL lấy từ AppConfig/env; không hardcode URL trực tiếp trong feature code.
|
|
||||||
- Token/session xử lý qua tầng storage/network ở `lib/core`, tránh duplicate auth flow trong từng feature.
|
|
||||||
- Khi thêm màn hình mới, cập nhật router tập trung ở `lib/app/router/app_router.dart`.
|
|
||||||
- Ưu tiên tái sử dụng models trong `lib/core/models` thay vì tạo kiểu dữ liệu rời rạc.
|
|
||||||
|
|
||||||
## Pitfalls
|
|
||||||
- Android emulator phải dùng `10.0.2.2` để gọi localhost backend; iOS simulator/web thường dùng `localhost`.
|
|
||||||
- Google Sign-In Android dễ lỗi `ApiException: 10` nếu cấu hình SHA/OAuth client không khớp.
|
|
||||||
- Thiếu `.env.mobile` hoặc thiếu `GOOGLE_SERVER_CLIENT_ID` có thể làm luồng đăng nhập thất bại.
|
|
||||||
- Thiết bị thật cần LAN IP đúng mạng nội bộ; không dùng VPN IP nếu điện thoại không cùng tunnel.
|
|
||||||
|
|
||||||
## Key References
|
|
||||||
- Tổng quan setup + Google Sign-In: `README.md`
|
|
||||||
- Lint rules: `analysis_options.yaml`
|
|
||||||
- App config/env: `lib/core/config/app_config.dart`
|
|
||||||
- API client + interceptor: `lib/core/network/api_client.dart`
|
|
||||||
- Router trung tâm: `lib/app/router/app_router.dart`
|
|
||||||
- Script chạy với env: `scripts/flutter_run_with_env.sh`
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
---
|
|
||||||
name: "Debug Triage Checklist"
|
|
||||||
description: "Use when: cần phân tích nhanh lỗi runtime từ logcat/server logs, xác định root cause và đề xuất patch an toàn"
|
|
||||||
argument-hint: "Dán log, stack trace, bước tái hiện, và môi trường bị lỗi"
|
|
||||||
agent: "Sleuth Debugger Agent"
|
|
||||||
---
|
|
||||||
Thực hiện debug triage theo checklist chuẩn cho lỗi runtime được cung cấp.
|
|
||||||
|
|
||||||
Checklist bắt buộc:
|
|
||||||
- Trích xuất error signature chính và stack trace quan trọng nhất.
|
|
||||||
- Xác định giả định tái hiện lỗi và điều kiện kích hoạt.
|
|
||||||
- Khoanh vùng root cause bằng bằng chứng từ log + code path.
|
|
||||||
- Đề xuất patch nhỏ nhất có thể áp dụng ngay.
|
|
||||||
- Đưa kế hoạch verify sau fix với expected behavior rõ ràng.
|
|
||||||
|
|
||||||
Kết quả bắt buộc:
|
|
||||||
- Error signature.
|
|
||||||
- Root cause theo chuỗi nhân quả.
|
|
||||||
- File/symbol bị ảnh hưởng.
|
|
||||||
- Patch proposal cụ thể.
|
|
||||||
- Verification checklist.
|
|
||||||
- Guardrails phòng tái phát (test/logging/alerts).
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
---
|
|
||||||
name: "Flutter Integration Test Flow"
|
|
||||||
description: "Use when: cần viết hoặc mở rộng Flutter integration_test cho user flow quan trọng và chống regression mobile"
|
|
||||||
argument-hint: "Nêu flow cần test, dữ liệu đầu vào, và tiêu chí thành công"
|
|
||||||
agent: "SDET Agent"
|
|
||||||
---
|
|
||||||
Tạo hoặc cập nhật Flutter integration_test cho user flow được chỉ định.
|
|
||||||
|
|
||||||
Yêu cầu thực hiện:
|
|
||||||
- Ưu tiên `integration_test` theo cấu trúc hiện có của dự án.
|
|
||||||
- Thiết kế test theo hành vi người dùng thật (đăng nhập, điều hướng, thao tác chính, trạng thái mong đợi).
|
|
||||||
- Bao gồm ít nhất một nhánh lỗi hoặc edge case có giá trị.
|
|
||||||
- Tránh test phụ thuộc dữ liệu không ổn định; thêm setup/teardown cần thiết.
|
|
||||||
|
|
||||||
Kết quả bắt buộc:
|
|
||||||
- Danh sách file test đã tạo/cập nhật.
|
|
||||||
- Lệnh chạy cụ thể (`flutter test integration_test` hoặc lệnh tương đương của repo).
|
|
||||||
- Kết quả pass/fail và nguyên nhân nếu fail.
|
|
||||||
- Đề xuất mở rộng coverage cho vòng kế tiếp.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
---
|
|
||||||
name: "Multi-Agent Workflow"
|
|
||||||
description: "Use when: vận hành chu kỳ Planner -> Coder -> Tester -> Debugger cho tính năng mới hoặc bugfix"
|
|
||||||
argument-hint: "Nêu tính năng/bug cần xử lý, phạm vi web-mobile-api, và tiêu chí hoàn tất"
|
|
||||||
agent: "System Architect Agent"
|
|
||||||
---
|
|
||||||
Điều phối quy trình Multi-Agent theo chu kỳ sau, bám sát phạm vi người dùng cung cấp.
|
|
||||||
|
|
||||||
Quy trình bắt buộc:
|
|
||||||
1. Planner: dùng tư duy @workspace + plan-first để thiết kế feature/bugfix scope và các mốc triển khai.
|
|
||||||
2. Coder: triển khai đồng bộ phần Backend API và phần Web/Mobile liên quan theo contract hiện hành.
|
|
||||||
3. Tester: gọi SDET Agent để tạo/chạy test tự động.
|
|
||||||
- API: ưu tiên Postman/Newman.
|
|
||||||
- Mobile: ưu tiên Flutter integration_test.
|
|
||||||
4. Debugger: nếu crash/lỗi runtime, gọi Sleuth Debugger Agent phân tích log và đề xuất patch nhỏ nhất.
|
|
||||||
|
|
||||||
Ràng buộc:
|
|
||||||
- API phải REST-only.
|
|
||||||
- Contract chuẩn dùng duy nhất reader-api/docs/openapi.yaml.
|
|
||||||
- Báo cáo rõ tác động tới web và mobile sau mỗi thay đổi contract.
|
|
||||||
|
|
||||||
Kết quả bắt buộc:
|
|
||||||
- Kế hoạch triển khai theo bước.
|
|
||||||
- Danh sách file thay đổi theo từng pha.
|
|
||||||
- Kết quả test pass/fail.
|
|
||||||
- Root cause + patch nếu có lỗi.
|
|
||||||
- Danh sách việc còn lại trước khi merge.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
---
|
|
||||||
name: "Regression API Newman"
|
|
||||||
description: "Use when: cần tạo hoặc cập nhật regression suite cho API bằng Postman/Newman sau khi sửa endpoint hoặc auth flow"
|
|
||||||
argument-hint: "Nêu endpoint/flow cần cover, env chạy test, và tiêu chí pass/fail"
|
|
||||||
agent: "SDET Agent"
|
|
||||||
---
|
|
||||||
Thiết kế và triển khai regression API test theo chuẩn Postman/Newman cho phạm vi người dùng cung cấp.
|
|
||||||
|
|
||||||
Yêu cầu thực hiện:
|
|
||||||
- Ưu tiên Postman collection + environment + Newman command.
|
|
||||||
- Bám theo cấu trúc endpoint và auth flow hiện có của workspace đang mở.
|
|
||||||
- Bao phủ cả happy path, validation errors, auth errors và regression cases đã biết.
|
|
||||||
- Nếu thiếu dữ liệu test, đề xuất seed/mock tối thiểu và cách chạy lại ổn định.
|
|
||||||
|
|
||||||
Kết quả bắt buộc:
|
|
||||||
- Danh sách file đã tạo/cập nhật cho Postman/Newman.
|
|
||||||
- Lệnh chạy Newman cụ thể.
|
|
||||||
- Bảng pass/fail theo từng test case.
|
|
||||||
- Rủi ro còn lại và test nên thêm tiếp.
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
## Summary
|
||||||
|
- What problem does this PR solve?
|
||||||
|
- Why now?
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- [ ] Web (`reader`)
|
||||||
|
- [ ] API (`reader-api`)
|
||||||
|
- [ ] Mobile (`reader-app`)
|
||||||
|
|
||||||
|
## API Contract Impact
|
||||||
|
- [ ] No API changes
|
||||||
|
- [ ] Backward-compatible API changes
|
||||||
|
- [ ] Breaking API changes (must include migration plan)
|
||||||
|
|
||||||
|
Changed endpoints:
|
||||||
|
- `...`
|
||||||
|
|
||||||
|
## Auth Impact
|
||||||
|
- [ ] None
|
||||||
|
- [ ] Web session (NextAuth cookie)
|
||||||
|
- [ ] Mobile JWT
|
||||||
|
|
||||||
|
## Cross-Platform Parity Checklist
|
||||||
|
- [ ] Web behavior aligned with API response
|
||||||
|
- [ ] Mobile behavior aligned with API response
|
||||||
|
- [ ] Error states consistent (401/403/4xx/5xx)
|
||||||
|
- [ ] Pagination behavior consistent (`page`, `limit`)
|
||||||
|
|
||||||
|
## Test Evidence
|
||||||
|
- [ ] Unit tests
|
||||||
|
- [ ] Integration/API tests
|
||||||
|
- [ ] Manual smoke test Web
|
||||||
|
- [ ] Manual smoke test Mobile
|
||||||
|
|
||||||
|
Commands / notes:
|
||||||
|
```bash
|
||||||
|
# paste test/build commands executed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rollout & Rollback
|
||||||
|
- Rollout order: API -> Web/Mobile -> QA
|
||||||
|
- Rollback strategy:
|
||||||
|
- API:
|
||||||
|
- Web:
|
||||||
|
- Mobile:
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Reader Suite Architecture (Android/Flutter)
|
||||||
|
|
||||||
|
Tai lieu nay mo ta vai tro `reader-app` trong he sinh thai doc truyen gom web + mobile + backend.
|
||||||
|
|
||||||
|
## Vai tro trong he thong
|
||||||
|
|
||||||
|
- `reader-app` la mobile client cho end-user feature.
|
||||||
|
- KHONG chua logic nghiep vu canonic; backend (`reader-api`) la noi quyet dinh rule.
|
||||||
|
- Muc tieu: feature parity voi web user-facing, tru workflow MOD/ADMIN.
|
||||||
|
|
||||||
|
## Kien truc app
|
||||||
|
|
||||||
|
- `lib/core`: config, networking, storage, app-level services.
|
||||||
|
- `lib/features`: module theo domain (auth, browse, novel-detail, reader, bookshelf...).
|
||||||
|
- `lib/shared`: widget dung chung.
|
||||||
|
|
||||||
|
## Nguyen tac dong bo voi web + backend
|
||||||
|
|
||||||
|
- Dung chung endpoint contract voi web, khong tao API rieng cho mobile neu khong can thiet.
|
||||||
|
- Dung chung semantic cho state:
|
||||||
|
- bookmark
|
||||||
|
- reading-progress
|
||||||
|
- recommendation
|
||||||
|
- user-settings
|
||||||
|
- Auth mobile qua JWT; backend phai map cung identity voi web.
|
||||||
|
|
||||||
|
## Environment va ket noi
|
||||||
|
|
||||||
|
- Khong co `--dart-define`: Android native mac dinh toi `https://reader-api.fevirtus.dev`; cac platform khac `http://localhost:8000` (`lib/core/config/app_config.dart`).
|
||||||
|
- Dev local Android emulator: dat `BASE_URL=http://10.0.2.2:8000` trong `.env.mobile` va chay qua `scripts/flutter_run_with_env.sh`.
|
||||||
|
- `BASE_URL`, `GOOGLE_SERVER_CLIENT_ID`, `GOOGLE_CLIENT_ID` duoc truyen qua `--dart-define`.
|
||||||
|
|
||||||
|
## Definition of Done (Mobile)
|
||||||
|
|
||||||
|
- API calls tuan thu contract `reader-api` (status code + error format).
|
||||||
|
- UX/state nhat quan voi web cho luong user-facing.
|
||||||
|
- Da test tren it nhat Android emulator + 1 moi truong khac (iOS simulator hoac device).
|
||||||
|
- Cac huong dan env/auth trong README van dung sau thay doi.
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
# API Contract Standard (Reader Suite)
|
||||||
|
|
||||||
|
Tai lieu contract chung cho `reader`, `reader-app`, `reader-api`.
|
||||||
|
|
||||||
|
## Base and Versioning
|
||||||
|
|
||||||
|
- Base path: `/api/*`
|
||||||
|
- Current mode: unversioned with backward-compatible evolution.
|
||||||
|
- Breaking changes bat buoc qua ke hoach migration va release note.
|
||||||
|
|
||||||
|
## Response Convention
|
||||||
|
|
||||||
|
- Success:
|
||||||
|
- `200/201`: tra JSON payload domain data.
|
||||||
|
- `204`: khong co body.
|
||||||
|
- Error (standardized):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "string",
|
||||||
|
"message": "human readable",
|
||||||
|
"details": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## HTTP Status Usage
|
||||||
|
|
||||||
|
- `400`: input/validation khong hop le.
|
||||||
|
- `401`: chua dang nhap hoặc token/session invalid.
|
||||||
|
- `403`: da dang nhap nhung khong du quyen.
|
||||||
|
- `404`: resource khong ton tai.
|
||||||
|
- `409`: xung dot du lieu.
|
||||||
|
- `422`: payload format dung JSON nhung khong dat rule nghiep vu.
|
||||||
|
- `500`: loi he thong.
|
||||||
|
- `410`: (du tru) tai nguyen da go bo hoac khong con ho tro.
|
||||||
|
|
||||||
|
## Pagination Convention
|
||||||
|
|
||||||
|
- Query params: `page`, `limit`.
|
||||||
|
- Response envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [],
|
||||||
|
"pagination": {
|
||||||
|
"page": 1,
|
||||||
|
"limit": 20,
|
||||||
|
"total": 0,
|
||||||
|
"totalPages": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth Matrix
|
||||||
|
|
||||||
|
- Web: NextAuth session cookie.
|
||||||
|
- Mobile: Bearer JWT.
|
||||||
|
- Backend phai map ca 2 vao cung user identity + role.
|
||||||
|
|
||||||
|
## Compatibility Rules
|
||||||
|
|
||||||
|
- Khong xoa hoac doi y nghia field dang duoc client su dung.
|
||||||
|
- Field moi phai optional theo default trong giai doan rollout.
|
||||||
|
- Doi ten field => tao migration layer hoac add field moi, deprecate sau.
|
||||||
|
- Endpoint moi can update README + mapping matrix.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Cross-Repo Endpoint Mapping Matrix
|
||||||
|
|
||||||
|
Muc tieu: map 1-1 giua API backend, Web va Mobile cho user-facing flows.
|
||||||
|
|
||||||
|
Legend:
|
||||||
|
- `Y`: da tich hop
|
||||||
|
- `P`: partial / can verify them
|
||||||
|
- `N`: chua tich hop
|
||||||
|
|
||||||
|
| Domain | Endpoint | API | Web | Mobile | Notes |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Health | `GET /api/health` | Y | P | P | Dung cho monitor, khong phai main UI flow |
|
||||||
|
| Auth | `POST /api/auth/mobile-login` | Y | Y | Y | Web dung route adapter login, mobile dung JWT |
|
||||||
|
| User | `GET /api/user/profile` | Y | P | Y | Web dang goi qua user route proxy |
|
||||||
|
| User | `GET/POST /api/user/bookmarks` | Y | Y | Y | Parity can test theo tabs bookshelf |
|
||||||
|
| User | `DELETE /api/user/bookmarks/{novelId}` | Y | P | Y | Web co remove flow can verify UX parity |
|
||||||
|
| User | `POST /api/user/reading-progress` | Y | P | Y | Can doi chieu dong bo chapter progress |
|
||||||
|
| User | `GET/POST /api/user/settings` | Y | Y | N | Mobile chua thay call settings ro rang |
|
||||||
|
| User | `GET/POST/DELETE /api/user/recommendations` | Y | Y | N | Mobile chua thay provider recommendation |
|
||||||
|
| Catalog | `GET /api/genres` | Y | Y | Y | |
|
||||||
|
| Catalog | `GET /api/novels/browse` | Y | Y | Y | |
|
||||||
|
| Catalog | `GET /api/novels/{idOrSlug}` | Y | Y | Y | |
|
||||||
|
| Novel | `GET /api/truyen/{id}/chapters` | Y | Y | Y | |
|
||||||
|
| Novel | `GET /api/truyen/{id}/chapters/by-number/{n}` | Y | Y | N | Mobile doc chapter theo chapterId endpoint |
|
||||||
|
| Chapter | `GET /api/chapters/{chapterId}` | Y | N | Y | Web doc chapter qua truyen/by-number |
|
||||||
|
| Comment | `GET/POST /api/truyen/{id}/comments` | Y | Y | Y | |
|
||||||
|
| Rating | `POST /api/truyen/{id}/rate` | Y | Y | N | Mobile chua thay rating flow |
|
||||||
|
| Search | `GET /api/truyen/suggest` | Y | Y | N | Mobile search suggest can bo sung |
|
||||||
|
| Import | `POST /api/import/uploads/preview` | Y | Y | N | Upload EPUB multipart (preview) |
|
||||||
|
| Import | `POST /api/mod/epub`, `POST /api/mod/epub/ai-suggest` | Y | Y | N | Luong `/mod/import` |
|
||||||
|
| Import | `GET/POST/PUT/DELETE /api/mod/the-loai` | Y | Y | N | MOD quan ly the loai trong wizard |
|
||||||
|
|
||||||
|
## Priority gaps de dong bo tiep
|
||||||
|
|
||||||
|
1. Mobile: `user/settings`, `recommendations`, `rate`, `suggest`.
|
||||||
|
2. Web/Mobile chapter-read strategy can unify (`chapters/{id}` vs `by-number`).
|
||||||
|
3. Chuan hoa error contract implementation theo `CONTRACT.md`.
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
# Features - Reader Android App
|
||||||
|
|
||||||
|
Trang thai tinh nang mobile `reader-app` theo parity voi web.
|
||||||
|
|
||||||
|
## Guest/User-facing
|
||||||
|
|
||||||
|
| Feature | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Google login | done | Qua `/api/auth/mobile-login` |
|
||||||
|
| Home/browse boards | done | `/api/novels/browse` |
|
||||||
|
| Genre list | done | `/api/genres` |
|
||||||
|
| Novel detail + chapter list | done | `/api/novels/{idOrSlug}`, `/api/truyen/{id}/chapters` |
|
||||||
|
| Reader chapter detail | done | `/api/chapters/{chapterId}` |
|
||||||
|
| Bookmark | done | `/api/user/bookmarks` |
|
||||||
|
| Reading progress sync | done | `/api/user/reading-progress` |
|
||||||
|
| Comments | done | `/api/truyen/{id}/comments` |
|
||||||
|
|
||||||
|
## Parity Gaps
|
||||||
|
|
||||||
|
| Feature | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| User settings sync | planned | `/api/user/settings` |
|
||||||
|
| User recommendations | planned | `/api/user/recommendations` |
|
||||||
|
| Rating | planned | `/api/truyen/{id}/rate` |
|
||||||
|
| Search suggest | planned | `/api/truyen/suggest` |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Contract: `reader-app/CONTRACT.md`
|
||||||
|
- Mapping: `reader-app/CROSS_REPO_ENDPOINT_MATRIX.md`
|
||||||
|
|
||||||
|
## Note
|
||||||
|
|
||||||
|
- EPUB import flow is currently MOD-only on web (`reader /mod/import`), not in mobile scope.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Flows - Reader Android App
|
||||||
|
|
||||||
|
Muc tieu: mobile follow cung business behavior voi web.
|
||||||
|
|
||||||
|
## Flow 1: Mobile Login and Token
|
||||||
|
|
||||||
|
- Trigger: user dang nhap Google tren app.
|
||||||
|
- Steps:
|
||||||
|
1. Lay Google token.
|
||||||
|
2. Goi `/api/auth/mobile-login` doi lay app JWT.
|
||||||
|
3. Luu JWT, goi `/api/user/profile` hydrate session.
|
||||||
|
- Failure:
|
||||||
|
- token invalid -> `401`
|
||||||
|
- profile unavailable -> retry + user notice
|
||||||
|
|
||||||
|
## Flow 2: Discover and Read
|
||||||
|
|
||||||
|
- Discover:
|
||||||
|
- `/api/genres`
|
||||||
|
- `/api/novels/browse`
|
||||||
|
- `/api/novels/{idOrSlug}`
|
||||||
|
- Read:
|
||||||
|
- `/api/truyen/{id}/chapters`
|
||||||
|
- `/api/chapters/{chapterId}`
|
||||||
|
- Expected: state chapter/current progress dong bo server.
|
||||||
|
|
||||||
|
## Flow 3: Bookmark and Progress
|
||||||
|
|
||||||
|
- Bookmark add/remove/sync:
|
||||||
|
- `GET/POST/DELETE /api/user/bookmarks`
|
||||||
|
- Progress sync:
|
||||||
|
- `POST /api/user/reading-progress`
|
||||||
|
- Rule: optimistic UI + rollback neu API fail.
|
||||||
|
|
||||||
|
## Flow 4: Comment Interaction
|
||||||
|
|
||||||
|
- Load comments:
|
||||||
|
- `GET /api/truyen/{id}/comments`
|
||||||
|
- Post comment:
|
||||||
|
- `POST /api/truyen/{id}/comments`
|
||||||
|
- Rule: require login, follow error contract chung.
|
||||||
|
|
||||||
|
## Planned Flows (Parity)
|
||||||
|
|
||||||
|
- Settings sync flow (`/api/user/settings`)
|
||||||
|
- Recommendation flow (`/api/user/recommendations`)
|
||||||
|
- Rating flow (`/api/truyen/{id}/rate`)
|
||||||
|
- Search suggest flow (`/api/truyen/suggest`)
|
||||||
|
|
||||||
|
## Out of Scope (Current)
|
||||||
|
|
||||||
|
- MOD EPUB import chi tren web (`/mod/import`: `/api/mod/epub`, `POST /api/import/uploads/preview`, …). Mobile khong co wizard import.
|
||||||
@@ -7,16 +7,11 @@ Flutter mobile app for reading novels, synced with the existing web platform.
|
|||||||
- Full end-user feature parity with the current web app.
|
- Full end-user feature parity with the current web app.
|
||||||
- Excludes all moderator/admin workflows.
|
- Excludes all moderator/admin workflows.
|
||||||
|
|
||||||
## Planned Feature Set
|
## Implemented vs planned
|
||||||
|
|
||||||
- Google login and authenticated user session.
|
Đã có trong code (theo `lib/features`): đăng nhập Google, home/browse, genres, tìm kiếm, chi tiết truyện + danh sách chương, reader (kèm TTS), bookshelf, bookmark/progress, bình luận, splash/settings.
|
||||||
- Home feed, hot boards, and recommendations.
|
|
||||||
- Search with suggestions, genre, status, and sort filters.
|
Còn thiếu hoặc mới dạng placeholder so với web: gợi ý tìm kiếm (`/api/truyen/suggest`), đánh giá truyện (`/api/truyen/{id}/rate`), đồng bộ settings và đề cử người dùng (`/api/user/settings`, `/api/user/recommendations`). Chi tiết parity xem `FEATURES.md` và `CROSS_REPO_ENDPOINT_MATRIX.md`.
|
||||||
- Novel detail with chapter list and series metadata.
|
|
||||||
- Reader with TOC, reading preferences, and progress sync.
|
|
||||||
- Bookshelf tabs: Dang doc, Danh dau, Da doc, De cu.
|
|
||||||
- Comments, ratings, and user recommendations.
|
|
||||||
- Native TTS and offline reading support.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -51,12 +46,14 @@ This script reads `.env.mobile` and automatically passes:
|
|||||||
- `GOOGLE_SERVER_CLIENT_ID`
|
- `GOOGLE_SERVER_CLIENT_ID`
|
||||||
- optional `GOOGLE_CLIENT_ID`
|
- optional `GOOGLE_CLIENT_ID`
|
||||||
|
|
||||||
Default `BASE_URL` behavior:
|
Default `BASE_URL` khi **không** truyền `--dart-define` (xem `lib/core/config/app_config.dart`):
|
||||||
|
|
||||||
- Android emulator: `http://10.0.2.2:8000`
|
- Android (native, không phải web build): `https://reader-api.fevirtus.dev`
|
||||||
- Others (iOS simulator, desktop, web): `http://localhost:8000`
|
- Các nền tảng khác (iOS, desktop, web build, v.v.): `http://localhost:8000`
|
||||||
|
|
||||||
If needed, you can still override explicitly:
|
Để dev local trên Android emulator, luôn set rõ qua `--dart-define` hoặc file `.env.mobile` + `scripts/flutter_run_with_env.sh`, ví dụ `http://10.0.2.2:8000`.
|
||||||
|
|
||||||
|
Override trực tiếp:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flutter run --dart-define=BASE_URL=http://localhost:8000
|
flutter run --dart-define=BASE_URL=http://localhost:8000
|
||||||
@@ -115,3 +112,11 @@ Optional (iOS/web):
|
|||||||
```bash
|
```bash
|
||||||
--dart-define=GOOGLE_CLIENT_ID=<YOUR_IOS_OR_WEB_CLIENT_ID>.apps.googleusercontent.com
|
--dart-define=GOOGLE_CLIENT_ID=<YOUR_IOS_OR_WEB_CLIENT_ID>.apps.googleusercontent.com
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Noted:
|
||||||
|
|
||||||
|
Với MIUI:
|
||||||
|
Cần hướng dẫn user (không thể fix bằng code)
|
||||||
|
MIUI AutoStart: User phải vào Cài đặt → Ứng dụng → [app] → AutoStart và bật thủ công
|
||||||
|
MIUI Battery Optimization: User phải vào Cài đặt → Pin → Ứng dụng tiêu hao pin → [app] → chọn "Không hạn chế" (permission REQUEST_IGNORE_BATTERY_OPTIMIZATIONS đã có trong Manifest để trigger dialog, nhưng user vẫn phải accept)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Shared Development Skills (Reader Suite)
|
||||||
|
|
||||||
|
Tai lieu mo ta bo skill de phat trien thong nhat giua web/app/api.
|
||||||
|
|
||||||
|
## skill: feature-parity-check
|
||||||
|
- Muc dich: so sanh 1 feature giua web va mobile theo cung API contract.
|
||||||
|
- Input: ten feature, endpoint list, expected UX behavior.
|
||||||
|
- Output: parity report (done/missing/risk) cho 3 repo.
|
||||||
|
|
||||||
|
## skill: api-contract-guard
|
||||||
|
- Muc dich: review thay doi API de tranh breaking web/mobile.
|
||||||
|
- Input: diff endpoint, request/response schema, auth requirement.
|
||||||
|
- Output: compatibility checklist + migration huong dan.
|
||||||
|
|
||||||
|
## skill: auth-flow-verifier
|
||||||
|
- Muc dich: verify luong dang nhap web cookie va mobile JWT map cung identity.
|
||||||
|
- Input: login flow, token/session behavior, protected endpoints.
|
||||||
|
- Output: matrix pass/fail + fix recommendation.
|
||||||
|
|
||||||
|
## skill: release-readiness
|
||||||
|
- Muc dich: chuan bi release dong bo 3 repo.
|
||||||
|
- Input: danh sach thay doi theo repo.
|
||||||
|
- Output: rollout order, smoke-test list, rollback note.
|
||||||
@@ -13,7 +13,7 @@ import io.flutter.plugin.common.EventChannel
|
|||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import com.example.reader_app.tts.ReaderTtsMediaBridge
|
import com.example.reader_app.tts.ReaderTtsMediaBridge
|
||||||
import com.example.reader_app.tts.ReaderTtsMediaService
|
import com.example.reader_app.tts.ReaderTtsMediaService
|
||||||
import com.example.reader_app.tts.ReaderTtsSegment
|
import com.example.reader_app.tts.ReaderTtsStartRequest
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
private val channelName = "reader_app/tts_background"
|
private val channelName = "reader_app/tts_background"
|
||||||
@@ -53,23 +53,34 @@ class MainActivity : FlutterActivity() {
|
|||||||
}
|
}
|
||||||
"getSnapshot" -> result.success(ReaderTtsMediaBridge.snapshot())
|
"getSnapshot" -> result.success(ReaderTtsMediaBridge.snapshot())
|
||||||
"startReading" -> {
|
"startReading" -> {
|
||||||
val startIndex = call.argument<Int>("startIndex") ?: 0
|
val content = call.argument<String>("content") ?: ""
|
||||||
val contentKey = call.argument<String>("contentKey")
|
val contentKey = call.argument<String>("contentKey")
|
||||||
val title = call.argument<String>("title")
|
val title = call.argument<String>("title")
|
||||||
val speed = call.argument<Double>("speed") ?: 0.9
|
val speed = call.argument<Double>("speed") ?: 0.9
|
||||||
val language = call.argument<String>("language") ?: "vi-VN"
|
val language = call.argument<String>("language") ?: "vi-VN"
|
||||||
val voiceName = call.argument<String>("voiceName")
|
val voiceName = call.argument<String>("voiceName")
|
||||||
val backgroundModeEnabled = call.argument<Boolean>("backgroundModeEnabled") ?: true
|
val backgroundModeEnabled = call.argument<Boolean>("backgroundModeEnabled") ?: true
|
||||||
|
val nextChapterId = call.argument<String>("nextChapterId")
|
||||||
|
val chapterNumber = call.argument<Int>("chapterNumber")
|
||||||
|
val includeTitle = call.argument<Boolean>("includeTitle") ?: true
|
||||||
|
val apiBaseUrl = call.argument<String>("apiBaseUrl")
|
||||||
|
val startIndex = call.argument<Int>("startIndex") ?: 0
|
||||||
ReaderTtsMediaService.startReading(
|
ReaderTtsMediaService.startReading(
|
||||||
this,
|
this,
|
||||||
parseSegments(call.argument<List<*>>("segments")),
|
ReaderTtsStartRequest(
|
||||||
startIndex,
|
content = content,
|
||||||
contentKey,
|
contentKey = contentKey,
|
||||||
title,
|
title = title,
|
||||||
speed,
|
speed = speed,
|
||||||
language,
|
language = language,
|
||||||
voiceName,
|
voiceName = voiceName,
|
||||||
backgroundModeEnabled,
|
backgroundModeEnabled = backgroundModeEnabled,
|
||||||
|
nextChapterId = nextChapterId,
|
||||||
|
chapterNumber = chapterNumber,
|
||||||
|
includeTitle = includeTitle,
|
||||||
|
apiBaseUrl = apiBaseUrl,
|
||||||
|
startIndex = startIndex,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
@@ -137,24 +148,6 @@ class MainActivity : FlutterActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseSegments(rawSegments: List<*>?): ArrayList<ReaderTtsSegment> {
|
|
||||||
val segments = arrayListOf<ReaderTtsSegment>()
|
|
||||||
rawSegments.orEmpty().forEach { item ->
|
|
||||||
val map = item as? Map<*, *> ?: return@forEach
|
|
||||||
val text = map["text"]?.toString() ?: return@forEach
|
|
||||||
val paragraphIndex = (map["paragraphIndex"] as? Number)?.toInt() ?: -1
|
|
||||||
val start = (map["start"] as? Number)?.toInt() ?: -1
|
|
||||||
val end = (map["end"] as? Number)?.toInt() ?: -1
|
|
||||||
segments += ReaderTtsSegment(
|
|
||||||
text = text,
|
|
||||||
paragraphIndex = paragraphIndex,
|
|
||||||
start = start,
|
|
||||||
end = end,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return segments
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isIgnoringBatteryOptimizations(): Boolean {
|
private fun isIgnoringBatteryOptimizations(): Boolean {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
|
||||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.app.PendingIntent
|
|||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
import android.media.AudioFocusRequest
|
import android.media.AudioFocusRequest
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
@@ -15,7 +16,6 @@ import android.os.Bundle
|
|||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.Parcelable
|
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import android.speech.tts.TextToSpeech
|
import android.speech.tts.TextToSpeech
|
||||||
import android.speech.tts.UtteranceProgressListener
|
import android.speech.tts.UtteranceProgressListener
|
||||||
@@ -28,17 +28,27 @@ import android.support.v4.media.MediaMetadataCompat
|
|||||||
import android.support.v4.media.session.MediaSessionCompat
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
import android.support.v4.media.session.PlaybackStateCompat
|
||||||
import com.example.reader_app.R
|
import com.example.reader_app.R
|
||||||
import kotlinx.parcelize.Parcelize
|
import org.json.JSONObject
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
data class ReaderTtsSegment(
|
data class ReaderTtsSegment(
|
||||||
val text: String,
|
val text: String,
|
||||||
val paragraphIndex: Int,
|
val paragraphIndex: Int,
|
||||||
val start: Int,
|
val start: Int,
|
||||||
val end: Int,
|
val end: Int,
|
||||||
) : Parcelable
|
)
|
||||||
|
|
||||||
|
private data class ReaderRemoteChapter(
|
||||||
|
val id: String,
|
||||||
|
val number: Int?,
|
||||||
|
val title: String?,
|
||||||
|
val content: String,
|
||||||
|
val nextChapterId: String?,
|
||||||
|
)
|
||||||
|
|
||||||
class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -50,6 +60,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
private const val HEALTH_CHECK_INTERVAL_MS = 1500L
|
private const val HEALTH_CHECK_INTERVAL_MS = 1500L
|
||||||
private const val START_GRACE_PERIOD_MS = 5_000L
|
private const val START_GRACE_PERIOD_MS = 5_000L
|
||||||
private const val MAX_SEGMENT_RETRIES_BEFORE_SKIP = 4
|
private const val MAX_SEGMENT_RETRIES_BEFORE_SKIP = 4
|
||||||
|
private const val DEDUPE_START_WINDOW_MS = 600L
|
||||||
|
|
||||||
const val ACTION_INIT = "com.example.reader_app.tts.INIT"
|
const val ACTION_INIT = "com.example.reader_app.tts.INIT"
|
||||||
const val ACTION_START_READING = "com.example.reader_app.tts.START_READING"
|
const val ACTION_START_READING = "com.example.reader_app.tts.START_READING"
|
||||||
@@ -62,8 +73,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
const val ACTION_SET_VOICE = "com.example.reader_app.tts.SET_VOICE"
|
const val ACTION_SET_VOICE = "com.example.reader_app.tts.SET_VOICE"
|
||||||
const val ACTION_SET_BACKGROUND_MODE = "com.example.reader_app.tts.SET_BACKGROUND_MODE"
|
const val ACTION_SET_BACKGROUND_MODE = "com.example.reader_app.tts.SET_BACKGROUND_MODE"
|
||||||
|
|
||||||
const val EXTRA_SEGMENTS = "segments"
|
const val EXTRA_SESSION_TOKEN = "sessionToken"
|
||||||
const val EXTRA_START_INDEX = "startIndex"
|
|
||||||
const val EXTRA_CONTENT_KEY = "contentKey"
|
const val EXTRA_CONTENT_KEY = "contentKey"
|
||||||
const val EXTRA_TITLE = "title"
|
const val EXTRA_TITLE = "title"
|
||||||
const val EXTRA_SPEED = "speed"
|
const val EXTRA_SPEED = "speed"
|
||||||
@@ -83,31 +93,21 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startReading(
|
fun startReading(context: Context, request: ReaderTtsStartRequest): Boolean {
|
||||||
context: Context,
|
return try {
|
||||||
segments: ArrayList<ReaderTtsSegment>,
|
val sessionToken = ReaderTtsPlaybackStore.enqueue(request)
|
||||||
startIndex: Int,
|
ContextCompat.startForegroundService(
|
||||||
contentKey: String?,
|
context,
|
||||||
title: String?,
|
Intent(context, ReaderTtsMediaService::class.java).apply {
|
||||||
speed: Double,
|
action = ACTION_START_READING
|
||||||
language: String,
|
putExtra(EXTRA_SESSION_TOKEN, sessionToken)
|
||||||
voiceName: String?,
|
},
|
||||||
backgroundModeEnabled: Boolean,
|
)
|
||||||
) {
|
true
|
||||||
ContextCompat.startForegroundService(
|
} catch (e: Throwable) {
|
||||||
context,
|
Log.e(TAG, "startForegroundService blocked or failed", e)
|
||||||
Intent(context, ReaderTtsMediaService::class.java).apply {
|
false
|
||||||
action = ACTION_START_READING
|
}
|
||||||
putParcelableArrayListExtra(EXTRA_SEGMENTS, segments)
|
|
||||||
putExtra(EXTRA_START_INDEX, startIndex)
|
|
||||||
putExtra(EXTRA_CONTENT_KEY, contentKey)
|
|
||||||
putExtra(EXTRA_TITLE, title)
|
|
||||||
putExtra(EXTRA_SPEED, speed)
|
|
||||||
putExtra(EXTRA_LANGUAGE, language)
|
|
||||||
putExtra(EXTRA_VOICE_NAME, voiceName)
|
|
||||||
putExtra(EXTRA_BACKGROUND_MODE_ENABLED, backgroundModeEnabled)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pause(context: Context) =
|
fun pause(context: Context) =
|
||||||
@@ -194,7 +194,17 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
private var consecutiveSilentHealthChecks = 0
|
private var consecutiveSilentHealthChecks = 0
|
||||||
private var utteranceWatchdog: Runnable? = null
|
private var utteranceWatchdog: Runnable? = null
|
||||||
private var pausedByAudioFocus = false
|
private var pausedByAudioFocus = false
|
||||||
|
private var isDuckedByAudioFocus = false
|
||||||
|
private var volumeMultiplier = 1.0f
|
||||||
private var lastSpeakRequestTimeMs = 0L
|
private var lastSpeakRequestTimeMs = 0L
|
||||||
|
private var nextChapterId: String? = null
|
||||||
|
private var chapterNumber: Int? = null
|
||||||
|
private var includeChapterTitleInPlayback = true
|
||||||
|
private var apiBaseUrl: String? = null
|
||||||
|
private var isPreparingNextChapter = false
|
||||||
|
private var lastStartSignature: String? = null
|
||||||
|
private var lastStartRequestAtMs = 0L
|
||||||
|
private val networkExecutor = Executors.newSingleThreadExecutor()
|
||||||
private val playbackHealthRunnable = object : Runnable {
|
private val playbackHealthRunnable = object : Runnable {
|
||||||
override fun run() {
|
override fun run() {
|
||||||
runPlaybackHealthCheck()
|
runPlaybackHealthCheck()
|
||||||
@@ -207,15 +217,27 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
when (focusChange) {
|
when (focusChange) {
|
||||||
AudioManager.AUDIOFOCUS_LOSS,
|
AudioManager.AUDIOFOCUS_LOSS,
|
||||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||||
|
clearDuckingState(restartPlayback = false)
|
||||||
if (status == "playing") {
|
if (status == "playing") {
|
||||||
pausedByAudioFocus = true
|
pausedByAudioFocus = true
|
||||||
handlePause()
|
handlePause()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> handleDuckAudioFocusLoss()
|
||||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||||
|
val shouldRestorePlaybackVolume = isDuckedByAudioFocus
|
||||||
|
clearDuckingState(
|
||||||
|
restartPlayback = shouldRestorePlaybackVolume && status == "playing",
|
||||||
|
)
|
||||||
if (pausedByAudioFocus && status == "paused") {
|
if (pausedByAudioFocus && status == "paused") {
|
||||||
pausedByAudioFocus = false
|
pausedByAudioFocus = false
|
||||||
handleResume()
|
handleResume()
|
||||||
|
} else if (pausedByAudioFocus && status == "playing") {
|
||||||
|
// Delayed focus grant arrived while status was already "playing"
|
||||||
|
// (set optimistically). Treat same as resume.
|
||||||
|
pausedByAudioFocus = false
|
||||||
|
clearAudioFocusRetry()
|
||||||
|
speakCurrentSegment(forceRestart = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,6 +251,11 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
setupMediaSession()
|
setupMediaSession()
|
||||||
|
// Call startForeground() IMMEDIATELY in onCreate() before any async work.
|
||||||
|
// Android O+ (and MIUI strictly enforced) requires startForeground() to be called
|
||||||
|
// within 5 seconds of startForegroundService(). TTS engine init is async and may
|
||||||
|
// take longer on cold start / low-end devices, so we must not wait for it.
|
||||||
|
isForegroundActive = startForegroundCompat(buildIdleNotification())
|
||||||
setupTextToSpeech()
|
setupTextToSpeech()
|
||||||
mainHandler.postDelayed(playbackHealthRunnable, HEALTH_CHECK_INTERVAL_MS)
|
mainHandler.postDelayed(playbackHealthRunnable, HEALTH_CHECK_INTERVAL_MS)
|
||||||
publishSnapshot()
|
publishSnapshot()
|
||||||
@@ -257,13 +284,17 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
ACTION_SKIP_BACK -> handleSkip(-1)
|
ACTION_SKIP_BACK -> handleSkip(-1)
|
||||||
ACTION_SET_SPEED -> {
|
ACTION_SET_SPEED -> {
|
||||||
speed = intent.getDoubleExtra(EXTRA_SPEED, speed)
|
speed = intent.getDoubleExtra(EXTRA_SPEED, speed)
|
||||||
applyVoiceAndSpeedSettings()
|
if (isTtsReady) {
|
||||||
|
applyVoiceAndSpeedSettings()
|
||||||
|
}
|
||||||
publishSnapshot()
|
publishSnapshot()
|
||||||
}
|
}
|
||||||
ACTION_SET_VOICE -> {
|
ACTION_SET_VOICE -> {
|
||||||
voiceName = intent.getStringExtra(EXTRA_VOICE_NAME)
|
voiceName = intent.getStringExtra(EXTRA_VOICE_NAME)
|
||||||
language = intent.getStringExtra(EXTRA_LANGUAGE) ?: language
|
language = intent.getStringExtra(EXTRA_LANGUAGE) ?: language
|
||||||
applyVoiceAndSpeedSettings()
|
if (isTtsReady) {
|
||||||
|
applyVoiceAndSpeedSettings()
|
||||||
|
}
|
||||||
publishSnapshot()
|
publishSnapshot()
|
||||||
}
|
}
|
||||||
ACTION_SET_BACKGROUND_MODE -> {
|
ACTION_SET_BACKGROUND_MODE -> {
|
||||||
@@ -404,21 +435,53 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleStartReading(intent: Intent) {
|
private fun handleStartReading(intent: Intent) {
|
||||||
|
val request = ReaderTtsPlaybackStore.consume(intent.getStringExtra(EXTRA_SESSION_TOKEN))
|
||||||
|
if (request == null) {
|
||||||
|
Log.e(TAG, "Missing in-memory TTS start request; refusing to start playback")
|
||||||
|
handleStop(clearContentKey = true, reason = "missing_start_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val signature = listOf(
|
||||||
|
request.contentKey ?: "",
|
||||||
|
request.title ?: "",
|
||||||
|
request.chapterNumber?.toString() ?: "",
|
||||||
|
request.startIndex.toString(),
|
||||||
|
request.includeTitle.toString(),
|
||||||
|
request.content.length.toString(),
|
||||||
|
).joinToString("|")
|
||||||
|
val isDuplicateRapidStart =
|
||||||
|
signature == lastStartSignature &&
|
||||||
|
(now - lastStartRequestAtMs) in 0..DEDUPE_START_WINDOW_MS
|
||||||
|
if (isDuplicateRapidStart && (status == "playing" || status == "paused")) {
|
||||||
|
Log.w(TAG, "Ignore duplicated rapid START_READING request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastStartSignature = signature
|
||||||
|
lastStartRequestAtMs = now
|
||||||
|
|
||||||
cancelIdleStop()
|
cancelIdleStop()
|
||||||
backgroundModeEnabled = intent.getBooleanExtra(
|
backgroundModeEnabled = request.backgroundModeEnabled
|
||||||
EXTRA_BACKGROUND_MODE_ENABLED,
|
speed = request.speed
|
||||||
backgroundModeEnabled,
|
language = request.language
|
||||||
|
voiceName = request.voiceName
|
||||||
|
contentKey = request.contentKey
|
||||||
|
title = request.title
|
||||||
|
nextChapterId = request.nextChapterId
|
||||||
|
chapterNumber = request.chapterNumber
|
||||||
|
includeChapterTitleInPlayback = request.includeTitle
|
||||||
|
apiBaseUrl = request.apiBaseUrl?.trimEnd('/')
|
||||||
|
segments = buildSegments(
|
||||||
|
content = request.content,
|
||||||
|
title = request.title,
|
||||||
|
includeTitle = includeChapterTitleInPlayback,
|
||||||
)
|
)
|
||||||
speed = intent.getDoubleExtra(EXTRA_SPEED, speed)
|
currentIndex = request.startIndex.coerceIn(0, (segments.size - 1).coerceAtLeast(0))
|
||||||
language = intent.getStringExtra(EXTRA_LANGUAGE) ?: language
|
|
||||||
voiceName = intent.getStringExtra(EXTRA_VOICE_NAME)
|
|
||||||
contentKey = intent.getStringExtra(EXTRA_CONTENT_KEY)
|
|
||||||
title = intent.getStringExtra(EXTRA_TITLE)
|
|
||||||
segments = extractSegments(intent)
|
|
||||||
currentIndex = intent.getIntExtra(EXTRA_START_INDEX, 0)
|
|
||||||
.coerceIn(0, (segments.size - 1).coerceAtLeast(0))
|
|
||||||
sessionGeneration += 1
|
sessionGeneration += 1
|
||||||
clearUtteranceRuntimeState()
|
clearUtteranceRuntimeState()
|
||||||
|
clearDuckingState(restartPlayback = false)
|
||||||
|
isPreparingNextChapter = false
|
||||||
status = "playing"
|
status = "playing"
|
||||||
pausedByAudioFocus = false
|
pausedByAudioFocus = false
|
||||||
pendingReplayAfterInit = false
|
pendingReplayAfterInit = false
|
||||||
@@ -426,6 +489,11 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
syncPowerState()
|
syncPowerState()
|
||||||
publishSnapshot()
|
publishSnapshot()
|
||||||
|
|
||||||
|
if (segments.isEmpty()) {
|
||||||
|
handleStop(clearContentKey = false, reason = "empty_segments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!isTtsReady) return
|
if (!isTtsReady) return
|
||||||
speakCurrentSegment(forceRestart = true)
|
speakCurrentSegment(forceRestart = true)
|
||||||
}
|
}
|
||||||
@@ -435,6 +503,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
sessionGeneration += 1
|
sessionGeneration += 1
|
||||||
clearUtteranceRuntimeState()
|
clearUtteranceRuntimeState()
|
||||||
status = "paused"
|
status = "paused"
|
||||||
|
isPreparingNextChapter = false
|
||||||
pendingReplayAfterInit = false
|
pendingReplayAfterInit = false
|
||||||
tts?.stop()
|
tts?.stop()
|
||||||
syncPowerState()
|
syncPowerState()
|
||||||
@@ -446,6 +515,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
if (segments.isEmpty()) return
|
if (segments.isEmpty()) return
|
||||||
cancelIdleStop()
|
cancelIdleStop()
|
||||||
status = "playing"
|
status = "playing"
|
||||||
|
isPreparingNextChapter = false
|
||||||
sessionGeneration += 1
|
sessionGeneration += 1
|
||||||
clearUtteranceRuntimeState()
|
clearUtteranceRuntimeState()
|
||||||
pendingReplayAfterInit = false
|
pendingReplayAfterInit = false
|
||||||
@@ -461,10 +531,15 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
clearScheduledRecoveries()
|
clearScheduledRecoveries()
|
||||||
cancelIdleStop()
|
cancelIdleStop()
|
||||||
clearUtteranceRuntimeState()
|
clearUtteranceRuntimeState()
|
||||||
|
clearDuckingState(restartPlayback = false)
|
||||||
|
isPreparingNextChapter = false
|
||||||
status = "idle"
|
status = "idle"
|
||||||
currentIndex = 0
|
currentIndex = 0
|
||||||
segments = emptyList()
|
segments = emptyList()
|
||||||
title = null
|
title = null
|
||||||
|
nextChapterId = null
|
||||||
|
chapterNumber = null
|
||||||
|
apiBaseUrl = null
|
||||||
if (clearContentKey) {
|
if (clearContentKey) {
|
||||||
contentKey = null
|
contentKey = null
|
||||||
}
|
}
|
||||||
@@ -483,6 +558,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
currentIndex = nextIndex
|
currentIndex = nextIndex
|
||||||
sessionGeneration += 1
|
sessionGeneration += 1
|
||||||
clearUtteranceRuntimeState()
|
clearUtteranceRuntimeState()
|
||||||
|
isPreparingNextChapter = false
|
||||||
status = "playing"
|
status = "playing"
|
||||||
pendingReplayAfterInit = false
|
pendingReplayAfterInit = false
|
||||||
tts?.stop()
|
tts?.stop()
|
||||||
@@ -498,16 +574,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
|
|
||||||
val nextIndex = currentIndex + 1
|
val nextIndex = currentIndex + 1
|
||||||
if (nextIndex >= segments.size) {
|
if (nextIndex >= segments.size) {
|
||||||
status = "idle"
|
handleChapterCompleted()
|
||||||
currentIndex = 0
|
|
||||||
completedCount += 1
|
|
||||||
Log.i(TAG, "chapter_completed contentKey=$contentKey completedCount=$completedCount")
|
|
||||||
clearUtteranceRuntimeState()
|
|
||||||
abandonAudioFocus()
|
|
||||||
syncPowerState()
|
|
||||||
syncNotificationState()
|
|
||||||
publishSnapshot()
|
|
||||||
scheduleIdleStop()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,6 +602,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
|
|
||||||
private fun speakCurrentSegment(forceRestart: Boolean) {
|
private fun speakCurrentSegment(forceRestart: Boolean) {
|
||||||
if (segments.isEmpty() || !isTtsReady) return
|
if (segments.isEmpty() || !isTtsReady) return
|
||||||
|
isPreparingNextChapter = false
|
||||||
if (!requestAudioFocus()) {
|
if (!requestAudioFocus()) {
|
||||||
pausedByAudioFocus = true
|
pausedByAudioFocus = true
|
||||||
status = "paused"
|
status = "paused"
|
||||||
@@ -569,10 +637,20 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
scheduleUtteranceWatchdog(utteranceId)
|
scheduleUtteranceWatchdog(utteranceId)
|
||||||
val speakResult = try {
|
val speakResult = try {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, Bundle(), utteranceId)
|
val params = Bundle().apply {
|
||||||
|
putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, volumeMultiplier)
|
||||||
|
}
|
||||||
|
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, params, utteranceId)
|
||||||
} else {
|
} else {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, null)
|
tts?.speak(
|
||||||
|
segment.text,
|
||||||
|
TextToSpeech.QUEUE_FLUSH,
|
||||||
|
hashMapOf(
|
||||||
|
TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID to utteranceId,
|
||||||
|
TextToSpeech.Engine.KEY_PARAM_VOLUME to volumeMultiplier.toString(),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "speak() failed for index=$currentIndex", e)
|
Log.e(TAG, "speak() failed for index=$currentIndex", e)
|
||||||
@@ -754,10 +832,13 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.setAcceptsDelayedFocusGain(true)
|
.setAcceptsDelayedFocusGain(true)
|
||||||
|
.setWillPauseWhenDucked(false)
|
||||||
.setOnAudioFocusChangeListener(audioFocusListener)
|
.setOnAudioFocusChangeListener(audioFocusListener)
|
||||||
.build()
|
.build()
|
||||||
.also { audioFocusRequest = it }
|
.also { audioFocusRequest = it }
|
||||||
val result = audioManager.requestAudioFocus(request)
|
val result = audioManager.requestAudioFocus(request)
|
||||||
|
// AUDIOFOCUS_REQUEST_DELAYED (= 2) means focus will arrive via the listener.
|
||||||
|
// Treat it as "not yet granted" – the listener will resume playback on GAIN.
|
||||||
result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||||
} else {
|
} else {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
@@ -839,7 +920,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
if (wakeLock?.isHeld == true) return
|
if (wakeLock?.isHeld == true) return
|
||||||
wakeLock = powerManager.newWakeLock(
|
wakeLock = powerManager.newWakeLock(
|
||||||
PowerManager.PARTIAL_WAKE_LOCK,
|
PowerManager.PARTIAL_WAKE_LOCK,
|
||||||
"reader_app:ReaderTtsPlayback"
|
"$packageName:ReaderTtsPlayback"
|
||||||
).apply {
|
).apply {
|
||||||
setReferenceCounted(false)
|
setReferenceCounted(false)
|
||||||
acquire()
|
acquire()
|
||||||
@@ -868,6 +949,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
private fun currentSegment(): ReaderTtsSegment? = segments.getOrNull(currentIndex)
|
private fun currentSegment(): ReaderTtsSegment? = segments.getOrNull(currentIndex)
|
||||||
|
|
||||||
private fun currentProgressLabel(): String {
|
private fun currentProgressLabel(): String {
|
||||||
|
if (isPreparingNextChapter) return "Đang tải chương tiếp theo"
|
||||||
if (segments.isEmpty()) return voiceName ?: language
|
if (segments.isEmpty()) return voiceName ?: language
|
||||||
return "Câu ${currentIndex + 1}/${segments.size}"
|
return "Câu ${currentIndex + 1}/${segments.size}"
|
||||||
}
|
}
|
||||||
@@ -903,9 +985,20 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Minimal notification used in onCreate() to satisfy the 5-second startForeground() rule. */
|
||||||
|
private fun buildIdleNotification() = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||||
|
.setContentTitle(appLabel())
|
||||||
|
.setContentText("Đang khởi động TTS…")
|
||||||
|
.setOngoing(true)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
|
.build()
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID)
|
private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
// Avoid adaptive launcher icon for foreground notifications on strict OEM ROMs.
|
||||||
|
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||||
.setContentTitle(title ?: appLabel())
|
.setContentTitle(title ?: appLabel())
|
||||||
.setContentText(currentProgressLabel())
|
.setContentText(currentProgressLabel())
|
||||||
.setContentIntent(buildLaunchIntent())
|
.setContentIntent(buildLaunchIntent())
|
||||||
@@ -995,8 +1088,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
// to prevent Android from killing us.
|
// to prevent Android from killing us.
|
||||||
if (status == "playing" || status == "paused") {
|
if (status == "playing" || status == "paused") {
|
||||||
val notification = buildNotification()
|
val notification = buildNotification()
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
isForegroundActive = startForegroundCompat(notification)
|
||||||
isForegroundActive = true
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1005,8 +1097,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
"playing", "paused" -> {
|
"playing", "paused" -> {
|
||||||
val notification = buildNotification()
|
val notification = buildNotification()
|
||||||
if (!isForegroundActive) {
|
if (!isForegroundActive) {
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
isForegroundActive = startForegroundCompat(notification)
|
||||||
isForegroundActive = true
|
|
||||||
} else {
|
} else {
|
||||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||||
}
|
}
|
||||||
@@ -1021,6 +1112,24 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startForegroundCompat(notification: android.app.Notification): Boolean {
|
||||||
|
return try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(
|
||||||
|
NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e(TAG, "startForeground failed", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun publishSnapshot() {
|
private fun publishSnapshot() {
|
||||||
val segment = currentSegment()
|
val segment = currentSegment()
|
||||||
val canExposeSegmentProgress = status == "playing" && currentUtteranceStarted
|
val canExposeSegmentProgress = status == "playing" && currentUtteranceStarted
|
||||||
@@ -1047,6 +1156,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
"contentKey" to contentKey,
|
"contentKey" to contentKey,
|
||||||
"completedCount" to completedCount,
|
"completedCount" to completedCount,
|
||||||
"backgroundModeEnabled" to backgroundModeEnabled,
|
"backgroundModeEnabled" to backgroundModeEnabled,
|
||||||
|
"isPreparingNextChapter" to isPreparingNextChapter,
|
||||||
"language" to language,
|
"language" to language,
|
||||||
"voiceName" to voiceName,
|
"voiceName" to voiceName,
|
||||||
"availableVietnameseVoices" to availableVoices,
|
"availableVietnameseVoices" to availableVoices,
|
||||||
@@ -1060,23 +1170,254 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
CHANNEL_NAME,
|
CHANNEL_NAME,
|
||||||
NotificationManager.IMPORTANCE_LOW,
|
// IMPORTANCE_DEFAULT is required on MIUI 12+ so the system does not demote
|
||||||
|
// the foreground service. Sound/vibration are disabled explicitly so the user
|
||||||
|
// is not disturbed despite the higher importance level.
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT,
|
||||||
).apply {
|
).apply {
|
||||||
description = "Điều khiển đọc truyện bằng TTS"
|
description = "Điều khiển đọc truyện bằng TTS"
|
||||||
setShowBadge(false)
|
setShowBadge(false)
|
||||||
|
setSound(null, null)
|
||||||
|
enableLights(false)
|
||||||
|
enableVibration(false)
|
||||||
}
|
}
|
||||||
manager.createNotificationChannel(channel)
|
manager.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractSegments(intent: Intent): List<ReaderTtsSegment> {
|
private fun sanitizeForTts(raw: String): String {
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
if (raw.isBlank()) return raw
|
||||||
intent.getParcelableArrayListExtra(EXTRA_SEGMENTS, ReaderTtsSegment::class.java)
|
return raw
|
||||||
?: arrayListOf()
|
.replace(Regex("[\"“”]"), " ")
|
||||||
} else {
|
.replace(Regex("[_\\$#^*+=~`|<>\\\\\\[\\]{}]"), " ")
|
||||||
@Suppress("DEPRECATION")
|
.replace(Regex("\\s+"), " ")
|
||||||
(intent.getParcelableArrayListExtra<ReaderTtsSegment>(EXTRA_SEGMENTS)
|
.trim()
|
||||||
?: arrayListOf())
|
}
|
||||||
|
|
||||||
|
private fun buildSegments(
|
||||||
|
content: String,
|
||||||
|
title: String?,
|
||||||
|
includeTitle: Boolean,
|
||||||
|
): List<ReaderTtsSegment> {
|
||||||
|
val builtSegments = mutableListOf<ReaderTtsSegment>()
|
||||||
|
val trimmedTitle = title?.trim().orEmpty()
|
||||||
|
if (includeTitle && trimmedTitle.isNotEmpty()) {
|
||||||
|
val sanitizedTitle = sanitizeForTts(trimmedTitle)
|
||||||
|
if (sanitizedTitle.isNotEmpty()) {
|
||||||
|
builtSegments += ReaderTtsSegment(
|
||||||
|
text = sanitizedTitle,
|
||||||
|
paragraphIndex = -1,
|
||||||
|
start = -1,
|
||||||
|
end = -1,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val paragraphs = content
|
||||||
|
.split(Regex("\\n+"))
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(String::isNotEmpty)
|
||||||
|
val sentenceRegex = Regex("[^.!?…]+[.!?…]*")
|
||||||
|
|
||||||
|
paragraphs.forEachIndexed { paragraphIndex, paragraph ->
|
||||||
|
var cursor = 0
|
||||||
|
sentenceRegex.findAll(paragraph).forEach { match ->
|
||||||
|
val sentence = match.value.trim()
|
||||||
|
if (sentence.isEmpty()) return@forEach
|
||||||
|
val sanitizedSentence = sanitizeForTts(sentence)
|
||||||
|
if (sanitizedSentence.isEmpty()) return@forEach
|
||||||
|
|
||||||
|
var start = paragraph.indexOf(sentence, cursor)
|
||||||
|
if (start < 0) {
|
||||||
|
start = cursor.coerceIn(0, paragraph.length)
|
||||||
|
}
|
||||||
|
val end = (start + sentence.length).coerceIn(0, paragraph.length)
|
||||||
|
cursor = end
|
||||||
|
|
||||||
|
builtSegments += ReaderTtsSegment(
|
||||||
|
text = sanitizedSentence,
|
||||||
|
paragraphIndex = paragraphIndex,
|
||||||
|
start = start,
|
||||||
|
end = end,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builtSegments
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleChapterCompleted() {
|
||||||
|
clearUtteranceRuntimeState()
|
||||||
|
val nextId = nextChapterId
|
||||||
|
if (nextId.isNullOrBlank() || apiBaseUrl.isNullOrBlank()) {
|
||||||
|
finishPlaybackAfterChapterCompletion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isPreparingNextChapter = true
|
||||||
|
syncNotificationState()
|
||||||
|
publishSnapshot()
|
||||||
|
fetchAndPlayNextChapter(nextId, sessionGeneration)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finishPlaybackAfterChapterCompletion() {
|
||||||
|
status = "idle"
|
||||||
|
currentIndex = 0
|
||||||
|
completedCount += 1
|
||||||
|
Log.i(TAG, "chapter_completed contentKey=$contentKey completedCount=$completedCount")
|
||||||
|
clearUtteranceRuntimeState()
|
||||||
|
clearDuckingState(restartPlayback = false)
|
||||||
|
abandonAudioFocus()
|
||||||
|
syncPowerState()
|
||||||
|
syncNotificationState()
|
||||||
|
publishSnapshot()
|
||||||
|
scheduleIdleStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchAndPlayNextChapter(chapterId: String, generation: Int) {
|
||||||
|
networkExecutor.execute {
|
||||||
|
val remoteChapter = fetchChapterWithRetries(chapterId)
|
||||||
|
mainHandler.post {
|
||||||
|
if (generation != sessionGeneration || status == "idle") return@post
|
||||||
|
if (remoteChapter == null) {
|
||||||
|
Log.e(TAG, "Failed to fetch next chapter chapterId=$chapterId")
|
||||||
|
isPreparingNextChapter = false
|
||||||
|
finishPlaybackAfterChapterCompletion()
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
adoptRemoteChapter(remoteChapter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchChapterWithRetries(chapterId: String): ReaderRemoteChapter? {
|
||||||
|
repeat(3) { attempt ->
|
||||||
|
try {
|
||||||
|
return fetchChapter(chapterId)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
Log.w(TAG, "fetch_next_chapter_failed attempt=${attempt + 1} chapterId=$chapterId", error)
|
||||||
|
if (attempt < 2) {
|
||||||
|
Thread.sleep((750L * (attempt + 1)).coerceAtMost(2_500L))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchChapter(chapterId: String): ReaderRemoteChapter {
|
||||||
|
val baseUrl = apiBaseUrl?.trimEnd('/') ?: error("Missing api base URL for TTS service")
|
||||||
|
val connection = (URL("$baseUrl/api/chapters/$chapterId").openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "GET"
|
||||||
|
connectTimeout = 20_000
|
||||||
|
readTimeout = 20_000
|
||||||
|
setRequestProperty("Accept", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val statusCode = connection.responseCode
|
||||||
|
val responseBody = (if (statusCode in 200..299) {
|
||||||
|
connection.inputStream
|
||||||
|
} else {
|
||||||
|
connection.errorStream
|
||||||
|
})?.bufferedReader()?.use { it.readText() }.orEmpty()
|
||||||
|
|
||||||
|
if (statusCode !in 200..299) {
|
||||||
|
error("HTTP $statusCode when fetching chapter $chapterId: $responseBody")
|
||||||
|
}
|
||||||
|
|
||||||
|
val json = JSONObject(responseBody)
|
||||||
|
val id = json.optString("id").takeIf { it.isNotBlank() }
|
||||||
|
?: error("Chapter payload missing id")
|
||||||
|
val title = json.optString("title").takeIf { it.isNotBlank() }
|
||||||
|
val content = json.optString("content")
|
||||||
|
val nextId = json.optString("nextChapterId").takeIf { it.isNotBlank() }
|
||||||
|
val number = if (json.isNull("number")) null else json.optInt("number")
|
||||||
|
|
||||||
|
return ReaderRemoteChapter(
|
||||||
|
id = id,
|
||||||
|
number = number,
|
||||||
|
title = title,
|
||||||
|
content = content,
|
||||||
|
nextChapterId = nextId,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adoptRemoteChapter(remoteChapter: ReaderRemoteChapter) {
|
||||||
|
val nextTitle = buildChapterTitle(remoteChapter.number, remoteChapter.title)
|
||||||
|
val nextSegments = buildSegments(
|
||||||
|
content = remoteChapter.content,
|
||||||
|
title = nextTitle,
|
||||||
|
includeTitle = includeChapterTitleInPlayback,
|
||||||
|
)
|
||||||
|
if (nextSegments.isEmpty()) {
|
||||||
|
Log.e(TAG, "Fetched next chapter has no readable segments id=${remoteChapter.id}")
|
||||||
|
isPreparingNextChapter = false
|
||||||
|
finishPlaybackAfterChapterCompletion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
completedCount += 1
|
||||||
|
contentKey = remoteChapter.id
|
||||||
|
title = nextTitle
|
||||||
|
chapterNumber = remoteChapter.number
|
||||||
|
nextChapterId = remoteChapter.nextChapterId
|
||||||
|
segments = nextSegments
|
||||||
|
currentIndex = 0
|
||||||
|
sessionGeneration += 1
|
||||||
|
clearUtteranceRuntimeState()
|
||||||
|
isPreparingNextChapter = false
|
||||||
|
status = "playing"
|
||||||
|
pausedByAudioFocus = false
|
||||||
|
pendingReplayAfterInit = false
|
||||||
|
clearDuckingState(restartPlayback = false)
|
||||||
|
publishSnapshot()
|
||||||
|
|
||||||
|
if (!isTtsReady) {
|
||||||
|
pendingReplayAfterInit = true
|
||||||
|
scheduleEngineRebuild("next_chapter_tts_not_ready")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
speakCurrentSegment(forceRestart = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildChapterTitle(number: Int?, rawTitle: String?): String? {
|
||||||
|
val trimmedTitle = rawTitle?.trim().orEmpty()
|
||||||
|
return when {
|
||||||
|
number != null && trimmedTitle.isNotEmpty() -> "Chương $number: $trimmedTitle"
|
||||||
|
number != null -> "Chương $number"
|
||||||
|
trimmedTitle.isNotEmpty() -> trimmedTitle
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDuckAudioFocusLoss() {
|
||||||
|
pausedByAudioFocus = false
|
||||||
|
if (isDuckedByAudioFocus) return
|
||||||
|
isDuckedByAudioFocus = true
|
||||||
|
volumeMultiplier = 0.35f
|
||||||
|
if (status == "playing" && !isPreparingNextChapter) {
|
||||||
|
restartCurrentSegmentForFocusChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearDuckingState(restartPlayback: Boolean) {
|
||||||
|
if (!isDuckedByAudioFocus && volumeMultiplier == 1.0f) return
|
||||||
|
isDuckedByAudioFocus = false
|
||||||
|
volumeMultiplier = 1.0f
|
||||||
|
if (restartPlayback && status == "playing" && !isPreparingNextChapter) {
|
||||||
|
restartCurrentSegmentForFocusChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restartCurrentSegmentForFocusChange() {
|
||||||
|
if (segments.isEmpty() || !isTtsReady) return
|
||||||
|
sessionGeneration += 1
|
||||||
|
clearUtteranceRuntimeState()
|
||||||
|
tts?.stop()
|
||||||
|
speakCurrentSegment(forceRestart = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
@@ -1087,8 +1428,13 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
status = "idle"
|
status = "idle"
|
||||||
currentIndex = 0
|
currentIndex = 0
|
||||||
segments = emptyList()
|
segments = emptyList()
|
||||||
|
nextChapterId = null
|
||||||
|
chapterNumber = null
|
||||||
|
apiBaseUrl = null
|
||||||
|
isPreparingNextChapter = false
|
||||||
clearUtteranceRuntimeState()
|
clearUtteranceRuntimeState()
|
||||||
pendingReplayAfterInit = false
|
pendingReplayAfterInit = false
|
||||||
|
clearDuckingState(restartPlayback = false)
|
||||||
publishSnapshot()
|
publishSnapshot()
|
||||||
tts?.stop()
|
tts?.stop()
|
||||||
tts?.shutdown()
|
tts?.shutdown()
|
||||||
@@ -1099,6 +1445,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
isForegroundActive = false
|
isForegroundActive = false
|
||||||
}
|
}
|
||||||
mediaSession.release()
|
mediaSession.release()
|
||||||
|
networkExecutor.shutdownNow()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.example.reader_app.tts
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class ReaderTtsStartRequest(
|
||||||
|
val content: String,
|
||||||
|
val contentKey: String?,
|
||||||
|
val title: String?,
|
||||||
|
val speed: Double,
|
||||||
|
val language: String,
|
||||||
|
val voiceName: String?,
|
||||||
|
val backgroundModeEnabled: Boolean,
|
||||||
|
val nextChapterId: String?,
|
||||||
|
val chapterNumber: Int?,
|
||||||
|
val includeTitle: Boolean,
|
||||||
|
val apiBaseUrl: String?,
|
||||||
|
val startIndex: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
object ReaderTtsPlaybackStore {
|
||||||
|
private const val MAX_PENDING_REQUESTS = 4
|
||||||
|
private val pendingRequests = LinkedHashMap<String, ReaderTtsStartRequest>()
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun enqueue(request: ReaderTtsStartRequest): String {
|
||||||
|
val token = UUID.randomUUID().toString()
|
||||||
|
pendingRequests[token] = request
|
||||||
|
while (pendingRequests.size > MAX_PENDING_REQUESTS) {
|
||||||
|
val oldestKey = pendingRequests.entries.firstOrNull()?.key ?: break
|
||||||
|
pendingRequests.remove(oldestKey)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun consume(token: String?): ReaderTtsStartRequest? {
|
||||||
|
if (token.isNullOrBlank()) return null
|
||||||
|
return pendingRequests.remove(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.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/session_expiry_notifier.dart';
|
import '../core/auth/session_expiry_notifier.dart';
|
||||||
import '../core/theme/app_theme.dart';
|
import '../core/theme/app_theme.dart';
|
||||||
|
import '../core/storage/local_store.dart';
|
||||||
import '../features/auth/providers/auth_provider.dart';
|
import '../features/auth/providers/auth_provider.dart';
|
||||||
import '../features/reader/tts/tts_service.dart';
|
import '../features/reader/tts/tts_service.dart';
|
||||||
import 'router/route_names.dart';
|
import 'router/route_names.dart';
|
||||||
@@ -19,10 +23,35 @@ class ReaderApp extends ConsumerStatefulWidget {
|
|||||||
class _ReaderAppState extends ConsumerState<ReaderApp> {
|
class _ReaderAppState extends ConsumerState<ReaderApp> {
|
||||||
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||||
ProviderSubscription<int>? _sessionExpirySub;
|
ProviderSubscription<int>? _sessionExpirySub;
|
||||||
|
late final GoRouter _router;
|
||||||
|
String? _previousPath;
|
||||||
|
|
||||||
|
void _persistRouteForRestore() {
|
||||||
|
if (!mounted) return;
|
||||||
|
final uri = _router.state.uri;
|
||||||
|
final fullPath = uri.hasQuery ? '${uri.path}?${uri.query}' : uri.path;
|
||||||
|
if (fullPath == RouteNames.splash) return;
|
||||||
|
|
||||||
|
// When navigating into reader from a novel page, save "novelPath|readerPath"
|
||||||
|
// so the splash screen can reconstruct the full back stack on restore.
|
||||||
|
final String pathToSave;
|
||||||
|
if (fullPath.startsWith('/reader/') &&
|
||||||
|
_previousPath != null &&
|
||||||
|
_previousPath!.startsWith('/novel/')) {
|
||||||
|
pathToSave = '$_previousPath|$fullPath';
|
||||||
|
} else {
|
||||||
|
pathToSave = fullPath;
|
||||||
|
}
|
||||||
|
_previousPath = fullPath;
|
||||||
|
|
||||||
|
unawaited(ref.read(localStoreProvider).saveLastRoutePath(pathToSave));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_router = ref.read(appRouterProvider);
|
||||||
|
_router.routerDelegate.addListener(_persistRouteForRestore);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_ensureMandatoryTtsRequirements();
|
_ensureMandatoryTtsRequirements();
|
||||||
});
|
});
|
||||||
@@ -92,6 +121,7 @@ class _ReaderAppState extends ConsumerState<ReaderApp> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_router.routerDelegate.removeListener(_persistRouteForRestore);
|
||||||
_sessionExpirySub?.close();
|
_sessionExpirySub?.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class AppConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||||
return 'http://10.0.2.2:8000';
|
return 'https://reader-api.fevirtus.dev';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'http://localhost:8000';
|
return 'http://localhost:8000';
|
||||||
|
|||||||
@@ -2,10 +2,26 @@ import 'package:equatable/equatable.dart';
|
|||||||
|
|
||||||
import 'novel_model.dart';
|
import 'novel_model.dart';
|
||||||
|
|
||||||
|
enum BookmarkType {
|
||||||
|
reading('reading'),
|
||||||
|
bookmarked('bookmarked');
|
||||||
|
|
||||||
|
const BookmarkType(this.value);
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
static BookmarkType fromString(String? str) {
|
||||||
|
return values.firstWhere(
|
||||||
|
(e) => e.value == str,
|
||||||
|
orElse: () => BookmarkType.bookmarked,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class BookmarkModel extends Equatable {
|
class BookmarkModel extends Equatable {
|
||||||
const BookmarkModel({
|
const BookmarkModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.novelId,
|
required this.novelId,
|
||||||
|
this.type = BookmarkType.bookmarked,
|
||||||
this.lastChapterId,
|
this.lastChapterId,
|
||||||
this.lastChapterNumber,
|
this.lastChapterNumber,
|
||||||
this.readChapters = const [],
|
this.readChapters = const [],
|
||||||
@@ -14,6 +30,7 @@ class BookmarkModel extends Equatable {
|
|||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
final String novelId;
|
final String novelId;
|
||||||
|
final BookmarkType type;
|
||||||
final String? lastChapterId;
|
final String? lastChapterId;
|
||||||
final int? lastChapterNumber;
|
final int? lastChapterNumber;
|
||||||
final List<int> readChapters;
|
final List<int> readChapters;
|
||||||
@@ -28,11 +45,27 @@ class BookmarkModel extends Equatable {
|
|||||||
?.map((e) => (e as num).toInt())
|
?.map((e) => (e as num).toInt())
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
|
type: () {
|
||||||
|
final explicitType = BookmarkType.fromString(json['type'] as String?);
|
||||||
|
if ((json['type'] as String?) != null) {
|
||||||
|
return explicitType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward-compatible inference when API does not return `type`.
|
||||||
|
final inferredLastChapter = json['lastChapterNumber'] as int?;
|
||||||
|
final inferredReadChapters = (json['readChapters'] as List<dynamic>?)
|
||||||
|
?.map((e) => (e as num).toInt())
|
||||||
|
.toList() ??
|
||||||
|
const <int>[];
|
||||||
|
return (inferredLastChapter != null || inferredReadChapters.isNotEmpty)
|
||||||
|
? BookmarkType.reading
|
||||||
|
: BookmarkType.bookmarked;
|
||||||
|
}(),
|
||||||
novel: json['novel'] != null
|
novel: json['novel'] != null
|
||||||
? NovelModel.fromJson(json['novel'] as Map<String, dynamic>)
|
? NovelModel.fromJson(json['novel'] as Map<String, dynamic>)
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [id, novelId];
|
List<Object?> get props => [id, novelId, type];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class ReadingSettings {
|
|||||||
this.horizontalPadding = 20,
|
this.horizontalPadding = 20,
|
||||||
this.paragraphSpacing = 24,
|
this.paragraphSpacing = 24,
|
||||||
this.textAlign = 'left',
|
this.textAlign = 'left',
|
||||||
|
this.enableSentenceTapTts = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final double fontSize;
|
final double fontSize;
|
||||||
@@ -22,6 +23,7 @@ class ReadingSettings {
|
|||||||
final double horizontalPadding;
|
final double horizontalPadding;
|
||||||
final double paragraphSpacing;
|
final double paragraphSpacing;
|
||||||
final String textAlign;
|
final String textAlign;
|
||||||
|
final bool enableSentenceTapTts;
|
||||||
|
|
||||||
ReadingSettings copyWith({
|
ReadingSettings copyWith({
|
||||||
double? fontSize,
|
double? fontSize,
|
||||||
@@ -34,6 +36,7 @@ class ReadingSettings {
|
|||||||
double? horizontalPadding,
|
double? horizontalPadding,
|
||||||
double? paragraphSpacing,
|
double? paragraphSpacing,
|
||||||
String? textAlign,
|
String? textAlign,
|
||||||
|
bool? enableSentenceTapTts,
|
||||||
}) =>
|
}) =>
|
||||||
ReadingSettings(
|
ReadingSettings(
|
||||||
fontSize: fontSize ?? this.fontSize,
|
fontSize: fontSize ?? this.fontSize,
|
||||||
@@ -46,6 +49,7 @@ class ReadingSettings {
|
|||||||
horizontalPadding: horizontalPadding ?? this.horizontalPadding,
|
horizontalPadding: horizontalPadding ?? this.horizontalPadding,
|
||||||
paragraphSpacing: paragraphSpacing ?? this.paragraphSpacing,
|
paragraphSpacing: paragraphSpacing ?? this.paragraphSpacing,
|
||||||
textAlign: textAlign ?? this.textAlign,
|
textAlign: textAlign ?? this.textAlign,
|
||||||
|
enableSentenceTapTts: enableSentenceTapTts ?? this.enableSentenceTapTts,
|
||||||
);
|
);
|
||||||
|
|
||||||
factory ReadingSettings.fromJson(Map<String, dynamic> json) => ReadingSettings(
|
factory ReadingSettings.fromJson(Map<String, dynamic> json) => ReadingSettings(
|
||||||
@@ -60,6 +64,7 @@ class ReadingSettings {
|
|||||||
horizontalPadding: (json['horizontalPadding'] as num?)?.toDouble() ?? 20,
|
horizontalPadding: (json['horizontalPadding'] as num?)?.toDouble() ?? 20,
|
||||||
paragraphSpacing: (json['paragraphSpacing'] as num?)?.toDouble() ?? 24,
|
paragraphSpacing: (json['paragraphSpacing'] as num?)?.toDouble() ?? 24,
|
||||||
textAlign: json['textAlign'] as String? ?? 'left',
|
textAlign: json['textAlign'] as String? ?? 'left',
|
||||||
|
enableSentenceTapTts: json['enableSentenceTapTts'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
@@ -73,5 +78,6 @@ class ReadingSettings {
|
|||||||
'horizontalPadding': horizontalPadding,
|
'horizontalPadding': horizontalPadding,
|
||||||
'paragraphSpacing': paragraphSpacing,
|
'paragraphSpacing': paragraphSpacing,
|
||||||
'textAlign': textAlign,
|
'textAlign': textAlign,
|
||||||
|
'enableSentenceTapTts': enableSentenceTapTts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class LocalStore {
|
|||||||
static const _kProgressChapterId = 'progress_chapter_id_';
|
static const _kProgressChapterId = 'progress_chapter_id_';
|
||||||
static const _kProgressChapterNum = 'progress_chapter_num_';
|
static const _kProgressChapterNum = 'progress_chapter_num_';
|
||||||
static const _kProgressOffset = 'progress_offset_';
|
static const _kProgressOffset = 'progress_offset_';
|
||||||
|
static const _kLastRoutePath = 'last_route_path';
|
||||||
|
|
||||||
// ── Reading settings ──────────────────────────────────────────────────────
|
// ── Reading settings ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -86,6 +87,27 @@ class LocalStore {
|
|||||||
'scrollOffset': prefs.getDouble('$_kProgressOffset$novelId') ?? 0.0,
|
'scrollOffset': prefs.getDouble('$_kProgressOffset$novelId') ?? 0.0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Last route restore (cold start after process reclaim) ───────────────
|
||||||
|
|
||||||
|
Future<void> saveLastRoutePath(String path) async {
|
||||||
|
final normalized = path.trim();
|
||||||
|
if (normalized.isEmpty || normalized == '/') return;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_kLastRoutePath, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> loadLastRoutePath() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final value = prefs.getString(_kLastRoutePath)?.trim();
|
||||||
|
if (value == null || value.isEmpty) return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearLastRoutePath() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_kLastRoutePath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final localStoreProvider = Provider<LocalStore>((_) => LocalStore());
|
final localStoreProvider = Provider<LocalStore>((_) => LocalStore());
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../../../app/router/route_names.dart';
|
import '../../../app/router/route_names.dart';
|
||||||
import '../../../core/models/bookmark_model.dart';
|
import '../../../core/models/bookmark_model.dart';
|
||||||
import '../../../shared/widgets/main_app_header.dart';
|
import '../../../shared/widgets/main_app_header.dart';
|
||||||
|
import '../../novel/providers/novels_provider.dart';
|
||||||
import '../providers/bookshelf_provider.dart';
|
import '../providers/bookshelf_provider.dart';
|
||||||
import '../../auth/providers/auth_provider.dart';
|
import '../../auth/providers/auth_provider.dart';
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ class BookshelfScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: DefaultTabController(
|
body: DefaultTabController(
|
||||||
length: 3,
|
length: 2,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
MainAppHeader(
|
MainAppHeader(
|
||||||
@@ -68,9 +69,8 @@ class BookshelfScreen extends ConsumerWidget {
|
|||||||
unselectedLabelColor: Colors.white70,
|
unselectedLabelColor: Colors.white70,
|
||||||
dividerColor: Colors.transparent,
|
dividerColor: Colors.transparent,
|
||||||
tabs: const [
|
tabs: const [
|
||||||
Tab(text: 'Đã đọc'),
|
Tab(text: 'Đang đọc'),
|
||||||
Tab(text: 'Đã lưu'),
|
Tab(text: 'Đánh dấu'),
|
||||||
Tab(text: 'Đang mở'),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -93,23 +93,18 @@ class BookshelfScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
data: (bookmarks) {
|
data: (bookmarks) {
|
||||||
final readItems = bookmarks.where((e) => e.readChapters.isNotEmpty).toList();
|
final readingItems = ref.watch(readingBookmarksProvider);
|
||||||
final savedItems = bookmarks;
|
final bookmarkedItems = ref.watch(savedBookmarksProvider);
|
||||||
final openingItems = bookmarks.where((e) => e.lastChapterId != null).toList();
|
|
||||||
|
|
||||||
return TabBarView(
|
return TabBarView(
|
||||||
children: [
|
children: [
|
||||||
_BookshelfList(
|
_BookshelfList(
|
||||||
bookmarks: readItems,
|
bookmarks: readingItems,
|
||||||
emptyLabel: 'Chưa có truyện đã đọc.',
|
emptyLabel: 'Chưa có truyện đang đọc.',
|
||||||
),
|
),
|
||||||
_BookshelfList(
|
_BookshelfList(
|
||||||
bookmarks: savedItems,
|
bookmarks: bookmarkedItems,
|
||||||
emptyLabel: 'Chưa có truyện nào trong tủ sách.',
|
emptyLabel: 'Chưa có truyện đánh dấu.',
|
||||||
),
|
|
||||||
_BookshelfList(
|
|
||||||
bookmarks: openingItems,
|
|
||||||
emptyLabel: 'Chưa có truyện đang mở.',
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -150,20 +145,57 @@ class _BookshelfList extends ConsumerWidget {
|
|||||||
padding: const EdgeInsets.fromLTRB(14, 14, 14, 24),
|
padding: const EdgeInsets.fromLTRB(14, 14, 14, 24),
|
||||||
itemCount: bookmarks.length,
|
itemCount: bookmarks.length,
|
||||||
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||||
itemBuilder: (context, index) => _BookmarkTile(bookmark: bookmarks[index]),
|
itemBuilder: (context, index) {
|
||||||
|
final bookmark = bookmarks[index];
|
||||||
|
return _BookmarkTile(
|
||||||
|
bookmark: bookmark,
|
||||||
|
onRemove: () => ref
|
||||||
|
.read(bookshelfProvider.notifier)
|
||||||
|
.removeFromShelf(bookmark.novelId, bookmark.type),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BookmarkTile extends StatelessWidget {
|
class _BookmarkTile extends ConsumerWidget {
|
||||||
final BookmarkModel bookmark;
|
final BookmarkModel bookmark;
|
||||||
const _BookmarkTile({required this.bookmark});
|
final VoidCallback onRemove;
|
||||||
|
const _BookmarkTile({
|
||||||
|
required this.bookmark,
|
||||||
|
required this.onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> _openContinueReader(BuildContext context, WidgetRef ref) async {
|
||||||
|
var targetChapterId = bookmark.lastChapterId;
|
||||||
|
if (targetChapterId == null || targetChapterId.isEmpty) {
|
||||||
|
try {
|
||||||
|
final chapters = await ref.read(
|
||||||
|
chapterListProvider(bookmark.novelId).future,
|
||||||
|
);
|
||||||
|
if (chapters.isNotEmpty) {
|
||||||
|
targetChapterId = chapters.first.id;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Fall through to novel detail when chapter lookup fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (targetChapterId != null && targetChapterId.isNotEmpty) {
|
||||||
|
context.push(RouteNames.readerChapter(targetChapterId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.push(RouteNames.novelDetail(bookmark.novelId));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final novel = bookmark.novel;
|
final novel = bookmark.novel;
|
||||||
return Container(
|
return GestureDetector(
|
||||||
|
onTap: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
|
||||||
|
child: Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||||
@@ -172,7 +204,7 @@ class _BookmarkTile extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
@@ -211,7 +243,10 @@ class _BookmarkTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Icon(Icons.close_rounded, size: 20),
|
GestureDetector(
|
||||||
|
onTap: onRemove,
|
||||||
|
child: const Icon(Icons.close_rounded, size: 20),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -249,7 +284,7 @@ class _BookmarkTile extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
|
onPressed: () => _openContinueReader(context, ref),
|
||||||
icon: const Icon(Icons.menu_book_rounded),
|
icon: const Icon(Icons.menu_book_rounded),
|
||||||
label: const Text('Đọc tiếp'),
|
label: const Text('Đọc tiếp'),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
@@ -258,22 +293,11 @@ class _BookmarkTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: FilledButton.icon(
|
|
||||||
onPressed: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
|
|
||||||
icon: const Icon(Icons.headphones_rounded),
|
|
||||||
label: const Text('Nghe tiếp'),
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: const Color(0xFF14B8A6),
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,62 @@ class BookshelfNotifier extends StateNotifier<AsyncValue<List<BookmarkModel>>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void syncProgress({
|
||||||
|
required String novelId,
|
||||||
|
required String chapterId,
|
||||||
|
required int chapterNumber,
|
||||||
|
Map<String, dynamic>? serverBookmark,
|
||||||
|
}) {
|
||||||
|
final current = state.valueOrNull ?? const <BookmarkModel>[];
|
||||||
|
|
||||||
|
BookmarkModel? parsedFromServer;
|
||||||
|
if (serverBookmark != null) {
|
||||||
|
try {
|
||||||
|
parsedFromServer = BookmarkModel.fromJson(serverBookmark);
|
||||||
|
} catch (_) {
|
||||||
|
parsedFromServer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final index = current.indexWhere((b) => b.novelId == novelId);
|
||||||
|
if (index >= 0) {
|
||||||
|
final existing = current[index];
|
||||||
|
final merged = parsedFromServer ?? BookmarkModel(
|
||||||
|
id: existing.id,
|
||||||
|
novelId: existing.novelId,
|
||||||
|
type: BookmarkType.reading,
|
||||||
|
lastChapterId: chapterId,
|
||||||
|
lastChapterNumber: chapterNumber,
|
||||||
|
readChapters: {
|
||||||
|
...existing.readChapters,
|
||||||
|
chapterNumber,
|
||||||
|
}.toList()
|
||||||
|
..sort(),
|
||||||
|
novel: existing.novel,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = [...current]..[index] = merged;
|
||||||
|
state = AsyncValue.data(updated);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedFromServer != null) {
|
||||||
|
state = AsyncValue.data([parsedFromServer, ...current]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback when API response doesn't include bookmark object.
|
||||||
|
final synthetic = BookmarkModel(
|
||||||
|
id: 'progress-$novelId',
|
||||||
|
novelId: novelId,
|
||||||
|
type: BookmarkType.reading,
|
||||||
|
lastChapterId: chapterId,
|
||||||
|
lastChapterNumber: chapterNumber,
|
||||||
|
readChapters: [chapterNumber],
|
||||||
|
);
|
||||||
|
state = AsyncValue.data([synthetic, ...current]);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> toggle(String novelId) async {
|
Future<void> toggle(String novelId) async {
|
||||||
try {
|
try {
|
||||||
final client = _ref.read(apiClientProvider);
|
final client = _ref.read(apiClientProvider);
|
||||||
@@ -44,6 +100,22 @@ class BookshelfNotifier extends StateNotifier<AsyncValue<List<BookmarkModel>>> {
|
|||||||
bool isBookmarked(String novelId) {
|
bool isBookmarked(String novelId) {
|
||||||
return (state.valueOrNull ?? []).any((b) => b.novelId == novelId);
|
return (state.valueOrNull ?? []).any((b) => b.novelId == novelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> removeFromShelf(String novelId, BookmarkType type) async {
|
||||||
|
try {
|
||||||
|
final client = _ref.read(apiClientProvider);
|
||||||
|
await client.dio.delete(
|
||||||
|
'/api/user/bookmarks/$novelId',
|
||||||
|
queryParameters: {'type': type.value},
|
||||||
|
);
|
||||||
|
final current = state.valueOrNull ?? [];
|
||||||
|
state = AsyncValue.data(
|
||||||
|
current.where((b) => b.novelId != novelId || b.type != type).toList(),
|
||||||
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncValue.error(e, st);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final bookshelfProvider =
|
final bookshelfProvider =
|
||||||
@@ -51,6 +123,16 @@ final bookshelfProvider =
|
|||||||
return BookshelfNotifier(ref);
|
return BookshelfNotifier(ref);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final readingBookmarksProvider = Provider<List<BookmarkModel>>((ref) {
|
||||||
|
final bookmarks = ref.watch(bookshelfProvider).valueOrNull ?? [];
|
||||||
|
return bookmarks.where((b) => b.type == BookmarkType.reading).toList();
|
||||||
|
});
|
||||||
|
|
||||||
|
final savedBookmarksProvider = Provider<List<BookmarkModel>>((ref) {
|
||||||
|
final bookmarks = ref.watch(bookshelfProvider).valueOrNull ?? [];
|
||||||
|
return bookmarks.where((b) => b.type == BookmarkType.bookmarked).toList();
|
||||||
|
});
|
||||||
|
|
||||||
final isBookmarkedProvider = Provider.family<bool, String>((ref, novelId) {
|
final isBookmarkedProvider = Provider.family<bool, String>((ref, novelId) {
|
||||||
final bookshelf = ref.watch(bookshelfProvider);
|
final bookshelf = ref.watch(bookshelfProvider);
|
||||||
return bookshelf.valueOrNull?.any((b) => b.novelId == novelId) ?? false;
|
return bookshelf.valueOrNull?.any((b) => b.novelId == novelId) ?? false;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,6 @@ import '../../../core/models/novel_model.dart';
|
|||||||
import '../../../core/models/chapter_model.dart';
|
import '../../../core/models/chapter_model.dart';
|
||||||
import '../../../core/network/providers.dart';
|
import '../../../core/network/providers.dart';
|
||||||
|
|
||||||
const chapterPageSize = 50;
|
|
||||||
|
|
||||||
// ─── Browse / Search ──────────────────────────────────────────────────────────
|
// ─── Browse / Search ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class BrowseParams {
|
class BrowseParams {
|
||||||
@@ -28,11 +26,11 @@ class BrowseParams {
|
|||||||
if (raw == null || raw.isEmpty) return null;
|
if (raw == null || raw.isEmpty) return null;
|
||||||
switch (raw.toLowerCase()) {
|
switch (raw.toLowerCase()) {
|
||||||
case 'ongoing':
|
case 'ongoing':
|
||||||
return 'Đang ra';
|
return 'ONGOING';
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return 'Hoàn thành';
|
return 'COMPLETED';
|
||||||
case 'hiatus':
|
case 'hiatus':
|
||||||
return 'Tạm ngưng';
|
return 'HIATUS';
|
||||||
default:
|
default:
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
@@ -189,77 +187,51 @@ final novelDetailProvider =
|
|||||||
|
|
||||||
// ─── Chapter List ─────────────────────────────────────────────────────────────
|
// ─── Chapter List ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class ChapterListQuery {
|
|
||||||
const ChapterListQuery({required this.novelId, this.page = 1});
|
|
||||||
|
|
||||||
final String novelId;
|
|
||||||
final int page;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) {
|
|
||||||
if (identical(this, other)) return true;
|
|
||||||
return other is ChapterListQuery &&
|
|
||||||
other.novelId == novelId &&
|
|
||||||
other.page == page;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => Object.hash(novelId, page);
|
|
||||||
}
|
|
||||||
|
|
||||||
class ChapterListPage {
|
|
||||||
const ChapterListPage({
|
|
||||||
required this.chapters,
|
|
||||||
required this.totalChapters,
|
|
||||||
required this.totalPages,
|
|
||||||
required this.currentPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
final List<ChapterListItem> chapters;
|
|
||||||
final int totalChapters;
|
|
||||||
final int totalPages;
|
|
||||||
final int currentPage;
|
|
||||||
}
|
|
||||||
|
|
||||||
final chapterListProvider =
|
final chapterListProvider =
|
||||||
FutureProvider.family<ChapterListPage, ChapterListQuery>((ref, query) async {
|
FutureProvider.family<List<ChapterListItem>, String>((ref, novelId) async {
|
||||||
final client = ref.read(apiClientProvider);
|
final client = ref.read(apiClientProvider);
|
||||||
|
|
||||||
Future<Map<String, dynamic>> fetchChapterPage(String idOrSlug) async {
|
Future<List<ChapterListItem>> fetchAllChapters(String idOrSlug) async {
|
||||||
final res = await client.dio.get(
|
const limit = 500;
|
||||||
'/api/truyen/$idOrSlug/chapters',
|
var page = 1;
|
||||||
queryParameters: {
|
var totalPages = 1;
|
||||||
'page': query.page,
|
final items = <ChapterListItem>[];
|
||||||
'limit': chapterPageSize,
|
|
||||||
},
|
while (page <= totalPages) {
|
||||||
);
|
final res = await client.dio.get(
|
||||||
return res.data as Map<String, dynamic>;
|
'/api/truyen/$idOrSlug/chapters',
|
||||||
|
queryParameters: {'page': page, 'limit': limit},
|
||||||
|
);
|
||||||
|
final data = res.data as Map<String, dynamic>;
|
||||||
|
final chapters = data['chapters'] as List? ?? const [];
|
||||||
|
|
||||||
|
items.addAll(
|
||||||
|
chapters.map((e) => ChapterListItem.fromJson(e as Map<String, dynamic>)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final apiTotalPages = (data['totalPages'] as num?)?.toInt() ?? 1;
|
||||||
|
totalPages = apiTotalPages > 0 ? apiTotalPages : 1;
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
var data = await fetchChapterPage(query.novelId);
|
try {
|
||||||
var chapters = data['chapters'] as List? ?? const [];
|
return await fetchAllChapters(novelId);
|
||||||
|
} catch (_) {
|
||||||
// Backend stores chapters by novel id in MongoDB; if route opened by slug,
|
// If route opened by slug/id mismatch, resolve canonical novel id and retry once.
|
||||||
// first request can return empty list. Resolve canonical id and retry once.
|
// first request can return empty list. Resolve canonical id and retry once.
|
||||||
if (chapters.isEmpty) {
|
|
||||||
try {
|
try {
|
||||||
final novelRes = await client.dio.get('/api/novels/${query.novelId}');
|
final novelRes = await client.dio.get('/api/novels/$novelId');
|
||||||
final novelData = novelRes.data as Map<String, dynamic>;
|
final novelData = novelRes.data as Map<String, dynamic>;
|
||||||
final canonicalId = novelData['id'] as String?;
|
final canonicalId = novelData['id'] as String?;
|
||||||
if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != query.novelId) {
|
if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != novelId) {
|
||||||
data = await fetchChapterPage(canonicalId);
|
return await fetchAllChapters(canonicalId);
|
||||||
chapters = data['chapters'] as List? ?? const [];
|
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Keep original empty list when fallback resolution fails.
|
// Keep original empty list when fallback resolution fails.
|
||||||
}
|
}
|
||||||
|
rethrow;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ChapterListPage(
|
|
||||||
chapters:
|
|
||||||
chapters.map((e) => ChapterListItem.fromJson(e as Map<String, dynamic>)).toList(),
|
|
||||||
totalChapters: (data['totalChapters'] as num?)?.toInt() ?? 0,
|
|
||||||
totalPages: (data['totalPages'] as num?)?.toInt() ?? 0,
|
|
||||||
currentPage: (data['currentPage'] as num?)?.toInt() ?? query.page,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../app/router/route_names.dart';
|
import '../../../app/router/route_names.dart';
|
||||||
|
import '../../../core/config/app_config.dart';
|
||||||
import '../../../core/models/chapter_model.dart';
|
import '../../../core/models/chapter_model.dart';
|
||||||
import '../../../core/models/reading_settings.dart';
|
import '../../../core/models/reading_settings.dart';
|
||||||
import '../../../core/storage/local_store.dart';
|
import '../../../core/storage/local_store.dart';
|
||||||
@@ -25,7 +26,8 @@ class ReaderScreen extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<ReaderScreen> createState() => _ReaderScreenState();
|
ConsumerState<ReaderScreen> createState() => _ReaderScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
class _ReaderScreenState extends ConsumerState<ReaderScreen>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
static const List<Color> _backgroundColorChoices = [
|
static const List<Color> _backgroundColorChoices = [
|
||||||
Color(0xFFFFFEF8),
|
Color(0xFFFFFEF8),
|
||||||
Color(0xFFF6EAD7),
|
Color(0xFFF6EAD7),
|
||||||
@@ -97,38 +99,64 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
required int highlightStart,
|
required int highlightStart,
|
||||||
required int highlightEnd,
|
required int highlightEnd,
|
||||||
required Function(int charOffset) onSentenceTap,
|
required Function(int charOffset) onSentenceTap,
|
||||||
|
// When true, renders Text.rich instead of SelectableText.rich.
|
||||||
|
// This avoids the "selection.isValid" assertion that fires when a
|
||||||
|
// TapGestureRecognizer on a span triggers TTS/scroll while SelectableText
|
||||||
|
// still holds a stale internal selection.
|
||||||
|
bool useTapRecognizer = false,
|
||||||
}) {
|
}) {
|
||||||
if (sentenceSlices.isEmpty) {
|
final spans = sentenceSlices.map((slice) {
|
||||||
return SelectableText(
|
final start = slice.start;
|
||||||
'',
|
final end = slice.end;
|
||||||
textAlign: textAlign,
|
|
||||||
style: style,
|
final isCurrentSpoken = isActiveParagraph &&
|
||||||
onTap: () => onSentenceTap(0),
|
highlightStart >= 0 &&
|
||||||
|
highlightEnd > highlightStart &&
|
||||||
|
start >= highlightStart &&
|
||||||
|
end <= highlightEnd;
|
||||||
|
|
||||||
|
if (!useTapRecognizer) {
|
||||||
|
return TextSpan(
|
||||||
|
text: slice.text,
|
||||||
|
style: isCurrentSpoken ? highlightStyle : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use WidgetSpan + GestureDetector to avoid lifecycle issues from
|
||||||
|
// creating/discarding many TapGestureRecognizer instances across rebuilds.
|
||||||
|
return WidgetSpan(
|
||||||
|
alignment: PlaceholderAlignment.baseline,
|
||||||
|
baseline: TextBaseline.alphabetic,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onTap: () {
|
||||||
|
// Unfocus immediately so SelectableText drops its selection state
|
||||||
|
// before any scroll notification can call getBoxesForSelection.
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
onSentenceTap(start);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
slice.text,
|
||||||
|
style: isCurrentSpoken ? highlightStyle : style,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
final textSpan = TextSpan(style: style, children: spans);
|
||||||
|
|
||||||
|
if (useTapRecognizer || sentenceSlices.isEmpty) {
|
||||||
|
// Use plain Text.rich when sentence-tap TTS is active.
|
||||||
|
// SelectableText keeps an internal TextEditingController/selection that
|
||||||
|
// becomes invalid after a programmatic rebuild, causing a Flutter
|
||||||
|
// assertion failure in getBoxesForSelection during scroll.
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: sentenceSlices.isEmpty ? () => onSentenceTap(0) : null,
|
||||||
|
child: RichText(text: textSpan, textAlign: textAlign),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SelectableText.rich(
|
return SelectableText.rich(textSpan, textAlign: textAlign);
|
||||||
TextSpan(
|
|
||||||
style: style,
|
|
||||||
children: sentenceSlices.map((slice) {
|
|
||||||
final start = slice.start;
|
|
||||||
final end = slice.end;
|
|
||||||
|
|
||||||
final isCurrentSpoken = isActiveParagraph &&
|
|
||||||
highlightStart >= 0 &&
|
|
||||||
highlightEnd > highlightStart &&
|
|
||||||
start >= highlightStart &&
|
|
||||||
end <= highlightEnd;
|
|
||||||
|
|
||||||
return TextSpan(
|
|
||||||
text: slice.text,
|
|
||||||
style: isCurrentSpoken ? highlightStyle : null,
|
|
||||||
recognizer: TapGestureRecognizer()..onTap = () => onSentenceTap(start),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
textAlign: textAlign,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<List<_SentenceSlice>> _sentenceSlicesForChapter(
|
List<List<_SentenceSlice>> _sentenceSlicesForChapter(
|
||||||
@@ -179,7 +207,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _maybeAutoScrollToTtsParagraph(TtsState tts, int paragraphCount) {
|
void _maybeAutoScrollToTtsParagraph(TtsState tts, int paragraphCount) {
|
||||||
if (tts.status == TtsStatus.idle) return;
|
if (tts.status != TtsStatus.playing) return;
|
||||||
final index = tts.activeParagraphIndex;
|
final index = tts.activeParagraphIndex;
|
||||||
if (index < 0 || index >= paragraphCount) return;
|
if (index < 0 || index >= paragraphCount) return;
|
||||||
if (index == _lastAutoScrolledParagraph) return;
|
if (index == _lastAutoScrolledParagraph) return;
|
||||||
@@ -189,6 +217,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final ctx = _paragraphKeys[index].currentContext;
|
final ctx = _paragraphKeys[index].currentContext;
|
||||||
if (ctx == null) return;
|
if (ctx == null) return;
|
||||||
|
// Clear any active text-selection focus before programmatic scrolling.
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
Scrollable.ensureVisible(
|
Scrollable.ensureVisible(
|
||||||
ctx,
|
ctx,
|
||||||
alignment: 0.22,
|
alignment: 0.22,
|
||||||
@@ -231,11 +261,39 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
return partiallyVisibleIndex ?? 0;
|
return partiallyVisibleIndex ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _reconcileChapterWithNativeTts() async {
|
||||||
|
if (defaultTargetPlatform != TargetPlatform.android) return;
|
||||||
|
|
||||||
|
final notifier = ref.read(ttsProvider.notifier);
|
||||||
|
await notifier.refreshNativeSnapshot();
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final tts = ref.read(ttsProvider);
|
||||||
|
final targetChapterId = tts.contentKey;
|
||||||
|
if (targetChapterId == null || targetChapterId.isEmpty) return;
|
||||||
|
if (targetChapterId == widget.chapterId) return;
|
||||||
|
if (tts.status != TtsStatus.playing && tts.status != TtsStatus.paused) return;
|
||||||
|
|
||||||
|
context.pushReplacement(RouteNames.readerChapter(targetChapterId));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
_scrollCtrl.addListener(_onScroll);
|
_scrollCtrl.addListener(_onScroll);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
unawaited(_reconcileChapterWithNativeTts());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
unawaited(_reconcileChapterWithNativeTts());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle TTS state transitions that require navigation or restarts.
|
/// Handle TTS state transitions that require navigation or restarts.
|
||||||
@@ -248,7 +306,29 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
final chapter = chapterAsync.valueOrNull;
|
final chapter = chapterAsync.valueOrNull;
|
||||||
if (chapter == null) return;
|
if (chapter == null) return;
|
||||||
|
|
||||||
|
if (previous.contentKey == chapter.id &&
|
||||||
|
next.contentKey != null &&
|
||||||
|
next.contentKey != chapter.id &&
|
||||||
|
next.contentKey != previous.contentKey &&
|
||||||
|
(next.status == TtsStatus.playing || next.status == TtsStatus.paused)) {
|
||||||
|
final targetChapterId = next.contentKey!;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.pushReplacement(RouteNames.readerChapter(targetChapterId));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Chapter-completion → auto-advance to next chapter.
|
// Chapter-completion → auto-advance to next chapter.
|
||||||
|
// On Android, native service already fetches and starts next chapter.
|
||||||
|
// Re-queueing auto-start from UI causes duplicate START_READING races.
|
||||||
|
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||||
|
if (next.completedCount > _lastTtsCompletedCount) {
|
||||||
|
_lastTtsCompletedCount = next.completedCount;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (next.completedCount > _lastTtsCompletedCount) {
|
if (next.completedCount > _lastTtsCompletedCount) {
|
||||||
_lastTtsCompletedCount = next.completedCount;
|
_lastTtsCompletedCount = next.completedCount;
|
||||||
if (next.contentKey == chapter.id && chapter.nextChapterId != null) {
|
if (next.contentKey == chapter.id && chapter.nextChapterId != null) {
|
||||||
@@ -274,6 +354,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
chapter.content,
|
chapter.content,
|
||||||
contentKey: chapter.id,
|
contentKey: chapter.id,
|
||||||
title: 'Chương ${chapter.number}: ${chapter.title}',
|
title: 'Chương ${chapter.number}: ${chapter.title}',
|
||||||
|
nextChapterId: chapter.nextChapterId,
|
||||||
|
chapterNumber: chapter.number,
|
||||||
|
apiBaseUrl: AppConfig.baseUrl,
|
||||||
);
|
);
|
||||||
_autoStartQueuedChapterId = null;
|
_autoStartQueuedChapterId = null;
|
||||||
});
|
});
|
||||||
@@ -282,6 +365,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_uiAutoHideTimer?.cancel();
|
_uiAutoHideTimer?.cancel();
|
||||||
_scrollCtrl.removeListener(_onScroll);
|
_scrollCtrl.removeListener(_onScroll);
|
||||||
_scrollCtrl.dispose();
|
_scrollCtrl.dispose();
|
||||||
@@ -413,9 +497,11 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
String targetChapterId,
|
String targetChapterId,
|
||||||
) {
|
) {
|
||||||
final tts = ref.read(ttsProvider);
|
final tts = ref.read(ttsProvider);
|
||||||
final isCurrentlyReading = tts.contentKey == currentChapterId &&
|
// Only auto-start on the target chapter when TTS is actively PLAYING.
|
||||||
(tts.status == TtsStatus.playing || tts.status == TtsStatus.paused);
|
// If paused, the user intentionally stopped – do not resume on navigation.
|
||||||
if (!isCurrentlyReading) return;
|
final isActivelyPlaying = tts.contentKey == currentChapterId &&
|
||||||
|
tts.status == TtsStatus.playing;
|
||||||
|
if (!isActivelyPlaying) return;
|
||||||
ref.read(ttsProvider.notifier).scheduleAutoStartForChapter(targetChapterId);
|
ref.read(ttsProvider.notifier).scheduleAutoStartForChapter(targetChapterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,6 +510,16 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
if (tts.pendingAutoStartChapterId != chapter.id) return;
|
if (tts.pendingAutoStartChapterId != chapter.id) return;
|
||||||
if (_autoStartQueuedChapterId == chapter.id) return;
|
if (_autoStartQueuedChapterId == chapter.id) return;
|
||||||
|
|
||||||
|
// If native TTS service already moved to this chapter and is actively
|
||||||
|
// controlling playback, do not issue another manual START_READING.
|
||||||
|
final isAlreadyPlayingThisChapter =
|
||||||
|
tts.contentKey == chapter.id &&
|
||||||
|
(tts.status == TtsStatus.playing || tts.status == TtsStatus.paused);
|
||||||
|
if (isAlreadyPlayingThisChapter) {
|
||||||
|
ref.read(ttsProvider.notifier).clearPendingAutoStartChapter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_autoStartQueuedChapterId = chapter.id;
|
_autoStartQueuedChapterId = chapter.id;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -433,6 +529,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
chapter.content,
|
chapter.content,
|
||||||
contentKey: chapter.id,
|
contentKey: chapter.id,
|
||||||
title: 'Chương ${chapter.number}: ${chapter.title}',
|
title: 'Chương ${chapter.number}: ${chapter.title}',
|
||||||
|
nextChapterId: chapter.nextChapterId,
|
||||||
|
chapterNumber: chapter.number,
|
||||||
|
apiBaseUrl: AppConfig.baseUrl,
|
||||||
);
|
);
|
||||||
_autoStartQueuedChapterId = null;
|
_autoStartQueuedChapterId = null;
|
||||||
});
|
});
|
||||||
@@ -455,11 +554,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
builder: (sheetContext) {
|
builder: (sheetContext) {
|
||||||
return Consumer(
|
return Consumer(
|
||||||
builder: (context, ref, _) {
|
builder: (context, ref, _) {
|
||||||
final tocPage = ((currentChapter.number - 1) ~/ chapterPageSize) + 1;
|
|
||||||
final chaptersAsync = ref.watch(
|
final chaptersAsync = ref.watch(
|
||||||
chapterListProvider(
|
chapterListProvider(currentChapter.novelId),
|
||||||
ChapterListQuery(novelId: currentChapter.novelId, page: tocPage),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return FractionallySizedBox(
|
return FractionallySizedBox(
|
||||||
heightFactor: 0.82,
|
heightFactor: 0.82,
|
||||||
@@ -489,12 +585,20 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
child: chaptersAsync.when(
|
child: chaptersAsync.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('Không tải được mục lục: $e')),
|
error: (e, _) => Center(child: Text('Không tải được mục lục: $e')),
|
||||||
data: (pageData) {
|
data: (chapters) {
|
||||||
final chapters = pageData.chapters;
|
|
||||||
if (chapters.isEmpty) {
|
if (chapters.isEmpty) {
|
||||||
return const Center(child: Text('Chưa có danh sách chương.'));
|
return const Center(child: Text('Chưa có danh sách chương.'));
|
||||||
}
|
}
|
||||||
|
// Find index of current chapter for auto-scroll
|
||||||
|
final currentIndex = chapters.indexWhere((ch) => ch.id == currentChapter.id);
|
||||||
|
final scrollController = ScrollController(
|
||||||
|
initialScrollOffset: currentIndex > 0
|
||||||
|
? currentIndex * 48.0 // Approximate height per ListTile
|
||||||
|
: 0,
|
||||||
|
);
|
||||||
|
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
|
controller: scrollController,
|
||||||
itemCount: chapters.length,
|
itemCount: chapters.length,
|
||||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
@@ -542,6 +646,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
String previewContent,
|
String previewContent,
|
||||||
String chapterId,
|
String chapterId,
|
||||||
String chapterTitle,
|
String chapterTitle,
|
||||||
|
String? nextChapterId,
|
||||||
|
int? chapterNumber,
|
||||||
) async {
|
) async {
|
||||||
await showModalBottomSheet<void>(
|
await showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -847,6 +953,22 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
SwitchListTile.adaptive(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
value: settings.enableSentenceTapTts,
|
||||||
|
onChanged: (enabled) {
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(readingSettingsProvider.notifier)
|
||||||
|
.setSentenceTapTtsEnabled(enabled),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
title: const Text('Bật chạm câu để phát TTS'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Tắt để tránh chạm nhầm làm bắt đầu TTS.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -996,6 +1118,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
content: previewContent,
|
content: previewContent,
|
||||||
contentKey: chapterId,
|
contentKey: chapterId,
|
||||||
title: 'Chương $chapterTitle',
|
title: 'Chương $chapterTitle',
|
||||||
|
nextChapterId: nextChapterId,
|
||||||
|
chapterNumber: chapterNumber,
|
||||||
|
apiBaseUrl: AppConfig.baseUrl,
|
||||||
includeTitleOnStart: false,
|
includeTitleOnStart: false,
|
||||||
resolveStartParagraphIndex:
|
resolveStartParagraphIndex:
|
||||||
_firstVisibleParagraphIndex,
|
_firstVisibleParagraphIndex,
|
||||||
@@ -1094,6 +1219,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
chapter.content,
|
chapter.content,
|
||||||
chapter.id,
|
chapter.id,
|
||||||
'Chương ${chapter.number}: ${chapter.title}',
|
'Chương ${chapter.number}: ${chapter.title}',
|
||||||
|
chapter.nextChapterId,
|
||||||
|
chapter.number,
|
||||||
),
|
),
|
||||||
barBackgroundColor: readerBackground,
|
barBackgroundColor: readerBackground,
|
||||||
foregroundColor: readerTextColor,
|
foregroundColor: readerTextColor,
|
||||||
@@ -1166,6 +1293,12 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
.titleLarge
|
.titleLarge
|
||||||
?.copyWith(color: readerTextColor),
|
?.copyWith(color: readerTextColor),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_NavButtons(
|
||||||
|
chapter: chapter,
|
||||||
|
onGoPrevious: () => _goToPreviousChapter(chapter),
|
||||||
|
onGoNext: () => _goToNextChapter(chapter),
|
||||||
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
if (chapter.content.trim().isEmpty)
|
if (chapter.content.trim().isEmpty)
|
||||||
Text(
|
Text(
|
||||||
@@ -1197,32 +1330,57 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 760),
|
constraints: const BoxConstraints(maxWidth: 760),
|
||||||
child: Padding(
|
child: SizedBox(
|
||||||
key: _paragraphKeys[index],
|
width: double.infinity,
|
||||||
padding: EdgeInsets.only(
|
child: Padding(
|
||||||
bottom: index == paragraphs.length - 1
|
key: _paragraphKeys[index],
|
||||||
? 0
|
padding: EdgeInsets.only(
|
||||||
: settings.paragraphSpacing,
|
bottom: index == paragraphs.length - 1
|
||||||
),
|
? 0
|
||||||
child: _buildParagraphText(
|
: settings.paragraphSpacing,
|
||||||
context: context,
|
),
|
||||||
sentenceSlices: sentenceSlices,
|
child: _buildParagraphText(
|
||||||
textAlign: textAlign,
|
context: context,
|
||||||
style: paragraphStyle,
|
sentenceSlices: sentenceSlices,
|
||||||
highlightStyle: paragraphHighlightStyle,
|
textAlign: textAlign,
|
||||||
isActiveParagraph: shouldHighlightTts &&
|
style: paragraphStyle,
|
||||||
tts.activeParagraphIndex == index,
|
highlightStyle: paragraphHighlightStyle,
|
||||||
highlightStart: tts.progressStart,
|
isActiveParagraph: shouldHighlightTts &&
|
||||||
highlightEnd: tts.progressEnd,
|
tts.activeParagraphIndex == index,
|
||||||
onSentenceTap: (charOffset) {
|
highlightStart: tts.progressStart,
|
||||||
ref.read(ttsProvider.notifier).startReading(
|
highlightEnd: tts.progressEnd,
|
||||||
chapter.content,
|
onSentenceTap: (charOffset) {
|
||||||
contentKey: chapter.id,
|
final hasActiveTtsSession =
|
||||||
title: 'Chương ${chapter.number}: ${chapter.title}',
|
tts.contentKey == chapter.id &&
|
||||||
startParagraphIndex: index,
|
(tts.status == TtsStatus.playing ||
|
||||||
startCharOffset: charOffset,
|
tts.status == TtsStatus.paused);
|
||||||
);
|
final canStartFromSentence =
|
||||||
},
|
settings.enableSentenceTapTts || hasActiveTtsSession;
|
||||||
|
if (!canStartFromSentence) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Synchronous unfocus clears stale SelectableText selection
|
||||||
|
// before startReading triggers a widget rebuild + scroll.
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
ref
|
||||||
|
.read(ttsProvider.notifier)
|
||||||
|
.clearPendingAutoStartChapter();
|
||||||
|
ref.read(ttsProvider.notifier).startReading(
|
||||||
|
chapter.content,
|
||||||
|
contentKey: chapter.id,
|
||||||
|
title: 'Chương ${chapter.number}: ${chapter.title}',
|
||||||
|
nextChapterId: chapter.nextChapterId,
|
||||||
|
chapterNumber: chapter.number,
|
||||||
|
apiBaseUrl: AppConfig.baseUrl,
|
||||||
|
startParagraphIndex: index,
|
||||||
|
startCharOffset: charOffset,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
useTapRecognizer: settings.enableSentenceTapTts ||
|
||||||
|
(tts.contentKey == chapter.id &&
|
||||||
|
(tts.status == TtsStatus.playing ||
|
||||||
|
tts.status == TtsStatus.paused)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1316,6 +1474,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
content: chapter.content,
|
content: chapter.content,
|
||||||
contentKey: chapter.id,
|
contentKey: chapter.id,
|
||||||
title: 'Chương ${chapter.number}: ${chapter.title}',
|
title: 'Chương ${chapter.number}: ${chapter.title}',
|
||||||
|
nextChapterId: chapter.nextChapterId,
|
||||||
|
chapterNumber: chapter.number,
|
||||||
|
apiBaseUrl: AppConfig.baseUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1614,20 +1775,18 @@ class _NavButtons extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
if (chapter.prevChapterId != null)
|
if (chapter.prevChapterId != null)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton(
|
||||||
onPressed: onGoPrevious,
|
onPressed: onGoPrevious,
|
||||||
icon: const Icon(Icons.chevron_left),
|
child: Text('< Chương ${chapter.prevChapterNumber ?? '?'}'),
|
||||||
label: Text('Chương ${chapter.prevChapterNumber ?? '?'}'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (chapter.prevChapterId != null && chapter.nextChapterId != null)
|
if (chapter.prevChapterId != null && chapter.nextChapterId != null)
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
if (chapter.nextChapterId != null)
|
if (chapter.nextChapterId != null)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton.icon(
|
child: FilledButton(
|
||||||
onPressed: onGoNext,
|
onPressed: onGoNext,
|
||||||
icon: const Icon(Icons.chevron_right),
|
child: Text('> Chương ${chapter.nextChapterNumber ?? '?'}'),
|
||||||
label: Text('Chương ${chapter.nextChapterNumber ?? '?'}'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ class TtsPlayerWidget extends ConsumerWidget {
|
|||||||
required this.content,
|
required this.content,
|
||||||
this.contentKey,
|
this.contentKey,
|
||||||
this.title,
|
this.title,
|
||||||
|
this.nextChapterId,
|
||||||
|
this.chapterNumber,
|
||||||
|
this.apiBaseUrl,
|
||||||
this.includeTitleOnStart = true,
|
this.includeTitleOnStart = true,
|
||||||
this.resolveStartParagraphIndex,
|
this.resolveStartParagraphIndex,
|
||||||
this.onStarted,
|
this.onStarted,
|
||||||
@@ -20,6 +23,9 @@ class TtsPlayerWidget extends ConsumerWidget {
|
|||||||
final String content;
|
final String content;
|
||||||
final String? contentKey;
|
final String? contentKey;
|
||||||
final String? title;
|
final String? title;
|
||||||
|
final String? nextChapterId;
|
||||||
|
final int? chapterNumber;
|
||||||
|
final String? apiBaseUrl;
|
||||||
final bool includeTitleOnStart;
|
final bool includeTitleOnStart;
|
||||||
final int Function()? resolveStartParagraphIndex;
|
final int Function()? resolveStartParagraphIndex;
|
||||||
final VoidCallback? onStarted;
|
final VoidCallback? onStarted;
|
||||||
@@ -39,6 +45,8 @@ class TtsPlayerWidget extends ConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notifier.clearPendingAutoStartChapter();
|
||||||
|
|
||||||
unawaited(
|
unawaited(
|
||||||
notifier.startReading(
|
notifier.startReading(
|
||||||
content,
|
content,
|
||||||
@@ -46,6 +54,9 @@ class TtsPlayerWidget extends ConsumerWidget {
|
|||||||
startParagraphIndex: resolveStartParagraphIndex?.call(),
|
startParagraphIndex: resolveStartParagraphIndex?.call(),
|
||||||
contentKey: contentKey,
|
contentKey: contentKey,
|
||||||
title: title,
|
title: title,
|
||||||
|
nextChapterId: nextChapterId,
|
||||||
|
chapterNumber: chapterNumber,
|
||||||
|
apiBaseUrl: apiBaseUrl,
|
||||||
includeTitle: includeTitleOnStart,
|
includeTitle: includeTitleOnStart,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../../../core/models/reading_settings.dart';
|
|||||||
import '../../../core/network/providers.dart';
|
import '../../../core/network/providers.dart';
|
||||||
import '../../../core/storage/local_store.dart';
|
import '../../../core/storage/local_store.dart';
|
||||||
import '../../../core/storage/offline_cache.dart';
|
import '../../../core/storage/offline_cache.dart';
|
||||||
|
import '../../bookshelf/providers/bookshelf_provider.dart';
|
||||||
|
|
||||||
// ─── Chapter content ─────────────────────────────────────────────────────────
|
// ─── Chapter content ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -94,12 +95,28 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
|
|||||||
// Also notify server (fire and forget)
|
// Also notify server (fire and forget)
|
||||||
try {
|
try {
|
||||||
final client = _ref.read(apiClientProvider);
|
final client = _ref.read(apiClientProvider);
|
||||||
await client.dio.post('/api/user/reading-progress', data: {
|
final res = await client.dio.post('/api/user/reading-progress', data: {
|
||||||
'novelId': _novelId,
|
'novelId': _novelId,
|
||||||
'chapterId': chapterId,
|
'chapterId': chapterId,
|
||||||
'chapterNumber': chapterNumber,
|
'chapterNumber': chapterNumber,
|
||||||
'progress': offset,
|
'progress': offset,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final data = res.data;
|
||||||
|
Map<String, dynamic>? bookmarkJson;
|
||||||
|
if (data is Map<String, dynamic>) {
|
||||||
|
final bookmark = data['bookmark'];
|
||||||
|
if (bookmark is Map<String, dynamic>) {
|
||||||
|
bookmarkJson = bookmark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ref.read(bookshelfProvider.notifier).syncProgress(
|
||||||
|
novelId: _novelId!,
|
||||||
|
chapterId: chapterId,
|
||||||
|
chapterNumber: chapterNumber,
|
||||||
|
serverBookmark: bookmarkJson,
|
||||||
|
);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +162,10 @@ class ReadingSettingsNotifier extends StateNotifier<ReadingSettings> {
|
|||||||
final localStore = _ref.read(localStoreProvider);
|
final localStore = _ref.read(localStoreProvider);
|
||||||
await localStore.saveReadingSettings(settings);
|
await localStore.saveReadingSettings(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setSentenceTapTtsEnabled(bool enabled) async {
|
||||||
|
await update(state.copyWith(enableSentenceTapTts: enabled));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final readingSettingsProvider =
|
final readingSettingsProvider =
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_tts/flutter_tts.dart';
|
import 'package:flutter_tts/flutter_tts.dart';
|
||||||
|
|
||||||
|
import '../../../core/config/app_config.dart';
|
||||||
|
|
||||||
enum TtsStatus { idle, playing, paused }
|
enum TtsStatus { idle, playing, paused }
|
||||||
|
|
||||||
const double kTtsBaseSpeechRate = 0.9;
|
const double kTtsBaseSpeechRate = 0.9;
|
||||||
@@ -155,6 +157,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
int _pendingFallbackIndex = -1;
|
int _pendingFallbackIndex = -1;
|
||||||
bool _didStartCurrentFallbackUtterance = false;
|
bool _didStartCurrentFallbackUtterance = false;
|
||||||
bool _hasPromptedNotificationSettings = false;
|
bool _hasPromptedNotificationSettings = false;
|
||||||
|
bool _androidFallbackReady = false;
|
||||||
|
|
||||||
bool get _useNativeAndroidMediaService => Platform.isAndroid;
|
bool get _useNativeAndroidMediaService => Platform.isAndroid;
|
||||||
|
|
||||||
@@ -258,6 +261,22 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
await _mediaChannel.invokeMethod<void>('openNotificationSettings');
|
await _mediaChannel.invokeMethod<void>('openNotificationSettings');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> refreshNativeSnapshot() async {
|
||||||
|
if (!_useNativeAndroidMediaService) return;
|
||||||
|
|
||||||
|
if (!_initialized) {
|
||||||
|
await (_initFuture ?? _init());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final snapshot = await _mediaChannel.invokeMethod<dynamic>('getSnapshot');
|
||||||
|
_applyAndroidSnapshot(snapshot);
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore snapshot pull errors; event stream updates will continue.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _configureVietnameseVoiceWithFlutterTts() async {
|
Future<void> _configureVietnameseVoiceWithFlutterTts() async {
|
||||||
final dynamic voicesRaw = await _tts.getVoices;
|
final dynamic voicesRaw = await _tts.getVoices;
|
||||||
|
|
||||||
@@ -315,6 +334,73 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _ensureAndroidFallbackReady() async {
|
||||||
|
if (_androidFallbackReady) return;
|
||||||
|
|
||||||
|
await _tts.awaitSpeakCompletion(true);
|
||||||
|
await _tts.setSharedInstance(true);
|
||||||
|
await _configureVietnameseVoiceWithFlutterTts();
|
||||||
|
await _tts.setSpeechRate(state.speed);
|
||||||
|
await _tts.setVolume(1.0);
|
||||||
|
await _tts.setPitch(1.0);
|
||||||
|
|
||||||
|
_tts.setStartHandler(() {
|
||||||
|
_didStartCurrentFallbackUtterance = true;
|
||||||
|
final index = _pendingFallbackIndex;
|
||||||
|
if (index >= 0 && index < _segments.length) {
|
||||||
|
final segment = _segments[index];
|
||||||
|
state = state.copyWith(
|
||||||
|
status: TtsStatus.playing,
|
||||||
|
paragraphIndex: index,
|
||||||
|
activeParagraphIndex: segment.paragraphIndex,
|
||||||
|
progressStart: segment.start,
|
||||||
|
progressEnd: segment.end,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
state = state.copyWith(status: TtsStatus.playing);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_tts.setCompletionHandler(() {
|
||||||
|
// Fallback playback progression is driven by _playFallbackFromGeneration.
|
||||||
|
});
|
||||||
|
|
||||||
|
_tts.setErrorHandler((_) {
|
||||||
|
if (_isInterruptingPlayback) return;
|
||||||
|
_pendingFallbackIndex = -1;
|
||||||
|
_didStartCurrentFallbackUtterance = false;
|
||||||
|
state = state.copyWith(
|
||||||
|
status: TtsStatus.idle,
|
||||||
|
activeParagraphIndex: -1,
|
||||||
|
progressStart: -1,
|
||||||
|
progressEnd: -1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
_androidFallbackReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startFallbackReading({
|
||||||
|
required int validIndex,
|
||||||
|
required _TtsSegment selectedSegment,
|
||||||
|
required String? contentKey,
|
||||||
|
}) async {
|
||||||
|
await _ensureAndroidFallbackReady();
|
||||||
|
final sessionId = await _interruptFallbackPlayback();
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
status: TtsStatus.playing,
|
||||||
|
paragraphIndex: validIndex,
|
||||||
|
totalParagraphs: _segments.length,
|
||||||
|
activeParagraphIndex: selectedSegment.paragraphIndex,
|
||||||
|
progressStart: selectedSegment.start,
|
||||||
|
progressEnd: selectedSegment.end,
|
||||||
|
contentKey: contentKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
void _handleAndroidMediaEvent(dynamic event) {
|
void _handleAndroidMediaEvent(dynamic event) {
|
||||||
_applyAndroidSnapshot(event);
|
_applyAndroidSnapshot(event);
|
||||||
}
|
}
|
||||||
@@ -372,6 +458,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
|
|
||||||
// Keep natural sentence flow while removing symbols that are usually read out noisily.
|
// Keep natural sentence flow while removing symbols that are usually read out noisily.
|
||||||
final cleaned = raw
|
final cleaned = raw
|
||||||
|
.replaceAll(RegExp(r'["“”]'), ' ')
|
||||||
.replaceAll(RegExp(r'[_$#^*+=~`|<>\\\[\]{}]'), ' ')
|
.replaceAll(RegExp(r'[_$#^*+=~`|<>\\\[\]{}]'), ' ')
|
||||||
.replaceAll(RegExp(r'\s+'), ' ')
|
.replaceAll(RegExp(r'\s+'), ' ')
|
||||||
.trim();
|
.trim();
|
||||||
@@ -566,12 +653,19 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
int? startCharOffset,
|
int? startCharOffset,
|
||||||
String? contentKey,
|
String? contentKey,
|
||||||
String? title,
|
String? title,
|
||||||
|
String? nextChapterId,
|
||||||
|
int? chapterNumber,
|
||||||
|
String? apiBaseUrl,
|
||||||
bool includeTitle = true,
|
bool includeTitle = true,
|
||||||
}) async {
|
}) async {
|
||||||
if (!_initialized) {
|
if (!_initialized) {
|
||||||
await (_initFuture ?? _init());
|
await (_initFuture ?? _init());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A direct start request (tap sentence/play button) should win over any
|
||||||
|
// queued chapter auto-start from previous navigation/completion events.
|
||||||
|
state = state.copyWith(clearPendingAutoStartChapterId: true);
|
||||||
|
|
||||||
_segments = _buildSegments(
|
_segments = _buildSegments(
|
||||||
content,
|
content,
|
||||||
title: title,
|
title: title,
|
||||||
@@ -614,33 +708,36 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
contentKey: contentKey,
|
contentKey: contentKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _mediaChannel.invokeMethod<void>('startReading', {
|
try {
|
||||||
'contentKey': contentKey,
|
await _mediaChannel.invokeMethod<void>('startReading', {
|
||||||
'title': title,
|
'content': content,
|
||||||
'startIndex': validIndex,
|
'contentKey': contentKey,
|
||||||
'speed': state.speed,
|
'title': title,
|
||||||
'language': state.language,
|
'nextChapterId': nextChapterId,
|
||||||
'voiceName': state.voiceName,
|
'chapterNumber': chapterNumber,
|
||||||
'backgroundModeEnabled': state.backgroundModeEnabled,
|
'apiBaseUrl': apiBaseUrl ?? AppConfig.baseUrl,
|
||||||
'segments': _segments.map((segment) => segment.toMap()).toList(),
|
'startIndex': validIndex,
|
||||||
});
|
'speed': state.speed,
|
||||||
|
'language': state.language,
|
||||||
|
'voiceName': state.voiceName,
|
||||||
|
'backgroundModeEnabled': state.backgroundModeEnabled,
|
||||||
|
'includeTitle': includeTitle,
|
||||||
|
});
|
||||||
|
} on PlatformException {
|
||||||
|
await _startFallbackReading(
|
||||||
|
validIndex: validIndex,
|
||||||
|
selectedSegment: selectedSegment,
|
||||||
|
contentKey: contentKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final sessionId = await _interruptFallbackPlayback();
|
await _startFallbackReading(
|
||||||
|
validIndex: validIndex,
|
||||||
state = state.copyWith(
|
selectedSegment: selectedSegment,
|
||||||
status: TtsStatus.playing,
|
|
||||||
paragraphIndex: validIndex,
|
|
||||||
totalParagraphs: _segments.length,
|
|
||||||
activeParagraphIndex: -1,
|
|
||||||
progressStart: -1,
|
|
||||||
progressEnd: -1,
|
|
||||||
contentKey: contentKey,
|
contentKey: contentKey,
|
||||||
);
|
);
|
||||||
await _syncBackgroundMode();
|
|
||||||
|
|
||||||
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> _interruptFallbackPlayback() async {
|
Future<int> _interruptFallbackPlayback() async {
|
||||||
|
|||||||
@@ -1,25 +1,77 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../app/router/route_names.dart';
|
import '../../../app/router/route_names.dart';
|
||||||
|
import '../../../core/storage/local_store.dart';
|
||||||
|
|
||||||
class SplashScreen extends StatefulWidget {
|
class SplashScreen extends ConsumerStatefulWidget {
|
||||||
const SplashScreen({super.key});
|
const SplashScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SplashScreen> createState() => _SplashScreenState();
|
ConsumerState<SplashScreen> createState() => _SplashScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SplashScreenState extends State<SplashScreen> {
|
class _SplashScreenState extends ConsumerState<SplashScreen> {
|
||||||
Timer? _redirectTimer;
|
Timer? _redirectTimer;
|
||||||
|
|
||||||
|
bool _isRestorableRoute(String path) {
|
||||||
|
if (path.isEmpty || path == RouteNames.splash) return false;
|
||||||
|
// Composite "parentPath|deepPath" — validate the deep path portion
|
||||||
|
final checkPath = path.contains('|') ? path.substring(path.indexOf('|') + 1) : path;
|
||||||
|
return checkPath == RouteNames.home ||
|
||||||
|
checkPath == RouteNames.login ||
|
||||||
|
checkPath == RouteNames.search ||
|
||||||
|
checkPath.startsWith('${RouteNames.search}?') ||
|
||||||
|
checkPath == RouteNames.genres ||
|
||||||
|
checkPath == RouteNames.bookshelf ||
|
||||||
|
checkPath == RouteNames.profile ||
|
||||||
|
checkPath == RouteNames.settings ||
|
||||||
|
checkPath.startsWith('/novel/') ||
|
||||||
|
checkPath.startsWith('/reader/') ||
|
||||||
|
checkPath.startsWith('/comments/');
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_redirectTimer = Timer(const Duration(milliseconds: 700), () {
|
_redirectTimer = Timer(const Duration(milliseconds: 700), () async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
final lastPath = await ref.read(localStoreProvider).loadLastRoutePath();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (lastPath != null && _isRestorableRoute(lastPath)) {
|
||||||
|
if (lastPath.contains('|')) {
|
||||||
|
// Composite "parentPath|deepPath" e.g. "/novel/123|/reader/abc"
|
||||||
|
// Restore full stack: Home → Novel Detail → Reader
|
||||||
|
final sep = lastPath.indexOf('|');
|
||||||
|
final parentPath = lastPath.substring(0, sep);
|
||||||
|
final deepPath = lastPath.substring(sep + 1);
|
||||||
|
context.go(RouteNames.home);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.push(parentPath);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) context.push(deepPath);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Single deep route (novel, comments) outside ShellRoute: push on Home
|
||||||
|
final isDeepRoute = lastPath.startsWith('/reader/') ||
|
||||||
|
lastPath.startsWith('/novel/') ||
|
||||||
|
lastPath.startsWith('/comments/');
|
||||||
|
if (isDeepRoute) {
|
||||||
|
context.go(RouteNames.home);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) context.push(lastPath);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
context.go(lastPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
context.go(RouteNames.home);
|
context.go(RouteNames.home);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.2+3
|
version: 1.0.3+6
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.3
|
sdk: ^3.11.3
|
||||||
|
|||||||
Reference in New Issue
Block a user