From 6946083aeed1bd7437337379e9568057dcd717f5 Mon Sep 17 00:00:00 2001 From: virtus Date: Tue, 7 Apr 2026 18:49:29 +0700 Subject: [PATCH] feat: Enhance chapter list and TTS functionality - Introduced ChapterListQuery and ChapterListPage classes for better chapter management. - Updated chapterListProvider to handle pagination and canonical ID resolution. - Improved ReaderScreen with enhanced TTS features, including auto-scroll to active paragraph and better handling of TTS state. - Added TtsPlayerWidget with compact mode and improved UI for TTS controls. - Enhanced TtsService to manage speech segments and background mode for TTS. - Implemented battery optimization checks for TTS background mode on Android. - Updated main.dart to ensure proper error handling in a zoned environment. --- .gitea/workflows/build-apk.yml | 61 ++++ .github/agents/api-contract.agent.md | 33 ++ .github/agents/backend-security.agent.md | 33 ++ .github/agents/mobile-app.agent.md | 32 ++ .github/agents/sdet-qa.agent.md | 35 ++ .github/agents/sleuth-debugger.agent.md | 32 ++ .github/agents/system-architect.agent.md | 33 ++ .github/agents/web-frontend-nextjs.agent.md | 32 ++ .github/copilot-instructions.md | 42 +++ .../prompts/debug-triage-checklist.prompt.md | 22 ++ .../flutter-integration-test-flow.prompt.md | 19 ++ .../prompts/multi-agent-workflow.prompt.md | 27 ++ .../prompts/regression-api-newman.prompt.md | 19 ++ android/app/src/main/AndroidManifest.xml | 2 + .../com/example/reader_app/MainActivity.kt | 80 ++++- lib/app/app.dart | 49 ++- lib/core/auth/session_expiry_notifier.dart | 14 + lib/core/models/chapter_model.dart | 43 ++- lib/core/network/api_client.dart | 25 ++ lib/core/network/providers.dart | 7 +- .../auth/providers/auth_provider.dart | 5 + .../presentation/novel_detail_screen.dart | 120 +++++-- .../novel/providers/novels_provider.dart | 80 ++++- .../reader/presentation/reader_screen.dart | 291 ++++++++++++++++- .../presentation/tts_player_widget.dart | 284 +++++++++++++---- lib/features/reader/tts/tts_service.dart | 299 ++++++++++++++++-- lib/main.dart | 28 +- 27 files changed, 1590 insertions(+), 157 deletions(-) create mode 100644 .gitea/workflows/build-apk.yml create mode 100644 .github/agents/api-contract.agent.md create mode 100644 .github/agents/backend-security.agent.md create mode 100644 .github/agents/mobile-app.agent.md create mode 100644 .github/agents/sdet-qa.agent.md create mode 100644 .github/agents/sleuth-debugger.agent.md create mode 100644 .github/agents/system-architect.agent.md create mode 100644 .github/agents/web-frontend-nextjs.agent.md create mode 100644 .github/copilot-instructions.md create mode 100644 .github/prompts/debug-triage-checklist.prompt.md create mode 100644 .github/prompts/flutter-integration-test-flow.prompt.md create mode 100644 .github/prompts/multi-agent-workflow.prompt.md create mode 100644 .github/prompts/regression-api-newman.prompt.md create mode 100644 lib/core/auth/session_expiry_notifier.dart diff --git a/.gitea/workflows/build-apk.yml b/.gitea/workflows/build-apk.yml new file mode 100644 index 0000000..2a0ecbe --- /dev/null +++ b/.gitea/workflows/build-apk.yml @@ -0,0 +1,61 @@ +name: Build Android APK + +on: + workflow_dispatch: + push: + branches: + - main + +jobs: + build-apk: + runs-on: ubuntu-latest + + env: + BASE_URL: ${{ secrets.BASE_URL }} + GOOGLE_SERVER_CLIENT_ID: ${{ secrets.GOOGLE_SERVER_CLIENT_ID }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: "3.24.5" + channel: stable + + - name: Install dependencies + run: flutter pub get + + - name: Build release APK + run: | + BASE_URL_VALUE="${BASE_URL:-http://127.0.0.1:8000}" + + FLUTTER_CMD=( + flutter build apk --release + --dart-define=BASE_URL=${BASE_URL_VALUE} + ) + + if [ -n "${GOOGLE_SERVER_CLIENT_ID}" ]; then + FLUTTER_CMD+=(--dart-define=GOOGLE_SERVER_CLIENT_ID=${GOOGLE_SERVER_CLIENT_ID}) + fi + + if [ -n "${GOOGLE_CLIENT_ID}" ]; then + FLUTTER_CMD+=(--dart-define=GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}) + fi + + "${FLUTTER_CMD[@]}" + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: reader-app-release-apk + path: build/app/outputs/flutter-apk/app-release.apk + if-no-files-found: error diff --git a/.github/agents/api-contract.agent.md b/.github/agents/api-contract.agent.md new file mode 100644 index 0000000..72470ba --- /dev/null +++ b/.github/agents/api-contract.agent.md @@ -0,0 +1,33 @@ +--- +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 diff --git a/.github/agents/backend-security.agent.md b/.github/agents/backend-security.agent.md new file mode 100644 index 0000000..57593e3 --- /dev/null +++ b/.github/agents/backend-security.agent.md @@ -0,0 +1,33 @@ +--- +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 diff --git a/.github/agents/mobile-app.agent.md b/.github/agents/mobile-app.agent.md new file mode 100644 index 0000000..308881e --- /dev/null +++ b/.github/agents/mobile-app.agent.md @@ -0,0 +1,32 @@ +--- +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 diff --git a/.github/agents/sdet-qa.agent.md b/.github/agents/sdet-qa.agent.md new file mode 100644 index 0000000..7bbaad5 --- /dev/null +++ b/.github/agents/sdet-qa.agent.md @@ -0,0 +1,35 @@ +--- +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 diff --git a/.github/agents/sleuth-debugger.agent.md b/.github/agents/sleuth-debugger.agent.md new file mode 100644 index 0000000..2286f17 --- /dev/null +++ b/.github/agents/sleuth-debugger.agent.md @@ -0,0 +1,32 @@ +--- +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) diff --git a/.github/agents/system-architect.agent.md b/.github/agents/system-architect.agent.md new file mode 100644 index 0000000..fc2051a --- /dev/null +++ b/.github/agents/system-architect.agent.md @@ -0,0 +1,33 @@ +--- +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 diff --git a/.github/agents/web-frontend-nextjs.agent.md b/.github/agents/web-frontend-nextjs.agent.md new file mode 100644 index 0000000..2809850 --- /dev/null +++ b/.github/agents/web-frontend-nextjs.agent.md @@ -0,0 +1,32 @@ +--- +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 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..60ea793 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,42 @@ +# 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` diff --git a/.github/prompts/debug-triage-checklist.prompt.md b/.github/prompts/debug-triage-checklist.prompt.md new file mode 100644 index 0000000..c917e9e --- /dev/null +++ b/.github/prompts/debug-triage-checklist.prompt.md @@ -0,0 +1,22 @@ +--- +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). diff --git a/.github/prompts/flutter-integration-test-flow.prompt.md b/.github/prompts/flutter-integration-test-flow.prompt.md new file mode 100644 index 0000000..d2aa661 --- /dev/null +++ b/.github/prompts/flutter-integration-test-flow.prompt.md @@ -0,0 +1,19 @@ +--- +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. diff --git a/.github/prompts/multi-agent-workflow.prompt.md b/.github/prompts/multi-agent-workflow.prompt.md new file mode 100644 index 0000000..061a743 --- /dev/null +++ b/.github/prompts/multi-agent-workflow.prompt.md @@ -0,0 +1,27 @@ +--- +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. diff --git a/.github/prompts/regression-api-newman.prompt.md b/.github/prompts/regression-api-newman.prompt.md new file mode 100644 index 0000000..0f34b52 --- /dev/null +++ b/.github/prompts/regression-api-newman.prompt.md @@ -0,0 +1,19 @@ +--- +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. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0d56e57..baf8bc1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,7 @@ + + + when (call.method) { + "setWakeLock" -> { + val enabled = call.argument("enabled") ?: false + setWakeLockEnabled(enabled) + result.success(null) + } + "isIgnoringBatteryOptimizations" -> { + result.success(isIgnoringBatteryOptimizations()) + } + "requestIgnoreBatteryOptimizations" -> { + requestIgnoreBatteryOptimizations() + result.success(null) + } + else -> result.notImplemented() + } + } + } + + private fun isIgnoringBatteryOptimizations(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager + return powerManager.isIgnoringBatteryOptimizations(packageName) + } + + private fun requestIgnoreBatteryOptimizations() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return + if (isIgnoringBatteryOptimizations()) return + + val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:$packageName") + } + startActivity(intent) + } + + private fun setWakeLockEnabled(enabled: Boolean) { + if (enabled) { + if (wakeLock?.isHeld == true) return + + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "reader_app:TtsWakeLock" + ).apply { + setReferenceCounted(false) + acquire() + } + return + } + + wakeLock?.let { + if (it.isHeld) { + it.release() + } + } + wakeLock = null + } + + override fun onDestroy() { + setWakeLockEnabled(false) + super.onDestroy() + } +} diff --git a/lib/app/app.dart b/lib/app/app.dart index 61db114..99ab8bd 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,19 +1,64 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/auth/session_expiry_notifier.dart'; import '../core/theme/app_theme.dart'; +import '../features/auth/providers/auth_provider.dart'; +import 'router/route_names.dart'; import 'router/app_router.dart'; -class ReaderApp extends ConsumerWidget { +class ReaderApp extends ConsumerStatefulWidget { const ReaderApp({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _ReaderAppState(); +} + +class _ReaderAppState extends ConsumerState { + final _scaffoldMessengerKey = GlobalKey(); + ProviderSubscription? _sessionExpirySub; + + @override + void initState() { + super.initState(); + _sessionExpirySub = ref.listenManual( + sessionExpiryProvider, + (previous, next) async { + if (previous == null || next == previous) return; + + await ref.read(authProvider.notifier).handleSessionExpired(); + + if (!mounted) return; + final router = ref.read(appRouterProvider); + if (router.state.uri.path != RouteNames.login) { + router.go(RouteNames.login); + } + + _scaffoldMessengerKey.currentState + ?..hideCurrentSnackBar() + ..showSnackBar( + const SnackBar( + content: Text('Phiên đăng nhập đã hết hạn. Vui lòng đăng nhập lại.'), + ), + ); + }, + ); + } + + @override + void dispose() { + _sessionExpirySub?.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { final router = ref.watch(appRouterProvider); return MaterialApp.router( title: 'Reader App', debugShowCheckedModeBanner: false, + scaffoldMessengerKey: _scaffoldMessengerKey, theme: AppTheme.lightTheme, darkTheme: AppTheme.darkTheme, themeMode: ThemeMode.system, diff --git a/lib/core/auth/session_expiry_notifier.dart b/lib/core/auth/session_expiry_notifier.dart new file mode 100644 index 0000000..b820207 --- /dev/null +++ b/lib/core/auth/session_expiry_notifier.dart @@ -0,0 +1,14 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class SessionExpiryNotifier extends StateNotifier { + SessionExpiryNotifier() : super(0); + + void notifyExpired() { + state = state + 1; + } +} + +final sessionExpiryProvider = + StateNotifierProvider((ref) { + return SessionExpiryNotifier(); +}); diff --git a/lib/core/models/chapter_model.dart b/lib/core/models/chapter_model.dart index 0fbbdc9..b53d6eb 100644 --- a/lib/core/models/chapter_model.dart +++ b/lib/core/models/chapter_model.dart @@ -1,5 +1,26 @@ import 'package:equatable/equatable.dart'; +int _toInt(dynamic value, {int fallback = 0}) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value) ?? fallback; + return fallback; +} + +DateTime _toDateTime(dynamic value) { + if (value is DateTime) return value; + if (value is String && value.isNotEmpty) { + final parsed = DateTime.tryParse(value); + if (parsed != null) return parsed; + } + return DateTime.fromMillisecondsSinceEpoch(0); +} + +int? _toNullableInt(dynamic value) { + if (value == null) return null; + return _toInt(value); +} + class ChapterModel extends Equatable { const ChapterModel({ required this.id, @@ -36,18 +57,18 @@ class ChapterModel extends Equatable { factory ChapterModel.fromJson(Map json) => ChapterModel( id: json['id'] as String, novelId: json['novelId'] as String, - number: (json['number'] as num).toInt(), + number: _toInt(json['number']), title: json['title'] as String, content: json['content'] as String, - views: (json['views'] as num?)?.toInt() ?? 0, - volumeNumber: json['volumeNumber'] as int?, + views: _toInt(json['views']), + volumeNumber: _toNullableInt(json['volumeNumber']), volumeTitle: json['volumeTitle'] as String?, - volumeChapterNumber: json['volumeChapterNumber'] as int?, + volumeChapterNumber: _toNullableInt(json['volumeChapterNumber']), prevChapterId: json['prevChapterId'] as String?, - prevChapterNumber: json['prevChapterNumber'] as int?, + prevChapterNumber: _toNullableInt(json['prevChapterNumber']), nextChapterId: json['nextChapterId'] as String?, - nextChapterNumber: json['nextChapterNumber'] as int?, - createdAt: DateTime.parse(json['createdAt'] as String), + nextChapterNumber: _toNullableInt(json['nextChapterNumber']), + createdAt: _toDateTime(json['createdAt']), ); @override @@ -75,12 +96,12 @@ class ChapterListItem extends Equatable { factory ChapterListItem.fromJson(Map json) => ChapterListItem( id: json['id'] as String, - number: (json['number'] as num).toInt(), + number: _toInt(json['number']), title: json['title'] as String, - volumeNumber: json['volumeNumber'] as int?, + volumeNumber: _toNullableInt(json['volumeNumber']), volumeTitle: json['volumeTitle'] as String?, - volumeChapterNumber: json['volumeChapterNumber'] as int?, - createdAt: DateTime.parse(json['createdAt'] as String), + volumeChapterNumber: _toNullableInt(json['volumeChapterNumber']), + createdAt: _toDateTime(json['createdAt']), ); @override diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index 9ac1463..f8e2048 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -7,6 +7,7 @@ class ApiClient { ApiClient({ required String baseUrl, required SecureStore secureStore, + this.onSessionExpired, }) : _secureStore = secureStore, dio = Dio( BaseOptions( @@ -35,6 +36,13 @@ class ApiClient { handler.next(response); }, onError: (error, handler) { + final statusCode = error.response?.statusCode; + final path = error.requestOptions.path; + + if ((statusCode == 401 || statusCode == 403) && !_isAuthEndpoint(path)) { + _handleSessionExpired(); + } + debugPrint( '[API][ERROR] ${error.requestOptions.method} ${error.requestOptions.baseUrl}${error.requestOptions.path} ' '-> ${error.type}: ${error.message}', @@ -47,4 +55,21 @@ class ApiClient { final Dio dio; final SecureStore _secureStore; + final VoidCallback? onSessionExpired; + DateTime? _lastSessionExpiredAt; + + bool _isAuthEndpoint(String path) { + return path.contains('/api/auth/mobile-login'); + } + + void _handleSessionExpired() { + final now = DateTime.now(); + if (_lastSessionExpiredAt != null && + now.difference(_lastSessionExpiredAt!) < const Duration(seconds: 2)) { + return; + } + _lastSessionExpiredAt = now; + _secureStore.clear(); + onSessionExpired?.call(); + } } diff --git a/lib/core/network/providers.dart b/lib/core/network/providers.dart index 1d31d7d..8460101 100644 --- a/lib/core/network/providers.dart +++ b/lib/core/network/providers.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../auth/session_expiry_notifier.dart'; import '../config/app_config.dart'; import '../storage/secure_store.dart'; import 'api_client.dart'; @@ -8,5 +9,9 @@ final secureStoreProvider = Provider((ref) => SecureStore()); final apiClientProvider = Provider((ref) { final secureStore = ref.watch(secureStoreProvider); - return ApiClient(baseUrl: AppConfig.baseUrl, secureStore: secureStore); + return ApiClient( + baseUrl: AppConfig.baseUrl, + secureStore: secureStore, + onSessionExpired: () => ref.read(sessionExpiryProvider.notifier).notifyExpired(), + ); }); diff --git a/lib/features/auth/providers/auth_provider.dart b/lib/features/auth/providers/auth_provider.dart index 48ecc86..ff2e4f9 100644 --- a/lib/features/auth/providers/auth_provider.dart +++ b/lib/features/auth/providers/auth_provider.dart @@ -144,6 +144,11 @@ class AuthNotifier extends StateNotifier { await _store.clear(); state = AuthUnauthenticated(); } + + Future handleSessionExpired() async { + await _store.clear(); + state = AuthUnauthenticated(); + } } final authProvider = StateNotifierProvider((ref) { diff --git a/lib/features/novel/presentation/novel_detail_screen.dart b/lib/features/novel/presentation/novel_detail_screen.dart index 34f3cab..6047fc6 100644 --- a/lib/features/novel/presentation/novel_detail_screen.dart +++ b/lib/features/novel/presentation/novel_detail_screen.dart @@ -5,18 +5,47 @@ import 'package:go_router/go_router.dart'; import '../../../app/router/route_names.dart'; import '../../../core/models/novel_model.dart'; +import '../../../core/storage/local_store.dart'; import '../../bookshelf/providers/bookshelf_provider.dart'; import '../providers/novels_provider.dart'; -class NovelDetailScreen extends ConsumerWidget { +final novelReadProgressProvider = + FutureProvider.family?, String>((ref, novelId) async { + final localStore = ref.read(localStoreProvider); + return localStore.loadProgress(novelId); +}); + +class NovelDetailScreen extends ConsumerStatefulWidget { const NovelDetailScreen({super.key, required this.novelId}); final String novelId; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _NovelDetailScreenState(); +} + +class _NovelDetailScreenState extends ConsumerState { + int _currentPage = 1; + + @override + void didUpdateWidget(covariant NovelDetailScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.novelId != widget.novelId && _currentPage != 1) { + setState(() => _currentPage = 1); + } + } + + @override + Widget build(BuildContext context) { + final novelId = widget.novelId; final novelAsync = ref.watch(novelDetailProvider(novelId)); - final chaptersAsync = ref.watch(chapterListProvider(novelId)); + final chaptersAsync = ref.watch( + chapterListProvider(ChapterListQuery(novelId: novelId, page: _currentPage)), + ); + final firstChapterAsync = ref.watch( + chapterListProvider(ChapterListQuery(novelId: novelId, page: 1)), + ); + final readProgressAsync = ref.watch(novelReadProgressProvider(novelId)); final isBookmarked = ref.watch(isBookmarkedProvider(novelId)); return Scaffold( @@ -56,20 +85,30 @@ class NovelDetailScreen extends ConsumerWidget { _StatsRow(novel: novel), const SizedBox(height: 16), // Read button - chaptersAsync.when( + firstChapterAsync.when( loading: () => const SizedBox.shrink(), - error: (_, error) => const SizedBox.shrink(), - data: (chapters) { - if (chapters.isEmpty) return const SizedBox.shrink(); - final first = chapters.first; + error: (_, _) => const SizedBox.shrink(), + data: (firstPage) { + final first = + firstPage.chapters.isNotEmpty ? firstPage.chapters.first : null; + if (first == null) return const SizedBox.shrink(); + + final progress = readProgressAsync.valueOrNull; + final continueChapterId = progress?['chapterId'] as String?; + final continueChapterNumber = + (progress?['chapterNumber'] as num?)?.toInt(); + final hasProgress = continueChapterId != null && continueChapterId.isNotEmpty; + final targetChapterId = hasProgress ? continueChapterId : first.id; + final buttonLabel = hasProgress + ? 'Đọc tiếp chương ${continueChapterNumber ?? '?'}' + : 'Đọc từ đầu'; + return FilledButton.icon( onPressed: () => context.push( - RouteNames.readerChapter(first.id), + RouteNames.readerChapter(targetChapterId), ), icon: const Icon(Icons.menu_book), - label: Text( - 'Đọc Chương ${first.number}: ${first.title}', - ), + label: Text(buttonLabel), style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(48), ), @@ -83,8 +122,8 @@ class NovelDetailScreen extends ConsumerWidget { Text('Danh sách chương', style: Theme.of(context).textTheme.titleMedium), const Spacer(), chaptersAsync.whenOrNull( - data: (chapters) => Text( - '${chapters.length} chương', + data: (pageData) => Text( + '${pageData.totalChapters} chương • Trang ${pageData.currentPage}/${pageData.totalPages == 0 ? 1 : pageData.totalPages}', style: Theme.of(context).textTheme.bodySmall, ), ) ?? const SizedBox.shrink(), @@ -102,12 +141,22 @@ class NovelDetailScreen extends ConsumerWidget { child: Center(child: CircularProgressIndicator()), ), ), - error: (_, error) => - const SliverToBoxAdapter(child: SizedBox.shrink()), - data: (chapters) => SliverList( + error: (error, _) => SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Text( + 'Không tải được danh sách chương: $error', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Theme.of(context).colorScheme.error), + ), + ), + ), + data: (pageData) => SliverList( delegate: SliverChildBuilderDelegate( (context, index) { - final ch = chapters[index]; + final ch = pageData.chapters[index]; return ListTile( dense: true, title: Text( @@ -118,10 +167,43 @@ class NovelDetailScreen extends ConsumerWidget { onTap: () => context.push(RouteNames.readerChapter(ch.id)), ); }, - childCount: chapters.length, + childCount: pageData.chapters.length, ), ), ), + SliverToBoxAdapter( + child: chaptersAsync.when( + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + data: (pageData) { + final totalPages = pageData.totalPages == 0 ? 1 : pageData.totalPages; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Row( + children: [ + OutlinedButton.icon( + onPressed: _currentPage > 1 + ? () => setState(() => _currentPage = _currentPage - 1) + : null, + icon: const Icon(Icons.chevron_left), + label: const Text('Trang trước'), + ), + const Spacer(), + Text('Trang $_currentPage/$totalPages'), + const Spacer(), + OutlinedButton.icon( + onPressed: _currentPage < totalPages + ? () => setState(() => _currentPage = _currentPage + 1) + : null, + icon: const Icon(Icons.chevron_right), + label: const Text('Trang sau'), + ), + ], + ), + ); + }, + ), + ), const SliverToBoxAdapter(child: SizedBox(height: 24)), ], ), diff --git a/lib/features/novel/providers/novels_provider.dart b/lib/features/novel/providers/novels_provider.dart index 4de447a..98ee3fe 100644 --- a/lib/features/novel/providers/novels_provider.dart +++ b/lib/features/novel/providers/novels_provider.dart @@ -4,6 +4,8 @@ import '../../../core/models/novel_model.dart'; import '../../../core/models/chapter_model.dart'; import '../../../core/network/providers.dart'; +const chapterPageSize = 50; + // ─── Browse / Search ────────────────────────────────────────────────────────── class BrowseParams { @@ -109,13 +111,77 @@ final novelDetailProvider = // ─── 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 chapters; + final int totalChapters; + final int totalPages; + final int currentPage; +} + final chapterListProvider = - FutureProvider.family, String>((ref, novelId) async { + FutureProvider.family((ref, query) async { final client = ref.read(apiClientProvider); - final res = await client.dio.get('/api/truyen/$novelId/chapters'); - final data = res.data as Map; - final chapters = data['chapters'] as List? ?? []; - return chapters - .map((e) => ChapterListItem.fromJson(e as Map)) - .toList(); + + Future> fetchChapterPage(String idOrSlug) async { + final res = await client.dio.get( + '/api/truyen/$idOrSlug/chapters', + queryParameters: { + 'page': query.page, + 'limit': chapterPageSize, + }, + ); + return res.data as Map; + } + + var data = await fetchChapterPage(query.novelId); + var chapters = data['chapters'] as List? ?? const []; + + // Backend stores chapters by novel id in MongoDB; if route opened by slug, + // first request can return empty list. Resolve canonical id and retry once. + if (chapters.isEmpty) { + try { + final novelRes = await client.dio.get('/api/novels/${query.novelId}'); + final novelData = novelRes.data as Map; + final canonicalId = novelData['id'] as String?; + if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != query.novelId) { + data = await fetchChapterPage(canonicalId); + chapters = data['chapters'] as List? ?? const []; + } + } catch (_) { + // Keep original empty list when fallback resolution fails. + } + } + + return ChapterListPage( + chapters: + chapters.map((e) => ChapterListItem.fromJson(e as Map)).toList(), + totalChapters: (data['totalChapters'] as num?)?.toInt() ?? 0, + totalPages: (data['totalPages'] as num?)?.toInt() ?? 0, + currentPage: (data['currentPage'] as num?)?.toInt() ?? query.page, + ); }); diff --git a/lib/features/reader/presentation/reader_screen.dart b/lib/features/reader/presentation/reader_screen.dart index 59277ef..1134a50 100644 --- a/lib/features/reader/presentation/reader_screen.dart +++ b/lib/features/reader/presentation/reader_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -33,6 +34,10 @@ class _ReaderScreenState extends ConsumerState { double _lastScrollOffset = 0; double _scrollDeltaSinceToggle = 0; int _chapterDirection = 0; // -1: previous, 1: next + int _lastAutoScrolledParagraph = -1; + int _lastTtsCompletedCount = 0; + String? _autoStartQueuedChapterId; + final List _paragraphKeys = []; List _paragraphsOf(String content) => content .split(RegExp(r'\n+')) @@ -61,6 +66,112 @@ class _ReaderScreenState extends ConsumerState { } } + Widget _buildParagraphText({ + required BuildContext context, + required String paragraph, + required TextStyle style, + required TextAlign textAlign, + required bool isActiveParagraph, + required int highlightStart, + required int highlightEnd, + }) { + if (!isActiveParagraph || highlightStart < 0 || highlightEnd <= highlightStart) { + return SelectableText( + paragraph, + textAlign: textAlign, + style: style, + ); + } + + final safeStart = highlightStart.clamp(0, paragraph.length); + final safeEnd = highlightEnd.clamp(0, paragraph.length); + if (safeEnd <= safeStart) { + return SelectableText( + paragraph, + textAlign: textAlign, + style: style, + ); + } + + final highlightStyle = style.copyWith( + backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(80), + fontWeight: FontWeight.w600, + ); + + return RichText( + textAlign: textAlign, + text: TextSpan( + style: style, + children: [ + if (safeStart > 0) TextSpan(text: paragraph.substring(0, safeStart)), + TextSpan(text: paragraph.substring(safeStart, safeEnd), style: highlightStyle), + if (safeEnd < paragraph.length) TextSpan(text: paragraph.substring(safeEnd)), + ], + ), + ); + } + + void _ensureParagraphKeys(int count) { + if (_paragraphKeys.length == count) return; + _paragraphKeys + ..clear() + ..addAll(List.generate(count, (_) => GlobalKey())); + _lastAutoScrolledParagraph = -1; + } + + void _maybeAutoScrollToTtsParagraph(TtsState tts, int paragraphCount) { + if (tts.status == TtsStatus.idle) return; + final index = tts.activeParagraphIndex; + if (index < 0 || index >= paragraphCount) return; + if (index == _lastAutoScrolledParagraph) return; + + _lastAutoScrolledParagraph = index; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final ctx = _paragraphKeys[index].currentContext; + if (ctx == null) return; + Scrollable.ensureVisible( + ctx, + alignment: 0.22, + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + ); + }); + } + + int _firstVisibleParagraphIndex() { + if (!_scrollCtrl.hasClients || _paragraphKeys.isEmpty) return 0; + + final viewportTop = _scrollCtrl.offset + 8; + final viewportBottom = + _scrollCtrl.offset + _scrollCtrl.position.viewportDimension - 8; + + int? partiallyVisibleIndex; + + for (var i = 0; i < _paragraphKeys.length; i++) { + final ctx = _paragraphKeys[i].currentContext; + if (ctx == null) continue; + + final renderObject = ctx.findRenderObject(); + if (renderObject == null || !renderObject.attached) continue; + + final viewport = RenderAbstractViewport.of(renderObject); + + final top = viewport.getOffsetToReveal(renderObject, 0).offset; + final bottom = viewport.getOffsetToReveal(renderObject, 1).offset; + + final fullyVisible = top >= viewportTop && bottom <= viewportBottom; + if (fullyVisible) return i; + + final partiallyVisible = bottom > viewportTop && top < viewportBottom; + if (partiallyVisible && partiallyVisibleIndex == null) { + partiallyVisibleIndex = i; + } + } + + return partiallyVisibleIndex ?? 0; + } + @override void initState() { super.initState(); @@ -191,7 +302,12 @@ class _ReaderScreenState extends ConsumerState { builder: (sheetContext) { return Consumer( builder: (context, ref, _) { - final chaptersAsync = ref.watch(chapterListProvider(currentChapter.novelId)); + final tocPage = ((currentChapter.number - 1) ~/ chapterPageSize) + 1; + final chaptersAsync = ref.watch( + chapterListProvider( + ChapterListQuery(novelId: currentChapter.novelId, page: tocPage), + ), + ); return FractionallySizedBox( heightFactor: 0.82, child: SafeArea( @@ -220,13 +336,14 @@ class _ReaderScreenState extends ConsumerState { child: chaptersAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Không tải được mục lục: $e')), - data: (chapters) { + data: (pageData) { + final chapters = pageData.chapters; if (chapters.isEmpty) { return const Center(child: Text('Chưa có danh sách chương.')); } return ListView.separated( itemCount: chapters.length, - separatorBuilder: (_, __) => const Divider(height: 1), + separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { final item = chapters[index]; final isCurrent = item.id == currentChapter.id; @@ -264,7 +381,11 @@ class _ReaderScreenState extends ConsumerState { ); } - Future _openReadingSettingsSheet(String previewContent) async { + Future _openReadingSettingsSheet( + String previewContent, + String chapterId, + String chapterTitle, + ) async { await showModalBottomSheet( context: context, isScrollControlled: true, @@ -277,6 +398,16 @@ class _ReaderScreenState extends ConsumerState { final tts = ref.watch(ttsProvider); final ttsNotifier = ref.read(ttsProvider.notifier); + void closeSettingsSheet() { + if (!sheetContext.mounted) return; + final route = ModalRoute.of(sheetContext); + if (route != null) { + Navigator.of(sheetContext).removeRoute(route); + return; + } + Navigator.of(sheetContext).maybePop(); + } + Future update(dynamic next) async { await ref.read(readingSettingsProvider.notifier).update(next); } @@ -314,7 +445,7 @@ class _ReaderScreenState extends ConsumerState { child: const Text('Mặc định'), ), IconButton( - onPressed: () => Navigator.of(context).pop(), + onPressed: closeSettingsSheet, icon: const Icon(Icons.close), ), ], @@ -544,7 +675,10 @@ class _ReaderScreenState extends ConsumerState { color: Theme.of(context).colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(999), ), - child: Text('${tts.speed}x', style: Theme.of(context).textTheme.labelLarge), + child: Text( + formatTtsSpeedLabel(tts.speed), + style: Theme.of(context).textTheme.labelLarge, + ), ), ], ), @@ -552,17 +686,83 @@ class _ReaderScreenState extends ConsumerState { Wrap( spacing: 8, runSpacing: 8, - children: [0.75, 1.0, 1.25, 1.5, 1.75].map((speed) { + children: [0.35, 0.45, 0.55, 0.65, 0.8, 1.0].map((speed) { final selected = tts.speed == speed; return ChoiceChip( - label: Text('${speed}x'), + label: Text(formatTtsSpeedLabel(speed)), selected: selected, onSelected: (_) => ttsNotifier.setSpeed(speed), ); }).toList(), ), const SizedBox(height: 12), - TtsPlayerWidget(content: previewContent), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Chạy nền cho TTS'), + subtitle: const Text( + 'Tiếp tục đọc khi chuyển app hoặc tắt màn hình (Android)', + ), + value: tts.backgroundModeEnabled, + onChanged: ttsNotifier.setBackgroundModeEnabled, + ), + if (tts.backgroundModeEnabled) + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Loại trừ tối ưu pin'), + subtitle: Text( + tts.batteryOptimizationIgnored + ? 'Đã bật: Android sẽ ít khả năng dừng TTS khi chạy nền.' + : 'Nên bật để Android không chặn TTS khi tắt màn hình.', + ), + trailing: tts.batteryOptimizationIgnored + ? const Icon(Icons.verified, color: Colors.green) + : OutlinedButton( + onPressed: ttsNotifier + .ensureBatteryOptimizationIgnored, + child: const Text('Bật ngay'), + ), + ), + const SizedBox(height: 12), + if (tts.availableVietnameseVoices.isNotEmpty) + DropdownButtonFormField( + initialValue: tts.voiceName, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Giọng đọc tiếng Việt', + border: OutlineInputBorder(), + ), + items: tts.availableVietnameseVoices + .map( + (v) => DropdownMenuItem( + value: v.name, + child: Text( + v.displayName, + overflow: TextOverflow.ellipsis, + ), + ), + ) + .toList(), + onChanged: (value) { + if (value != null) { + ttsNotifier.setVoiceByName(value); + } + }, + ) + else + Text( + 'Thiết bị không cung cấp nhiều giọng tiếng Việt. Đang dùng ${tts.voiceName ?? tts.language}.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + TtsPlayerWidget( + content: previewContent, + contentKey: chapterId, + title: 'Chương $chapterTitle', + includeTitleOnStart: false, + resolveStartParagraphIndex: + _firstVisibleParagraphIndex, + onStarted: closeSettingsSheet, + ), ], ), ), @@ -628,8 +828,42 @@ class _ReaderScreenState extends ConsumerState { ), data: (chapter) { final paragraphs = _paragraphsOf(chapter.content); + _ensureParagraphKeys(paragraphs.length); final textAlign = _textAlignFor(settings.textAlign); final novelAsync = ref.watch(novelDetailProvider(chapter.novelId)); + final tts = ref.watch(ttsProvider); + final shouldHighlightTts = tts.contentKey == chapter.id && + (tts.status == TtsStatus.playing || tts.status == TtsStatus.paused); + + if (tts.completedCount > _lastTtsCompletedCount) { + _lastTtsCompletedCount = tts.completedCount; + if (tts.contentKey == chapter.id && chapter.nextChapterId != null) { + final nextChapterId = chapter.nextChapterId!; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ref.read(ttsProvider.notifier).scheduleAutoStartForChapter(nextChapterId); + context.pushReplacement(RouteNames.readerChapter(nextChapterId)); + }); + } + } + + if (tts.pendingAutoStartChapterId == chapter.id && + _autoStartQueuedChapterId != chapter.id) { + _autoStartQueuedChapterId = chapter.id; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final notifier = ref.read(ttsProvider.notifier); + notifier.clearPendingAutoStartChapter(); + notifier.startReading( + chapter.content, + contentKey: chapter.id, + title: 'Chương ${chapter.number}: ${chapter.title}', + ); + _autoStartQueuedChapterId = null; + }); + } + + _maybeAutoScrollToTtsParagraph(tts, paragraphs.length); WidgetsBinding.instance.addPostFrameCallback((_) { _initializeChapterSession(chapter); }); @@ -645,7 +879,11 @@ class _ReaderScreenState extends ConsumerState { _TopBar( title: _chapterTopBarTitle(chapter), progress: _readingProgress, - onOpenSettings: () => _openReadingSettingsSheet(chapter.content), + onOpenSettings: () => _openReadingSettingsSheet( + chapter.content, + chapter.id, + 'Chương ${chapter.number}: ${chapter.title}', + ), barBackgroundColor: readerBackground, foregroundColor: readerTextColor, ), @@ -693,7 +931,7 @@ class _ReaderScreenState extends ConsumerState { color: readerMutedColor, ), ), - error: (_, __) => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), data: (novel) => Text( novel.title, maxLines: 1, @@ -727,13 +965,15 @@ class _ReaderScreenState extends ConsumerState { children: [ for (var index = 0; index < paragraphs.length; index++) Padding( + key: _paragraphKeys[index], padding: EdgeInsets.only( bottom: index == paragraphs.length - 1 ? 0 : settings.paragraphSpacing, ), - child: SelectableText( - paragraphs[index], + child: _buildParagraphText( + context: context, + paragraph: paragraphs[index], textAlign: textAlign, style: TextStyle( color: readerTextColor, @@ -746,6 +986,10 @@ class _ReaderScreenState extends ConsumerState { ? 'Courier' : null, ), + isActiveParagraph: shouldHighlightTts && + tts.activeParagraphIndex == index, + highlightStart: tts.progressStart, + highlightEnd: tts.progressEnd, ), ), ], @@ -801,6 +1045,27 @@ class _ReaderScreenState extends ConsumerState { ), ) : null, + bottomNavigationBar: chapterAsync.whenOrNull( + data: (chapter) { + final tts = ref.watch(ttsProvider); + final showMini = tts.contentKey == chapter.id && + (tts.status == TtsStatus.playing || tts.status == TtsStatus.paused); + if (!showMini) return const SizedBox.shrink(); + + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 10), + child: TtsPlayerWidget( + compact: true, + content: chapter.content, + contentKey: chapter.id, + title: 'Chương ${chapter.number}: ${chapter.title}', + ), + ), + ); + }, + ), ); } } diff --git a/lib/features/reader/presentation/tts_player_widget.dart b/lib/features/reader/presentation/tts_player_widget.dart index 9e61d77..6eada5b 100644 --- a/lib/features/reader/presentation/tts_player_widget.dart +++ b/lib/features/reader/presentation/tts_player_widget.dart @@ -1,96 +1,240 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../tts/tts_service.dart'; class TtsPlayerWidget extends ConsumerWidget { + const TtsPlayerWidget({ + super.key, + required this.content, + this.contentKey, + this.title, + this.includeTitleOnStart = true, + this.resolveStartParagraphIndex, + this.onStarted, + this.compact = false, + }); + final String content; - const TtsPlayerWidget({super.key, required this.content}); + final String? contentKey; + final String? title; + final bool includeTitleOnStart; + final int Function()? resolveStartParagraphIndex; + final VoidCallback? onStarted; + final bool compact; @override Widget build(BuildContext context, WidgetRef ref) { final tts = ref.watch(ttsProvider); final notifier = ref.read(ttsProvider.notifier); + const speeds = [0.35, 0.45, 0.55, 0.65, 0.8, 1.0]; + + Future start() async { + if (tts.status == TtsStatus.paused) { + unawaited(notifier.resume()); + onStarted?.call(); + return; + } + + unawaited( + notifier.startReading( + content, + paragraphIndex: tts.paragraphIndex, + startParagraphIndex: resolveStartParagraphIndex?.call(), + contentKey: contentKey, + title: title, + includeTitle: includeTitleOnStart, + ), + ); + onStarted?.call(); + } + + Widget speedButton() { + return PopupMenuButton( + initialValue: tts.speed, + onSelected: notifier.setSpeed, + icon: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + color: Theme.of(context).colorScheme.surface.withAlpha(170), + ), + child: Text( + formatTtsSpeedLabel(tts.speed), + style: Theme.of(context).textTheme.labelLarge, + ), + ), + itemBuilder: (_) => speeds + .map((s) => PopupMenuItem(value: s, child: Text(formatTtsSpeedLabel(s)))) + .toList(), + ); + } + + if (compact) { + final progressValue = tts.totalParagraphs > 0 + ? ((tts.paragraphIndex + 1) / tts.totalParagraphs).clamp(0.0, 1.0) + : 0.0; + + return SizedBox( + height: 82, + child: Container( + padding: const EdgeInsets.fromLTRB(12, 8, 8, 6), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Theme.of(context).colorScheme.primaryContainer, + Theme.of(context).colorScheme.secondaryContainer, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(28), + blurRadius: 16, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withAlpha(180), + borderRadius: BorderRadius.circular(999), + ), + child: Icon( + Icons.graphic_eq_rounded, + size: 20, + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title?.trim().isNotEmpty == true ? title! : 'Đang phát TTS', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + Text( + tts.totalParagraphs > 0 + ? 'Câu ${tts.paragraphIndex + 1}/${tts.totalParagraphs}' + : (tts.voiceName ?? tts.language), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ), + if (!tts.isPlaying) + IconButton.filled( + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.play_arrow_rounded), + onPressed: () => start(), + ) + else + IconButton.filled( + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.pause_rounded), + onPressed: notifier.pause, + ), + IconButton( + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.stop_rounded), + onPressed: tts.status != TtsStatus.idle ? notifier.stop : null, + ), + speedButton(), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + minHeight: 4, + value: progressValue, + backgroundColor: Theme.of(context).colorScheme.surface.withAlpha(120), + ), + ), + ], + ), + ), + ); + } + return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(24), + borderRadius: BorderRadius.circular(20), ), - child: Row( + child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - IconButton( - icon: const Icon(Icons.skip_previous), - onPressed: tts.status != TtsStatus.idle ? notifier.skipBack : null, - ), - // Play/Pause/Stop - if (!tts.isPlaying) - IconButton.filled( - icon: const Icon(Icons.play_arrow), - onPressed: () { - if (tts.status == TtsStatus.paused) { - notifier.resume(); - return; - } - notifier.startReading( - content, - paragraphIndex: tts.paragraphIndex, - ); - }, - ) - else - IconButton.filled( - icon: const Icon(Icons.pause), - onPressed: notifier.pause, - ), - IconButton( - icon: const Icon(Icons.stop), - onPressed: tts.status != TtsStatus.idle ? notifier.stop : null, - ), - IconButton( - icon: const Icon(Icons.skip_next), - onPressed: tts.status != TtsStatus.idle ? notifier.skipForward : null, - ), - // Speed control - PopupMenuButton( - initialValue: tts.speed, - onSelected: notifier.setSpeed, - icon: Text( - '${tts.speed}x', - style: Theme.of(context).textTheme.labelSmall, - ), - itemBuilder: (_) => [0.5, 0.75, 1.0, 1.25, 1.5, 2.0] - .map((s) => PopupMenuItem(value: s, child: Text('${s}x'))) - .toList(), - ), - // Progress indicator - if (tts.totalParagraphs > 0) - Padding( - padding: const EdgeInsets.only(right: 8), - child: Text( - '${tts.paragraphIndex + 1}/${tts.totalParagraphs}', - style: Theme.of(context).textTheme.labelSmall, + Wrap( + spacing: 6, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.skip_previous), + onPressed: tts.status != TtsStatus.idle ? notifier.skipBack : null, ), - ), - if (tts.voiceName != null) - Padding( - padding: const EdgeInsets.only(right: 8), - child: Text( - tts.voiceName!, + if (!tts.isPlaying) + IconButton.filled( + icon: const Icon(Icons.play_arrow), + onPressed: () => start(), + ) + else + IconButton.filled( + icon: const Icon(Icons.pause), + onPressed: notifier.pause, + ), + IconButton( + icon: const Icon(Icons.stop), + onPressed: tts.status != TtsStatus.idle ? notifier.stop : null, + ), + IconButton( + icon: const Icon(Icons.skip_next), + onPressed: tts.status != TtsStatus.idle ? notifier.skipForward : null, + ), + speedButton(), + ], + ), + const SizedBox(height: 6), + Wrap( + spacing: 12, + runSpacing: 4, + children: [ + if (tts.totalParagraphs > 0) + Text( + '${tts.paragraphIndex + 1}/${tts.totalParagraphs}', + style: Theme.of(context).textTheme.labelSmall, + ), + Text( + tts.voiceName ?? tts.language, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall, ), - ) - else - Padding( - padding: const EdgeInsets.only(right: 8), - child: Text( - tts.language, - style: Theme.of(context).textTheme.labelSmall, - ), - ), + ], + ), ], ), ); diff --git a/lib/features/reader/tts/tts_service.dart b/lib/features/reader/tts/tts_service.dart index 14fe82c..21f61c4 100644 --- a/lib/features/reader/tts/tts_service.dart +++ b/lib/features/reader/tts/tts_service.dart @@ -1,43 +1,123 @@ +import 'dart:async'; import 'dart:io'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_tts/flutter_tts.dart'; enum TtsStatus { idle, playing, paused } +const double kTtsBaseSpeechRate = 0.45; + +double ttsDisplayMultiplier(double speechRate) => speechRate / kTtsBaseSpeechRate; + +String formatTtsSpeedLabel(double speechRate) { + final multiplier = ttsDisplayMultiplier(speechRate); + final rounded = multiplier.roundToDouble(); + if ((multiplier - rounded).abs() < 0.05) { + return '${rounded.toInt()}x'; + } + return '${multiplier.toStringAsFixed(1)}x'; +} + +class _TtsSegment { + const _TtsSegment({ + required this.text, + required this.paragraphIndex, + required this.start, + required this.end, + }); + + final String text; + final int paragraphIndex; + final int start; + final int end; +} + +class TtsVoice { + const TtsVoice({required this.name, required this.locale}); + + final String name; + final String locale; + + String get displayName => '$name ($locale)'; +} + class TtsState { final TtsStatus status; - final int paragraphIndex; - final int totalParagraphs; + final int paragraphIndex; // current spoken segment index + final int totalParagraphs; // total spoken segments + final int activeParagraphIndex; // paragraph index in chapter content final double speed; final String language; final String? voiceName; + final List availableVietnameseVoices; + final int progressStart; + final int progressEnd; + final String? contentKey; + final int completedCount; + final bool backgroundModeEnabled; + final bool batteryOptimizationIgnored; + final String? pendingAutoStartChapterId; const TtsState({ this.status = TtsStatus.idle, this.paragraphIndex = 0, this.totalParagraphs = 0, - this.speed = 1.0, + this.activeParagraphIndex = -1, + this.speed = 0.45, this.language = 'vi-VN', this.voiceName, + this.availableVietnameseVoices = const [], + this.progressStart = -1, + this.progressEnd = -1, + this.contentKey, + this.completedCount = 0, + this.backgroundModeEnabled = true, + this.batteryOptimizationIgnored = false, + this.pendingAutoStartChapterId, }); TtsState copyWith({ TtsStatus? status, int? paragraphIndex, int? totalParagraphs, + int? activeParagraphIndex, double? speed, String? language, String? voiceName, + List? availableVietnameseVoices, + int? progressStart, + int? progressEnd, + String? contentKey, + bool clearContentKey = false, bool clearVoiceName = false, + int? completedCount, + bool? backgroundModeEnabled, + bool? batteryOptimizationIgnored, + String? pendingAutoStartChapterId, + bool clearPendingAutoStartChapterId = false, }) => TtsState( status: status ?? this.status, paragraphIndex: paragraphIndex ?? this.paragraphIndex, totalParagraphs: totalParagraphs ?? this.totalParagraphs, + activeParagraphIndex: activeParagraphIndex ?? this.activeParagraphIndex, speed: speed ?? this.speed, language: language ?? this.language, voiceName: clearVoiceName ? null : (voiceName ?? this.voiceName), + availableVietnameseVoices: + availableVietnameseVoices ?? this.availableVietnameseVoices, + progressStart: progressStart ?? this.progressStart, + progressEnd: progressEnd ?? this.progressEnd, + contentKey: clearContentKey ? null : (contentKey ?? this.contentKey), + completedCount: completedCount ?? this.completedCount, + backgroundModeEnabled: backgroundModeEnabled ?? this.backgroundModeEnabled, + batteryOptimizationIgnored: + batteryOptimizationIgnored ?? this.batteryOptimizationIgnored, + pendingAutoStartChapterId: clearPendingAutoStartChapterId + ? null + : (pendingAutoStartChapterId ?? this.pendingAutoStartChapterId), ); bool get isPlaying => status == TtsStatus.playing; @@ -45,7 +125,8 @@ class TtsState { class TtsNotifier extends StateNotifier { final FlutterTts _tts = FlutterTts(); - List _paragraphs = []; + static const MethodChannel _backgroundChannel = MethodChannel('reader_app/tts_background'); + List<_TtsSegment> _segments = []; bool _initialized = false; Future? _initFuture; @@ -74,12 +155,15 @@ class TtsNotifier extends StateNotifier { } await _configureVietnameseVoice(); - await _tts.setSpeechRate(1.0); + await _tts.setSpeechRate(kTtsBaseSpeechRate); await _tts.setVolume(1.0); await _tts.setPitch(1.0); _tts.setStartHandler(() { - state = state.copyWith(status: TtsStatus.playing); + state = state.copyWith( + status: TtsStatus.playing, + ); + unawaited(_syncBackgroundMode()); }); _tts.setCompletionHandler(() { @@ -90,8 +174,11 @@ class TtsNotifier extends StateNotifier { _tts.setErrorHandler((msg) { state = state.copyWith(status: TtsStatus.idle); + unawaited(_syncBackgroundMode()); }); + await _syncBackgroundMode(); + _initialized = true; } @@ -100,6 +187,7 @@ class TtsNotifier extends StateNotifier { String? selectedName; String selectedLanguage = 'vi-VN'; + final List vietnameseVoices = []; if (voicesRaw is List) { final vietnamese = voicesRaw.whereType().where((voice) { @@ -107,6 +195,13 @@ class TtsNotifier extends StateNotifier { return locale.startsWith('vi'); }).toList(); + for (final voice in vietnamese) { + final name = voice['name']?.toString(); + final locale = (voice['locale'] ?? voice['language'])?.toString(); + if (name == null || name.isEmpty || locale == null || locale.isEmpty) continue; + vietnameseVoices.add(TtsVoice(name: name, locale: locale)); + } + if (vietnamese.isNotEmpty) { final preferred = vietnamese.firstWhere( (voice) => @@ -124,66 +219,226 @@ class TtsNotifier extends StateNotifier { if (selectedName != null) { await _tts.setVoice({'name': selectedName, 'locale': selectedLanguage}); } - state = state.copyWith(language: selectedLanguage, voiceName: selectedName); + state = state.copyWith( + language: selectedLanguage, + voiceName: selectedName, + availableVietnameseVoices: vietnameseVoices, + ); + } + + Future setVoiceByName(String voiceName) async { + final selected = state.availableVietnameseVoices.where((v) => v.name == voiceName); + if (selected.isEmpty) return; + + final voice = selected.first; + if (!voice.locale.toLowerCase().startsWith('vi')) return; + + await _tts.setLanguage(voice.locale); + await _tts.setVoice({'name': voice.name, 'locale': voice.locale}); + state = state.copyWith(language: voice.locale, voiceName: voice.name); + } + + Future setBackgroundModeEnabled(bool enabled) async { + state = state.copyWith(backgroundModeEnabled: enabled); + await _syncBackgroundMode(); + if (enabled) { + await ensureBatteryOptimizationIgnored(); + } + } + + void scheduleAutoStartForChapter(String chapterId) { + state = state.copyWith(pendingAutoStartChapterId: chapterId); + } + + void clearPendingAutoStartChapter() { + state = state.copyWith(clearPendingAutoStartChapterId: true); + } + + Future ensureBatteryOptimizationIgnored() async { + if (!Platform.isAndroid) return; + try { + final isIgnored = await _backgroundChannel.invokeMethod( + 'isIgnoringBatteryOptimizations', + ) ?? + false; + + state = state.copyWith(batteryOptimizationIgnored: isIgnored); + if (isIgnored) return; + + await _backgroundChannel.invokeMethod('requestIgnoreBatteryOptimizations'); + + final afterRequest = await _backgroundChannel.invokeMethod( + 'isIgnoringBatteryOptimizations', + ) ?? + false; + state = state.copyWith(batteryOptimizationIgnored: afterRequest); + } catch (_) { + // Ignore bridge errors and keep TTS playback functional. + } + } + + Future _syncBackgroundMode() async { + if (!Platform.isAndroid) return; + + final shouldKeepAlive = + state.backgroundModeEnabled && state.status == TtsStatus.playing; + try { + await _backgroundChannel + .invokeMethod('setWakeLock', {'enabled': shouldKeepAlive}); + } catch (_) { + // Keep playback functional even if native wake lock bridge is unavailable. + } } /// Start reading from [content] starting at optional [paragraphIndex]. - Future startReading(String content, {int paragraphIndex = 0}) async { + Future startReading( + String content, { + int paragraphIndex = 0, + int? startParagraphIndex, + String? contentKey, + String? title, + bool includeTitle = true, + }) async { if (!_initialized) { await (_initFuture ?? _init()); } - _paragraphs = content + final segments = <_TtsSegment>[]; + + final titleText = title?.trim(); + if (includeTitle && titleText != null && titleText.isNotEmpty) { + segments.add(_TtsSegment(text: titleText, paragraphIndex: -1, start: -1, end: -1)); + } + + final paragraphs = content .split(RegExp(r'\n+')) .map((p) => p.trim()) .where((p) => p.isNotEmpty) .toList(); - if (_paragraphs.isEmpty) return; + for (var pIndex = 0; pIndex < paragraphs.length; pIndex++) { + final paragraph = paragraphs[pIndex]; + final sentenceMatches = RegExp(r'[^.!?…]+[.!?…]*').allMatches(paragraph); + var cursor = 0; - final validIndex = paragraphIndex.clamp(0, _paragraphs.length - 1); + for (final match in sentenceMatches) { + final sentence = match.group(0)?.trim() ?? ''; + if (sentence.isEmpty) continue; + var start = paragraph.indexOf(sentence, cursor); + if (start < 0) start = cursor.clamp(0, paragraph.length); + final end = (start + sentence.length).clamp(0, paragraph.length); + cursor = end; + + segments.add( + _TtsSegment( + text: sentence, + paragraphIndex: pIndex, + start: start, + end: end, + ), + ); + } + } + + _segments = segments; + if (_segments.isEmpty) return; + + var validIndex = paragraphIndex.clamp(0, _segments.length - 1); + if (startParagraphIndex != null) { + final startFromVisible = _segments.indexWhere( + (segment) => segment.paragraphIndex >= startParagraphIndex, + ); + if (startFromVisible >= 0) { + validIndex = startFromVisible; + } + } + + final selectedSegment = _segments[validIndex]; state = state.copyWith( status: TtsStatus.playing, paragraphIndex: validIndex, - totalParagraphs: _paragraphs.length, + totalParagraphs: _segments.length, + activeParagraphIndex: selectedSegment.paragraphIndex, + progressStart: selectedSegment.start, + progressEnd: selectedSegment.end, + contentKey: contentKey, ); + await _syncBackgroundMode(); await _speak(validIndex); } Future _speak(int index) async { - if (index >= _paragraphs.length) { - state = state.copyWith(status: TtsStatus.idle); + if (index >= _segments.length) { + state = state.copyWith( + status: TtsStatus.idle, + activeParagraphIndex: -1, + progressStart: -1, + progressEnd: -1, + ); + await _syncBackgroundMode(); return; } + + final segment = _segments[index]; + state = state.copyWith( + paragraphIndex: index, + activeParagraphIndex: segment.paragraphIndex, + progressStart: segment.start, + progressEnd: segment.end, + ); + await _tts.setSpeechRate(state.speed); - await _tts.speak(_paragraphs[index]); + await _tts.speak(segment.text); } Future _next() async { final next = state.paragraphIndex + 1; if (next >= state.totalParagraphs) { - state = state.copyWith(status: TtsStatus.idle, paragraphIndex: 0); + state = state.copyWith( + status: TtsStatus.idle, + paragraphIndex: 0, + activeParagraphIndex: -1, + progressStart: -1, + progressEnd: -1, + completedCount: state.completedCount + 1, + ); + await _syncBackgroundMode(); return; } - state = state.copyWith(paragraphIndex: next); + state = state.copyWith( + paragraphIndex: next, + activeParagraphIndex: _segments[next].paragraphIndex, + progressStart: _segments[next].start, + progressEnd: _segments[next].end, + ); await _speak(next); } Future pause() async { await _tts.pause(); state = state.copyWith(status: TtsStatus.paused); + await _syncBackgroundMode(); } Future resume() async { if (state.status != TtsStatus.paused) return; state = state.copyWith(status: TtsStatus.playing); + await _syncBackgroundMode(); // Use paragraph-level resume for consistent behavior across engines. await _speak(state.paragraphIndex); } Future stop() async { await _tts.stop(); - state = state.copyWith(status: TtsStatus.idle, paragraphIndex: 0); + state = state.copyWith( + status: TtsStatus.idle, + paragraphIndex: 0, + activeParagraphIndex: -1, + progressStart: -1, + progressEnd: -1, + clearContentKey: true, + ); + await _syncBackgroundMode(); } Future skipForward() async { @@ -195,7 +450,12 @@ class TtsNotifier extends StateNotifier { await _tts.stop(); if (state.totalParagraphs <= 0) return; final prev = (state.paragraphIndex - 1).clamp(0, state.totalParagraphs - 1); - state = state.copyWith(paragraphIndex: prev); + state = state.copyWith( + paragraphIndex: prev, + activeParagraphIndex: _segments[prev].paragraphIndex, + progressStart: _segments[prev].start, + progressEnd: _segments[prev].end, + ); if (state.status == TtsStatus.playing) await _speak(prev); } @@ -206,6 +466,7 @@ class TtsNotifier extends StateNotifier { @override void dispose() { + unawaited(_backgroundChannel.invokeMethod('setWakeLock', {'enabled': false})); _tts.stop(); super.dispose(); } diff --git a/lib/main.dart b/lib/main.dart index ab061e7..8d9ef90 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,22 +8,22 @@ import 'app/app.dart'; import 'core/logging/app_provider_observer.dart'; void main() { - WidgetsFlutterBinding.ensureInitialized(); - - FlutterError.onError = (details) { - FlutterError.presentError(details); - debugPrint('[APP][FLUTTER_ERROR] ${details.exceptionAsString()}'); - debugPrintStack(stackTrace: details.stack); - }; - - PlatformDispatcher.instance.onError = (error, stack) { - debugPrint('[APP][PLATFORM_ERROR] $error'); - debugPrintStack(stackTrace: stack); - return true; - }; - runZonedGuarded( () { + WidgetsFlutterBinding.ensureInitialized(); + + FlutterError.onError = (details) { + FlutterError.presentError(details); + debugPrint('[APP][FLUTTER_ERROR] ${details.exceptionAsString()}'); + debugPrintStack(stackTrace: details.stack); + }; + + PlatformDispatcher.instance.onError = (error, stack) { + debugPrint('[APP][PLATFORM_ERROR] $error'); + debugPrintStack(stackTrace: stack); + return true; + }; + runApp( const ProviderScope( observers: [AppProviderObserver()],