16 Commits

Author SHA1 Message Date
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
virtus 583a41879f feat: Update app branding and icon assets for Virtus's Reader
Build Android AAB / build-aab (push) Successful in 12m2s
Build Android APK / build-apk (push) Successful in 11m58s
2026-04-16 02:29:03 +07:00
virtus 1256475bf9 feat: Enhance search functionality with initial query parameters and infinite scrolling 2026-04-16 02:08:04 +07:00
virtus c892928ff8 feat: Implement native Android MediaSession and foreground service for TTS playback
- Add `ReaderTtsMediaService` to handle background playback, media controls, and notifications on Android
- Integrate `MediaSessionCompat` to support external media controls and lock screen integration
- Add `ReaderTtsMediaBridge` for synchronized state communication between Kotlin and Flutter
- Update `TtsNotifier` to use the native Android service when available, with a fallback for other platforms
- Implement sentence-level highlighting and tapping to start reading from a specific location
- Update Android manifest with necessary permissions for foreground services and notifications
- Adjust TTS speech rate constants and improve playback health monitoring and recovery logic
2026-04-10 18:57:21 +07:00
virtus 76edaa25a4 feat: Implement native Android MediaSession and foreground service for TTS playback
- Add `ReaderTtsMediaService` to handle background playback, media controls, and notifications on Android
- Integrate `MediaSessionCompat` to support external media controls and lock screen integration
- Add `ReaderTtsMediaBridge` for synchronized state communication between Kotlin and Flutter
- Update `TtsNotifier` to use the native Android service when available, with a fallback for other platforms
- Implement sentence-level highlighting and tapping to start reading from a specific location
- Update Android manifest with necessary permissions for foreground services and notifications
- Adjust TTS speech rate constants and improve playback health monitoring and recovery logic
2026-04-10 18:56:36 +07:00
virtus 2d41121b84 feat: Remove specific Java home configuration from gradle.properties
Build Android AAB / build-aab (push) Successful in 11m41s
Build Android APK / build-apk (push) Successful in 13m58s
2026-04-10 16:11:08 +07:00
virtus 1e8c19e5c9 feat: Enhance Android release signing process with improved key alias resolution and error handling
Build Android APK / build-apk (push) Failing after 2m44s
Build Android AAB / build-aab (push) Failing after 2m55s
2026-04-10 13:11:48 +07:00
virtus 183a0acabb feat: Improve AAB SHA-1 verification by enhancing parsing logic and adding error handling 2026-04-10 11:58:35 +07:00
virtus 62ca390691 feat: Add workflow for building Android AAB with release tagging and signing verification 2026-04-10 11:43:32 +07:00
64 changed files with 5992 additions and 1419 deletions
+236
View File
@@ -0,0 +1,236 @@
name: Build Android AAB
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: "Release tag (example: v1.0.10)"
required: false
jobs:
build-aab:
runs-on: ubuntu-latest
env:
BASE_URL: ${{ secrets.BASE_URL }}
GOOGLE_SERVER_CLIENT_ID: ${{ secrets.GOOGLE_SERVER_CLIENT_ID }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
TOKEN: ${{ secrets.TOKEN }}
RELEASE_TAG: ""
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
EXPECTED_ANDROID_SHA1: ${{ secrets.EXPECTED_ANDROID_SHA1 }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Install Android SDK packages
run: |
sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
set +e
yes | sdkmanager --licenses >/dev/null 2>&1
LICENSE_EXIT=$?
set -e
if [ "$LICENSE_EXIT" -ne 0 ] && [ "$LICENSE_EXIT" -ne 141 ]; then
echo "sdkmanager --licenses failed with exit code $LICENSE_EXIT"
exit "$LICENSE_EXIT"
fi
- name: Show Flutter and Dart version
run: |
flutter --version
flutter doctor -v
- name: Install dependencies
run: flutter pub get
- name: Install release tooling
run: sudo apt-get update && sudo apt-get install -y jq
- name: Resolve release tag input
run: |
RESOLVED_TAG=""
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ -n "${INPUT_RELEASE_TAG:-}" ]; then
RESOLVED_TAG="${INPUT_RELEASE_TAG}"
elif [[ "${GITHUB_REF:-}" == refs/tags/* ]]; then
RESOLVED_TAG="${GITHUB_REF#refs/tags/}"
fi
echo "RELEASE_TAG=${RESOLVED_TAG}" >> "$GITHUB_ENV"
echo "Resolved RELEASE_TAG=${RESOLVED_TAG:-<empty>}"
- name: Prepare Android release signing
run: |
if [ -n "${ANDROID_KEYSTORE_BASE64}" ] && [ -n "${ANDROID_KEYSTORE_PASSWORD}" ] && [ -n "${ANDROID_KEY_PASSWORD}" ]; then
echo "Preparing release keystore from secrets"
if ! python3 -c "import base64,os,re,sys; s=os.environ.get('ANDROID_KEYSTORE_BASE64',''); s=s.strip().strip(chr(34)).strip(chr(39)); s=s.replace('\\n',''); s=re.sub(r'^data:[^,]*,','',s); s=re.sub(r'\\s+','',s); s=s + ('=' * (-len(s) % 4)); (print('ANDROID_KEYSTORE_BASE64 is empty after normalization'), sys.exit(1)) if not s else None; d=base64.b64decode(s, validate=False); open('android/app/release.keystore','wb').write(d); print('Decoded keystore bytes:', len(d))"
then
echo "Keystore decoding failed"
exit 1
fi
if [ ! -s android/app/release.keystore ]; then
echo "Decoded keystore file is empty"
exit 1
fi
if ! keytool -list -keystore android/app/release.keystore -storepass "${ANDROID_KEYSTORE_PASSWORD}" >/dev/null 2>&1; then
echo "Decoded keystore is invalid or ANDROID_KEYSTORE_PASSWORD is incorrect"
exit 1
fi
RESOLVED_KEY_ALIAS="${ANDROID_KEY_ALIAS}"
if [ -z "$RESOLVED_KEY_ALIAS" ]; then
RESOLVED_KEY_ALIAS=$(keytool -list -v -keystore android/app/release.keystore -storepass "${ANDROID_KEYSTORE_PASSWORD}" | awk -F': ' '/Alias name:/{print $2; exit}')
fi
if [ -z "$RESOLVED_KEY_ALIAS" ]; then
echo "Could not resolve key alias from keystore"
exit 1
fi
if ! keytool -list -keystore android/app/release.keystore -storepass "${ANDROID_KEYSTORE_PASSWORD}" -alias "$RESOLVED_KEY_ALIAS" >/dev/null 2>&1; then
echo "Configured key alias does not exist in keystore: $RESOLVED_KEY_ALIAS"
keytool -list -v -keystore android/app/release.keystore -storepass "${ANDROID_KEYSTORE_PASSWORD}" | sed -n 's/^Alias name: /Available alias: /p'
exit 1
fi
echo "Using keystore alias: $RESOLVED_KEY_ALIAS"
{
echo "storeFile=release.keystore"
echo "storePassword=${ANDROID_KEYSTORE_PASSWORD}"
echo "keyAlias=${RESOLVED_KEY_ALIAS}"
echo "keyPassword=${ANDROID_KEY_PASSWORD}"
} > android/key.properties
else
echo "Release signing secrets are required for tagged release builds."
echo "Please configure: ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_PASSWORD"
echo "Optional: ANDROID_KEY_ALIAS (auto-detected if omitted)"
exit 1
fi
- name: Build release AAB
run: |
BASE_URL_VALUE="${BASE_URL:-http://127.0.0.1:8000}"
FLUTTER_CMD=(
flutter build appbundle --release
--dart-define=BASE_URL=${BASE_URL_VALUE}
)
if [ -n "${GOOGLE_SERVER_CLIENT_ID}" ]; then
FLUTTER_CMD+=(--dart-define=GOOGLE_SERVER_CLIENT_ID=${GOOGLE_SERVER_CLIENT_ID})
fi
if [ -n "${GOOGLE_CLIENT_ID}" ]; then
FLUTTER_CMD+=(--dart-define=GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID})
fi
"${FLUTTER_CMD[@]}"
- name: Verify AAB signing certificate
run: |
AAB_PATH="build/app/outputs/bundle/release/app-release.aab"
if [ ! -f "$AAB_PATH" ]; then
echo "AAB not found at $AAB_PATH"
exit 1
fi
CERT_OUTPUT="$(keytool -printcert -jarfile "$AAB_PATH")"
echo "$CERT_OUTPUT"
if [ -n "${EXPECTED_ANDROID_SHA1}" ]; then
AAB_SHA1=$(echo "$CERT_OUTPUT" | awk -F'SHA1:' '/SHA1:/{print $2; exit}')
AAB_SHA1_NORMALIZED=$(echo "$AAB_SHA1" | tr -cd '[:xdigit:]' | tr '[:lower:]' '[:upper:]')
EXPECTED_SHA1_NORMALIZED=$(echo "${EXPECTED_ANDROID_SHA1}" | tr -cd '[:xdigit:]' | tr '[:lower:]' '[:upper:]')
if [ -z "$AAB_SHA1_NORMALIZED" ]; then
echo "Could not parse SHA-1 from keytool output"
exit 1
fi
if [ "$AAB_SHA1_NORMALIZED" != "$EXPECTED_SHA1_NORMALIZED" ]; then
echo "AAB SHA-1 mismatch"
echo "Expected (normalized): $EXPECTED_SHA1_NORMALIZED"
echo "Actual (normalized): $AAB_SHA1_NORMALIZED"
echo "Tip: update EXPECTED_ANDROID_SHA1 to this signer if this is your intended upload key"
exit 1
fi
fi
- name: Create or update Gitea release and upload AAB
run: |
if [ -z "${RELEASE_TAG}" ]; then
echo "No release_tag provided. Build completed without creating a release."
exit 0
fi
if [ -z "${TOKEN}" ]; then
echo "Missing required secret: TOKEN"
exit 1
fi
AAB_PATH="build/app/outputs/bundle/release/app-release.aab"
if [ ! -f "$AAB_PATH" ]; then
echo "AAB not found at $AAB_PATH"
exit 1
fi
OWNER_REPO="${GITHUB_REPOSITORY}"
OWNER="${OWNER_REPO%%/*}"
REPO="${OWNER_REPO##*/}"
TAG="${RELEASE_TAG}"
API_BASE="${GITHUB_SERVER_URL}/api/v1"
RELEASE_JSON=$(curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/repos/${OWNER}/${REPO}/releases" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
|| true)
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id // empty')
if [ -z "$RELEASE_ID" ]; then
RELEASE_JSON=$(curl -sS \
-H "Authorization: token ${TOKEN}" \
"${API_BASE}/repos/${OWNER}/${REPO}/releases/tags/${TAG}")
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id // empty')
fi
if [ -z "$RELEASE_ID" ]; then
echo "Could not resolve release ID for tag ${TAG}"
echo "$RELEASE_JSON"
exit 1
fi
curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${AAB_PATH}" \
"${API_BASE}/repos/${OWNER}/${REPO}/releases/${RELEASE_ID}/assets?name=reader-app-${TAG}.aab"
+22 -3
View File
@@ -84,7 +84,7 @@ jobs:
- name: Prepare Android release signing - name: Prepare Android release signing
run: | run: |
if [ -n "${ANDROID_KEYSTORE_BASE64}" ] && [ -n "${ANDROID_KEYSTORE_PASSWORD}" ] && [ -n "${ANDROID_KEY_ALIAS}" ] && [ -n "${ANDROID_KEY_PASSWORD}" ]; then if [ -n "${ANDROID_KEYSTORE_BASE64}" ] && [ -n "${ANDROID_KEYSTORE_PASSWORD}" ] && [ -n "${ANDROID_KEY_PASSWORD}" ]; then
echo "Preparing release keystore from secrets" echo "Preparing release keystore from secrets"
if ! python3 -c "import base64,os,re,sys; s=os.environ.get('ANDROID_KEYSTORE_BASE64',''); s=s.strip().strip(chr(34)).strip(chr(39)); s=s.replace('\\n',''); s=re.sub(r'^data:[^,]*,','',s); s=re.sub(r'\\s+','',s); s=s + ('=' * (-len(s) % 4)); (print('ANDROID_KEYSTORE_BASE64 is empty after normalization'), sys.exit(1)) if not s else None; d=base64.b64decode(s, validate=False); open('android/app/release.keystore','wb').write(d); print('Decoded keystore bytes:', len(d))" if ! python3 -c "import base64,os,re,sys; s=os.environ.get('ANDROID_KEYSTORE_BASE64',''); s=s.strip().strip(chr(34)).strip(chr(39)); s=s.replace('\\n',''); s=re.sub(r'^data:[^,]*,','',s); s=re.sub(r'\\s+','',s); s=s + ('=' * (-len(s) % 4)); (print('ANDROID_KEYSTORE_BASE64 is empty after normalization'), sys.exit(1)) if not s else None; d=base64.b64decode(s, validate=False); open('android/app/release.keystore','wb').write(d); print('Decoded keystore bytes:', len(d))"
@@ -103,15 +103,34 @@ jobs:
exit 1 exit 1
fi fi
RESOLVED_KEY_ALIAS="${ANDROID_KEY_ALIAS}"
if [ -z "$RESOLVED_KEY_ALIAS" ]; then
RESOLVED_KEY_ALIAS=$(keytool -list -v -keystore android/app/release.keystore -storepass "${ANDROID_KEYSTORE_PASSWORD}" | awk -F': ' '/Alias name:/{print $2; exit}')
fi
if [ -z "$RESOLVED_KEY_ALIAS" ]; then
echo "Could not resolve key alias from keystore"
exit 1
fi
if ! keytool -list -keystore android/app/release.keystore -storepass "${ANDROID_KEYSTORE_PASSWORD}" -alias "$RESOLVED_KEY_ALIAS" >/dev/null 2>&1; then
echo "Configured key alias does not exist in keystore: $RESOLVED_KEY_ALIAS"
keytool -list -v -keystore android/app/release.keystore -storepass "${ANDROID_KEYSTORE_PASSWORD}" | sed -n 's/^Alias name: /Available alias: /p'
exit 1
fi
echo "Using keystore alias: $RESOLVED_KEY_ALIAS"
{ {
echo "storeFile=release.keystore" echo "storeFile=release.keystore"
echo "storePassword=${ANDROID_KEYSTORE_PASSWORD}" echo "storePassword=${ANDROID_KEYSTORE_PASSWORD}"
echo "keyAlias=${ANDROID_KEY_ALIAS}" echo "keyAlias=${RESOLVED_KEY_ALIAS}"
echo "keyPassword=${ANDROID_KEY_PASSWORD}" echo "keyPassword=${ANDROID_KEY_PASSWORD}"
} > android/key.properties } > android/key.properties
else else
echo "Release signing secrets are required for tagged release builds." echo "Release signing secrets are required for tagged release builds."
echo "Please configure: ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD" echo "Please configure: ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_PASSWORD"
echo "Optional: ANDROID_KEY_ALIAS (auto-detected if omitted)"
exit 1 exit 1
fi fi
+8
View File
@@ -115,3 +115,11 @@ Optional (iOS/web):
```bash ```bash
--dart-define=GOOGLE_CLIENT_ID=<YOUR_IOS_OR_WEB_CLIENT_ID>.apps.googleusercontent.com --dart-define=GOOGLE_CLIENT_ID=<YOUR_IOS_OR_WEB_CLIENT_ID>.apps.googleusercontent.com
``` ```
Noted:
Với MIUI:
Cần hướng dẫn user (không thể fix bằng code)
MIUI AutoStart: User phải vào Cài đặt → Ứng dụng → [app] → AutoStart và bật thủ công
MIUI Battery Optimization: User phải vào Cài đặt → Pin → Ứng dụng tiêu hao pin → [app] → chọn "Không hạn chế" (permission REQUEST_IGNORE_BATTERY_OPTIMIZATIONS đã có trong Manifest để trigger dialog, nhưng user vẫn phải accept)
+6 -1
View File
@@ -4,6 +4,7 @@ import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
id("kotlin-parcelize")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services") id("com.google.gms.google-services")
@@ -34,7 +35,7 @@ android {
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.reader_app" applicationId = "dev.fevirtus.reader"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion
@@ -66,6 +67,10 @@ android {
} }
} }
dependencies {
implementation("androidx.media:media:1.7.0")
}
flutter { flutter {
source = "../.." source = "../.."
} }
+20 -4
View File
@@ -7,20 +7,36 @@
"client": [ "client": [
{ {
"client_info": { "client_info": {
"mobilesdk_app_id": "1:308259929553:android:9142ae16d9ddd8a91c34f0", "mobilesdk_app_id": "1:308259929553:android:14f7828b9b9ca9d31c34f0",
"android_client_info": { "android_client_info": {
"package_name": "com.example.reader_app" "package_name": "dev.fevirtus.reader"
} }
}, },
"oauth_client": [ "oauth_client": [
{ {
"client_id": "308259929553-6k3q1g76skt3id4e2mk9k6pr5l7gdtju.apps.googleusercontent.com", "client_id": "308259929553-58cnurk30t6stf9ebj7p5jv1b5gftr29.apps.googleusercontent.com",
"client_type": 1, "client_type": 1,
"android_info": { "android_info": {
"package_name": "com.example.reader_app", "package_name": "dev.fevirtus.reader",
"certificate_hash": "72eb1a744349efea8402128e2d9c98c989ec62b5"
}
},
{
"client_id": "308259929553-fd8teopc4chi2jjd8kr5vn9inn35ar6j.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "dev.fevirtus.reader",
"certificate_hash": "fa21a3e6a319b71b2dd0ef9573b22046dba5d55c" "certificate_hash": "fa21a3e6a319b71b2dd0ef9573b22046dba5d55c"
} }
}, },
{
"client_id": "308259929553-kdfvnu11cq6k9a2l1b3gtrmfmtsggduk.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "dev.fevirtus.reader",
"certificate_hash": "f7e9f7ec9bafd1de69934b2c9b52ee491d73bad7"
}
},
{ {
"client_id": "308259929553-9oame596io3s4lcj9cdb5db6v3i6f6rk.apps.googleusercontent.com", "client_id": "308259929553-9oame596io3s4lcj9cdb5db6v3i6f6rk.apps.googleusercontent.com",
"client_type": 3 "client_type": 3
+8 -1
View File
@@ -2,8 +2,11 @@
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application <application
android:label="reader_app" android:label="Virtus's Reader"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true">
@@ -34,6 +37,10 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<service
android:name=".tts.ReaderTtsMediaService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and
@@ -6,12 +6,19 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.PowerManager import android.os.PowerManager
import android.provider.Settings import android.provider.Settings
import androidx.core.app.NotificationManagerCompat
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import com.example.reader_app.tts.ReaderTtsMediaBridge
import com.example.reader_app.tts.ReaderTtsMediaService
import com.example.reader_app.tts.ReaderTtsStartRequest
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private val channelName = "reader_app/tts_background" private val channelName = "reader_app/tts_background"
private val mediaChannelName = "reader_app/tts_media"
private val mediaEventsChannelName = "reader_app/tts_media_events"
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
@@ -35,6 +42,110 @@ class MainActivity : FlutterActivity() {
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, mediaChannelName)
.setMethodCallHandler { call, result ->
when (call.method) {
"initialize" -> {
val enabled = call.argument<Boolean>("backgroundModeEnabled") ?: true
ReaderTtsMediaService.initialize(this, enabled)
result.success(ReaderTtsMediaBridge.snapshot())
}
"getSnapshot" -> result.success(ReaderTtsMediaBridge.snapshot())
"startReading" -> {
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,
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)
}
"pause" -> {
ReaderTtsMediaService.pause(this)
result.success(null)
}
"resume" -> {
ReaderTtsMediaService.resume(this)
result.success(null)
}
"stop" -> {
ReaderTtsMediaService.stop(this)
result.success(null)
}
"skipForward" -> {
ReaderTtsMediaService.skipForward(this)
result.success(null)
}
"skipBack" -> {
ReaderTtsMediaService.skipBack(this)
result.success(null)
}
"setSpeed" -> {
val speed = call.argument<Double>("speed") ?: 0.9
ReaderTtsMediaService.setSpeed(this, speed)
result.success(null)
}
"setVoiceByName" -> {
ReaderTtsMediaService.setVoice(
this,
call.argument<String>("voiceName"),
call.argument<String>("language"),
)
result.success(null)
}
"setBackgroundModeEnabled" -> {
val enabled = call.argument<Boolean>("enabled") ?: true
ReaderTtsMediaService.setBackgroundModeEnabled(this, enabled)
result.success(null)
}
"areNotificationsEnabled" -> {
result.success(NotificationManagerCompat.from(this).areNotificationsEnabled())
}
"openNotificationSettings" -> {
openNotificationSettings()
result.success(null)
}
"dispose" -> result.success(null)
else -> result.notImplemented()
}
}
EventChannel(flutterEngine.dartExecutor.binaryMessenger, mediaEventsChannelName)
.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
ReaderTtsMediaBridge.attachSink(events)
}
override fun onCancel(arguments: Any?) {
ReaderTtsMediaBridge.detachSink()
}
},
)
} }
private fun isIgnoringBatteryOptimizations(): Boolean { private fun isIgnoringBatteryOptimizations(): Boolean {
@@ -76,6 +187,19 @@ class MainActivity : FlutterActivity() {
wakeLock = null wakeLock = null
} }
private fun openNotificationSettings() {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
}
} else {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
}
}
startActivity(intent)
}
override fun onDestroy() { override fun onDestroy() {
setWakeLockEnabled(false) setWakeLockEnabled(false)
super.onDestroy() super.onDestroy()
@@ -0,0 +1,44 @@
package com.example.reader_app.tts
import io.flutter.plugin.common.EventChannel
object ReaderTtsMediaBridge {
private var eventSink: EventChannel.EventSink? = null
private var latestSnapshot: Map<String, Any?> = defaultSnapshot()
@Synchronized
fun attachSink(sink: EventChannel.EventSink) {
eventSink = sink
sink.success(HashMap(latestSnapshot))
}
@Synchronized
fun detachSink() {
eventSink = null
}
@Synchronized
fun publish(snapshot: Map<String, Any?>) {
latestSnapshot = HashMap(snapshot)
eventSink?.success(HashMap(latestSnapshot))
}
@Synchronized
fun snapshot(): Map<String, Any?> = HashMap(latestSnapshot)
private fun defaultSnapshot(): Map<String, Any?> = hashMapOf(
"status" to "idle",
"paragraphIndex" to 0,
"totalParagraphs" to 0,
"activeParagraphIndex" to -1,
"progressStart" to -1,
"progressEnd" to -1,
"contentKey" to null,
"completedCount" to 0,
"backgroundModeEnabled" to true,
"language" to "vi-VN",
"voiceName" to null,
"availableVietnameseVoices" to emptyList<Map<String, String>>()
)
}
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)
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

+3
View File
@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
+2 -2
View File
@@ -431,7 +431,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";
@@ -488,7 +488,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";
@@ -1,122 +1 @@
{ {"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 54 KiB

+1 -1
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Reader App</string> <string>Virtus's Reader</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
+75
View File
@@ -1,9 +1,15 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../core/auth/session_expiry_notifier.dart'; import '../core/auth/session_expiry_notifier.dart';
import '../core/theme/app_theme.dart'; import '../core/theme/app_theme.dart';
import '../core/storage/local_store.dart';
import '../features/auth/providers/auth_provider.dart'; import '../features/auth/providers/auth_provider.dart';
import '../features/reader/tts/tts_service.dart';
import 'router/route_names.dart'; import 'router/route_names.dart';
import 'router/app_router.dart'; import 'router/app_router.dart';
@@ -17,10 +23,39 @@ class ReaderApp extends ConsumerStatefulWidget {
class _ReaderAppState extends ConsumerState<ReaderApp> { class _ReaderAppState extends ConsumerState<ReaderApp> {
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>(); final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
ProviderSubscription<int>? _sessionExpirySub; ProviderSubscription<int>? _sessionExpirySub;
late final GoRouter _router;
String? _previousPath;
void _persistRouteForRestore() {
if (!mounted) return;
final uri = _router.state.uri;
final fullPath = uri.hasQuery ? '${uri.path}?${uri.query}' : uri.path;
if (fullPath == RouteNames.splash) return;
// When navigating into reader from a novel page, save "novelPath|readerPath"
// so the splash screen can reconstruct the full back stack on restore.
final String pathToSave;
if (fullPath.startsWith('/reader/') &&
_previousPath != null &&
_previousPath!.startsWith('/novel/')) {
pathToSave = '$_previousPath|$fullPath';
} else {
pathToSave = fullPath;
}
_previousPath = fullPath;
unawaited(ref.read(localStoreProvider).saveLastRoutePath(pathToSave));
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_router = ref.read(appRouterProvider);
_router.routerDelegate.addListener(_persistRouteForRestore);
WidgetsBinding.instance.addPostFrameCallback((_) {
_ensureMandatoryTtsRequirements();
});
_sessionExpirySub = ref.listenManual<int>( _sessionExpirySub = ref.listenManual<int>(
sessionExpiryProvider, sessionExpiryProvider,
(previous, next) async { (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 @override
void dispose() { void dispose() {
_router.routerDelegate.removeListener(_persistRouteForRestore);
_sessionExpirySub?.close(); _sessionExpirySub?.close();
super.dispose(); super.dispose();
} }
+11 -1
View File
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -36,7 +37,16 @@ final appRouterProvider = Provider<GoRouter>((ref) {
), ),
GoRoute( GoRoute(
path: RouteNames.search, path: RouteNames.search,
builder: (context, state) => const SearchScreen(), builder: (context, state) {
final query = state.uri.queryParameters;
return SearchScreen(
key: ValueKey(state.uri.toString()),
initialQuery: query['q'],
initialGenre: query['genre'],
initialStatus: query['status'],
initialSort: query['sort'] ?? 'latest',
);
},
), ),
GoRoute( GoRoute(
path: RouteNames.genres, path: RouteNames.genres,
+1 -1
View File
@@ -11,7 +11,7 @@ class AppConfig {
} }
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
return 'http://10.0.2.2:8000'; return 'https://reader-api.fevirtus.dev';
} }
return 'http://localhost:8000'; return 'http://localhost:8000';
+34 -1
View File
@@ -2,10 +2,26 @@ import 'package:equatable/equatable.dart';
import 'novel_model.dart'; import 'novel_model.dart';
enum BookmarkType {
reading('reading'),
bookmarked('bookmarked');
const BookmarkType(this.value);
final String value;
static BookmarkType fromString(String? str) {
return values.firstWhere(
(e) => e.value == str,
orElse: () => BookmarkType.bookmarked,
);
}
}
class BookmarkModel extends Equatable { class BookmarkModel extends Equatable {
const BookmarkModel({ const BookmarkModel({
required this.id, required this.id,
required this.novelId, required this.novelId,
this.type = BookmarkType.bookmarked,
this.lastChapterId, this.lastChapterId,
this.lastChapterNumber, this.lastChapterNumber,
this.readChapters = const [], this.readChapters = const [],
@@ -14,6 +30,7 @@ class BookmarkModel extends Equatable {
final String id; final String id;
final String novelId; final String novelId;
final BookmarkType type;
final String? lastChapterId; final String? lastChapterId;
final int? lastChapterNumber; final int? lastChapterNumber;
final List<int> readChapters; final List<int> readChapters;
@@ -28,11 +45,27 @@ class BookmarkModel extends Equatable {
?.map((e) => (e as num).toInt()) ?.map((e) => (e as num).toInt())
.toList() ?? .toList() ??
[], [],
type: () {
final explicitType = BookmarkType.fromString(json['type'] as String?);
if ((json['type'] as String?) != null) {
return explicitType;
}
// Backward-compatible inference when API does not return `type`.
final inferredLastChapter = json['lastChapterNumber'] as int?;
final inferredReadChapters = (json['readChapters'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList() ??
const <int>[];
return (inferredLastChapter != null || inferredReadChapters.isNotEmpty)
? BookmarkType.reading
: BookmarkType.bookmarked;
}(),
novel: json['novel'] != null novel: json['novel'] != null
? NovelModel.fromJson(json['novel'] as Map<String, dynamic>) ? NovelModel.fromJson(json['novel'] as Map<String, dynamic>)
: null, : null,
); );
@override @override
List<Object?> get props => [id, novelId]; List<Object?> get props => [id, novelId, type];
} }
+21 -2
View File
@@ -5,9 +5,12 @@ class ReadingSettings {
this.letterSpacing = 0, this.letterSpacing = 0,
this.fontFamily = 'serif', this.fontFamily = 'serif',
this.themePreset = 'paper', this.themePreset = 'paper',
this.backgroundColorValue = 0xFFFFFEF8,
this.textColorValue = 0xFF111111,
this.horizontalPadding = 20, this.horizontalPadding = 20,
this.paragraphSpacing = 24, this.paragraphSpacing = 24,
this.textAlign = 'justify', this.textAlign = 'left',
this.enableSentenceTapTts = false,
}); });
final double fontSize; final double fontSize;
@@ -15,9 +18,12 @@ class ReadingSettings {
final double letterSpacing; final double letterSpacing;
final String fontFamily; final String fontFamily;
final String themePreset; final String themePreset;
final int backgroundColorValue;
final int textColorValue;
final double horizontalPadding; final double horizontalPadding;
final double paragraphSpacing; final double paragraphSpacing;
final String textAlign; final String textAlign;
final bool enableSentenceTapTts;
ReadingSettings copyWith({ ReadingSettings copyWith({
double? fontSize, double? fontSize,
@@ -25,9 +31,12 @@ class ReadingSettings {
double? letterSpacing, double? letterSpacing,
String? fontFamily, String? fontFamily,
String? themePreset, String? themePreset,
int? backgroundColorValue,
int? textColorValue,
double? horizontalPadding, double? horizontalPadding,
double? paragraphSpacing, double? paragraphSpacing,
String? textAlign, String? textAlign,
bool? enableSentenceTapTts,
}) => }) =>
ReadingSettings( ReadingSettings(
fontSize: fontSize ?? this.fontSize, fontSize: fontSize ?? this.fontSize,
@@ -35,9 +44,12 @@ class ReadingSettings {
letterSpacing: letterSpacing ?? this.letterSpacing, letterSpacing: letterSpacing ?? this.letterSpacing,
fontFamily: fontFamily ?? this.fontFamily, fontFamily: fontFamily ?? this.fontFamily,
themePreset: themePreset ?? this.themePreset, themePreset: themePreset ?? this.themePreset,
backgroundColorValue: backgroundColorValue ?? this.backgroundColorValue,
textColorValue: textColorValue ?? this.textColorValue,
horizontalPadding: horizontalPadding ?? this.horizontalPadding, horizontalPadding: horizontalPadding ?? this.horizontalPadding,
paragraphSpacing: paragraphSpacing ?? this.paragraphSpacing, paragraphSpacing: paragraphSpacing ?? this.paragraphSpacing,
textAlign: textAlign ?? this.textAlign, textAlign: textAlign ?? this.textAlign,
enableSentenceTapTts: enableSentenceTapTts ?? this.enableSentenceTapTts,
); );
factory ReadingSettings.fromJson(Map<String, dynamic> json) => ReadingSettings( factory ReadingSettings.fromJson(Map<String, dynamic> json) => ReadingSettings(
@@ -46,9 +58,13 @@ class ReadingSettings {
letterSpacing: (json['letterSpacing'] as num?)?.toDouble() ?? 0, letterSpacing: (json['letterSpacing'] as num?)?.toDouble() ?? 0,
fontFamily: json['fontFamily'] as String? ?? 'serif', fontFamily: json['fontFamily'] as String? ?? 'serif',
themePreset: json['themePreset'] as String? ?? 'paper', 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, horizontalPadding: (json['horizontalPadding'] as num?)?.toDouble() ?? 20,
paragraphSpacing: (json['paragraphSpacing'] as num?)?.toDouble() ?? 24, paragraphSpacing: (json['paragraphSpacing'] as num?)?.toDouble() ?? 24,
textAlign: json['textAlign'] as String? ?? 'justify', textAlign: json['textAlign'] as String? ?? 'left',
enableSentenceTapTts: json['enableSentenceTapTts'] as bool? ?? false,
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
@@ -57,8 +73,11 @@ class ReadingSettings {
'letterSpacing': letterSpacing, 'letterSpacing': letterSpacing,
'fontFamily': fontFamily, 'fontFamily': fontFamily,
'themePreset': themePreset, 'themePreset': themePreset,
'backgroundColorValue': backgroundColorValue,
'textColorValue': textColorValue,
'horizontalPadding': horizontalPadding, 'horizontalPadding': horizontalPadding,
'paragraphSpacing': paragraphSpacing, 'paragraphSpacing': paragraphSpacing,
'textAlign': textAlign, 'textAlign': textAlign,
'enableSentenceTapTts': enableSentenceTapTts,
}; };
} }
+42 -2
View File
@@ -8,12 +8,15 @@ class LocalStore {
static const _kLetterSpacing = 'reader_letter_spacing'; static const _kLetterSpacing = 'reader_letter_spacing';
static const _kFontFamily = 'reader_font_family'; static const _kFontFamily = 'reader_font_family';
static const _kThemePreset = 'reader_theme_preset'; 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 _kHorizontalPadding = 'reader_horizontal_padding';
static const _kParagraphSpacing = 'reader_paragraph_spacing'; static const _kParagraphSpacing = 'reader_paragraph_spacing';
static const _kTextAlign = 'reader_text_align'; static const _kTextAlign = 'reader_text_align';
static const _kProgressChapterId = 'progress_chapter_id_'; static const _kProgressChapterId = 'progress_chapter_id_';
static const _kProgressChapterNum = 'progress_chapter_num_'; static const _kProgressChapterNum = 'progress_chapter_num_';
static const _kProgressOffset = 'progress_offset_'; static const _kProgressOffset = 'progress_offset_';
static const _kLastRoutePath = 'last_route_path';
// ── Reading settings ────────────────────────────────────────────────────── // ── Reading settings ──────────────────────────────────────────────────────
@@ -24,6 +27,8 @@ class LocalStore {
await prefs.setDouble(_kLetterSpacing, settings.letterSpacing); await prefs.setDouble(_kLetterSpacing, settings.letterSpacing);
await prefs.setString(_kFontFamily, settings.fontFamily); await prefs.setString(_kFontFamily, settings.fontFamily);
await prefs.setString(_kThemePreset, settings.themePreset); 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(_kHorizontalPadding, settings.horizontalPadding);
await prefs.setDouble(_kParagraphSpacing, settings.paragraphSpacing); await prefs.setDouble(_kParagraphSpacing, settings.paragraphSpacing);
await prefs.setString(_kTextAlign, settings.textAlign); await prefs.setString(_kTextAlign, settings.textAlign);
@@ -32,15 +37,29 @@ class LocalStore {
Future<ReadingSettings?> loadReadingSettings() async { Future<ReadingSettings?> loadReadingSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
if (!prefs.containsKey(_kFontSize)) return null; 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( return ReadingSettings(
fontSize: prefs.getDouble(_kFontSize) ?? 18, fontSize: prefs.getDouble(_kFontSize) ?? 18,
lineHeight: prefs.getDouble(_kLineHeight) ?? 1.8, lineHeight: prefs.getDouble(_kLineHeight) ?? 1.8,
letterSpacing: prefs.getDouble(_kLetterSpacing) ?? 0, letterSpacing: prefs.getDouble(_kLetterSpacing) ?? 0,
fontFamily: prefs.getString(_kFontFamily) ?? 'serif', 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, horizontalPadding: prefs.getDouble(_kHorizontalPadding) ?? 20,
paragraphSpacing: prefs.getDouble(_kParagraphSpacing) ?? 24, 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, 'scrollOffset': prefs.getDouble('$_kProgressOffset$novelId') ?? 0.0,
}; };
} }
// ── Last route restore (cold start after process reclaim) ───────────────
Future<void> saveLastRoutePath(String path) async {
final normalized = path.trim();
if (normalized.isEmpty || normalized == '/') return;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kLastRoutePath, normalized);
}
Future<String?> loadLastRoutePath() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getString(_kLastRoutePath)?.trim();
if (value == null || value.isEmpty) return null;
return value;
}
Future<void> clearLastRoutePath() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kLastRoutePath);
}
} }
final localStoreProvider = Provider<LocalStore>((_) => LocalStore()); final localStoreProvider = Provider<LocalStore>((_) => LocalStore());
@@ -5,6 +5,8 @@ import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart'; import '../../../app/router/route_names.dart';
import '../../../core/models/bookmark_model.dart'; import '../../../core/models/bookmark_model.dart';
import '../../../shared/widgets/main_app_header.dart';
import '../../novel/providers/novels_provider.dart';
import '../providers/bookshelf_provider.dart'; import '../providers/bookshelf_provider.dart';
import '../../auth/providers/auth_provider.dart'; import '../../auth/providers/auth_provider.dart';
@@ -17,12 +19,17 @@ class BookshelfScreen extends ConsumerWidget {
if (!isAuth) { if (!isAuth) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Tủ sách')), body: Column(
body: Center( children: [
const MainAppHeader(title: 'Đăng truyện'),
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.lock_outline, size: 48), const Icon(Icons.lock_outline_rounded, size: 54),
const SizedBox(height: 12), const SizedBox(height: 12),
const Text('Vui lòng đăng nhập để xem tủ sách'), const Text('Vui lòng đăng nhập để xem tủ sách'),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -33,28 +40,49 @@ class BookshelfScreen extends ConsumerWidget {
], ],
), ),
), ),
),
),
],
),
); );
} }
final bookshelfAsync = ref.watch(bookshelfProvider); final bookshelfAsync = ref.watch(bookshelfProvider);
return Scaffold( return Scaffold(
appBar: AppBar( body: DefaultTabController(
title: const Text('Tủ sách'), length: 2,
actions: [ child: Column(
IconButton( children: [
icon: const Icon(Icons.refresh), MainAppHeader(
onPressed: () => ref.read(bookshelfProvider.notifier).fetch(), 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'),
], ],
), ),
body: bookshelfAsync.when( ),
),
Expanded(
child: bookshelfAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center( error: (e, _) => Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.error_outline, size: 48), const Icon(Icons.error_outline_rounded, size: 48),
const SizedBox(height: 8), const SizedBox(height: 8),
Text('Lỗi: $e'), Text('Lỗi: $e'),
TextButton( TextButton(
@@ -65,25 +93,65 @@ class BookshelfScreen extends ConsumerWidget {
), ),
), ),
data: (bookmarks) { 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) { if (bookmarks.isEmpty) {
return const Center( return Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.menu_book_outlined, size: 56), const Icon(Icons.menu_book_outlined, size: 56),
SizedBox(height: 12), const SizedBox(height: 12),
Text('Chưa có truyện nào trong tủ sách'), Text(emptyLabel),
], ],
), ),
); );
} }
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => ref.read(bookshelfProvider.notifier).fetch(), onRefresh: () => ref.read(bookshelfProvider.notifier).fetch(),
child: ListView.builder( child: ListView.separated(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 24),
itemCount: bookmarks.length, itemCount: bookmarks.length,
itemBuilder: (context, index) => separatorBuilder: (context, index) => const SizedBox(height: 12),
_BookmarkTile(bookmark: bookmarks[index]), itemBuilder: (context, index) {
), final bookmark = bookmarks[index];
return _BookmarkTile(
bookmark: bookmark,
onRemove: () => ref
.read(bookshelfProvider.notifier)
.removeFromShelf(bookmark.novelId, bookmark.type),
); );
}, },
), ),
@@ -91,39 +159,145 @@ class BookshelfScreen extends ConsumerWidget {
} }
} }
class _BookmarkTile extends StatelessWidget { class _BookmarkTile extends ConsumerWidget {
final BookmarkModel bookmark; final BookmarkModel bookmark;
const _BookmarkTile({required this.bookmark}); final VoidCallback onRemove;
const _BookmarkTile({
required this.bookmark,
required this.onRemove,
});
Future<void> _openContinueReader(BuildContext context, WidgetRef ref) async {
var targetChapterId = bookmark.lastChapterId;
if (targetChapterId == null || targetChapterId.isEmpty) {
try {
final chapters = await ref.read(
chapterListProvider(bookmark.novelId).future,
);
if (chapters.isNotEmpty) {
targetChapterId = chapters.first.id;
}
} catch (_) {
// Fall through to novel detail when chapter lookup fails.
}
}
if (!context.mounted) return;
if (targetChapterId != null && targetChapterId.isNotEmpty) {
context.push(RouteNames.readerChapter(targetChapterId));
return;
}
context.push(RouteNames.novelDetail(bookmark.novelId));
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final novel = bookmark.novel; final novel = bookmark.novel;
return ListTile( return GestureDetector(
leading: ClipRRect( onTap: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
borderRadius: BorderRadius.circular(6), 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 child: novel?.coverUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: novel!.coverUrl!, imageUrl: novel!.coverUrl!,
width: 44, width: 92,
height: 60, height: 126,
fit: BoxFit.cover, fit: BoxFit.cover,
) )
: Container( : Container(
width: 44, width: 92,
height: 60, height: 126,
color: Theme.of(context).colorScheme.primaryContainer, color: Theme.of(context).colorScheme.primaryContainer,
child: const Icon(Icons.menu_book, size: 20), child: const Icon(Icons.menu_book, size: 28),
), ),
), ),
title: Text( const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
novel?.title ?? bookmark.novelId, 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, maxLines: 1,
overflow: TextOverflow.ellipsis, 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,
),
),
),
],
),
],
),
), ),
subtitle: novel?.authorName != null
? Text(novel!.authorName, maxLines: 1, overflow: TextOverflow.ellipsis)
: null,
onTap: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
); );
} }
} }
@@ -23,6 +23,62 @@ class BookshelfNotifier extends StateNotifier<AsyncValue<List<BookmarkModel>>> {
} }
} }
void syncProgress({
required String novelId,
required String chapterId,
required int chapterNumber,
Map<String, dynamic>? serverBookmark,
}) {
final current = state.valueOrNull ?? const <BookmarkModel>[];
BookmarkModel? parsedFromServer;
if (serverBookmark != null) {
try {
parsedFromServer = BookmarkModel.fromJson(serverBookmark);
} catch (_) {
parsedFromServer = null;
}
}
final index = current.indexWhere((b) => b.novelId == novelId);
if (index >= 0) {
final existing = current[index];
final merged = parsedFromServer ?? BookmarkModel(
id: existing.id,
novelId: existing.novelId,
type: BookmarkType.reading,
lastChapterId: chapterId,
lastChapterNumber: chapterNumber,
readChapters: {
...existing.readChapters,
chapterNumber,
}.toList()
..sort(),
novel: existing.novel,
);
final updated = [...current]..[index] = merged;
state = AsyncValue.data(updated);
return;
}
if (parsedFromServer != null) {
state = AsyncValue.data([parsedFromServer, ...current]);
return;
}
// Fallback when API response doesn't include bookmark object.
final synthetic = BookmarkModel(
id: 'progress-$novelId',
novelId: novelId,
type: BookmarkType.reading,
lastChapterId: chapterId,
lastChapterNumber: chapterNumber,
readChapters: [chapterNumber],
);
state = AsyncValue.data([synthetic, ...current]);
}
Future<void> toggle(String novelId) async { Future<void> toggle(String novelId) async {
try { try {
final client = _ref.read(apiClientProvider); final client = _ref.read(apiClientProvider);
@@ -44,6 +100,22 @@ class BookshelfNotifier extends StateNotifier<AsyncValue<List<BookmarkModel>>> {
bool isBookmarked(String novelId) { bool isBookmarked(String novelId) {
return (state.valueOrNull ?? []).any((b) => b.novelId == novelId); return (state.valueOrNull ?? []).any((b) => b.novelId == novelId);
} }
Future<void> removeFromShelf(String novelId, BookmarkType type) async {
try {
final client = _ref.read(apiClientProvider);
await client.dio.delete(
'/api/user/bookmarks/$novelId',
queryParameters: {'type': type.value},
);
final current = state.valueOrNull ?? [];
state = AsyncValue.data(
current.where((b) => b.novelId != novelId || b.type != type).toList(),
);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
} }
final bookshelfProvider = final bookshelfProvider =
@@ -51,6 +123,16 @@ final bookshelfProvider =
return BookshelfNotifier(ref); return BookshelfNotifier(ref);
}); });
final readingBookmarksProvider = Provider<List<BookmarkModel>>((ref) {
final bookmarks = ref.watch(bookshelfProvider).valueOrNull ?? [];
return bookmarks.where((b) => b.type == BookmarkType.reading).toList();
});
final savedBookmarksProvider = Provider<List<BookmarkModel>>((ref) {
final bookmarks = ref.watch(bookshelfProvider).valueOrNull ?? [];
return bookmarks.where((b) => b.type == BookmarkType.bookmarked).toList();
});
final isBookmarkedProvider = Provider.family<bool, String>((ref, novelId) { final isBookmarkedProvider = Provider.family<bool, String>((ref, novelId) {
final bookshelf = ref.watch(bookshelfProvider); final bookshelf = ref.watch(bookshelfProvider);
return bookshelf.valueOrNull?.any((b) => b.novelId == novelId) ?? false; return bookshelf.valueOrNull?.any((b) => b.novelId == novelId) ?? false;
+312 -49
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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart'; import '../../../app/router/route_names.dart';
import '../../../core/models/novel_model.dart'; import '../../../core/models/novel_model.dart';
import '../../../shared/widgets/main_app_header.dart';
import '../providers/home_provider.dart'; import '../providers/home_provider.dart';
class HomeScreen extends ConsumerWidget { class HomeScreen extends ConsumerWidget {
@@ -13,63 +16,68 @@ class HomeScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final homeAsync = ref.watch(homeProvider); final homeAsync = ref.watch(homeProvider);
final colorScheme = Theme.of(context).colorScheme;
return Scaffold( return Scaffold(
appBar: AppBar( backgroundColor: colorScheme.surface,
title: const Text('Reader'), body: Column(
actions: [ children: [
IconButton( const MainAppHeader(),
icon: const Icon(Icons.search), Expanded(
onPressed: () => context.go(RouteNames.search), child: homeAsync.when(
),
],
),
body: homeAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center( error: (e, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.error_outline, size: 48), const Icon(Icons.cloud_off_rounded, size: 52),
const SizedBox(height: 12), const SizedBox(height: 12),
Text('Lỗi tải dữ liệu', style: Theme.of(context).textTheme.bodyLarge), Text('Không thể tải dữ liệu trang chủ'),
Padding( const SizedBox(height: 8),
padding: const EdgeInsets.fromLTRB(24, 8, 24, 0), Text(
child: Text(
e.toString(), e.toString(),
textAlign: TextAlign.center,
maxLines: 3, maxLines: 3,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
), const SizedBox(height: 12),
TextButton( FilledButton(
onPressed: () => ref.invalidate(homeProvider), onPressed: () => ref.invalidate(homeProvider),
child: const Text('Thử lại'), child: const Text('Tải lại'),
), ),
], ],
), ),
), ),
),
data: (data) => RefreshIndicator( data: (data) => RefreshIndicator(
onRefresh: () async => ref.invalidate(homeProvider), onRefresh: () async => ref.invalidate(homeProvider),
child: ListView( child: ListView(
padding: const EdgeInsets.fromLTRB(0, 12, 0, 24),
children: [ children: [
_HotCarousel(novels: data.hot), _HotCarousel(novels: data.hot),
const SizedBox(height: 12),
const _HomeQuickFilters(),
_SectionHeader( _SectionHeader(
title: 'Mới cập nht', title: 'Truyện mới nht',
onMore: () => context.go(RouteNames.search), onMore: () => context.go(RouteNames.search),
), ),
_NovelHorizontalList(novels: data.latest), _NovelHorizontalList(novels: data.latest),
_SectionHeader( _SectionHeader(
title: 'Đánh giá cao', title: 'Đề cử nổi bật',
onMore: () => context.go(RouteNames.search), onMore: () => context.go('${RouteNames.search}?sort=rating'),
), ),
_NovelHorizontalList(novels: data.topRated), _FeatureGrid(novels: data.topRated.take(6).toList()),
const SizedBox(height: 16), const SizedBox(height: 12),
], ],
), ),
), ),
), ),
),
],
),
); );
} }
} }
@@ -82,18 +90,82 @@ class _SectionHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) => Padding( Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 8, 8), padding: const EdgeInsets.fromLTRB(18, 18, 12, 6),
child: Row( child: Row(
children: [ children: [
Text(title, style: Theme.of(context).textTheme.titleMedium), Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
const Spacer(), const Spacer(),
if (onMore != null) 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 { class _HotCarousel extends StatefulWidget {
final List<NovelModel> novels; final List<NovelModel> novels;
const _HotCarousel({required this.novels}); const _HotCarousel({required this.novels});
@@ -103,10 +175,58 @@ class _HotCarousel extends StatefulWidget {
} }
class _HotCarouselState extends State<_HotCarousel> { 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 @override
void dispose() { void dispose() {
_autoSlideTimer?.cancel();
_controller.dispose(); _controller.dispose();
super.dispose(); super.dispose();
} }
@@ -115,10 +235,19 @@ class _HotCarouselState extends State<_HotCarousel> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.novels.isEmpty) return const SizedBox.shrink(); if (widget.novels.isEmpty) return const SizedBox.shrink();
return SizedBox( return SizedBox(
height: 220, 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( child: PageView.builder(
controller: _controller, controller: _controller,
itemCount: widget.novels.length, itemCount: widget.novels.length,
onPageChanged: (value) => setState(() => _currentPage = value),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final novel = widget.novels[index]; final novel = widget.novels[index];
return GestureDetector( return GestureDetector(
@@ -127,6 +256,29 @@ class _HotCarouselState extends State<_HotCarousel> {
); );
}, },
), ),
),
),
),
),
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,11 +289,7 @@ class _CarouselCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Stack(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 12),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
if (novel.coverUrl != null) if (novel.coverUrl != null)
@@ -149,8 +297,7 @@ class _CarouselCard extends StatelessWidget {
imageUrl: novel.coverUrl!, imageUrl: novel.coverUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (_, imageUrl) => Container(color: Colors.grey[200]), placeholder: (_, imageUrl) => Container(color: Colors.grey[200]),
errorWidget: (_, imageUrl, error) => errorWidget: (_, imageUrl, error) => Container(color: Colors.grey[300]),
Container(color: Colors.grey[300]),
) )
else else
Container(color: Theme.of(context).colorScheme.primaryContainer), Container(color: Theme.of(context).colorScheme.primaryContainer),
@@ -169,20 +316,49 @@ class _CarouselCard extends StatelessWidget {
bottom: 12, bottom: 12,
left: 12, left: 12,
right: 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( child: Text(
novel.status,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: 10),
Text(
novel.title, novel.title,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16, fontSize: 20,
), ),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return SizedBox(
height: 200, height: 226,
child: ListView.separated( child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 18),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: novels.length, itemCount: novels.length,
separatorBuilder: (_, separatorIndex) => const SizedBox(width: 12), separatorBuilder: (_, separatorIndex) => const SizedBox(width: 12),
@@ -205,32 +381,45 @@ class _NovelHorizontalList extends StatelessWidget {
return GestureDetector( return GestureDetector(
onTap: () => context.push(RouteNames.novelDetail(novel.id)), onTap: () => context.push(RouteNames.novelDetail(novel.id)),
child: SizedBox( child: SizedBox(
width: 110, width: 122,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(10),
child: novel.coverUrl != null child: novel.coverUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: novel.coverUrl!, imageUrl: novel.coverUrl!,
width: 110, width: 122,
height: 150, height: 155,
fit: BoxFit.cover, fit: BoxFit.cover,
) )
: Container( : Container(
width: 110, width: 122,
height: 150, height: 155,
color: Theme.of(context).colorScheme.primaryContainer, color: Theme.of(context).colorScheme.primaryContainer,
child: const Icon(Icons.menu_book), child: const Icon(Icons.menu_book),
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 6),
Text( Flexible(
child: Text(
novel.title, novel.title,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 3),
Text(
'${novel.totalChapters} chương',
maxLines: 1,
overflow: TextOverflow.ellipsis,
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
+117 -67
View File
@@ -4,8 +4,6 @@ import '../../../core/models/novel_model.dart';
import '../../../core/models/chapter_model.dart'; import '../../../core/models/chapter_model.dart';
import '../../../core/network/providers.dart'; import '../../../core/network/providers.dart';
const chapterPageSize = 50;
// ─── Browse / Search ────────────────────────────────────────────────────────── // ─── Browse / Search ──────────────────────────────────────────────────────────
class BrowseParams { class BrowseParams {
@@ -23,11 +21,40 @@ class BrowseParams {
this.page = 1, this.page = 1,
}); });
String? _normalizedStatus() {
final raw = status?.trim();
if (raw == null || raw.isEmpty) return null;
switch (raw.toLowerCase()) {
case 'ongoing':
return 'ONGOING';
case 'completed':
return 'COMPLETED';
case 'hiatus':
return 'HIATUS';
default:
return raw;
}
}
String _normalizedSort() {
final raw = sort.trim();
if (raw.isEmpty) return 'latest';
switch (raw.toLowerCase()) {
case 'latest':
case 'popular':
case 'rating':
case 'name':
return raw.toLowerCase();
default:
return 'latest';
}
}
Map<String, dynamic> toQueryParams() => { Map<String, dynamic> toQueryParams() => {
if (query != null && query!.isNotEmpty) 'q': query, if (query != null && query!.isNotEmpty) 'q': query,
if (genre != null) 'genre': genre, if (genre != null) 'genre': genre,
if (status != null) 'status': status, if (_normalizedStatus() != null) 'status': _normalizedStatus(),
'sort': sort, 'sort': _normalizedSort(),
'page': page.toString(), 'page': page.toString(),
'limit': '20', 'limit': '20',
}; };
@@ -56,18 +83,39 @@ class BrowseResult {
final int totalCount; final int totalCount;
final int totalPages; final int totalPages;
final int currentPage; final int currentPage;
final bool isLoadingMore;
const BrowseResult({ const BrowseResult({
required this.items, required this.items,
required this.totalCount, required this.totalCount,
required this.totalPages, required this.totalPages,
required this.currentPage, required this.currentPage,
this.isLoadingMore = false,
}); });
bool get hasMore => currentPage < totalPages;
BrowseResult copyWith({
List<NovelModel>? items,
int? totalCount,
int? totalPages,
int? currentPage,
bool? isLoadingMore,
}) {
return BrowseResult(
items: items ?? this.items,
totalCount: totalCount ?? this.totalCount,
totalPages: totalPages ?? this.totalPages,
currentPage: currentPage ?? this.currentPage,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
);
}
} }
class NovelsNotifier extends StateNotifier<AsyncValue<BrowseResult>> { class NovelsNotifier extends StateNotifier<AsyncValue<BrowseResult>> {
final Ref _ref; final Ref _ref;
BrowseParams _params = const BrowseParams(); BrowseParams _params = const BrowseParams();
bool _isLoadingMore = false;
NovelsNotifier(this._ref) : super(const AsyncValue.loading()) { NovelsNotifier(this._ref) : super(const AsyncValue.loading()) {
fetch(); fetch();
@@ -75,25 +123,53 @@ class NovelsNotifier extends StateNotifier<AsyncValue<BrowseResult>> {
BrowseParams get params => _params; BrowseParams get params => _params;
Future<BrowseResult> _fetchPage(BrowseParams params) async {
final client = _ref.read(apiClientProvider);
final res = await client.dio.get('/api/novels/browse', queryParameters: params.toQueryParams());
final data = res.data as Map<String, dynamic>;
return BrowseResult(
items: (data['items'] as List).map((e) => NovelModel.fromJson(e as Map<String, dynamic>)).toList(),
totalCount: (data['totalCount'] as num?)?.toInt() ?? 0,
totalPages: (data['totalPages'] as num?)?.toInt() ?? 1,
currentPage: (data['currentPage'] as num?)?.toInt() ?? params.page,
);
}
Future<void> fetch({BrowseParams? params}) async { Future<void> fetch({BrowseParams? params}) async {
if (params != null) _params = params; if (params != null) _params = params;
state = const AsyncValue.loading(); state = const AsyncValue.loading();
try { try {
final client = _ref.read(apiClientProvider); final firstPageParams = _params.copyWith(page: 1);
final res = await client.dio.get('/api/novels/browse', queryParameters: _params.toQueryParams()); final result = await _fetchPage(firstPageParams);
final data = res.data as Map<String, dynamic>; _params = firstPageParams;
state = AsyncValue.data(BrowseResult( state = AsyncValue.data(result);
items: (data['items'] as List).map((e) => NovelModel.fromJson(e as Map<String, dynamic>)).toList(),
totalCount: data['totalCount'] as int,
totalPages: data['totalPages'] as int,
currentPage: data['currentPage'] as int,
));
} catch (e, st) { } catch (e, st) {
state = AsyncValue.error(e, st); state = AsyncValue.error(e, st);
} }
} }
Future<void> updateParams(BrowseParams params) => fetch(params: params); Future<void> updateParams(BrowseParams params) => fetch(params: params);
Future<void> loadNextPage() async {
final current = state.valueOrNull;
if (current == null || !current.hasMore || _isLoadingMore) return;
_isLoadingMore = true;
state = AsyncValue.data(current.copyWith(isLoadingMore: true));
try {
final nextParams = _params.copyWith(page: current.currentPage + 1);
final nextPage = await _fetchPage(nextParams);
_params = nextParams;
final merged = [...current.items, ...nextPage.items];
state = AsyncValue.data(nextPage.copyWith(items: merged, isLoadingMore: false));
} catch (e, st) {
state = AsyncValue.error(e, st);
} finally {
_isLoadingMore = false;
}
}
} }
final novelsProvider = StateNotifierProvider<NovelsNotifier, AsyncValue<BrowseResult>>((ref) { final novelsProvider = StateNotifierProvider<NovelsNotifier, AsyncValue<BrowseResult>>((ref) {
@@ -111,77 +187,51 @@ final novelDetailProvider =
// ─── Chapter List ───────────────────────────────────────────────────────────── // ─── Chapter List ─────────────────────────────────────────────────────────────
class ChapterListQuery {
const ChapterListQuery({required this.novelId, this.page = 1});
final String novelId;
final int page;
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ChapterListQuery &&
other.novelId == novelId &&
other.page == page;
}
@override
int get hashCode => Object.hash(novelId, page);
}
class ChapterListPage {
const ChapterListPage({
required this.chapters,
required this.totalChapters,
required this.totalPages,
required this.currentPage,
});
final List<ChapterListItem> chapters;
final int totalChapters;
final int totalPages;
final int currentPage;
}
final chapterListProvider = final chapterListProvider =
FutureProvider.family<ChapterListPage, ChapterListQuery>((ref, query) async { FutureProvider.family<List<ChapterListItem>, String>((ref, novelId) async {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
Future<Map<String, dynamic>> fetchChapterPage(String idOrSlug) async { Future<List<ChapterListItem>> fetchAllChapters(String idOrSlug) async {
const limit = 500;
var page = 1;
var totalPages = 1;
final items = <ChapterListItem>[];
while (page <= totalPages) {
final res = await client.dio.get( final res = await client.dio.get(
'/api/truyen/$idOrSlug/chapters', '/api/truyen/$idOrSlug/chapters',
queryParameters: { queryParameters: {'page': page, 'limit': limit},
'page': query.page,
'limit': chapterPageSize,
},
); );
return res.data as Map<String, dynamic>; 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;
} }
var data = await fetchChapterPage(query.novelId); return items;
var chapters = data['chapters'] as List? ?? const []; }
try {
return await fetchAllChapters(novelId);
} catch (_) {
// Backend stores chapters by novel id in MongoDB; if route opened by slug, // 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. // first request can return empty list. Resolve canonical id and retry once.
if (chapters.isEmpty) {
try { try {
final novelRes = await client.dio.get('/api/novels/${query.novelId}'); final novelRes = await client.dio.get('/api/novels/$novelId');
final novelData = novelRes.data as Map<String, dynamic>; final novelData = novelRes.data as Map<String, dynamic>;
final canonicalId = novelData['id'] as String?; final canonicalId = novelData['id'] as String?;
if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != query.novelId) { if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != novelId) {
data = await fetchChapterPage(canonicalId); return await fetchAllChapters(canonicalId);
chapters = data['chapters'] as List? ?? const [];
} }
} catch (_) { } catch (_) {
// Keep original empty list when fallback resolution fails. // Keep original empty list when fallback resolution fails.
} }
rethrow;
} }
return ChapterListPage(
chapters:
chapters.map((e) => ChapterListItem.fromJson(e as Map<String, dynamic>)).toList(),
totalChapters: (data['totalChapters'] as num?)?.toInt() ?? 0,
totalPages: (data['totalPages'] as num?)?.toInt() ?? 0,
currentPage: (data['currentPage'] as num?)?.toInt() ?? query.page,
);
}); });
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart'; import '../../../app/router/route_names.dart';
import '../../../shared/widgets/main_app_header.dart';
import '../../auth/providers/auth_provider.dart'; import '../../auth/providers/auth_provider.dart';
import '../../bookshelf/providers/bookshelf_provider.dart'; import '../../bookshelf/providers/bookshelf_provider.dart';
@@ -22,94 +23,109 @@ class ProfileScreen extends ConsumerWidget {
: ''; : '';
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Tài khoản')), body: Column(
body: switch (authState) {
AuthAuthenticated(:final user) => SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [ children: [
// User Avatar & Basic Info const MainAppHeader(title: 'Trang cá nhân', showGenresShortcut: false),
Container( Expanded(
padding: const EdgeInsets.all(20), child: switch (authState) {
decoration: BoxDecoration( AuthAuthenticated(:final user) => SingleChildScrollView(
color: Theme.of(context).colorScheme.primaryContainer, padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
borderRadius: BorderRadius.circular(12),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(22),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
CircleAvatar( CircleAvatar(
radius: 40, radius: 34,
backgroundImage: backgroundImage:
user.image != null ? NetworkImage(user.image!) : null, user.image != null ? NetworkImage(user.image!) : null,
child: user.image == null child: user.image == null
? Text( ? Text(
displayName[0].toUpperCase(), displayName.isNotEmpty ? displayName[0].toUpperCase() : 'U',
style: style: Theme.of(context).textTheme.headlineMedium,
Theme.of(context).textTheme.headlineMedium,
) )
: null, : null,
), ),
const SizedBox(height: 12), const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text( Text(
displayName, displayName,
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center, ),
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), const SizedBox(height: 4),
Text( _AccountStatRow(
user.email, icon: Icons.diamond,
style: Theme.of(context).textTheme.bodyMedium, label: 'Linh Phiếu: 0 LP',
textAlign: TextAlign.center,
),
],
),
),
const SizedBox(height: 24),
// Stats Cards
Row(
children: [
Expanded(
child: _buildStatCard(
context: context,
label: 'Sách Đánh Dấu',
count: bookmarkedCount,
),
),
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: 4),
_AccountStatRow(
icon: Icons.local_activity,
label: 'Ngọc Phiếu: $bookmarkedCount',
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton.icon(
// Logout Button onPressed: () {},
SizedBox( icon: const Icon(Icons.workspace_premium_rounded),
width: double.infinity, label: const Text('Thêm Tiên Thạch'),
child: OutlinedButton.icon( style: FilledButton.styleFrom(
onPressed: () async { backgroundColor: const Color(0xFF14B8A6),
foregroundColor: Colors.white,
),
),
],
),
),
],
),
),
const SizedBox(height: 18),
_ProfileMenuTile(
title: 'Chỉnh sửa thông tin',
onTap: () => context.push(RouteNames.settings),
),
_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(); await ref.read(authProvider.notifier).signOut();
if (context.mounted) context.go(RouteNames.home); if (context.mounted) context.go(RouteNames.home);
}, },
icon: const Icon(Icons.logout),
label: const Text('Đăng Xuất'),
),
), ),
], ],
), ),
@@ -137,36 +153,50 @@ class ProfileScreen extends ConsumerWidget {
), ),
_ => const Center(child: CircularProgressIndicator()), _ => 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,
), ),
], ],
), ),
); );
} }
} }
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, required this.content,
this.contentKey, this.contentKey,
this.title, this.title,
this.nextChapterId,
this.chapterNumber,
this.apiBaseUrl,
this.includeTitleOnStart = true, this.includeTitleOnStart = true,
this.resolveStartParagraphIndex, this.resolveStartParagraphIndex,
this.onStarted, this.onStarted,
@@ -20,6 +23,9 @@ class TtsPlayerWidget extends ConsumerWidget {
final String content; final String content;
final String? contentKey; final String? contentKey;
final String? title; final String? title;
final String? nextChapterId;
final int? chapterNumber;
final String? apiBaseUrl;
final bool includeTitleOnStart; final bool includeTitleOnStart;
final int Function()? resolveStartParagraphIndex; final int Function()? resolveStartParagraphIndex;
final VoidCallback? onStarted; final VoidCallback? onStarted;
@@ -30,7 +36,7 @@ class TtsPlayerWidget extends ConsumerWidget {
final tts = ref.watch(ttsProvider); final tts = ref.watch(ttsProvider);
final notifier = ref.read(ttsProvider.notifier); final notifier = ref.read(ttsProvider.notifier);
const speeds = [0.35, 0.45, 0.55, 0.65, 0.8, 1.0]; const speeds = [0.45, 0.675, 0.9, 1.125, 1.35, 1.8];
Future<void> start() async { Future<void> start() async {
if (tts.status == TtsStatus.paused) { if (tts.status == TtsStatus.paused) {
@@ -39,6 +45,8 @@ class TtsPlayerWidget extends ConsumerWidget {
return; return;
} }
notifier.clearPendingAutoStartChapter();
unawaited( unawaited(
notifier.startReading( notifier.startReading(
content, content,
@@ -46,6 +54,9 @@ class TtsPlayerWidget extends ConsumerWidget {
startParagraphIndex: resolveStartParagraphIndex?.call(), startParagraphIndex: resolveStartParagraphIndex?.call(),
contentKey: contentKey, contentKey: contentKey,
title: title, title: title,
nextChapterId: nextChapterId,
chapterNumber: chapterNumber,
apiBaseUrl: apiBaseUrl,
includeTitle: includeTitleOnStart, includeTitle: includeTitleOnStart,
), ),
); );
@@ -6,6 +6,7 @@ import '../../../core/models/reading_settings.dart';
import '../../../core/network/providers.dart'; import '../../../core/network/providers.dart';
import '../../../core/storage/local_store.dart'; import '../../../core/storage/local_store.dart';
import '../../../core/storage/offline_cache.dart'; import '../../../core/storage/offline_cache.dart';
import '../../bookshelf/providers/bookshelf_provider.dart';
// Chapter content // Chapter content
@@ -61,6 +62,21 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
scrollOffset: 0, 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) { void updateScroll(double offset) {
if (state == null) return; if (state == null) return;
state = ReadingProgress( state = ReadingProgress(
@@ -79,23 +95,45 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
// Also notify server (fire and forget) // Also notify server (fire and forget)
try { try {
final client = _ref.read(apiClientProvider); final client = _ref.read(apiClientProvider);
await client.dio.post('/api/user/reading-progress', data: { final res = await client.dio.post('/api/user/reading-progress', data: {
'novelId': _novelId, 'novelId': _novelId,
'chapterId': chapterId, 'chapterId': chapterId,
'chapterNumber': chapterNumber, 'chapterNumber': chapterNumber,
'progress': offset, 'progress': offset,
}); });
final data = res.data;
Map<String, dynamic>? bookmarkJson;
if (data is Map<String, dynamic>) {
final bookmark = data['bookmark'];
if (bookmark is Map<String, dynamic>) {
bookmarkJson = bookmark;
}
}
_ref.read(bookshelfProvider.notifier).syncProgress(
novelId: _novelId!,
chapterId: chapterId,
chapterNumber: chapterNumber,
serverBookmark: bookmarkJson,
);
} catch (_) {} } catch (_) {}
} }
DateTime? _lastUpdate; Timer? _debounceTimer;
Future<void> _debounceUpdate(double offset) async { void _debounceUpdate(double offset) {
final now = DateTime.now(); _debounceTimer?.cancel();
if (_lastUpdate != null && now.difference(_lastUpdate!).inSeconds < 3) return; _debounceTimer = Timer(const Duration(seconds: 3), () {
_lastUpdate = now;
if (state != null) { if (state != null) {
await _persistProgress(state!.chapterId, state!.chapterNumber, offset); 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); final localStore = _ref.read(localStoreProvider);
await localStore.saveReadingSettings(settings); await localStore.saveReadingSettings(settings);
} }
Future<void> setSentenceTapTtsEnabled(bool enabled) async {
await update(state.copyWith(enableSentenceTapTts: enabled));
}
} }
final readingSettingsProvider = final readingSettingsProvider =
@@ -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 Dartnative 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);
+596 -114
View File
@@ -5,9 +5,11 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_tts/flutter_tts.dart'; import 'package:flutter_tts/flutter_tts.dart';
import '../../../core/config/app_config.dart';
enum TtsStatus { idle, playing, paused } enum TtsStatus { idle, playing, paused }
const double kTtsBaseSpeechRate = 0.45; const double kTtsBaseSpeechRate = 0.9;
double ttsDisplayMultiplier(double speechRate) => speechRate / kTtsBaseSpeechRate; double ttsDisplayMultiplier(double speechRate) => speechRate / kTtsBaseSpeechRate;
@@ -32,6 +34,13 @@ class _TtsSegment {
final int paragraphIndex; final int paragraphIndex;
final int start; final int start;
final int end; final int end;
Map<String, Object?> toMap() => {
'text': text,
'paragraphIndex': paragraphIndex,
'start': start,
'end': end,
};
} }
class TtsVoice { class TtsVoice {
@@ -65,7 +74,7 @@ class TtsState {
this.paragraphIndex = 0, this.paragraphIndex = 0,
this.totalParagraphs = 0, this.totalParagraphs = 0,
this.activeParagraphIndex = -1, this.activeParagraphIndex = -1,
this.speed = 0.45, this.speed = 0.9,
this.language = 'vi-VN', this.language = 'vi-VN',
this.voiceName, this.voiceName,
this.availableVietnameseVoices = const [], this.availableVietnameseVoices = const [],
@@ -124,17 +133,41 @@ class TtsState {
} }
class TtsNotifier extends StateNotifier<TtsState> { class TtsNotifier extends StateNotifier<TtsState> {
final FlutterTts _tts = FlutterTts();
static const MethodChannel _backgroundChannel = MethodChannel('reader_app/tts_background');
List<_TtsSegment> _segments = [];
bool _initialized = false;
Future<void>? _initFuture;
TtsNotifier() : super(const TtsState()) { TtsNotifier() : super(const TtsState()) {
_initFuture = _init(); _initFuture = _init();
} }
static const MethodChannel _backgroundChannel = MethodChannel(
'reader_app/tts_background',
);
static const MethodChannel _mediaChannel = MethodChannel(
'reader_app/tts_media',
);
static const EventChannel _mediaEventsChannel = EventChannel(
'reader_app/tts_media_events',
);
final FlutterTts _tts = FlutterTts();
List<_TtsSegment> _segments = [];
bool _initialized = false;
Future<void>? _initFuture;
StreamSubscription<dynamic>? _mediaEventsSub;
int _playbackGeneration = 0;
bool _isInterruptingPlayback = false;
int _pendingFallbackIndex = -1;
bool _didStartCurrentFallbackUtterance = false;
bool _hasPromptedNotificationSettings = false;
bool _androidFallbackReady = false;
bool get _useNativeAndroidMediaService => Platform.isAndroid;
Future<void> _init() async { Future<void> _init() async {
if (_useNativeAndroidMediaService) {
await _initAndroidBridge();
_initialized = true;
return;
}
await _tts.awaitSpeakCompletion(true); await _tts.awaitSpeakCompletion(true);
await _tts.setSharedInstance(true); await _tts.setSharedInstance(true);
@@ -150,39 +183,101 @@ class TtsNotifier extends StateNotifier<TtsState> {
); );
} }
if (Platform.isAndroid) { await _configureVietnameseVoiceWithFlutterTts();
await _tts.setAudioAttributesForNavigation();
}
await _configureVietnameseVoice();
await _tts.setSpeechRate(kTtsBaseSpeechRate); await _tts.setSpeechRate(kTtsBaseSpeechRate);
await _tts.setVolume(1.0); await _tts.setVolume(1.0);
await _tts.setPitch(1.0); await _tts.setPitch(1.0);
_tts.setStartHandler(() { _tts.setStartHandler(() {
_didStartCurrentFallbackUtterance = true;
final index = _pendingFallbackIndex;
if (index >= 0 && index < _segments.length) {
final segment = _segments[index];
state = state.copyWith( state = state.copyWith(
status: TtsStatus.playing, status: TtsStatus.playing,
paragraphIndex: index,
activeParagraphIndex: segment.paragraphIndex,
progressStart: segment.start,
progressEnd: segment.end,
); );
} else {
state = state.copyWith(status: TtsStatus.playing);
}
unawaited(_syncBackgroundMode()); unawaited(_syncBackgroundMode());
}); });
_tts.setCompletionHandler(() { _tts.setCompletionHandler(() {
if (state.status == TtsStatus.playing) { // Fallback playback progression is driven by _playFallbackFromGeneration.
_next();
}
}); });
_tts.setErrorHandler((msg) { _tts.setErrorHandler((msg) {
state = state.copyWith(status: TtsStatus.idle); if (_isInterruptingPlayback) return;
_pendingFallbackIndex = -1;
_didStartCurrentFallbackUtterance = false;
state = state.copyWith(
status: TtsStatus.idle,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
unawaited(_syncBackgroundMode()); unawaited(_syncBackgroundMode());
}); });
await _syncBackgroundMode(); await _syncBackgroundMode();
_initialized = true; _initialized = true;
} }
Future<void> _configureVietnameseVoice() async { Future<void> _initAndroidBridge() async {
_mediaEventsSub ??= _mediaEventsChannel.receiveBroadcastStream().listen(
_handleAndroidMediaEvent,
onError: (_) {
state = state.copyWith(
status: TtsStatus.idle,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
},
);
await _mediaChannel.invokeMethod<void>('initialize', {
'backgroundModeEnabled': state.backgroundModeEnabled,
});
final snapshot = await _mediaChannel.invokeMethod<dynamic>('getSnapshot');
_applyAndroidSnapshot(snapshot);
await _ensureAndroidMediaNotificationsEnabled();
}
Future<void> _ensureAndroidMediaNotificationsEnabled() async {
if (!_useNativeAndroidMediaService) return;
if (_hasPromptedNotificationSettings) return;
final enabled = await _mediaChannel.invokeMethod<bool>('areNotificationsEnabled') ?? true;
if (enabled) return;
_hasPromptedNotificationSettings = true;
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; final dynamic voicesRaw = await _tts.getVoices;
String? selectedName; String? selectedName;
@@ -191,22 +286,34 @@ class TtsNotifier extends StateNotifier<TtsState> {
if (voicesRaw is List) { if (voicesRaw is List) {
final vietnamese = voicesRaw.whereType<Map>().where((voice) { final vietnamese = voicesRaw.whereType<Map>().where((voice) {
final locale = (voice['locale'] ?? voice['language'] ?? '').toString().toLowerCase(); final locale = (voice['locale'] ?? voice['language'] ?? '')
.toString()
.toLowerCase();
return locale.startsWith('vi'); return locale.startsWith('vi');
}).toList(); }).toList();
for (final voice in vietnamese) { for (final voice in vietnamese) {
final name = voice['name']?.toString(); final name = voice['name']?.toString();
final locale = (voice['locale'] ?? voice['language'])?.toString(); final locale = (voice['locale'] ?? voice['language'])?.toString();
if (name == null || name.isEmpty || locale == null || locale.isEmpty) continue; if (name == null || name.isEmpty || locale == null || locale.isEmpty) {
continue;
}
vietnameseVoices.add(TtsVoice(name: name, locale: locale)); vietnameseVoices.add(TtsVoice(name: name, locale: locale));
} }
if (vietnamese.isNotEmpty) { if (vietnamese.isNotEmpty) {
final preferred = vietnamese.firstWhere( final preferred = vietnamese.firstWhere(
(voice) => (voice) =>
(voice['name']?.toString().toLowerCase().contains('female') ?? false) || (voice['name']
(voice['name']?.toString().toLowerCase().contains('natural') ?? false), ?.toString()
.toLowerCase()
.contains('female') ??
false) ||
(voice['name']
?.toString()
.toLowerCase()
.contains('natural') ??
false),
orElse: () => vietnamese.first, orElse: () => vietnamese.first,
); );
selectedName = preferred['name']?.toString(); selectedName = preferred['name']?.toString();
@@ -219,6 +326,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
if (selectedName != null) { if (selectedName != null) {
await _tts.setVoice({'name': selectedName, 'locale': selectedLanguage}); await _tts.setVoice({'name': selectedName, 'locale': selectedLanguage});
} }
state = state.copyWith( state = state.copyWith(
language: selectedLanguage, language: selectedLanguage,
voiceName: selectedName, voiceName: selectedName,
@@ -226,7 +334,244 @@ 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);
}
void _applyAndroidSnapshot(dynamic snapshot) {
if (snapshot is! Map) return;
final data = Map<String, dynamic>.from(
snapshot.map((key, value) => MapEntry(key.toString(), value)),
);
final statusRaw = data['status']?.toString() ?? 'idle';
final status = switch (statusRaw) {
'playing' => TtsStatus.playing,
'paused' => TtsStatus.paused,
_ => TtsStatus.idle,
};
final voicesRaw = data['availableVietnameseVoices'];
final voices = <TtsVoice>[];
if (voicesRaw is List) {
for (final item in voicesRaw) {
if (item is! Map) continue;
final map = Map<String, dynamic>.from(
item.map((key, value) => MapEntry(key.toString(), value)),
);
final name = map['name']?.toString();
final locale = map['locale']?.toString();
if (name == null || name.isEmpty || locale == null || locale.isEmpty) {
continue;
}
voices.add(TtsVoice(name: name, locale: locale));
}
}
state = state.copyWith(
status: status,
paragraphIndex: (data['paragraphIndex'] as num?)?.toInt() ?? 0,
totalParagraphs: (data['totalParagraphs'] as num?)?.toInt() ?? 0,
activeParagraphIndex: (data['activeParagraphIndex'] as num?)?.toInt() ?? -1,
progressStart: (data['progressStart'] as num?)?.toInt() ?? -1,
progressEnd: (data['progressEnd'] as num?)?.toInt() ?? -1,
contentKey: data['contentKey']?.toString(),
completedCount: (data['completedCount'] as num?)?.toInt() ?? state.completedCount,
language: data['language']?.toString() ?? state.language,
voiceName: data['voiceName']?.toString(),
availableVietnameseVoices: voices,
backgroundModeEnabled:
data['backgroundModeEnabled'] as bool? ?? state.backgroundModeEnabled,
);
}
String _sanitizeForTts(String raw) {
if (raw.isEmpty) return raw;
// 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();
return cleaned;
}
List<_TtsSegment> _buildSegments(
String content, {
String? title,
bool includeTitle = true,
}) {
final segments = <_TtsSegment>[];
final titleText = title?.trim();
if (includeTitle && titleText != null && titleText.isNotEmpty) {
final sanitizedTitle = _sanitizeForTts(titleText);
if (sanitizedTitle.isNotEmpty) {
segments.add(
_TtsSegment(
text: sanitizedTitle,
paragraphIndex: -1,
start: -1,
end: -1,
),
);
}
}
final paragraphs = content
.split(RegExp(r'\n+'))
.map((p) => p.trim())
.where((p) => p.isNotEmpty)
.toList();
for (var pIndex = 0; pIndex < paragraphs.length; pIndex++) {
final paragraph = paragraphs[pIndex];
final sentenceMatches = RegExp(r'[^.!?…]+[.!?…]*').allMatches(paragraph);
var cursor = 0;
for (final match in sentenceMatches) {
final sentence = match.group(0)?.trim() ?? '';
if (sentence.isEmpty) continue;
final sanitizedSentence = _sanitizeForTts(sentence);
if (sanitizedSentence.isEmpty) continue;
var start = paragraph.indexOf(sentence, cursor);
if (start < 0) {
start = cursor.clamp(0, paragraph.length);
}
final end = (start + sentence.length).clamp(0, paragraph.length);
cursor = end;
segments.add(
_TtsSegment(
text: sanitizedSentence,
paragraphIndex: pIndex,
start: start,
end: end,
),
);
}
}
return segments;
}
int _resolveStartIndex(
int paragraphIndex, {
int? startParagraphIndex,
int? startCharOffset,
}) {
var validIndex = paragraphIndex.clamp(0, _segments.length - 1);
if (startParagraphIndex != null) {
final matchIndex = _segments.indexWhere(
(segment) =>
segment.paragraphIndex == startParagraphIndex &&
(startCharOffset == null || segment.start >= startCharOffset),
);
if (matchIndex >= 0) {
validIndex = matchIndex;
} else {
final fallbackIndex = _segments.indexWhere(
(segment) => segment.paragraphIndex >= startParagraphIndex,
);
if (fallbackIndex >= 0) {
validIndex = fallbackIndex;
}
}
}
return validIndex;
}
Future<void> setVoiceByName(String voiceName) async { Future<void> setVoiceByName(String voiceName) async {
if (_useNativeAndroidMediaService) {
final selected = state.availableVietnameseVoices.where(
(voice) => voice.name == voiceName,
);
if (selected.isEmpty) return;
final voice = selected.first;
await _mediaChannel.invokeMethod<void>('setVoiceByName', {
'voiceName': voice.name,
'language': voice.locale,
});
state = state.copyWith(language: voice.locale, voiceName: voice.name);
return;
}
final selected = state.availableVietnameseVoices.where((v) => v.name == voiceName); final selected = state.availableVietnameseVoices.where((v) => v.name == voiceName);
if (selected.isEmpty) return; if (selected.isEmpty) return;
@@ -240,6 +585,16 @@ class TtsNotifier extends StateNotifier<TtsState> {
Future<void> setBackgroundModeEnabled(bool enabled) async { Future<void> setBackgroundModeEnabled(bool enabled) async {
state = state.copyWith(backgroundModeEnabled: enabled); state = state.copyWith(backgroundModeEnabled: enabled);
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('setBackgroundModeEnabled', {
'enabled': enabled,
});
if (enabled) {
await ensureBatteryOptimizationIgnored();
}
return;
}
await _syncBackgroundMode(); await _syncBackgroundMode();
if (enabled) { if (enabled) {
await ensureBatteryOptimizationIgnored(); await ensureBatteryOptimizationIgnored();
@@ -278,82 +633,71 @@ class TtsNotifier extends StateNotifier<TtsState> {
} }
Future<void> _syncBackgroundMode() async { Future<void> _syncBackgroundMode() async {
if (!Platform.isAndroid) return; if (_useNativeAndroidMediaService || !Platform.isAndroid) return;
final shouldKeepAlive = final shouldKeepAlive =
state.backgroundModeEnabled && state.status == TtsStatus.playing; state.backgroundModeEnabled && state.status == TtsStatus.playing;
try { try {
await _backgroundChannel await _backgroundChannel.invokeMethod<void>('setWakeLock', {
.invokeMethod<void>('setWakeLock', {'enabled': shouldKeepAlive}); 'enabled': shouldKeepAlive,
});
} catch (_) { } catch (_) {
// Keep playback functional even if native wake lock bridge is unavailable. // Keep playback functional even if native wake lock bridge is unavailable.
} }
} }
/// Start reading from [content] starting at optional [paragraphIndex].
Future<void> startReading( Future<void> startReading(
String content, { String content, {
int paragraphIndex = 0, int paragraphIndex = 0,
int? startParagraphIndex, int? startParagraphIndex,
int? startCharOffset,
String? contentKey, String? contentKey,
String? title, String? title,
String? nextChapterId,
int? chapterNumber,
String? apiBaseUrl,
bool includeTitle = true, bool includeTitle = true,
}) async { }) async {
if (!_initialized) { if (!_initialized) {
await (_initFuture ?? _init()); await (_initFuture ?? _init());
} }
final segments = <_TtsSegment>[]; // 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);
final titleText = title?.trim(); _segments = _buildSegments(
if (includeTitle && titleText != null && titleText.isNotEmpty) { content,
segments.add(_TtsSegment(text: titleText, paragraphIndex: -1, start: -1, end: -1)); title: title,
} includeTitle: includeTitle,
final paragraphs = content
.split(RegExp(r'\n+'))
.map((p) => p.trim())
.where((p) => p.isNotEmpty)
.toList();
for (var pIndex = 0; pIndex < paragraphs.length; pIndex++) {
final paragraph = paragraphs[pIndex];
final sentenceMatches = RegExp(r'[^.!?…]+[.!?…]*').allMatches(paragraph);
var cursor = 0;
for (final match in sentenceMatches) {
final sentence = match.group(0)?.trim() ?? '';
if (sentence.isEmpty) continue;
var start = paragraph.indexOf(sentence, cursor);
if (start < 0) start = cursor.clamp(0, paragraph.length);
final end = (start + sentence.length).clamp(0, paragraph.length);
cursor = end;
segments.add(
_TtsSegment(
text: sentence,
paragraphIndex: pIndex,
start: start,
end: end,
),
); );
}
}
_segments = segments; if (_segments.isEmpty) {
if (_segments.isEmpty) return; state = state.copyWith(
status: TtsStatus.idle,
var validIndex = paragraphIndex.clamp(0, _segments.length - 1); paragraphIndex: 0,
if (startParagraphIndex != null) { totalParagraphs: 0,
final startFromVisible = _segments.indexWhere( activeParagraphIndex: -1,
(segment) => segment.paragraphIndex >= startParagraphIndex, progressStart: -1,
progressEnd: -1,
contentKey: contentKey,
); );
if (startFromVisible >= 0) { if (!_useNativeAndroidMediaService) {
validIndex = startFromVisible; await _syncBackgroundMode();
} }
return;
} }
final validIndex = _resolveStartIndex(
paragraphIndex,
startParagraphIndex: startParagraphIndex,
startCharOffset: startCharOffset,
);
final selectedSegment = _segments[validIndex]; final selectedSegment = _segments[validIndex];
if (_useNativeAndroidMediaService) {
await _ensureAndroidMediaNotificationsEnabled();
state = state.copyWith( state = state.copyWith(
status: TtsStatus.playing, status: TtsStatus.playing,
paragraphIndex: validIndex, paragraphIndex: validIndex,
@@ -363,12 +707,94 @@ class TtsNotifier extends StateNotifier<TtsState> {
progressEnd: selectedSegment.end, progressEnd: selectedSegment.end,
contentKey: contentKey, contentKey: contentKey,
); );
await _syncBackgroundMode();
await _speak(validIndex); 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;
} }
Future<void> _speak(int index) async { await _startFallbackReading(
if (index >= _segments.length) { validIndex: validIndex,
selectedSegment: selectedSegment,
contentKey: contentKey,
);
}
Future<int> _interruptFallbackPlayback() async {
_playbackGeneration++;
_pendingFallbackIndex = -1;
_didStartCurrentFallbackUtterance = false;
_isInterruptingPlayback = true;
try {
await _tts.stop();
if (Platform.isAndroid) {
await Future<void>.delayed(const Duration(milliseconds: 120));
}
} finally {
_isInterruptingPlayback = false;
}
return _playbackGeneration;
}
Future<void> _playFallbackFromGeneration(int startIndex, int generation) async {
if (startIndex < 0 || startIndex >= _segments.length) {
state = state.copyWith(
status: TtsStatus.idle,
paragraphIndex: 0,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
await _syncBackgroundMode();
return;
}
for (var index = startIndex; index < _segments.length; index++) {
if (generation != _playbackGeneration) return;
if (state.status != TtsStatus.playing) return;
_pendingFallbackIndex = index;
_didStartCurrentFallbackUtterance = false;
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: index,
totalParagraphs: _segments.length,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
await _syncBackgroundMode();
await _tts.setSpeechRate(state.speed);
final result = await _tts.speak(_segments[index].text);
if (generation != _playbackGeneration) return;
if (state.status != TtsStatus.playing) return;
if (result is int && result != 1) {
state = state.copyWith( state = state.copyWith(
status: TtsStatus.idle, status: TtsStatus.idle,
activeParagraphIndex: -1, activeParagraphIndex: -1,
@@ -379,21 +805,23 @@ class TtsNotifier extends StateNotifier<TtsState> {
return; return;
} }
final segment = _segments[index]; if (!_didStartCurrentFallbackUtterance) {
state = state.copyWith( state = state.copyWith(
paragraphIndex: index, status: TtsStatus.idle,
activeParagraphIndex: segment.paragraphIndex, activeParagraphIndex: -1,
progressStart: segment.start, progressStart: -1,
progressEnd: segment.end, progressEnd: -1,
); );
await _syncBackgroundMode();
await _tts.setSpeechRate(state.speed); return;
await _tts.speak(segment.text); }
} }
Future<void> _next() async { if (generation != _playbackGeneration) return;
final next = state.paragraphIndex + 1;
if (next >= state.totalParagraphs) { _pendingFallbackIndex = -1;
_didStartCurrentFallbackUtterance = false;
state = state.copyWith( state = state.copyWith(
status: TtsStatus.idle, status: TtsStatus.idle,
paragraphIndex: 0, paragraphIndex: 0,
@@ -403,33 +831,66 @@ class TtsNotifier extends StateNotifier<TtsState> {
completedCount: state.completedCount + 1, completedCount: state.completedCount + 1,
); );
await _syncBackgroundMode(); await _syncBackgroundMode();
return;
}
state = state.copyWith(
paragraphIndex: next,
activeParagraphIndex: _segments[next].paragraphIndex,
progressStart: _segments[next].start,
progressEnd: _segments[next].end,
);
await _speak(next);
} }
Future<void> pause() async { Future<void> pause() async {
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('pause');
return;
}
if (state.status != TtsStatus.playing) return;
_playbackGeneration++;
await _tts.pause(); await _tts.pause();
state = state.copyWith(status: TtsStatus.paused); state = state.copyWith(status: TtsStatus.paused);
await _syncBackgroundMode(); await _syncBackgroundMode();
} }
Future<void> resume() async { Future<void> _restartFallbackFromIndex(int index) async {
if (state.status != TtsStatus.paused) return; if (_segments.isEmpty) return;
state = state.copyWith(status: TtsStatus.playing);
final sessionId = await _interruptFallbackPlayback();
final validIndex = index.clamp(0, _segments.length - 1);
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: validIndex,
totalParagraphs: _segments.length,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
await _syncBackgroundMode(); await _syncBackgroundMode();
// Use paragraph-level resume for consistent behavior across engines.
await _speak(state.paragraphIndex); unawaited(_playFallbackFromGeneration(validIndex, sessionId));
}
Future<void> resume() async {
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('resume');
return;
}
if (state.status != TtsStatus.paused) return;
await _restartFallbackFromIndex(state.paragraphIndex);
} }
Future<void> stop() async { Future<void> stop() async {
await _tts.stop(); if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('stop');
state = state.copyWith(
status: TtsStatus.idle,
paragraphIndex: 0,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
clearContentKey: true,
);
return;
}
await _interruptFallbackPlayback();
state = state.copyWith( state = state.copyWith(
status: TtsStatus.idle, status: TtsStatus.idle,
paragraphIndex: 0, paragraphIndex: 0,
@@ -442,32 +903,53 @@ class TtsNotifier extends StateNotifier<TtsState> {
} }
Future<void> skipForward() async { Future<void> skipForward() async {
await _tts.stop(); if (_useNativeAndroidMediaService) {
await _next(); await _mediaChannel.invokeMethod<void>('skipForward');
return;
}
if (_segments.isEmpty || state.totalParagraphs <= 0) return;
final next = state.paragraphIndex + 1;
if (next >= _segments.length) {
await stop();
return;
}
await _restartFallbackFromIndex(next);
} }
Future<void> skipBack() async { Future<void> skipBack() async {
await _tts.stop(); if (_useNativeAndroidMediaService) {
if (state.totalParagraphs <= 0) return; await _mediaChannel.invokeMethod<void>('skipBack');
final prev = (state.paragraphIndex - 1).clamp(0, state.totalParagraphs - 1); return;
state = state.copyWith( }
paragraphIndex: prev,
activeParagraphIndex: _segments[prev].paragraphIndex, if (_segments.isEmpty || state.totalParagraphs <= 0) return;
progressStart: _segments[prev].start,
progressEnd: _segments[prev].end, final prev = (state.paragraphIndex - 1).clamp(0, _segments.length - 1);
); await _restartFallbackFromIndex(prev);
if (state.status == TtsStatus.playing) await _speak(prev);
} }
Future<void> setSpeed(double speed) async { Future<void> setSpeed(double speed) async {
state = state.copyWith(speed: speed); state = state.copyWith(speed: speed);
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('setSpeed', {'speed': speed});
return;
}
await _tts.setSpeechRate(speed); await _tts.setSpeechRate(speed);
} }
@override @override
void dispose() { void dispose() {
unawaited(_backgroundChannel.invokeMethod<void>('setWakeLock', {'enabled': false})); _mediaEventsSub?.cancel();
_tts.stop(); if (_useNativeAndroidMediaService) {
unawaited(_mediaChannel.invokeMethod<void>('dispose'));
} else {
unawaited(_tts.stop());
}
super.dispose(); super.dispose();
} }
} }
@@ -10,7 +10,18 @@ import '../../novel/providers/novels_provider.dart';
import '../../genres/providers/genres_provider.dart'; import '../../genres/providers/genres_provider.dart';
class SearchScreen extends ConsumerStatefulWidget { class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key}); const SearchScreen({
super.key,
this.initialQuery,
this.initialGenre,
this.initialStatus,
this.initialSort = 'latest',
});
final String? initialQuery;
final String? initialGenre;
final String? initialStatus;
final String initialSort;
@override @override
ConsumerState<SearchScreen> createState() => _SearchScreenState(); ConsumerState<SearchScreen> createState() => _SearchScreenState();
@@ -18,6 +29,7 @@ class SearchScreen extends ConsumerStatefulWidget {
class _SearchScreenState extends ConsumerState<SearchScreen> { class _SearchScreenState extends ConsumerState<SearchScreen> {
final _controller = TextEditingController(); final _controller = TextEditingController();
final _scrollController = ScrollController();
Timer? _debounce; Timer? _debounce;
String? _selectedGenre; String? _selectedGenre;
String? _selectedStatus; String? _selectedStatus;
@@ -35,13 +47,61 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
('Tên A-Z', 'name'), ('Tên A-Z', 'name'),
]; ];
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_syncFromInitialParams(applyImmediately: false);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_applyFilters();
});
}
@override
void didUpdateWidget(covariant SearchScreen oldWidget) {
super.didUpdateWidget(oldWidget);
final hasRouteFilterChange = oldWidget.initialQuery != widget.initialQuery ||
oldWidget.initialGenre != widget.initialGenre ||
oldWidget.initialStatus != widget.initialStatus ||
oldWidget.initialSort != widget.initialSort;
if (hasRouteFilterChange) {
_syncFromInitialParams(applyImmediately: true);
}
}
@override @override
void dispose() { void dispose() {
_scrollController
..removeListener(_onScroll)
..dispose();
_controller.dispose(); _controller.dispose();
_debounce?.cancel(); _debounce?.cancel();
super.dispose(); super.dispose();
} }
void _onScroll() {
if (!_scrollController.hasClients) return;
final position = _scrollController.position;
if (position.pixels >= position.maxScrollExtent - 240) {
ref.read(novelsProvider.notifier).loadNextPage();
}
}
void _syncFromInitialParams({required bool applyImmediately}) {
final incomingQuery = widget.initialQuery?.trim();
_controller.text = incomingQuery == null || incomingQuery.isEmpty ? '' : incomingQuery;
_selectedGenre = widget.initialGenre;
_selectedStatus = widget.initialStatus;
_sort = widget.initialSort;
if (applyImmediately) {
if (mounted) {
setState(() {});
}
_applyFilters();
}
}
void _onQueryChanged(String value) { void _onQueryChanged(String value) {
_debounce?.cancel(); _debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 500), _applyFilters); _debounce = Timer(const Duration(milliseconds: 500), _applyFilters);
@@ -167,8 +227,25 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
return const Center(child: Text('Không tìm thấy truyện')); return const Center(child: Text('Không tìm thấy truyện'));
} }
return ListView.builder( return ListView.builder(
itemCount: result.items.length, controller: _scrollController,
itemBuilder: (context, index) => _NovelListTile(novel: result.items[index]), itemCount: result.items.length + (result.hasMore || result.isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= result.items.length) {
if (result.isLoadingMore) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator()),
);
}
// Trigger page load when user reaches the end.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(novelsProvider.notifier).loadNextPage();
});
return const SizedBox(height: 32);
}
return _NovelListTile(novel: result.items[index]);
},
); );
}, },
), ),
@@ -199,6 +276,7 @@ class _FilterChipDropdown extends StatelessWidget {
return PopupMenuButton<String>( return PopupMenuButton<String>(
onSelected: onSelected, onSelected: onSelected,
itemBuilder: (_) => items, itemBuilder: (_) => items,
child: IgnorePointer(
child: FilterChip( child: FilterChip(
label: Text(label), label: Text(label),
selected: selected, selected: selected,
@@ -206,6 +284,7 @@ class _FilterChipDropdown extends StatelessWidget {
deleteIcon: selected && onClear != null ? const Icon(Icons.close, size: 14) : null, deleteIcon: selected && onClear != null ? const Icon(Icons.close, size: 14) : null,
onDeleted: selected ? onClear : null, onDeleted: selected ? onClear : null,
), ),
),
); );
} }
} }
@@ -1,25 +1,77 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart'; import '../../../app/router/route_names.dart';
import '../../../core/storage/local_store.dart';
class SplashScreen extends StatefulWidget { class SplashScreen extends ConsumerStatefulWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});
@override @override
State<SplashScreen> createState() => _SplashScreenState(); ConsumerState<SplashScreen> createState() => _SplashScreenState();
} }
class _SplashScreenState extends State<SplashScreen> { class _SplashScreenState extends ConsumerState<SplashScreen> {
Timer? _redirectTimer; Timer? _redirectTimer;
bool _isRestorableRoute(String path) {
if (path.isEmpty || path == RouteNames.splash) return false;
// Composite "parentPath|deepPath" validate the deep path portion
final checkPath = path.contains('|') ? path.substring(path.indexOf('|') + 1) : path;
return checkPath == RouteNames.home ||
checkPath == RouteNames.login ||
checkPath == RouteNames.search ||
checkPath.startsWith('${RouteNames.search}?') ||
checkPath == RouteNames.genres ||
checkPath == RouteNames.bookshelf ||
checkPath == RouteNames.profile ||
checkPath == RouteNames.settings ||
checkPath.startsWith('/novel/') ||
checkPath.startsWith('/reader/') ||
checkPath.startsWith('/comments/');
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_redirectTimer = Timer(const Duration(milliseconds: 700), () { _redirectTimer = Timer(const Duration(milliseconds: 700), () async {
if (!mounted) return; if (!mounted) return;
final lastPath = await ref.read(localStoreProvider).loadLastRoutePath();
if (!mounted) return;
if (lastPath != null && _isRestorableRoute(lastPath)) {
if (lastPath.contains('|')) {
// Composite "parentPath|deepPath" e.g. "/novel/123|/reader/abc"
// Restore full stack: Home Novel Detail Reader
final sep = lastPath.indexOf('|');
final parentPath = lastPath.substring(0, sep);
final deepPath = lastPath.substring(sep + 1);
context.go(RouteNames.home);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.push(parentPath);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) context.push(deepPath);
});
});
} else {
// Single deep route (novel, comments) outside ShellRoute: push on Home
final isDeepRoute = lastPath.startsWith('/reader/') ||
lastPath.startsWith('/novel/') ||
lastPath.startsWith('/comments/');
if (isDeepRoute) {
context.go(RouteNames.home);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) context.push(lastPath);
});
} else {
context.go(lastPath);
}
}
return;
}
context.go(RouteNames.home); context.go(RouteNames.home);
}); });
} }
+107 -29
View File
@@ -8,45 +8,123 @@ class AppShell extends StatelessWidget {
final Widget child; final Widget child;
int _indexForLocation(String location) { String _tabForLocation(String location) {
if (location.startsWith(RouteNames.search)) return 1; if (location.startsWith(RouteNames.bookshelf)) return RouteNames.bookshelf;
if (location.startsWith(RouteNames.bookshelf)) return 2; if (location.startsWith(RouteNames.genres)) return RouteNames.genres;
if (location.startsWith(RouteNames.genres)) return 3; if (location.startsWith(RouteNames.profile)) return RouteNames.profile;
if (location.startsWith(RouteNames.profile)) return 4; return RouteNames.home;
return 0;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final location = GoRouterState.of(context).uri.path; final location = GoRouterState.of(context).uri.path;
final selectedIndex = _indexForLocation(location); final selectedTab = _tabForLocation(location);
return Scaffold( return Scaffold(
body: child, body: child,
bottomNavigationBar: NavigationBar( bottomNavigationBar: Container(
selectedIndex: selectedIndex, decoration: BoxDecoration(
onDestinationSelected: (index) { color: colorScheme.surface,
switch (index) { border: Border(
case 0: top: BorderSide(color: colorScheme.outlineVariant.withAlpha(80)),
context.go(RouteNames.home); ),
case 1: ),
context.go(RouteNames.search); child: SafeArea(
case 2: top: false,
context.go(RouteNames.bookshelf); child: Padding(
case 3: padding: const EdgeInsets.fromLTRB(10, 8, 10, 6),
context.go(RouteNames.genres); child: Row(
case 4: children: [
context.go(RouteNames.profile); _ShellNavItem(
} icon: Icons.home_rounded,
}, label: 'Trang chủ',
destinations: const [ selected: selectedTab == RouteNames.home,
NavigationDestination(icon: Icon(Icons.home_outlined), label: 'Home'), onTap: () => context.go(RouteNames.home),
NavigationDestination(icon: Icon(Icons.search), label: 'Tim kiem'), ),
NavigationDestination(icon: Icon(Icons.bookmark_border), label: 'Tu sach'), _ShellNavItem(
NavigationDestination(icon: Icon(Icons.category_outlined), label: 'The loai'), icon: Icons.layers_rounded,
NavigationDestination(icon: Icon(Icons.person_outline), label: 'Tai khoan'), 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!,
],
],
),
),
);
}
}
+56
View File
@@ -1,6 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -57,6 +65,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@@ -190,6 +214,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.4.1" version: "3.4.1"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -360,6 +392,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -376,6 +416,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.7" version: "0.6.7"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
url: "https://pub.dev"
source: hosted
version: "4.11.0"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -560,6 +608,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
+14 -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 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1 version: 1.0.3+6
environment: environment:
sdk: ^3.11.3 sdk: ^3.11.3
@@ -56,6 +56,16 @@ dev_dependencies:
# package. See that file for information about deactivating specific lint # package. See that file for information about deactivating specific lint
# rules and activating additional ones. # rules and activating additional ones.
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.14.3
# For information on the generic Dart part of this file, see the
flutter_launcher_icons:
android: true
ios: true
image_path: "assets/app_icon.png"
min_sdk_android: 21
web:
generate: false
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec
@@ -68,6 +78,9 @@ flutter:
# the material Icons class. # the material Icons class.
uses-material-design: true uses-material-design: true
assets:
- assets/app_icon.png
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
# assets: # assets:
# - images/a_dot_burr.jpeg # - images/a_dot_burr.jpeg