Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d505806f6e | |||
| 41309ff6ee | |||
| fd370f7833 | |||
| d4c6cdb013 | |||
| c3e6d66f43 | |||
| 66613857e8 | |||
| 2b8fa4ee57 | |||
| 297fc45707 | |||
| 583a41879f | |||
| 1256475bf9 | |||
| c892928ff8 | |||
| 76edaa25a4 |
@@ -115,3 +115,11 @@ Optional (iOS/web):
|
|||||||
```bash
|
```bash
|
||||||
--dart-define=GOOGLE_CLIENT_ID=<YOUR_IOS_OR_WEB_CLIENT_ID>.apps.googleusercontent.com
|
--dart-define=GOOGLE_CLIENT_ID=<YOUR_IOS_OR_WEB_CLIENT_ID>.apps.googleusercontent.com
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Noted:
|
||||||
|
|
||||||
|
Với MIUI:
|
||||||
|
Cần hướng dẫn user (không thể fix bằng code)
|
||||||
|
MIUI AutoStart: User phải vào Cài đặt → Ứng dụng → [app] → AutoStart và bật thủ công
|
||||||
|
MIUI Battery Optimization: User phải vào Cài đặt → Pin → Ứng dụng tiêu hao pin → [app] → chọn "Không hạn chế" (permission REQUEST_IGNORE_BATTERY_OPTIMIZATIONS đã có trong Manifest để trigger dialog, nhưng user vẫn phải accept)
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -66,6 +67,10 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.media:media:1.7.0")
|
||||||
|
}
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,43 +5,6 @@
|
|||||||
"storage_bucket": "reader-1658c.firebasestorage.app"
|
"storage_bucket": "reader-1658c.firebasestorage.app"
|
||||||
},
|
},
|
||||||
"client": [
|
"client": [
|
||||||
{
|
|
||||||
"client_info": {
|
|
||||||
"mobilesdk_app_id": "1:308259929553:android:9142ae16d9ddd8a91c34f0",
|
|
||||||
"android_client_info": {
|
|
||||||
"package_name": "com.example.reader_app"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"oauth_client": [
|
|
||||||
{
|
|
||||||
"client_id": "308259929553-7cdc4g8fe7os799trig7hk7ugkuansov.apps.googleusercontent.com",
|
|
||||||
"client_type": 1,
|
|
||||||
"android_info": {
|
|
||||||
"package_name": "com.example.reader_app",
|
|
||||||
"certificate_hash": "f7e9f7ec9bafd1de69934b2c9b52ee491d73bad7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"client_id": "308259929553-9oame596io3s4lcj9cdb5db6v3i6f6rk.apps.googleusercontent.com",
|
|
||||||
"client_type": 3
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"api_key": [
|
|
||||||
{
|
|
||||||
"current_key": "AIzaSyBibgTrvBWtJBL4PGeIyahBwRlYKcjQ47k"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"services": {
|
|
||||||
"appinvite_service": {
|
|
||||||
"other_platform_oauth_client": [
|
|
||||||
{
|
|
||||||
"client_id": "308259929553-9oame596io3s4lcj9cdb5db6v3i6f6rk.apps.googleusercontent.com",
|
|
||||||
"client_type": 3
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"client_info": {
|
"client_info": {
|
||||||
"mobilesdk_app_id": "1:308259929553:android:14f7828b9b9ca9d31c34f0",
|
"mobilesdk_app_id": "1:308259929553:android:14f7828b9b9ca9d31c34f0",
|
||||||
@@ -50,6 +13,30 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"oauth_client": [
|
"oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "308259929553-58cnurk30t6stf9ebj7p5jv1b5gftr29.apps.googleusercontent.com",
|
||||||
|
"client_type": 1,
|
||||||
|
"android_info": {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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.ReaderTtsStartRequest
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
private val channelName = "reader_app/tts_background"
|
private val channelName = "reader_app/tts_background"
|
||||||
|
private val mediaChannelName = "reader_app/tts_media"
|
||||||
|
private val mediaEventsChannelName = "reader_app/tts_media_events"
|
||||||
private var wakeLock: PowerManager.WakeLock? = null
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
@@ -35,6 +42,110 @@ class MainActivity : FlutterActivity() {
|
|||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, mediaChannelName)
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"initialize" -> {
|
||||||
|
val enabled = call.argument<Boolean>("backgroundModeEnabled") ?: true
|
||||||
|
ReaderTtsMediaService.initialize(this, enabled)
|
||||||
|
result.success(ReaderTtsMediaBridge.snapshot())
|
||||||
|
}
|
||||||
|
"getSnapshot" -> result.success(ReaderTtsMediaBridge.snapshot())
|
||||||
|
"startReading" -> {
|
||||||
|
val content = call.argument<String>("content") ?: ""
|
||||||
|
val contentKey = call.argument<String>("contentKey")
|
||||||
|
val title = call.argument<String>("title")
|
||||||
|
val speed = call.argument<Double>("speed") ?: 0.9
|
||||||
|
val language = call.argument<String>("language") ?: "vi-VN"
|
||||||
|
val voiceName = call.argument<String>("voiceName")
|
||||||
|
val backgroundModeEnabled = call.argument<Boolean>("backgroundModeEnabled") ?: true
|
||||||
|
val nextChapterId = call.argument<String>("nextChapterId")
|
||||||
|
val chapterNumber = call.argument<Int>("chapterNumber")
|
||||||
|
val includeTitle = call.argument<Boolean>("includeTitle") ?: true
|
||||||
|
val apiBaseUrl = call.argument<String>("apiBaseUrl")
|
||||||
|
val startIndex = call.argument<Int>("startIndex") ?: 0
|
||||||
|
ReaderTtsMediaService.startReading(
|
||||||
|
this,
|
||||||
|
ReaderTtsStartRequest(
|
||||||
|
content = content,
|
||||||
|
contentKey = contentKey,
|
||||||
|
title = title,
|
||||||
|
speed = speed,
|
||||||
|
language = language,
|
||||||
|
voiceName = voiceName,
|
||||||
|
backgroundModeEnabled = backgroundModeEnabled,
|
||||||
|
nextChapterId = nextChapterId,
|
||||||
|
chapterNumber = chapterNumber,
|
||||||
|
includeTitle = includeTitle,
|
||||||
|
apiBaseUrl = apiBaseUrl,
|
||||||
|
startIndex = startIndex,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"pause" -> {
|
||||||
|
ReaderTtsMediaService.pause(this)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"resume" -> {
|
||||||
|
ReaderTtsMediaService.resume(this)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"stop" -> {
|
||||||
|
ReaderTtsMediaService.stop(this)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"skipForward" -> {
|
||||||
|
ReaderTtsMediaService.skipForward(this)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"skipBack" -> {
|
||||||
|
ReaderTtsMediaService.skipBack(this)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"setSpeed" -> {
|
||||||
|
val speed = call.argument<Double>("speed") ?: 0.9
|
||||||
|
ReaderTtsMediaService.setSpeed(this, speed)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"setVoiceByName" -> {
|
||||||
|
ReaderTtsMediaService.setVoice(
|
||||||
|
this,
|
||||||
|
call.argument<String>("voiceName"),
|
||||||
|
call.argument<String>("language"),
|
||||||
|
)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"setBackgroundModeEnabled" -> {
|
||||||
|
val enabled = call.argument<Boolean>("enabled") ?: true
|
||||||
|
ReaderTtsMediaService.setBackgroundModeEnabled(this, enabled)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"areNotificationsEnabled" -> {
|
||||||
|
result.success(NotificationManagerCompat.from(this).areNotificationsEnabled())
|
||||||
|
}
|
||||||
|
"openNotificationSettings" -> {
|
||||||
|
openNotificationSettings()
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"dispose" -> result.success(null)
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EventChannel(flutterEngine.dartExecutor.binaryMessenger, mediaEventsChannelName)
|
||||||
|
.setStreamHandler(
|
||||||
|
object : EventChannel.StreamHandler {
|
||||||
|
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||||
|
ReaderTtsMediaBridge.attachSink(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(arguments: Any?) {
|
||||||
|
ReaderTtsMediaBridge.detachSink()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isIgnoringBatteryOptimizations(): Boolean {
|
private fun isIgnoringBatteryOptimizations(): Boolean {
|
||||||
@@ -76,6 +187,19 @@ class MainActivity : FlutterActivity() {
|
|||||||
wakeLock = null
|
wakeLock = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun openNotificationSettings() {
|
||||||
|
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||||
|
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||||
|
data = Uri.fromParts("package", packageName, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
setWakeLockEnabled(false)
|
setWakeLockEnabled(false)
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.example.reader_app.tts
|
||||||
|
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
|
||||||
|
object ReaderTtsMediaBridge {
|
||||||
|
private var eventSink: EventChannel.EventSink? = null
|
||||||
|
private var latestSnapshot: Map<String, Any?> = defaultSnapshot()
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun attachSink(sink: EventChannel.EventSink) {
|
||||||
|
eventSink = sink
|
||||||
|
sink.success(HashMap(latestSnapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun detachSink() {
|
||||||
|
eventSink = null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun publish(snapshot: Map<String, Any?>) {
|
||||||
|
latestSnapshot = HashMap(snapshot)
|
||||||
|
eventSink?.success(HashMap(latestSnapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun snapshot(): Map<String, Any?> = HashMap(latestSnapshot)
|
||||||
|
|
||||||
|
private fun defaultSnapshot(): Map<String, Any?> = hashMapOf(
|
||||||
|
"status" to "idle",
|
||||||
|
"paragraphIndex" to 0,
|
||||||
|
"totalParagraphs" to 0,
|
||||||
|
"activeParagraphIndex" to -1,
|
||||||
|
"progressStart" to -1,
|
||||||
|
"progressEnd" to -1,
|
||||||
|
"contentKey" to null,
|
||||||
|
"completedCount" to 0,
|
||||||
|
"backgroundModeEnabled" to true,
|
||||||
|
"language" to "vi-VN",
|
||||||
|
"voiceName" to null,
|
||||||
|
"availableVietnameseVoices" to emptyList<Map<String, String>>()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.example.reader_app.tts
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class ReaderTtsStartRequest(
|
||||||
|
val content: String,
|
||||||
|
val contentKey: String?,
|
||||||
|
val title: String?,
|
||||||
|
val speed: Double,
|
||||||
|
val language: String,
|
||||||
|
val voiceName: String?,
|
||||||
|
val backgroundModeEnabled: Boolean,
|
||||||
|
val nextChapterId: String?,
|
||||||
|
val chapterNumber: Int?,
|
||||||
|
val includeTitle: Boolean,
|
||||||
|
val apiBaseUrl: String?,
|
||||||
|
val startIndex: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
object ReaderTtsPlaybackStore {
|
||||||
|
private const val MAX_PENDING_REQUESTS = 4
|
||||||
|
private val pendingRequests = LinkedHashMap<String, ReaderTtsStartRequest>()
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun enqueue(request: ReaderTtsStartRequest): String {
|
||||||
|
val token = UUID.randomUUID().toString()
|
||||||
|
pendingRequests[token] = request
|
||||||
|
while (pendingRequests.size > MAX_PENDING_REQUESTS) {
|
||||||
|
val oldestKey = pendingRequests.entries.firstOrNull()?.key ?: break
|
||||||
|
pendingRequests.remove(oldestKey)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun consume(token: String?): ReaderTtsStartRequest? {
|
||||||
|
if (token.isNullOrBlank()) return null
|
||||||
|
return pendingRequests.remove(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,9 +1,15 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../core/auth/session_expiry_notifier.dart';
|
import '../core/auth/session_expiry_notifier.dart';
|
||||||
import '../core/theme/app_theme.dart';
|
import '../core/theme/app_theme.dart';
|
||||||
|
import '../core/storage/local_store.dart';
|
||||||
import '../features/auth/providers/auth_provider.dart';
|
import '../features/auth/providers/auth_provider.dart';
|
||||||
|
import '../features/reader/tts/tts_service.dart';
|
||||||
import 'router/route_names.dart';
|
import 'router/route_names.dart';
|
||||||
import 'router/app_router.dart';
|
import 'router/app_router.dart';
|
||||||
|
|
||||||
@@ -17,10 +23,39 @@ class ReaderApp extends ConsumerStatefulWidget {
|
|||||||
class _ReaderAppState extends ConsumerState<ReaderApp> {
|
class _ReaderAppState extends ConsumerState<ReaderApp> {
|
||||||
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||||
ProviderSubscription<int>? _sessionExpirySub;
|
ProviderSubscription<int>? _sessionExpirySub;
|
||||||
|
late final GoRouter _router;
|
||||||
|
String? _previousPath;
|
||||||
|
|
||||||
|
void _persistRouteForRestore() {
|
||||||
|
if (!mounted) return;
|
||||||
|
final uri = _router.state.uri;
|
||||||
|
final fullPath = uri.hasQuery ? '${uri.path}?${uri.query}' : uri.path;
|
||||||
|
if (fullPath == RouteNames.splash) return;
|
||||||
|
|
||||||
|
// When navigating into reader from a novel page, save "novelPath|readerPath"
|
||||||
|
// so the splash screen can reconstruct the full back stack on restore.
|
||||||
|
final String pathToSave;
|
||||||
|
if (fullPath.startsWith('/reader/') &&
|
||||||
|
_previousPath != null &&
|
||||||
|
_previousPath!.startsWith('/novel/')) {
|
||||||
|
pathToSave = '$_previousPath|$fullPath';
|
||||||
|
} else {
|
||||||
|
pathToSave = fullPath;
|
||||||
|
}
|
||||||
|
_previousPath = fullPath;
|
||||||
|
|
||||||
|
unawaited(ref.read(localStoreProvider).saveLastRoutePath(pathToSave));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_router = ref.read(appRouterProvider);
|
||||||
|
_router.routerDelegate.addListener(_persistRouteForRestore);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_ensureMandatoryTtsRequirements();
|
||||||
|
});
|
||||||
|
|
||||||
_sessionExpirySub = ref.listenManual<int>(
|
_sessionExpirySub = ref.listenManual<int>(
|
||||||
sessionExpiryProvider,
|
sessionExpiryProvider,
|
||||||
(previous, next) async {
|
(previous, next) async {
|
||||||
@@ -45,8 +80,48 @@ class _ReaderAppState extends ConsumerState<ReaderApp> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _ensureMandatoryTtsRequirements() async {
|
||||||
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final notifier = ref.read(ttsProvider.notifier);
|
||||||
|
await notifier.setBackgroundModeEnabled(true);
|
||||||
|
await notifier.ensureBatteryOptimizationIgnored();
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
while (mounted && !ref.read(ttsProvider).batteryOptimizationIgnored) {
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Yeu cau bat buoc cho TTS'),
|
||||||
|
content: const Text(
|
||||||
|
'Can bat Chay nen va Loai tru toi uu pin de TTS khong bi ngat dot ngot.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await notifier.setBackgroundModeEnabled(true);
|
||||||
|
await notifier.ensureBatteryOptimizationIgnored();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (ref.read(ttsProvider).batteryOptimizationIgnored) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Bat ngay'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_router.routerDelegate.removeListener(_persistRouteForRestore);
|
||||||
_sessionExpirySub?.close();
|
_sessionExpirySub?.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class AppConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||||
return 'http://10.0.2.2:8000';
|
return 'https://reader-api.fevirtus.dev';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'http://localhost:8000';
|
return 'http://localhost:8000';
|
||||||
|
|||||||
@@ -2,10 +2,26 @@ import 'package:equatable/equatable.dart';
|
|||||||
|
|
||||||
import 'novel_model.dart';
|
import 'novel_model.dart';
|
||||||
|
|
||||||
|
enum BookmarkType {
|
||||||
|
reading('reading'),
|
||||||
|
bookmarked('bookmarked');
|
||||||
|
|
||||||
|
const BookmarkType(this.value);
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
static BookmarkType fromString(String? str) {
|
||||||
|
return values.firstWhere(
|
||||||
|
(e) => e.value == str,
|
||||||
|
orElse: () => BookmarkType.bookmarked,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class BookmarkModel extends Equatable {
|
class BookmarkModel extends Equatable {
|
||||||
const BookmarkModel({
|
const BookmarkModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.novelId,
|
required this.novelId,
|
||||||
|
this.type = BookmarkType.bookmarked,
|
||||||
this.lastChapterId,
|
this.lastChapterId,
|
||||||
this.lastChapterNumber,
|
this.lastChapterNumber,
|
||||||
this.readChapters = const [],
|
this.readChapters = const [],
|
||||||
@@ -14,6 +30,7 @@ class BookmarkModel extends Equatable {
|
|||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
final String novelId;
|
final String novelId;
|
||||||
|
final BookmarkType type;
|
||||||
final String? lastChapterId;
|
final String? lastChapterId;
|
||||||
final int? lastChapterNumber;
|
final int? lastChapterNumber;
|
||||||
final List<int> readChapters;
|
final List<int> readChapters;
|
||||||
@@ -28,11 +45,27 @@ class BookmarkModel extends Equatable {
|
|||||||
?.map((e) => (e as num).toInt())
|
?.map((e) => (e as num).toInt())
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
|
type: () {
|
||||||
|
final explicitType = BookmarkType.fromString(json['type'] as String?);
|
||||||
|
if ((json['type'] as String?) != null) {
|
||||||
|
return explicitType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward-compatible inference when API does not return `type`.
|
||||||
|
final inferredLastChapter = json['lastChapterNumber'] as int?;
|
||||||
|
final inferredReadChapters = (json['readChapters'] as List<dynamic>?)
|
||||||
|
?.map((e) => (e as num).toInt())
|
||||||
|
.toList() ??
|
||||||
|
const <int>[];
|
||||||
|
return (inferredLastChapter != null || inferredReadChapters.isNotEmpty)
|
||||||
|
? BookmarkType.reading
|
||||||
|
: BookmarkType.bookmarked;
|
||||||
|
}(),
|
||||||
novel: json['novel'] != null
|
novel: json['novel'] != null
|
||||||
? NovelModel.fromJson(json['novel'] as Map<String, dynamic>)
|
? NovelModel.fromJson(json['novel'] as Map<String, dynamic>)
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [id, novelId];
|
List<Object?> get props => [id, novelId, type];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ class ReadingSettings {
|
|||||||
this.letterSpacing = 0,
|
this.letterSpacing = 0,
|
||||||
this.fontFamily = 'serif',
|
this.fontFamily = 'serif',
|
||||||
this.themePreset = 'paper',
|
this.themePreset = 'paper',
|
||||||
|
this.backgroundColorValue = 0xFFFFFEF8,
|
||||||
|
this.textColorValue = 0xFF111111,
|
||||||
this.horizontalPadding = 20,
|
this.horizontalPadding = 20,
|
||||||
this.paragraphSpacing = 24,
|
this.paragraphSpacing = 24,
|
||||||
this.textAlign = 'justify',
|
this.textAlign = 'left',
|
||||||
|
this.enableSentenceTapTts = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final double fontSize;
|
final double fontSize;
|
||||||
@@ -15,9 +18,12 @@ class ReadingSettings {
|
|||||||
final double letterSpacing;
|
final double letterSpacing;
|
||||||
final String fontFamily;
|
final String fontFamily;
|
||||||
final String themePreset;
|
final String themePreset;
|
||||||
|
final int backgroundColorValue;
|
||||||
|
final int textColorValue;
|
||||||
final double horizontalPadding;
|
final double horizontalPadding;
|
||||||
final double paragraphSpacing;
|
final double paragraphSpacing;
|
||||||
final String textAlign;
|
final String textAlign;
|
||||||
|
final bool enableSentenceTapTts;
|
||||||
|
|
||||||
ReadingSettings copyWith({
|
ReadingSettings copyWith({
|
||||||
double? fontSize,
|
double? fontSize,
|
||||||
@@ -25,9 +31,12 @@ class ReadingSettings {
|
|||||||
double? letterSpacing,
|
double? letterSpacing,
|
||||||
String? fontFamily,
|
String? fontFamily,
|
||||||
String? themePreset,
|
String? themePreset,
|
||||||
|
int? backgroundColorValue,
|
||||||
|
int? textColorValue,
|
||||||
double? horizontalPadding,
|
double? horizontalPadding,
|
||||||
double? paragraphSpacing,
|
double? paragraphSpacing,
|
||||||
String? textAlign,
|
String? textAlign,
|
||||||
|
bool? enableSentenceTapTts,
|
||||||
}) =>
|
}) =>
|
||||||
ReadingSettings(
|
ReadingSettings(
|
||||||
fontSize: fontSize ?? this.fontSize,
|
fontSize: fontSize ?? this.fontSize,
|
||||||
@@ -35,9 +44,12 @@ class ReadingSettings {
|
|||||||
letterSpacing: letterSpacing ?? this.letterSpacing,
|
letterSpacing: letterSpacing ?? this.letterSpacing,
|
||||||
fontFamily: fontFamily ?? this.fontFamily,
|
fontFamily: fontFamily ?? this.fontFamily,
|
||||||
themePreset: themePreset ?? this.themePreset,
|
themePreset: themePreset ?? this.themePreset,
|
||||||
|
backgroundColorValue: backgroundColorValue ?? this.backgroundColorValue,
|
||||||
|
textColorValue: textColorValue ?? this.textColorValue,
|
||||||
horizontalPadding: horizontalPadding ?? this.horizontalPadding,
|
horizontalPadding: horizontalPadding ?? this.horizontalPadding,
|
||||||
paragraphSpacing: paragraphSpacing ?? this.paragraphSpacing,
|
paragraphSpacing: paragraphSpacing ?? this.paragraphSpacing,
|
||||||
textAlign: textAlign ?? this.textAlign,
|
textAlign: textAlign ?? this.textAlign,
|
||||||
|
enableSentenceTapTts: enableSentenceTapTts ?? this.enableSentenceTapTts,
|
||||||
);
|
);
|
||||||
|
|
||||||
factory ReadingSettings.fromJson(Map<String, dynamic> json) => ReadingSettings(
|
factory ReadingSettings.fromJson(Map<String, dynamic> json) => ReadingSettings(
|
||||||
@@ -46,9 +58,13 @@ class ReadingSettings {
|
|||||||
letterSpacing: (json['letterSpacing'] as num?)?.toDouble() ?? 0,
|
letterSpacing: (json['letterSpacing'] as num?)?.toDouble() ?? 0,
|
||||||
fontFamily: json['fontFamily'] as String? ?? 'serif',
|
fontFamily: json['fontFamily'] as String? ?? 'serif',
|
||||||
themePreset: json['themePreset'] as String? ?? 'paper',
|
themePreset: json['themePreset'] as String? ?? 'paper',
|
||||||
|
backgroundColorValue:
|
||||||
|
(json['backgroundColorValue'] as num?)?.toInt() ?? 0xFFFFFEF8,
|
||||||
|
textColorValue: (json['textColorValue'] as num?)?.toInt() ?? 0xFF111111,
|
||||||
horizontalPadding: (json['horizontalPadding'] as num?)?.toDouble() ?? 20,
|
horizontalPadding: (json['horizontalPadding'] as num?)?.toDouble() ?? 20,
|
||||||
paragraphSpacing: (json['paragraphSpacing'] as num?)?.toDouble() ?? 24,
|
paragraphSpacing: (json['paragraphSpacing'] as num?)?.toDouble() ?? 24,
|
||||||
textAlign: json['textAlign'] as String? ?? 'justify',
|
textAlign: json['textAlign'] as String? ?? 'left',
|
||||||
|
enableSentenceTapTts: json['enableSentenceTapTts'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
@@ -57,8 +73,11 @@ class ReadingSettings {
|
|||||||
'letterSpacing': letterSpacing,
|
'letterSpacing': letterSpacing,
|
||||||
'fontFamily': fontFamily,
|
'fontFamily': fontFamily,
|
||||||
'themePreset': themePreset,
|
'themePreset': themePreset,
|
||||||
|
'backgroundColorValue': backgroundColorValue,
|
||||||
|
'textColorValue': textColorValue,
|
||||||
'horizontalPadding': horizontalPadding,
|
'horizontalPadding': horizontalPadding,
|
||||||
'paragraphSpacing': paragraphSpacing,
|
'paragraphSpacing': paragraphSpacing,
|
||||||
'textAlign': textAlign,
|
'textAlign': textAlign,
|
||||||
|
'enableSentenceTapTts': enableSentenceTapTts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,15 @@ class LocalStore {
|
|||||||
static const _kLetterSpacing = 'reader_letter_spacing';
|
static const _kLetterSpacing = 'reader_letter_spacing';
|
||||||
static const _kFontFamily = 'reader_font_family';
|
static const _kFontFamily = 'reader_font_family';
|
||||||
static const _kThemePreset = 'reader_theme_preset';
|
static const _kThemePreset = 'reader_theme_preset';
|
||||||
|
static const _kBackgroundColor = 'reader_background_color';
|
||||||
|
static const _kTextColor = 'reader_text_color';
|
||||||
static const _kHorizontalPadding = 'reader_horizontal_padding';
|
static const _kHorizontalPadding = 'reader_horizontal_padding';
|
||||||
static const _kParagraphSpacing = 'reader_paragraph_spacing';
|
static const _kParagraphSpacing = 'reader_paragraph_spacing';
|
||||||
static const _kTextAlign = 'reader_text_align';
|
static const _kTextAlign = 'reader_text_align';
|
||||||
static const _kProgressChapterId = 'progress_chapter_id_';
|
static const _kProgressChapterId = 'progress_chapter_id_';
|
||||||
static const _kProgressChapterNum = 'progress_chapter_num_';
|
static const _kProgressChapterNum = 'progress_chapter_num_';
|
||||||
static const _kProgressOffset = 'progress_offset_';
|
static const _kProgressOffset = 'progress_offset_';
|
||||||
|
static const _kLastRoutePath = 'last_route_path';
|
||||||
|
|
||||||
// ── Reading settings ──────────────────────────────────────────────────────
|
// ── Reading settings ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -24,6 +27,8 @@ class LocalStore {
|
|||||||
await prefs.setDouble(_kLetterSpacing, settings.letterSpacing);
|
await prefs.setDouble(_kLetterSpacing, settings.letterSpacing);
|
||||||
await prefs.setString(_kFontFamily, settings.fontFamily);
|
await prefs.setString(_kFontFamily, settings.fontFamily);
|
||||||
await prefs.setString(_kThemePreset, settings.themePreset);
|
await prefs.setString(_kThemePreset, settings.themePreset);
|
||||||
|
await prefs.setInt(_kBackgroundColor, settings.backgroundColorValue);
|
||||||
|
await prefs.setInt(_kTextColor, settings.textColorValue);
|
||||||
await prefs.setDouble(_kHorizontalPadding, settings.horizontalPadding);
|
await prefs.setDouble(_kHorizontalPadding, settings.horizontalPadding);
|
||||||
await prefs.setDouble(_kParagraphSpacing, settings.paragraphSpacing);
|
await prefs.setDouble(_kParagraphSpacing, settings.paragraphSpacing);
|
||||||
await prefs.setString(_kTextAlign, settings.textAlign);
|
await prefs.setString(_kTextAlign, settings.textAlign);
|
||||||
@@ -32,15 +37,29 @@ class LocalStore {
|
|||||||
Future<ReadingSettings?> loadReadingSettings() async {
|
Future<ReadingSettings?> loadReadingSettings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
if (!prefs.containsKey(_kFontSize)) return null;
|
if (!prefs.containsKey(_kFontSize)) return null;
|
||||||
|
final themePreset = prefs.getString(_kThemePreset) ?? 'paper';
|
||||||
|
final fallbackBackground = switch (themePreset) {
|
||||||
|
'night' => 0xFF101418,
|
||||||
|
'sepia' => 0xFFF6EAD7,
|
||||||
|
_ => 0xFFFFFEF8,
|
||||||
|
};
|
||||||
|
final fallbackText = switch (themePreset) {
|
||||||
|
'night' => 0xFFE6EAF2,
|
||||||
|
'sepia' => 0xFF3B2F23,
|
||||||
|
_ => 0xFF111111,
|
||||||
|
};
|
||||||
|
|
||||||
return ReadingSettings(
|
return ReadingSettings(
|
||||||
fontSize: prefs.getDouble(_kFontSize) ?? 18,
|
fontSize: prefs.getDouble(_kFontSize) ?? 18,
|
||||||
lineHeight: prefs.getDouble(_kLineHeight) ?? 1.8,
|
lineHeight: prefs.getDouble(_kLineHeight) ?? 1.8,
|
||||||
letterSpacing: prefs.getDouble(_kLetterSpacing) ?? 0,
|
letterSpacing: prefs.getDouble(_kLetterSpacing) ?? 0,
|
||||||
fontFamily: prefs.getString(_kFontFamily) ?? 'serif',
|
fontFamily: prefs.getString(_kFontFamily) ?? 'serif',
|
||||||
themePreset: prefs.getString(_kThemePreset) ?? 'paper',
|
themePreset: themePreset,
|
||||||
|
backgroundColorValue: prefs.getInt(_kBackgroundColor) ?? fallbackBackground,
|
||||||
|
textColorValue: prefs.getInt(_kTextColor) ?? fallbackText,
|
||||||
horizontalPadding: prefs.getDouble(_kHorizontalPadding) ?? 20,
|
horizontalPadding: prefs.getDouble(_kHorizontalPadding) ?? 20,
|
||||||
paragraphSpacing: prefs.getDouble(_kParagraphSpacing) ?? 24,
|
paragraphSpacing: prefs.getDouble(_kParagraphSpacing) ?? 24,
|
||||||
textAlign: prefs.getString(_kTextAlign) ?? 'justify',
|
textAlign: prefs.getString(_kTextAlign) ?? 'left',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +87,27 @@ class LocalStore {
|
|||||||
'scrollOffset': prefs.getDouble('$_kProgressOffset$novelId') ?? 0.0,
|
'scrollOffset': prefs.getDouble('$_kProgressOffset$novelId') ?? 0.0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Last route restore (cold start after process reclaim) ───────────────
|
||||||
|
|
||||||
|
Future<void> saveLastRoutePath(String path) async {
|
||||||
|
final normalized = path.trim();
|
||||||
|
if (normalized.isEmpty || normalized == '/') return;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_kLastRoutePath, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> loadLastRoutePath() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final value = prefs.getString(_kLastRoutePath)?.trim();
|
||||||
|
if (value == null || value.isEmpty) return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearLastRoutePath() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_kLastRoutePath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final localStoreProvider = Provider<LocalStore>((_) => LocalStore());
|
final localStoreProvider = Provider<LocalStore>((_) => LocalStore());
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import '../../../app/router/route_names.dart';
|
import '../../../app/router/route_names.dart';
|
||||||
import '../../../core/models/bookmark_model.dart';
|
import '../../../core/models/bookmark_model.dart';
|
||||||
|
import '../../../shared/widgets/main_app_header.dart';
|
||||||
|
import '../../novel/providers/novels_provider.dart';
|
||||||
import '../providers/bookshelf_provider.dart';
|
import '../providers/bookshelf_provider.dart';
|
||||||
import '../../auth/providers/auth_provider.dart';
|
import '../../auth/providers/auth_provider.dart';
|
||||||
|
|
||||||
@@ -17,12 +19,17 @@ class BookshelfScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
if (!isAuth) {
|
if (!isAuth) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Tủ sách')),
|
body: Column(
|
||||||
body: Center(
|
children: [
|
||||||
|
const MainAppHeader(title: 'Đăng truyện'),
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.lock_outline, size: 48),
|
const Icon(Icons.lock_outline_rounded, size: 54),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const Text('Vui lòng đăng nhập để xem tủ sách'),
|
const Text('Vui lòng đăng nhập để xem tủ sách'),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -33,28 +40,49 @@ class BookshelfScreen extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final bookshelfAsync = ref.watch(bookshelfProvider);
|
final bookshelfAsync = ref.watch(bookshelfProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
body: DefaultTabController(
|
||||||
title: const Text('Tủ sách'),
|
length: 2,
|
||||||
actions: [
|
child: Column(
|
||||||
IconButton(
|
children: [
|
||||||
icon: const Icon(Icons.refresh),
|
MainAppHeader(
|
||||||
onPressed: () => ref.read(bookshelfProvider.notifier).fetch(),
|
title: 'Đăng truyện',
|
||||||
|
bottom: Container(
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF14B8A6),
|
||||||
|
borderRadius: BorderRadius.circular(0),
|
||||||
),
|
),
|
||||||
|
child: TabBar(
|
||||||
|
indicatorColor: const Color(0xFFF7B500),
|
||||||
|
indicatorWeight: 3,
|
||||||
|
labelColor: Colors.white,
|
||||||
|
unselectedLabelColor: Colors.white70,
|
||||||
|
dividerColor: Colors.transparent,
|
||||||
|
tabs: const [
|
||||||
|
Tab(text: 'Đang đọc'),
|
||||||
|
Tab(text: 'Đánh dấu'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: bookshelfAsync.when(
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: bookshelfAsync.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(
|
error: (e, _) => Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.error_outline, size: 48),
|
const Icon(Icons.error_outline_rounded, size: 48),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text('Lỗi: $e'),
|
Text('Lỗi: $e'),
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -65,25 +93,65 @@ class BookshelfScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
data: (bookmarks) {
|
data: (bookmarks) {
|
||||||
|
final readingItems = ref.watch(readingBookmarksProvider);
|
||||||
|
final bookmarkedItems = ref.watch(savedBookmarksProvider);
|
||||||
|
|
||||||
|
return TabBarView(
|
||||||
|
children: [
|
||||||
|
_BookshelfList(
|
||||||
|
bookmarks: readingItems,
|
||||||
|
emptyLabel: 'Chưa có truyện đang đọc.',
|
||||||
|
),
|
||||||
|
_BookshelfList(
|
||||||
|
bookmarks: bookmarkedItems,
|
||||||
|
emptyLabel: 'Chưa có truyện đánh dấu.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BookshelfList extends ConsumerWidget {
|
||||||
|
const _BookshelfList({required this.bookmarks, required this.emptyLabel});
|
||||||
|
|
||||||
|
final List<BookmarkModel> bookmarks;
|
||||||
|
final String emptyLabel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
if (bookmarks.isEmpty) {
|
if (bookmarks.isEmpty) {
|
||||||
return const Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.menu_book_outlined, size: 56),
|
const Icon(Icons.menu_book_outlined, size: 56),
|
||||||
SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text('Chưa có truyện nào trong tủ sách'),
|
Text(emptyLabel),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () => ref.read(bookshelfProvider.notifier).fetch(),
|
onRefresh: () => ref.read(bookshelfProvider.notifier).fetch(),
|
||||||
child: ListView.builder(
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 14, 14, 24),
|
||||||
itemCount: bookmarks.length,
|
itemCount: bookmarks.length,
|
||||||
itemBuilder: (context, index) =>
|
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||||
_BookmarkTile(bookmark: bookmarks[index]),
|
itemBuilder: (context, index) {
|
||||||
),
|
final bookmark = bookmarks[index];
|
||||||
|
return _BookmarkTile(
|
||||||
|
bookmark: bookmark,
|
||||||
|
onRemove: () => ref
|
||||||
|
.read(bookshelfProvider.notifier)
|
||||||
|
.removeFromShelf(bookmark.novelId, bookmark.type),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -91,39 +159,145 @@ class BookshelfScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BookmarkTile extends StatelessWidget {
|
class _BookmarkTile extends ConsumerWidget {
|
||||||
final BookmarkModel bookmark;
|
final BookmarkModel bookmark;
|
||||||
const _BookmarkTile({required this.bookmark});
|
final VoidCallback onRemove;
|
||||||
|
const _BookmarkTile({
|
||||||
|
required this.bookmark,
|
||||||
|
required this.onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> _openContinueReader(BuildContext context, WidgetRef ref) async {
|
||||||
|
var targetChapterId = bookmark.lastChapterId;
|
||||||
|
if (targetChapterId == null || targetChapterId.isEmpty) {
|
||||||
|
try {
|
||||||
|
final chapters = await ref.read(
|
||||||
|
chapterListProvider(bookmark.novelId).future,
|
||||||
|
);
|
||||||
|
if (chapters.isNotEmpty) {
|
||||||
|
targetChapterId = chapters.first.id;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Fall through to novel detail when chapter lookup fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (targetChapterId != null && targetChapterId.isNotEmpty) {
|
||||||
|
context.push(RouteNames.readerChapter(targetChapterId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.push(RouteNames.novelDetail(bookmark.novelId));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final novel = bookmark.novel;
|
final novel = bookmark.novel;
|
||||||
return ListTile(
|
return GestureDetector(
|
||||||
leading: ClipRRect(
|
onTap: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
|
||||||
borderRadius: BorderRadius.circular(6),
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
child: novel?.coverUrl != null
|
child: novel?.coverUrl != null
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: novel!.coverUrl!,
|
imageUrl: novel!.coverUrl!,
|
||||||
width: 44,
|
width: 92,
|
||||||
height: 60,
|
height: 126,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
)
|
)
|
||||||
: Container(
|
: Container(
|
||||||
width: 44,
|
width: 92,
|
||||||
height: 60,
|
height: 126,
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
color: Theme.of(context).colorScheme.primaryContainer,
|
||||||
child: const Icon(Icons.menu_book, size: 20),
|
child: const Icon(Icons.menu_book, size: 28),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
novel?.title ?? bookmark.novelId,
|
novel?.title ?? bookmark.novelId,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onRemove,
|
||||||
|
child: const Icon(Icons.close_rounded, size: 20),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Số chương: ${novel?.totalChapters ?? '--'}',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (bookmark.lastChapterNumber != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Đang đọc đến: ${bookmark.lastChapterNumber} / ${novel?.totalChapters ?? '--'}',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (novel?.authorName != null) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
novel!.authorName,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: () => _openContinueReader(context, ref),
|
||||||
|
icon: const Icon(Icons.menu_book_rounded),
|
||||||
|
label: const Text('Đọc tiếp'),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF14B8A6),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
subtitle: novel?.authorName != null
|
|
||||||
? Text(novel!.authorName, maxLines: 1, overflow: TextOverflow.ellipsis)
|
|
||||||
: null,
|
|
||||||
onTap: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,62 @@ class BookshelfNotifier extends StateNotifier<AsyncValue<List<BookmarkModel>>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void syncProgress({
|
||||||
|
required String novelId,
|
||||||
|
required String chapterId,
|
||||||
|
required int chapterNumber,
|
||||||
|
Map<String, dynamic>? serverBookmark,
|
||||||
|
}) {
|
||||||
|
final current = state.valueOrNull ?? const <BookmarkModel>[];
|
||||||
|
|
||||||
|
BookmarkModel? parsedFromServer;
|
||||||
|
if (serverBookmark != null) {
|
||||||
|
try {
|
||||||
|
parsedFromServer = BookmarkModel.fromJson(serverBookmark);
|
||||||
|
} catch (_) {
|
||||||
|
parsedFromServer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final index = current.indexWhere((b) => b.novelId == novelId);
|
||||||
|
if (index >= 0) {
|
||||||
|
final existing = current[index];
|
||||||
|
final merged = parsedFromServer ?? BookmarkModel(
|
||||||
|
id: existing.id,
|
||||||
|
novelId: existing.novelId,
|
||||||
|
type: BookmarkType.reading,
|
||||||
|
lastChapterId: chapterId,
|
||||||
|
lastChapterNumber: chapterNumber,
|
||||||
|
readChapters: {
|
||||||
|
...existing.readChapters,
|
||||||
|
chapterNumber,
|
||||||
|
}.toList()
|
||||||
|
..sort(),
|
||||||
|
novel: existing.novel,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = [...current]..[index] = merged;
|
||||||
|
state = AsyncValue.data(updated);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedFromServer != null) {
|
||||||
|
state = AsyncValue.data([parsedFromServer, ...current]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback when API response doesn't include bookmark object.
|
||||||
|
final synthetic = BookmarkModel(
|
||||||
|
id: 'progress-$novelId',
|
||||||
|
novelId: novelId,
|
||||||
|
type: BookmarkType.reading,
|
||||||
|
lastChapterId: chapterId,
|
||||||
|
lastChapterNumber: chapterNumber,
|
||||||
|
readChapters: [chapterNumber],
|
||||||
|
);
|
||||||
|
state = AsyncValue.data([synthetic, ...current]);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> toggle(String novelId) async {
|
Future<void> toggle(String novelId) async {
|
||||||
try {
|
try {
|
||||||
final client = _ref.read(apiClientProvider);
|
final client = _ref.read(apiClientProvider);
|
||||||
@@ -44,6 +100,22 @@ class BookshelfNotifier extends StateNotifier<AsyncValue<List<BookmarkModel>>> {
|
|||||||
bool isBookmarked(String novelId) {
|
bool isBookmarked(String novelId) {
|
||||||
return (state.valueOrNull ?? []).any((b) => b.novelId == novelId);
|
return (state.valueOrNull ?? []).any((b) => b.novelId == novelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> removeFromShelf(String novelId, BookmarkType type) async {
|
||||||
|
try {
|
||||||
|
final client = _ref.read(apiClientProvider);
|
||||||
|
await client.dio.delete(
|
||||||
|
'/api/user/bookmarks/$novelId',
|
||||||
|
queryParameters: {'type': type.value},
|
||||||
|
);
|
||||||
|
final current = state.valueOrNull ?? [];
|
||||||
|
state = AsyncValue.data(
|
||||||
|
current.where((b) => b.novelId != novelId || b.type != type).toList(),
|
||||||
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncValue.error(e, st);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final bookshelfProvider =
|
final bookshelfProvider =
|
||||||
@@ -51,6 +123,16 @@ final bookshelfProvider =
|
|||||||
return BookshelfNotifier(ref);
|
return BookshelfNotifier(ref);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final readingBookmarksProvider = Provider<List<BookmarkModel>>((ref) {
|
||||||
|
final bookmarks = ref.watch(bookshelfProvider).valueOrNull ?? [];
|
||||||
|
return bookmarks.where((b) => b.type == BookmarkType.reading).toList();
|
||||||
|
});
|
||||||
|
|
||||||
|
final savedBookmarksProvider = Provider<List<BookmarkModel>>((ref) {
|
||||||
|
final bookmarks = ref.watch(bookshelfProvider).valueOrNull ?? [];
|
||||||
|
return bookmarks.where((b) => b.type == BookmarkType.bookmarked).toList();
|
||||||
|
});
|
||||||
|
|
||||||
final isBookmarkedProvider = Provider.family<bool, String>((ref, novelId) {
|
final isBookmarkedProvider = Provider.family<bool, String>((ref, novelId) {
|
||||||
final bookshelf = ref.watch(bookshelfProvider);
|
final bookshelf = ref.watch(bookshelfProvider);
|
||||||
return bookshelf.valueOrNull?.any((b) => b.novelId == novelId) ?? false;
|
return bookshelf.valueOrNull?.any((b) => b.novelId == novelId) ?? false;
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../app/router/route_names.dart';
|
import '../../../app/router/route_names.dart';
|
||||||
import '../../../core/models/novel_model.dart';
|
import '../../../core/models/novel_model.dart';
|
||||||
|
import '../../../shared/widgets/main_app_header.dart';
|
||||||
import '../providers/home_provider.dart';
|
import '../providers/home_provider.dart';
|
||||||
|
|
||||||
class HomeScreen extends ConsumerWidget {
|
class HomeScreen extends ConsumerWidget {
|
||||||
@@ -13,63 +16,68 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final homeAsync = ref.watch(homeProvider);
|
final homeAsync = ref.watch(homeProvider);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
backgroundColor: colorScheme.surface,
|
||||||
title: const Text('Reader'),
|
body: Column(
|
||||||
actions: [
|
children: [
|
||||||
IconButton(
|
const MainAppHeader(),
|
||||||
icon: const Icon(Icons.search),
|
Expanded(
|
||||||
onPressed: () => context.go(RouteNames.search),
|
child: homeAsync.when(
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: homeAsync.when(
|
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(
|
error: (e, _) => Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.error_outline, size: 48),
|
const Icon(Icons.cloud_off_rounded, size: 52),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text('Lỗi tải dữ liệu', style: Theme.of(context).textTheme.bodyLarge),
|
Text('Không thể tải dữ liệu trang chủ'),
|
||||||
Padding(
|
const SizedBox(height: 8),
|
||||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 0),
|
Text(
|
||||||
child: Text(
|
|
||||||
e.toString(),
|
e.toString(),
|
||||||
textAlign: TextAlign.center,
|
|
||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 12),
|
||||||
TextButton(
|
FilledButton(
|
||||||
onPressed: () => ref.invalidate(homeProvider),
|
onPressed: () => ref.invalidate(homeProvider),
|
||||||
child: const Text('Thử lại'),
|
child: const Text('Tải lại'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
data: (data) => RefreshIndicator(
|
data: (data) => RefreshIndicator(
|
||||||
onRefresh: () async => ref.invalidate(homeProvider),
|
onRefresh: () async => ref.invalidate(homeProvider),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(0, 12, 0, 24),
|
||||||
children: [
|
children: [
|
||||||
_HotCarousel(novels: data.hot),
|
_HotCarousel(novels: data.hot),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const _HomeQuickFilters(),
|
||||||
_SectionHeader(
|
_SectionHeader(
|
||||||
title: 'Mới cập nhật',
|
title: 'Truyện mới nhất',
|
||||||
onMore: () => context.go(RouteNames.search),
|
onMore: () => context.go(RouteNames.search),
|
||||||
),
|
),
|
||||||
_NovelHorizontalList(novels: data.latest),
|
_NovelHorizontalList(novels: data.latest),
|
||||||
_SectionHeader(
|
_SectionHeader(
|
||||||
title: 'Đánh giá cao',
|
title: 'Đề cử nổi bật',
|
||||||
onMore: () => context.go(RouteNames.search),
|
onMore: () => context.go('${RouteNames.search}?sort=rating'),
|
||||||
),
|
),
|
||||||
_NovelHorizontalList(novels: data.topRated),
|
_FeatureGrid(novels: data.topRated.take(6).toList()),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 12),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,18 +90,82 @@ class _SectionHeader extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Padding(
|
Widget build(BuildContext context) => Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 20, 8, 8),
|
padding: const EdgeInsets.fromLTRB(18, 18, 12, 6),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (onMore != null)
|
if (onMore != null)
|
||||||
TextButton(onPressed: onMore, child: const Text('Xem thêm')),
|
InkWell(
|
||||||
|
onTap: onMore,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Xem thêm',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
color: const Color(0xFF14B8A6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Icon(Icons.chevron_right_rounded, color: Color(0xFF14B8A6)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _HomeQuickFilters extends StatelessWidget {
|
||||||
|
const _HomeQuickFilters();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
const items = [
|
||||||
|
(Icons.dashboard_customize_rounded, 'Thể loại'),
|
||||||
|
(Icons.verified_rounded, 'Hoàn thành'),
|
||||||
|
(Icons.sell_rounded, 'Miễn phí'),
|
||||||
|
(Icons.local_fire_department_rounded, 'Truyện hot'),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(10, 0, 10, 8),
|
||||||
|
child: Row(
|
||||||
|
children: items
|
||||||
|
.map(
|
||||||
|
(item) => Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(item.$1, color: const Color(0xFF14B8A6), size: 26),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
item.$2,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: const Color(0xFF14B8A6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _HotCarousel extends StatefulWidget {
|
class _HotCarousel extends StatefulWidget {
|
||||||
final List<NovelModel> novels;
|
final List<NovelModel> novels;
|
||||||
const _HotCarousel({required this.novels});
|
const _HotCarousel({required this.novels});
|
||||||
@@ -103,10 +175,58 @@ class _HotCarousel extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HotCarouselState extends State<_HotCarousel> {
|
class _HotCarouselState extends State<_HotCarousel> {
|
||||||
final PageController _controller = PageController(viewportFraction: 0.85);
|
late PageController _controller;
|
||||||
|
Timer? _autoSlideTimer;
|
||||||
|
int _currentPage = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = PageController(viewportFraction: 1);
|
||||||
|
_startAutoSlide();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void reassemble() {
|
||||||
|
super.reassemble();
|
||||||
|
_recreateController();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _recreateController() {
|
||||||
|
final oldController = _controller;
|
||||||
|
final page = oldController.hasClients
|
||||||
|
? (oldController.page?.round() ?? _currentPage)
|
||||||
|
: _currentPage;
|
||||||
|
_controller = PageController(initialPage: page, viewportFraction: 1);
|
||||||
|
oldController.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant _HotCarousel oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.novels.length != widget.novels.length) {
|
||||||
|
_startAutoSlide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startAutoSlide() {
|
||||||
|
_autoSlideTimer?.cancel();
|
||||||
|
if (widget.novels.length <= 1) return;
|
||||||
|
|
||||||
|
_autoSlideTimer = Timer.periodic(const Duration(seconds: 4), (_) {
|
||||||
|
if (!mounted || !_controller.hasClients) return;
|
||||||
|
final nextPage = (_currentPage + 1) % widget.novels.length;
|
||||||
|
_controller.animateToPage(
|
||||||
|
nextPage,
|
||||||
|
duration: const Duration(milliseconds: 360),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_autoSlideTimer?.cancel();
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -115,10 +235,19 @@ class _HotCarouselState extends State<_HotCarousel> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (widget.novels.isEmpty) return const SizedBox.shrink();
|
if (widget.novels.isEmpty) return const SizedBox.shrink();
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 220,
|
height: 260,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: ClipRect(
|
||||||
child: PageView.builder(
|
child: PageView.builder(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
itemCount: widget.novels.length,
|
itemCount: widget.novels.length,
|
||||||
|
onPageChanged: (value) => setState(() => _currentPage = value),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final novel = widget.novels[index];
|
final novel = widget.novels[index];
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
@@ -127,6 +256,29 @@ class _HotCarouselState extends State<_HotCarousel> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: List.generate(widget.novels.length.clamp(0, 5), (index) {
|
||||||
|
final active = index == _currentPage.clamp(0, 4);
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||||
|
width: active ? 16 : 7,
|
||||||
|
height: 7,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: active ? const Color(0xFF14B8A6) : Colors.white54,
|
||||||
|
borderRadius: BorderRadius.circular(99),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,11 +289,7 @@ class _CarouselCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Stack(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 12),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: Stack(
|
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
if (novel.coverUrl != null)
|
if (novel.coverUrl != null)
|
||||||
@@ -149,8 +297,7 @@ class _CarouselCard extends StatelessWidget {
|
|||||||
imageUrl: novel.coverUrl!,
|
imageUrl: novel.coverUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: (_, imageUrl) => Container(color: Colors.grey[200]),
|
placeholder: (_, imageUrl) => Container(color: Colors.grey[200]),
|
||||||
errorWidget: (_, imageUrl, error) =>
|
errorWidget: (_, imageUrl, error) => Container(color: Colors.grey[300]),
|
||||||
Container(color: Colors.grey[300]),
|
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Container(color: Theme.of(context).colorScheme.primaryContainer),
|
Container(color: Theme.of(context).colorScheme.primaryContainer),
|
||||||
@@ -169,20 +316,49 @@ class _CarouselCard extends StatelessWidget {
|
|||||||
bottom: 12,
|
bottom: 12,
|
||||||
left: 12,
|
left: 12,
|
||||||
right: 12,
|
right: 12,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (novel.status.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF22C55E),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
|
novel.status,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
novel.title,
|
novel.title,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 16,
|
fontSize: 20,
|
||||||
),
|
),
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
novel.description?.trim().isNotEmpty == true
|
||||||
|
? novel.description!.trim()
|
||||||
|
: novel.authorName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(color: Colors.white70, fontStyle: FontStyle.italic),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,9 +370,9 @@ class _NovelHorizontalList extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 200,
|
height: 226,
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
itemCount: novels.length,
|
itemCount: novels.length,
|
||||||
separatorBuilder: (_, separatorIndex) => const SizedBox(width: 12),
|
separatorBuilder: (_, separatorIndex) => const SizedBox(width: 12),
|
||||||
@@ -205,32 +381,45 @@ class _NovelHorizontalList extends StatelessWidget {
|
|||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => context.push(RouteNames.novelDetail(novel.id)),
|
onTap: () => context.push(RouteNames.novelDetail(novel.id)),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 110,
|
width: 122,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(10),
|
||||||
child: novel.coverUrl != null
|
child: novel.coverUrl != null
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: novel.coverUrl!,
|
imageUrl: novel.coverUrl!,
|
||||||
width: 110,
|
width: 122,
|
||||||
height: 150,
|
height: 155,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
)
|
)
|
||||||
: Container(
|
: Container(
|
||||||
width: 110,
|
width: 122,
|
||||||
height: 150,
|
height: 155,
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
color: Theme.of(context).colorScheme.primaryContainer,
|
||||||
child: const Icon(Icons.menu_book),
|
child: const Icon(Icons.menu_book),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Flexible(
|
||||||
|
child: Text(
|
||||||
novel.title,
|
novel.title,
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
'${novel.totalChapters} chương',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: const Color(0xFF58D68D),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -241,3 +430,77 @@ class _NovelHorizontalList extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _FeatureGrid extends StatelessWidget {
|
||||||
|
const _FeatureGrid({required this.novels});
|
||||||
|
|
||||||
|
final List<NovelModel> novels;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (novels.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 4, 18, 0),
|
||||||
|
child: GridView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
itemCount: novels.length,
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
mainAxisSpacing: 18,
|
||||||
|
crossAxisSpacing: 14,
|
||||||
|
childAspectRatio: 0.74,
|
||||||
|
),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final novel = novels[index];
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => context.push(RouteNames.novelDetail(novel.id)),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: novel.coverUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: novel.coverUrl!,
|
||||||
|
width: double.infinity,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
color: Theme.of(context).colorScheme.primaryContainer,
|
||||||
|
child: const Center(child: Icon(Icons.menu_book_rounded)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
novel.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'${novel.totalChapters} Chương',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: const Color(0xFF58D68D),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${novel.bookmarkCount > 0 ? novel.bookmarkCount : novel.views} ${novel.bookmarkCount > 0 ? 'Đề cử/tuần' : 'Lượt xem'}',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: const Color(0xFF1677FF),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import '../../../core/models/novel_model.dart';
|
|||||||
import '../../../core/models/chapter_model.dart';
|
import '../../../core/models/chapter_model.dart';
|
||||||
import '../../../core/network/providers.dart';
|
import '../../../core/network/providers.dart';
|
||||||
|
|
||||||
const chapterPageSize = 50;
|
|
||||||
|
|
||||||
// ─── Browse / Search ──────────────────────────────────────────────────────────
|
// ─── Browse / Search ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class BrowseParams {
|
class BrowseParams {
|
||||||
@@ -23,11 +21,40 @@ class BrowseParams {
|
|||||||
this.page = 1,
|
this.page = 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
String? _normalizedStatus() {
|
||||||
|
final raw = status?.trim();
|
||||||
|
if (raw == null || raw.isEmpty) return null;
|
||||||
|
switch (raw.toLowerCase()) {
|
||||||
|
case 'ongoing':
|
||||||
|
return 'ONGOING';
|
||||||
|
case 'completed':
|
||||||
|
return 'COMPLETED';
|
||||||
|
case 'hiatus':
|
||||||
|
return 'HIATUS';
|
||||||
|
default:
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizedSort() {
|
||||||
|
final raw = sort.trim();
|
||||||
|
if (raw.isEmpty) return 'latest';
|
||||||
|
switch (raw.toLowerCase()) {
|
||||||
|
case 'latest':
|
||||||
|
case 'popular':
|
||||||
|
case 'rating':
|
||||||
|
case 'name':
|
||||||
|
return raw.toLowerCase();
|
||||||
|
default:
|
||||||
|
return 'latest';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toQueryParams() => {
|
Map<String, dynamic> toQueryParams() => {
|
||||||
if (query != null && query!.isNotEmpty) 'q': query,
|
if (query != null && query!.isNotEmpty) 'q': query,
|
||||||
if (genre != null) 'genre': genre,
|
if (genre != null) 'genre': genre,
|
||||||
if (status != null) 'status': status,
|
if (_normalizedStatus() != null) 'status': _normalizedStatus(),
|
||||||
'sort': sort,
|
'sort': _normalizedSort(),
|
||||||
'page': page.toString(),
|
'page': page.toString(),
|
||||||
'limit': '20',
|
'limit': '20',
|
||||||
};
|
};
|
||||||
@@ -56,18 +83,39 @@ class BrowseResult {
|
|||||||
final int totalCount;
|
final int totalCount;
|
||||||
final int totalPages;
|
final int totalPages;
|
||||||
final int currentPage;
|
final int currentPage;
|
||||||
|
final bool isLoadingMore;
|
||||||
|
|
||||||
const BrowseResult({
|
const BrowseResult({
|
||||||
required this.items,
|
required this.items,
|
||||||
required this.totalCount,
|
required this.totalCount,
|
||||||
required this.totalPages,
|
required this.totalPages,
|
||||||
required this.currentPage,
|
required this.currentPage,
|
||||||
|
this.isLoadingMore = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool get hasMore => currentPage < totalPages;
|
||||||
|
|
||||||
|
BrowseResult copyWith({
|
||||||
|
List<NovelModel>? items,
|
||||||
|
int? totalCount,
|
||||||
|
int? totalPages,
|
||||||
|
int? currentPage,
|
||||||
|
bool? isLoadingMore,
|
||||||
|
}) {
|
||||||
|
return BrowseResult(
|
||||||
|
items: items ?? this.items,
|
||||||
|
totalCount: totalCount ?? this.totalCount,
|
||||||
|
totalPages: totalPages ?? this.totalPages,
|
||||||
|
currentPage: currentPage ?? this.currentPage,
|
||||||
|
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class NovelsNotifier extends StateNotifier<AsyncValue<BrowseResult>> {
|
class NovelsNotifier extends StateNotifier<AsyncValue<BrowseResult>> {
|
||||||
final Ref _ref;
|
final Ref _ref;
|
||||||
BrowseParams _params = const BrowseParams();
|
BrowseParams _params = const BrowseParams();
|
||||||
|
bool _isLoadingMore = false;
|
||||||
|
|
||||||
NovelsNotifier(this._ref) : super(const AsyncValue.loading()) {
|
NovelsNotifier(this._ref) : super(const AsyncValue.loading()) {
|
||||||
fetch();
|
fetch();
|
||||||
@@ -75,25 +123,53 @@ class NovelsNotifier extends StateNotifier<AsyncValue<BrowseResult>> {
|
|||||||
|
|
||||||
BrowseParams get params => _params;
|
BrowseParams get params => _params;
|
||||||
|
|
||||||
|
Future<BrowseResult> _fetchPage(BrowseParams params) async {
|
||||||
|
final client = _ref.read(apiClientProvider);
|
||||||
|
final res = await client.dio.get('/api/novels/browse', queryParameters: params.toQueryParams());
|
||||||
|
final data = res.data as Map<String, dynamic>;
|
||||||
|
return BrowseResult(
|
||||||
|
items: (data['items'] as List).map((e) => NovelModel.fromJson(e as Map<String, dynamic>)).toList(),
|
||||||
|
totalCount: (data['totalCount'] as num?)?.toInt() ?? 0,
|
||||||
|
totalPages: (data['totalPages'] as num?)?.toInt() ?? 1,
|
||||||
|
currentPage: (data['currentPage'] as num?)?.toInt() ?? params.page,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> fetch({BrowseParams? params}) async {
|
Future<void> fetch({BrowseParams? params}) async {
|
||||||
if (params != null) _params = params;
|
if (params != null) _params = params;
|
||||||
state = const AsyncValue.loading();
|
state = const AsyncValue.loading();
|
||||||
try {
|
try {
|
||||||
final client = _ref.read(apiClientProvider);
|
final firstPageParams = _params.copyWith(page: 1);
|
||||||
final res = await client.dio.get('/api/novels/browse', queryParameters: _params.toQueryParams());
|
final result = await _fetchPage(firstPageParams);
|
||||||
final data = res.data as Map<String, dynamic>;
|
_params = firstPageParams;
|
||||||
state = AsyncValue.data(BrowseResult(
|
state = AsyncValue.data(result);
|
||||||
items: (data['items'] as List).map((e) => NovelModel.fromJson(e as Map<String, dynamic>)).toList(),
|
|
||||||
totalCount: data['totalCount'] as int,
|
|
||||||
totalPages: data['totalPages'] as int,
|
|
||||||
currentPage: data['currentPage'] as int,
|
|
||||||
));
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
state = AsyncValue.error(e, st);
|
state = AsyncValue.error(e, st);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateParams(BrowseParams params) => fetch(params: params);
|
Future<void> updateParams(BrowseParams params) => fetch(params: params);
|
||||||
|
|
||||||
|
Future<void> loadNextPage() async {
|
||||||
|
final current = state.valueOrNull;
|
||||||
|
if (current == null || !current.hasMore || _isLoadingMore) return;
|
||||||
|
|
||||||
|
_isLoadingMore = true;
|
||||||
|
state = AsyncValue.data(current.copyWith(isLoadingMore: true));
|
||||||
|
|
||||||
|
try {
|
||||||
|
final nextParams = _params.copyWith(page: current.currentPage + 1);
|
||||||
|
final nextPage = await _fetchPage(nextParams);
|
||||||
|
_params = nextParams;
|
||||||
|
|
||||||
|
final merged = [...current.items, ...nextPage.items];
|
||||||
|
state = AsyncValue.data(nextPage.copyWith(items: merged, isLoadingMore: false));
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncValue.error(e, st);
|
||||||
|
} finally {
|
||||||
|
_isLoadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final novelsProvider = StateNotifierProvider<NovelsNotifier, AsyncValue<BrowseResult>>((ref) {
|
final novelsProvider = StateNotifierProvider<NovelsNotifier, AsyncValue<BrowseResult>>((ref) {
|
||||||
@@ -111,77 +187,51 @@ final novelDetailProvider =
|
|||||||
|
|
||||||
// ─── Chapter List ─────────────────────────────────────────────────────────────
|
// ─── Chapter List ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class ChapterListQuery {
|
|
||||||
const ChapterListQuery({required this.novelId, this.page = 1});
|
|
||||||
|
|
||||||
final String novelId;
|
|
||||||
final int page;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) {
|
|
||||||
if (identical(this, other)) return true;
|
|
||||||
return other is ChapterListQuery &&
|
|
||||||
other.novelId == novelId &&
|
|
||||||
other.page == page;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => Object.hash(novelId, page);
|
|
||||||
}
|
|
||||||
|
|
||||||
class ChapterListPage {
|
|
||||||
const ChapterListPage({
|
|
||||||
required this.chapters,
|
|
||||||
required this.totalChapters,
|
|
||||||
required this.totalPages,
|
|
||||||
required this.currentPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
final List<ChapterListItem> chapters;
|
|
||||||
final int totalChapters;
|
|
||||||
final int totalPages;
|
|
||||||
final int currentPage;
|
|
||||||
}
|
|
||||||
|
|
||||||
final chapterListProvider =
|
final chapterListProvider =
|
||||||
FutureProvider.family<ChapterListPage, ChapterListQuery>((ref, query) async {
|
FutureProvider.family<List<ChapterListItem>, String>((ref, novelId) async {
|
||||||
final client = ref.read(apiClientProvider);
|
final client = ref.read(apiClientProvider);
|
||||||
|
|
||||||
Future<Map<String, dynamic>> fetchChapterPage(String idOrSlug) async {
|
Future<List<ChapterListItem>> fetchAllChapters(String idOrSlug) async {
|
||||||
|
const limit = 500;
|
||||||
|
var page = 1;
|
||||||
|
var totalPages = 1;
|
||||||
|
final items = <ChapterListItem>[];
|
||||||
|
|
||||||
|
while (page <= totalPages) {
|
||||||
final res = await client.dio.get(
|
final res = await client.dio.get(
|
||||||
'/api/truyen/$idOrSlug/chapters',
|
'/api/truyen/$idOrSlug/chapters',
|
||||||
queryParameters: {
|
queryParameters: {'page': page, 'limit': limit},
|
||||||
'page': query.page,
|
|
||||||
'limit': chapterPageSize,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
return res.data as Map<String, dynamic>;
|
final data = res.data as Map<String, dynamic>;
|
||||||
|
final chapters = data['chapters'] as List? ?? const [];
|
||||||
|
|
||||||
|
items.addAll(
|
||||||
|
chapters.map((e) => ChapterListItem.fromJson(e as Map<String, dynamic>)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final apiTotalPages = (data['totalPages'] as num?)?.toInt() ?? 1;
|
||||||
|
totalPages = apiTotalPages > 0 ? apiTotalPages : 1;
|
||||||
|
page += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
var data = await fetchChapterPage(query.novelId);
|
return items;
|
||||||
var chapters = data['chapters'] as List? ?? const [];
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await fetchAllChapters(novelId);
|
||||||
|
} catch (_) {
|
||||||
// Backend stores chapters by novel id in MongoDB; if route opened by slug,
|
// Backend stores chapters by novel id in MongoDB; if route opened by slug,
|
||||||
// first request can return empty list. Resolve canonical id and retry once.
|
// first request can return empty list. Resolve canonical id and retry once.
|
||||||
if (chapters.isEmpty) {
|
|
||||||
try {
|
try {
|
||||||
final novelRes = await client.dio.get('/api/novels/${query.novelId}');
|
final novelRes = await client.dio.get('/api/novels/$novelId');
|
||||||
final novelData = novelRes.data as Map<String, dynamic>;
|
final novelData = novelRes.data as Map<String, dynamic>;
|
||||||
final canonicalId = novelData['id'] as String?;
|
final canonicalId = novelData['id'] as String?;
|
||||||
if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != query.novelId) {
|
if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != novelId) {
|
||||||
data = await fetchChapterPage(canonicalId);
|
return await fetchAllChapters(canonicalId);
|
||||||
chapters = data['chapters'] as List? ?? const [];
|
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Keep original empty list when fallback resolution fails.
|
// Keep original empty list when fallback resolution fails.
|
||||||
}
|
}
|
||||||
|
rethrow;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ChapterListPage(
|
|
||||||
chapters:
|
|
||||||
chapters.map((e) => ChapterListItem.fromJson(e as Map<String, dynamic>)).toList(),
|
|
||||||
totalChapters: (data['totalChapters'] as num?)?.toInt() ?? 0,
|
|
||||||
totalPages: (data['totalPages'] as num?)?.toInt() ?? 0,
|
|
||||||
currentPage: (data['currentPage'] as num?)?.toInt() ?? query.page,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../app/router/route_names.dart';
|
import '../../../app/router/route_names.dart';
|
||||||
|
import '../../../shared/widgets/main_app_header.dart';
|
||||||
import '../../auth/providers/auth_provider.dart';
|
import '../../auth/providers/auth_provider.dart';
|
||||||
import '../../bookshelf/providers/bookshelf_provider.dart';
|
import '../../bookshelf/providers/bookshelf_provider.dart';
|
||||||
|
|
||||||
@@ -22,94 +23,109 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Tài khoản')),
|
body: Column(
|
||||||
body: switch (authState) {
|
|
||||||
AuthAuthenticated(:final user) => SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
children: [
|
||||||
// User Avatar & Basic Info
|
const MainAppHeader(title: 'Trang cá nhân', showGenresShortcut: false),
|
||||||
Container(
|
Expanded(
|
||||||
padding: const EdgeInsets.all(20),
|
child: switch (authState) {
|
||||||
decoration: BoxDecoration(
|
AuthAuthenticated(:final user) => SingleChildScrollView(
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 40,
|
radius: 34,
|
||||||
backgroundImage:
|
backgroundImage:
|
||||||
user.image != null ? NetworkImage(user.image!) : null,
|
user.image != null ? NetworkImage(user.image!) : null,
|
||||||
child: user.image == null
|
child: user.image == null
|
||||||
? Text(
|
? Text(
|
||||||
displayName[0].toUpperCase(),
|
displayName.isNotEmpty ? displayName[0].toUpperCase() : 'U',
|
||||||
style:
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
Theme.of(context).textTheme.headlineMedium,
|
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
Text(
|
Text(
|
||||||
displayName,
|
displayName,
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
textAlign: TextAlign.center,
|
),
|
||||||
|
Text(
|
||||||
|
user.role.toLowerCase(),
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_AccountStatRow(
|
||||||
|
icon: Icons.auto_awesome,
|
||||||
|
label: 'Tiên Thạch: 0.00 TT',
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
_AccountStatRow(
|
||||||
user.email,
|
icon: Icons.diamond,
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
label: 'Linh Phiếu: 0 LP',
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Stats Cards
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _buildStatCard(
|
|
||||||
context: context,
|
|
||||||
label: 'Sách Đánh Dấu',
|
|
||||||
count: bookmarkedCount,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: _buildStatCard(
|
|
||||||
context: context,
|
|
||||||
label: 'Đang Đọc',
|
|
||||||
count: bookmarkedCount,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Settings Button
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: FilledButton.icon(
|
|
||||||
onPressed: () => context.push(RouteNames.settings),
|
|
||||||
icon: const Icon(Icons.tune),
|
|
||||||
label: const Text('Cài Đặt Đọc'),
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_AccountStatRow(
|
||||||
|
icon: Icons.local_activity,
|
||||||
|
label: 'Ngọc Phiếu: $bookmarkedCount',
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
FilledButton.icon(
|
||||||
// Logout Button
|
onPressed: () {},
|
||||||
SizedBox(
|
icon: const Icon(Icons.workspace_premium_rounded),
|
||||||
width: double.infinity,
|
label: const Text('Thêm Tiên Thạch'),
|
||||||
child: OutlinedButton.icon(
|
style: FilledButton.styleFrom(
|
||||||
onPressed: () async {
|
backgroundColor: const Color(0xFF14B8A6),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_ProfileMenuTile(
|
||||||
|
title: 'Chỉnh sửa thông tin',
|
||||||
|
onTap: () => context.push(RouteNames.settings),
|
||||||
|
),
|
||||||
|
_ProfileMenuTile(
|
||||||
|
title: 'Lịch sử giao dịch',
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
_ProfileMenuTile(
|
||||||
|
title: 'Liên hệ, báo lỗi',
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
_ProfileMenuTile(
|
||||||
|
title: 'Điều khoản dịch vụ',
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
_ProfileMenuTile(
|
||||||
|
title: 'Xóa tài khoản',
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
_ProfileMenuTile(
|
||||||
|
title: 'Đăng xuất',
|
||||||
|
onTap: () async {
|
||||||
await ref.read(authProvider.notifier).signOut();
|
await ref.read(authProvider.notifier).signOut();
|
||||||
if (context.mounted) context.go(RouteNames.home);
|
if (context.mounted) context.go(RouteNames.home);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.logout),
|
|
||||||
label: const Text('Đăng Xuất'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -137,36 +153,50 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
_ => const Center(child: CircularProgressIndicator()),
|
_ => const Center(child: CircularProgressIndicator()),
|
||||||
},
|
},
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildStatCard({
|
|
||||||
required BuildContext context,
|
|
||||||
required String label,
|
|
||||||
required int count,
|
|
||||||
}) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
count.toString(),
|
|
||||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _AccountStatRow extends StatelessWidget {
|
||||||
|
const _AccountStatRow({required this.icon, required this.label});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: const Color(0xFF58D68D)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(label, style: Theme.of(context).textTheme.titleMedium)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProfileMenuTile extends StatelessWidget {
|
||||||
|
const _ProfileMenuTile({required this.title, required this.onTap});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(title),
|
||||||
|
trailing: const Icon(Icons.chevron_right_rounded),
|
||||||
|
onTap: onTap,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ class TtsPlayerWidget extends ConsumerWidget {
|
|||||||
required this.content,
|
required this.content,
|
||||||
this.contentKey,
|
this.contentKey,
|
||||||
this.title,
|
this.title,
|
||||||
|
this.nextChapterId,
|
||||||
|
this.chapterNumber,
|
||||||
|
this.apiBaseUrl,
|
||||||
this.includeTitleOnStart = true,
|
this.includeTitleOnStart = true,
|
||||||
this.resolveStartParagraphIndex,
|
this.resolveStartParagraphIndex,
|
||||||
this.onStarted,
|
this.onStarted,
|
||||||
@@ -20,6 +23,9 @@ class TtsPlayerWidget extends ConsumerWidget {
|
|||||||
final String content;
|
final String content;
|
||||||
final String? contentKey;
|
final String? contentKey;
|
||||||
final String? title;
|
final String? title;
|
||||||
|
final String? nextChapterId;
|
||||||
|
final int? chapterNumber;
|
||||||
|
final String? apiBaseUrl;
|
||||||
final bool includeTitleOnStart;
|
final bool includeTitleOnStart;
|
||||||
final int Function()? resolveStartParagraphIndex;
|
final int Function()? resolveStartParagraphIndex;
|
||||||
final VoidCallback? onStarted;
|
final VoidCallback? onStarted;
|
||||||
@@ -30,7 +36,7 @@ class TtsPlayerWidget extends ConsumerWidget {
|
|||||||
final tts = ref.watch(ttsProvider);
|
final tts = ref.watch(ttsProvider);
|
||||||
final notifier = ref.read(ttsProvider.notifier);
|
final notifier = ref.read(ttsProvider.notifier);
|
||||||
|
|
||||||
const speeds = [0.35, 0.45, 0.55, 0.65, 0.8, 1.0];
|
const speeds = [0.45, 0.675, 0.9, 1.125, 1.35, 1.8];
|
||||||
|
|
||||||
Future<void> start() async {
|
Future<void> start() async {
|
||||||
if (tts.status == TtsStatus.paused) {
|
if (tts.status == TtsStatus.paused) {
|
||||||
@@ -39,6 +45,8 @@ class TtsPlayerWidget extends ConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notifier.clearPendingAutoStartChapter();
|
||||||
|
|
||||||
unawaited(
|
unawaited(
|
||||||
notifier.startReading(
|
notifier.startReading(
|
||||||
content,
|
content,
|
||||||
@@ -46,6 +54,9 @@ class TtsPlayerWidget extends ConsumerWidget {
|
|||||||
startParagraphIndex: resolveStartParagraphIndex?.call(),
|
startParagraphIndex: resolveStartParagraphIndex?.call(),
|
||||||
contentKey: contentKey,
|
contentKey: contentKey,
|
||||||
title: title,
|
title: title,
|
||||||
|
nextChapterId: nextChapterId,
|
||||||
|
chapterNumber: chapterNumber,
|
||||||
|
apiBaseUrl: apiBaseUrl,
|
||||||
includeTitle: includeTitleOnStart,
|
includeTitle: includeTitleOnStart,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../../../core/models/reading_settings.dart';
|
|||||||
import '../../../core/network/providers.dart';
|
import '../../../core/network/providers.dart';
|
||||||
import '../../../core/storage/local_store.dart';
|
import '../../../core/storage/local_store.dart';
|
||||||
import '../../../core/storage/offline_cache.dart';
|
import '../../../core/storage/offline_cache.dart';
|
||||||
|
import '../../bookshelf/providers/bookshelf_provider.dart';
|
||||||
|
|
||||||
// ─── Chapter content ─────────────────────────────────────────────────────────
|
// ─── Chapter content ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -61,6 +62,21 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
|
|||||||
scrollOffset: 0,
|
scrollOffset: 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void resetCurrentChapterProgress() {
|
||||||
|
if (state == null) return;
|
||||||
|
|
||||||
|
state = ReadingProgress(
|
||||||
|
novelId: state!.novelId,
|
||||||
|
chapterId: state!.chapterId,
|
||||||
|
chapterNumber: state!.chapterNumber,
|
||||||
|
scrollOffset: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persist immediately so a freshly opened chapter always resumes at top.
|
||||||
|
unawaited(_persistProgress(state!.chapterId, state!.chapterNumber, 0));
|
||||||
|
}
|
||||||
|
|
||||||
void updateScroll(double offset) {
|
void updateScroll(double offset) {
|
||||||
if (state == null) return;
|
if (state == null) return;
|
||||||
state = ReadingProgress(
|
state = ReadingProgress(
|
||||||
@@ -79,23 +95,45 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
|
|||||||
// Also notify server (fire and forget)
|
// Also notify server (fire and forget)
|
||||||
try {
|
try {
|
||||||
final client = _ref.read(apiClientProvider);
|
final client = _ref.read(apiClientProvider);
|
||||||
await client.dio.post('/api/user/reading-progress', data: {
|
final res = await client.dio.post('/api/user/reading-progress', data: {
|
||||||
'novelId': _novelId,
|
'novelId': _novelId,
|
||||||
'chapterId': chapterId,
|
'chapterId': chapterId,
|
||||||
'chapterNumber': chapterNumber,
|
'chapterNumber': chapterNumber,
|
||||||
'progress': offset,
|
'progress': offset,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final data = res.data;
|
||||||
|
Map<String, dynamic>? bookmarkJson;
|
||||||
|
if (data is Map<String, dynamic>) {
|
||||||
|
final bookmark = data['bookmark'];
|
||||||
|
if (bookmark is Map<String, dynamic>) {
|
||||||
|
bookmarkJson = bookmark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ref.read(bookshelfProvider.notifier).syncProgress(
|
||||||
|
novelId: _novelId!,
|
||||||
|
chapterId: chapterId,
|
||||||
|
chapterNumber: chapterNumber,
|
||||||
|
serverBookmark: bookmarkJson,
|
||||||
|
);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
DateTime? _lastUpdate;
|
Timer? _debounceTimer;
|
||||||
Future<void> _debounceUpdate(double offset) async {
|
void _debounceUpdate(double offset) {
|
||||||
final now = DateTime.now();
|
_debounceTimer?.cancel();
|
||||||
if (_lastUpdate != null && now.difference(_lastUpdate!).inSeconds < 3) return;
|
_debounceTimer = Timer(const Duration(seconds: 3), () {
|
||||||
_lastUpdate = now;
|
|
||||||
if (state != null) {
|
if (state != null) {
|
||||||
await _persistProgress(state!.chapterId, state!.chapterNumber, offset);
|
unawaited(_persistProgress(state!.chapterId, state!.chapterNumber, offset));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +162,10 @@ class ReadingSettingsNotifier extends StateNotifier<ReadingSettings> {
|
|||||||
final localStore = _ref.read(localStoreProvider);
|
final localStore = _ref.read(localStoreProvider);
|
||||||
await localStore.saveReadingSettings(settings);
|
await localStore.saveReadingSettings(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setSentenceTapTtsEnabled(bool enabled) async {
|
||||||
|
await update(state.copyWith(enableSentenceTapTts: enabled));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final readingSettingsProvider =
|
final readingSettingsProvider =
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_tts/flutter_tts.dart';
|
||||||
|
|
||||||
|
enum TtsStatus { idle, playing, paused, stopped }
|
||||||
|
|
||||||
|
class TtsState {
|
||||||
|
final TtsStatus status;
|
||||||
|
final int currentSentenceIndex;
|
||||||
|
final List<String> sentences;
|
||||||
|
final double speechRate;
|
||||||
|
final double volume;
|
||||||
|
final double pitch;
|
||||||
|
final String? currentLanguage;
|
||||||
|
|
||||||
|
const TtsState({
|
||||||
|
this.status = TtsStatus.idle,
|
||||||
|
this.currentSentenceIndex = 0,
|
||||||
|
this.sentences = const [],
|
||||||
|
this.speechRate = 0.5,
|
||||||
|
this.volume = 1.0,
|
||||||
|
this.pitch = 1.0,
|
||||||
|
this.currentLanguage,
|
||||||
|
});
|
||||||
|
|
||||||
|
TtsState copyWith({
|
||||||
|
TtsStatus? status,
|
||||||
|
int? currentSentenceIndex,
|
||||||
|
List<String>? sentences,
|
||||||
|
double? speechRate,
|
||||||
|
double? volume,
|
||||||
|
double? pitch,
|
||||||
|
String? currentLanguage,
|
||||||
|
}) {
|
||||||
|
return TtsState(
|
||||||
|
status: status ?? this.status,
|
||||||
|
currentSentenceIndex: currentSentenceIndex ?? this.currentSentenceIndex,
|
||||||
|
sentences: sentences ?? this.sentences,
|
||||||
|
speechRate: speechRate ?? this.speechRate,
|
||||||
|
volume: volume ?? this.volume,
|
||||||
|
pitch: pitch ?? this.pitch,
|
||||||
|
currentLanguage: currentLanguage ?? this.currentLanguage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TtsNotifier extends Notifier<TtsState> {
|
||||||
|
late FlutterTts _tts;
|
||||||
|
|
||||||
|
@override
|
||||||
|
TtsState build() {
|
||||||
|
_tts = FlutterTts();
|
||||||
|
_initTts();
|
||||||
|
ref.onDispose(() async {
|
||||||
|
await _tts.stop();
|
||||||
|
});
|
||||||
|
return const TtsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initTts() async {
|
||||||
|
await _tts.setLanguage('vi-VN');
|
||||||
|
await _tts.setSpeechRate(state.speechRate);
|
||||||
|
await _tts.setVolume(state.volume);
|
||||||
|
await _tts.setPitch(state.pitch);
|
||||||
|
// Do NOT use awaitSpeakCompletion(true) — it blocks the Dart↔native channel
|
||||||
|
// between sentences, causing Android TTS service to disconnect.
|
||||||
|
await _tts.awaitSpeakCompletion(false);
|
||||||
|
|
||||||
|
_tts.setCompletionHandler(_onSentenceComplete);
|
||||||
|
|
||||||
|
_tts.setCancelHandler(() {
|
||||||
|
if (state.status == TtsStatus.playing) {
|
||||||
|
state = state.copyWith(status: TtsStatus.stopped);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_tts.setErrorHandler((msg) {
|
||||||
|
state = state.copyWith(status: TtsStatus.stopped);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSentenceComplete() {
|
||||||
|
if (state.status != TtsStatus.playing) return;
|
||||||
|
final nextIndex = state.currentSentenceIndex + 1;
|
||||||
|
if (nextIndex < state.sentences.length) {
|
||||||
|
state = state.copyWith(currentSentenceIndex: nextIndex);
|
||||||
|
_speakCurrent();
|
||||||
|
} else {
|
||||||
|
state = state.copyWith(
|
||||||
|
status: TtsStatus.stopped, currentSentenceIndex: 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _speakCurrent() async {
|
||||||
|
if (state.sentences.isEmpty) return;
|
||||||
|
if (state.status != TtsStatus.playing) return;
|
||||||
|
final sentence = state.sentences[state.currentSentenceIndex];
|
||||||
|
await _tts.speak(sentence);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> play(List<String> sentences) async {
|
||||||
|
await _tts.stop();
|
||||||
|
state = state.copyWith(
|
||||||
|
sentences: sentences,
|
||||||
|
currentSentenceIndex: 0,
|
||||||
|
status: TtsStatus.playing,
|
||||||
|
);
|
||||||
|
await _speakCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pause() async {
|
||||||
|
state = state.copyWith(status: TtsStatus.paused);
|
||||||
|
await _tts.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> resume() async {
|
||||||
|
state = state.copyWith(status: TtsStatus.playing);
|
||||||
|
await _speakCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stop() async {
|
||||||
|
state = state.copyWith(status: TtsStatus.stopped, currentSentenceIndex: 0);
|
||||||
|
await _tts.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setSpeechRate(double rate) async {
|
||||||
|
await _tts.setSpeechRate(rate);
|
||||||
|
state = state.copyWith(speechRate: rate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final ttsProvider = NotifierProvider<TtsNotifier, TtsState>(TtsNotifier.new);
|
||||||
@@ -5,9 +5,11 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_tts/flutter_tts.dart';
|
import 'package:flutter_tts/flutter_tts.dart';
|
||||||
|
|
||||||
|
import '../../../core/config/app_config.dart';
|
||||||
|
|
||||||
enum TtsStatus { idle, playing, paused }
|
enum TtsStatus { idle, playing, paused }
|
||||||
|
|
||||||
const double kTtsBaseSpeechRate = 0.45;
|
const double kTtsBaseSpeechRate = 0.9;
|
||||||
|
|
||||||
double ttsDisplayMultiplier(double speechRate) => speechRate / kTtsBaseSpeechRate;
|
double ttsDisplayMultiplier(double speechRate) => speechRate / kTtsBaseSpeechRate;
|
||||||
|
|
||||||
@@ -32,6 +34,13 @@ class _TtsSegment {
|
|||||||
final int paragraphIndex;
|
final int paragraphIndex;
|
||||||
final int start;
|
final int start;
|
||||||
final int end;
|
final int end;
|
||||||
|
|
||||||
|
Map<String, Object?> toMap() => {
|
||||||
|
'text': text,
|
||||||
|
'paragraphIndex': paragraphIndex,
|
||||||
|
'start': start,
|
||||||
|
'end': end,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class TtsVoice {
|
class TtsVoice {
|
||||||
@@ -65,7 +74,7 @@ class TtsState {
|
|||||||
this.paragraphIndex = 0,
|
this.paragraphIndex = 0,
|
||||||
this.totalParagraphs = 0,
|
this.totalParagraphs = 0,
|
||||||
this.activeParagraphIndex = -1,
|
this.activeParagraphIndex = -1,
|
||||||
this.speed = 0.45,
|
this.speed = 0.9,
|
||||||
this.language = 'vi-VN',
|
this.language = 'vi-VN',
|
||||||
this.voiceName,
|
this.voiceName,
|
||||||
this.availableVietnameseVoices = const [],
|
this.availableVietnameseVoices = const [],
|
||||||
@@ -124,17 +133,41 @@ class TtsState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class TtsNotifier extends StateNotifier<TtsState> {
|
class TtsNotifier extends StateNotifier<TtsState> {
|
||||||
final FlutterTts _tts = FlutterTts();
|
|
||||||
static const MethodChannel _backgroundChannel = MethodChannel('reader_app/tts_background');
|
|
||||||
List<_TtsSegment> _segments = [];
|
|
||||||
bool _initialized = false;
|
|
||||||
Future<void>? _initFuture;
|
|
||||||
|
|
||||||
TtsNotifier() : super(const TtsState()) {
|
TtsNotifier() : super(const TtsState()) {
|
||||||
_initFuture = _init();
|
_initFuture = _init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const MethodChannel _backgroundChannel = MethodChannel(
|
||||||
|
'reader_app/tts_background',
|
||||||
|
);
|
||||||
|
static const MethodChannel _mediaChannel = MethodChannel(
|
||||||
|
'reader_app/tts_media',
|
||||||
|
);
|
||||||
|
static const EventChannel _mediaEventsChannel = EventChannel(
|
||||||
|
'reader_app/tts_media_events',
|
||||||
|
);
|
||||||
|
|
||||||
|
final FlutterTts _tts = FlutterTts();
|
||||||
|
List<_TtsSegment> _segments = [];
|
||||||
|
bool _initialized = false;
|
||||||
|
Future<void>? _initFuture;
|
||||||
|
StreamSubscription<dynamic>? _mediaEventsSub;
|
||||||
|
int _playbackGeneration = 0;
|
||||||
|
bool _isInterruptingPlayback = false;
|
||||||
|
int _pendingFallbackIndex = -1;
|
||||||
|
bool _didStartCurrentFallbackUtterance = false;
|
||||||
|
bool _hasPromptedNotificationSettings = false;
|
||||||
|
bool _androidFallbackReady = false;
|
||||||
|
|
||||||
|
bool get _useNativeAndroidMediaService => Platform.isAndroid;
|
||||||
|
|
||||||
Future<void> _init() async {
|
Future<void> _init() async {
|
||||||
|
if (_useNativeAndroidMediaService) {
|
||||||
|
await _initAndroidBridge();
|
||||||
|
_initialized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await _tts.awaitSpeakCompletion(true);
|
await _tts.awaitSpeakCompletion(true);
|
||||||
await _tts.setSharedInstance(true);
|
await _tts.setSharedInstance(true);
|
||||||
|
|
||||||
@@ -150,39 +183,101 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Platform.isAndroid) {
|
await _configureVietnameseVoiceWithFlutterTts();
|
||||||
await _tts.setAudioAttributesForNavigation();
|
|
||||||
}
|
|
||||||
|
|
||||||
await _configureVietnameseVoice();
|
|
||||||
await _tts.setSpeechRate(kTtsBaseSpeechRate);
|
await _tts.setSpeechRate(kTtsBaseSpeechRate);
|
||||||
await _tts.setVolume(1.0);
|
await _tts.setVolume(1.0);
|
||||||
await _tts.setPitch(1.0);
|
await _tts.setPitch(1.0);
|
||||||
|
|
||||||
_tts.setStartHandler(() {
|
_tts.setStartHandler(() {
|
||||||
|
_didStartCurrentFallbackUtterance = true;
|
||||||
|
final index = _pendingFallbackIndex;
|
||||||
|
if (index >= 0 && index < _segments.length) {
|
||||||
|
final segment = _segments[index];
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
status: TtsStatus.playing,
|
status: TtsStatus.playing,
|
||||||
|
paragraphIndex: index,
|
||||||
|
activeParagraphIndex: segment.paragraphIndex,
|
||||||
|
progressStart: segment.start,
|
||||||
|
progressEnd: segment.end,
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
state = state.copyWith(status: TtsStatus.playing);
|
||||||
|
}
|
||||||
unawaited(_syncBackgroundMode());
|
unawaited(_syncBackgroundMode());
|
||||||
});
|
});
|
||||||
|
|
||||||
_tts.setCompletionHandler(() {
|
_tts.setCompletionHandler(() {
|
||||||
if (state.status == TtsStatus.playing) {
|
// Fallback playback progression is driven by _playFallbackFromGeneration.
|
||||||
_next();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
_tts.setErrorHandler((msg) {
|
_tts.setErrorHandler((msg) {
|
||||||
state = state.copyWith(status: TtsStatus.idle);
|
if (_isInterruptingPlayback) return;
|
||||||
|
_pendingFallbackIndex = -1;
|
||||||
|
_didStartCurrentFallbackUtterance = false;
|
||||||
|
state = state.copyWith(
|
||||||
|
status: TtsStatus.idle,
|
||||||
|
activeParagraphIndex: -1,
|
||||||
|
progressStart: -1,
|
||||||
|
progressEnd: -1,
|
||||||
|
);
|
||||||
unawaited(_syncBackgroundMode());
|
unawaited(_syncBackgroundMode());
|
||||||
});
|
});
|
||||||
|
|
||||||
await _syncBackgroundMode();
|
await _syncBackgroundMode();
|
||||||
|
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _configureVietnameseVoice() async {
|
Future<void> _initAndroidBridge() async {
|
||||||
|
_mediaEventsSub ??= _mediaEventsChannel.receiveBroadcastStream().listen(
|
||||||
|
_handleAndroidMediaEvent,
|
||||||
|
onError: (_) {
|
||||||
|
state = state.copyWith(
|
||||||
|
status: TtsStatus.idle,
|
||||||
|
activeParagraphIndex: -1,
|
||||||
|
progressStart: -1,
|
||||||
|
progressEnd: -1,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await _mediaChannel.invokeMethod<void>('initialize', {
|
||||||
|
'backgroundModeEnabled': state.backgroundModeEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
final snapshot = await _mediaChannel.invokeMethod<dynamic>('getSnapshot');
|
||||||
|
_applyAndroidSnapshot(snapshot);
|
||||||
|
|
||||||
|
await _ensureAndroidMediaNotificationsEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _ensureAndroidMediaNotificationsEnabled() async {
|
||||||
|
if (!_useNativeAndroidMediaService) return;
|
||||||
|
if (_hasPromptedNotificationSettings) return;
|
||||||
|
|
||||||
|
final enabled = await _mediaChannel.invokeMethod<bool>('areNotificationsEnabled') ?? true;
|
||||||
|
if (enabled) return;
|
||||||
|
|
||||||
|
_hasPromptedNotificationSettings = true;
|
||||||
|
await _mediaChannel.invokeMethod<void>('openNotificationSettings');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refreshNativeSnapshot() async {
|
||||||
|
if (!_useNativeAndroidMediaService) return;
|
||||||
|
|
||||||
|
if (!_initialized) {
|
||||||
|
await (_initFuture ?? _init());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final snapshot = await _mediaChannel.invokeMethod<dynamic>('getSnapshot');
|
||||||
|
_applyAndroidSnapshot(snapshot);
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore snapshot pull errors; event stream updates will continue.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _configureVietnameseVoiceWithFlutterTts() async {
|
||||||
final dynamic voicesRaw = await _tts.getVoices;
|
final dynamic voicesRaw = await _tts.getVoices;
|
||||||
|
|
||||||
String? selectedName;
|
String? selectedName;
|
||||||
@@ -191,22 +286,34 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
|
|
||||||
if (voicesRaw is List) {
|
if (voicesRaw is List) {
|
||||||
final vietnamese = voicesRaw.whereType<Map>().where((voice) {
|
final vietnamese = voicesRaw.whereType<Map>().where((voice) {
|
||||||
final locale = (voice['locale'] ?? voice['language'] ?? '').toString().toLowerCase();
|
final locale = (voice['locale'] ?? voice['language'] ?? '')
|
||||||
|
.toString()
|
||||||
|
.toLowerCase();
|
||||||
return locale.startsWith('vi');
|
return locale.startsWith('vi');
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
for (final voice in vietnamese) {
|
for (final voice in vietnamese) {
|
||||||
final name = voice['name']?.toString();
|
final name = voice['name']?.toString();
|
||||||
final locale = (voice['locale'] ?? voice['language'])?.toString();
|
final locale = (voice['locale'] ?? voice['language'])?.toString();
|
||||||
if (name == null || name.isEmpty || locale == null || locale.isEmpty) continue;
|
if (name == null || name.isEmpty || locale == null || locale.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
vietnameseVoices.add(TtsVoice(name: name, locale: locale));
|
vietnameseVoices.add(TtsVoice(name: name, locale: locale));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vietnamese.isNotEmpty) {
|
if (vietnamese.isNotEmpty) {
|
||||||
final preferred = vietnamese.firstWhere(
|
final preferred = vietnamese.firstWhere(
|
||||||
(voice) =>
|
(voice) =>
|
||||||
(voice['name']?.toString().toLowerCase().contains('female') ?? false) ||
|
(voice['name']
|
||||||
(voice['name']?.toString().toLowerCase().contains('natural') ?? false),
|
?.toString()
|
||||||
|
.toLowerCase()
|
||||||
|
.contains('female') ??
|
||||||
|
false) ||
|
||||||
|
(voice['name']
|
||||||
|
?.toString()
|
||||||
|
.toLowerCase()
|
||||||
|
.contains('natural') ??
|
||||||
|
false),
|
||||||
orElse: () => vietnamese.first,
|
orElse: () => vietnamese.first,
|
||||||
);
|
);
|
||||||
selectedName = preferred['name']?.toString();
|
selectedName = preferred['name']?.toString();
|
||||||
@@ -219,6 +326,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
if (selectedName != null) {
|
if (selectedName != null) {
|
||||||
await _tts.setVoice({'name': selectedName, 'locale': selectedLanguage});
|
await _tts.setVoice({'name': selectedName, 'locale': selectedLanguage});
|
||||||
}
|
}
|
||||||
|
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
language: selectedLanguage,
|
language: selectedLanguage,
|
||||||
voiceName: selectedName,
|
voiceName: selectedName,
|
||||||
@@ -226,7 +334,244 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _ensureAndroidFallbackReady() async {
|
||||||
|
if (_androidFallbackReady) return;
|
||||||
|
|
||||||
|
await _tts.awaitSpeakCompletion(true);
|
||||||
|
await _tts.setSharedInstance(true);
|
||||||
|
await _configureVietnameseVoiceWithFlutterTts();
|
||||||
|
await _tts.setSpeechRate(state.speed);
|
||||||
|
await _tts.setVolume(1.0);
|
||||||
|
await _tts.setPitch(1.0);
|
||||||
|
|
||||||
|
_tts.setStartHandler(() {
|
||||||
|
_didStartCurrentFallbackUtterance = true;
|
||||||
|
final index = _pendingFallbackIndex;
|
||||||
|
if (index >= 0 && index < _segments.length) {
|
||||||
|
final segment = _segments[index];
|
||||||
|
state = state.copyWith(
|
||||||
|
status: TtsStatus.playing,
|
||||||
|
paragraphIndex: index,
|
||||||
|
activeParagraphIndex: segment.paragraphIndex,
|
||||||
|
progressStart: segment.start,
|
||||||
|
progressEnd: segment.end,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
state = state.copyWith(status: TtsStatus.playing);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_tts.setCompletionHandler(() {
|
||||||
|
// Fallback playback progression is driven by _playFallbackFromGeneration.
|
||||||
|
});
|
||||||
|
|
||||||
|
_tts.setErrorHandler((_) {
|
||||||
|
if (_isInterruptingPlayback) return;
|
||||||
|
_pendingFallbackIndex = -1;
|
||||||
|
_didStartCurrentFallbackUtterance = false;
|
||||||
|
state = state.copyWith(
|
||||||
|
status: TtsStatus.idle,
|
||||||
|
activeParagraphIndex: -1,
|
||||||
|
progressStart: -1,
|
||||||
|
progressEnd: -1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
_androidFallbackReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startFallbackReading({
|
||||||
|
required int validIndex,
|
||||||
|
required _TtsSegment selectedSegment,
|
||||||
|
required String? contentKey,
|
||||||
|
}) async {
|
||||||
|
await _ensureAndroidFallbackReady();
|
||||||
|
final sessionId = await _interruptFallbackPlayback();
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
status: TtsStatus.playing,
|
||||||
|
paragraphIndex: validIndex,
|
||||||
|
totalParagraphs: _segments.length,
|
||||||
|
activeParagraphIndex: selectedSegment.paragraphIndex,
|
||||||
|
progressStart: selectedSegment.start,
|
||||||
|
progressEnd: selectedSegment.end,
|
||||||
|
contentKey: contentKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleAndroidMediaEvent(dynamic event) {
|
||||||
|
_applyAndroidSnapshot(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyAndroidSnapshot(dynamic snapshot) {
|
||||||
|
if (snapshot is! Map) return;
|
||||||
|
|
||||||
|
final data = Map<String, dynamic>.from(
|
||||||
|
snapshot.map((key, value) => MapEntry(key.toString(), value)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final statusRaw = data['status']?.toString() ?? 'idle';
|
||||||
|
final status = switch (statusRaw) {
|
||||||
|
'playing' => TtsStatus.playing,
|
||||||
|
'paused' => TtsStatus.paused,
|
||||||
|
_ => TtsStatus.idle,
|
||||||
|
};
|
||||||
|
|
||||||
|
final voicesRaw = data['availableVietnameseVoices'];
|
||||||
|
final voices = <TtsVoice>[];
|
||||||
|
if (voicesRaw is List) {
|
||||||
|
for (final item in voicesRaw) {
|
||||||
|
if (item is! Map) continue;
|
||||||
|
final map = Map<String, dynamic>.from(
|
||||||
|
item.map((key, value) => MapEntry(key.toString(), value)),
|
||||||
|
);
|
||||||
|
final name = map['name']?.toString();
|
||||||
|
final locale = map['locale']?.toString();
|
||||||
|
if (name == null || name.isEmpty || locale == null || locale.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
voices.add(TtsVoice(name: name, locale: locale));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
status: status,
|
||||||
|
paragraphIndex: (data['paragraphIndex'] as num?)?.toInt() ?? 0,
|
||||||
|
totalParagraphs: (data['totalParagraphs'] as num?)?.toInt() ?? 0,
|
||||||
|
activeParagraphIndex: (data['activeParagraphIndex'] as num?)?.toInt() ?? -1,
|
||||||
|
progressStart: (data['progressStart'] as num?)?.toInt() ?? -1,
|
||||||
|
progressEnd: (data['progressEnd'] as num?)?.toInt() ?? -1,
|
||||||
|
contentKey: data['contentKey']?.toString(),
|
||||||
|
completedCount: (data['completedCount'] as num?)?.toInt() ?? state.completedCount,
|
||||||
|
language: data['language']?.toString() ?? state.language,
|
||||||
|
voiceName: data['voiceName']?.toString(),
|
||||||
|
availableVietnameseVoices: voices,
|
||||||
|
backgroundModeEnabled:
|
||||||
|
data['backgroundModeEnabled'] as bool? ?? state.backgroundModeEnabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _sanitizeForTts(String raw) {
|
||||||
|
if (raw.isEmpty) return raw;
|
||||||
|
|
||||||
|
// Keep natural sentence flow while removing symbols that are usually read out noisily.
|
||||||
|
final cleaned = raw
|
||||||
|
.replaceAll(RegExp(r'["“”]'), ' ')
|
||||||
|
.replaceAll(RegExp(r'[_$#^*+=~`|<>\\\[\]{}]'), ' ')
|
||||||
|
.replaceAll(RegExp(r'\s+'), ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<_TtsSegment> _buildSegments(
|
||||||
|
String content, {
|
||||||
|
String? title,
|
||||||
|
bool includeTitle = true,
|
||||||
|
}) {
|
||||||
|
final segments = <_TtsSegment>[];
|
||||||
|
|
||||||
|
final titleText = title?.trim();
|
||||||
|
if (includeTitle && titleText != null && titleText.isNotEmpty) {
|
||||||
|
final sanitizedTitle = _sanitizeForTts(titleText);
|
||||||
|
if (sanitizedTitle.isNotEmpty) {
|
||||||
|
segments.add(
|
||||||
|
_TtsSegment(
|
||||||
|
text: sanitizedTitle,
|
||||||
|
paragraphIndex: -1,
|
||||||
|
start: -1,
|
||||||
|
end: -1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final paragraphs = content
|
||||||
|
.split(RegExp(r'\n+'))
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.where((p) => p.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
for (var pIndex = 0; pIndex < paragraphs.length; pIndex++) {
|
||||||
|
final paragraph = paragraphs[pIndex];
|
||||||
|
final sentenceMatches = RegExp(r'[^.!?…]+[.!?…]*').allMatches(paragraph);
|
||||||
|
var cursor = 0;
|
||||||
|
|
||||||
|
for (final match in sentenceMatches) {
|
||||||
|
final sentence = match.group(0)?.trim() ?? '';
|
||||||
|
if (sentence.isEmpty) continue;
|
||||||
|
final sanitizedSentence = _sanitizeForTts(sentence);
|
||||||
|
if (sanitizedSentence.isEmpty) continue;
|
||||||
|
|
||||||
|
var start = paragraph.indexOf(sentence, cursor);
|
||||||
|
if (start < 0) {
|
||||||
|
start = cursor.clamp(0, paragraph.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
final end = (start + sentence.length).clamp(0, paragraph.length);
|
||||||
|
cursor = end;
|
||||||
|
|
||||||
|
segments.add(
|
||||||
|
_TtsSegment(
|
||||||
|
text: sanitizedSentence,
|
||||||
|
paragraphIndex: pIndex,
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _resolveStartIndex(
|
||||||
|
int paragraphIndex, {
|
||||||
|
int? startParagraphIndex,
|
||||||
|
int? startCharOffset,
|
||||||
|
}) {
|
||||||
|
var validIndex = paragraphIndex.clamp(0, _segments.length - 1);
|
||||||
|
|
||||||
|
if (startParagraphIndex != null) {
|
||||||
|
final matchIndex = _segments.indexWhere(
|
||||||
|
(segment) =>
|
||||||
|
segment.paragraphIndex == startParagraphIndex &&
|
||||||
|
(startCharOffset == null || segment.start >= startCharOffset),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchIndex >= 0) {
|
||||||
|
validIndex = matchIndex;
|
||||||
|
} else {
|
||||||
|
final fallbackIndex = _segments.indexWhere(
|
||||||
|
(segment) => segment.paragraphIndex >= startParagraphIndex,
|
||||||
|
);
|
||||||
|
if (fallbackIndex >= 0) {
|
||||||
|
validIndex = fallbackIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return validIndex;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setVoiceByName(String voiceName) async {
|
Future<void> setVoiceByName(String voiceName) async {
|
||||||
|
if (_useNativeAndroidMediaService) {
|
||||||
|
final selected = state.availableVietnameseVoices.where(
|
||||||
|
(voice) => voice.name == voiceName,
|
||||||
|
);
|
||||||
|
if (selected.isEmpty) return;
|
||||||
|
|
||||||
|
final voice = selected.first;
|
||||||
|
await _mediaChannel.invokeMethod<void>('setVoiceByName', {
|
||||||
|
'voiceName': voice.name,
|
||||||
|
'language': voice.locale,
|
||||||
|
});
|
||||||
|
state = state.copyWith(language: voice.locale, voiceName: voice.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final selected = state.availableVietnameseVoices.where((v) => v.name == voiceName);
|
final selected = state.availableVietnameseVoices.where((v) => v.name == voiceName);
|
||||||
if (selected.isEmpty) return;
|
if (selected.isEmpty) return;
|
||||||
|
|
||||||
@@ -240,6 +585,16 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
|
|
||||||
Future<void> setBackgroundModeEnabled(bool enabled) async {
|
Future<void> setBackgroundModeEnabled(bool enabled) async {
|
||||||
state = state.copyWith(backgroundModeEnabled: enabled);
|
state = state.copyWith(backgroundModeEnabled: enabled);
|
||||||
|
if (_useNativeAndroidMediaService) {
|
||||||
|
await _mediaChannel.invokeMethod<void>('setBackgroundModeEnabled', {
|
||||||
|
'enabled': enabled,
|
||||||
|
});
|
||||||
|
if (enabled) {
|
||||||
|
await ensureBatteryOptimizationIgnored();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await _syncBackgroundMode();
|
await _syncBackgroundMode();
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
await ensureBatteryOptimizationIgnored();
|
await ensureBatteryOptimizationIgnored();
|
||||||
@@ -278,82 +633,71 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _syncBackgroundMode() async {
|
Future<void> _syncBackgroundMode() async {
|
||||||
if (!Platform.isAndroid) return;
|
if (_useNativeAndroidMediaService || !Platform.isAndroid) return;
|
||||||
|
|
||||||
final shouldKeepAlive =
|
final shouldKeepAlive =
|
||||||
state.backgroundModeEnabled && state.status == TtsStatus.playing;
|
state.backgroundModeEnabled && state.status == TtsStatus.playing;
|
||||||
try {
|
try {
|
||||||
await _backgroundChannel
|
await _backgroundChannel.invokeMethod<void>('setWakeLock', {
|
||||||
.invokeMethod<void>('setWakeLock', {'enabled': shouldKeepAlive});
|
'enabled': shouldKeepAlive,
|
||||||
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Keep playback functional even if native wake lock bridge is unavailable.
|
// Keep playback functional even if native wake lock bridge is unavailable.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start reading from [content] starting at optional [paragraphIndex].
|
|
||||||
Future<void> startReading(
|
Future<void> startReading(
|
||||||
String content, {
|
String content, {
|
||||||
int paragraphIndex = 0,
|
int paragraphIndex = 0,
|
||||||
int? startParagraphIndex,
|
int? startParagraphIndex,
|
||||||
|
int? startCharOffset,
|
||||||
String? contentKey,
|
String? contentKey,
|
||||||
String? title,
|
String? title,
|
||||||
|
String? nextChapterId,
|
||||||
|
int? chapterNumber,
|
||||||
|
String? apiBaseUrl,
|
||||||
bool includeTitle = true,
|
bool includeTitle = true,
|
||||||
}) async {
|
}) async {
|
||||||
if (!_initialized) {
|
if (!_initialized) {
|
||||||
await (_initFuture ?? _init());
|
await (_initFuture ?? _init());
|
||||||
}
|
}
|
||||||
|
|
||||||
final segments = <_TtsSegment>[];
|
// A direct start request (tap sentence/play button) should win over any
|
||||||
|
// queued chapter auto-start from previous navigation/completion events.
|
||||||
|
state = state.copyWith(clearPendingAutoStartChapterId: true);
|
||||||
|
|
||||||
final titleText = title?.trim();
|
_segments = _buildSegments(
|
||||||
if (includeTitle && titleText != null && titleText.isNotEmpty) {
|
content,
|
||||||
segments.add(_TtsSegment(text: titleText, paragraphIndex: -1, start: -1, end: -1));
|
title: title,
|
||||||
}
|
includeTitle: includeTitle,
|
||||||
|
|
||||||
final paragraphs = content
|
|
||||||
.split(RegExp(r'\n+'))
|
|
||||||
.map((p) => p.trim())
|
|
||||||
.where((p) => p.isNotEmpty)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
for (var pIndex = 0; pIndex < paragraphs.length; pIndex++) {
|
|
||||||
final paragraph = paragraphs[pIndex];
|
|
||||||
final sentenceMatches = RegExp(r'[^.!?…]+[.!?…]*').allMatches(paragraph);
|
|
||||||
var cursor = 0;
|
|
||||||
|
|
||||||
for (final match in sentenceMatches) {
|
|
||||||
final sentence = match.group(0)?.trim() ?? '';
|
|
||||||
if (sentence.isEmpty) continue;
|
|
||||||
var start = paragraph.indexOf(sentence, cursor);
|
|
||||||
if (start < 0) start = cursor.clamp(0, paragraph.length);
|
|
||||||
final end = (start + sentence.length).clamp(0, paragraph.length);
|
|
||||||
cursor = end;
|
|
||||||
|
|
||||||
segments.add(
|
|
||||||
_TtsSegment(
|
|
||||||
text: sentence,
|
|
||||||
paragraphIndex: pIndex,
|
|
||||||
start: start,
|
|
||||||
end: end,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_segments = segments;
|
if (_segments.isEmpty) {
|
||||||
if (_segments.isEmpty) return;
|
state = state.copyWith(
|
||||||
|
status: TtsStatus.idle,
|
||||||
var validIndex = paragraphIndex.clamp(0, _segments.length - 1);
|
paragraphIndex: 0,
|
||||||
if (startParagraphIndex != null) {
|
totalParagraphs: 0,
|
||||||
final startFromVisible = _segments.indexWhere(
|
activeParagraphIndex: -1,
|
||||||
(segment) => segment.paragraphIndex >= startParagraphIndex,
|
progressStart: -1,
|
||||||
|
progressEnd: -1,
|
||||||
|
contentKey: contentKey,
|
||||||
);
|
);
|
||||||
if (startFromVisible >= 0) {
|
if (!_useNativeAndroidMediaService) {
|
||||||
validIndex = startFromVisible;
|
await _syncBackgroundMode();
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final validIndex = _resolveStartIndex(
|
||||||
|
paragraphIndex,
|
||||||
|
startParagraphIndex: startParagraphIndex,
|
||||||
|
startCharOffset: startCharOffset,
|
||||||
|
);
|
||||||
final selectedSegment = _segments[validIndex];
|
final selectedSegment = _segments[validIndex];
|
||||||
|
|
||||||
|
if (_useNativeAndroidMediaService) {
|
||||||
|
await _ensureAndroidMediaNotificationsEnabled();
|
||||||
|
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
status: TtsStatus.playing,
|
status: TtsStatus.playing,
|
||||||
paragraphIndex: validIndex,
|
paragraphIndex: validIndex,
|
||||||
@@ -363,12 +707,94 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
progressEnd: selectedSegment.end,
|
progressEnd: selectedSegment.end,
|
||||||
contentKey: contentKey,
|
contentKey: contentKey,
|
||||||
);
|
);
|
||||||
await _syncBackgroundMode();
|
|
||||||
await _speak(validIndex);
|
try {
|
||||||
|
await _mediaChannel.invokeMethod<void>('startReading', {
|
||||||
|
'content': content,
|
||||||
|
'contentKey': contentKey,
|
||||||
|
'title': title,
|
||||||
|
'nextChapterId': nextChapterId,
|
||||||
|
'chapterNumber': chapterNumber,
|
||||||
|
'apiBaseUrl': apiBaseUrl ?? AppConfig.baseUrl,
|
||||||
|
'startIndex': validIndex,
|
||||||
|
'speed': state.speed,
|
||||||
|
'language': state.language,
|
||||||
|
'voiceName': state.voiceName,
|
||||||
|
'backgroundModeEnabled': state.backgroundModeEnabled,
|
||||||
|
'includeTitle': includeTitle,
|
||||||
|
});
|
||||||
|
} on PlatformException {
|
||||||
|
await _startFallbackReading(
|
||||||
|
validIndex: validIndex,
|
||||||
|
selectedSegment: selectedSegment,
|
||||||
|
contentKey: contentKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _speak(int index) async {
|
await _startFallbackReading(
|
||||||
if (index >= _segments.length) {
|
validIndex: validIndex,
|
||||||
|
selectedSegment: selectedSegment,
|
||||||
|
contentKey: contentKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> _interruptFallbackPlayback() async {
|
||||||
|
_playbackGeneration++;
|
||||||
|
_pendingFallbackIndex = -1;
|
||||||
|
_didStartCurrentFallbackUtterance = false;
|
||||||
|
_isInterruptingPlayback = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _tts.stop();
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 120));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isInterruptingPlayback = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _playbackGeneration;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _playFallbackFromGeneration(int startIndex, int generation) async {
|
||||||
|
if (startIndex < 0 || startIndex >= _segments.length) {
|
||||||
|
state = state.copyWith(
|
||||||
|
status: TtsStatus.idle,
|
||||||
|
paragraphIndex: 0,
|
||||||
|
activeParagraphIndex: -1,
|
||||||
|
progressStart: -1,
|
||||||
|
progressEnd: -1,
|
||||||
|
);
|
||||||
|
await _syncBackgroundMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var index = startIndex; index < _segments.length; index++) {
|
||||||
|
if (generation != _playbackGeneration) return;
|
||||||
|
if (state.status != TtsStatus.playing) return;
|
||||||
|
|
||||||
|
_pendingFallbackIndex = index;
|
||||||
|
_didStartCurrentFallbackUtterance = false;
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
status: TtsStatus.playing,
|
||||||
|
paragraphIndex: index,
|
||||||
|
totalParagraphs: _segments.length,
|
||||||
|
activeParagraphIndex: -1,
|
||||||
|
progressStart: -1,
|
||||||
|
progressEnd: -1,
|
||||||
|
);
|
||||||
|
await _syncBackgroundMode();
|
||||||
|
|
||||||
|
await _tts.setSpeechRate(state.speed);
|
||||||
|
final result = await _tts.speak(_segments[index].text);
|
||||||
|
|
||||||
|
if (generation != _playbackGeneration) return;
|
||||||
|
if (state.status != TtsStatus.playing) return;
|
||||||
|
|
||||||
|
if (result is int && result != 1) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
status: TtsStatus.idle,
|
status: TtsStatus.idle,
|
||||||
activeParagraphIndex: -1,
|
activeParagraphIndex: -1,
|
||||||
@@ -379,21 +805,23 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final segment = _segments[index];
|
if (!_didStartCurrentFallbackUtterance) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
paragraphIndex: index,
|
status: TtsStatus.idle,
|
||||||
activeParagraphIndex: segment.paragraphIndex,
|
activeParagraphIndex: -1,
|
||||||
progressStart: segment.start,
|
progressStart: -1,
|
||||||
progressEnd: segment.end,
|
progressEnd: -1,
|
||||||
);
|
);
|
||||||
|
await _syncBackgroundMode();
|
||||||
await _tts.setSpeechRate(state.speed);
|
return;
|
||||||
await _tts.speak(segment.text);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _next() async {
|
if (generation != _playbackGeneration) return;
|
||||||
final next = state.paragraphIndex + 1;
|
|
||||||
if (next >= state.totalParagraphs) {
|
_pendingFallbackIndex = -1;
|
||||||
|
_didStartCurrentFallbackUtterance = false;
|
||||||
|
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
status: TtsStatus.idle,
|
status: TtsStatus.idle,
|
||||||
paragraphIndex: 0,
|
paragraphIndex: 0,
|
||||||
@@ -403,33 +831,66 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
completedCount: state.completedCount + 1,
|
completedCount: state.completedCount + 1,
|
||||||
);
|
);
|
||||||
await _syncBackgroundMode();
|
await _syncBackgroundMode();
|
||||||
return;
|
|
||||||
}
|
|
||||||
state = state.copyWith(
|
|
||||||
paragraphIndex: next,
|
|
||||||
activeParagraphIndex: _segments[next].paragraphIndex,
|
|
||||||
progressStart: _segments[next].start,
|
|
||||||
progressEnd: _segments[next].end,
|
|
||||||
);
|
|
||||||
await _speak(next);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> pause() async {
|
Future<void> pause() async {
|
||||||
|
if (_useNativeAndroidMediaService) {
|
||||||
|
await _mediaChannel.invokeMethod<void>('pause');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status != TtsStatus.playing) return;
|
||||||
|
|
||||||
|
_playbackGeneration++;
|
||||||
await _tts.pause();
|
await _tts.pause();
|
||||||
state = state.copyWith(status: TtsStatus.paused);
|
state = state.copyWith(status: TtsStatus.paused);
|
||||||
await _syncBackgroundMode();
|
await _syncBackgroundMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> resume() async {
|
Future<void> _restartFallbackFromIndex(int index) async {
|
||||||
if (state.status != TtsStatus.paused) return;
|
if (_segments.isEmpty) return;
|
||||||
state = state.copyWith(status: TtsStatus.playing);
|
|
||||||
|
final sessionId = await _interruptFallbackPlayback();
|
||||||
|
final validIndex = index.clamp(0, _segments.length - 1);
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
status: TtsStatus.playing,
|
||||||
|
paragraphIndex: validIndex,
|
||||||
|
totalParagraphs: _segments.length,
|
||||||
|
activeParagraphIndex: -1,
|
||||||
|
progressStart: -1,
|
||||||
|
progressEnd: -1,
|
||||||
|
);
|
||||||
await _syncBackgroundMode();
|
await _syncBackgroundMode();
|
||||||
// Use paragraph-level resume for consistent behavior across engines.
|
|
||||||
await _speak(state.paragraphIndex);
|
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> resume() async {
|
||||||
|
if (_useNativeAndroidMediaService) {
|
||||||
|
await _mediaChannel.invokeMethod<void>('resume');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status != TtsStatus.paused) return;
|
||||||
|
await _restartFallbackFromIndex(state.paragraphIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
await _tts.stop();
|
if (_useNativeAndroidMediaService) {
|
||||||
|
await _mediaChannel.invokeMethod<void>('stop');
|
||||||
|
state = state.copyWith(
|
||||||
|
status: TtsStatus.idle,
|
||||||
|
paragraphIndex: 0,
|
||||||
|
activeParagraphIndex: -1,
|
||||||
|
progressStart: -1,
|
||||||
|
progressEnd: -1,
|
||||||
|
clearContentKey: true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _interruptFallbackPlayback();
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
status: TtsStatus.idle,
|
status: TtsStatus.idle,
|
||||||
paragraphIndex: 0,
|
paragraphIndex: 0,
|
||||||
@@ -442,32 +903,53 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> skipForward() async {
|
Future<void> skipForward() async {
|
||||||
await _tts.stop();
|
if (_useNativeAndroidMediaService) {
|
||||||
await _next();
|
await _mediaChannel.invokeMethod<void>('skipForward');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_segments.isEmpty || state.totalParagraphs <= 0) return;
|
||||||
|
|
||||||
|
final next = state.paragraphIndex + 1;
|
||||||
|
if (next >= _segments.length) {
|
||||||
|
await stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _restartFallbackFromIndex(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> skipBack() async {
|
Future<void> skipBack() async {
|
||||||
await _tts.stop();
|
if (_useNativeAndroidMediaService) {
|
||||||
if (state.totalParagraphs <= 0) return;
|
await _mediaChannel.invokeMethod<void>('skipBack');
|
||||||
final prev = (state.paragraphIndex - 1).clamp(0, state.totalParagraphs - 1);
|
return;
|
||||||
state = state.copyWith(
|
}
|
||||||
paragraphIndex: prev,
|
|
||||||
activeParagraphIndex: _segments[prev].paragraphIndex,
|
if (_segments.isEmpty || state.totalParagraphs <= 0) return;
|
||||||
progressStart: _segments[prev].start,
|
|
||||||
progressEnd: _segments[prev].end,
|
final prev = (state.paragraphIndex - 1).clamp(0, _segments.length - 1);
|
||||||
);
|
await _restartFallbackFromIndex(prev);
|
||||||
if (state.status == TtsStatus.playing) await _speak(prev);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setSpeed(double speed) async {
|
Future<void> setSpeed(double speed) async {
|
||||||
state = state.copyWith(speed: speed);
|
state = state.copyWith(speed: speed);
|
||||||
|
|
||||||
|
if (_useNativeAndroidMediaService) {
|
||||||
|
await _mediaChannel.invokeMethod<void>('setSpeed', {'speed': speed});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await _tts.setSpeechRate(speed);
|
await _tts.setSpeechRate(speed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
unawaited(_backgroundChannel.invokeMethod<void>('setWakeLock', {'enabled': false}));
|
_mediaEventsSub?.cancel();
|
||||||
_tts.stop();
|
if (_useNativeAndroidMediaService) {
|
||||||
|
unawaited(_mediaChannel.invokeMethod<void>('dispose'));
|
||||||
|
} else {
|
||||||
|
unawaited(_tts.stop());
|
||||||
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,18 @@ import '../../novel/providers/novels_provider.dart';
|
|||||||
import '../../genres/providers/genres_provider.dart';
|
import '../../genres/providers/genres_provider.dart';
|
||||||
|
|
||||||
class SearchScreen extends ConsumerStatefulWidget {
|
class SearchScreen extends ConsumerStatefulWidget {
|
||||||
const SearchScreen({super.key});
|
const SearchScreen({
|
||||||
|
super.key,
|
||||||
|
this.initialQuery,
|
||||||
|
this.initialGenre,
|
||||||
|
this.initialStatus,
|
||||||
|
this.initialSort = 'latest',
|
||||||
|
});
|
||||||
|
|
||||||
|
final String? initialQuery;
|
||||||
|
final String? initialGenre;
|
||||||
|
final String? initialStatus;
|
||||||
|
final String initialSort;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<SearchScreen> createState() => _SearchScreenState();
|
ConsumerState<SearchScreen> createState() => _SearchScreenState();
|
||||||
@@ -18,6 +29,7 @@ class SearchScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _SearchScreenState extends ConsumerState<SearchScreen> {
|
class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
final _controller = TextEditingController();
|
final _controller = TextEditingController();
|
||||||
|
final _scrollController = ScrollController();
|
||||||
Timer? _debounce;
|
Timer? _debounce;
|
||||||
String? _selectedGenre;
|
String? _selectedGenre;
|
||||||
String? _selectedStatus;
|
String? _selectedStatus;
|
||||||
@@ -35,13 +47,61 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
('Tên A-Z', 'name'),
|
('Tên A-Z', 'name'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
_syncFromInitialParams(applyImmediately: false);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_applyFilters();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant SearchScreen oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
final hasRouteFilterChange = oldWidget.initialQuery != widget.initialQuery ||
|
||||||
|
oldWidget.initialGenre != widget.initialGenre ||
|
||||||
|
oldWidget.initialStatus != widget.initialStatus ||
|
||||||
|
oldWidget.initialSort != widget.initialSort;
|
||||||
|
if (hasRouteFilterChange) {
|
||||||
|
_syncFromInitialParams(applyImmediately: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_scrollController
|
||||||
|
..removeListener(_onScroll)
|
||||||
|
..dispose();
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (!_scrollController.hasClients) return;
|
||||||
|
final position = _scrollController.position;
|
||||||
|
if (position.pixels >= position.maxScrollExtent - 240) {
|
||||||
|
ref.read(novelsProvider.notifier).loadNextPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncFromInitialParams({required bool applyImmediately}) {
|
||||||
|
final incomingQuery = widget.initialQuery?.trim();
|
||||||
|
_controller.text = incomingQuery == null || incomingQuery.isEmpty ? '' : incomingQuery;
|
||||||
|
_selectedGenre = widget.initialGenre;
|
||||||
|
_selectedStatus = widget.initialStatus;
|
||||||
|
_sort = widget.initialSort;
|
||||||
|
if (applyImmediately) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
_applyFilters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _onQueryChanged(String value) {
|
void _onQueryChanged(String value) {
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
_debounce = Timer(const Duration(milliseconds: 500), _applyFilters);
|
_debounce = Timer(const Duration(milliseconds: 500), _applyFilters);
|
||||||
@@ -167,8 +227,25 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
return const Center(child: Text('Không tìm thấy truyện'));
|
return const Center(child: Text('Không tìm thấy truyện'));
|
||||||
}
|
}
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: result.items.length,
|
controller: _scrollController,
|
||||||
itemBuilder: (context, index) => _NovelListTile(novel: result.items[index]),
|
itemCount: result.items.length + (result.hasMore || result.isLoadingMore ? 1 : 0),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index >= result.items.length) {
|
||||||
|
if (result.isLoadingMore) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Trigger page load when user reaches the end.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ref.read(novelsProvider.notifier).loadNextPage();
|
||||||
|
});
|
||||||
|
return const SizedBox(height: 32);
|
||||||
|
}
|
||||||
|
return _NovelListTile(novel: result.items[index]);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -199,6 +276,7 @@ class _FilterChipDropdown extends StatelessWidget {
|
|||||||
return PopupMenuButton<String>(
|
return PopupMenuButton<String>(
|
||||||
onSelected: onSelected,
|
onSelected: onSelected,
|
||||||
itemBuilder: (_) => items,
|
itemBuilder: (_) => items,
|
||||||
|
child: IgnorePointer(
|
||||||
child: FilterChip(
|
child: FilterChip(
|
||||||
label: Text(label),
|
label: Text(label),
|
||||||
selected: selected,
|
selected: selected,
|
||||||
@@ -206,6 +284,7 @@ class _FilterChipDropdown extends StatelessWidget {
|
|||||||
deleteIcon: selected && onClear != null ? const Icon(Icons.close, size: 14) : null,
|
deleteIcon: selected && onClear != null ? const Icon(Icons.close, size: 14) : null,
|
||||||
onDeleted: selected ? onClear : null,
|
onDeleted: selected ? onClear : null,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,77 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../app/router/route_names.dart';
|
import '../../../app/router/route_names.dart';
|
||||||
|
import '../../../core/storage/local_store.dart';
|
||||||
|
|
||||||
class SplashScreen extends StatefulWidget {
|
class SplashScreen extends ConsumerStatefulWidget {
|
||||||
const SplashScreen({super.key});
|
const SplashScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SplashScreen> createState() => _SplashScreenState();
|
ConsumerState<SplashScreen> createState() => _SplashScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SplashScreenState extends State<SplashScreen> {
|
class _SplashScreenState extends ConsumerState<SplashScreen> {
|
||||||
Timer? _redirectTimer;
|
Timer? _redirectTimer;
|
||||||
|
|
||||||
|
bool _isRestorableRoute(String path) {
|
||||||
|
if (path.isEmpty || path == RouteNames.splash) return false;
|
||||||
|
// Composite "parentPath|deepPath" — validate the deep path portion
|
||||||
|
final checkPath = path.contains('|') ? path.substring(path.indexOf('|') + 1) : path;
|
||||||
|
return checkPath == RouteNames.home ||
|
||||||
|
checkPath == RouteNames.login ||
|
||||||
|
checkPath == RouteNames.search ||
|
||||||
|
checkPath.startsWith('${RouteNames.search}?') ||
|
||||||
|
checkPath == RouteNames.genres ||
|
||||||
|
checkPath == RouteNames.bookshelf ||
|
||||||
|
checkPath == RouteNames.profile ||
|
||||||
|
checkPath == RouteNames.settings ||
|
||||||
|
checkPath.startsWith('/novel/') ||
|
||||||
|
checkPath.startsWith('/reader/') ||
|
||||||
|
checkPath.startsWith('/comments/');
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_redirectTimer = Timer(const Duration(milliseconds: 700), () {
|
_redirectTimer = Timer(const Duration(milliseconds: 700), () async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
final lastPath = await ref.read(localStoreProvider).loadLastRoutePath();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (lastPath != null && _isRestorableRoute(lastPath)) {
|
||||||
|
if (lastPath.contains('|')) {
|
||||||
|
// Composite "parentPath|deepPath" e.g. "/novel/123|/reader/abc"
|
||||||
|
// Restore full stack: Home → Novel Detail → Reader
|
||||||
|
final sep = lastPath.indexOf('|');
|
||||||
|
final parentPath = lastPath.substring(0, sep);
|
||||||
|
final deepPath = lastPath.substring(sep + 1);
|
||||||
|
context.go(RouteNames.home);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.push(parentPath);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) context.push(deepPath);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Single deep route (novel, comments) outside ShellRoute: push on Home
|
||||||
|
final isDeepRoute = lastPath.startsWith('/reader/') ||
|
||||||
|
lastPath.startsWith('/novel/') ||
|
||||||
|
lastPath.startsWith('/comments/');
|
||||||
|
if (isDeepRoute) {
|
||||||
|
context.go(RouteNames.home);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) context.push(lastPath);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
context.go(lastPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
context.go(RouteNames.home);
|
context.go(RouteNames.home);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,45 +8,123 @@ class AppShell extends StatelessWidget {
|
|||||||
|
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
int _indexForLocation(String location) {
|
String _tabForLocation(String location) {
|
||||||
if (location.startsWith(RouteNames.search)) return 1;
|
if (location.startsWith(RouteNames.bookshelf)) return RouteNames.bookshelf;
|
||||||
if (location.startsWith(RouteNames.bookshelf)) return 2;
|
if (location.startsWith(RouteNames.genres)) return RouteNames.genres;
|
||||||
if (location.startsWith(RouteNames.genres)) return 3;
|
if (location.startsWith(RouteNames.profile)) return RouteNames.profile;
|
||||||
if (location.startsWith(RouteNames.profile)) return 4;
|
return RouteNames.home;
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
final location = GoRouterState.of(context).uri.path;
|
final location = GoRouterState.of(context).uri.path;
|
||||||
final selectedIndex = _indexForLocation(location);
|
final selectedTab = _tabForLocation(location);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: child,
|
body: child,
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: Container(
|
||||||
selectedIndex: selectedIndex,
|
decoration: BoxDecoration(
|
||||||
onDestinationSelected: (index) {
|
color: colorScheme.surface,
|
||||||
switch (index) {
|
border: Border(
|
||||||
case 0:
|
top: BorderSide(color: colorScheme.outlineVariant.withAlpha(80)),
|
||||||
context.go(RouteNames.home);
|
),
|
||||||
case 1:
|
),
|
||||||
context.go(RouteNames.search);
|
child: SafeArea(
|
||||||
case 2:
|
top: false,
|
||||||
context.go(RouteNames.bookshelf);
|
child: Padding(
|
||||||
case 3:
|
padding: const EdgeInsets.fromLTRB(10, 8, 10, 6),
|
||||||
context.go(RouteNames.genres);
|
child: Row(
|
||||||
case 4:
|
children: [
|
||||||
context.go(RouteNames.profile);
|
_ShellNavItem(
|
||||||
}
|
icon: Icons.home_rounded,
|
||||||
},
|
label: 'Trang chủ',
|
||||||
destinations: const [
|
selected: selectedTab == RouteNames.home,
|
||||||
NavigationDestination(icon: Icon(Icons.home_outlined), label: 'Home'),
|
onTap: () => context.go(RouteNames.home),
|
||||||
NavigationDestination(icon: Icon(Icons.search), label: 'Tim kiem'),
|
),
|
||||||
NavigationDestination(icon: Icon(Icons.bookmark_border), label: 'Tu sach'),
|
_ShellNavItem(
|
||||||
NavigationDestination(icon: Icon(Icons.category_outlined), label: 'The loai'),
|
icon: Icons.layers_rounded,
|
||||||
NavigationDestination(icon: Icon(Icons.person_outline), label: 'Tai khoan'),
|
label: 'Tủ sách',
|
||||||
|
selected: selectedTab == RouteNames.bookshelf,
|
||||||
|
onTap: () => context.go(RouteNames.bookshelf),
|
||||||
|
),
|
||||||
|
_ShellNavItem(
|
||||||
|
icon: Icons.category_rounded,
|
||||||
|
label: 'Thể loại',
|
||||||
|
selected: selectedTab == RouteNames.genres,
|
||||||
|
onTap: () => context.go(RouteNames.genres),
|
||||||
|
),
|
||||||
|
_ShellNavItem(
|
||||||
|
icon: Icons.person_rounded,
|
||||||
|
label: 'Tài khoản',
|
||||||
|
selected: selectedTab == RouteNames.profile,
|
||||||
|
onTap: () => context.go(RouteNames.profile),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ShellNavItem extends StatelessWidget {
|
||||||
|
const _ShellNavItem({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.selected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final activeColor = const Color(0xFF14B8A6);
|
||||||
|
final inactiveColor = colorScheme.onSurfaceVariant;
|
||||||
|
|
||||||
|
return Expanded(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? activeColor.withAlpha(28) : Colors.transparent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
size: 22,
|
||||||
|
color: selected ? activeColor : inactiveColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: selected ? const Color(0xFFF7B500) : inactiveColor,
|
||||||
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../app/router/route_names.dart';
|
||||||
|
|
||||||
|
class MainAppHeader extends StatelessWidget {
|
||||||
|
const MainAppHeader({
|
||||||
|
super.key,
|
||||||
|
this.title = 'Đăng truyện',
|
||||||
|
this.showSearch = true,
|
||||||
|
this.showGenresShortcut = true,
|
||||||
|
this.bottom,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final bool showSearch;
|
||||||
|
final bool showGenresShortcut;
|
||||||
|
final Widget? bottom;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 10, 14, 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface.withAlpha(245),
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(color: colorScheme.outlineVariant.withAlpha(90)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => context.go(RouteNames.home),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/app_icon.png',
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (context, error, stackTrace) => Icon(
|
||||||
|
Icons.menu_book_rounded,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
"Virtus's Reader",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: const Color(0xFF15B8A6),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
if (showSearch)
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Tìm kiếm',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: () => context.go(RouteNames.search),
|
||||||
|
icon: const Icon(Icons.search_rounded),
|
||||||
|
color: const Color(0xFF15B8A6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (bottom != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
bottom!,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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.3+6
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.3
|
sdk: ^3.11.3
|
||||||
@@ -56,6 +56,16 @@ dev_dependencies:
|
|||||||
# package. See that file for information about deactivating specific lint
|
# package. See that file for information about deactivating specific lint
|
||||||
# rules and activating additional ones.
|
# rules and activating additional ones.
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
flutter_launcher_icons: ^0.14.3
|
||||||
|
|
||||||
|
# For information on the generic Dart part of this file, see the
|
||||||
|
flutter_launcher_icons:
|
||||||
|
android: true
|
||||||
|
ios: true
|
||||||
|
image_path: "assets/app_icon.png"
|
||||||
|
min_sdk_android: 21
|
||||||
|
web:
|
||||||
|
generate: false
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
@@ -68,6 +78,9 @@ flutter:
|
|||||||
# the material Icons class.
|
# the material Icons class.
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
|
assets:
|
||||||
|
- assets/app_icon.png
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
# To add assets to your application, add an assets section, like this:
|
||||||
# assets:
|
# assets:
|
||||||
# - images/a_dot_burr.jpeg
|
# - images/a_dot_burr.jpeg
|
||||||
|
|||||||