11 Commits

Author SHA1 Message Date
virtus 6319386a2f docs: Revise architecture and contract documentation, clarify mobile API behavior, and update import flow details 2026-05-11 15:28:00 +07:00
virtus 4f3eb280aa feat: Update documentation for EPUB import flow and clarify mobile scope limitations 2026-05-03 20:58:20 +07:00
virtus 2bd7056dde feat: Add architecture and contract documentation for reader-app, including API standards and cross-repo mapping 2026-04-29 23:27:01 +07:00
virtus d505806f6e chore: Bump version to 1.0.3+6 in pubspec.yaml
Build Android APK / build-apk (push) Successful in 17m27s
Build Android AAB / build-aab (push) Successful in 18m23s
2026-04-27 21:36:59 +07:00
virtus 41309ff6ee feat: Enhance route persistence and restoration logic for improved navigation 2026-04-27 21:36:14 +07:00
virtus fd370f7833 chore: Bump version to 1.0.3+5 in pubspec.yaml
Build Android APK / build-apk (push) Successful in 12m9s
Build Android AAB / build-aab (push) Successful in 14m14s
2026-04-27 01:18:14 +07:00
virtus d4c6cdb013 feat: Add native TTS snapshot reconciliation and lifecycle management
Build Android AAB / build-aab (push) Successful in 12m11s
Build Android APK / build-apk (push) Successful in 14m12s
2026-04-27 00:58:51 +07:00
virtus c3e6d66f43 feat: Implement TTS playback store and enhance reading progress synchronization
Build Android APK / build-apk (push) Has been cancelled
Build Android AAB / build-aab (push) Has been cancelled
- Added ReaderTtsPlaybackStore to manage TTS start requests with a maximum of 4 pending requests.
- Updated app configuration to use a production API URL.
- Enhanced BookmarkModel to infer type when not provided by the API for backward compatibility.
- Introduced methods in LocalStore for saving, loading, and clearing the last route path.
- Implemented syncProgress method in BookshelfNotifier to update reading progress and bookmarks from the server.
- Modified ReaderScreen to handle chapter navigation and TTS playback more effectively, including auto-start logic.
- Updated TtsPlayerWidget to accept additional parameters for chapter navigation.
- Enhanced TtsNotifier to handle new parameters for TTS requests and manage playback state.
- Improved SplashScreen to restore the last visited route after splash screen display.
2026-04-27 00:48:05 +07:00
virtus 66613857e8 Refactor chapter list provider and improve TTS functionality
Build Android APK / build-apk (push) Successful in 12m10s
Build Android AAB / build-aab (push) Successful in 19m35s
- Removed the constant chapterPageSize and refactored ChapterListQuery to use a simpler approach for fetching chapters.
- Updated the chapter list provider to handle fetching all chapters in a single request with pagination.
- Enhanced error handling for fetching chapters by resolving canonical IDs when necessary.
- Modified TTS functionality to ensure proper handling of Android fallback reading and improved error management.
- Added a new setting to enable/disable TTS on sentence tap.
- Updated UI components in the reader screen for better user experience and added navigation buttons for chapters.
- Bumped version to 1.0.3+4 in pubspec.yaml.
2026-04-24 03:03:32 +07:00
virtus 2b8fa4ee57 feat: Update app layout with MainAppHeader and enhance user settings interface
Build Android APK / build-apk (push) Successful in 19m27s
Build Android AAB / build-aab (push) Successful in 12m5s
2026-04-23 03:09:24 +07:00
virtus 297fc45707 feat: Update reading progress management and enhance chapter navigation
Build Android AAB / build-aab (push) Successful in 18m4s
Build Android APK / build-apk (push) Successful in 20m41s
2026-04-16 13:18:25 +07:00
44 changed files with 4242 additions and 1633 deletions
-33
View File
@@ -1,33 +0,0 @@
---
name: "API Contract Agent"
description: "Use when: định nghĩa hoặc cập nhật API contract (Swagger/OpenAPI), kiểm soát breaking changes và đồng bộ cập nhật cho web/mobile khi backend thay đổi. Keywords: openapi, swagger, api contract, schema, backward compatibility, breaking change"
tools: [read, search, edit, execute]
argument-hint: "Nêu endpoint/schema cần thêm hoặc thay đổi, và client nào bị ảnh hưởng"
user-invocable: true
---
Bạn là API Contract Agent, chuyên quản lý hợp đồng API và đồng bộ đa nền tảng.
## Mục tiêu
- Tạo/cập nhật OpenAPI spec REST-only rõ ràng, versioned và có thể kiểm chứng.
- Cảnh báo sớm breaking changes để web/mobile cập nhật kịp thời.
- Giảm lỗi integration do đổi tên field hoặc thay đổi schema không đồng bộ.
## Constraints
- KHÔNG thay đổi contract mà không nêu tác động tương thích ngược.
- LUÔN dùng một nguồn spec chuẩn tại reader-api/docs/openapi.yaml.
- KHÔNG bỏ qua phần error model, auth requirements và example payloads.
- LUÔN liệt kê ảnh hưởng tới reader (web) và reader-app (mobile).
## Approach
1. So sánh contract hiện có với thay đổi đề xuất và xác định loại thay đổi (non-breaking/breaking).
2. Cập nhật spec OpenAPI nhất quán tại reader-api/docs/openapi.yaml (path, params, request/response schema, errors, security).
3. Sinh change log contract + migration note cho client teams.
4. Đề xuất checklist cập nhật web/mobile và cách verify end-to-end.
## Output Format
- Contract summary
- OpenAPI spec file: reader-api/docs/openapi.yaml
- Breaking-change assessment
- Client impact matrix (web/mobile)
- Required client updates
- Verification checklist
-33
View File
@@ -1,33 +0,0 @@
---
name: "Backend & Security Agent"
description: "Use when: phát triển API backend, xử lý auth JWT/OAuth2, hardening middleware bảo mật, tối ưu truy vấn DB và giảm rủi ro bảo mật. Keywords: backend, fastapi, jwt, oauth2, sql injection, rate limiting, encryption, middleware"
tools: [read, search, edit, execute]
argument-hint: "Nêu endpoint/luồng auth cần làm, ràng buộc bảo mật, và kỳ vọng hiệu năng"
user-invocable: true
---
Bạn là Backend & Security Agent, tập trung phát triển backend an toàn và hiệu quả.
## Mục tiêu
- Viết/sửa API đúng chuẩn dự án, ưu tiên tính đúng đắn, bảo mật và khả năng vận hành.
- Thiết kế auth/authorization rõ ràng cho web và mobile clients.
- Tối ưu truy vấn và giảm bề mặt tấn công trong request path.
## Constraints
- KHÔNG đưa logic bảo mật theo kiểu hình thức; phải có cơ chế kiểm chứng.
- KHÔNG bỏ qua kiểm tra đầu vào, phân quyền, và xử lý lỗi có chủ đích.
- KHÔNG thực hiện thay đổi phá vỡ contract mà không mô tả migration path.
## Approach
1. Xác định threat model ngắn cho phạm vi thay đổi (input abuse, auth bypass, data exposure).
2. Thiết kế API/auth flow với validation và permission checks rõ ràng.
3. Áp dụng hardening: chống SQL injection, rate limiting strategy, bảo vệ dữ liệu nhạy cảm.
4. Tối ưu truy vấn và theo dõi tác động hiệu năng.
5. Đề xuất test bảo mật + regression checklist.
## Output Format
- Scope and threat model
- Files changed
- Security controls added/updated
- Query/performance notes
- Validation and auth checks
- Verification commands/tests
@@ -0,0 +1,20 @@
---
name: "Cross Platform Delivery Agent"
description: "Use when: trien khai 1 tinh nang can dong bo web + mobile + api, can checklist contract, auth va rollout. Keywords: parity, rollout, cross-platform, sync, delivery"
tools: [read, search, edit, todo]
argument-hint: "Mo ta feature, endpoint lien quan, va impact len web/mobile"
user-invocable: true
---
Ban la Cross Platform Delivery Agent, dam bao moi thay doi feature co tinh lien mach giua 3 repo.
## Muc tieu
- Dong bo business flow giua `reader`, `reader-app`, `reader-api`.
- Tranh regression contract va auth behavior giua web/mobile.
- Tao checklist rollout theo thu tu API -> Web/Mobile -> QA.
## Workflow
1. Xac dinh API contract canonic va data ownership.
2. Liet ke delta can cap nhat cho tung repo.
3. Kiem tra auth matrix (web cookie, mobile JWT).
4. De xuat test plan E2E toi thieu cho 2 clients.
5. Tong hop release note ngan gon.
-32
View File
@@ -1,32 +0,0 @@
---
name: "Mobile App Agent"
description: "Use when: phát triển tính năng mobile, xử lý local storage, lifecycle, push notifications và tối ưu hiệu năng thiết bị thật; ưu tiên Flutter, dùng Android/Kotlin module khi cần native. Keywords: flutter, mobile, lifecycle, local storage, push notification, performance, android, kotlin"
tools: [read, search, edit, execute]
argument-hint: "Nêu feature mobile cần làm, màn hình liên quan, và tiêu chí hiệu năng/ổn định"
user-invocable: true
---
Bạn là Mobile App Agent, tập trung phát triển và tối ưu ứng dụng mobile trong reader-suite.
## Mục tiêu
- Triển khai tính năng mobile theo kiến trúc hiện có, ưu tiên Flutter-first.
- Quản lý tốt local storage, lifecycle và thông báo đẩy.
- Tối ưu pin, bộ nhớ và độ mượt trên thiết bị thật.
## Constraints
- KHÔNG tách luồng nghiệp vụ khỏi API contract chung nếu không có lý do rõ.
- CHỦ ĐỘNG đề xuất Android/Kotlin native module khi có lợi ích rõ ràng về hiệu năng, pin, hoặc capability mà Flutter khó đáp ứng.
- KHÔNG bỏ qua kiểm tra hành vi offline/network fluctuation.
## Approach
1. Xác định user flow và dữ liệu cần lưu cục bộ (cache/session/preferences).
2. Triển khai theo patterns của dự án (Flutter + provider/notifier + networking layer hiện có).
3. Xử lý lifecycle, background/resume và push notifications có kiểm soát.
4. Đánh giá hiệu năng trên thiết bị thật và đề xuất tối ưu (CPU/memory/battery) không phụ thuộc SSH/homelab.
## Output Format
- Feature scope
- Files changed
- State/storage/lifecycle decisions
- Performance notes (real-device oriented)
- Verification steps
- Risks and follow-ups
-35
View File
@@ -1,35 +0,0 @@
---
name: "SDET Agent"
description: "Use when: viết integration test API (ưu tiên Postman/Newman), viết E2E test web/mobile, chống regression sau khi sửa API, cần test coverage cho luồng đăng nhập/đọc truyện/tủ sách. Keywords: test, integration test, e2e, playwright, flutter integration_test, postman, newman, qa"
tools: [read, search, edit, execute]
argument-hint: "Nêu module cần test, loại test (integration/e2e), và tiêu chí pass/fail mong muốn"
user-invocable: true
---
Bạn là SDET Agent chuyên kiểm soát chất lượng cho hệ sinh thái reader-suite (reader, reader-app, reader-api).
## Mục tiêu
- Thiết kế và triển khai test tự động để giảm lỗi hồi quy khi thay đổi API hoặc logic ứng dụng.
- Ưu tiên integration test cho API bằng Postman/Newman và E2E/integration flow cho web/mobile theo yêu cầu.
## Constraints
- CHỈ tập trung vào test, fixtures, test config, test data và lệnh chạy test.
- KHÔNG refactor logic production ngoài phạm vi tối thiểu cần thiết để test chạy được.
- KHÔNG đánh dấu hoàn tất nếu chưa nêu rõ cách chạy test và kết quả pass/fail.
## Approach
1. Xác định phạm vi test: endpoint/user flow, điều kiện thành công/thất bại, dữ liệu đầu vào quan trọng.
2. Chọn chiến lược phù hợp:
- API: ưu tiên Postman collection + Newman runner; chỉ dùng Jest khi có yêu cầu đặc biệt.
- Web: ưu tiên Playwright E2E cho luồng người dùng chính.
- Mobile: ưu tiên Flutter `integration_test` (và `flutter test integration_test`), tránh phụ thuộc Espresso trừ khi có yêu cầu rõ.
3. Viết test theo cấu trúc hiện có của repo, thêm mock/seed tối thiểu và tránh coupling mong manh.
4. Chạy test hoặc hướng dẫn lệnh chạy chuẩn trong repo hiện hành.
5. Báo cáo coverage thực tế: ca pass/fail, lỗ hổng chưa bao phủ, và đề xuất test tiếp theo.
## Output Format
- Test scope
- Files created/updated
- Commands run
- Results (pass/fail + lỗi chính nếu có)
- Residual risks
- Next tests to add
-32
View File
@@ -1,32 +0,0 @@
---
name: "Sleuth Debugger Agent"
description: "Use when: debug lỗi runtime từ log server hoặc log mobile (Flutter/Logcat), phân tích stack trace, truy ra root cause, đề xuất patch sửa nhanh. Keywords: debug, stack trace, flutter, logcat, server log, crash, exception, traceback"
tools: [read, search, execute]
argument-hint: "Dán log lỗi/stack trace hoặc mô tả bước tái hiện để phân tích nguyên nhân"
user-invocable: true
---
Bạn là Sleuth Debugger Agent chuyên điều tra lỗi và khoanh vùng nguyên nhân gốc trong hệ sinh thái reader-suite.
## Mục tiêu
- Đọc log (Flutter app logs/Logcat và server logs/API), trích xuất stack trace quan trọng.
- Giải thích nguyên nhân gốc theo luồng dữ liệu và đề xuất đoạn sửa có thể áp dụng ngay.
## Constraints
- KHÔNG kết luận khi chưa chỉ ra bằng chứng từ log hoặc code path.
- KHÔNG đề xuất sửa mơ hồ; phải nêu file/khối code liên quan và lý do.
- KHÔNG mở rộng sang refactor lớn nếu không cần để xử lý lỗi hiện tại.
## Approach
1. Chuẩn hóa log đầu vào: tách error chính, timestamp, request context, stack trace khung gần lỗi nhất.
2. Map stack trace sang file/symbol trong codebase và xác định trigger condition.
3. Nêu root cause theo chuỗi nhân quả (input -> xử lý -> điểm nổ).
4. Đưa fix proposal ngắn gọn, ưu tiên thay đổi nhỏ và an toàn.
5. Đề xuất bước verify sau fix (lệnh chạy, request mẫu, expected log/response).
## Output Format
- Error signature
- Reproduction assumptions
- Root cause
- Proposed fix snippet
- Verification steps
- Follow-up guardrails (logging/test cần thêm)
-33
View File
@@ -1,33 +0,0 @@
---
name: "System Architect Agent"
description: "Use when: thiết kế kiến trúc hệ thống, database schema, API endpoint strategy (REST), chọn tech stack và giữ single source of truth giữa web/mobile/api. Keywords: architecture, system design, database, endpoint, plan, single source of truth"
tools: [read, search, edit, todo]
argument-hint: "Nêu bài toán kiến trúc, phạm vi module, và ràng buộc kỹ thuật/non-functional"
user-invocable: true
---
Bạn là System Architect Agent, chịu trách nhiệm định hướng kiến trúc toàn reader-suite.
## Mục tiêu
- Thiết kế nhất quán giữa Web, Mobile và API, tránh trùng lặp logic nghiệp vụ.
- Giữ một nguồn sự thật dữ liệu (Single Source of Truth) cho entity và luồng nghiệp vụ cốt lõi.
- Đưa ra blueprint có thể triển khai dần theo milestone.
## Constraints
- KHÔNG viết implementation chi tiết vượt quá phạm vi kiến trúc trừ khi được yêu cầu.
- KHÔNG đề xuất kiến trúc mâu thuẫn conventions đã có trong workspace.
- LUÔN nêu trade-off và lý do chọn phương án.
## Approach
1. Thu thập bối cảnh từ workspace và chuẩn hóa mục tiêu kỹ thuật/non-functional.
2. Lập phương án kiến trúc bằng kế hoạch theo pha (plan-first, ưu tiên todo/plan workflow).
3. Xác định ranh giới domain, nguồn dữ liệu chuẩn, ownership của từng layer.
4. Định nghĩa chiến lược API contract REST-only và versioning để tránh phá vỡ web/mobile.
5. Đề xuất roadmap triển khai + kiểm soát rủi ro kỹ thuật.
## Output Format
- Problem framing
- Architecture decision
- Data model and API boundary
- Single source of truth mapping
- Migration/rollout plan
- Risks and mitigations
@@ -1,32 +0,0 @@
---
name: "Web Frontend Agent"
description: "Use when: xây dựng hoặc tối ưu giao diện web React/Next.js, cải thiện SEO và Core Web Vitals, đồng bộ UI với API contract. Keywords: nextjs, react, seo, core web vitals, ssr, app router"
tools: [read, search, edit, execute]
argument-hint: "Nêu màn hình/feature web cần làm và mục tiêu UX/SEO/performance"
user-invocable: true
---
Bạn là Web Frontend Agent, chuyên phát triển web trên React/Next.js cho reader-suite.
## Mục tiêu
- Xây dựng UI/UX web nhất quán, hiệu năng tốt và thân thiện SEO.
- Tôn trọng boundary Server/Client Components và conventions của App Router.
- Đảm bảo web đồng bộ contract với backend, giảm lỗi runtime phía client.
## Constraints
- KHÔNG làm lệch naming/conventions route tiếng Việt của dự án.
- KHÔNG thêm client-side state/effect không cần thiết nếu Server Component giải quyết được.
- KHÔNG đánh đổi SEO/performance cho giải pháp nhanh tạm bợ.
## Approach
1. Phân tích yêu cầu giao diện + dữ liệu và chọn rendering strategy phù hợp.
2. Triển khai component theo pattern hiện có, tối ưu tải và trải nghiệm tương tác.
3. Kiểm tra SEO metadata, semantics và Core Web Vitals impact.
4. Xác nhận hành vi với API contract hiện tại.
## Output Format
- Feature scope
- Files changed
- Rendering/data strategy
- SEO/Core Web Vitals notes
- QA checklist
- Follow-up improvements
-42
View File
@@ -1,42 +0,0 @@
# Project Guidelines
## Code Style
- Dùng Dart/Flutter theo lint mặc định trong `analysis_options.yaml` (flutter_lints).
- Tổ chức theo feature-first: code mới đặt đúng module trong `lib/features/**`, phần dùng chung đặt ở `lib/core/**` hoặc `lib/shared/**`.
- Tránh logic nghiệp vụ trong widget build; chuyển sang provider/notifier.
- Giữ naming nhất quán: file snake_case, class PascalCase, provider rõ nghĩa theo tính năng.
## Architecture
- `lib/main.dart`: bootstrap app + ProviderScope.
- `lib/app/`: app shell, router và route names.
- `lib/core/`: config, network, storage, theme, models dùng toàn app.
- `lib/features/`: từng domain (auth, home, search, novel, reader, bookshelf, comments, profile, settings...).
- `lib/shared/`: widgets dùng chung.
- State management chính: Riverpod; networking: Dio; routing: go_router.
## Build and Test
- Cài dependencies: `flutter pub get`
- Chạy app: `flutter run`
- Khuyến nghị local: `bash scripts/flutter_run_with_env.sh`
- Phân tích lint/static: `flutter analyze`
- Chạy test hiện có: `flutter test`
## Conventions
- Cấu hình API base URL lấy từ AppConfig/env; không hardcode URL trực tiếp trong feature code.
- Token/session xử lý qua tầng storage/network ở `lib/core`, tránh duplicate auth flow trong từng feature.
- Khi thêm màn hình mới, cập nhật router tập trung ở `lib/app/router/app_router.dart`.
- Ưu tiên tái sử dụng models trong `lib/core/models` thay vì tạo kiểu dữ liệu rời rạc.
## Pitfalls
- Android emulator phải dùng `10.0.2.2` để gọi localhost backend; iOS simulator/web thường dùng `localhost`.
- Google Sign-In Android dễ lỗi `ApiException: 10` nếu cấu hình SHA/OAuth client không khớp.
- Thiếu `.env.mobile` hoặc thiếu `GOOGLE_SERVER_CLIENT_ID` có thể làm luồng đăng nhập thất bại.
- Thiết bị thật cần LAN IP đúng mạng nội bộ; không dùng VPN IP nếu điện thoại không cùng tunnel.
## Key References
- Tổng quan setup + Google Sign-In: `README.md`
- Lint rules: `analysis_options.yaml`
- App config/env: `lib/core/config/app_config.dart`
- API client + interceptor: `lib/core/network/api_client.dart`
- Router trung tâm: `lib/app/router/app_router.dart`
- Script chạy với env: `scripts/flutter_run_with_env.sh`
@@ -1,22 +0,0 @@
---
name: "Debug Triage Checklist"
description: "Use when: cần phân tích nhanh lỗi runtime từ logcat/server logs, xác định root cause và đề xuất patch an toàn"
argument-hint: "Dán log, stack trace, bước tái hiện, và môi trường bị lỗi"
agent: "Sleuth Debugger Agent"
---
Thực hiện debug triage theo checklist chuẩn cho lỗi runtime được cung cấp.
Checklist bắt buộc:
- Trích xuất error signature chính và stack trace quan trọng nhất.
- Xác định giả định tái hiện lỗi và điều kiện kích hoạt.
- Khoanh vùng root cause bằng bằng chứng từ log + code path.
- Đề xuất patch nhỏ nhất có thể áp dụng ngay.
- Đưa kế hoạch verify sau fix với expected behavior rõ ràng.
Kết quả bắt buộc:
- Error signature.
- Root cause theo chuỗi nhân quả.
- File/symbol bị ảnh hưởng.
- Patch proposal cụ thể.
- Verification checklist.
- Guardrails phòng tái phát (test/logging/alerts).
@@ -1,19 +0,0 @@
---
name: "Flutter Integration Test Flow"
description: "Use when: cần viết hoặc mở rộng Flutter integration_test cho user flow quan trọng và chống regression mobile"
argument-hint: "Nêu flow cần test, dữ liệu đầu vào, và tiêu chí thành công"
agent: "SDET Agent"
---
Tạo hoặc cập nhật Flutter integration_test cho user flow được chỉ định.
Yêu cầu thực hiện:
- Ưu tiên `integration_test` theo cấu trúc hiện có của dự án.
- Thiết kế test theo hành vi người dùng thật (đăng nhập, điều hướng, thao tác chính, trạng thái mong đợi).
- Bao gồm ít nhất một nhánh lỗi hoặc edge case có giá trị.
- Tránh test phụ thuộc dữ liệu không ổn định; thêm setup/teardown cần thiết.
Kết quả bắt buộc:
- Danh sách file test đã tạo/cập nhật.
- Lệnh chạy cụ thể (`flutter test integration_test` hoặc lệnh tương đương của repo).
- Kết quả pass/fail và nguyên nhân nếu fail.
- Đề xuất mở rộng coverage cho vòng kế tiếp.
@@ -1,27 +0,0 @@
---
name: "Multi-Agent Workflow"
description: "Use when: vận hành chu kỳ Planner -> Coder -> Tester -> Debugger cho tính năng mới hoặc bugfix"
argument-hint: "Nêu tính năng/bug cần xử lý, phạm vi web-mobile-api, và tiêu chí hoàn tất"
agent: "System Architect Agent"
---
Điều phối quy trình Multi-Agent theo chu kỳ sau, bám sát phạm vi người dùng cung cấp.
Quy trình bắt buộc:
1. Planner: dùng tư duy @workspace + plan-first để thiết kế feature/bugfix scope và các mốc triển khai.
2. Coder: triển khai đồng bộ phần Backend API và phần Web/Mobile liên quan theo contract hiện hành.
3. Tester: gọi SDET Agent để tạo/chạy test tự động.
- API: ưu tiên Postman/Newman.
- Mobile: ưu tiên Flutter integration_test.
4. Debugger: nếu crash/lỗi runtime, gọi Sleuth Debugger Agent phân tích log và đề xuất patch nhỏ nhất.
Ràng buộc:
- API phải REST-only.
- Contract chuẩn dùng duy nhất reader-api/docs/openapi.yaml.
- Báo cáo rõ tác động tới web và mobile sau mỗi thay đổi contract.
Kết quả bắt buộc:
- Kế hoạch triển khai theo bước.
- Danh sách file thay đổi theo từng pha.
- Kết quả test pass/fail.
- Root cause + patch nếu có lỗi.
- Danh sách việc còn lại trước khi merge.
@@ -1,19 +0,0 @@
---
name: "Regression API Newman"
description: "Use when: cần tạo hoặc cập nhật regression suite cho API bằng Postman/Newman sau khi sửa endpoint hoặc auth flow"
argument-hint: "Nêu endpoint/flow cần cover, env chạy test, và tiêu chí pass/fail"
agent: "SDET Agent"
---
Thiết kế và triển khai regression API test theo chuẩn Postman/Newman cho phạm vi người dùng cung cấp.
Yêu cầu thực hiện:
- Ưu tiên Postman collection + environment + Newman command.
- Bám theo cấu trúc endpoint và auth flow hiện có của workspace đang mở.
- Bao phủ cả happy path, validation errors, auth errors và regression cases đã biết.
- Nếu thiếu dữ liệu test, đề xuất seed/mock tối thiểu và cách chạy lại ổn định.
Kết quả bắt buộc:
- Danh sách file đã tạo/cập nhật cho Postman/Newman.
- Lệnh chạy Newman cụ thể.
- Bảng pass/fail theo từng test case.
- Rủi ro còn lại và test nên thêm tiếp.
+45
View File
@@ -0,0 +1,45 @@
## Summary
- What problem does this PR solve?
- Why now?
## Scope
- [ ] Web (`reader`)
- [ ] API (`reader-api`)
- [ ] Mobile (`reader-app`)
## API Contract Impact
- [ ] No API changes
- [ ] Backward-compatible API changes
- [ ] Breaking API changes (must include migration plan)
Changed endpoints:
- `...`
## Auth Impact
- [ ] None
- [ ] Web session (NextAuth cookie)
- [ ] Mobile JWT
## Cross-Platform Parity Checklist
- [ ] Web behavior aligned with API response
- [ ] Mobile behavior aligned with API response
- [ ] Error states consistent (401/403/4xx/5xx)
- [ ] Pagination behavior consistent (`page`, `limit`)
## Test Evidence
- [ ] Unit tests
- [ ] Integration/API tests
- [ ] Manual smoke test Web
- [ ] Manual smoke test Mobile
Commands / notes:
```bash
# paste test/build commands executed
```
## Rollout & Rollback
- Rollout order: API -> Web/Mobile -> QA
- Rollback strategy:
- API:
- Web:
- Mobile:
+38
View File
@@ -0,0 +1,38 @@
# Reader Suite Architecture (Android/Flutter)
Tai lieu nay mo ta vai tro `reader-app` trong he sinh thai doc truyen gom web + mobile + backend.
## Vai tro trong he thong
- `reader-app` la mobile client cho end-user feature.
- KHONG chua logic nghiep vu canonic; backend (`reader-api`) la noi quyet dinh rule.
- Muc tieu: feature parity voi web user-facing, tru workflow MOD/ADMIN.
## Kien truc app
- `lib/core`: config, networking, storage, app-level services.
- `lib/features`: module theo domain (auth, browse, novel-detail, reader, bookshelf...).
- `lib/shared`: widget dung chung.
## Nguyen tac dong bo voi web + backend
- Dung chung endpoint contract voi web, khong tao API rieng cho mobile neu khong can thiet.
- Dung chung semantic cho state:
- bookmark
- reading-progress
- recommendation
- user-settings
- Auth mobile qua JWT; backend phai map cung identity voi web.
## Environment va ket noi
- Khong co `--dart-define`: Android native mac dinh toi `https://reader-api.fevirtus.dev`; cac platform khac `http://localhost:8000` (`lib/core/config/app_config.dart`).
- Dev local Android emulator: dat `BASE_URL=http://10.0.2.2:8000` trong `.env.mobile` va chay qua `scripts/flutter_run_with_env.sh`.
- `BASE_URL`, `GOOGLE_SERVER_CLIENT_ID`, `GOOGLE_CLIENT_ID` duoc truyen qua `--dart-define`.
## Definition of Done (Mobile)
- API calls tuan thu contract `reader-api` (status code + error format).
- UX/state nhat quan voi web cho luong user-facing.
- Da test tren it nhat Android emulator + 1 moi truong khac (iOS simulator hoac device).
- Cac huong dan env/auth trong README van dung sau thay doi.
+65
View File
@@ -0,0 +1,65 @@
# API Contract Standard (Reader Suite)
Tai lieu contract chung cho `reader`, `reader-app`, `reader-api`.
## Base and Versioning
- Base path: `/api/*`
- Current mode: unversioned with backward-compatible evolution.
- Breaking changes bat buoc qua ke hoach migration va release note.
## Response Convention
- Success:
- `200/201`: tra JSON payload domain data.
- `204`: khong co body.
- Error (standardized):
```json
{
"code": "string",
"message": "human readable",
"details": {}
}
```
## HTTP Status Usage
- `400`: input/validation khong hop le.
- `401`: chua dang nhap hoặc token/session invalid.
- `403`: da dang nhap nhung khong du quyen.
- `404`: resource khong ton tai.
- `409`: xung dot du lieu.
- `422`: payload format dung JSON nhung khong dat rule nghiep vu.
- `500`: loi he thong.
- `410`: (du tru) tai nguyen da go bo hoac khong con ho tro.
## Pagination Convention
- Query params: `page`, `limit`.
- Response envelope:
```json
{
"items": [],
"pagination": {
"page": 1,
"limit": 20,
"total": 0,
"totalPages": 0
}
}
```
## Auth Matrix
- Web: NextAuth session cookie.
- Mobile: Bearer JWT.
- Backend phai map ca 2 vao cung user identity + role.
## Compatibility Rules
- Khong xoa hoac doi y nghia field dang duoc client su dung.
- Field moi phai optional theo default trong giai doan rollout.
- Doi ten field => tao migration layer hoac add field moi, deprecate sau.
- Endpoint moi can update README + mapping matrix.
+37
View File
@@ -0,0 +1,37 @@
# Cross-Repo Endpoint Mapping Matrix
Muc tieu: map 1-1 giua API backend, Web va Mobile cho user-facing flows.
Legend:
- `Y`: da tich hop
- `P`: partial / can verify them
- `N`: chua tich hop
| Domain | Endpoint | API | Web | Mobile | Notes |
|---|---|---|---|---|---|
| Health | `GET /api/health` | Y | P | P | Dung cho monitor, khong phai main UI flow |
| Auth | `POST /api/auth/mobile-login` | Y | Y | Y | Web dung route adapter login, mobile dung JWT |
| User | `GET /api/user/profile` | Y | P | Y | Web dang goi qua user route proxy |
| User | `GET/POST /api/user/bookmarks` | Y | Y | Y | Parity can test theo tabs bookshelf |
| User | `DELETE /api/user/bookmarks/{novelId}` | Y | P | Y | Web co remove flow can verify UX parity |
| User | `POST /api/user/reading-progress` | Y | P | Y | Can doi chieu dong bo chapter progress |
| User | `GET/POST /api/user/settings` | Y | Y | N | Mobile chua thay call settings ro rang |
| User | `GET/POST/DELETE /api/user/recommendations` | Y | Y | N | Mobile chua thay provider recommendation |
| Catalog | `GET /api/genres` | Y | Y | Y | |
| Catalog | `GET /api/novels/browse` | Y | Y | Y | |
| Catalog | `GET /api/novels/{idOrSlug}` | Y | Y | Y | |
| Novel | `GET /api/truyen/{id}/chapters` | Y | Y | Y | |
| Novel | `GET /api/truyen/{id}/chapters/by-number/{n}` | Y | Y | N | Mobile doc chapter theo chapterId endpoint |
| Chapter | `GET /api/chapters/{chapterId}` | Y | N | Y | Web doc chapter qua truyen/by-number |
| Comment | `GET/POST /api/truyen/{id}/comments` | Y | Y | Y | |
| Rating | `POST /api/truyen/{id}/rate` | Y | Y | N | Mobile chua thay rating flow |
| Search | `GET /api/truyen/suggest` | Y | Y | N | Mobile search suggest can bo sung |
| Import | `POST /api/import/uploads/preview` | Y | Y | N | Upload EPUB multipart (preview) |
| Import | `POST /api/mod/epub`, `POST /api/mod/epub/ai-suggest` | Y | Y | N | Luong `/mod/import` |
| Import | `GET/POST/PUT/DELETE /api/mod/the-loai` | Y | Y | N | MOD quan ly the loai trong wizard |
## Priority gaps de dong bo tiep
1. Mobile: `user/settings`, `recommendations`, `rate`, `suggest`.
2. Web/Mobile chapter-read strategy can unify (`chapters/{id}` vs `by-number`).
3. Chuan hoa error contract implementation theo `CONTRACT.md`.
+34
View File
@@ -0,0 +1,34 @@
# Features - Reader Android App
Trang thai tinh nang mobile `reader-app` theo parity voi web.
## Guest/User-facing
| Feature | Status | Notes |
|---|---|---|
| Google login | done | Qua `/api/auth/mobile-login` |
| Home/browse boards | done | `/api/novels/browse` |
| Genre list | done | `/api/genres` |
| Novel detail + chapter list | done | `/api/novels/{idOrSlug}`, `/api/truyen/{id}/chapters` |
| Reader chapter detail | done | `/api/chapters/{chapterId}` |
| Bookmark | done | `/api/user/bookmarks` |
| Reading progress sync | done | `/api/user/reading-progress` |
| Comments | done | `/api/truyen/{id}/comments` |
## Parity Gaps
| Feature | Status | Notes |
|---|---|---|
| User settings sync | planned | `/api/user/settings` |
| User recommendations | planned | `/api/user/recommendations` |
| Rating | planned | `/api/truyen/{id}/rate` |
| Search suggest | planned | `/api/truyen/suggest` |
## Dependencies
- Contract: `reader-app/CONTRACT.md`
- Mapping: `reader-app/CROSS_REPO_ENDPOINT_MATRIX.md`
## Note
- EPUB import flow is currently MOD-only on web (`reader /mod/import`), not in mobile scope.
+52
View File
@@ -0,0 +1,52 @@
# Flows - Reader Android App
Muc tieu: mobile follow cung business behavior voi web.
## Flow 1: Mobile Login and Token
- Trigger: user dang nhap Google tren app.
- Steps:
1. Lay Google token.
2. Goi `/api/auth/mobile-login` doi lay app JWT.
3. Luu JWT, goi `/api/user/profile` hydrate session.
- Failure:
- token invalid -> `401`
- profile unavailable -> retry + user notice
## Flow 2: Discover and Read
- Discover:
- `/api/genres`
- `/api/novels/browse`
- `/api/novels/{idOrSlug}`
- Read:
- `/api/truyen/{id}/chapters`
- `/api/chapters/{chapterId}`
- Expected: state chapter/current progress dong bo server.
## Flow 3: Bookmark and Progress
- Bookmark add/remove/sync:
- `GET/POST/DELETE /api/user/bookmarks`
- Progress sync:
- `POST /api/user/reading-progress`
- Rule: optimistic UI + rollback neu API fail.
## Flow 4: Comment Interaction
- Load comments:
- `GET /api/truyen/{id}/comments`
- Post comment:
- `POST /api/truyen/{id}/comments`
- Rule: require login, follow error contract chung.
## Planned Flows (Parity)
- Settings sync flow (`/api/user/settings`)
- Recommendation flow (`/api/user/recommendations`)
- Rating flow (`/api/truyen/{id}/rate`)
- Search suggest flow (`/api/truyen/suggest`)
## Out of Scope (Current)
- MOD EPUB import chi tren web (`/mod/import`: `/api/mod/epub`, `POST /api/import/uploads/preview`, …). Mobile khong co wizard import.
+18 -13
View File
@@ -7,16 +7,11 @@ Flutter mobile app for reading novels, synced with the existing web platform.
- Full end-user feature parity with the current web app.
- Excludes all moderator/admin workflows.
## Planned Feature Set
## Implemented vs planned
- Google login and authenticated user session.
- Home feed, hot boards, and recommendations.
- Search with suggestions, genre, status, and sort filters.
- Novel detail with chapter list and series metadata.
- Reader with TOC, reading preferences, and progress sync.
- Bookshelf tabs: Dang doc, Danh dau, Da doc, De cu.
- Comments, ratings, and user recommendations.
- Native TTS and offline reading support.
Đã có trong code (theo `lib/features`): đăng nhập Google, home/browse, genres, tìm kiếm, chi tiết truyện + danh sách chương, reader (kèm TTS), bookshelf, bookmark/progress, bình luận, splash/settings.
Còn thiếu hoặc mới dạng placeholder so với web: gợi ý tìm kiếm (`/api/truyen/suggest`), đánh giá truyện (`/api/truyen/{id}/rate`), đồng bộ settings và đề cử người dùng (`/api/user/settings`, `/api/user/recommendations`). Chi tiết parity xem `FEATURES.md``CROSS_REPO_ENDPOINT_MATRIX.md`.
## Architecture
@@ -51,12 +46,14 @@ This script reads `.env.mobile` and automatically passes:
- `GOOGLE_SERVER_CLIENT_ID`
- optional `GOOGLE_CLIENT_ID`
Default `BASE_URL` behavior:
Default `BASE_URL` khi **không** truyền `--dart-define` (xem `lib/core/config/app_config.dart`):
- Android emulator: `http://10.0.2.2:8000`
- Others (iOS simulator, desktop, web): `http://localhost:8000`
- Android (native, không phải web build): `https://reader-api.fevirtus.dev`
- Các nền tảng khác (iOS, desktop, web build, v.v.): `http://localhost:8000`
If needed, you can still override explicitly:
Để dev local trên Android emulator, luôn set rõ qua `--dart-define` hoặc file `.env.mobile` + `scripts/flutter_run_with_env.sh`, ví dụ `http://10.0.2.2:8000`.
Override trực tiếp:
```bash
flutter run --dart-define=BASE_URL=http://localhost:8000
@@ -115,3 +112,11 @@ Optional (iOS/web):
```bash
--dart-define=GOOGLE_CLIENT_ID=<YOUR_IOS_OR_WEB_CLIENT_ID>.apps.googleusercontent.com
```
Noted:
Với MIUI:
Cần hướng dẫn user (không thể fix bằng code)
MIUI AutoStart: User phải vào Cài đặt → Ứng dụng → [app] → AutoStart và bật thủ công
MIUI Battery Optimization: User phải vào Cài đặt → Pin → Ứng dụng tiêu hao pin → [app] → chọn "Không hạn chế" (permission REQUEST_IGNORE_BATTERY_OPTIMIZATIONS đã có trong Manifest để trigger dialog, nhưng user vẫn phải accept)
+23
View File
@@ -0,0 +1,23 @@
# Shared Development Skills (Reader Suite)
Tai lieu mo ta bo skill de phat trien thong nhat giua web/app/api.
## skill: feature-parity-check
- Muc dich: so sanh 1 feature giua web va mobile theo cung API contract.
- Input: ten feature, endpoint list, expected UX behavior.
- Output: parity report (done/missing/risk) cho 3 repo.
## skill: api-contract-guard
- Muc dich: review thay doi API de tranh breaking web/mobile.
- Input: diff endpoint, request/response schema, auth requirement.
- Output: compatibility checklist + migration huong dan.
## skill: auth-flow-verifier
- Muc dich: verify luong dang nhap web cookie va mobile JWT map cung identity.
- Input: login flow, token/session behavior, protected endpoints.
- Output: matrix pass/fail + fix recommendation.
## skill: release-readiness
- Muc dich: chuan bi release dong bo 3 repo.
- Input: danh sach thay doi theo repo.
- Output: rollout order, smoke-test list, rollback note.
@@ -13,7 +13,7 @@ import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import com.example.reader_app.tts.ReaderTtsMediaBridge
import com.example.reader_app.tts.ReaderTtsMediaService
import com.example.reader_app.tts.ReaderTtsSegment
import com.example.reader_app.tts.ReaderTtsStartRequest
class MainActivity : FlutterActivity() {
private val channelName = "reader_app/tts_background"
@@ -53,23 +53,34 @@ class MainActivity : FlutterActivity() {
}
"getSnapshot" -> result.success(ReaderTtsMediaBridge.snapshot())
"startReading" -> {
val startIndex = call.argument<Int>("startIndex") ?: 0
val content = call.argument<String>("content") ?: ""
val contentKey = call.argument<String>("contentKey")
val title = call.argument<String>("title")
val speed = call.argument<Double>("speed") ?: 0.9
val language = call.argument<String>("language") ?: "vi-VN"
val voiceName = call.argument<String>("voiceName")
val backgroundModeEnabled = call.argument<Boolean>("backgroundModeEnabled") ?: true
val nextChapterId = call.argument<String>("nextChapterId")
val chapterNumber = call.argument<Int>("chapterNumber")
val includeTitle = call.argument<Boolean>("includeTitle") ?: true
val apiBaseUrl = call.argument<String>("apiBaseUrl")
val startIndex = call.argument<Int>("startIndex") ?: 0
ReaderTtsMediaService.startReading(
this,
parseSegments(call.argument<List<*>>("segments")),
startIndex,
contentKey,
title,
speed,
language,
voiceName,
backgroundModeEnabled,
ReaderTtsStartRequest(
content = content,
contentKey = contentKey,
title = title,
speed = speed,
language = language,
voiceName = voiceName,
backgroundModeEnabled = backgroundModeEnabled,
nextChapterId = nextChapterId,
chapterNumber = chapterNumber,
includeTitle = includeTitle,
apiBaseUrl = apiBaseUrl,
startIndex = startIndex,
),
)
result.success(null)
}
@@ -137,24 +148,6 @@ class MainActivity : FlutterActivity() {
)
}
private fun parseSegments(rawSegments: List<*>?): ArrayList<ReaderTtsSegment> {
val segments = arrayListOf<ReaderTtsSegment>()
rawSegments.orEmpty().forEach { item ->
val map = item as? Map<*, *> ?: return@forEach
val text = map["text"]?.toString() ?: return@forEach
val paragraphIndex = (map["paragraphIndex"] as? Number)?.toInt() ?: -1
val start = (map["start"] as? Number)?.toInt() ?: -1
val end = (map["end"] as? Number)?.toInt() ?: -1
segments += ReaderTtsSegment(
text = text,
paragraphIndex = paragraphIndex,
start = start,
end = end,
)
}
return segments
}
private fun isIgnoringBatteryOptimizations(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
package com.example.reader_app.tts
import java.util.LinkedHashMap
import java.util.UUID
data class ReaderTtsStartRequest(
val content: String,
val contentKey: String?,
val title: String?,
val speed: Double,
val language: String,
val voiceName: String?,
val backgroundModeEnabled: Boolean,
val nextChapterId: String?,
val chapterNumber: Int?,
val includeTitle: Boolean,
val apiBaseUrl: String?,
val startIndex: Int = 0,
)
object ReaderTtsPlaybackStore {
private const val MAX_PENDING_REQUESTS = 4
private val pendingRequests = LinkedHashMap<String, ReaderTtsStartRequest>()
@Synchronized
fun enqueue(request: ReaderTtsStartRequest): String {
val token = UUID.randomUUID().toString()
pendingRequests[token] = request
while (pendingRequests.size > MAX_PENDING_REQUESTS) {
val oldestKey = pendingRequests.entries.firstOrNull()?.key ?: break
pendingRequests.remove(oldestKey)
}
return token
}
@Synchronized
fun consume(token: String?): ReaderTtsStartRequest? {
if (token.isNullOrBlank()) return null
return pendingRequests.remove(token)
}
}
+75
View File
@@ -1,9 +1,15 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../core/auth/session_expiry_notifier.dart';
import '../core/theme/app_theme.dart';
import '../core/storage/local_store.dart';
import '../features/auth/providers/auth_provider.dart';
import '../features/reader/tts/tts_service.dart';
import 'router/route_names.dart';
import 'router/app_router.dart';
@@ -17,10 +23,39 @@ class ReaderApp extends ConsumerStatefulWidget {
class _ReaderAppState extends ConsumerState<ReaderApp> {
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
ProviderSubscription<int>? _sessionExpirySub;
late final GoRouter _router;
String? _previousPath;
void _persistRouteForRestore() {
if (!mounted) return;
final uri = _router.state.uri;
final fullPath = uri.hasQuery ? '${uri.path}?${uri.query}' : uri.path;
if (fullPath == RouteNames.splash) return;
// When navigating into reader from a novel page, save "novelPath|readerPath"
// so the splash screen can reconstruct the full back stack on restore.
final String pathToSave;
if (fullPath.startsWith('/reader/') &&
_previousPath != null &&
_previousPath!.startsWith('/novel/')) {
pathToSave = '$_previousPath|$fullPath';
} else {
pathToSave = fullPath;
}
_previousPath = fullPath;
unawaited(ref.read(localStoreProvider).saveLastRoutePath(pathToSave));
}
@override
void initState() {
super.initState();
_router = ref.read(appRouterProvider);
_router.routerDelegate.addListener(_persistRouteForRestore);
WidgetsBinding.instance.addPostFrameCallback((_) {
_ensureMandatoryTtsRequirements();
});
_sessionExpirySub = ref.listenManual<int>(
sessionExpiryProvider,
(previous, next) async {
@@ -45,8 +80,48 @@ class _ReaderAppState extends ConsumerState<ReaderApp> {
);
}
Future<void> _ensureMandatoryTtsRequirements() async {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android || !mounted) {
return;
}
final notifier = ref.read(ttsProvider.notifier);
await notifier.setBackgroundModeEnabled(true);
await notifier.ensureBatteryOptimizationIgnored();
if (!mounted) return;
while (mounted && !ref.read(ttsProvider).batteryOptimizationIgnored) {
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) {
return AlertDialog(
title: const Text('Yeu cau bat buoc cho TTS'),
content: const Text(
'Can bat Chay nen va Loai tru toi uu pin de TTS khong bi ngat dot ngot.',
),
actions: [
FilledButton(
onPressed: () async {
await notifier.setBackgroundModeEnabled(true);
await notifier.ensureBatteryOptimizationIgnored();
if (!context.mounted) return;
if (ref.read(ttsProvider).batteryOptimizationIgnored) {
Navigator.of(context).pop();
}
},
child: const Text('Bat ngay'),
),
],
);
},
);
}
}
@override
void dispose() {
_router.routerDelegate.removeListener(_persistRouteForRestore);
_sessionExpirySub?.close();
super.dispose();
}
+1 -1
View File
@@ -11,7 +11,7 @@ class AppConfig {
}
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
return 'http://10.0.2.2:8000';
return 'https://reader-api.fevirtus.dev';
}
return 'http://localhost:8000';
+34 -1
View File
@@ -2,10 +2,26 @@ import 'package:equatable/equatable.dart';
import 'novel_model.dart';
enum BookmarkType {
reading('reading'),
bookmarked('bookmarked');
const BookmarkType(this.value);
final String value;
static BookmarkType fromString(String? str) {
return values.firstWhere(
(e) => e.value == str,
orElse: () => BookmarkType.bookmarked,
);
}
}
class BookmarkModel extends Equatable {
const BookmarkModel({
required this.id,
required this.novelId,
this.type = BookmarkType.bookmarked,
this.lastChapterId,
this.lastChapterNumber,
this.readChapters = const [],
@@ -14,6 +30,7 @@ class BookmarkModel extends Equatable {
final String id;
final String novelId;
final BookmarkType type;
final String? lastChapterId;
final int? lastChapterNumber;
final List<int> readChapters;
@@ -28,11 +45,27 @@ class BookmarkModel extends Equatable {
?.map((e) => (e as num).toInt())
.toList() ??
[],
type: () {
final explicitType = BookmarkType.fromString(json['type'] as String?);
if ((json['type'] as String?) != null) {
return explicitType;
}
// Backward-compatible inference when API does not return `type`.
final inferredLastChapter = json['lastChapterNumber'] as int?;
final inferredReadChapters = (json['readChapters'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList() ??
const <int>[];
return (inferredLastChapter != null || inferredReadChapters.isNotEmpty)
? BookmarkType.reading
: BookmarkType.bookmarked;
}(),
novel: json['novel'] != null
? NovelModel.fromJson(json['novel'] as Map<String, dynamic>)
: null,
);
@override
List<Object?> get props => [id, novelId];
List<Object?> get props => [id, novelId, type];
}
+21 -2
View File
@@ -5,9 +5,12 @@ class ReadingSettings {
this.letterSpacing = 0,
this.fontFamily = 'serif',
this.themePreset = 'paper',
this.backgroundColorValue = 0xFFFFFEF8,
this.textColorValue = 0xFF111111,
this.horizontalPadding = 20,
this.paragraphSpacing = 24,
this.textAlign = 'justify',
this.textAlign = 'left',
this.enableSentenceTapTts = false,
});
final double fontSize;
@@ -15,9 +18,12 @@ class ReadingSettings {
final double letterSpacing;
final String fontFamily;
final String themePreset;
final int backgroundColorValue;
final int textColorValue;
final double horizontalPadding;
final double paragraphSpacing;
final String textAlign;
final bool enableSentenceTapTts;
ReadingSettings copyWith({
double? fontSize,
@@ -25,9 +31,12 @@ class ReadingSettings {
double? letterSpacing,
String? fontFamily,
String? themePreset,
int? backgroundColorValue,
int? textColorValue,
double? horizontalPadding,
double? paragraphSpacing,
String? textAlign,
bool? enableSentenceTapTts,
}) =>
ReadingSettings(
fontSize: fontSize ?? this.fontSize,
@@ -35,9 +44,12 @@ class ReadingSettings {
letterSpacing: letterSpacing ?? this.letterSpacing,
fontFamily: fontFamily ?? this.fontFamily,
themePreset: themePreset ?? this.themePreset,
backgroundColorValue: backgroundColorValue ?? this.backgroundColorValue,
textColorValue: textColorValue ?? this.textColorValue,
horizontalPadding: horizontalPadding ?? this.horizontalPadding,
paragraphSpacing: paragraphSpacing ?? this.paragraphSpacing,
textAlign: textAlign ?? this.textAlign,
enableSentenceTapTts: enableSentenceTapTts ?? this.enableSentenceTapTts,
);
factory ReadingSettings.fromJson(Map<String, dynamic> json) => ReadingSettings(
@@ -46,9 +58,13 @@ class ReadingSettings {
letterSpacing: (json['letterSpacing'] as num?)?.toDouble() ?? 0,
fontFamily: json['fontFamily'] as String? ?? 'serif',
themePreset: json['themePreset'] as String? ?? 'paper',
backgroundColorValue:
(json['backgroundColorValue'] as num?)?.toInt() ?? 0xFFFFFEF8,
textColorValue: (json['textColorValue'] as num?)?.toInt() ?? 0xFF111111,
horizontalPadding: (json['horizontalPadding'] as num?)?.toDouble() ?? 20,
paragraphSpacing: (json['paragraphSpacing'] as num?)?.toDouble() ?? 24,
textAlign: json['textAlign'] as String? ?? 'justify',
textAlign: json['textAlign'] as String? ?? 'left',
enableSentenceTapTts: json['enableSentenceTapTts'] as bool? ?? false,
);
Map<String, dynamic> toJson() => {
@@ -57,8 +73,11 @@ class ReadingSettings {
'letterSpacing': letterSpacing,
'fontFamily': fontFamily,
'themePreset': themePreset,
'backgroundColorValue': backgroundColorValue,
'textColorValue': textColorValue,
'horizontalPadding': horizontalPadding,
'paragraphSpacing': paragraphSpacing,
'textAlign': textAlign,
'enableSentenceTapTts': enableSentenceTapTts,
};
}
+42 -2
View File
@@ -8,12 +8,15 @@ class LocalStore {
static const _kLetterSpacing = 'reader_letter_spacing';
static const _kFontFamily = 'reader_font_family';
static const _kThemePreset = 'reader_theme_preset';
static const _kBackgroundColor = 'reader_background_color';
static const _kTextColor = 'reader_text_color';
static const _kHorizontalPadding = 'reader_horizontal_padding';
static const _kParagraphSpacing = 'reader_paragraph_spacing';
static const _kTextAlign = 'reader_text_align';
static const _kProgressChapterId = 'progress_chapter_id_';
static const _kProgressChapterNum = 'progress_chapter_num_';
static const _kProgressOffset = 'progress_offset_';
static const _kLastRoutePath = 'last_route_path';
// ── Reading settings ──────────────────────────────────────────────────────
@@ -24,6 +27,8 @@ class LocalStore {
await prefs.setDouble(_kLetterSpacing, settings.letterSpacing);
await prefs.setString(_kFontFamily, settings.fontFamily);
await prefs.setString(_kThemePreset, settings.themePreset);
await prefs.setInt(_kBackgroundColor, settings.backgroundColorValue);
await prefs.setInt(_kTextColor, settings.textColorValue);
await prefs.setDouble(_kHorizontalPadding, settings.horizontalPadding);
await prefs.setDouble(_kParagraphSpacing, settings.paragraphSpacing);
await prefs.setString(_kTextAlign, settings.textAlign);
@@ -32,15 +37,29 @@ class LocalStore {
Future<ReadingSettings?> loadReadingSettings() async {
final prefs = await SharedPreferences.getInstance();
if (!prefs.containsKey(_kFontSize)) return null;
final themePreset = prefs.getString(_kThemePreset) ?? 'paper';
final fallbackBackground = switch (themePreset) {
'night' => 0xFF101418,
'sepia' => 0xFFF6EAD7,
_ => 0xFFFFFEF8,
};
final fallbackText = switch (themePreset) {
'night' => 0xFFE6EAF2,
'sepia' => 0xFF3B2F23,
_ => 0xFF111111,
};
return ReadingSettings(
fontSize: prefs.getDouble(_kFontSize) ?? 18,
lineHeight: prefs.getDouble(_kLineHeight) ?? 1.8,
letterSpacing: prefs.getDouble(_kLetterSpacing) ?? 0,
fontFamily: prefs.getString(_kFontFamily) ?? 'serif',
themePreset: prefs.getString(_kThemePreset) ?? 'paper',
themePreset: themePreset,
backgroundColorValue: prefs.getInt(_kBackgroundColor) ?? fallbackBackground,
textColorValue: prefs.getInt(_kTextColor) ?? fallbackText,
horizontalPadding: prefs.getDouble(_kHorizontalPadding) ?? 20,
paragraphSpacing: prefs.getDouble(_kParagraphSpacing) ?? 24,
textAlign: prefs.getString(_kTextAlign) ?? 'justify',
textAlign: prefs.getString(_kTextAlign) ?? 'left',
);
}
@@ -68,6 +87,27 @@ class LocalStore {
'scrollOffset': prefs.getDouble('$_kProgressOffset$novelId') ?? 0.0,
};
}
// ── Last route restore (cold start after process reclaim) ───────────────
Future<void> saveLastRoutePath(String path) async {
final normalized = path.trim();
if (normalized.isEmpty || normalized == '/') return;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kLastRoutePath, normalized);
}
Future<String?> loadLastRoutePath() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getString(_kLastRoutePath)?.trim();
if (value == null || value.isEmpty) return null;
return value;
}
Future<void> clearLastRoutePath() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kLastRoutePath);
}
}
final localStoreProvider = Provider<LocalStore>((_) => LocalStore());
@@ -5,6 +5,8 @@ import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart';
import '../../../core/models/bookmark_model.dart';
import '../../../shared/widgets/main_app_header.dart';
import '../../novel/providers/novels_provider.dart';
import '../providers/bookshelf_provider.dart';
import '../../auth/providers/auth_provider.dart';
@@ -17,21 +19,30 @@ class BookshelfScreen extends ConsumerWidget {
if (!isAuth) {
return Scaffold(
appBar: AppBar(title: const Text('Tủ sách')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.lock_outline, size: 48),
const SizedBox(height: 12),
const Text('Vui lòng đăng nhập để xem tủ sách'),
const SizedBox(height: 16),
FilledButton(
onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(),
child: const Text('Đăng nhập bằng Google'),
body: Column(
children: [
const MainAppHeader(title: 'Đăng truyện'),
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.lock_outline_rounded, size: 54),
const SizedBox(height: 12),
const Text('Vui lòng đăng nhập để xem tủ sách'),
const SizedBox(height: 16),
FilledButton(
onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(),
child: const Text('Đăng nhập bằng Google'),
),
],
),
),
),
],
),
),
],
),
);
}
@@ -39,51 +50,108 @@ class BookshelfScreen extends ConsumerWidget {
final bookshelfAsync = ref.watch(bookshelfProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Tủ sách'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => ref.read(bookshelfProvider.notifier).fetch(),
),
],
),
body: bookshelfAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48),
const SizedBox(height: 8),
Text('Lỗi: $e'),
TextButton(
onPressed: () => ref.read(bookshelfProvider.notifier).fetch(),
child: const Text('Thử lại'),
body: DefaultTabController(
length: 2,
child: Column(
children: [
MainAppHeader(
title: 'Đăng truyện',
bottom: Container(
height: 42,
decoration: BoxDecoration(
color: const Color(0xFF14B8A6),
borderRadius: BorderRadius.circular(0),
),
child: TabBar(
indicatorColor: const Color(0xFFF7B500),
indicatorWeight: 3,
labelColor: Colors.white,
unselectedLabelColor: Colors.white70,
dividerColor: Colors.transparent,
tabs: const [
Tab(text: 'Đang đọc'),
Tab(text: 'Đánh dấu'),
],
),
),
],
),
),
data: (bookmarks) {
if (bookmarks.isEmpty) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.menu_book_outlined, size: 56),
SizedBox(height: 12),
Text('Chưa có truyện nào trong tủ sách'),
],
),
);
}
return RefreshIndicator(
onRefresh: () => ref.read(bookshelfProvider.notifier).fetch(),
child: ListView.builder(
itemCount: bookmarks.length,
itemBuilder: (context, index) =>
_BookmarkTile(bookmark: bookmarks[index]),
),
Expanded(
child: bookshelfAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline_rounded, size: 48),
const SizedBox(height: 8),
Text('Lỗi: $e'),
TextButton(
onPressed: () => ref.read(bookshelfProvider.notifier).fetch(),
child: const Text('Thử lại'),
),
],
),
),
data: (bookmarks) {
final readingItems = ref.watch(readingBookmarksProvider);
final bookmarkedItems = ref.watch(savedBookmarksProvider);
return TabBarView(
children: [
_BookshelfList(
bookmarks: readingItems,
emptyLabel: 'Chưa có truyện đang đọc.',
),
_BookshelfList(
bookmarks: bookmarkedItems,
emptyLabel: 'Chưa có truyện đánh dấu.',
),
],
);
},
),
),
],
),
),
);
}
}
class _BookshelfList extends ConsumerWidget {
const _BookshelfList({required this.bookmarks, required this.emptyLabel});
final List<BookmarkModel> bookmarks;
final String emptyLabel;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (bookmarks.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.menu_book_outlined, size: 56),
const SizedBox(height: 12),
Text(emptyLabel),
],
),
);
}
return RefreshIndicator(
onRefresh: () => ref.read(bookshelfProvider.notifier).fetch(),
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 24),
itemCount: bookmarks.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final bookmark = bookmarks[index];
return _BookmarkTile(
bookmark: bookmark,
onRemove: () => ref
.read(bookshelfProvider.notifier)
.removeFromShelf(bookmark.novelId, bookmark.type),
);
},
),
@@ -91,39 +159,145 @@ class BookshelfScreen extends ConsumerWidget {
}
}
class _BookmarkTile extends StatelessWidget {
class _BookmarkTile extends ConsumerWidget {
final BookmarkModel bookmark;
const _BookmarkTile({required this.bookmark});
final VoidCallback onRemove;
const _BookmarkTile({
required this.bookmark,
required this.onRemove,
});
Future<void> _openContinueReader(BuildContext context, WidgetRef ref) async {
var targetChapterId = bookmark.lastChapterId;
if (targetChapterId == null || targetChapterId.isEmpty) {
try {
final chapters = await ref.read(
chapterListProvider(bookmark.novelId).future,
);
if (chapters.isNotEmpty) {
targetChapterId = chapters.first.id;
}
} catch (_) {
// Fall through to novel detail when chapter lookup fails.
}
}
if (!context.mounted) return;
if (targetChapterId != null && targetChapterId.isNotEmpty) {
context.push(RouteNames.readerChapter(targetChapterId));
return;
}
context.push(RouteNames.novelDetail(bookmark.novelId));
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final novel = bookmark.novel;
return ListTile(
leading: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: novel?.coverUrl != null
? CachedNetworkImage(
imageUrl: novel!.coverUrl!,
width: 44,
height: 60,
fit: BoxFit.cover,
)
: Container(
width: 44,
height: 60,
color: Theme.of(context).colorScheme.primaryContainer,
child: const Icon(Icons.menu_book, size: 20),
),
),
title: Text(
novel?.title ?? bookmark.novelId,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: novel?.authorName != null
? Text(novel!.authorName, maxLines: 1, overflow: TextOverflow.ellipsis)
: null,
return GestureDetector(
onTap: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: novel?.coverUrl != null
? CachedNetworkImage(
imageUrl: novel!.coverUrl!,
width: 92,
height: 126,
fit: BoxFit.cover,
)
: Container(
width: 92,
height: 126,
color: Theme.of(context).colorScheme.primaryContainer,
child: const Icon(Icons.menu_book, size: 28),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
novel?.title ?? bookmark.novelId,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: onRemove,
child: const Icon(Icons.close_rounded, size: 20),
),
],
),
const SizedBox(height: 8),
Text(
'Số chương: ${novel?.totalChapters ?? '--'}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
if (bookmark.lastChapterNumber != null) ...[
const SizedBox(height: 6),
Text(
'Đang đọc đến: ${bookmark.lastChapterNumber} / ${novel?.totalChapters ?? '--'}',
style: Theme.of(context).textTheme.bodyMedium,
),
],
if (novel?.authorName != null) ...[
const SizedBox(height: 10),
Text(
novel!.authorName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
),
),
],
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: () => _openContinueReader(context, ref),
icon: const Icon(Icons.menu_book_rounded),
label: const Text('Đọc tiếp'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF14B8A6),
foregroundColor: Colors.white,
),
),
),
],
),
],
),
),
);
}
}
@@ -23,6 +23,62 @@ class BookshelfNotifier extends StateNotifier<AsyncValue<List<BookmarkModel>>> {
}
}
void syncProgress({
required String novelId,
required String chapterId,
required int chapterNumber,
Map<String, dynamic>? serverBookmark,
}) {
final current = state.valueOrNull ?? const <BookmarkModel>[];
BookmarkModel? parsedFromServer;
if (serverBookmark != null) {
try {
parsedFromServer = BookmarkModel.fromJson(serverBookmark);
} catch (_) {
parsedFromServer = null;
}
}
final index = current.indexWhere((b) => b.novelId == novelId);
if (index >= 0) {
final existing = current[index];
final merged = parsedFromServer ?? BookmarkModel(
id: existing.id,
novelId: existing.novelId,
type: BookmarkType.reading,
lastChapterId: chapterId,
lastChapterNumber: chapterNumber,
readChapters: {
...existing.readChapters,
chapterNumber,
}.toList()
..sort(),
novel: existing.novel,
);
final updated = [...current]..[index] = merged;
state = AsyncValue.data(updated);
return;
}
if (parsedFromServer != null) {
state = AsyncValue.data([parsedFromServer, ...current]);
return;
}
// Fallback when API response doesn't include bookmark object.
final synthetic = BookmarkModel(
id: 'progress-$novelId',
novelId: novelId,
type: BookmarkType.reading,
lastChapterId: chapterId,
lastChapterNumber: chapterNumber,
readChapters: [chapterNumber],
);
state = AsyncValue.data([synthetic, ...current]);
}
Future<void> toggle(String novelId) async {
try {
final client = _ref.read(apiClientProvider);
@@ -44,6 +100,22 @@ class BookshelfNotifier extends StateNotifier<AsyncValue<List<BookmarkModel>>> {
bool isBookmarked(String novelId) {
return (state.valueOrNull ?? []).any((b) => b.novelId == novelId);
}
Future<void> removeFromShelf(String novelId, BookmarkType type) async {
try {
final client = _ref.read(apiClientProvider);
await client.dio.delete(
'/api/user/bookmarks/$novelId',
queryParameters: {'type': type.value},
);
final current = state.valueOrNull ?? [];
state = AsyncValue.data(
current.where((b) => b.novelId != novelId || b.type != type).toList(),
);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
}
final bookshelfProvider =
@@ -51,6 +123,16 @@ final bookshelfProvider =
return BookshelfNotifier(ref);
});
final readingBookmarksProvider = Provider<List<BookmarkModel>>((ref) {
final bookmarks = ref.watch(bookshelfProvider).valueOrNull ?? [];
return bookmarks.where((b) => b.type == BookmarkType.reading).toList();
});
final savedBookmarksProvider = Provider<List<BookmarkModel>>((ref) {
final bookmarks = ref.watch(bookshelfProvider).valueOrNull ?? [];
return bookmarks.where((b) => b.type == BookmarkType.bookmarked).toList();
});
final isBookmarkedProvider = Provider.family<bool, String>((ref, novelId) {
final bookshelf = ref.watch(bookshelfProvider);
return bookshelf.valueOrNull?.any((b) => b.novelId == novelId) ?? false;
+376 -113
View File
@@ -1,10 +1,13 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart';
import '../../../core/models/novel_model.dart';
import '../../../shared/widgets/main_app_header.dart';
import '../providers/home_provider.dart';
class HomeScreen extends ConsumerWidget {
@@ -13,62 +16,67 @@ class HomeScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final homeAsync = ref.watch(homeProvider);
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('Reader'),
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () => context.go(RouteNames.search),
),
],
),
body: homeAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48),
const SizedBox(height: 12),
Text('Lỗi tải dữ liệu', style: Theme.of(context).textTheme.bodyLarge),
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 0),
child: Text(
e.toString(),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
backgroundColor: colorScheme.surface,
body: Column(
children: [
const MainAppHeader(),
Expanded(
child: homeAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off_rounded, size: 52),
const SizedBox(height: 12),
Text('Không thể tải dữ liệu trang chủ'),
const SizedBox(height: 8),
Text(
e.toString(),
maxLines: 3,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
FilledButton(
onPressed: () => ref.invalidate(homeProvider),
child: const Text('Tải lại'),
),
],
),
),
),
TextButton(
onPressed: () => ref.invalidate(homeProvider),
child: const Text('Thử lại'),
data: (data) => RefreshIndicator(
onRefresh: () async => ref.invalidate(homeProvider),
child: ListView(
padding: const EdgeInsets.fromLTRB(0, 12, 0, 24),
children: [
_HotCarousel(novels: data.hot),
const SizedBox(height: 12),
const _HomeQuickFilters(),
_SectionHeader(
title: 'Truyện mới nhất',
onMore: () => context.go(RouteNames.search),
),
_NovelHorizontalList(novels: data.latest),
_SectionHeader(
title: 'Đề cử nổi bật',
onMore: () => context.go('${RouteNames.search}?sort=rating'),
),
_FeatureGrid(novels: data.topRated.take(6).toList()),
const SizedBox(height: 12),
],
),
),
],
),
),
),
data: (data) => RefreshIndicator(
onRefresh: () async => ref.invalidate(homeProvider),
child: ListView(
children: [
_HotCarousel(novels: data.hot),
_SectionHeader(
title: 'Mới cập nhật',
onMore: () => context.go(RouteNames.search),
),
_NovelHorizontalList(novels: data.latest),
_SectionHeader(
title: 'Đánh giá cao',
onMore: () => context.go(RouteNames.search),
),
_NovelHorizontalList(novels: data.topRated),
const SizedBox(height: 16),
],
),
),
],
),
);
}
@@ -82,18 +90,82 @@ class _SectionHeader extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 8, 8),
padding: const EdgeInsets.fromLTRB(18, 18, 12, 6),
child: Row(
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
const Spacer(),
if (onMore != null)
TextButton(onPressed: onMore, child: const Text('Xem thêm')),
InkWell(
onTap: onMore,
borderRadius: BorderRadius.circular(999),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Row(
children: [
Text(
'Xem thêm',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: const Color(0xFF14B8A6),
),
),
const SizedBox(width: 4),
const Icon(Icons.chevron_right_rounded, color: Color(0xFF14B8A6)),
],
),
),
),
],
),
);
}
class _HomeQuickFilters extends StatelessWidget {
const _HomeQuickFilters();
@override
Widget build(BuildContext context) {
const items = [
(Icons.dashboard_customize_rounded, 'Thể loại'),
(Icons.verified_rounded, 'Hoàn thành'),
(Icons.sell_rounded, 'Miễn phí'),
(Icons.local_fire_department_rounded, 'Truyện hot'),
];
return Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 8),
child: Row(
children: items
.map(
(item) => Expanded(
child: Column(
children: [
Icon(item.$1, color: const Color(0xFF14B8A6), size: 26),
const SizedBox(height: 6),
Text(
item.$2,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: const Color(0xFF14B8A6),
),
),
],
),
),
)
.toList(),
),
);
}
}
class _HotCarousel extends StatefulWidget {
final List<NovelModel> novels;
const _HotCarousel({required this.novels});
@@ -103,10 +175,58 @@ class _HotCarousel extends StatefulWidget {
}
class _HotCarouselState extends State<_HotCarousel> {
final PageController _controller = PageController(viewportFraction: 0.85);
late PageController _controller;
Timer? _autoSlideTimer;
int _currentPage = 0;
@override
void initState() {
super.initState();
_controller = PageController(viewportFraction: 1);
_startAutoSlide();
}
@override
void reassemble() {
super.reassemble();
_recreateController();
}
void _recreateController() {
final oldController = _controller;
final page = oldController.hasClients
? (oldController.page?.round() ?? _currentPage)
: _currentPage;
_controller = PageController(initialPage: page, viewportFraction: 1);
oldController.dispose();
}
@override
void didUpdateWidget(covariant _HotCarousel oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.novels.length != widget.novels.length) {
_startAutoSlide();
}
}
void _startAutoSlide() {
_autoSlideTimer?.cancel();
if (widget.novels.length <= 1) return;
_autoSlideTimer = Timer.periodic(const Duration(seconds: 4), (_) {
if (!mounted || !_controller.hasClients) return;
final nextPage = (_currentPage + 1) % widget.novels.length;
_controller.animateToPage(
nextPage,
duration: const Duration(milliseconds: 360),
curve: Curves.easeInOut,
);
});
}
@override
void dispose() {
_autoSlideTimer?.cancel();
_controller.dispose();
super.dispose();
}
@@ -115,17 +235,49 @@ class _HotCarouselState extends State<_HotCarousel> {
Widget build(BuildContext context) {
if (widget.novels.isEmpty) return const SizedBox.shrink();
return SizedBox(
height: 220,
child: PageView.builder(
controller: _controller,
itemCount: widget.novels.length,
itemBuilder: (context, index) {
final novel = widget.novels[index];
return GestureDetector(
onTap: () => context.push(RouteNames.novelDetail(novel.id)),
child: _CarouselCard(novel: novel),
);
},
height: 260,
child: Column(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: ClipRect(
child: PageView.builder(
controller: _controller,
itemCount: widget.novels.length,
onPageChanged: (value) => setState(() => _currentPage = value),
itemBuilder: (context, index) {
final novel = widget.novels[index];
return GestureDetector(
onTap: () => context.push(RouteNames.novelDetail(novel.id)),
child: _CarouselCard(novel: novel),
);
},
),
),
),
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(widget.novels.length.clamp(0, 5), (index) {
final active = index == _currentPage.clamp(0, 4);
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: active ? 16 : 7,
height: 7,
decoration: BoxDecoration(
color: active ? const Color(0xFF14B8A6) : Colors.white54,
borderRadius: BorderRadius.circular(99),
),
);
}),
),
],
),
);
}
@@ -137,52 +289,76 @@ class _CarouselCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 12),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
fit: StackFit.expand,
children: [
if (novel.coverUrl != null)
CachedNetworkImage(
imageUrl: novel.coverUrl!,
fit: BoxFit.cover,
placeholder: (_, imageUrl) => Container(color: Colors.grey[200]),
errorWidget: (_, imageUrl, error) =>
Container(color: Colors.grey[300]),
)
else
Container(color: Theme.of(context).colorScheme.primaryContainer),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withAlpha(180)],
),
),
return Stack(
fit: StackFit.expand,
children: [
if (novel.coverUrl != null)
CachedNetworkImage(
imageUrl: novel.coverUrl!,
fit: BoxFit.cover,
placeholder: (_, imageUrl) => Container(color: Colors.grey[200]),
errorWidget: (_, imageUrl, error) => Container(color: Colors.grey[300]),
)
else
Container(color: Theme.of(context).colorScheme.primaryContainer),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withAlpha(180)],
),
),
Positioned(
bottom: 12,
left: 12,
right: 12,
child: Text(
),
),
Positioned(
bottom: 12,
left: 12,
right: 12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (novel.status.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF22C55E),
borderRadius: BorderRadius.circular(999),
),
child: Text(
novel.status,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: 10),
Text(
novel.title,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
fontSize: 20,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
const SizedBox(height: 4),
Text(
novel.description?.trim().isNotEmpty == true
? novel.description!.trim()
: novel.authorName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white70, fontStyle: FontStyle.italic),
),
],
),
),
),
],
);
}
}
@@ -194,9 +370,9 @@ class _NovelHorizontalList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
height: 200,
height: 226,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 18),
scrollDirection: Axis.horizontal,
itemCount: novels.length,
separatorBuilder: (_, separatorIndex) => const SizedBox(width: 12),
@@ -205,32 +381,45 @@ class _NovelHorizontalList extends StatelessWidget {
return GestureDetector(
onTap: () => context.push(RouteNames.novelDetail(novel.id)),
child: SizedBox(
width: 110,
width: 122,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(10),
child: novel.coverUrl != null
? CachedNetworkImage(
imageUrl: novel.coverUrl!,
width: 110,
height: 150,
width: 122,
height: 155,
fit: BoxFit.cover,
)
: Container(
width: 110,
height: 150,
width: 122,
height: 155,
color: Theme.of(context).colorScheme.primaryContainer,
child: const Icon(Icons.menu_book),
),
),
const SizedBox(height: 4),
const SizedBox(height: 6),
Flexible(
child: Text(
novel.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 3),
Text(
novel.title,
maxLines: 2,
'${novel.totalChapters} chương',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: const Color(0xFF58D68D),
),
),
],
),
@@ -241,3 +430,77 @@ class _NovelHorizontalList extends StatelessWidget {
);
}
}
class _FeatureGrid extends StatelessWidget {
const _FeatureGrid({required this.novels});
final List<NovelModel> novels;
@override
Widget build(BuildContext context) {
if (novels.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.fromLTRB(18, 4, 18, 0),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
itemCount: novels.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 18,
crossAxisSpacing: 14,
childAspectRatio: 0.74,
),
itemBuilder: (context, index) {
final novel = novels[index];
return GestureDetector(
onTap: () => context.push(RouteNames.novelDetail(novel.id)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: novel.coverUrl != null
? CachedNetworkImage(
imageUrl: novel.coverUrl!,
width: double.infinity,
fit: BoxFit.cover,
)
: Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: const Center(child: Icon(Icons.menu_book_rounded)),
),
),
),
const SizedBox(height: 8),
Text(
novel.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 2),
Text(
'${novel.totalChapters} Chương',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: const Color(0xFF58D68D),
),
),
Text(
'${novel.bookmarkCount > 0 ? novel.bookmarkCount : novel.views} ${novel.bookmarkCount > 0 ? 'Đề cử/tuần' : 'Lượt xem'}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: const Color(0xFF1677FF),
),
),
],
),
);
},
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -4,8 +4,6 @@ import '../../../core/models/novel_model.dart';
import '../../../core/models/chapter_model.dart';
import '../../../core/network/providers.dart';
const chapterPageSize = 50;
// ─── Browse / Search ──────────────────────────────────────────────────────────
class BrowseParams {
@@ -28,11 +26,11 @@ class BrowseParams {
if (raw == null || raw.isEmpty) return null;
switch (raw.toLowerCase()) {
case 'ongoing':
return 'Đang ra';
return 'ONGOING';
case 'completed':
return 'Hoàn thành';
return 'COMPLETED';
case 'hiatus':
return 'Tạm ngưng';
return 'HIATUS';
default:
return raw;
}
@@ -189,77 +187,51 @@ final novelDetailProvider =
// ─── Chapter List ─────────────────────────────────────────────────────────────
class ChapterListQuery {
const ChapterListQuery({required this.novelId, this.page = 1});
final String novelId;
final int page;
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ChapterListQuery &&
other.novelId == novelId &&
other.page == page;
}
@override
int get hashCode => Object.hash(novelId, page);
}
class ChapterListPage {
const ChapterListPage({
required this.chapters,
required this.totalChapters,
required this.totalPages,
required this.currentPage,
});
final List<ChapterListItem> chapters;
final int totalChapters;
final int totalPages;
final int currentPage;
}
final chapterListProvider =
FutureProvider.family<ChapterListPage, ChapterListQuery>((ref, query) async {
FutureProvider.family<List<ChapterListItem>, String>((ref, novelId) async {
final client = ref.read(apiClientProvider);
Future<Map<String, dynamic>> fetchChapterPage(String idOrSlug) async {
final res = await client.dio.get(
'/api/truyen/$idOrSlug/chapters',
queryParameters: {
'page': query.page,
'limit': chapterPageSize,
},
);
return res.data as Map<String, dynamic>;
Future<List<ChapterListItem>> fetchAllChapters(String idOrSlug) async {
const limit = 500;
var page = 1;
var totalPages = 1;
final items = <ChapterListItem>[];
while (page <= totalPages) {
final res = await client.dio.get(
'/api/truyen/$idOrSlug/chapters',
queryParameters: {'page': page, 'limit': limit},
);
final data = res.data as Map<String, dynamic>;
final chapters = data['chapters'] as List? ?? const [];
items.addAll(
chapters.map((e) => ChapterListItem.fromJson(e as Map<String, dynamic>)),
);
final apiTotalPages = (data['totalPages'] as num?)?.toInt() ?? 1;
totalPages = apiTotalPages > 0 ? apiTotalPages : 1;
page += 1;
}
return items;
}
var data = await fetchChapterPage(query.novelId);
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 {
return await fetchAllChapters(novelId);
} catch (_) {
// If route opened by slug/id mismatch, resolve canonical novel id and retry once.
// first request can return empty list. Resolve canonical id and retry once.
try {
final novelRes = await client.dio.get('/api/novels/${query.novelId}');
final novelRes = await client.dio.get('/api/novels/$novelId');
final novelData = novelRes.data as Map<String, dynamic>;
final canonicalId = novelData['id'] as String?;
if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != query.novelId) {
data = await fetchChapterPage(canonicalId);
chapters = data['chapters'] as List? ?? const [];
if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != novelId) {
return await fetchAllChapters(canonicalId);
}
} catch (_) {
// Keep original empty list when fallback resolution fails.
}
rethrow;
}
return ChapterListPage(
chapters:
chapters.map((e) => ChapterListItem.fromJson(e as Map<String, dynamic>)).toList(),
totalChapters: (data['totalChapters'] as num?)?.toInt() ?? 0,
totalPages: (data['totalPages'] as num?)?.toInt() ?? 0,
currentPage: (data['currentPage'] as num?)?.toInt() ?? query.page,
);
});
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart';
import '../../../shared/widgets/main_app_header.dart';
import '../../auth/providers/auth_provider.dart';
import '../../bookshelf/providers/bookshelf_provider.dart';
@@ -22,151 +23,180 @@ class ProfileScreen extends ConsumerWidget {
: '';
return Scaffold(
appBar: AppBar(title: const Text('Tài khoản')),
body: switch (authState) {
AuthAuthenticated(:final user) => SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// User Avatar & Basic Info
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
body: Column(
children: [
const MainAppHeader(title: 'Trang cá nhân', showGenresShortcut: false),
Expanded(
child: switch (authState) {
AuthAuthenticated(:final user) => SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 40,
backgroundImage:
user.image != null ? NetworkImage(user.image!) : null,
child: user.image == null
? Text(
displayName[0].toUpperCase(),
style:
Theme.of(context).textTheme.headlineMedium,
)
: null,
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(22),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 34,
backgroundImage:
user.image != null ? NetworkImage(user.image!) : null,
child: user.image == null
? Text(
displayName.isNotEmpty ? displayName[0].toUpperCase() : 'U',
style: Theme.of(context).textTheme.headlineMedium,
)
: null,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayName,
style: Theme.of(context).textTheme.headlineSmall,
),
Text(
user.role.toLowerCase(),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 10),
_AccountStatRow(
icon: Icons.auto_awesome,
label: 'Tiên Thạch: 0.00 TT',
),
const SizedBox(height: 4),
_AccountStatRow(
icon: Icons.diamond,
label: 'Linh Phiếu: 0 LP',
),
const SizedBox(height: 4),
_AccountStatRow(
icon: Icons.local_activity,
label: 'Ngọc Phiếu: $bookmarkedCount',
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () {},
icon: const Icon(Icons.workspace_premium_rounded),
label: const Text('Thêm Tiên Thạch'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF14B8A6),
foregroundColor: Colors.white,
),
),
],
),
),
],
),
),
const SizedBox(height: 12),
Text(
displayName,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
const SizedBox(height: 18),
_ProfileMenuTile(
title: 'Chỉnh sửa thông tin',
onTap: () => context.push(RouteNames.settings),
),
const SizedBox(height: 4),
Text(
user.email,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
_ProfileMenuTile(
title: 'Lịch sử giao dịch',
onTap: () {},
),
_ProfileMenuTile(
title: 'Liên hệ, báo lỗi',
onTap: () {},
),
_ProfileMenuTile(
title: 'Điều khoản dịch vụ',
onTap: () {},
),
_ProfileMenuTile(
title: 'Xóa tài khoản',
onTap: () {},
),
_ProfileMenuTile(
title: 'Đăng xuất',
onTap: () async {
await ref.read(authProvider.notifier).signOut();
if (context.mounted) context.go(RouteNames.home);
},
),
],
),
),
const SizedBox(height: 24),
// Stats Cards
Row(
children: [
Expanded(
child: _buildStatCard(
context: context,
label: 'Sách Đánh Dấu',
count: bookmarkedCount,
),
AuthError(:final message) => Center(child: Text(message)),
AuthUnauthenticated() => Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton(
onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(),
child: const Text('Đăng nhập bằng Google'),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => context.push(RouteNames.settings),
icon: const Icon(Icons.tune),
label: const Text('Mở Cài Đặt Đọc'),
),
],
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
context: context,
label: 'Đang Đọc',
count: bookmarkedCount,
),
),
],
),
const SizedBox(height: 24),
// Settings Button
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => context.push(RouteNames.settings),
icon: const Icon(Icons.tune),
label: const Text('Cài Đặt Đọc'),
),
),
const SizedBox(height: 12),
// Logout Button
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () async {
await ref.read(authProvider.notifier).signOut();
if (context.mounted) context.go(RouteNames.home);
},
icon: const Icon(Icons.logout),
label: const Text('Đăng Xuất'),
),
),
],
),
),
AuthError(:final message) => Center(child: Text(message)),
AuthUnauthenticated() => Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton(
onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(),
child: const Text('Đăng nhập bằng Google'),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => context.push(RouteNames.settings),
icon: const Icon(Icons.tune),
label: const Text('Mở Cài Đặt Đọc'),
),
],
),
),
),
_ => const Center(child: CircularProgressIndicator()),
},
);
}
Widget _buildStatCard({
required BuildContext context,
required String label,
required int count,
}) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Text(
count.toString(),
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
_ => const Center(child: CircularProgressIndicator()),
},
),
],
),
);
}
}
class _AccountStatRow extends StatelessWidget {
const _AccountStatRow({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 18, color: const Color(0xFF58D68D)),
const SizedBox(width: 8),
Expanded(child: Text(label, style: Theme.of(context).textTheme.titleMedium)),
],
);
}
}
class _ProfileMenuTile extends StatelessWidget {
const _ProfileMenuTile({required this.title, required this.onTap});
final String title;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(14),
),
child: ListTile(
title: Text(title),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: onTap,
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -11,6 +11,9 @@ class TtsPlayerWidget extends ConsumerWidget {
required this.content,
this.contentKey,
this.title,
this.nextChapterId,
this.chapterNumber,
this.apiBaseUrl,
this.includeTitleOnStart = true,
this.resolveStartParagraphIndex,
this.onStarted,
@@ -20,6 +23,9 @@ class TtsPlayerWidget extends ConsumerWidget {
final String content;
final String? contentKey;
final String? title;
final String? nextChapterId;
final int? chapterNumber;
final String? apiBaseUrl;
final bool includeTitleOnStart;
final int Function()? resolveStartParagraphIndex;
final VoidCallback? onStarted;
@@ -39,6 +45,8 @@ class TtsPlayerWidget extends ConsumerWidget {
return;
}
notifier.clearPendingAutoStartChapter();
unawaited(
notifier.startReading(
content,
@@ -46,6 +54,9 @@ class TtsPlayerWidget extends ConsumerWidget {
startParagraphIndex: resolveStartParagraphIndex?.call(),
contentKey: contentKey,
title: title,
nextChapterId: nextChapterId,
chapterNumber: chapterNumber,
apiBaseUrl: apiBaseUrl,
includeTitle: includeTitleOnStart,
),
);
@@ -6,6 +6,7 @@ import '../../../core/models/reading_settings.dart';
import '../../../core/network/providers.dart';
import '../../../core/storage/local_store.dart';
import '../../../core/storage/offline_cache.dart';
import '../../bookshelf/providers/bookshelf_provider.dart';
// ─── Chapter content ─────────────────────────────────────────────────────────
@@ -61,6 +62,21 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
scrollOffset: 0,
);
}
void resetCurrentChapterProgress() {
if (state == null) return;
state = ReadingProgress(
novelId: state!.novelId,
chapterId: state!.chapterId,
chapterNumber: state!.chapterNumber,
scrollOffset: 0,
);
// Persist immediately so a freshly opened chapter always resumes at top.
unawaited(_persistProgress(state!.chapterId, state!.chapterNumber, 0));
}
void updateScroll(double offset) {
if (state == null) return;
state = ReadingProgress(
@@ -79,23 +95,45 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
// Also notify server (fire and forget)
try {
final client = _ref.read(apiClientProvider);
await client.dio.post('/api/user/reading-progress', data: {
final res = await client.dio.post('/api/user/reading-progress', data: {
'novelId': _novelId,
'chapterId': chapterId,
'chapterNumber': chapterNumber,
'progress': offset,
});
final data = res.data;
Map<String, dynamic>? bookmarkJson;
if (data is Map<String, dynamic>) {
final bookmark = data['bookmark'];
if (bookmark is Map<String, dynamic>) {
bookmarkJson = bookmark;
}
}
_ref.read(bookshelfProvider.notifier).syncProgress(
novelId: _novelId!,
chapterId: chapterId,
chapterNumber: chapterNumber,
serverBookmark: bookmarkJson,
);
} catch (_) {}
}
DateTime? _lastUpdate;
Future<void> _debounceUpdate(double offset) async {
final now = DateTime.now();
if (_lastUpdate != null && now.difference(_lastUpdate!).inSeconds < 3) return;
_lastUpdate = now;
if (state != null) {
await _persistProgress(state!.chapterId, state!.chapterNumber, offset);
}
Timer? _debounceTimer;
void _debounceUpdate(double offset) {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(seconds: 3), () {
if (state != null) {
unawaited(_persistProgress(state!.chapterId, state!.chapterNumber, offset));
}
});
}
@override
void dispose() {
_debounceTimer?.cancel();
super.dispose();
}
}
@@ -124,6 +162,10 @@ class ReadingSettingsNotifier extends StateNotifier<ReadingSettings> {
final localStore = _ref.read(localStoreProvider);
await localStore.saveReadingSettings(settings);
}
Future<void> setSentenceTapTtsEnabled(bool enabled) async {
await update(state.copyWith(enableSentenceTapTts: enabled));
}
}
final readingSettingsProvider =
@@ -0,0 +1,133 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_tts/flutter_tts.dart';
enum TtsStatus { idle, playing, paused, stopped }
class TtsState {
final TtsStatus status;
final int currentSentenceIndex;
final List<String> sentences;
final double speechRate;
final double volume;
final double pitch;
final String? currentLanguage;
const TtsState({
this.status = TtsStatus.idle,
this.currentSentenceIndex = 0,
this.sentences = const [],
this.speechRate = 0.5,
this.volume = 1.0,
this.pitch = 1.0,
this.currentLanguage,
});
TtsState copyWith({
TtsStatus? status,
int? currentSentenceIndex,
List<String>? sentences,
double? speechRate,
double? volume,
double? pitch,
String? currentLanguage,
}) {
return TtsState(
status: status ?? this.status,
currentSentenceIndex: currentSentenceIndex ?? this.currentSentenceIndex,
sentences: sentences ?? this.sentences,
speechRate: speechRate ?? this.speechRate,
volume: volume ?? this.volume,
pitch: pitch ?? this.pitch,
currentLanguage: currentLanguage ?? this.currentLanguage,
);
}
}
class TtsNotifier extends Notifier<TtsState> {
late FlutterTts _tts;
@override
TtsState build() {
_tts = FlutterTts();
_initTts();
ref.onDispose(() async {
await _tts.stop();
});
return const TtsState();
}
Future<void> _initTts() async {
await _tts.setLanguage('vi-VN');
await _tts.setSpeechRate(state.speechRate);
await _tts.setVolume(state.volume);
await _tts.setPitch(state.pitch);
// Do NOT use awaitSpeakCompletion(true) — it blocks the Dart↔native channel
// between sentences, causing Android TTS service to disconnect.
await _tts.awaitSpeakCompletion(false);
_tts.setCompletionHandler(_onSentenceComplete);
_tts.setCancelHandler(() {
if (state.status == TtsStatus.playing) {
state = state.copyWith(status: TtsStatus.stopped);
}
});
_tts.setErrorHandler((msg) {
state = state.copyWith(status: TtsStatus.stopped);
});
}
void _onSentenceComplete() {
if (state.status != TtsStatus.playing) return;
final nextIndex = state.currentSentenceIndex + 1;
if (nextIndex < state.sentences.length) {
state = state.copyWith(currentSentenceIndex: nextIndex);
_speakCurrent();
} else {
state = state.copyWith(
status: TtsStatus.stopped, currentSentenceIndex: 0);
}
}
Future<void> _speakCurrent() async {
if (state.sentences.isEmpty) return;
if (state.status != TtsStatus.playing) return;
final sentence = state.sentences[state.currentSentenceIndex];
await _tts.speak(sentence);
}
Future<void> play(List<String> sentences) async {
await _tts.stop();
state = state.copyWith(
sentences: sentences,
currentSentenceIndex: 0,
status: TtsStatus.playing,
);
await _speakCurrent();
}
Future<void> pause() async {
state = state.copyWith(status: TtsStatus.paused);
await _tts.pause();
}
Future<void> resume() async {
state = state.copyWith(status: TtsStatus.playing);
await _speakCurrent();
}
Future<void> stop() async {
state = state.copyWith(status: TtsStatus.stopped, currentSentenceIndex: 0);
await _tts.stop();
}
Future<void> setSpeechRate(double rate) async {
await _tts.setSpeechRate(rate);
state = state.copyWith(speechRate: rate);
}
}
final ttsProvider = NotifierProvider<TtsNotifier, TtsState>(TtsNotifier.new);
+119 -22
View File
@@ -5,6 +5,8 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_tts/flutter_tts.dart';
import '../../../core/config/app_config.dart';
enum TtsStatus { idle, playing, paused }
const double kTtsBaseSpeechRate = 0.9;
@@ -155,6 +157,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
int _pendingFallbackIndex = -1;
bool _didStartCurrentFallbackUtterance = false;
bool _hasPromptedNotificationSettings = false;
bool _androidFallbackReady = false;
bool get _useNativeAndroidMediaService => Platform.isAndroid;
@@ -258,6 +261,22 @@ class TtsNotifier extends StateNotifier<TtsState> {
await _mediaChannel.invokeMethod<void>('openNotificationSettings');
}
Future<void> refreshNativeSnapshot() async {
if (!_useNativeAndroidMediaService) return;
if (!_initialized) {
await (_initFuture ?? _init());
return;
}
try {
final snapshot = await _mediaChannel.invokeMethod<dynamic>('getSnapshot');
_applyAndroidSnapshot(snapshot);
} catch (_) {
// Ignore snapshot pull errors; event stream updates will continue.
}
}
Future<void> _configureVietnameseVoiceWithFlutterTts() async {
final dynamic voicesRaw = await _tts.getVoices;
@@ -315,6 +334,73 @@ class TtsNotifier extends StateNotifier<TtsState> {
);
}
Future<void> _ensureAndroidFallbackReady() async {
if (_androidFallbackReady) return;
await _tts.awaitSpeakCompletion(true);
await _tts.setSharedInstance(true);
await _configureVietnameseVoiceWithFlutterTts();
await _tts.setSpeechRate(state.speed);
await _tts.setVolume(1.0);
await _tts.setPitch(1.0);
_tts.setStartHandler(() {
_didStartCurrentFallbackUtterance = true;
final index = _pendingFallbackIndex;
if (index >= 0 && index < _segments.length) {
final segment = _segments[index];
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: index,
activeParagraphIndex: segment.paragraphIndex,
progressStart: segment.start,
progressEnd: segment.end,
);
} else {
state = state.copyWith(status: TtsStatus.playing);
}
});
_tts.setCompletionHandler(() {
// Fallback playback progression is driven by _playFallbackFromGeneration.
});
_tts.setErrorHandler((_) {
if (_isInterruptingPlayback) return;
_pendingFallbackIndex = -1;
_didStartCurrentFallbackUtterance = false;
state = state.copyWith(
status: TtsStatus.idle,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
});
_androidFallbackReady = true;
}
Future<void> _startFallbackReading({
required int validIndex,
required _TtsSegment selectedSegment,
required String? contentKey,
}) async {
await _ensureAndroidFallbackReady();
final sessionId = await _interruptFallbackPlayback();
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: validIndex,
totalParagraphs: _segments.length,
activeParagraphIndex: selectedSegment.paragraphIndex,
progressStart: selectedSegment.start,
progressEnd: selectedSegment.end,
contentKey: contentKey,
);
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
}
void _handleAndroidMediaEvent(dynamic event) {
_applyAndroidSnapshot(event);
}
@@ -372,6 +458,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
// Keep natural sentence flow while removing symbols that are usually read out noisily.
final cleaned = raw
.replaceAll(RegExp(r'["“”]'), ' ')
.replaceAll(RegExp(r'[_$#^*+=~`|<>\\\[\]{}]'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
@@ -566,12 +653,19 @@ class TtsNotifier extends StateNotifier<TtsState> {
int? startCharOffset,
String? contentKey,
String? title,
String? nextChapterId,
int? chapterNumber,
String? apiBaseUrl,
bool includeTitle = true,
}) async {
if (!_initialized) {
await (_initFuture ?? _init());
}
// A direct start request (tap sentence/play button) should win over any
// queued chapter auto-start from previous navigation/completion events.
state = state.copyWith(clearPendingAutoStartChapterId: true);
_segments = _buildSegments(
content,
title: title,
@@ -614,33 +708,36 @@ class TtsNotifier extends StateNotifier<TtsState> {
contentKey: contentKey,
);
await _mediaChannel.invokeMethod<void>('startReading', {
'contentKey': contentKey,
'title': title,
'startIndex': validIndex,
'speed': state.speed,
'language': state.language,
'voiceName': state.voiceName,
'backgroundModeEnabled': state.backgroundModeEnabled,
'segments': _segments.map((segment) => segment.toMap()).toList(),
});
try {
await _mediaChannel.invokeMethod<void>('startReading', {
'content': content,
'contentKey': contentKey,
'title': title,
'nextChapterId': nextChapterId,
'chapterNumber': chapterNumber,
'apiBaseUrl': apiBaseUrl ?? AppConfig.baseUrl,
'startIndex': validIndex,
'speed': state.speed,
'language': state.language,
'voiceName': state.voiceName,
'backgroundModeEnabled': state.backgroundModeEnabled,
'includeTitle': includeTitle,
});
} on PlatformException {
await _startFallbackReading(
validIndex: validIndex,
selectedSegment: selectedSegment,
contentKey: contentKey,
);
}
return;
}
final sessionId = await _interruptFallbackPlayback();
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: validIndex,
totalParagraphs: _segments.length,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
await _startFallbackReading(
validIndex: validIndex,
selectedSegment: selectedSegment,
contentKey: contentKey,
);
await _syncBackgroundMode();
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
}
Future<int> _interruptFallbackPlayback() async {
@@ -1,25 +1,77 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart';
import '../../../core/storage/local_store.dart';
class SplashScreen extends StatefulWidget {
class SplashScreen extends ConsumerStatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
ConsumerState<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
class _SplashScreenState extends ConsumerState<SplashScreen> {
Timer? _redirectTimer;
bool _isRestorableRoute(String path) {
if (path.isEmpty || path == RouteNames.splash) return false;
// Composite "parentPath|deepPath" — validate the deep path portion
final checkPath = path.contains('|') ? path.substring(path.indexOf('|') + 1) : path;
return checkPath == RouteNames.home ||
checkPath == RouteNames.login ||
checkPath == RouteNames.search ||
checkPath.startsWith('${RouteNames.search}?') ||
checkPath == RouteNames.genres ||
checkPath == RouteNames.bookshelf ||
checkPath == RouteNames.profile ||
checkPath == RouteNames.settings ||
checkPath.startsWith('/novel/') ||
checkPath.startsWith('/reader/') ||
checkPath.startsWith('/comments/');
}
@override
void initState() {
super.initState();
_redirectTimer = Timer(const Duration(milliseconds: 700), () {
_redirectTimer = Timer(const Duration(milliseconds: 700), () async {
if (!mounted) return;
final lastPath = await ref.read(localStoreProvider).loadLastRoutePath();
if (!mounted) return;
if (lastPath != null && _isRestorableRoute(lastPath)) {
if (lastPath.contains('|')) {
// Composite "parentPath|deepPath" e.g. "/novel/123|/reader/abc"
// Restore full stack: Home → Novel Detail → Reader
final sep = lastPath.indexOf('|');
final parentPath = lastPath.substring(0, sep);
final deepPath = lastPath.substring(sep + 1);
context.go(RouteNames.home);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.push(parentPath);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) context.push(deepPath);
});
});
} else {
// Single deep route (novel, comments) outside ShellRoute: push on Home
final isDeepRoute = lastPath.startsWith('/reader/') ||
lastPath.startsWith('/novel/') ||
lastPath.startsWith('/comments/');
if (isDeepRoute) {
context.go(RouteNames.home);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) context.push(lastPath);
});
} else {
context.go(lastPath);
}
}
return;
}
context.go(RouteNames.home);
});
}
+108 -30
View File
@@ -8,44 +8,122 @@ class AppShell extends StatelessWidget {
final Widget child;
int _indexForLocation(String location) {
if (location.startsWith(RouteNames.search)) return 1;
if (location.startsWith(RouteNames.bookshelf)) return 2;
if (location.startsWith(RouteNames.genres)) return 3;
if (location.startsWith(RouteNames.profile)) return 4;
return 0;
String _tabForLocation(String location) {
if (location.startsWith(RouteNames.bookshelf)) return RouteNames.bookshelf;
if (location.startsWith(RouteNames.genres)) return RouteNames.genres;
if (location.startsWith(RouteNames.profile)) return RouteNames.profile;
return RouteNames.home;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final location = GoRouterState.of(context).uri.path;
final selectedIndex = _indexForLocation(location);
final selectedTab = _tabForLocation(location);
return Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
selectedIndex: selectedIndex,
onDestinationSelected: (index) {
switch (index) {
case 0:
context.go(RouteNames.home);
case 1:
context.go(RouteNames.search);
case 2:
context.go(RouteNames.bookshelf);
case 3:
context.go(RouteNames.genres);
case 4:
context.go(RouteNames.profile);
}
},
destinations: const [
NavigationDestination(icon: Icon(Icons.home_outlined), label: 'Home'),
NavigationDestination(icon: Icon(Icons.search), label: 'Tim kiem'),
NavigationDestination(icon: Icon(Icons.bookmark_border), label: 'Tu sach'),
NavigationDestination(icon: Icon(Icons.category_outlined), label: 'The loai'),
NavigationDestination(icon: Icon(Icons.person_outline), label: 'Tai khoan'),
],
bottomNavigationBar: Container(
decoration: BoxDecoration(
color: colorScheme.surface,
border: Border(
top: BorderSide(color: colorScheme.outlineVariant.withAlpha(80)),
),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 8, 10, 6),
child: Row(
children: [
_ShellNavItem(
icon: Icons.home_rounded,
label: 'Trang chủ',
selected: selectedTab == RouteNames.home,
onTap: () => context.go(RouteNames.home),
),
_ShellNavItem(
icon: Icons.layers_rounded,
label: 'Tủ sách',
selected: selectedTab == RouteNames.bookshelf,
onTap: () => context.go(RouteNames.bookshelf),
),
_ShellNavItem(
icon: Icons.category_rounded,
label: 'Thể loại',
selected: selectedTab == RouteNames.genres,
onTap: () => context.go(RouteNames.genres),
),
_ShellNavItem(
icon: Icons.person_rounded,
label: 'Tài khoản',
selected: selectedTab == RouteNames.profile,
onTap: () => context.go(RouteNames.profile),
),
],
),
),
),
),
);
}
}
class _ShellNavItem extends StatelessWidget {
const _ShellNavItem({
required this.icon,
required this.label,
required this.selected,
required this.onTap,
});
final IconData icon;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final activeColor = const Color(0xFF14B8A6);
final inactiveColor = colorScheme.onSurfaceVariant;
return Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: selected ? activeColor.withAlpha(28) : Colors.transparent,
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 22,
color: selected ? activeColor : inactiveColor,
),
),
const SizedBox(height: 4),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: selected ? const Color(0xFFF7B500) : inactiveColor,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
],
),
),
),
);
}
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../app/router/route_names.dart';
class MainAppHeader extends StatelessWidget {
const MainAppHeader({
super.key,
this.title = 'Đăng truyện',
this.showSearch = true,
this.showGenresShortcut = true,
this.bottom,
});
final String title;
final bool showSearch;
final bool showGenresShortcut;
final Widget? bottom;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 12),
decoration: BoxDecoration(
color: colorScheme.surface.withAlpha(245),
border: Border(
bottom: BorderSide(color: colorScheme.outlineVariant.withAlpha(90)),
),
),
child: SafeArea(
bottom: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
GestureDetector(
onTap: () => context.go(RouteNames.home),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox(
width: 34,
height: 34,
child: Image.asset(
'assets/app_icon.png',
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) => Icon(
Icons.menu_book_rounded,
color: theme.colorScheme.primary,
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
"Virtus's Reader",
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
color: const Color(0xFF15B8A6),
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 12),
if (showSearch)
IconButton(
tooltip: 'Tìm kiếm',
visualDensity: VisualDensity.compact,
onPressed: () => context.go(RouteNames.search),
icon: const Icon(Icons.search_rounded),
color: const Color(0xFF15B8A6),
),
],
),
if (bottom != null) ...[
const SizedBox(height: 12),
bottom!,
],
],
),
),
);
}
}
+4 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
version: 1.0.3+6
environment:
sdk: ^3.11.3
@@ -78,6 +78,9 @@ flutter:
# the material Icons class.
uses-material-design: true
assets:
- assets/app_icon.png
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg