feat: Enhance chapter list and TTS functionality
Build Android APK / build-apk (push) Failing after 4m37s
Build Android APK / build-apk (push) Failing after 4m37s
- 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.
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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`
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||||
<application
|
<application
|
||||||
android:label="reader_app"
|
android:label="reader_app"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
@@ -1,5 +1,83 @@
|
|||||||
package com.example.reader_app
|
package com.example.reader_app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
class MainActivity : FlutterActivity() {
|
||||||
|
private val channelName = "reader_app/tts_background"
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"setWakeLock" -> {
|
||||||
|
val enabled = call.argument<Boolean>("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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+47
-2
@@ -1,19 +1,64 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../core/auth/session_expiry_notifier.dart';
|
||||||
import '../core/theme/app_theme.dart';
|
import '../core/theme/app_theme.dart';
|
||||||
|
import '../features/auth/providers/auth_provider.dart';
|
||||||
|
import 'router/route_names.dart';
|
||||||
import 'router/app_router.dart';
|
import 'router/app_router.dart';
|
||||||
|
|
||||||
class ReaderApp extends ConsumerWidget {
|
class ReaderApp extends ConsumerStatefulWidget {
|
||||||
const ReaderApp({super.key});
|
const ReaderApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<ReaderApp> createState() => _ReaderAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReaderAppState extends ConsumerState<ReaderApp> {
|
||||||
|
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||||
|
ProviderSubscription<int>? _sessionExpirySub;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_sessionExpirySub = ref.listenManual<int>(
|
||||||
|
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);
|
final router = ref.watch(appRouterProvider);
|
||||||
|
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: 'Reader App',
|
title: 'Reader App',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
|
scaffoldMessengerKey: _scaffoldMessengerKey,
|
||||||
theme: AppTheme.lightTheme,
|
theme: AppTheme.lightTheme,
|
||||||
darkTheme: AppTheme.darkTheme,
|
darkTheme: AppTheme.darkTheme,
|
||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.system,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
class SessionExpiryNotifier extends StateNotifier<int> {
|
||||||
|
SessionExpiryNotifier() : super(0);
|
||||||
|
|
||||||
|
void notifyExpired() {
|
||||||
|
state = state + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final sessionExpiryProvider =
|
||||||
|
StateNotifierProvider<SessionExpiryNotifier, int>((ref) {
|
||||||
|
return SessionExpiryNotifier();
|
||||||
|
});
|
||||||
@@ -1,5 +1,26 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
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 {
|
class ChapterModel extends Equatable {
|
||||||
const ChapterModel({
|
const ChapterModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -36,18 +57,18 @@ class ChapterModel extends Equatable {
|
|||||||
factory ChapterModel.fromJson(Map<String, dynamic> json) => ChapterModel(
|
factory ChapterModel.fromJson(Map<String, dynamic> json) => ChapterModel(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
novelId: json['novelId'] as String,
|
novelId: json['novelId'] as String,
|
||||||
number: (json['number'] as num).toInt(),
|
number: _toInt(json['number']),
|
||||||
title: json['title'] as String,
|
title: json['title'] as String,
|
||||||
content: json['content'] as String,
|
content: json['content'] as String,
|
||||||
views: (json['views'] as num?)?.toInt() ?? 0,
|
views: _toInt(json['views']),
|
||||||
volumeNumber: json['volumeNumber'] as int?,
|
volumeNumber: _toNullableInt(json['volumeNumber']),
|
||||||
volumeTitle: json['volumeTitle'] as String?,
|
volumeTitle: json['volumeTitle'] as String?,
|
||||||
volumeChapterNumber: json['volumeChapterNumber'] as int?,
|
volumeChapterNumber: _toNullableInt(json['volumeChapterNumber']),
|
||||||
prevChapterId: json['prevChapterId'] as String?,
|
prevChapterId: json['prevChapterId'] as String?,
|
||||||
prevChapterNumber: json['prevChapterNumber'] as int?,
|
prevChapterNumber: _toNullableInt(json['prevChapterNumber']),
|
||||||
nextChapterId: json['nextChapterId'] as String?,
|
nextChapterId: json['nextChapterId'] as String?,
|
||||||
nextChapterNumber: json['nextChapterNumber'] as int?,
|
nextChapterNumber: _toNullableInt(json['nextChapterNumber']),
|
||||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
createdAt: _toDateTime(json['createdAt']),
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -75,12 +96,12 @@ class ChapterListItem extends Equatable {
|
|||||||
|
|
||||||
factory ChapterListItem.fromJson(Map<String, dynamic> json) => ChapterListItem(
|
factory ChapterListItem.fromJson(Map<String, dynamic> json) => ChapterListItem(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
number: (json['number'] as num).toInt(),
|
number: _toInt(json['number']),
|
||||||
title: json['title'] as String,
|
title: json['title'] as String,
|
||||||
volumeNumber: json['volumeNumber'] as int?,
|
volumeNumber: _toNullableInt(json['volumeNumber']),
|
||||||
volumeTitle: json['volumeTitle'] as String?,
|
volumeTitle: json['volumeTitle'] as String?,
|
||||||
volumeChapterNumber: json['volumeChapterNumber'] as int?,
|
volumeChapterNumber: _toNullableInt(json['volumeChapterNumber']),
|
||||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
createdAt: _toDateTime(json['createdAt']),
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class ApiClient {
|
|||||||
ApiClient({
|
ApiClient({
|
||||||
required String baseUrl,
|
required String baseUrl,
|
||||||
required SecureStore secureStore,
|
required SecureStore secureStore,
|
||||||
|
this.onSessionExpired,
|
||||||
}) : _secureStore = secureStore,
|
}) : _secureStore = secureStore,
|
||||||
dio = Dio(
|
dio = Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
@@ -35,6 +36,13 @@ class ApiClient {
|
|||||||
handler.next(response);
|
handler.next(response);
|
||||||
},
|
},
|
||||||
onError: (error, handler) {
|
onError: (error, handler) {
|
||||||
|
final statusCode = error.response?.statusCode;
|
||||||
|
final path = error.requestOptions.path;
|
||||||
|
|
||||||
|
if ((statusCode == 401 || statusCode == 403) && !_isAuthEndpoint(path)) {
|
||||||
|
_handleSessionExpired();
|
||||||
|
}
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[API][ERROR] ${error.requestOptions.method} ${error.requestOptions.baseUrl}${error.requestOptions.path} '
|
'[API][ERROR] ${error.requestOptions.method} ${error.requestOptions.baseUrl}${error.requestOptions.path} '
|
||||||
'-> ${error.type}: ${error.message}',
|
'-> ${error.type}: ${error.message}',
|
||||||
@@ -47,4 +55,21 @@ class ApiClient {
|
|||||||
|
|
||||||
final Dio dio;
|
final Dio dio;
|
||||||
final SecureStore _secureStore;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../auth/session_expiry_notifier.dart';
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
import '../storage/secure_store.dart';
|
import '../storage/secure_store.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
@@ -8,5 +9,9 @@ final secureStoreProvider = Provider<SecureStore>((ref) => SecureStore());
|
|||||||
|
|
||||||
final apiClientProvider = Provider<ApiClient>((ref) {
|
final apiClientProvider = Provider<ApiClient>((ref) {
|
||||||
final secureStore = ref.watch(secureStoreProvider);
|
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(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -144,6 +144,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
await _store.clear();
|
await _store.clear();
|
||||||
state = AuthUnauthenticated();
|
state = AuthUnauthenticated();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> handleSessionExpired() async {
|
||||||
|
await _store.clear();
|
||||||
|
state = AuthUnauthenticated();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
||||||
|
|||||||
@@ -5,18 +5,47 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import '../../../app/router/route_names.dart';
|
import '../../../app/router/route_names.dart';
|
||||||
import '../../../core/models/novel_model.dart';
|
import '../../../core/models/novel_model.dart';
|
||||||
|
import '../../../core/storage/local_store.dart';
|
||||||
import '../../bookshelf/providers/bookshelf_provider.dart';
|
import '../../bookshelf/providers/bookshelf_provider.dart';
|
||||||
import '../providers/novels_provider.dart';
|
import '../providers/novels_provider.dart';
|
||||||
|
|
||||||
class NovelDetailScreen extends ConsumerWidget {
|
final novelReadProgressProvider =
|
||||||
|
FutureProvider.family<Map<String, dynamic>?, String>((ref, novelId) async {
|
||||||
|
final localStore = ref.read(localStoreProvider);
|
||||||
|
return localStore.loadProgress(novelId);
|
||||||
|
});
|
||||||
|
|
||||||
|
class NovelDetailScreen extends ConsumerStatefulWidget {
|
||||||
const NovelDetailScreen({super.key, required this.novelId});
|
const NovelDetailScreen({super.key, required this.novelId});
|
||||||
|
|
||||||
final String novelId;
|
final String novelId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<NovelDetailScreen> createState() => _NovelDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NovelDetailScreenState extends ConsumerState<NovelDetailScreen> {
|
||||||
|
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 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));
|
final isBookmarked = ref.watch(isBookmarkedProvider(novelId));
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -56,20 +85,30 @@ class NovelDetailScreen extends ConsumerWidget {
|
|||||||
_StatsRow(novel: novel),
|
_StatsRow(novel: novel),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// Read button
|
// Read button
|
||||||
chaptersAsync.when(
|
firstChapterAsync.when(
|
||||||
loading: () => const SizedBox.shrink(),
|
loading: () => const SizedBox.shrink(),
|
||||||
error: (_, error) => const SizedBox.shrink(),
|
error: (_, _) => const SizedBox.shrink(),
|
||||||
data: (chapters) {
|
data: (firstPage) {
|
||||||
if (chapters.isEmpty) return const SizedBox.shrink();
|
final first =
|
||||||
final first = chapters.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(
|
return FilledButton.icon(
|
||||||
onPressed: () => context.push(
|
onPressed: () => context.push(
|
||||||
RouteNames.readerChapter(first.id),
|
RouteNames.readerChapter(targetChapterId),
|
||||||
),
|
),
|
||||||
icon: const Icon(Icons.menu_book),
|
icon: const Icon(Icons.menu_book),
|
||||||
label: Text(
|
label: Text(buttonLabel),
|
||||||
'Đọc Chương ${first.number}: ${first.title}',
|
|
||||||
),
|
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
minimumSize: const Size.fromHeight(48),
|
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),
|
Text('Danh sách chương', style: Theme.of(context).textTheme.titleMedium),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
chaptersAsync.whenOrNull(
|
chaptersAsync.whenOrNull(
|
||||||
data: (chapters) => Text(
|
data: (pageData) => Text(
|
||||||
'${chapters.length} chương',
|
'${pageData.totalChapters} chương • Trang ${pageData.currentPage}/${pageData.totalPages == 0 ? 1 : pageData.totalPages}',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
) ?? const SizedBox.shrink(),
|
) ?? const SizedBox.shrink(),
|
||||||
@@ -102,12 +141,22 @@ class NovelDetailScreen extends ConsumerWidget {
|
|||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
error: (_, error) =>
|
error: (error, _) => SliverToBoxAdapter(
|
||||||
const SliverToBoxAdapter(child: SizedBox.shrink()),
|
child: Padding(
|
||||||
data: (chapters) => SliverList(
|
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(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) {
|
(context, index) {
|
||||||
final ch = chapters[index];
|
final ch = pageData.chapters[index];
|
||||||
return ListTile(
|
return ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
title: Text(
|
title: Text(
|
||||||
@@ -118,10 +167,43 @@ class NovelDetailScreen extends ConsumerWidget {
|
|||||||
onTap: () => context.push(RouteNames.readerChapter(ch.id)),
|
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)),
|
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ 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 {
|
||||||
@@ -109,13 +111,77 @@ 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<List<ChapterListItem>, String>((ref, novelId) async {
|
FutureProvider.family<ChapterListPage, ChapterListQuery>((ref, query) async {
|
||||||
final client = ref.read(apiClientProvider);
|
final client = ref.read(apiClientProvider);
|
||||||
final res = await client.dio.get('/api/truyen/$novelId/chapters');
|
|
||||||
final data = res.data as Map<String, dynamic>;
|
Future<Map<String, dynamic>> fetchChapterPage(String idOrSlug) async {
|
||||||
final chapters = data['chapters'] as List? ?? [];
|
final res = await client.dio.get(
|
||||||
return chapters
|
'/api/truyen/$idOrSlug/chapters',
|
||||||
.map((e) => ChapterListItem.fromJson(e as Map<String, dynamic>))
|
queryParameters: {
|
||||||
.toList();
|
'page': query.page,
|
||||||
|
'limit': chapterPageSize,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return res.data as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, dynamic>;
|
||||||
|
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<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,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@@ -33,6 +34,10 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
double _lastScrollOffset = 0;
|
double _lastScrollOffset = 0;
|
||||||
double _scrollDeltaSinceToggle = 0;
|
double _scrollDeltaSinceToggle = 0;
|
||||||
int _chapterDirection = 0; // -1: previous, 1: next
|
int _chapterDirection = 0; // -1: previous, 1: next
|
||||||
|
int _lastAutoScrolledParagraph = -1;
|
||||||
|
int _lastTtsCompletedCount = 0;
|
||||||
|
String? _autoStartQueuedChapterId;
|
||||||
|
final List<GlobalKey> _paragraphKeys = [];
|
||||||
|
|
||||||
List<String> _paragraphsOf(String content) => content
|
List<String> _paragraphsOf(String content) => content
|
||||||
.split(RegExp(r'\n+'))
|
.split(RegExp(r'\n+'))
|
||||||
@@ -61,6 +66,112 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -191,7 +302,12 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
builder: (sheetContext) {
|
builder: (sheetContext) {
|
||||||
return Consumer(
|
return Consumer(
|
||||||
builder: (context, ref, _) {
|
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(
|
return FractionallySizedBox(
|
||||||
heightFactor: 0.82,
|
heightFactor: 0.82,
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
@@ -220,13 +336,14 @@ 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: (chapters) {
|
data: (pageData) {
|
||||||
|
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.'));
|
||||||
}
|
}
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
itemCount: chapters.length,
|
itemCount: chapters.length,
|
||||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = chapters[index];
|
final item = chapters[index];
|
||||||
final isCurrent = item.id == currentChapter.id;
|
final isCurrent = item.id == currentChapter.id;
|
||||||
@@ -264,7 +381,11 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openReadingSettingsSheet(String previewContent) async {
|
Future<void> _openReadingSettingsSheet(
|
||||||
|
String previewContent,
|
||||||
|
String chapterId,
|
||||||
|
String chapterTitle,
|
||||||
|
) async {
|
||||||
await showModalBottomSheet<void>(
|
await showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
@@ -277,6 +398,16 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
final tts = ref.watch(ttsProvider);
|
final tts = ref.watch(ttsProvider);
|
||||||
final ttsNotifier = ref.read(ttsProvider.notifier);
|
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<void> update(dynamic next) async {
|
Future<void> update(dynamic next) async {
|
||||||
await ref.read(readingSettingsProvider.notifier).update(next);
|
await ref.read(readingSettingsProvider.notifier).update(next);
|
||||||
}
|
}
|
||||||
@@ -314,7 +445,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
child: const Text('Mặc định'),
|
child: const Text('Mặc định'),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: closeSettingsSheet,
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -544,7 +675,10 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||||
borderRadius: BorderRadius.circular(999),
|
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<ReaderScreen> {
|
|||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 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;
|
final selected = tts.speed == speed;
|
||||||
return ChoiceChip(
|
return ChoiceChip(
|
||||||
label: Text('${speed}x'),
|
label: Text(formatTtsSpeedLabel(speed)),
|
||||||
selected: selected,
|
selected: selected,
|
||||||
onSelected: (_) => ttsNotifier.setSpeed(speed),
|
onSelected: (_) => ttsNotifier.setSpeed(speed),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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<String>(
|
||||||
|
initialValue: tts.voiceName,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Giọng đọc tiếng Việt',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: tts.availableVietnameseVoices
|
||||||
|
.map(
|
||||||
|
(v) => DropdownMenuItem<String>(
|
||||||
|
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<ReaderScreen> {
|
|||||||
),
|
),
|
||||||
data: (chapter) {
|
data: (chapter) {
|
||||||
final paragraphs = _paragraphsOf(chapter.content);
|
final paragraphs = _paragraphsOf(chapter.content);
|
||||||
|
_ensureParagraphKeys(paragraphs.length);
|
||||||
final textAlign = _textAlignFor(settings.textAlign);
|
final textAlign = _textAlignFor(settings.textAlign);
|
||||||
final novelAsync = ref.watch(novelDetailProvider(chapter.novelId));
|
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((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_initializeChapterSession(chapter);
|
_initializeChapterSession(chapter);
|
||||||
});
|
});
|
||||||
@@ -645,7 +879,11 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
_TopBar(
|
_TopBar(
|
||||||
title: _chapterTopBarTitle(chapter),
|
title: _chapterTopBarTitle(chapter),
|
||||||
progress: _readingProgress,
|
progress: _readingProgress,
|
||||||
onOpenSettings: () => _openReadingSettingsSheet(chapter.content),
|
onOpenSettings: () => _openReadingSettingsSheet(
|
||||||
|
chapter.content,
|
||||||
|
chapter.id,
|
||||||
|
'Chương ${chapter.number}: ${chapter.title}',
|
||||||
|
),
|
||||||
barBackgroundColor: readerBackground,
|
barBackgroundColor: readerBackground,
|
||||||
foregroundColor: readerTextColor,
|
foregroundColor: readerTextColor,
|
||||||
),
|
),
|
||||||
@@ -693,7 +931,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
color: readerMutedColor,
|
color: readerMutedColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
error: (_, __) => const SizedBox.shrink(),
|
error: (_, _) => const SizedBox.shrink(),
|
||||||
data: (novel) => Text(
|
data: (novel) => Text(
|
||||||
novel.title,
|
novel.title,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
@@ -727,13 +965,15 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
children: [
|
children: [
|
||||||
for (var index = 0; index < paragraphs.length; index++)
|
for (var index = 0; index < paragraphs.length; index++)
|
||||||
Padding(
|
Padding(
|
||||||
|
key: _paragraphKeys[index],
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
bottom: index == paragraphs.length - 1
|
bottom: index == paragraphs.length - 1
|
||||||
? 0
|
? 0
|
||||||
: settings.paragraphSpacing,
|
: settings.paragraphSpacing,
|
||||||
),
|
),
|
||||||
child: SelectableText(
|
child: _buildParagraphText(
|
||||||
paragraphs[index],
|
context: context,
|
||||||
|
paragraph: paragraphs[index],
|
||||||
textAlign: textAlign,
|
textAlign: textAlign,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: readerTextColor,
|
color: readerTextColor,
|
||||||
@@ -746,6 +986,10 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
? 'Courier'
|
? 'Courier'
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
|
isActiveParagraph: shouldHighlightTts &&
|
||||||
|
tts.activeParagraphIndex == index,
|
||||||
|
highlightStart: tts.progressStart,
|
||||||
|
highlightEnd: tts.progressEnd,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -801,6 +1045,27 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: 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}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,96 +1,240 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../tts/tts_service.dart';
|
import '../tts/tts_service.dart';
|
||||||
|
|
||||||
class TtsPlayerWidget extends ConsumerWidget {
|
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;
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final tts = ref.watch(ttsProvider);
|
final tts = ref.watch(ttsProvider);
|
||||||
final notifier = ref.read(ttsProvider.notifier);
|
final notifier = ref.read(ttsProvider.notifier);
|
||||||
|
|
||||||
|
const speeds = [0.35, 0.45, 0.55, 0.65, 0.8, 1.0];
|
||||||
|
|
||||||
|
Future<void> 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<double>(
|
||||||
|
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(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
Wrap(
|
||||||
icon: const Icon(Icons.skip_previous),
|
spacing: 6,
|
||||||
onPressed: tts.status != TtsStatus.idle ? notifier.skipBack : null,
|
runSpacing: 6,
|
||||||
),
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
// Play/Pause/Stop
|
children: [
|
||||||
if (!tts.isPlaying)
|
IconButton(
|
||||||
IconButton.filled(
|
icon: const Icon(Icons.skip_previous),
|
||||||
icon: const Icon(Icons.play_arrow),
|
onPressed: tts.status != TtsStatus.idle ? notifier.skipBack : null,
|
||||||
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<double>(
|
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
),
|
if (!tts.isPlaying)
|
||||||
if (tts.voiceName != null)
|
IconButton.filled(
|
||||||
Padding(
|
icon: const Icon(Icons.play_arrow),
|
||||||
padding: const EdgeInsets.only(right: 8),
|
onPressed: () => start(),
|
||||||
child: Text(
|
)
|
||||||
tts.voiceName!,
|
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,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
),
|
),
|
||||||
)
|
],
|
||||||
else
|
),
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8),
|
|
||||||
child: Text(
|
|
||||||
tts.language,
|
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,43 +1,123 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
enum TtsStatus { idle, playing, paused }
|
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 {
|
class TtsState {
|
||||||
final TtsStatus status;
|
final TtsStatus status;
|
||||||
final int paragraphIndex;
|
final int paragraphIndex; // current spoken segment index
|
||||||
final int totalParagraphs;
|
final int totalParagraphs; // total spoken segments
|
||||||
|
final int activeParagraphIndex; // paragraph index in chapter content
|
||||||
final double speed;
|
final double speed;
|
||||||
final String language;
|
final String language;
|
||||||
final String? voiceName;
|
final String? voiceName;
|
||||||
|
final List<TtsVoice> availableVietnameseVoices;
|
||||||
|
final int progressStart;
|
||||||
|
final int progressEnd;
|
||||||
|
final String? contentKey;
|
||||||
|
final int completedCount;
|
||||||
|
final bool backgroundModeEnabled;
|
||||||
|
final bool batteryOptimizationIgnored;
|
||||||
|
final String? pendingAutoStartChapterId;
|
||||||
|
|
||||||
const TtsState({
|
const TtsState({
|
||||||
this.status = TtsStatus.idle,
|
this.status = TtsStatus.idle,
|
||||||
this.paragraphIndex = 0,
|
this.paragraphIndex = 0,
|
||||||
this.totalParagraphs = 0,
|
this.totalParagraphs = 0,
|
||||||
this.speed = 1.0,
|
this.activeParagraphIndex = -1,
|
||||||
|
this.speed = 0.45,
|
||||||
this.language = 'vi-VN',
|
this.language = 'vi-VN',
|
||||||
this.voiceName,
|
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({
|
TtsState copyWith({
|
||||||
TtsStatus? status,
|
TtsStatus? status,
|
||||||
int? paragraphIndex,
|
int? paragraphIndex,
|
||||||
int? totalParagraphs,
|
int? totalParagraphs,
|
||||||
|
int? activeParagraphIndex,
|
||||||
double? speed,
|
double? speed,
|
||||||
String? language,
|
String? language,
|
||||||
String? voiceName,
|
String? voiceName,
|
||||||
|
List<TtsVoice>? availableVietnameseVoices,
|
||||||
|
int? progressStart,
|
||||||
|
int? progressEnd,
|
||||||
|
String? contentKey,
|
||||||
|
bool clearContentKey = false,
|
||||||
bool clearVoiceName = false,
|
bool clearVoiceName = false,
|
||||||
|
int? completedCount,
|
||||||
|
bool? backgroundModeEnabled,
|
||||||
|
bool? batteryOptimizationIgnored,
|
||||||
|
String? pendingAutoStartChapterId,
|
||||||
|
bool clearPendingAutoStartChapterId = false,
|
||||||
}) =>
|
}) =>
|
||||||
TtsState(
|
TtsState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
paragraphIndex: paragraphIndex ?? this.paragraphIndex,
|
paragraphIndex: paragraphIndex ?? this.paragraphIndex,
|
||||||
totalParagraphs: totalParagraphs ?? this.totalParagraphs,
|
totalParagraphs: totalParagraphs ?? this.totalParagraphs,
|
||||||
|
activeParagraphIndex: activeParagraphIndex ?? this.activeParagraphIndex,
|
||||||
speed: speed ?? this.speed,
|
speed: speed ?? this.speed,
|
||||||
language: language ?? this.language,
|
language: language ?? this.language,
|
||||||
voiceName: clearVoiceName ? null : (voiceName ?? this.voiceName),
|
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;
|
bool get isPlaying => status == TtsStatus.playing;
|
||||||
@@ -45,7 +125,8 @@ class TtsState {
|
|||||||
|
|
||||||
class TtsNotifier extends StateNotifier<TtsState> {
|
class TtsNotifier extends StateNotifier<TtsState> {
|
||||||
final FlutterTts _tts = FlutterTts();
|
final FlutterTts _tts = FlutterTts();
|
||||||
List<String> _paragraphs = [];
|
static const MethodChannel _backgroundChannel = MethodChannel('reader_app/tts_background');
|
||||||
|
List<_TtsSegment> _segments = [];
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
Future<void>? _initFuture;
|
Future<void>? _initFuture;
|
||||||
|
|
||||||
@@ -74,12 +155,15 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _configureVietnameseVoice();
|
await _configureVietnameseVoice();
|
||||||
await _tts.setSpeechRate(1.0);
|
await _tts.setSpeechRate(kTtsBaseSpeechRate);
|
||||||
await _tts.setVolume(1.0);
|
await _tts.setVolume(1.0);
|
||||||
await _tts.setPitch(1.0);
|
await _tts.setPitch(1.0);
|
||||||
|
|
||||||
_tts.setStartHandler(() {
|
_tts.setStartHandler(() {
|
||||||
state = state.copyWith(status: TtsStatus.playing);
|
state = state.copyWith(
|
||||||
|
status: TtsStatus.playing,
|
||||||
|
);
|
||||||
|
unawaited(_syncBackgroundMode());
|
||||||
});
|
});
|
||||||
|
|
||||||
_tts.setCompletionHandler(() {
|
_tts.setCompletionHandler(() {
|
||||||
@@ -90,8 +174,11 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
|
|
||||||
_tts.setErrorHandler((msg) {
|
_tts.setErrorHandler((msg) {
|
||||||
state = state.copyWith(status: TtsStatus.idle);
|
state = state.copyWith(status: TtsStatus.idle);
|
||||||
|
unawaited(_syncBackgroundMode());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await _syncBackgroundMode();
|
||||||
|
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +187,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
|
|
||||||
String? selectedName;
|
String? selectedName;
|
||||||
String selectedLanguage = 'vi-VN';
|
String selectedLanguage = 'vi-VN';
|
||||||
|
final List<TtsVoice> vietnameseVoices = [];
|
||||||
|
|
||||||
if (voicesRaw is List) {
|
if (voicesRaw is List) {
|
||||||
final vietnamese = voicesRaw.whereType<Map>().where((voice) {
|
final vietnamese = voicesRaw.whereType<Map>().where((voice) {
|
||||||
@@ -107,6 +195,13 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
return locale.startsWith('vi');
|
return locale.startsWith('vi');
|
||||||
}).toList();
|
}).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) {
|
if (vietnamese.isNotEmpty) {
|
||||||
final preferred = vietnamese.firstWhere(
|
final preferred = vietnamese.firstWhere(
|
||||||
(voice) =>
|
(voice) =>
|
||||||
@@ -124,66 +219,226 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
if (selectedName != null) {
|
if (selectedName != null) {
|
||||||
await _tts.setVoice({'name': selectedName, 'locale': selectedLanguage});
|
await _tts.setVoice({'name': selectedName, 'locale': selectedLanguage});
|
||||||
}
|
}
|
||||||
state = state.copyWith(language: selectedLanguage, voiceName: selectedName);
|
state = state.copyWith(
|
||||||
|
language: selectedLanguage,
|
||||||
|
voiceName: selectedName,
|
||||||
|
availableVietnameseVoices: vietnameseVoices,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<void> 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<void> ensureBatteryOptimizationIgnored() async {
|
||||||
|
if (!Platform.isAndroid) return;
|
||||||
|
try {
|
||||||
|
final isIgnored = await _backgroundChannel.invokeMethod<bool>(
|
||||||
|
'isIgnoringBatteryOptimizations',
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
|
||||||
|
state = state.copyWith(batteryOptimizationIgnored: isIgnored);
|
||||||
|
if (isIgnored) return;
|
||||||
|
|
||||||
|
await _backgroundChannel.invokeMethod<void>('requestIgnoreBatteryOptimizations');
|
||||||
|
|
||||||
|
final afterRequest = await _backgroundChannel.invokeMethod<bool>(
|
||||||
|
'isIgnoringBatteryOptimizations',
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
state = state.copyWith(batteryOptimizationIgnored: afterRequest);
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore bridge errors and keep TTS playback functional.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncBackgroundMode() async {
|
||||||
|
if (!Platform.isAndroid) return;
|
||||||
|
|
||||||
|
final shouldKeepAlive =
|
||||||
|
state.backgroundModeEnabled && state.status == TtsStatus.playing;
|
||||||
|
try {
|
||||||
|
await _backgroundChannel
|
||||||
|
.invokeMethod<void>('setWakeLock', {'enabled': shouldKeepAlive});
|
||||||
|
} catch (_) {
|
||||||
|
// Keep playback functional even if native wake lock bridge is unavailable.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start reading from [content] starting at optional [paragraphIndex].
|
/// Start reading from [content] starting at optional [paragraphIndex].
|
||||||
Future<void> startReading(String content, {int paragraphIndex = 0}) async {
|
Future<void> startReading(
|
||||||
|
String content, {
|
||||||
|
int paragraphIndex = 0,
|
||||||
|
int? startParagraphIndex,
|
||||||
|
String? contentKey,
|
||||||
|
String? title,
|
||||||
|
bool includeTitle = true,
|
||||||
|
}) async {
|
||||||
if (!_initialized) {
|
if (!_initialized) {
|
||||||
await (_initFuture ?? _init());
|
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+'))
|
.split(RegExp(r'\n+'))
|
||||||
.map((p) => p.trim())
|
.map((p) => p.trim())
|
||||||
.where((p) => p.isNotEmpty)
|
.where((p) => p.isNotEmpty)
|
||||||
.toList();
|
.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(
|
state = state.copyWith(
|
||||||
status: TtsStatus.playing,
|
status: TtsStatus.playing,
|
||||||
paragraphIndex: validIndex,
|
paragraphIndex: validIndex,
|
||||||
totalParagraphs: _paragraphs.length,
|
totalParagraphs: _segments.length,
|
||||||
|
activeParagraphIndex: selectedSegment.paragraphIndex,
|
||||||
|
progressStart: selectedSegment.start,
|
||||||
|
progressEnd: selectedSegment.end,
|
||||||
|
contentKey: contentKey,
|
||||||
);
|
);
|
||||||
|
await _syncBackgroundMode();
|
||||||
await _speak(validIndex);
|
await _speak(validIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _speak(int index) async {
|
Future<void> _speak(int index) async {
|
||||||
if (index >= _paragraphs.length) {
|
if (index >= _segments.length) {
|
||||||
state = state.copyWith(status: TtsStatus.idle);
|
state = state.copyWith(
|
||||||
|
status: TtsStatus.idle,
|
||||||
|
activeParagraphIndex: -1,
|
||||||
|
progressStart: -1,
|
||||||
|
progressEnd: -1,
|
||||||
|
);
|
||||||
|
await _syncBackgroundMode();
|
||||||
return;
|
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.setSpeechRate(state.speed);
|
||||||
await _tts.speak(_paragraphs[index]);
|
await _tts.speak(segment.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _next() async {
|
Future<void> _next() async {
|
||||||
final next = state.paragraphIndex + 1;
|
final next = state.paragraphIndex + 1;
|
||||||
if (next >= state.totalParagraphs) {
|
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;
|
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);
|
await _speak(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> pause() async {
|
Future<void> pause() async {
|
||||||
await _tts.pause();
|
await _tts.pause();
|
||||||
state = state.copyWith(status: TtsStatus.paused);
|
state = state.copyWith(status: TtsStatus.paused);
|
||||||
|
await _syncBackgroundMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> resume() async {
|
Future<void> resume() async {
|
||||||
if (state.status != TtsStatus.paused) return;
|
if (state.status != TtsStatus.paused) return;
|
||||||
state = state.copyWith(status: TtsStatus.playing);
|
state = state.copyWith(status: TtsStatus.playing);
|
||||||
|
await _syncBackgroundMode();
|
||||||
// Use paragraph-level resume for consistent behavior across engines.
|
// Use paragraph-level resume for consistent behavior across engines.
|
||||||
await _speak(state.paragraphIndex);
|
await _speak(state.paragraphIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
await _tts.stop();
|
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<void> skipForward() async {
|
Future<void> skipForward() async {
|
||||||
@@ -195,7 +450,12 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
await _tts.stop();
|
await _tts.stop();
|
||||||
if (state.totalParagraphs <= 0) return;
|
if (state.totalParagraphs <= 0) return;
|
||||||
final prev = (state.paragraphIndex - 1).clamp(0, state.totalParagraphs - 1);
|
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);
|
if (state.status == TtsStatus.playing) await _speak(prev);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +466,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
unawaited(_backgroundChannel.invokeMethod<void>('setWakeLock', {'enabled': false}));
|
||||||
_tts.stop();
|
_tts.stop();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-14
@@ -8,22 +8,22 @@ import 'app/app.dart';
|
|||||||
import 'core/logging/app_provider_observer.dart';
|
import 'core/logging/app_provider_observer.dart';
|
||||||
|
|
||||||
void main() {
|
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(
|
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(
|
runApp(
|
||||||
const ProviderScope(
|
const ProviderScope(
|
||||||
observers: [AppProviderObserver()],
|
observers: [AppProviderObserver()],
|
||||||
|
|||||||
Reference in New Issue
Block a user