6 Commits

Author SHA1 Message Date
virtus 2b8fa4ee57 feat: Update app layout with MainAppHeader and enhance user settings interface
Build Android APK / build-apk (push) Successful in 19m27s
Build Android AAB / build-aab (push) Successful in 12m5s
2026-04-23 03:09:24 +07:00
virtus 297fc45707 feat: Update reading progress management and enhance chapter navigation
Build Android AAB / build-aab (push) Successful in 18m4s
Build Android APK / build-apk (push) Successful in 20m41s
2026-04-16 13:18:25 +07:00
virtus 583a41879f feat: Update app branding and icon assets for Virtus's Reader
Build Android AAB / build-aab (push) Successful in 12m2s
Build Android APK / build-apk (push) Successful in 11m58s
2026-04-16 02:29:03 +07:00
virtus 1256475bf9 feat: Enhance search functionality with initial query parameters and infinite scrolling 2026-04-16 02:08:04 +07:00
virtus c892928ff8 feat: Implement native Android MediaSession and foreground service for TTS playback
- Add `ReaderTtsMediaService` to handle background playback, media controls, and notifications on Android
- Integrate `MediaSessionCompat` to support external media controls and lock screen integration
- Add `ReaderTtsMediaBridge` for synchronized state communication between Kotlin and Flutter
- Update `TtsNotifier` to use the native Android service when available, with a fallback for other platforms
- Implement sentence-level highlighting and tapping to start reading from a specific location
- Update Android manifest with necessary permissions for foreground services and notifications
- Adjust TTS speech rate constants and improve playback health monitoring and recovery logic
2026-04-10 18:57:21 +07:00
virtus 76edaa25a4 feat: Implement native Android MediaSession and foreground service for TTS playback
- Add `ReaderTtsMediaService` to handle background playback, media controls, and notifications on Android
- Integrate `MediaSessionCompat` to support external media controls and lock screen integration
- Add `ReaderTtsMediaBridge` for synchronized state communication between Kotlin and Flutter
- Update `TtsNotifier` to use the native Android service when available, with a fallback for other platforms
- Implement sentence-level highlighting and tapping to start reading from a specific location
- Update Android manifest with necessary permissions for foreground services and notifications
- Adjust TTS speech rate constants and improve playback health monitoring and recovery logic
2026-04-10 18:56:36 +07:00
55 changed files with 4012 additions and 1105 deletions
+5
View File
@@ -4,6 +4,7 @@ import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
id("kotlin-parcelize")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services") id("com.google.gms.google-services")
@@ -66,6 +67,10 @@ android {
} }
} }
dependencies {
implementation("androidx.media:media:1.7.0")
}
flutter { flutter {
source = "../.." source = "../.."
} }
+24 -37
View File
@@ -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
+8 -1
View File
@@ -2,8 +2,11 @@
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application <application
android:label="reader_app" android:label="Virtus's Reader"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true">
@@ -34,6 +37,10 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<service
android:name=".tts.ReaderTtsMediaService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and
@@ -6,12 +6,19 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.PowerManager import android.os.PowerManager
import android.provider.Settings import android.provider.Settings
import androidx.core.app.NotificationManagerCompat
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import com.example.reader_app.tts.ReaderTtsMediaBridge
import com.example.reader_app.tts.ReaderTtsMediaService
import com.example.reader_app.tts.ReaderTtsSegment
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private val channelName = "reader_app/tts_background" private val channelName = "reader_app/tts_background"
private val mediaChannelName = "reader_app/tts_media"
private val mediaEventsChannelName = "reader_app/tts_media_events"
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
@@ -35,6 +42,117 @@ class MainActivity : FlutterActivity() {
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, mediaChannelName)
.setMethodCallHandler { call, result ->
when (call.method) {
"initialize" -> {
val enabled = call.argument<Boolean>("backgroundModeEnabled") ?: true
ReaderTtsMediaService.initialize(this, enabled)
result.success(ReaderTtsMediaBridge.snapshot())
}
"getSnapshot" -> result.success(ReaderTtsMediaBridge.snapshot())
"startReading" -> {
val startIndex = call.argument<Int>("startIndex") ?: 0
val contentKey = call.argument<String>("contentKey")
val title = call.argument<String>("title")
val speed = call.argument<Double>("speed") ?: 0.9
val language = call.argument<String>("language") ?: "vi-VN"
val voiceName = call.argument<String>("voiceName")
val backgroundModeEnabled = call.argument<Boolean>("backgroundModeEnabled") ?: true
ReaderTtsMediaService.startReading(
this,
parseSegments(call.argument<List<*>>("segments")),
startIndex,
contentKey,
title,
speed,
language,
voiceName,
backgroundModeEnabled,
)
result.success(null)
}
"pause" -> {
ReaderTtsMediaService.pause(this)
result.success(null)
}
"resume" -> {
ReaderTtsMediaService.resume(this)
result.success(null)
}
"stop" -> {
ReaderTtsMediaService.stop(this)
result.success(null)
}
"skipForward" -> {
ReaderTtsMediaService.skipForward(this)
result.success(null)
}
"skipBack" -> {
ReaderTtsMediaService.skipBack(this)
result.success(null)
}
"setSpeed" -> {
val speed = call.argument<Double>("speed") ?: 0.9
ReaderTtsMediaService.setSpeed(this, speed)
result.success(null)
}
"setVoiceByName" -> {
ReaderTtsMediaService.setVoice(
this,
call.argument<String>("voiceName"),
call.argument<String>("language"),
)
result.success(null)
}
"setBackgroundModeEnabled" -> {
val enabled = call.argument<Boolean>("enabled") ?: true
ReaderTtsMediaService.setBackgroundModeEnabled(this, enabled)
result.success(null)
}
"areNotificationsEnabled" -> {
result.success(NotificationManagerCompat.from(this).areNotificationsEnabled())
}
"openNotificationSettings" -> {
openNotificationSettings()
result.success(null)
}
"dispose" -> result.success(null)
else -> result.notImplemented()
}
}
EventChannel(flutterEngine.dartExecutor.binaryMessenger, mediaEventsChannelName)
.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
ReaderTtsMediaBridge.attachSink(events)
}
override fun onCancel(arguments: Any?) {
ReaderTtsMediaBridge.detachSink()
}
},
)
}
private fun parseSegments(rawSegments: List<*>?): ArrayList<ReaderTtsSegment> {
val segments = arrayListOf<ReaderTtsSegment>()
rawSegments.orEmpty().forEach { item ->
val map = item as? Map<*, *> ?: return@forEach
val text = map["text"]?.toString() ?: return@forEach
val paragraphIndex = (map["paragraphIndex"] as? Number)?.toInt() ?: -1
val start = (map["start"] as? Number)?.toInt() ?: -1
val end = (map["end"] as? Number)?.toInt() ?: -1
segments += ReaderTtsSegment(
text = text,
paragraphIndex = paragraphIndex,
start = start,
end = end,
)
}
return segments
} }
private fun isIgnoringBatteryOptimizations(): Boolean { private fun isIgnoringBatteryOptimizations(): Boolean {
@@ -76,6 +194,19 @@ class MainActivity : FlutterActivity() {
wakeLock = null wakeLock = null
} }
private fun openNotificationSettings() {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
}
} else {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
}
}
startActivity(intent)
}
override fun onDestroy() { override fun onDestroy() {
setWakeLockEnabled(false) setWakeLockEnabled(false)
super.onDestroy() super.onDestroy()
@@ -0,0 +1,44 @@
package com.example.reader_app.tts
import io.flutter.plugin.common.EventChannel
object ReaderTtsMediaBridge {
private var eventSink: EventChannel.EventSink? = null
private var latestSnapshot: Map<String, Any?> = defaultSnapshot()
@Synchronized
fun attachSink(sink: EventChannel.EventSink) {
eventSink = sink
sink.success(HashMap(latestSnapshot))
}
@Synchronized
fun detachSink() {
eventSink = null
}
@Synchronized
fun publish(snapshot: Map<String, Any?>) {
latestSnapshot = HashMap(snapshot)
eventSink?.success(HashMap(latestSnapshot))
}
@Synchronized
fun snapshot(): Map<String, Any?> = HashMap(latestSnapshot)
private fun defaultSnapshot(): Map<String, Any?> = hashMapOf(
"status" to "idle",
"paragraphIndex" to 0,
"totalParagraphs" to 0,
"activeParagraphIndex" to -1,
"progressStart" to -1,
"progressEnd" to -1,
"contentKey" to null,
"completedCount" to 0,
"backgroundModeEnabled" to true,
"language" to "vi-VN",
"voiceName" to null,
"availableVietnameseVoices" to emptyList<Map<String, String>>()
)
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

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

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 54 KiB

+1 -1
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Reader App</string> <string>Virtus's Reader</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
+45
View File
@@ -1,9 +1,11 @@
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 '../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 '../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';
@@ -21,6 +23,10 @@ class _ReaderAppState extends ConsumerState<ReaderApp> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_ensureMandatoryTtsRequirements();
});
_sessionExpirySub = ref.listenManual<int>( _sessionExpirySub = ref.listenManual<int>(
sessionExpiryProvider, sessionExpiryProvider,
(previous, next) async { (previous, next) async {
@@ -45,6 +51,45 @@ 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() {
_sessionExpirySub?.close(); _sessionExpirySub?.close();
+11 -1
View File
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -36,7 +37,16 @@ final appRouterProvider = Provider<GoRouter>((ref) {
), ),
GoRoute( GoRoute(
path: RouteNames.search, path: RouteNames.search,
builder: (context, state) => const SearchScreen(), builder: (context, state) {
final query = state.uri.queryParameters;
return SearchScreen(
key: ValueKey(state.uri.toString()),
initialQuery: query['q'],
initialGenre: query['genre'],
initialStatus: query['status'],
initialSort: query['sort'] ?? 'latest',
);
},
), ),
GoRoute( GoRoute(
path: RouteNames.genres, path: RouteNames.genres,
+15 -2
View File
@@ -5,9 +5,11 @@ 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',
}); });
final double fontSize; final double fontSize;
@@ -15,6 +17,8 @@ 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;
@@ -25,6 +29,8 @@ 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,
@@ -35,6 +41,8 @@ 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,
@@ -46,9 +54,12 @@ 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',
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
@@ -57,6 +68,8 @@ 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,
+20 -2
View File
@@ -8,6 +8,8 @@ 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';
@@ -24,6 +26,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 +36,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',
); );
} }
@@ -5,6 +5,7 @@ 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 '../providers/bookshelf_provider.dart'; import '../providers/bookshelf_provider.dart';
import '../../auth/providers/auth_provider.dart'; import '../../auth/providers/auth_provider.dart';
@@ -17,21 +18,30 @@ 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: [
child: Column( const MainAppHeader(title: 'Đăng truyện'),
mainAxisSize: MainAxisSize.min, Expanded(
children: [ child: Center(
const Icon(Icons.lock_outline, size: 48), child: Padding(
const SizedBox(height: 12), padding: const EdgeInsets.all(24),
const Text('Vui lòng đăng nhập để xem tủ sách'), child: Column(
const SizedBox(height: 16), mainAxisSize: MainAxisSize.min,
FilledButton( children: [
onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(), const Icon(Icons.lock_outline_rounded, size: 54),
child: const Text('Đăng nhập bằng Google'), const SizedBox(height: 12),
const Text('Vui lòng đăng nhập để xem tủ sách'),
const SizedBox(height: 16),
FilledButton(
onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(),
child: const Text('Đăng nhập bằng Google'),
),
],
),
),
), ),
], ),
), ],
), ),
); );
} }
@@ -39,53 +49,108 @@ 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: 3,
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(
body: bookshelfAsync.when( color: const Color(0xFF14B8A6),
loading: () => const Center(child: CircularProgressIndicator()), borderRadius: BorderRadius.circular(0),
error: (e, _) => Center( ),
child: Column( child: TabBar(
mainAxisSize: MainAxisSize.min, indicatorColor: const Color(0xFFF7B500),
children: [ indicatorWeight: 3,
const Icon(Icons.error_outline, size: 48), labelColor: Colors.white,
const SizedBox(height: 8), unselectedLabelColor: Colors.white70,
Text('Lỗi: $e'), dividerColor: Colors.transparent,
TextButton( tabs: const [
onPressed: () => ref.read(bookshelfProvider.notifier).fetch(), Tab(text: 'Đã đọc'),
child: const Text('Thử lại'), Tab(text: 'Đã lưu'),
Tab(text: 'Đang mở'),
],
),
), ),
],
),
),
data: (bookmarks) {
if (bookmarks.isEmpty) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.menu_book_outlined, size: 56),
SizedBox(height: 12),
Text('Chưa có truyện nào trong tủ sách'),
],
),
);
}
return RefreshIndicator(
onRefresh: () => ref.read(bookshelfProvider.notifier).fetch(),
child: ListView.builder(
itemCount: bookmarks.length,
itemBuilder: (context, index) =>
_BookmarkTile(bookmark: bookmarks[index]),
), ),
); Expanded(
}, child: bookshelfAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline_rounded, size: 48),
const SizedBox(height: 8),
Text('Lỗi: $e'),
TextButton(
onPressed: () => ref.read(bookshelfProvider.notifier).fetch(),
child: const Text('Thử lại'),
),
],
),
),
data: (bookmarks) {
final readItems = bookmarks.where((e) => e.readChapters.isNotEmpty).toList();
final savedItems = bookmarks;
final openingItems = bookmarks.where((e) => e.lastChapterId != null).toList();
return TabBarView(
children: [
_BookshelfList(
bookmarks: readItems,
emptyLabel: 'Chưa có truyện đã đọc.',
),
_BookshelfList(
bookmarks: savedItems,
emptyLabel: 'Chưa có truyện nào trong tủ sách.',
),
_BookshelfList(
bookmarks: openingItems,
emptyLabel: 'Chưa có truyện đang mở.',
),
],
);
},
),
),
],
),
),
);
}
}
class _BookshelfList extends ConsumerWidget {
const _BookshelfList({required this.bookmarks, required this.emptyLabel});
final List<BookmarkModel> bookmarks;
final String emptyLabel;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (bookmarks.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.menu_book_outlined, size: 56),
const SizedBox(height: 12),
Text(emptyLabel),
],
),
);
}
return RefreshIndicator(
onRefresh: () => ref.read(bookshelfProvider.notifier).fetch(),
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 24),
itemCount: bookmarks.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) => _BookmarkTile(bookmark: bookmarks[index]),
), ),
); );
} }
@@ -98,32 +163,117 @@ class _BookmarkTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final novel = bookmark.novel; final novel = bookmark.novel;
return ListTile( return Container(
leading: ClipRRect( padding: const EdgeInsets.all(12),
borderRadius: BorderRadius.circular(6), decoration: BoxDecoration(
child: novel?.coverUrl != null color: Theme.of(context).colorScheme.surfaceContainerLow,
? CachedNetworkImage( borderRadius: BorderRadius.circular(18),
imageUrl: novel!.coverUrl!, ),
width: 44, child: Column(
height: 60, crossAxisAlignment: CrossAxisAlignment.start,
fit: BoxFit.cover, children: [
) Row(
: Container( crossAxisAlignment: CrossAxisAlignment.start,
width: 44, children: [
height: 60, ClipRRect(
color: Theme.of(context).colorScheme.primaryContainer, borderRadius: BorderRadius.circular(10),
child: const Icon(Icons.menu_book, size: 20), child: novel?.coverUrl != null
? CachedNetworkImage(
imageUrl: novel!.coverUrl!,
width: 92,
height: 126,
fit: BoxFit.cover,
)
: Container(
width: 92,
height: 126,
color: Theme.of(context).colorScheme.primaryContainer,
child: const Icon(Icons.menu_book, size: 28),
),
), ),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
novel?.title ?? bookmark.novelId,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
const Icon(Icons.close_rounded, size: 20),
],
),
const SizedBox(height: 8),
Text(
'Số chương: ${novel?.totalChapters ?? '--'}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
if (bookmark.lastChapterNumber != null) ...[
const SizedBox(height: 6),
Text(
'Đang đọc đến: ${bookmark.lastChapterNumber} / ${novel?.totalChapters ?? '--'}',
style: Theme.of(context).textTheme.bodyMedium,
),
],
if (novel?.authorName != null) ...[
const SizedBox(height: 10),
Text(
novel!.authorName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
),
),
],
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
icon: const Icon(Icons.menu_book_rounded),
label: const Text('Đọc tiếp'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF14B8A6),
foregroundColor: Colors.white,
),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton.icon(
onPressed: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
icon: const Icon(Icons.headphones_rounded),
label: const Text('Nghe tiếp'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF14B8A6),
foregroundColor: Colors.white,
),
),
),
],
),
],
), ),
title: Text(
novel?.title ?? bookmark.novelId,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: novel?.authorName != null
? Text(novel!.authorName, maxLines: 1, overflow: TextOverflow.ellipsis)
: null,
onTap: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
); );
} }
} }
+376 -113
View File
@@ -1,10 +1,13 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/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,62 +16,67 @@ 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(
), loading: () => const Center(child: CircularProgressIndicator()),
], error: (e, _) => Center(
), child: Padding(
body: homeAsync.when( padding: const EdgeInsets.all(24),
loading: () => const Center(child: CircularProgressIndicator()), child: Column(
error: (e, _) => Center( mainAxisSize: MainAxisSize.min,
child: Column( children: [
mainAxisSize: MainAxisSize.min, const Icon(Icons.cloud_off_rounded, size: 52),
children: [ const SizedBox(height: 12),
const Icon(Icons.error_outline, size: 48), Text('Không thể tải dữ liệu trang chủ'),
const SizedBox(height: 12), const SizedBox(height: 8),
Text('Lỗi tải dữ liệu', style: Theme.of(context).textTheme.bodyLarge), Text(
Padding( e.toString(),
padding: const EdgeInsets.fromLTRB(24, 8, 24, 0), maxLines: 3,
child: Text( overflow: TextOverflow.ellipsis,
e.toString(), textAlign: TextAlign.center,
textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall,
maxLines: 3, ),
overflow: TextOverflow.ellipsis, const SizedBox(height: 12),
style: Theme.of(context).textTheme.bodySmall, FilledButton(
onPressed: () => ref.invalidate(homeProvider),
child: const Text('Tải lại'),
),
],
),
), ),
), ),
TextButton( data: (data) => RefreshIndicator(
onPressed: () => ref.invalidate(homeProvider), onRefresh: () async => ref.invalidate(homeProvider),
child: const Text('Thử lại'), child: ListView(
padding: const EdgeInsets.fromLTRB(0, 12, 0, 24),
children: [
_HotCarousel(novels: data.hot),
const SizedBox(height: 12),
const _HomeQuickFilters(),
_SectionHeader(
title: 'Truyện mới nhất',
onMore: () => context.go(RouteNames.search),
),
_NovelHorizontalList(novels: data.latest),
_SectionHeader(
title: 'Đề cử nổi bật',
onMore: () => context.go('${RouteNames.search}?sort=rating'),
),
_FeatureGrid(novels: data.topRated.take(6).toList()),
const SizedBox(height: 12),
],
),
), ),
], ),
), ),
), ],
data: (data) => RefreshIndicator(
onRefresh: () async => ref.invalidate(homeProvider),
child: ListView(
children: [
_HotCarousel(novels: data.hot),
_SectionHeader(
title: 'Mới cập nhật',
onMore: () => context.go(RouteNames.search),
),
_NovelHorizontalList(novels: data.latest),
_SectionHeader(
title: 'Đánh giá cao',
onMore: () => context.go(RouteNames.search),
),
_NovelHorizontalList(novels: data.topRated),
const SizedBox(height: 16),
],
),
),
), ),
); );
} }
@@ -82,18 +90,82 @@ class _SectionHeader extends StatelessWidget {
@override @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,17 +235,49 @@ 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: PageView.builder( child: Column(
controller: _controller, children: [
itemCount: widget.novels.length, Expanded(
itemBuilder: (context, index) { child: Padding(
final novel = widget.novels[index]; padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
return GestureDetector( child: ClipRRect(
onTap: () => context.push(RouteNames.novelDetail(novel.id)), borderRadius: BorderRadius.circular(12),
child: _CarouselCard(novel: novel), child: ClipRect(
); child: PageView.builder(
}, controller: _controller,
itemCount: widget.novels.length,
onPageChanged: (value) => setState(() => _currentPage = value),
itemBuilder: (context, index) {
final novel = widget.novels[index];
return GestureDetector(
onTap: () => context.push(RouteNames.novelDetail(novel.id)),
child: _CarouselCard(novel: novel),
);
},
),
),
),
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(widget.novels.length.clamp(0, 5), (index) {
final active = index == _currentPage.clamp(0, 4);
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: active ? 16 : 7,
height: 7,
decoration: BoxDecoration(
color: active ? const Color(0xFF14B8A6) : Colors.white54,
borderRadius: BorderRadius.circular(99),
),
);
}),
),
],
), ),
); );
} }
@@ -137,52 +289,76 @@ class _CarouselCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Stack(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 12), fit: StackFit.expand,
child: ClipRRect( children: [
borderRadius: BorderRadius.circular(12), if (novel.coverUrl != null)
child: Stack( CachedNetworkImage(
fit: StackFit.expand, imageUrl: novel.coverUrl!,
children: [ fit: BoxFit.cover,
if (novel.coverUrl != null) placeholder: (_, imageUrl) => Container(color: Colors.grey[200]),
CachedNetworkImage( errorWidget: (_, imageUrl, error) => Container(color: Colors.grey[300]),
imageUrl: novel.coverUrl!, )
fit: BoxFit.cover, else
placeholder: (_, imageUrl) => Container(color: Colors.grey[200]), Container(color: Theme.of(context).colorScheme.primaryContainer),
errorWidget: (_, imageUrl, error) => Positioned.fill(
Container(color: Colors.grey[300]), child: DecoratedBox(
) decoration: BoxDecoration(
else gradient: LinearGradient(
Container(color: Theme.of(context).colorScheme.primaryContainer), begin: Alignment.topCenter,
Positioned.fill( end: Alignment.bottomCenter,
child: DecoratedBox( colors: [Colors.transparent, Colors.black.withAlpha(180)],
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withAlpha(180)],
),
),
), ),
), ),
Positioned( ),
bottom: 12, ),
left: 12, Positioned(
right: 12, bottom: 12,
child: Text( left: 12,
right: 12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (novel.status.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF22C55E),
borderRadius: BorderRadius.circular(999),
),
child: Text(
novel.status,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: 10),
Text(
novel.title, 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),
Flexible(
child: Text(
novel.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 3),
Text( Text(
novel.title, '${novel.totalChapters} chương',
maxLines: 2, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: const Color(0xFF58D68D),
),
), ),
], ],
), ),
@@ -241,3 +430,77 @@ class _NovelHorizontalList extends StatelessWidget {
); );
} }
} }
class _FeatureGrid extends StatelessWidget {
const _FeatureGrid({required this.novels});
final List<NovelModel> novels;
@override
Widget build(BuildContext context) {
if (novels.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.fromLTRB(18, 4, 18, 0),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
itemCount: novels.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 18,
crossAxisSpacing: 14,
childAspectRatio: 0.74,
),
itemBuilder: (context, index) {
final novel = novels[index];
return GestureDetector(
onTap: () => context.push(RouteNames.novelDetail(novel.id)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: novel.coverUrl != null
? CachedNetworkImage(
imageUrl: novel.coverUrl!,
width: double.infinity,
fit: BoxFit.cover,
)
: Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: const Center(child: Icon(Icons.menu_book_rounded)),
),
),
),
const SizedBox(height: 8),
Text(
novel.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 2),
Text(
'${novel.totalChapters} Chương',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: const Color(0xFF58D68D),
),
),
Text(
'${novel.bookmarkCount > 0 ? novel.bookmarkCount : novel.views} ${novel.bookmarkCount > 0 ? 'Đề cử/tuần' : 'Lượt xem'}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: const Color(0xFF1677FF),
),
),
],
),
);
},
),
);
}
}
@@ -23,11 +23,40 @@ class BrowseParams {
this.page = 1, this.page = 1,
}); });
String? _normalizedStatus() {
final raw = status?.trim();
if (raw == null || raw.isEmpty) return null;
switch (raw.toLowerCase()) {
case 'ongoing':
return 'Đang ra';
case 'completed':
return 'Hoàn thành';
case 'hiatus':
return 'Tạm ngưng';
default:
return raw;
}
}
String _normalizedSort() {
final raw = sort.trim();
if (raw.isEmpty) return 'latest';
switch (raw.toLowerCase()) {
case 'latest':
case 'popular':
case 'rating':
case 'name':
return raw.toLowerCase();
default:
return 'latest';
}
}
Map<String, dynamic> toQueryParams() => { Map<String, dynamic> toQueryParams() => {
if (query != null && query!.isNotEmpty) 'q': query, if (query != null && query!.isNotEmpty) 'q': query,
if (genre != null) 'genre': genre, if (genre != null) 'genre': genre,
if (status != null) 'status': status, if (_normalizedStatus() != null) 'status': _normalizedStatus(),
'sort': sort, 'sort': _normalizedSort(),
'page': page.toString(), 'page': page.toString(),
'limit': '20', 'limit': '20',
}; };
@@ -56,18 +85,39 @@ class BrowseResult {
final int totalCount; final int totalCount;
final int totalPages; final int totalPages;
final int currentPage; final int currentPage;
final bool isLoadingMore;
const BrowseResult({ const BrowseResult({
required this.items, required this.items,
required this.totalCount, required this.totalCount,
required this.totalPages, required this.totalPages,
required this.currentPage, required this.currentPage,
this.isLoadingMore = false,
}); });
bool get hasMore => currentPage < totalPages;
BrowseResult copyWith({
List<NovelModel>? items,
int? totalCount,
int? totalPages,
int? currentPage,
bool? isLoadingMore,
}) {
return BrowseResult(
items: items ?? this.items,
totalCount: totalCount ?? this.totalCount,
totalPages: totalPages ?? this.totalPages,
currentPage: currentPage ?? this.currentPage,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
);
}
} }
class NovelsNotifier extends StateNotifier<AsyncValue<BrowseResult>> { class NovelsNotifier extends StateNotifier<AsyncValue<BrowseResult>> {
final Ref _ref; final Ref _ref;
BrowseParams _params = const BrowseParams(); BrowseParams _params = const BrowseParams();
bool _isLoadingMore = false;
NovelsNotifier(this._ref) : super(const AsyncValue.loading()) { NovelsNotifier(this._ref) : super(const AsyncValue.loading()) {
fetch(); fetch();
@@ -75,25 +125,53 @@ class NovelsNotifier extends StateNotifier<AsyncValue<BrowseResult>> {
BrowseParams get params => _params; BrowseParams get params => _params;
Future<BrowseResult> _fetchPage(BrowseParams params) async {
final client = _ref.read(apiClientProvider);
final res = await client.dio.get('/api/novels/browse', queryParameters: params.toQueryParams());
final data = res.data as Map<String, dynamic>;
return BrowseResult(
items: (data['items'] as List).map((e) => NovelModel.fromJson(e as Map<String, dynamic>)).toList(),
totalCount: (data['totalCount'] as num?)?.toInt() ?? 0,
totalPages: (data['totalPages'] as num?)?.toInt() ?? 1,
currentPage: (data['currentPage'] as num?)?.toInt() ?? params.page,
);
}
Future<void> fetch({BrowseParams? params}) async { Future<void> fetch({BrowseParams? params}) async {
if (params != null) _params = params; if (params != null) _params = params;
state = const AsyncValue.loading(); state = const AsyncValue.loading();
try { try {
final client = _ref.read(apiClientProvider); final firstPageParams = _params.copyWith(page: 1);
final res = await client.dio.get('/api/novels/browse', queryParameters: _params.toQueryParams()); final result = await _fetchPage(firstPageParams);
final data = res.data as Map<String, dynamic>; _params = firstPageParams;
state = AsyncValue.data(BrowseResult( state = AsyncValue.data(result);
items: (data['items'] as List).map((e) => NovelModel.fromJson(e as Map<String, dynamic>)).toList(),
totalCount: data['totalCount'] as int,
totalPages: data['totalPages'] as int,
currentPage: data['currentPage'] as int,
));
} catch (e, st) { } catch (e, st) {
state = AsyncValue.error(e, st); state = AsyncValue.error(e, st);
} }
} }
Future<void> updateParams(BrowseParams params) => fetch(params: params); Future<void> updateParams(BrowseParams params) => fetch(params: params);
Future<void> loadNextPage() async {
final current = state.valueOrNull;
if (current == null || !current.hasMore || _isLoadingMore) return;
_isLoadingMore = true;
state = AsyncValue.data(current.copyWith(isLoadingMore: true));
try {
final nextParams = _params.copyWith(page: current.currentPage + 1);
final nextPage = await _fetchPage(nextParams);
_params = nextParams;
final merged = [...current.items, ...nextPage.items];
state = AsyncValue.data(nextPage.copyWith(items: merged, isLoadingMore: false));
} catch (e, st) {
state = AsyncValue.error(e, st);
} finally {
_isLoadingMore = false;
}
}
} }
final novelsProvider = StateNotifierProvider<NovelsNotifier, AsyncValue<BrowseResult>>((ref) { final novelsProvider = StateNotifierProvider<NovelsNotifier, AsyncValue<BrowseResult>>((ref) {
@@ -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,151 +23,180 @@ class ProfileScreen extends ConsumerWidget {
: ''; : '';
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Tài khoản')), body: Column(
body: switch (authState) { children: [
AuthAuthenticated(:final user) => SingleChildScrollView( const MainAppHeader(title: 'Trang cá nhân', showGenresShortcut: false),
padding: const EdgeInsets.all(16), Expanded(
child: Column( child: switch (authState) {
children: [ AuthAuthenticated(:final user) => SingleChildScrollView(
// User Avatar & Basic Info padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
CircleAvatar( Container(
radius: 40, padding: const EdgeInsets.all(14),
backgroundImage: decoration: BoxDecoration(
user.image != null ? NetworkImage(user.image!) : null, color: Theme.of(context).colorScheme.surfaceContainerLow,
child: user.image == null borderRadius: BorderRadius.circular(22),
? Text( ),
displayName[0].toUpperCase(), child: Row(
style: crossAxisAlignment: CrossAxisAlignment.start,
Theme.of(context).textTheme.headlineMedium, children: [
) CircleAvatar(
: null, radius: 34,
backgroundImage:
user.image != null ? NetworkImage(user.image!) : null,
child: user.image == null
? Text(
displayName.isNotEmpty ? displayName[0].toUpperCase() : 'U',
style: Theme.of(context).textTheme.headlineMedium,
)
: null,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayName,
style: Theme.of(context).textTheme.headlineSmall,
),
Text(
user.role.toLowerCase(),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 10),
_AccountStatRow(
icon: Icons.auto_awesome,
label: 'Tiên Thạch: 0.00 TT',
),
const SizedBox(height: 4),
_AccountStatRow(
icon: Icons.diamond,
label: 'Linh Phiếu: 0 LP',
),
const SizedBox(height: 4),
_AccountStatRow(
icon: Icons.local_activity,
label: 'Ngọc Phiếu: $bookmarkedCount',
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () {},
icon: const Icon(Icons.workspace_premium_rounded),
label: const Text('Thêm Tiên Thạch'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF14B8A6),
foregroundColor: Colors.white,
),
),
],
),
),
],
),
), ),
const SizedBox(height: 12), const SizedBox(height: 18),
Text( _ProfileMenuTile(
displayName, title: 'Chỉnh sửa thông tin',
style: Theme.of(context).textTheme.titleLarge, onTap: () => context.push(RouteNames.settings),
textAlign: TextAlign.center,
), ),
const SizedBox(height: 4), _ProfileMenuTile(
Text( title: 'Lịch sử giao dịch',
user.email, onTap: () {},
style: Theme.of(context).textTheme.bodyMedium, ),
textAlign: TextAlign.center, _ProfileMenuTile(
title: 'Liên hệ, báo lỗi',
onTap: () {},
),
_ProfileMenuTile(
title: 'Điều khoản dịch vụ',
onTap: () {},
),
_ProfileMenuTile(
title: 'Xóa tài khoản',
onTap: () {},
),
_ProfileMenuTile(
title: 'Đăng xuất',
onTap: () async {
await ref.read(authProvider.notifier).signOut();
if (context.mounted) context.go(RouteNames.home);
},
), ),
], ],
), ),
), ),
const SizedBox(height: 24), AuthError(:final message) => Center(child: Text(message)),
AuthUnauthenticated() => Center(
// Stats Cards child: Padding(
Row( padding: const EdgeInsets.all(16),
children: [ child: Column(
Expanded( mainAxisSize: MainAxisSize.min,
child: _buildStatCard( children: [
context: context, FilledButton(
label: 'Sách Đánh Dấu', onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(),
count: bookmarkedCount, child: const Text('Đăng nhập bằng Google'),
), ),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => context.push(RouteNames.settings),
icon: const Icon(Icons.tune),
label: const Text('Mở Cài Đặt Đọc'),
),
],
), ),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
context: context,
label: 'Đang Đọc',
count: bookmarkedCount,
),
),
],
),
const SizedBox(height: 24),
// Settings Button
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => context.push(RouteNames.settings),
icon: const Icon(Icons.tune),
label: const Text('Cài Đặt Đọc'),
), ),
), ),
const SizedBox(height: 12), _ => const Center(child: CircularProgressIndicator()),
},
// Logout Button
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () async {
await ref.read(authProvider.notifier).signOut();
if (context.mounted) context.go(RouteNames.home);
},
icon: const Icon(Icons.logout),
label: const Text('Đăng Xuất'),
),
),
],
),
),
AuthError(:final message) => Center(child: Text(message)),
AuthUnauthenticated() => Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton(
onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(),
child: const Text('Đăng nhập bằng Google'),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => context.push(RouteNames.settings),
icon: const Icon(Icons.tune),
label: const Text('Mở Cài Đặt Đọc'),
),
],
),
),
),
_ => const Center(child: CircularProgressIndicator()),
},
);
}
Widget _buildStatCard({
required BuildContext context,
required String label,
required int count,
}) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Text(
count.toString(),
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
), ),
], ],
), ),
); );
} }
} }
class _AccountStatRow extends StatelessWidget {
const _AccountStatRow({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 18, color: const Color(0xFF58D68D)),
const SizedBox(width: 8),
Expanded(child: Text(label, style: Theme.of(context).textTheme.titleMedium)),
],
);
}
}
class _ProfileMenuTile extends StatelessWidget {
const _ProfileMenuTile({required this.title, required this.onTap});
final String title;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(14),
),
child: ListTile(
title: Text(title),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: onTap,
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -30,7 +30,7 @@ class TtsPlayerWidget extends ConsumerWidget {
final tts = ref.watch(ttsProvider); final tts = ref.watch(ttsProvider);
final notifier = ref.read(ttsProvider.notifier); final notifier = ref.read(ttsProvider.notifier);
const speeds = [0.35, 0.45, 0.55, 0.65, 0.8, 1.0]; const speeds = [0.45, 0.675, 0.9, 1.125, 1.35, 1.8];
Future<void> start() async { Future<void> start() async {
if (tts.status == TtsStatus.paused) { if (tts.status == TtsStatus.paused) {
@@ -61,6 +61,21 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
scrollOffset: 0, scrollOffset: 0,
); );
} }
void resetCurrentChapterProgress() {
if (state == null) return;
state = ReadingProgress(
novelId: state!.novelId,
chapterId: state!.chapterId,
chapterNumber: state!.chapterNumber,
scrollOffset: 0,
);
// Persist immediately so a freshly opened chapter always resumes at top.
unawaited(_persistProgress(state!.chapterId, state!.chapterNumber, 0));
}
void updateScroll(double offset) { void updateScroll(double offset) {
if (state == null) return; if (state == null) return;
state = ReadingProgress( state = ReadingProgress(
@@ -88,14 +103,20 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
} 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) { unawaited(_persistProgress(state!.chapterId, state!.chapterNumber, offset));
await _persistProgress(state!.chapterId, state!.chapterNumber, offset); }
} });
}
@override
void dispose() {
_debounceTimer?.cancel();
super.dispose();
} }
} }
@@ -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);
+515 -130
View File
@@ -7,7 +7,7 @@ import 'package:flutter_tts/flutter_tts.dart';
enum TtsStatus { idle, playing, paused } enum TtsStatus { idle, playing, paused }
const double kTtsBaseSpeechRate = 0.45; const double kTtsBaseSpeechRate = 0.9;
double ttsDisplayMultiplier(double speechRate) => speechRate / kTtsBaseSpeechRate; double ttsDisplayMultiplier(double speechRate) => speechRate / kTtsBaseSpeechRate;
@@ -32,6 +32,13 @@ class _TtsSegment {
final int paragraphIndex; final int paragraphIndex;
final int start; final int start;
final int end; final int end;
Map<String, Object?> toMap() => {
'text': text,
'paragraphIndex': paragraphIndex,
'start': start,
'end': end,
};
} }
class TtsVoice { class TtsVoice {
@@ -65,7 +72,7 @@ class TtsState {
this.paragraphIndex = 0, this.paragraphIndex = 0,
this.totalParagraphs = 0, this.totalParagraphs = 0,
this.activeParagraphIndex = -1, this.activeParagraphIndex = -1,
this.speed = 0.45, this.speed = 0.9,
this.language = 'vi-VN', this.language = 'vi-VN',
this.voiceName, this.voiceName,
this.availableVietnameseVoices = const [], this.availableVietnameseVoices = const [],
@@ -116,25 +123,48 @@ class TtsState {
batteryOptimizationIgnored: batteryOptimizationIgnored:
batteryOptimizationIgnored ?? this.batteryOptimizationIgnored, batteryOptimizationIgnored ?? this.batteryOptimizationIgnored,
pendingAutoStartChapterId: clearPendingAutoStartChapterId pendingAutoStartChapterId: clearPendingAutoStartChapterId
? null ? null
: (pendingAutoStartChapterId ?? this.pendingAutoStartChapterId), : (pendingAutoStartChapterId ?? this.pendingAutoStartChapterId),
); );
bool get isPlaying => status == TtsStatus.playing; bool get isPlaying => status == TtsStatus.playing;
} }
class TtsNotifier extends StateNotifier<TtsState> { class TtsNotifier extends StateNotifier<TtsState> {
final FlutterTts _tts = FlutterTts();
static const MethodChannel _backgroundChannel = MethodChannel('reader_app/tts_background');
List<_TtsSegment> _segments = [];
bool _initialized = false;
Future<void>? _initFuture;
TtsNotifier() : super(const TtsState()) { TtsNotifier() : super(const TtsState()) {
_initFuture = _init(); _initFuture = _init();
} }
static const MethodChannel _backgroundChannel = MethodChannel(
'reader_app/tts_background',
);
static const MethodChannel _mediaChannel = MethodChannel(
'reader_app/tts_media',
);
static const EventChannel _mediaEventsChannel = EventChannel(
'reader_app/tts_media_events',
);
final FlutterTts _tts = FlutterTts();
List<_TtsSegment> _segments = [];
bool _initialized = false;
Future<void>? _initFuture;
StreamSubscription<dynamic>? _mediaEventsSub;
int _playbackGeneration = 0;
bool _isInterruptingPlayback = false;
int _pendingFallbackIndex = -1;
bool _didStartCurrentFallbackUtterance = false;
bool _hasPromptedNotificationSettings = false;
bool get _useNativeAndroidMediaService => Platform.isAndroid;
Future<void> _init() async { Future<void> _init() async {
if (_useNativeAndroidMediaService) {
await _initAndroidBridge();
_initialized = true;
return;
}
await _tts.awaitSpeakCompletion(true); await _tts.awaitSpeakCompletion(true);
await _tts.setSharedInstance(true); await _tts.setSharedInstance(true);
@@ -150,39 +180,85 @@ class TtsNotifier extends StateNotifier<TtsState> {
); );
} }
if (Platform.isAndroid) { await _configureVietnameseVoiceWithFlutterTts();
await _tts.setAudioAttributesForNavigation();
}
await _configureVietnameseVoice();
await _tts.setSpeechRate(kTtsBaseSpeechRate); await _tts.setSpeechRate(kTtsBaseSpeechRate);
await _tts.setVolume(1.0); await _tts.setVolume(1.0);
await _tts.setPitch(1.0); await _tts.setPitch(1.0);
_tts.setStartHandler(() { _tts.setStartHandler(() {
state = state.copyWith( _didStartCurrentFallbackUtterance = true;
status: TtsStatus.playing, 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);
}
unawaited(_syncBackgroundMode()); unawaited(_syncBackgroundMode());
}); });
_tts.setCompletionHandler(() { _tts.setCompletionHandler(() {
if (state.status == TtsStatus.playing) { // Fallback playback progression is driven by _playFallbackFromGeneration.
_next();
}
}); });
_tts.setErrorHandler((msg) { _tts.setErrorHandler((msg) {
state = state.copyWith(status: TtsStatus.idle); if (_isInterruptingPlayback) return;
_pendingFallbackIndex = -1;
_didStartCurrentFallbackUtterance = false;
state = state.copyWith(
status: TtsStatus.idle,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
unawaited(_syncBackgroundMode()); unawaited(_syncBackgroundMode());
}); });
await _syncBackgroundMode(); await _syncBackgroundMode();
_initialized = true; _initialized = true;
} }
Future<void> _configureVietnameseVoice() async { Future<void> _initAndroidBridge() async {
_mediaEventsSub ??= _mediaEventsChannel.receiveBroadcastStream().listen(
_handleAndroidMediaEvent,
onError: (_) {
state = state.copyWith(
status: TtsStatus.idle,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
},
);
await _mediaChannel.invokeMethod<void>('initialize', {
'backgroundModeEnabled': state.backgroundModeEnabled,
});
final snapshot = await _mediaChannel.invokeMethod<dynamic>('getSnapshot');
_applyAndroidSnapshot(snapshot);
await _ensureAndroidMediaNotificationsEnabled();
}
Future<void> _ensureAndroidMediaNotificationsEnabled() async {
if (!_useNativeAndroidMediaService) return;
if (_hasPromptedNotificationSettings) return;
final enabled = await _mediaChannel.invokeMethod<bool>('areNotificationsEnabled') ?? true;
if (enabled) return;
_hasPromptedNotificationSettings = true;
await _mediaChannel.invokeMethod<void>('openNotificationSettings');
}
Future<void> _configureVietnameseVoiceWithFlutterTts() async {
final dynamic voicesRaw = await _tts.getVoices; final dynamic voicesRaw = await _tts.getVoices;
String? selectedName; String? selectedName;
@@ -191,22 +267,34 @@ class TtsNotifier extends StateNotifier<TtsState> {
if (voicesRaw is List) { if (voicesRaw is List) {
final vietnamese = voicesRaw.whereType<Map>().where((voice) { final vietnamese = voicesRaw.whereType<Map>().where((voice) {
final locale = (voice['locale'] ?? voice['language'] ?? '').toString().toLowerCase(); final locale = (voice['locale'] ?? voice['language'] ?? '')
.toString()
.toLowerCase();
return locale.startsWith('vi'); return locale.startsWith('vi');
}).toList(); }).toList();
for (final voice in vietnamese) { for (final voice in vietnamese) {
final name = voice['name']?.toString(); final name = voice['name']?.toString();
final locale = (voice['locale'] ?? voice['language'])?.toString(); final locale = (voice['locale'] ?? voice['language'])?.toString();
if (name == null || name.isEmpty || locale == null || locale.isEmpty) continue; if (name == null || name.isEmpty || locale == null || locale.isEmpty) {
continue;
}
vietnameseVoices.add(TtsVoice(name: name, locale: locale)); vietnameseVoices.add(TtsVoice(name: name, locale: locale));
} }
if (vietnamese.isNotEmpty) { if (vietnamese.isNotEmpty) {
final preferred = vietnamese.firstWhere( final preferred = vietnamese.firstWhere(
(voice) => (voice) =>
(voice['name']?.toString().toLowerCase().contains('female') ?? false) || (voice['name']
(voice['name']?.toString().toLowerCase().contains('natural') ?? false), ?.toString()
.toLowerCase()
.contains('female') ??
false) ||
(voice['name']
?.toString()
.toLowerCase()
.contains('natural') ??
false),
orElse: () => vietnamese.first, orElse: () => vietnamese.first,
); );
selectedName = preferred['name']?.toString(); selectedName = preferred['name']?.toString();
@@ -219,6 +307,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
if (selectedName != null) { if (selectedName != null) {
await _tts.setVoice({'name': selectedName, 'locale': selectedLanguage}); await _tts.setVoice({'name': selectedName, 'locale': selectedLanguage});
} }
state = state.copyWith( state = state.copyWith(
language: selectedLanguage, language: selectedLanguage,
voiceName: selectedName, voiceName: selectedName,
@@ -226,7 +315,176 @@ class TtsNotifier extends StateNotifier<TtsState> {
); );
} }
void _handleAndroidMediaEvent(dynamic event) {
_applyAndroidSnapshot(event);
}
void _applyAndroidSnapshot(dynamic snapshot) {
if (snapshot is! Map) return;
final data = Map<String, dynamic>.from(
snapshot.map((key, value) => MapEntry(key.toString(), value)),
);
final statusRaw = data['status']?.toString() ?? 'idle';
final status = switch (statusRaw) {
'playing' => TtsStatus.playing,
'paused' => TtsStatus.paused,
_ => TtsStatus.idle,
};
final voicesRaw = data['availableVietnameseVoices'];
final voices = <TtsVoice>[];
if (voicesRaw is List) {
for (final item in voicesRaw) {
if (item is! Map) continue;
final map = Map<String, dynamic>.from(
item.map((key, value) => MapEntry(key.toString(), value)),
);
final name = map['name']?.toString();
final locale = map['locale']?.toString();
if (name == null || name.isEmpty || locale == null || locale.isEmpty) {
continue;
}
voices.add(TtsVoice(name: name, locale: locale));
}
}
state = state.copyWith(
status: status,
paragraphIndex: (data['paragraphIndex'] as num?)?.toInt() ?? 0,
totalParagraphs: (data['totalParagraphs'] as num?)?.toInt() ?? 0,
activeParagraphIndex: (data['activeParagraphIndex'] as num?)?.toInt() ?? -1,
progressStart: (data['progressStart'] as num?)?.toInt() ?? -1,
progressEnd: (data['progressEnd'] as num?)?.toInt() ?? -1,
contentKey: data['contentKey']?.toString(),
completedCount: (data['completedCount'] as num?)?.toInt() ?? state.completedCount,
language: data['language']?.toString() ?? state.language,
voiceName: data['voiceName']?.toString(),
availableVietnameseVoices: voices,
backgroundModeEnabled:
data['backgroundModeEnabled'] as bool? ?? state.backgroundModeEnabled,
);
}
String _sanitizeForTts(String raw) {
if (raw.isEmpty) return raw;
// Keep natural sentence flow while removing symbols that are usually read out noisily.
final cleaned = raw
.replaceAll(RegExp(r'[_$#^*+=~`|<>\\\[\]{}]'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
return cleaned;
}
List<_TtsSegment> _buildSegments(
String content, {
String? title,
bool includeTitle = true,
}) {
final segments = <_TtsSegment>[];
final titleText = title?.trim();
if (includeTitle && titleText != null && titleText.isNotEmpty) {
final sanitizedTitle = _sanitizeForTts(titleText);
if (sanitizedTitle.isNotEmpty) {
segments.add(
_TtsSegment(
text: sanitizedTitle,
paragraphIndex: -1,
start: -1,
end: -1,
),
);
}
}
final paragraphs = content
.split(RegExp(r'\n+'))
.map((p) => p.trim())
.where((p) => p.isNotEmpty)
.toList();
for (var pIndex = 0; pIndex < paragraphs.length; pIndex++) {
final paragraph = paragraphs[pIndex];
final sentenceMatches = RegExp(r'[^.!?…]+[.!?…]*').allMatches(paragraph);
var cursor = 0;
for (final match in sentenceMatches) {
final sentence = match.group(0)?.trim() ?? '';
if (sentence.isEmpty) continue;
final sanitizedSentence = _sanitizeForTts(sentence);
if (sanitizedSentence.isEmpty) continue;
var start = paragraph.indexOf(sentence, cursor);
if (start < 0) {
start = cursor.clamp(0, paragraph.length);
}
final end = (start + sentence.length).clamp(0, paragraph.length);
cursor = end;
segments.add(
_TtsSegment(
text: sanitizedSentence,
paragraphIndex: pIndex,
start: start,
end: end,
),
);
}
}
return segments;
}
int _resolveStartIndex(
int paragraphIndex, {
int? startParagraphIndex,
int? startCharOffset,
}) {
var validIndex = paragraphIndex.clamp(0, _segments.length - 1);
if (startParagraphIndex != null) {
final matchIndex = _segments.indexWhere(
(segment) =>
segment.paragraphIndex == startParagraphIndex &&
(startCharOffset == null || segment.start >= startCharOffset),
);
if (matchIndex >= 0) {
validIndex = matchIndex;
} else {
final fallbackIndex = _segments.indexWhere(
(segment) => segment.paragraphIndex >= startParagraphIndex,
);
if (fallbackIndex >= 0) {
validIndex = fallbackIndex;
}
}
}
return validIndex;
}
Future<void> setVoiceByName(String voiceName) async { Future<void> setVoiceByName(String voiceName) async {
if (_useNativeAndroidMediaService) {
final selected = state.availableVietnameseVoices.where(
(voice) => voice.name == voiceName,
);
if (selected.isEmpty) return;
final voice = selected.first;
await _mediaChannel.invokeMethod<void>('setVoiceByName', {
'voiceName': voice.name,
'language': voice.locale,
});
state = state.copyWith(language: voice.locale, voiceName: voice.name);
return;
}
final selected = state.availableVietnameseVoices.where((v) => v.name == voiceName); final selected = state.availableVietnameseVoices.where((v) => v.name == voiceName);
if (selected.isEmpty) return; if (selected.isEmpty) return;
@@ -240,6 +498,16 @@ class TtsNotifier extends StateNotifier<TtsState> {
Future<void> setBackgroundModeEnabled(bool enabled) async { Future<void> setBackgroundModeEnabled(bool enabled) async {
state = state.copyWith(backgroundModeEnabled: enabled); state = state.copyWith(backgroundModeEnabled: enabled);
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('setBackgroundModeEnabled', {
'enabled': enabled,
});
if (enabled) {
await ensureBatteryOptimizationIgnored();
}
return;
}
await _syncBackgroundMode(); await _syncBackgroundMode();
if (enabled) { if (enabled) {
await ensureBatteryOptimizationIgnored(); await ensureBatteryOptimizationIgnored();
@@ -278,23 +546,24 @@ class TtsNotifier extends StateNotifier<TtsState> {
} }
Future<void> _syncBackgroundMode() async { Future<void> _syncBackgroundMode() async {
if (!Platform.isAndroid) return; if (_useNativeAndroidMediaService || !Platform.isAndroid) return;
final shouldKeepAlive = final shouldKeepAlive =
state.backgroundModeEnabled && state.status == TtsStatus.playing; state.backgroundModeEnabled && state.status == TtsStatus.playing;
try { try {
await _backgroundChannel await _backgroundChannel.invokeMethod<void>('setWakeLock', {
.invokeMethod<void>('setWakeLock', {'enabled': shouldKeepAlive}); 'enabled': shouldKeepAlive,
});
} catch (_) { } catch (_) {
// Keep playback functional even if native wake lock bridge is unavailable. // Keep playback functional even if native wake lock bridge is unavailable.
} }
} }
/// Start reading from [content] starting at optional [paragraphIndex].
Future<void> startReading( Future<void> startReading(
String content, { String content, {
int paragraphIndex = 0, int paragraphIndex = 0,
int? startParagraphIndex, int? startParagraphIndex,
int? startCharOffset,
String? contentKey, String? contentKey,
String? title, String? title,
bool includeTitle = true, bool includeTitle = true,
@@ -303,133 +572,228 @@ class TtsNotifier extends StateNotifier<TtsState> {
await (_initFuture ?? _init()); await (_initFuture ?? _init());
} }
final segments = <_TtsSegment>[]; _segments = _buildSegments(
content,
title: title,
includeTitle: includeTitle,
);
final titleText = title?.trim(); if (_segments.isEmpty) {
if (includeTitle && titleText != null && titleText.isNotEmpty) { state = state.copyWith(
segments.add(_TtsSegment(text: titleText, paragraphIndex: -1, start: -1, end: -1)); status: TtsStatus.idle,
} paragraphIndex: 0,
totalParagraphs: 0,
final paragraphs = content activeParagraphIndex: -1,
.split(RegExp(r'\n+')) progressStart: -1,
.map((p) => p.trim()) progressEnd: -1,
.where((p) => p.isNotEmpty) contentKey: contentKey,
.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) return;
var validIndex = paragraphIndex.clamp(0, _segments.length - 1);
if (startParagraphIndex != null) {
final startFromVisible = _segments.indexWhere(
(segment) => segment.paragraphIndex >= startParagraphIndex,
); );
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(
status: TtsStatus.playing,
paragraphIndex: validIndex,
totalParagraphs: _segments.length,
activeParagraphIndex: selectedSegment.paragraphIndex,
progressStart: selectedSegment.start,
progressEnd: selectedSegment.end,
contentKey: contentKey,
);
await _mediaChannel.invokeMethod<void>('startReading', {
'contentKey': contentKey,
'title': title,
'startIndex': validIndex,
'speed': state.speed,
'language': state.language,
'voiceName': state.voiceName,
'backgroundModeEnabled': state.backgroundModeEnabled,
'segments': _segments.map((segment) => segment.toMap()).toList(),
});
return;
}
final sessionId = await _interruptFallbackPlayback();
state = state.copyWith( state = state.copyWith(
status: TtsStatus.playing, status: TtsStatus.playing,
paragraphIndex: validIndex, paragraphIndex: validIndex,
totalParagraphs: _segments.length, totalParagraphs: _segments.length,
activeParagraphIndex: selectedSegment.paragraphIndex, activeParagraphIndex: -1,
progressStart: selectedSegment.start, progressStart: -1,
progressEnd: selectedSegment.end, progressEnd: -1,
contentKey: contentKey, contentKey: contentKey,
); );
await _syncBackgroundMode(); await _syncBackgroundMode();
await _speak(validIndex);
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
} }
Future<void> _speak(int index) async { Future<int> _interruptFallbackPlayback() async {
if (index >= _segments.length) { _playbackGeneration++;
state = state.copyWith( _pendingFallbackIndex = -1;
status: TtsStatus.idle, _didStartCurrentFallbackUtterance = false;
activeParagraphIndex: -1, _isInterruptingPlayback = true;
progressStart: -1,
progressEnd: -1, try {
); await _tts.stop();
await _syncBackgroundMode(); if (Platform.isAndroid) {
return; await Future<void>.delayed(const Duration(milliseconds: 120));
}
} finally {
_isInterruptingPlayback = false;
} }
final segment = _segments[index]; return _playbackGeneration;
state = state.copyWith(
paragraphIndex: index,
activeParagraphIndex: segment.paragraphIndex,
progressStart: segment.start,
progressEnd: segment.end,
);
await _tts.setSpeechRate(state.speed);
await _tts.speak(segment.text);
} }
Future<void> _next() async { Future<void> _playFallbackFromGeneration(int startIndex, int generation) async {
final next = state.paragraphIndex + 1; if (startIndex < 0 || startIndex >= _segments.length) {
if (next >= state.totalParagraphs) {
state = state.copyWith( state = state.copyWith(
status: TtsStatus.idle, status: TtsStatus.idle,
paragraphIndex: 0, paragraphIndex: 0,
activeParagraphIndex: -1, activeParagraphIndex: -1,
progressStart: -1, progressStart: -1,
progressEnd: -1, progressEnd: -1,
completedCount: state.completedCount + 1,
); );
await _syncBackgroundMode(); await _syncBackgroundMode();
return; 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(
status: TtsStatus.idle,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
await _syncBackgroundMode();
return;
}
if (!_didStartCurrentFallbackUtterance) {
state = state.copyWith(
status: TtsStatus.idle,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
await _syncBackgroundMode();
return;
}
}
if (generation != _playbackGeneration) return;
_pendingFallbackIndex = -1;
_didStartCurrentFallbackUtterance = false;
state = state.copyWith( state = state.copyWith(
paragraphIndex: next, status: TtsStatus.idle,
activeParagraphIndex: _segments[next].paragraphIndex, paragraphIndex: 0,
progressStart: _segments[next].start, activeParagraphIndex: -1,
progressEnd: _segments[next].end, progressStart: -1,
progressEnd: -1,
completedCount: state.completedCount + 1,
); );
await _speak(next); await _syncBackgroundMode();
} }
Future<void> pause() async { Future<void> pause() async {
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('pause');
return;
}
if (state.status != TtsStatus.playing) return;
_playbackGeneration++;
await _tts.pause(); await _tts.pause();
state = state.copyWith(status: TtsStatus.paused); state = state.copyWith(status: TtsStatus.paused);
await _syncBackgroundMode(); await _syncBackgroundMode();
} }
Future<void> resume() async { Future<void> _restartFallbackFromIndex(int index) async {
if (state.status != TtsStatus.paused) return; if (_segments.isEmpty) return;
state = state.copyWith(status: TtsStatus.playing);
final sessionId = await _interruptFallbackPlayback();
final validIndex = index.clamp(0, _segments.length - 1);
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: validIndex,
totalParagraphs: _segments.length,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
await _syncBackgroundMode(); await _syncBackgroundMode();
// Use paragraph-level resume for consistent behavior across engines.
await _speak(state.paragraphIndex); unawaited(_playFallbackFromGeneration(validIndex, sessionId));
}
Future<void> resume() async {
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('resume');
return;
}
if (state.status != TtsStatus.paused) return;
await _restartFallbackFromIndex(state.paragraphIndex);
} }
Future<void> stop() async { Future<void> stop() async {
await _tts.stop(); if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('stop');
state = state.copyWith(
status: TtsStatus.idle,
paragraphIndex: 0,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
clearContentKey: true,
);
return;
}
await _interruptFallbackPlayback();
state = state.copyWith( state = state.copyWith(
status: TtsStatus.idle, status: TtsStatus.idle,
paragraphIndex: 0, paragraphIndex: 0,
@@ -442,32 +806,53 @@ class TtsNotifier extends StateNotifier<TtsState> {
} }
Future<void> skipForward() async { Future<void> skipForward() async {
await _tts.stop(); if (_useNativeAndroidMediaService) {
await _next(); await _mediaChannel.invokeMethod<void>('skipForward');
return;
}
if (_segments.isEmpty || state.totalParagraphs <= 0) return;
final next = state.paragraphIndex + 1;
if (next >= _segments.length) {
await stop();
return;
}
await _restartFallbackFromIndex(next);
} }
Future<void> skipBack() async { Future<void> skipBack() async {
await _tts.stop(); if (_useNativeAndroidMediaService) {
if (state.totalParagraphs <= 0) return; await _mediaChannel.invokeMethod<void>('skipBack');
final prev = (state.paragraphIndex - 1).clamp(0, state.totalParagraphs - 1); return;
state = state.copyWith( }
paragraphIndex: prev,
activeParagraphIndex: _segments[prev].paragraphIndex, if (_segments.isEmpty || state.totalParagraphs <= 0) return;
progressStart: _segments[prev].start,
progressEnd: _segments[prev].end, final prev = (state.paragraphIndex - 1).clamp(0, _segments.length - 1);
); await _restartFallbackFromIndex(prev);
if (state.status == TtsStatus.playing) await _speak(prev);
} }
Future<void> setSpeed(double speed) async { Future<void> setSpeed(double speed) async {
state = state.copyWith(speed: speed); state = state.copyWith(speed: speed);
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('setSpeed', {'speed': speed});
return;
}
await _tts.setSpeechRate(speed); await _tts.setSpeechRate(speed);
} }
@override @override
void dispose() { void dispose() {
unawaited(_backgroundChannel.invokeMethod<void>('setWakeLock', {'enabled': false})); _mediaEventsSub?.cancel();
_tts.stop(); if (_useNativeAndroidMediaService) {
unawaited(_mediaChannel.invokeMethod<void>('dispose'));
} else {
unawaited(_tts.stop());
}
super.dispose(); super.dispose();
} }
} }
@@ -10,7 +10,18 @@ import '../../novel/providers/novels_provider.dart';
import '../../genres/providers/genres_provider.dart'; import '../../genres/providers/genres_provider.dart';
class SearchScreen extends ConsumerStatefulWidget { class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key}); const SearchScreen({
super.key,
this.initialQuery,
this.initialGenre,
this.initialStatus,
this.initialSort = 'latest',
});
final String? initialQuery;
final String? initialGenre;
final String? initialStatus;
final String initialSort;
@override @override
ConsumerState<SearchScreen> createState() => _SearchScreenState(); ConsumerState<SearchScreen> createState() => _SearchScreenState();
@@ -18,6 +29,7 @@ class SearchScreen extends ConsumerStatefulWidget {
class _SearchScreenState extends ConsumerState<SearchScreen> { class _SearchScreenState extends ConsumerState<SearchScreen> {
final _controller = TextEditingController(); final _controller = TextEditingController();
final _scrollController = ScrollController();
Timer? _debounce; Timer? _debounce;
String? _selectedGenre; String? _selectedGenre;
String? _selectedStatus; String? _selectedStatus;
@@ -35,13 +47,61 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
('Tên A-Z', 'name'), ('Tên A-Z', 'name'),
]; ];
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_syncFromInitialParams(applyImmediately: false);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_applyFilters();
});
}
@override
void didUpdateWidget(covariant SearchScreen oldWidget) {
super.didUpdateWidget(oldWidget);
final hasRouteFilterChange = oldWidget.initialQuery != widget.initialQuery ||
oldWidget.initialGenre != widget.initialGenre ||
oldWidget.initialStatus != widget.initialStatus ||
oldWidget.initialSort != widget.initialSort;
if (hasRouteFilterChange) {
_syncFromInitialParams(applyImmediately: true);
}
}
@override @override
void dispose() { void dispose() {
_scrollController
..removeListener(_onScroll)
..dispose();
_controller.dispose(); _controller.dispose();
_debounce?.cancel(); _debounce?.cancel();
super.dispose(); super.dispose();
} }
void _onScroll() {
if (!_scrollController.hasClients) return;
final position = _scrollController.position;
if (position.pixels >= position.maxScrollExtent - 240) {
ref.read(novelsProvider.notifier).loadNextPage();
}
}
void _syncFromInitialParams({required bool applyImmediately}) {
final incomingQuery = widget.initialQuery?.trim();
_controller.text = incomingQuery == null || incomingQuery.isEmpty ? '' : incomingQuery;
_selectedGenre = widget.initialGenre;
_selectedStatus = widget.initialStatus;
_sort = widget.initialSort;
if (applyImmediately) {
if (mounted) {
setState(() {});
}
_applyFilters();
}
}
void _onQueryChanged(String value) { void _onQueryChanged(String value) {
_debounce?.cancel(); _debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 500), _applyFilters); _debounce = Timer(const Duration(milliseconds: 500), _applyFilters);
@@ -167,8 +227,25 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
return const Center(child: Text('Không tìm thấy truyện')); return const Center(child: Text('Không tìm thấy truyện'));
} }
return ListView.builder( return ListView.builder(
itemCount: result.items.length, controller: _scrollController,
itemBuilder: (context, index) => _NovelListTile(novel: result.items[index]), itemCount: result.items.length + (result.hasMore || result.isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= result.items.length) {
if (result.isLoadingMore) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator()),
);
}
// Trigger page load when user reaches the end.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(novelsProvider.notifier).loadNextPage();
});
return const SizedBox(height: 32);
}
return _NovelListTile(novel: result.items[index]);
},
); );
}, },
), ),
@@ -199,12 +276,14 @@ class _FilterChipDropdown extends StatelessWidget {
return PopupMenuButton<String>( return PopupMenuButton<String>(
onSelected: onSelected, onSelected: onSelected,
itemBuilder: (_) => items, itemBuilder: (_) => items,
child: FilterChip( child: IgnorePointer(
label: Text(label), child: FilterChip(
selected: selected, label: Text(label),
onSelected: (_) {}, selected: selected,
deleteIcon: selected && onClear != null ? const Icon(Icons.close, size: 14) : null, onSelected: (_) {},
onDeleted: selected ? onClear : null, deleteIcon: selected && onClear != null ? const Icon(Icons.close, size: 14) : null,
onDeleted: selected ? onClear : null,
),
), ),
); );
} }
+108 -30
View File
@@ -8,44 +8,122 @@ class AppShell extends StatelessWidget {
final Widget child; final Widget child;
int _indexForLocation(String location) { String _tabForLocation(String location) {
if (location.startsWith(RouteNames.search)) return 1; if (location.startsWith(RouteNames.bookshelf)) return RouteNames.bookshelf;
if (location.startsWith(RouteNames.bookshelf)) return 2; if (location.startsWith(RouteNames.genres)) return RouteNames.genres;
if (location.startsWith(RouteNames.genres)) return 3; if (location.startsWith(RouteNames.profile)) return RouteNames.profile;
if (location.startsWith(RouteNames.profile)) return 4; return RouteNames.home;
return 0;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final location = GoRouterState.of(context).uri.path; final location = GoRouterState.of(context).uri.path;
final selectedIndex = _indexForLocation(location); final selectedTab = _tabForLocation(location);
return Scaffold( return Scaffold(
body: child, body: child,
bottomNavigationBar: NavigationBar( bottomNavigationBar: Container(
selectedIndex: selectedIndex, decoration: BoxDecoration(
onDestinationSelected: (index) { color: colorScheme.surface,
switch (index) { border: Border(
case 0: top: BorderSide(color: colorScheme.outlineVariant.withAlpha(80)),
context.go(RouteNames.home); ),
case 1: ),
context.go(RouteNames.search); child: SafeArea(
case 2: top: false,
context.go(RouteNames.bookshelf); child: Padding(
case 3: padding: const EdgeInsets.fromLTRB(10, 8, 10, 6),
context.go(RouteNames.genres); child: Row(
case 4: children: [
context.go(RouteNames.profile); _ShellNavItem(
} icon: Icons.home_rounded,
}, label: 'Trang chủ',
destinations: const [ selected: selectedTab == RouteNames.home,
NavigationDestination(icon: Icon(Icons.home_outlined), label: 'Home'), onTap: () => context.go(RouteNames.home),
NavigationDestination(icon: Icon(Icons.search), label: 'Tim kiem'), ),
NavigationDestination(icon: Icon(Icons.bookmark_border), label: 'Tu sach'), _ShellNavItem(
NavigationDestination(icon: Icon(Icons.category_outlined), label: 'The loai'), icon: Icons.layers_rounded,
NavigationDestination(icon: Icon(Icons.person_outline), label: 'Tai khoan'), label: 'Tủ sách',
], selected: selectedTab == RouteNames.bookshelf,
onTap: () => context.go(RouteNames.bookshelf),
),
_ShellNavItem(
icon: Icons.category_rounded,
label: 'Thể loại',
selected: selectedTab == RouteNames.genres,
onTap: () => context.go(RouteNames.genres),
),
_ShellNavItem(
icon: Icons.person_rounded,
label: 'Tài khoản',
selected: selectedTab == RouteNames.profile,
onTap: () => context.go(RouteNames.profile),
),
],
),
),
),
),
);
}
}
class _ShellNavItem extends StatelessWidget {
const _ShellNavItem({
required this.icon,
required this.label,
required this.selected,
required this.onTap,
});
final IconData icon;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final activeColor = const Color(0xFF14B8A6);
final inactiveColor = colorScheme.onSurfaceVariant;
return Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: selected ? activeColor.withAlpha(28) : Colors.transparent,
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 22,
color: selected ? activeColor : inactiveColor,
),
),
const SizedBox(height: 4),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: selected ? const Color(0xFFF7B500) : inactiveColor,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
],
),
),
), ),
); );
} }
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../app/router/route_names.dart';
class MainAppHeader extends StatelessWidget {
const MainAppHeader({
super.key,
this.title = 'Đăng truyện',
this.showSearch = true,
this.showGenresShortcut = true,
this.bottom,
});
final String title;
final bool showSearch;
final bool showGenresShortcut;
final Widget? bottom;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 12),
decoration: BoxDecoration(
color: colorScheme.surface.withAlpha(245),
border: Border(
bottom: BorderSide(color: colorScheme.outlineVariant.withAlpha(90)),
),
),
child: SafeArea(
bottom: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
GestureDetector(
onTap: () => context.go(RouteNames.home),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox(
width: 34,
height: 34,
child: Image.asset(
'assets/app_icon.png',
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) => Icon(
Icons.menu_book_rounded,
color: theme.colorScheme.primary,
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
"Virtus's Reader",
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
color: const Color(0xFF15B8A6),
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 12),
if (showSearch)
IconButton(
tooltip: 'Tìm kiếm',
visualDensity: VisualDensity.compact,
onPressed: () => context.go(RouteNames.search),
icon: const Icon(Icons.search_rounded),
color: const Color(0xFF15B8A6),
),
],
),
if (bottom != null) ...[
const SizedBox(height: 12),
bottom!,
],
],
),
),
);
}
}
+56
View File
@@ -1,6 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -57,6 +65,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@@ -190,6 +214,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.4.1" version: "3.4.1"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -360,6 +392,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -376,6 +416,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.7" version: "0.6.7"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
url: "https://pub.dev"
source: hosted
version: "4.11.0"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -560,6 +608,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
+14 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1 version: 1.0.2+3
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