Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 297fc45707 | |||
| 583a41879f | |||
| 1256475bf9 | |||
| c892928ff8 | |||
| 76edaa25a4 | |||
| 2d41121b84 | |||
| 1e8c19e5c9 | |||
| 183a0acabb | |||
| 62ca390691 |
@@ -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"
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 = "../.."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.ReaderTtsSegment
|
||||||
|
|
||||||
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,117 @@ 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 startIndex = call.argument<Int>("startIndex") ?: 0
|
||||||
|
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
|
||||||
|
ReaderTtsMediaService.startReading(
|
||||||
|
this,
|
||||||
|
parseSegments(call.argument<List<*>>("segments")),
|
||||||
|
startIndex,
|
||||||
|
contentKey,
|
||||||
|
title,
|
||||||
|
speed,
|
||||||
|
language,
|
||||||
|
voiceName,
|
||||||
|
backgroundModeEnabled,
|
||||||
|
)
|
||||||
|
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 parseSegments(rawSegments: List<*>?): ArrayList<ReaderTtsSegment> {
|
||||||
|
val segments = arrayListOf<ReaderTtsSegment>()
|
||||||
|
rawSegments.orEmpty().forEach { item ->
|
||||||
|
val map = item as? Map<*, *> ?: return@forEach
|
||||||
|
val text = map["text"]?.toString() ?: return@forEach
|
||||||
|
val paragraphIndex = (map["paragraphIndex"] as? Number)?.toInt() ?: -1
|
||||||
|
val start = (map["start"] as? Number)?.toInt() ?: -1
|
||||||
|
val end = (map["end"] as? Number)?.toInt() ?: -1
|
||||||
|
segments += ReaderTtsSegment(
|
||||||
|
text = text,
|
||||||
|
paragraphIndex = paragraphIndex,
|
||||||
|
start = start,
|
||||||
|
end = end,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return segments
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isIgnoringBatteryOptimizations(): Boolean {
|
private fun isIgnoringBatteryOptimizations(): Boolean {
|
||||||
@@ -76,6 +194,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>>()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
@@ -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:
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 54 KiB |
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -23,11 +23,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 'Đang ra';
|
||||||
|
case 'completed':
|
||||||
|
return 'Hoàn thành';
|
||||||
|
case 'hiatus':
|
||||||
|
return 'Tạm ngưng';
|
||||||
|
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 +85,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 +125,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) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -27,17 +28,21 @@ class ReaderScreen extends ConsumerStatefulWidget {
|
|||||||
class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||||
final ScrollController _scrollCtrl = ScrollController();
|
final ScrollController _scrollCtrl = ScrollController();
|
||||||
Timer? _uiAutoHideTimer;
|
Timer? _uiAutoHideTimer;
|
||||||
double _readingProgress = 0;
|
final ValueNotifier<double> _readingProgress = ValueNotifier(0);
|
||||||
|
final ValueNotifier<bool> _showQuickActions = ValueNotifier(true);
|
||||||
String? _activeChapterId;
|
String? _activeChapterId;
|
||||||
bool _isRestoringProgress = false;
|
bool _isRestoringProgress = false;
|
||||||
bool _showQuickActions = true;
|
|
||||||
double _lastScrollOffset = 0;
|
double _lastScrollOffset = 0;
|
||||||
double _scrollDeltaSinceToggle = 0;
|
double _scrollDeltaSinceToggle = 0;
|
||||||
|
double _lastReportedOffset = 0;
|
||||||
|
DateTime? _lastReportedAt;
|
||||||
int _chapterDirection = 0; // -1: previous, 1: next
|
int _chapterDirection = 0; // -1: previous, 1: next
|
||||||
int _lastAutoScrolledParagraph = -1;
|
int _lastAutoScrolledParagraph = -1;
|
||||||
int _lastTtsCompletedCount = 0;
|
int _lastTtsCompletedCount = 0;
|
||||||
String? _autoStartQueuedChapterId;
|
String? _autoStartQueuedChapterId;
|
||||||
final List<GlobalKey> _paragraphKeys = [];
|
final List<GlobalKey> _paragraphKeys = [];
|
||||||
|
String? _sentenceSlicesChapterId;
|
||||||
|
List<List<_SentenceSlice>> _sentenceSlicesByParagraph = const [];
|
||||||
|
|
||||||
List<String> _paragraphsOf(String content) => content
|
List<String> _paragraphsOf(String content) => content
|
||||||
.split(RegExp(r'\n+'))
|
.split(RegExp(r'\n+'))
|
||||||
@@ -68,49 +73,87 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
|
|
||||||
Widget _buildParagraphText({
|
Widget _buildParagraphText({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required String paragraph,
|
required List<_SentenceSlice> sentenceSlices,
|
||||||
required TextStyle style,
|
required TextStyle style,
|
||||||
|
required TextStyle highlightStyle,
|
||||||
required TextAlign textAlign,
|
required TextAlign textAlign,
|
||||||
required bool isActiveParagraph,
|
required bool isActiveParagraph,
|
||||||
required int highlightStart,
|
required int highlightStart,
|
||||||
required int highlightEnd,
|
required int highlightEnd,
|
||||||
|
required Function(int charOffset) onSentenceTap,
|
||||||
}) {
|
}) {
|
||||||
if (!isActiveParagraph || highlightStart < 0 || highlightEnd <= highlightStart) {
|
if (sentenceSlices.isEmpty) {
|
||||||
return SelectableText(
|
return SelectableText(
|
||||||
paragraph,
|
'',
|
||||||
textAlign: textAlign,
|
textAlign: textAlign,
|
||||||
style: style,
|
style: style,
|
||||||
|
onTap: () => onSentenceTap(0),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final safeStart = highlightStart.clamp(0, paragraph.length);
|
return SelectableText.rich(
|
||||||
final safeEnd = highlightEnd.clamp(0, paragraph.length);
|
TextSpan(
|
||||||
if (safeEnd <= safeStart) {
|
|
||||||
return SelectableText(
|
|
||||||
paragraph,
|
|
||||||
textAlign: textAlign,
|
|
||||||
style: style,
|
style: style,
|
||||||
);
|
children: sentenceSlices.map((slice) {
|
||||||
}
|
final start = slice.start;
|
||||||
|
final end = slice.end;
|
||||||
|
|
||||||
final highlightStyle = style.copyWith(
|
final isCurrentSpoken = isActiveParagraph &&
|
||||||
backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(80),
|
highlightStart >= 0 &&
|
||||||
fontWeight: FontWeight.w600,
|
highlightEnd > highlightStart &&
|
||||||
);
|
start >= highlightStart &&
|
||||||
|
end <= highlightEnd;
|
||||||
|
|
||||||
return RichText(
|
return TextSpan(
|
||||||
textAlign: textAlign,
|
text: slice.text,
|
||||||
text: TextSpan(
|
style: isCurrentSpoken ? highlightStyle : null,
|
||||||
style: style,
|
recognizer: TapGestureRecognizer()..onTap = () => onSentenceTap(start),
|
||||||
children: [
|
);
|
||||||
if (safeStart > 0) TextSpan(text: paragraph.substring(0, safeStart)),
|
}).toList(),
|
||||||
TextSpan(text: paragraph.substring(safeStart, safeEnd), style: highlightStyle),
|
|
||||||
if (safeEnd < paragraph.length) TextSpan(text: paragraph.substring(safeEnd)),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
textAlign: textAlign,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<List<_SentenceSlice>> _sentenceSlicesForChapter(
|
||||||
|
ChapterModel chapter,
|
||||||
|
List<String> paragraphs,
|
||||||
|
) {
|
||||||
|
if (_sentenceSlicesChapterId == chapter.id &&
|
||||||
|
_sentenceSlicesByParagraph.length == paragraphs.length) {
|
||||||
|
return _sentenceSlicesByParagraph;
|
||||||
|
}
|
||||||
|
|
||||||
|
final sentencePattern = RegExp(r'[^.!?…]+[.!?…]*');
|
||||||
|
final parsed = <List<_SentenceSlice>>[];
|
||||||
|
|
||||||
|
for (final paragraph in paragraphs) {
|
||||||
|
final matches = sentencePattern.allMatches(paragraph).toList();
|
||||||
|
if (matches.isEmpty) {
|
||||||
|
parsed.add([
|
||||||
|
_SentenceSlice(text: paragraph, start: 0, end: paragraph.length),
|
||||||
|
]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed.add(
|
||||||
|
matches
|
||||||
|
.map(
|
||||||
|
(match) => _SentenceSlice(
|
||||||
|
text: match.group(0)!,
|
||||||
|
start: match.start,
|
||||||
|
end: match.end,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_sentenceSlicesChapterId = chapter.id;
|
||||||
|
_sentenceSlicesByParagraph = parsed;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
void _ensureParagraphKeys(int count) {
|
void _ensureParagraphKeys(int count) {
|
||||||
if (_paragraphKeys.length == count) return;
|
if (_paragraphKeys.length == count) return;
|
||||||
_paragraphKeys
|
_paragraphKeys
|
||||||
@@ -176,23 +219,79 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
_scrollCtrl.addListener(_onScroll);
|
_scrollCtrl.addListener(_onScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle TTS state transitions that require navigation or restarts.
|
||||||
|
/// Called once from [build] via [ref.listen] — safe to run side effects here.
|
||||||
|
void _onTtsStateChanged(TtsState? previous, TtsState next) {
|
||||||
|
// Guard: only act when something meaningful changed.
|
||||||
|
if (previous == null) return;
|
||||||
|
|
||||||
|
final chapterAsync = ref.read(chapterProvider(widget.chapterId));
|
||||||
|
final chapter = chapterAsync.valueOrNull;
|
||||||
|
if (chapter == null) return;
|
||||||
|
|
||||||
|
// Chapter-completion → auto-advance to next chapter.
|
||||||
|
if (next.completedCount > _lastTtsCompletedCount) {
|
||||||
|
_lastTtsCompletedCount = next.completedCount;
|
||||||
|
if (next.contentKey == chapter.id && chapter.nextChapterId != null) {
|
||||||
|
final nextChapterId = chapter.nextChapterId!;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ref.read(ttsProvider.notifier).scheduleAutoStartForChapter(nextChapterId);
|
||||||
|
context.pushReplacement(RouteNames.readerChapter(nextChapterId));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending auto-start for this chapter (set by previous chapter's completion).
|
||||||
|
if (next.pendingAutoStartChapterId == chapter.id &&
|
||||||
|
_autoStartQueuedChapterId != chapter.id) {
|
||||||
|
_autoStartQueuedChapterId = chapter.id;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final notifier = ref.read(ttsProvider.notifier);
|
||||||
|
notifier.clearPendingAutoStartChapter();
|
||||||
|
notifier.startReading(
|
||||||
|
chapter.content,
|
||||||
|
contentKey: chapter.id,
|
||||||
|
title: 'Chương ${chapter.number}: ${chapter.title}',
|
||||||
|
);
|
||||||
|
_autoStartQueuedChapterId = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_uiAutoHideTimer?.cancel();
|
_uiAutoHideTimer?.cancel();
|
||||||
_scrollCtrl.removeListener(_onScroll);
|
_scrollCtrl.removeListener(_onScroll);
|
||||||
_scrollCtrl.dispose();
|
_scrollCtrl.dispose();
|
||||||
|
_readingProgress.dispose();
|
||||||
|
_showQuickActions.dispose();
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _shouldReportScroll(double offset) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final elapsedMs = _lastReportedAt == null
|
||||||
|
? 1000
|
||||||
|
: now.difference(_lastReportedAt!).inMilliseconds;
|
||||||
|
final delta = (offset - _lastReportedOffset).abs();
|
||||||
|
return delta >= 24 || elapsedMs >= 700;
|
||||||
|
}
|
||||||
|
|
||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
if (_isRestoringProgress) return;
|
if (_isRestoringProgress) return;
|
||||||
ref.read(readerProvider.notifier).updateScroll(_scrollCtrl.offset);
|
final offset = _scrollCtrl.hasClients ? _scrollCtrl.offset : 0.0;
|
||||||
|
if (_shouldReportScroll(offset)) {
|
||||||
|
_lastReportedOffset = offset;
|
||||||
|
_lastReportedAt = DateTime.now();
|
||||||
|
ref.read(readerProvider.notifier).updateScroll(offset);
|
||||||
|
}
|
||||||
|
|
||||||
final currentOffset = _scrollCtrl.hasClients ? _scrollCtrl.offset : _lastScrollOffset;
|
final currentOffset = _scrollCtrl.hasClients ? _scrollCtrl.offset : _lastScrollOffset;
|
||||||
final delta = currentOffset - _lastScrollOffset;
|
final delta = currentOffset - _lastScrollOffset;
|
||||||
@@ -203,12 +302,12 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
_scrollDeltaSinceToggle = delta;
|
_scrollDeltaSinceToggle = delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_showQuickActions && currentOffset > 120 && _scrollDeltaSinceToggle > 56) {
|
if (_showQuickActions.value && currentOffset > 120 && _scrollDeltaSinceToggle > 56) {
|
||||||
setState(() => _showQuickActions = false);
|
_showQuickActions.value = false;
|
||||||
_scrollDeltaSinceToggle = 0;
|
_scrollDeltaSinceToggle = 0;
|
||||||
} else if (!_showQuickActions &&
|
} else if (!_showQuickActions.value &&
|
||||||
(_scrollDeltaSinceToggle < -36 || currentOffset <= 40)) {
|
(_scrollDeltaSinceToggle < -36 || currentOffset <= 40)) {
|
||||||
setState(() => _showQuickActions = true);
|
_showQuickActions.value = true;
|
||||||
_scrollDeltaSinceToggle = 0;
|
_scrollDeltaSinceToggle = 0;
|
||||||
}
|
}
|
||||||
_lastScrollOffset = currentOffset;
|
_lastScrollOffset = currentOffset;
|
||||||
@@ -216,15 +315,29 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
if (!_scrollCtrl.hasClients) return;
|
if (!_scrollCtrl.hasClients) return;
|
||||||
final max = _scrollCtrl.position.maxScrollExtent;
|
final max = _scrollCtrl.position.maxScrollExtent;
|
||||||
final next = max <= 0 ? 0.0 : (_scrollCtrl.offset / max).clamp(0.0, 1.0);
|
final next = max <= 0 ? 0.0 : (_scrollCtrl.offset / max).clamp(0.0, 1.0);
|
||||||
if ((next - _readingProgress).abs() > 0.01) {
|
if ((next - _readingProgress.value).abs() > 0.02) {
|
||||||
setState(() => _readingProgress = next);
|
_readingProgress.value = next;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initializeChapterSession(ChapterModel chapter) async {
|
Future<void> _initializeChapterSession(ChapterModel chapter) async {
|
||||||
if (_activeChapterId == chapter.id) return;
|
if (_activeChapterId == chapter.id) return;
|
||||||
|
final previousChapterId = _activeChapterId;
|
||||||
|
final switchedChapter = previousChapterId != null && previousChapterId != chapter.id;
|
||||||
_activeChapterId = chapter.id;
|
_activeChapterId = chapter.id;
|
||||||
_readingProgress = 0;
|
_readingProgress.value = 0;
|
||||||
|
_showQuickActions.value = true;
|
||||||
|
_lastScrollOffset = 0;
|
||||||
|
_scrollDeltaSinceToggle = 0;
|
||||||
|
_lastReportedOffset = 0;
|
||||||
|
_lastReportedAt = null;
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || !_scrollCtrl.hasClients) return;
|
||||||
|
_isRestoringProgress = true;
|
||||||
|
_scrollCtrl.jumpTo(0);
|
||||||
|
_isRestoringProgress = false;
|
||||||
|
});
|
||||||
|
|
||||||
ref.read(readerProvider.notifier).open(
|
ref.read(readerProvider.notifier).open(
|
||||||
chapter.novelId,
|
chapter.novelId,
|
||||||
@@ -232,6 +345,13 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
chapter.number,
|
chapter.number,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_consumePendingAutoStartForChapter(chapter);
|
||||||
|
|
||||||
|
if (switchedChapter) {
|
||||||
|
ref.read(readerProvider.notifier).resetCurrentChapterProgress();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final localStore = ref.read(localStoreProvider);
|
final localStore = ref.read(localStoreProvider);
|
||||||
final saved = await localStore.loadProgress(chapter.novelId);
|
final saved = await localStore.loadProgress(chapter.novelId);
|
||||||
if (!mounted || saved == null) return;
|
if (!mounted || saved == null) return;
|
||||||
@@ -248,6 +368,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
_isRestoringProgress = true;
|
_isRestoringProgress = true;
|
||||||
_scrollCtrl.jumpTo(target);
|
_scrollCtrl.jumpTo(target);
|
||||||
_isRestoringProgress = false;
|
_isRestoringProgress = false;
|
||||||
|
_lastReportedOffset = target;
|
||||||
|
_lastReportedAt = DateTime.now();
|
||||||
_onScroll();
|
_onScroll();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -274,6 +396,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
if (prevId == null) return;
|
if (prevId == null) return;
|
||||||
setState(() => _chapterDirection = -1);
|
setState(() => _chapterDirection = -1);
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
|
_queueAutoStartIfReadingCurrentChapter(chapter.id, prevId);
|
||||||
context.pushReplacement(RouteNames.readerChapter(prevId));
|
context.pushReplacement(RouteNames.readerChapter(prevId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,9 +405,40 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
if (nextId == null) return;
|
if (nextId == null) return;
|
||||||
setState(() => _chapterDirection = 1);
|
setState(() => _chapterDirection = 1);
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
|
_queueAutoStartIfReadingCurrentChapter(chapter.id, nextId);
|
||||||
context.pushReplacement(RouteNames.readerChapter(nextId));
|
context.pushReplacement(RouteNames.readerChapter(nextId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _queueAutoStartIfReadingCurrentChapter(
|
||||||
|
String currentChapterId,
|
||||||
|
String targetChapterId,
|
||||||
|
) {
|
||||||
|
final tts = ref.read(ttsProvider);
|
||||||
|
final isCurrentlyReading = tts.contentKey == currentChapterId &&
|
||||||
|
(tts.status == TtsStatus.playing || tts.status == TtsStatus.paused);
|
||||||
|
if (!isCurrentlyReading) return;
|
||||||
|
ref.read(ttsProvider.notifier).scheduleAutoStartForChapter(targetChapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _consumePendingAutoStartForChapter(ChapterModel chapter) {
|
||||||
|
final tts = ref.read(ttsProvider);
|
||||||
|
if (tts.pendingAutoStartChapterId != chapter.id) return;
|
||||||
|
if (_autoStartQueuedChapterId == chapter.id) return;
|
||||||
|
|
||||||
|
_autoStartQueuedChapterId = chapter.id;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final notifier = ref.read(ttsProvider.notifier);
|
||||||
|
notifier.clearPendingAutoStartChapter();
|
||||||
|
notifier.startReading(
|
||||||
|
chapter.content,
|
||||||
|
contentKey: chapter.id,
|
||||||
|
title: 'Chương ${chapter.number}: ${chapter.title}',
|
||||||
|
);
|
||||||
|
_autoStartQueuedChapterId = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _scrollToTop() async {
|
Future<void> _scrollToTop() async {
|
||||||
if (!_scrollCtrl.hasClients) return;
|
if (!_scrollCtrl.hasClients) return;
|
||||||
await _scrollCtrl.animateTo(
|
await _scrollCtrl.animateTo(
|
||||||
@@ -362,6 +516,10 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
if (!isCurrent) {
|
if (!isCurrent) {
|
||||||
|
_queueAutoStartIfReadingCurrentChapter(
|
||||||
|
currentChapter.id,
|
||||||
|
item.id,
|
||||||
|
);
|
||||||
context.pushReplacement(RouteNames.readerChapter(item.id));
|
context.pushReplacement(RouteNames.readerChapter(item.id));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -686,7 +844,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [0.35, 0.45, 0.55, 0.65, 0.8, 1.0].map((speed) {
|
children: [0.45, 0.675, 0.9, 1.125, 1.35, 1.8].map((speed) {
|
||||||
final selected = tts.speed == speed;
|
final selected = tts.speed == speed;
|
||||||
return ChoiceChip(
|
return ChoiceChip(
|
||||||
label: Text(formatTtsSpeedLabel(speed)),
|
label: Text(formatTtsSpeedLabel(speed)),
|
||||||
@@ -790,6 +948,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final chapterAsync = ref.watch(chapterProvider(widget.chapterId));
|
final chapterAsync = ref.watch(chapterProvider(widget.chapterId));
|
||||||
final settings = ref.watch(readingSettingsProvider);
|
final settings = ref.watch(readingSettingsProvider);
|
||||||
|
|
||||||
|
// Side-effects for TTS state changes (navigation, auto-start).
|
||||||
|
ref.listen<TtsState>(ttsProvider, _onTtsStateChanged);
|
||||||
Color readerBackground;
|
Color readerBackground;
|
||||||
Color readerTextColor;
|
Color readerTextColor;
|
||||||
Color readerMutedColor;
|
Color readerMutedColor;
|
||||||
@@ -829,39 +990,29 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
data: (chapter) {
|
data: (chapter) {
|
||||||
final paragraphs = _paragraphsOf(chapter.content);
|
final paragraphs = _paragraphsOf(chapter.content);
|
||||||
_ensureParagraphKeys(paragraphs.length);
|
_ensureParagraphKeys(paragraphs.length);
|
||||||
|
final sentenceSlicesByParagraph =
|
||||||
|
_sentenceSlicesForChapter(chapter, paragraphs);
|
||||||
final textAlign = _textAlignFor(settings.textAlign);
|
final textAlign = _textAlignFor(settings.textAlign);
|
||||||
final novelAsync = ref.watch(novelDetailProvider(chapter.novelId));
|
final novelAsync = ref.watch(novelDetailProvider(chapter.novelId));
|
||||||
final tts = ref.watch(ttsProvider);
|
final tts = ref.watch(ttsProvider);
|
||||||
final shouldHighlightTts = tts.contentKey == chapter.id &&
|
final shouldHighlightTts = tts.contentKey == chapter.id &&
|
||||||
(tts.status == TtsStatus.playing || tts.status == TtsStatus.paused);
|
(tts.status == TtsStatus.playing || tts.status == TtsStatus.paused);
|
||||||
|
final paragraphStyle = TextStyle(
|
||||||
if (tts.completedCount > _lastTtsCompletedCount) {
|
color: readerTextColor,
|
||||||
_lastTtsCompletedCount = tts.completedCount;
|
fontSize: settings.fontSize,
|
||||||
if (tts.contentKey == chapter.id && chapter.nextChapterId != null) {
|
height: settings.lineHeight,
|
||||||
final nextChapterId = chapter.nextChapterId!;
|
letterSpacing: settings.letterSpacing,
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
fontFamily: settings.fontFamily == 'serif'
|
||||||
if (!mounted) return;
|
? 'Georgia'
|
||||||
ref.read(ttsProvider.notifier).scheduleAutoStartForChapter(nextChapterId);
|
: settings.fontFamily == 'mono'
|
||||||
context.pushReplacement(RouteNames.readerChapter(nextChapterId));
|
? 'Courier'
|
||||||
});
|
: null,
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tts.pendingAutoStartChapterId == chapter.id &&
|
|
||||||
_autoStartQueuedChapterId != chapter.id) {
|
|
||||||
_autoStartQueuedChapterId = chapter.id;
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (!mounted) return;
|
|
||||||
final notifier = ref.read(ttsProvider.notifier);
|
|
||||||
notifier.clearPendingAutoStartChapter();
|
|
||||||
notifier.startReading(
|
|
||||||
chapter.content,
|
|
||||||
contentKey: chapter.id,
|
|
||||||
title: 'Chương ${chapter.number}: ${chapter.title}',
|
|
||||||
);
|
);
|
||||||
_autoStartQueuedChapterId = null;
|
final paragraphHighlightStyle = paragraphStyle.copyWith(
|
||||||
});
|
backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(80),
|
||||||
}
|
fontWeight: FontWeight.w600,
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
_maybeAutoScrollToTtsParagraph(tts, paragraphs.length);
|
_maybeAutoScrollToTtsParagraph(tts, paragraphs.length);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -876,9 +1027,12 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
color: readerBackground,
|
color: readerBackground,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_TopBar(
|
ValueListenableBuilder<double>(
|
||||||
|
valueListenable: _readingProgress,
|
||||||
|
builder: (context, progress, _) {
|
||||||
|
return _TopBar(
|
||||||
title: _chapterTopBarTitle(chapter),
|
title: _chapterTopBarTitle(chapter),
|
||||||
progress: _readingProgress,
|
progress: progress,
|
||||||
onOpenSettings: () => _openReadingSettingsSheet(
|
onOpenSettings: () => _openReadingSettingsSheet(
|
||||||
chapter.content,
|
chapter.content,
|
||||||
chapter.id,
|
chapter.id,
|
||||||
@@ -886,6 +1040,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
),
|
),
|
||||||
barBackgroundColor: readerBackground,
|
barBackgroundColor: readerBackground,
|
||||||
foregroundColor: readerTextColor,
|
foregroundColor: readerTextColor,
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AnimatedSwitcher(
|
child: AnimatedSwitcher(
|
||||||
@@ -909,14 +1065,17 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
key: ValueKey(chapter.id),
|
key: ValueKey(chapter.id),
|
||||||
child: Scrollbar(
|
child: Scrollbar(
|
||||||
controller: _scrollCtrl,
|
controller: _scrollCtrl,
|
||||||
child: SingleChildScrollView(
|
child: CustomScrollView(
|
||||||
controller: _scrollCtrl,
|
controller: _scrollCtrl,
|
||||||
|
slivers: [
|
||||||
|
SliverPadding(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
settings.horizontalPadding,
|
settings.horizontalPadding,
|
||||||
16,
|
16,
|
||||||
settings.horizontalPadding,
|
settings.horizontalPadding,
|
||||||
24,
|
chapter.content.trim().isEmpty ? 24 : 0,
|
||||||
),
|
),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
@@ -958,13 +1117,30 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
.textTheme
|
.textTheme
|
||||||
.bodyMedium
|
.bodyMedium
|
||||||
?.copyWith(color: readerMutedColor),
|
?.copyWith(color: readerMutedColor),
|
||||||
)
|
),
|
||||||
else
|
],
|
||||||
Column(
|
),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
),
|
||||||
children: [
|
),
|
||||||
for (var index = 0; index < paragraphs.length; index++)
|
),
|
||||||
Padding(
|
),
|
||||||
|
if (chapter.content.trim().isNotEmpty)
|
||||||
|
SliverPadding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
settings.horizontalPadding,
|
||||||
|
0,
|
||||||
|
settings.horizontalPadding,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
sliver: SliverList.builder(
|
||||||
|
itemCount: paragraphs.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final sentenceSlices = sentenceSlicesByParagraph[index];
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 760),
|
||||||
|
child: Padding(
|
||||||
key: _paragraphKeys[index],
|
key: _paragraphKeys[index],
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
bottom: index == paragraphs.length - 1
|
bottom: index == paragraphs.length - 1
|
||||||
@@ -973,35 +1149,53 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
),
|
),
|
||||||
child: _buildParagraphText(
|
child: _buildParagraphText(
|
||||||
context: context,
|
context: context,
|
||||||
paragraph: paragraphs[index],
|
sentenceSlices: sentenceSlices,
|
||||||
textAlign: textAlign,
|
textAlign: textAlign,
|
||||||
style: TextStyle(
|
style: paragraphStyle,
|
||||||
color: readerTextColor,
|
highlightStyle: paragraphHighlightStyle,
|
||||||
fontSize: settings.fontSize,
|
|
||||||
height: settings.lineHeight,
|
|
||||||
letterSpacing: settings.letterSpacing,
|
|
||||||
fontFamily: settings.fontFamily == 'serif'
|
|
||||||
? 'Georgia'
|
|
||||||
: settings.fontFamily == 'mono'
|
|
||||||
? 'Courier'
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
isActiveParagraph: shouldHighlightTts &&
|
isActiveParagraph: shouldHighlightTts &&
|
||||||
tts.activeParagraphIndex == index,
|
tts.activeParagraphIndex == index,
|
||||||
highlightStart: tts.progressStart,
|
highlightStart: tts.progressStart,
|
||||||
highlightEnd: tts.progressEnd,
|
highlightEnd: tts.progressEnd,
|
||||||
|
onSentenceTap: (charOffset) {
|
||||||
|
ref.read(ttsProvider.notifier).startReading(
|
||||||
|
chapter.content,
|
||||||
|
contentKey: chapter.id,
|
||||||
|
title: 'Chương ${chapter.number}: ${chapter.title}',
|
||||||
|
startParagraphIndex: index,
|
||||||
|
startCharOffset: charOffset,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverPadding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
settings.horizontalPadding,
|
||||||
|
40,
|
||||||
|
settings.horizontalPadding,
|
||||||
|
92,
|
||||||
|
),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 760),
|
||||||
|
child: _NavButtons(
|
||||||
|
chapter: chapter,
|
||||||
|
onGoPrevious: () => _goToPreviousChapter(chapter),
|
||||||
|
onGoNext: () => _goToNextChapter(chapter),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 40),
|
|
||||||
_NavButtons(chapter: chapter),
|
|
||||||
const SizedBox(height: 92),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1013,13 +1207,16 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
floatingActionButton: chapterAsync.hasValue
|
floatingActionButton: chapterAsync.hasValue
|
||||||
? AnimatedSlide(
|
? ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: _showQuickActions,
|
||||||
|
builder: (context, showQuickActions, _) {
|
||||||
|
return AnimatedSlide(
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: const Duration(milliseconds: 180),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
offset: _showQuickActions ? Offset.zero : const Offset(0, 1.4),
|
offset: showQuickActions ? Offset.zero : const Offset(0, 1.4),
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
duration: const Duration(milliseconds: 140),
|
duration: const Duration(milliseconds: 140),
|
||||||
opacity: _showQuickActions ? 1 : 0,
|
opacity: showQuickActions ? 1 : 0,
|
||||||
child: Builder(
|
child: Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final chapter = chapterAsync.value!;
|
final chapter = chapterAsync.value!;
|
||||||
@@ -1043,6 +1240,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
bottomNavigationBar: chapterAsync.whenOrNull(
|
bottomNavigationBar: chapterAsync.whenOrNull(
|
||||||
@@ -1070,6 +1269,18 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _SentenceSlice {
|
||||||
|
const _SentenceSlice({
|
||||||
|
required this.text,
|
||||||
|
required this.start,
|
||||||
|
required this.end,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String text;
|
||||||
|
final int start;
|
||||||
|
final int end;
|
||||||
|
}
|
||||||
|
|
||||||
class _TopBar extends StatelessWidget {
|
class _TopBar extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final double progress;
|
final double progress;
|
||||||
@@ -1339,20 +1550,25 @@ class _TabLabel extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _NavButtons extends ConsumerWidget {
|
class _NavButtons extends StatelessWidget {
|
||||||
final ChapterModel chapter;
|
final ChapterModel chapter;
|
||||||
const _NavButtons({required this.chapter});
|
final VoidCallback onGoPrevious;
|
||||||
|
final VoidCallback onGoNext;
|
||||||
|
|
||||||
|
const _NavButtons({
|
||||||
|
required this.chapter,
|
||||||
|
required this.onGoPrevious,
|
||||||
|
required this.onGoNext,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
if (chapter.prevChapterId != null)
|
if (chapter.prevChapterId != null)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () => context.pushReplacement(
|
onPressed: onGoPrevious,
|
||||||
RouteNames.readerChapter(chapter.prevChapterId!),
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.chevron_left),
|
icon: const Icon(Icons.chevron_left),
|
||||||
label: Text('Chương ${chapter.prevChapterNumber ?? '?'}'),
|
label: Text('Chương ${chapter.prevChapterNumber ?? '?'}'),
|
||||||
),
|
),
|
||||||
@@ -1362,9 +1578,7 @@ class _NavButtons extends ConsumerWidget {
|
|||||||
if (chapter.nextChapterId != null)
|
if (chapter.nextChapterId != null)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: () => context.pushReplacement(
|
onPressed: onGoNext,
|
||||||
RouteNames.readerChapter(chapter.nextChapterId!),
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.chevron_right),
|
icon: const Icon(Icons.chevron_right),
|
||||||
label: Text('Chương ${chapter.nextChapterNumber ?? '?'}'),
|
label: Text('Chương ${chapter.nextChapterNumber ?? '?'}'),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -30,7 +30,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) {
|
||||||
|
|||||||
@@ -61,6 +61,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(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import 'package:flutter_tts/flutter_tts.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 +32,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 +72,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 +131,40 @@ 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 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 +180,85 @@ 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> _configureVietnameseVoiceWithFlutterTts() async {
|
||||||
final dynamic voicesRaw = await _tts.getVoices;
|
final dynamic voicesRaw = await _tts.getVoices;
|
||||||
|
|
||||||
String? selectedName;
|
String? selectedName;
|
||||||
@@ -191,22 +267,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 +307,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 +315,176 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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'\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 +498,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,23 +546,24 @@ 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,
|
||||||
bool includeTitle = true,
|
bool includeTitle = true,
|
||||||
@@ -303,57 +572,38 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
await (_initFuture ?? _init());
|
await (_initFuture ?? _init());
|
||||||
}
|
}
|
||||||
|
|
||||||
final segments = <_TtsSegment>[];
|
_segments = _buildSegments(
|
||||||
|
content,
|
||||||
final titleText = title?.trim();
|
title: title,
|
||||||
if (includeTitle && titleText != null && titleText.isNotEmpty) {
|
includeTitle: includeTitle,
|
||||||
segments.add(_TtsSegment(text: titleText, paragraphIndex: -1, start: -1, end: -1));
|
|
||||||
}
|
|
||||||
|
|
||||||
final paragraphs = content
|
|
||||||
.split(RegExp(r'\n+'))
|
|
||||||
.map((p) => p.trim())
|
|
||||||
.where((p) => p.isNotEmpty)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
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 +613,91 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
progressEnd: selectedSegment.end,
|
progressEnd: selectedSegment.end,
|
||||||
contentKey: contentKey,
|
contentKey: contentKey,
|
||||||
);
|
);
|
||||||
await _syncBackgroundMode();
|
|
||||||
await _speak(validIndex);
|
await _mediaChannel.invokeMethod<void>('startReading', {
|
||||||
|
'contentKey': contentKey,
|
||||||
|
'title': title,
|
||||||
|
'startIndex': validIndex,
|
||||||
|
'speed': state.speed,
|
||||||
|
'language': state.language,
|
||||||
|
'voiceName': state.voiceName,
|
||||||
|
'backgroundModeEnabled': state.backgroundModeEnabled,
|
||||||
|
'segments': _segments.map((segment) => segment.toMap()).toList(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _speak(int index) async {
|
final sessionId = await _interruptFallbackPlayback();
|
||||||
if (index >= _segments.length) {
|
|
||||||
|
state = state.copyWith(
|
||||||
|
status: TtsStatus.playing,
|
||||||
|
paragraphIndex: validIndex,
|
||||||
|
totalParagraphs: _segments.length,
|
||||||
|
activeParagraphIndex: -1,
|
||||||
|
progressStart: -1,
|
||||||
|
progressEnd: -1,
|
||||||
|
contentKey: contentKey,
|
||||||
|
);
|
||||||
|
await _syncBackgroundMode();
|
||||||
|
|
||||||
|
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +708,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 +734,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 +806,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,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:
|
||||||
|
|||||||
@@ -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.1+2
|
||||||
|
|
||||||
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
|
||||||
|
|||||||