From 2b8fa4ee57e51ba4a097196b74bc191cd272861b Mon Sep 17 00:00:00 2001 From: virtus Date: Thu, 23 Apr 2026 03:09:24 +0700 Subject: [PATCH] feat: Update app layout with MainAppHeader and enhance user settings interface --- .../reader_app/tts/ReaderTtsMediaService.kt | 176 +++++- lib/app/app.dart | 45 ++ lib/core/models/reading_settings.dart | 17 +- lib/core/storage/local_store.dart | 22 +- .../presentation/bookshelf_screen.dart | 316 ++++++++--- .../home/presentation/home_screen.dart | 489 +++++++++++++---- .../profile/presentation/profile_screen.dart | 292 +++++----- .../reader/presentation/reader_screen.dart | 501 ++++++++++-------- .../reader/providers/reader_provider.dart | 22 +- .../reader/providers/tts_provider.dart | 133 +++++ lib/shared/widgets/app_shell.dart | 138 +++-- lib/shared/widgets/main_app_header.dart | 96 ++++ pubspec.yaml | 5 +- 13 files changed, 1627 insertions(+), 625 deletions(-) create mode 100644 lib/features/reader/providers/tts_provider.dart create mode 100644 lib/shared/widgets/main_app_header.dart diff --git a/android/app/src/main/kotlin/com/example/reader_app/tts/ReaderTtsMediaService.kt b/android/app/src/main/kotlin/com/example/reader_app/tts/ReaderTtsMediaService.kt index b8d4dc9..aca0ffe 100644 --- a/android/app/src/main/kotlin/com/example/reader_app/tts/ReaderTtsMediaService.kt +++ b/android/app/src/main/kotlin/com/example/reader_app/tts/ReaderTtsMediaService.kt @@ -29,6 +29,7 @@ import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import com.example.reader_app.R import kotlinx.parcelize.Parcelize +import kotlin.math.min import java.util.Locale @Parcelize @@ -47,7 +48,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { private const val BASE_SPEED = 0.9 private const val TAG = "ReaderTtsMediaService" private const val HEALTH_CHECK_INTERVAL_MS = 1500L - private const val START_GRACE_PERIOD_MS = 10_000L + private const val START_GRACE_PERIOD_MS = 5_000L private const val MAX_SEGMENT_RETRIES_BEFORE_SKIP = 4 const val ACTION_INIT = "com.example.reader_app.tts.INIT" @@ -70,6 +71,8 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { const val EXTRA_VOICE_NAME = "voiceName" const val EXTRA_BACKGROUND_MODE_ENABLED = "backgroundModeEnabled" const val EXTRA_CLEAR_CONTENT_KEY = "clearContentKey" + const val EXTRA_STOP_REASON = "stopReason" + private const val STOP_REASON_USER = "user" fun initialize(context: Context, backgroundModeEnabled: Boolean) { context.startService( @@ -121,6 +124,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { context.startService(Intent(context, ReaderTtsMediaService::class.java).apply { action = ACTION_STOP putExtra(EXTRA_CLEAR_CONTENT_KEY, clearContentKey) + putExtra(EXTRA_STOP_REASON, STOP_REASON_USER) }) fun skipForward(context: Context) = @@ -179,6 +183,13 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { private var currentUtteranceId: String? = null private var currentUtteranceStarted = false private var pendingReplayAfterInit = false + private var isRebuildingEngine = false + private var engineRebuildAttempt = 0 + private var audioFocusRetryAttempt = 0 + private var consecutivePlaybackRecoveryFailures = 0 + private var pendingEngineRebuild: Runnable? = null + private var pendingAudioFocusRetry: Runnable? = null + private var pendingIdleStop: Runnable? = null private var currentSegmentRetry = 0 private var consecutiveSilentHealthChecks = 0 private var utteranceWatchdog: Runnable? = null @@ -226,6 +237,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.i(TAG, "onStartCommand action=${intent?.action} status=$status index=$currentIndex") when (intent?.action) { ACTION_INIT -> { backgroundModeEnabled = intent.getBooleanExtra( @@ -239,6 +251,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { ACTION_RESUME -> handleResume() ACTION_STOP -> handleStop( clearContentKey = intent.getBooleanExtra(EXTRA_CLEAR_CONTENT_KEY, true), + reason = intent.getStringExtra(EXTRA_STOP_REASON) ?: "unknown", ) ACTION_SKIP_FORWARD -> handleSkip(1) ACTION_SKIP_BACK -> handleSkip(-1) @@ -306,7 +319,14 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { if (!isActiveUtterance(utteranceId)) return@post if (utteranceId != currentUtteranceId) return@post clearUtteranceRuntimeState() - recoverFromSilentPlayback("utterance_error_$errorCode") + // ERROR_SERVICE (-6) means the TTS engine process disconnected. + // Rebuild the engine immediately rather than retrying on a dead instance. + if (errorCode == TextToSpeech.ERROR_SERVICE || + errorCode == TextToSpeech.ERROR_NOT_INSTALLED_YET) { + rebuildTtsEngineForRecovery("utterance_error_$errorCode") + } else { + recoverFromSilentPlayback("utterance_error_$errorCode") + } } } }, @@ -314,8 +334,12 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { } override fun onInit(initStatus: Int) { + isRebuildingEngine = false isTtsReady = initStatus == TextToSpeech.SUCCESS if (isTtsReady) { + engineRebuildAttempt = 0 + consecutivePlaybackRecoveryFailures = 0 + currentSegmentRetry = 0 // reset retry counter after successful engine reconnect refreshAvailableVoices() applyVoiceAndSpeedSettings() if ((pendingReplayAfterInit || status == "playing") && segments.isNotEmpty()) { @@ -323,7 +347,12 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { speakCurrentSegment(forceRestart = true) } } else { - status = "idle" + if (status == "playing" || pendingReplayAfterInit || segments.isNotEmpty()) { + status = "paused" + scheduleEngineRebuild("onInit_failed_$initStatus") + } else { + status = "idle" + } } syncPowerState() syncNotificationState() @@ -375,6 +404,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { } private fun handleStartReading(intent: Intent) { + cancelIdleStop() backgroundModeEnabled = intent.getBooleanExtra( EXTRA_BACKGROUND_MODE_ENABLED, backgroundModeEnabled, @@ -414,6 +444,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { private fun handleResume() { if (segments.isEmpty()) return + cancelIdleStop() status = "playing" sessionGeneration += 1 clearUtteranceRuntimeState() @@ -424,8 +455,11 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { speakCurrentSegment(forceRestart = true) } - private fun handleStop(clearContentKey: Boolean) { + private fun handleStop(clearContentKey: Boolean, reason: String) { + Log.i(TAG, "handleStop reason=$reason clearContentKey=$clearContentKey") sessionGeneration += 1 + clearScheduledRecoveries() + cancelIdleStop() clearUtteranceRuntimeState() status = "idle" currentIndex = 0 @@ -467,12 +501,13 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { status = "idle" currentIndex = 0 completedCount += 1 + Log.i(TAG, "chapter_completed contentKey=$contentKey completedCount=$completedCount") clearUtteranceRuntimeState() abandonAudioFocus() syncPowerState() syncNotificationState() publishSnapshot() - stopSelf() + scheduleIdleStop() return } @@ -481,23 +516,35 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { } private fun handlePlaybackFailure() { - Log.e(TAG, "Playback stopped after recovery failed at index=$currentIndex contentKey=$contentKey") - status = "idle" - clearUtteranceRuntimeState() - pendingReplayAfterInit = false - abandonAudioFocus() + consecutivePlaybackRecoveryFailures += 1 + Log.e( + TAG, + "Playback failure at index=$currentIndex contentKey=$contentKey, recoveryAttempt=$consecutivePlaybackRecoveryFailures", + ) + status = "paused" + pendingReplayAfterInit = true + if (consecutivePlaybackRecoveryFailures > 12) { + // Keep trying indefinitely but avoid a tight error loop. + consecutivePlaybackRecoveryFailures = 6 + } syncPowerState() syncNotificationState() publishSnapshot() - stopSelf() + scheduleEngineRebuild("playback_failure") } private fun speakCurrentSegment(forceRestart: Boolean) { if (segments.isEmpty() || !isTtsReady) return if (!requestAudioFocus()) { - handlePlaybackFailure() + pausedByAudioFocus = true + status = "paused" + syncPowerState() + syncNotificationState() + publishSnapshot() + scheduleAudioFocusRetry() return } + clearAudioFocusRetry() val segment = segments.getOrNull(currentIndex) ?: run { handlePlaybackFailure() @@ -533,7 +580,12 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { } if (speakResult == null || speakResult == TextToSpeech.ERROR) { - recoverFromSilentPlayback("speak_error_or_null") + // speak() returning ERROR/null almost always means the TTS engine process died + // (visible in logcat as "Disconnected from TTS engine"). + // Rebuild immediately instead of burning 3 retries on a dead engine. + if (!isRebuildingEngine) { + rebuildTtsEngineForRecovery("speak_error_or_null") + } } } @@ -597,11 +649,21 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { } private fun rebuildTtsEngineForRecovery(reason: String) { + if (isRebuildingEngine) { + Log.w(TAG, "Rebuild already in progress, skipping: $reason") + return + } Log.w(TAG, "Rebuilding TextToSpeech engine for recovery: $reason") + isRebuildingEngine = true pendingReplayAfterInit = true isTtsReady = false + clearScheduledRecoveries() + // Increment session so callbacks from the dying engine are ignored + sessionGeneration += 1 + clearUtteranceRuntimeState() tts?.stop() tts?.shutdown() + tts = null setupTextToSpeech() } @@ -640,7 +702,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { } if (!isTtsReady) { - if (!pendingReplayAfterInit) { + if (!pendingReplayAfterInit && !isRebuildingEngine) { rebuildTtsEngineForRecovery("tts_not_ready") } return @@ -691,11 +753,12 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .build(), ) - .setAcceptsDelayedFocusGain(false) + .setAcceptsDelayedFocusGain(true) .setOnAudioFocusChangeListener(audioFocusListener) .build() .also { audioFocusRequest = it } - audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED + val result = audioManager.requestAudioFocus(request) + result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED } else { @Suppress("DEPRECATION") audioManager.requestAudioFocus( @@ -706,6 +769,61 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { } } + private fun scheduleEngineRebuild(reason: String) { + if (isRebuildingEngine) return + pendingEngineRebuild?.let(mainHandler::removeCallbacks) + engineRebuildAttempt += 1 + val delayMs = min(30_000L, 1_000L * engineRebuildAttempt * engineRebuildAttempt) + pendingEngineRebuild = Runnable { + if (status == "idle") return@Runnable + if (segments.isEmpty()) return@Runnable + rebuildTtsEngineForRecovery(reason) + }.also { mainHandler.postDelayed(it, delayMs) } + } + + private fun scheduleAudioFocusRetry() { + pendingAudioFocusRetry?.let(mainHandler::removeCallbacks) + audioFocusRetryAttempt += 1 + val delayMs = min(6_000L, 1_000L + (audioFocusRetryAttempt * 500L)) + pendingAudioFocusRetry = Runnable { + if (status == "idle") return@Runnable + if (!pausedByAudioFocus) return@Runnable + if (requestAudioFocus()) { + pausedByAudioFocus = false + audioFocusRetryAttempt = 0 + handleResume() + return@Runnable + } + scheduleAudioFocusRetry() + }.also { mainHandler.postDelayed(it, delayMs) } + } + + private fun clearAudioFocusRetry() { + pendingAudioFocusRetry?.let(mainHandler::removeCallbacks) + pendingAudioFocusRetry = null + audioFocusRetryAttempt = 0 + } + + private fun clearScheduledRecoveries() { + pendingEngineRebuild?.let(mainHandler::removeCallbacks) + pendingEngineRebuild = null + clearAudioFocusRetry() + } + + private fun scheduleIdleStop() { + pendingIdleStop?.let(mainHandler::removeCallbacks) + pendingIdleStop = Runnable { + if (status != "idle") return@Runnable + Log.i(TAG, "idle_timeout_stop") + stopSelf() + }.also { mainHandler.postDelayed(it, 30_000L) } + } + + private fun cancelIdleStop() { + pendingIdleStop?.let(mainHandler::removeCallbacks) + pendingIdleStop = null + } + private fun abandonAudioFocus() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { audioFocusRequest?.let(audioManager::abandonAudioFocusRequest) @@ -778,6 +896,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { this.action = action if (action == ACTION_STOP) { putExtra(EXTRA_CLEAR_CONTENT_KEY, true) + putExtra(EXTRA_STOP_REASON, STOP_REASON_USER) } }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, @@ -790,9 +909,8 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { .setContentTitle(title ?: appLabel()) .setContentText(currentProgressLabel()) .setContentIntent(buildLaunchIntent()) - .setDeleteIntent(buildServicePendingIntent(ACTION_STOP)) .setOnlyAlertOnce(true) - .setOngoing(status == "playing") + .setOngoing(status == "playing" || status == "paused") .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setCategory(NotificationCompat.CATEGORY_TRANSPORT) .addAction( @@ -828,7 +946,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { object : MediaSessionCompat.Callback() { override fun onPlay() = handleResume() override fun onPause() = handlePause() - override fun onStop() = handleStop(clearContentKey = true) + override fun onStop() = handleStop(clearContentKey = true, reason = STOP_REASON_USER) override fun onSkipToNext() = handleSkip(1) override fun onSkipToPrevious() = handleSkip(-1) }, @@ -873,11 +991,18 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { isForegroundActive = false } notificationManager.cancel(NOTIFICATION_ID) + // Even without background mode, keep foreground service alive while playing + // to prevent Android from killing us. + if (status == "playing" || status == "paused") { + val notification = buildNotification() + startForeground(NOTIFICATION_ID, notification) + isForegroundActive = true + } return } when (status) { - "playing" -> { + "playing", "paused" -> { val notification = buildNotification() if (!isForegroundActive) { startForeground(NOTIFICATION_ID, notification) @@ -886,14 +1011,6 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { notificationManager.notify(NOTIFICATION_ID, notification) } } - "paused" -> { - val notification = buildNotification() - if (isForegroundActive) { - stopForeground(false) - isForegroundActive = false - } - notificationManager.notify(NOTIFICATION_ID, notification) - } else -> { if (isForegroundActive) { stopForeground(true) @@ -964,6 +1081,9 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { override fun onDestroy() { mainHandler.removeCallbacks(playbackHealthRunnable) + isRebuildingEngine = false + clearScheduledRecoveries() + cancelIdleStop() status = "idle" currentIndex = 0 segments = emptyList() diff --git a/lib/app/app.dart b/lib/app/app.dart index 99ab8bd..0bcb5c2 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/auth/session_expiry_notifier.dart'; import '../core/theme/app_theme.dart'; import '../features/auth/providers/auth_provider.dart'; +import '../features/reader/tts/tts_service.dart'; import 'router/route_names.dart'; import 'router/app_router.dart'; @@ -21,6 +23,10 @@ class _ReaderAppState extends ConsumerState { @override void initState() { super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _ensureMandatoryTtsRequirements(); + }); + _sessionExpirySub = ref.listenManual( sessionExpiryProvider, (previous, next) async { @@ -45,6 +51,45 @@ class _ReaderAppState extends ConsumerState { ); } + Future _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( + context: context, + barrierDismissible: false, + builder: (context) { + return AlertDialog( + title: const Text('Yeu cau bat buoc cho TTS'), + content: const Text( + 'Can bat Chay nen va Loai tru toi uu pin de TTS khong bi ngat dot ngot.', + ), + actions: [ + FilledButton( + onPressed: () async { + await notifier.setBackgroundModeEnabled(true); + await notifier.ensureBatteryOptimizationIgnored(); + if (!context.mounted) return; + if (ref.read(ttsProvider).batteryOptimizationIgnored) { + Navigator.of(context).pop(); + } + }, + child: const Text('Bat ngay'), + ), + ], + ); + }, + ); + } + } + @override void dispose() { _sessionExpirySub?.close(); diff --git a/lib/core/models/reading_settings.dart b/lib/core/models/reading_settings.dart index a230043..c57b93c 100644 --- a/lib/core/models/reading_settings.dart +++ b/lib/core/models/reading_settings.dart @@ -5,9 +5,11 @@ class ReadingSettings { this.letterSpacing = 0, this.fontFamily = 'serif', this.themePreset = 'paper', + this.backgroundColorValue = 0xFFFFFEF8, + this.textColorValue = 0xFF111111, this.horizontalPadding = 20, this.paragraphSpacing = 24, - this.textAlign = 'justify', + this.textAlign = 'left', }); final double fontSize; @@ -15,6 +17,8 @@ class ReadingSettings { final double letterSpacing; final String fontFamily; final String themePreset; + final int backgroundColorValue; + final int textColorValue; final double horizontalPadding; final double paragraphSpacing; final String textAlign; @@ -25,6 +29,8 @@ class ReadingSettings { double? letterSpacing, String? fontFamily, String? themePreset, + int? backgroundColorValue, + int? textColorValue, double? horizontalPadding, double? paragraphSpacing, String? textAlign, @@ -35,6 +41,8 @@ class ReadingSettings { letterSpacing: letterSpacing ?? this.letterSpacing, fontFamily: fontFamily ?? this.fontFamily, themePreset: themePreset ?? this.themePreset, + backgroundColorValue: backgroundColorValue ?? this.backgroundColorValue, + textColorValue: textColorValue ?? this.textColorValue, horizontalPadding: horizontalPadding ?? this.horizontalPadding, paragraphSpacing: paragraphSpacing ?? this.paragraphSpacing, textAlign: textAlign ?? this.textAlign, @@ -46,9 +54,12 @@ class ReadingSettings { letterSpacing: (json['letterSpacing'] as num?)?.toDouble() ?? 0, fontFamily: json['fontFamily'] as String? ?? 'serif', themePreset: json['themePreset'] as String? ?? 'paper', + backgroundColorValue: + (json['backgroundColorValue'] as num?)?.toInt() ?? 0xFFFFFEF8, + textColorValue: (json['textColorValue'] as num?)?.toInt() ?? 0xFF111111, horizontalPadding: (json['horizontalPadding'] as num?)?.toDouble() ?? 20, paragraphSpacing: (json['paragraphSpacing'] as num?)?.toDouble() ?? 24, - textAlign: json['textAlign'] as String? ?? 'justify', + textAlign: json['textAlign'] as String? ?? 'left', ); Map toJson() => { @@ -57,6 +68,8 @@ class ReadingSettings { 'letterSpacing': letterSpacing, 'fontFamily': fontFamily, 'themePreset': themePreset, + 'backgroundColorValue': backgroundColorValue, + 'textColorValue': textColorValue, 'horizontalPadding': horizontalPadding, 'paragraphSpacing': paragraphSpacing, 'textAlign': textAlign, diff --git a/lib/core/storage/local_store.dart b/lib/core/storage/local_store.dart index 594bfc4..7895a1c 100644 --- a/lib/core/storage/local_store.dart +++ b/lib/core/storage/local_store.dart @@ -8,6 +8,8 @@ class LocalStore { static const _kLetterSpacing = 'reader_letter_spacing'; static const _kFontFamily = 'reader_font_family'; static const _kThemePreset = 'reader_theme_preset'; + static const _kBackgroundColor = 'reader_background_color'; + static const _kTextColor = 'reader_text_color'; static const _kHorizontalPadding = 'reader_horizontal_padding'; static const _kParagraphSpacing = 'reader_paragraph_spacing'; static const _kTextAlign = 'reader_text_align'; @@ -24,6 +26,8 @@ class LocalStore { await prefs.setDouble(_kLetterSpacing, settings.letterSpacing); await prefs.setString(_kFontFamily, settings.fontFamily); await prefs.setString(_kThemePreset, settings.themePreset); + await prefs.setInt(_kBackgroundColor, settings.backgroundColorValue); + await prefs.setInt(_kTextColor, settings.textColorValue); await prefs.setDouble(_kHorizontalPadding, settings.horizontalPadding); await prefs.setDouble(_kParagraphSpacing, settings.paragraphSpacing); await prefs.setString(_kTextAlign, settings.textAlign); @@ -32,15 +36,29 @@ class LocalStore { Future loadReadingSettings() async { final prefs = await SharedPreferences.getInstance(); if (!prefs.containsKey(_kFontSize)) return null; + final themePreset = prefs.getString(_kThemePreset) ?? 'paper'; + final fallbackBackground = switch (themePreset) { + 'night' => 0xFF101418, + 'sepia' => 0xFFF6EAD7, + _ => 0xFFFFFEF8, + }; + final fallbackText = switch (themePreset) { + 'night' => 0xFFE6EAF2, + 'sepia' => 0xFF3B2F23, + _ => 0xFF111111, + }; + return ReadingSettings( fontSize: prefs.getDouble(_kFontSize) ?? 18, lineHeight: prefs.getDouble(_kLineHeight) ?? 1.8, letterSpacing: prefs.getDouble(_kLetterSpacing) ?? 0, fontFamily: prefs.getString(_kFontFamily) ?? 'serif', - themePreset: prefs.getString(_kThemePreset) ?? 'paper', + themePreset: themePreset, + backgroundColorValue: prefs.getInt(_kBackgroundColor) ?? fallbackBackground, + textColorValue: prefs.getInt(_kTextColor) ?? fallbackText, horizontalPadding: prefs.getDouble(_kHorizontalPadding) ?? 20, paragraphSpacing: prefs.getDouble(_kParagraphSpacing) ?? 24, - textAlign: prefs.getString(_kTextAlign) ?? 'justify', + textAlign: prefs.getString(_kTextAlign) ?? 'left', ); } diff --git a/lib/features/bookshelf/presentation/bookshelf_screen.dart b/lib/features/bookshelf/presentation/bookshelf_screen.dart index dc31617..e8aabba 100644 --- a/lib/features/bookshelf/presentation/bookshelf_screen.dart +++ b/lib/features/bookshelf/presentation/bookshelf_screen.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import '../../../app/router/route_names.dart'; import '../../../core/models/bookmark_model.dart'; +import '../../../shared/widgets/main_app_header.dart'; import '../providers/bookshelf_provider.dart'; import '../../auth/providers/auth_provider.dart'; @@ -17,21 +18,30 @@ class BookshelfScreen extends ConsumerWidget { if (!isAuth) { return Scaffold( - appBar: AppBar(title: const Text('Tủ sách')), - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.lock_outline, size: 48), - const SizedBox(height: 12), - const Text('Vui lòng đăng nhập để xem tủ sách'), - const SizedBox(height: 16), - FilledButton( - onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(), - child: const Text('Đăng nhập bằng Google'), + body: Column( + children: [ + const MainAppHeader(title: 'Đăng truyện'), + Expanded( + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.lock_outline_rounded, size: 54), + const SizedBox(height: 12), + const Text('Vui lòng đăng nhập để xem tủ sách'), + const SizedBox(height: 16), + FilledButton( + onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(), + child: const Text('Đăng nhập bằng Google'), + ), + ], + ), + ), ), - ], - ), + ), + ], ), ); } @@ -39,53 +49,108 @@ class BookshelfScreen extends ConsumerWidget { final bookshelfAsync = ref.watch(bookshelfProvider); return Scaffold( - appBar: AppBar( - title: const Text('Tủ sách'), - actions: [ - IconButton( - icon: const Icon(Icons.refresh), - onPressed: () => ref.read(bookshelfProvider.notifier).fetch(), - ), - ], - ), - body: bookshelfAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.error_outline, size: 48), - const SizedBox(height: 8), - Text('Lỗi: $e'), - TextButton( - onPressed: () => ref.read(bookshelfProvider.notifier).fetch(), - child: const Text('Thử lại'), + body: DefaultTabController( + length: 3, + child: Column( + children: [ + MainAppHeader( + title: 'Đăng truyện', + bottom: Container( + height: 42, + decoration: BoxDecoration( + color: const Color(0xFF14B8A6), + borderRadius: BorderRadius.circular(0), + ), + child: TabBar( + indicatorColor: const Color(0xFFF7B500), + indicatorWeight: 3, + labelColor: Colors.white, + unselectedLabelColor: Colors.white70, + dividerColor: Colors.transparent, + tabs: const [ + Tab(text: 'Đã đọc'), + 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 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 Widget build(BuildContext context) { final novel = bookmark.novel; - return ListTile( - leading: ClipRRect( - borderRadius: BorderRadius.circular(6), - child: novel?.coverUrl != null - ? CachedNetworkImage( - imageUrl: novel!.coverUrl!, - width: 44, - height: 60, - fit: BoxFit.cover, - ) - : Container( - width: 44, - height: 60, - color: Theme.of(context).colorScheme.primaryContainer, - child: const Icon(Icons.menu_book, size: 20), + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(18), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: novel?.coverUrl != null + ? CachedNetworkImage( + imageUrl: novel!.coverUrl!, + width: 92, + height: 126, + fit: BoxFit.cover, + ) + : Container( + width: 92, + height: 126, + color: Theme.of(context).colorScheme.primaryContainer, + child: const Icon(Icons.menu_book, size: 28), + ), ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + novel?.title ?? bookmark.novelId, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 8), + 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)), ); } } diff --git a/lib/features/home/presentation/home_screen.dart b/lib/features/home/presentation/home_screen.dart index 95e8ae0..607fddd 100644 --- a/lib/features/home/presentation/home_screen.dart +++ b/lib/features/home/presentation/home_screen.dart @@ -1,10 +1,13 @@ +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:go_router/go_router.dart'; import '../../../app/router/route_names.dart'; import '../../../core/models/novel_model.dart'; +import '../../../shared/widgets/main_app_header.dart'; import '../providers/home_provider.dart'; class HomeScreen extends ConsumerWidget { @@ -13,62 +16,67 @@ class HomeScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final homeAsync = ref.watch(homeProvider); + final colorScheme = Theme.of(context).colorScheme; return Scaffold( - appBar: AppBar( - title: const Text('Reader'), - actions: [ - IconButton( - icon: const Icon(Icons.search), - onPressed: () => context.go(RouteNames.search), - ), - ], - ), - body: homeAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.error_outline, size: 48), - const SizedBox(height: 12), - Text('Lỗi tải dữ liệu', style: Theme.of(context).textTheme.bodyLarge), - Padding( - padding: const EdgeInsets.fromLTRB(24, 8, 24, 0), - child: Text( - e.toString(), - textAlign: TextAlign.center, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, + backgroundColor: colorScheme.surface, + body: Column( + children: [ + const MainAppHeader(), + Expanded( + child: homeAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.cloud_off_rounded, size: 52), + const SizedBox(height: 12), + Text('Không thể tải dữ liệu trang chủ'), + const SizedBox(height: 8), + Text( + e.toString(), + maxLines: 3, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + FilledButton( + onPressed: () => ref.invalidate(homeProvider), + child: const Text('Tải lại'), + ), + ], + ), ), ), - TextButton( - onPressed: () => ref.invalidate(homeProvider), - child: const Text('Thử lại'), + data: (data) => RefreshIndicator( + onRefresh: () async => ref.invalidate(homeProvider), + child: ListView( + padding: const EdgeInsets.fromLTRB(0, 12, 0, 24), + children: [ + _HotCarousel(novels: data.hot), + const SizedBox(height: 12), + const _HomeQuickFilters(), + _SectionHeader( + title: 'Truyện mới nhất', + onMore: () => context.go(RouteNames.search), + ), + _NovelHorizontalList(novels: data.latest), + _SectionHeader( + title: 'Đề cử nổi bật', + onMore: () => context.go('${RouteNames.search}?sort=rating'), + ), + _FeatureGrid(novels: data.topRated.take(6).toList()), + const SizedBox(height: 12), + ], + ), ), - ], + ), ), - ), - data: (data) => RefreshIndicator( - onRefresh: () async => ref.invalidate(homeProvider), - child: ListView( - children: [ - _HotCarousel(novels: data.hot), - _SectionHeader( - title: 'Mới cập nhật', - onMore: () => context.go(RouteNames.search), - ), - _NovelHorizontalList(novels: data.latest), - _SectionHeader( - title: 'Đánh giá cao', - onMore: () => context.go(RouteNames.search), - ), - _NovelHorizontalList(novels: data.topRated), - const SizedBox(height: 16), - ], - ), - ), + ], ), ); } @@ -82,18 +90,82 @@ class _SectionHeader extends StatelessWidget { @override Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.fromLTRB(16, 20, 8, 8), + padding: const EdgeInsets.fromLTRB(18, 18, 12, 6), child: Row( children: [ - Text(title, style: Theme.of(context).textTheme.titleMedium), + Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), const Spacer(), if (onMore != null) - TextButton(onPressed: onMore, child: const Text('Xem thêm')), + InkWell( + onTap: onMore, + borderRadius: BorderRadius.circular(999), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Row( + children: [ + Text( + 'Xem thêm', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: const Color(0xFF14B8A6), + ), + ), + const SizedBox(width: 4), + const Icon(Icons.chevron_right_rounded, color: Color(0xFF14B8A6)), + ], + ), + ), + ), ], ), ); } +class _HomeQuickFilters extends StatelessWidget { + const _HomeQuickFilters(); + + @override + Widget build(BuildContext context) { + const items = [ + (Icons.dashboard_customize_rounded, 'Thể loại'), + (Icons.verified_rounded, 'Hoàn thành'), + (Icons.sell_rounded, 'Miễn phí'), + (Icons.local_fire_department_rounded, 'Truyện hot'), + ]; + + return Padding( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 8), + child: Row( + children: items + .map( + (item) => Expanded( + child: Column( + children: [ + Icon(item.$1, color: const Color(0xFF14B8A6), size: 26), + const SizedBox(height: 6), + Text( + item.$2, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: const Color(0xFF14B8A6), + ), + ), + ], + ), + ), + ) + .toList(), + ), + ); + } +} + class _HotCarousel extends StatefulWidget { final List novels; const _HotCarousel({required this.novels}); @@ -103,10 +175,58 @@ class _HotCarousel extends StatefulWidget { } class _HotCarouselState extends State<_HotCarousel> { - final PageController _controller = PageController(viewportFraction: 0.85); + late PageController _controller; + Timer? _autoSlideTimer; + int _currentPage = 0; + + @override + void initState() { + super.initState(); + _controller = PageController(viewportFraction: 1); + _startAutoSlide(); + } + + @override + void reassemble() { + super.reassemble(); + _recreateController(); + } + + void _recreateController() { + final oldController = _controller; + final page = oldController.hasClients + ? (oldController.page?.round() ?? _currentPage) + : _currentPage; + _controller = PageController(initialPage: page, viewportFraction: 1); + oldController.dispose(); + } + + @override + void didUpdateWidget(covariant _HotCarousel oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.novels.length != widget.novels.length) { + _startAutoSlide(); + } + } + + void _startAutoSlide() { + _autoSlideTimer?.cancel(); + if (widget.novels.length <= 1) return; + + _autoSlideTimer = Timer.periodic(const Duration(seconds: 4), (_) { + if (!mounted || !_controller.hasClients) return; + final nextPage = (_currentPage + 1) % widget.novels.length; + _controller.animateToPage( + nextPage, + duration: const Duration(milliseconds: 360), + curve: Curves.easeInOut, + ); + }); + } @override void dispose() { + _autoSlideTimer?.cancel(); _controller.dispose(); super.dispose(); } @@ -115,17 +235,49 @@ class _HotCarouselState extends State<_HotCarousel> { Widget build(BuildContext context) { if (widget.novels.isEmpty) return const SizedBox.shrink(); return SizedBox( - height: 220, - child: PageView.builder( - controller: _controller, - itemCount: widget.novels.length, - itemBuilder: (context, index) { - final novel = widget.novels[index]; - return GestureDetector( - onTap: () => context.push(RouteNames.novelDetail(novel.id)), - child: _CarouselCard(novel: novel), - ); - }, + height: 260, + child: Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: ClipRect( + child: PageView.builder( + controller: _controller, + itemCount: widget.novels.length, + onPageChanged: (value) => setState(() => _currentPage = value), + itemBuilder: (context, index) { + final novel = widget.novels[index]; + return GestureDetector( + onTap: () => context.push(RouteNames.novelDetail(novel.id)), + child: _CarouselCard(novel: novel), + ); + }, + ), + ), + ), + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(widget.novels.length.clamp(0, 5), (index) { + final active = index == _currentPage.clamp(0, 4); + return AnimatedContainer( + duration: const Duration(milliseconds: 180), + margin: const EdgeInsets.symmetric(horizontal: 3), + width: active ? 16 : 7, + height: 7, + decoration: BoxDecoration( + color: active ? const Color(0xFF14B8A6) : Colors.white54, + borderRadius: BorderRadius.circular(99), + ), + ); + }), + ), + ], ), ); } @@ -137,52 +289,76 @@ class _CarouselCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 12), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Stack( - fit: StackFit.expand, - children: [ - if (novel.coverUrl != null) - CachedNetworkImage( - imageUrl: novel.coverUrl!, - fit: BoxFit.cover, - placeholder: (_, imageUrl) => Container(color: Colors.grey[200]), - errorWidget: (_, imageUrl, error) => - Container(color: Colors.grey[300]), - ) - else - Container(color: Theme.of(context).colorScheme.primaryContainer), - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, Colors.black.withAlpha(180)], - ), - ), + return Stack( + fit: StackFit.expand, + children: [ + if (novel.coverUrl != null) + CachedNetworkImage( + imageUrl: novel.coverUrl!, + fit: BoxFit.cover, + placeholder: (_, imageUrl) => Container(color: Colors.grey[200]), + errorWidget: (_, imageUrl, error) => Container(color: Colors.grey[300]), + ) + else + Container(color: Theme.of(context).colorScheme.primaryContainer), + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black.withAlpha(180)], ), ), - Positioned( - bottom: 12, - left: 12, - right: 12, - child: Text( + ), + ), + Positioned( + bottom: 12, + left: 12, + right: 12, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (novel.status.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF22C55E), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + novel.status, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 10), + Text( novel.title, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, - fontSize: 16, + fontSize: 20, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), - ), - ], + const SizedBox(height: 4), + Text( + novel.description?.trim().isNotEmpty == true + ? novel.description!.trim() + : novel.authorName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white70, fontStyle: FontStyle.italic), + ), + ], + ), ), - ), + ], ); } } @@ -194,9 +370,9 @@ class _NovelHorizontalList extends StatelessWidget { @override Widget build(BuildContext context) { return SizedBox( - height: 200, + height: 226, child: ListView.separated( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 18), scrollDirection: Axis.horizontal, itemCount: novels.length, separatorBuilder: (_, separatorIndex) => const SizedBox(width: 12), @@ -205,32 +381,45 @@ class _NovelHorizontalList extends StatelessWidget { return GestureDetector( onTap: () => context.push(RouteNames.novelDetail(novel.id)), child: SizedBox( - width: 110, + width: 122, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(10), child: novel.coverUrl != null ? CachedNetworkImage( imageUrl: novel.coverUrl!, - width: 110, - height: 150, + width: 122, + height: 155, fit: BoxFit.cover, ) : Container( - width: 110, - height: 150, + width: 122, + height: 155, color: Theme.of(context).colorScheme.primaryContainer, child: const Icon(Icons.menu_book), ), ), - const SizedBox(height: 4), + const SizedBox(height: 6), + Flexible( + child: Text( + novel.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(height: 3), Text( - novel.title, - maxLines: 2, + '${novel.totalChapters} chương', + maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: const Color(0xFF58D68D), + ), ), ], ), @@ -241,3 +430,77 @@ class _NovelHorizontalList extends StatelessWidget { ); } } + +class _FeatureGrid extends StatelessWidget { + const _FeatureGrid({required this.novels}); + + final List 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), + ), + ), + ], + ), + ); + }, + ), + ); + } +} + diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index 256a786..b087792 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../app/router/route_names.dart'; +import '../../../shared/widgets/main_app_header.dart'; import '../../auth/providers/auth_provider.dart'; import '../../bookshelf/providers/bookshelf_provider.dart'; @@ -22,151 +23,180 @@ class ProfileScreen extends ConsumerWidget { : ''; return Scaffold( - appBar: AppBar(title: const Text('Tài khoản')), - body: switch (authState) { - AuthAuthenticated(:final user) => SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - // User Avatar & Basic Info - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), + body: Column( + children: [ + const MainAppHeader(title: 'Trang cá nhân', showGenresShortcut: false), + Expanded( + child: switch (authState) { + AuthAuthenticated(:final user) => SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( - radius: 40, - backgroundImage: - user.image != null ? NetworkImage(user.image!) : null, - child: user.image == null - ? Text( - displayName[0].toUpperCase(), - style: - Theme.of(context).textTheme.headlineMedium, - ) - : null, + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(22), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 34, + backgroundImage: + user.image != null ? NetworkImage(user.image!) : null, + child: user.image == null + ? Text( + displayName.isNotEmpty ? displayName[0].toUpperCase() : 'U', + style: Theme.of(context).textTheme.headlineMedium, + ) + : null, + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayName, + style: Theme.of(context).textTheme.headlineSmall, + ), + Text( + user.role.toLowerCase(), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 10), + _AccountStatRow( + icon: Icons.auto_awesome, + label: 'Tiên Thạch: 0.00 TT', + ), + const SizedBox(height: 4), + _AccountStatRow( + icon: Icons.diamond, + label: 'Linh Phiếu: 0 LP', + ), + const SizedBox(height: 4), + _AccountStatRow( + icon: Icons.local_activity, + label: 'Ngọc Phiếu: $bookmarkedCount', + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: () {}, + icon: const Icon(Icons.workspace_premium_rounded), + label: const Text('Thêm Tiên Thạch'), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF14B8A6), + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ], + ), ), - const SizedBox(height: 12), - Text( - displayName, - style: Theme.of(context).textTheme.titleLarge, - textAlign: TextAlign.center, + const SizedBox(height: 18), + _ProfileMenuTile( + title: 'Chỉnh sửa thông tin', + onTap: () => context.push(RouteNames.settings), ), - const SizedBox(height: 4), - Text( - user.email, - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, + _ProfileMenuTile( + title: 'Lịch sử giao dịch', + onTap: () {}, + ), + _ProfileMenuTile( + title: 'Liên hệ, báo lỗi', + onTap: () {}, + ), + _ProfileMenuTile( + title: 'Điều khoản dịch vụ', + onTap: () {}, + ), + _ProfileMenuTile( + title: 'Xóa tài khoản', + onTap: () {}, + ), + _ProfileMenuTile( + title: 'Đăng xuất', + onTap: () async { + await ref.read(authProvider.notifier).signOut(); + if (context.mounted) context.go(RouteNames.home); + }, ), ], ), ), - const SizedBox(height: 24), - - // Stats Cards - Row( - children: [ - Expanded( - child: _buildStatCard( - context: context, - label: 'Sách Đánh Dấu', - count: bookmarkedCount, - ), + AuthError(:final message) => Center(child: Text(message)), + AuthUnauthenticated() => Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FilledButton( + onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(), + child: const Text('Đăng nhập bằng Google'), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: () => context.push(RouteNames.settings), + icon: const Icon(Icons.tune), + label: const Text('Mở Cài Đặt Đọc'), + ), + ], ), - const SizedBox(width: 12), - Expanded( - child: _buildStatCard( - context: context, - label: 'Đang Đọc', - count: bookmarkedCount, - ), - ), - ], - ), - const SizedBox(height: 24), - - // Settings Button - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: () => context.push(RouteNames.settings), - icon: const Icon(Icons.tune), - label: const Text('Cài Đặt Đọc'), ), ), - const SizedBox(height: 12), - - // Logout Button - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () async { - await ref.read(authProvider.notifier).signOut(); - if (context.mounted) context.go(RouteNames.home); - }, - icon: const Icon(Icons.logout), - label: const Text('Đăng Xuất'), - ), - ), - ], - ), - ), - AuthError(:final message) => Center(child: Text(message)), - AuthUnauthenticated() => Center( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - FilledButton( - onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(), - child: const Text('Đăng nhập bằng Google'), - ), - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: () => context.push(RouteNames.settings), - icon: const Icon(Icons.tune), - label: const Text('Mở Cài Đặt Đọc'), - ), - ], - ), - ), - ), - _ => const Center(child: CircularProgressIndicator()), - }, - ); - } - - Widget _buildStatCard({ - required BuildContext context, - required String label, - required int count, - }) { - return Container( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Column( - children: [ - Text( - count.toString(), - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(height: 4), - Text( - label, - style: Theme.of(context).textTheme.bodySmall, - textAlign: TextAlign.center, + _ => const Center(child: CircularProgressIndicator()), + }, ), ], ), ); } } + +class _AccountStatRow extends StatelessWidget { + const _AccountStatRow({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, size: 18, color: const Color(0xFF58D68D)), + const SizedBox(width: 8), + Expanded(child: Text(label, style: Theme.of(context).textTheme.titleMedium)), + ], + ); + } +} + +class _ProfileMenuTile extends StatelessWidget { + const _ProfileMenuTile({required this.title, required this.onTap}); + + final String title; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(14), + ), + child: ListTile( + title: Text(title), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: onTap, + ), + ); + } +} diff --git a/lib/features/reader/presentation/reader_screen.dart b/lib/features/reader/presentation/reader_screen.dart index 5585018..421dd46 100644 --- a/lib/features/reader/presentation/reader_screen.dart +++ b/lib/features/reader/presentation/reader_screen.dart @@ -26,6 +26,22 @@ class ReaderScreen extends ConsumerStatefulWidget { } class _ReaderScreenState extends ConsumerState { + static const List _backgroundColorChoices = [ + Color(0xFFFFFEF8), + Color(0xFFF6EAD7), + Color(0xFF101418), + Color(0xFFF3F7FF), + Color(0xFFF6FFF5), + ]; + + static const List _textColorChoices = [ + Color(0xFF111111), + Color(0xFF2C1E12), + Color(0xFFE6EAF2), + Color(0xFF1F2A44), + Color(0xFF0F5132), + ]; + final ScrollController _scrollCtrl = ScrollController(); Timer? _uiAutoHideTimer; final ValueNotifier _readingProgress = ValueNotifier(0); @@ -67,7 +83,7 @@ class _ReaderScreenState extends ConsumerState { case 'center': return TextAlign.center; default: - return TextAlign.justify; + return TextAlign.left; } } @@ -374,23 +390,6 @@ class _ReaderScreenState extends ConsumerState { }); } - void _handleHorizontalSwipeEnd(DragEndDetails details, ChapterModel chapter) { - final velocity = details.primaryVelocity ?? 0; - const minVelocity = 300.0; - - if (velocity.abs() < minVelocity) return; - - // Swipe right -> previous chapter; swipe left -> next chapter - if (velocity > 0 && chapter.prevChapterId != null) { - _goToPreviousChapter(chapter); - return; - } - - if (velocity < 0 && chapter.nextChapterId != null) { - _goToNextChapter(chapter); - } - } - void _goToPreviousChapter(ChapterModel chapter) { final prevId = chapter.prevChapterId; if (prevId == null) return; @@ -555,6 +554,9 @@ class _ReaderScreenState extends ConsumerState { final settings = ref.watch(readingSettingsProvider); final tts = ref.watch(ttsProvider); final ttsNotifier = ref.read(ttsProvider.notifier); + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isCompactTabs = MediaQuery.sizeOf(context).width < 380; void closeSettingsSheet() { if (!sheetContext.mounted) return; @@ -590,11 +592,6 @@ class _ReaderScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Tùy chỉnh đọc', style: Theme.of(context).textTheme.headlineSmall), - const SizedBox(height: 4), - Text( - 'Tùy chỉnh văn bản, giao diện, bố cục và TTS ngay trong chương', - style: Theme.of(context).textTheme.bodySmall, - ), ], ), ), @@ -617,23 +614,65 @@ class _ReaderScreenState extends ConsumerState { children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), - child: TabBar( - isScrollable: true, - tabAlignment: TabAlignment.start, - dividerColor: Colors.transparent, - labelPadding: const EdgeInsets.only(right: 8), - indicator: BoxDecoration( - color: Theme.of(context).colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(999), + child: Container( + height: 52, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withAlpha(180), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: colorScheme.outlineVariant.withAlpha(160), + ), + ), + child: TabBar( + isScrollable: false, + dividerColor: Colors.transparent, + padding: EdgeInsets.zero, + labelPadding: EdgeInsets.zero, + indicatorSize: TabBarIndicatorSize.tab, + splashBorderRadius: BorderRadius.circular(18), + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return colorScheme.primary.withAlpha(18); + } + return null; + }), + indicator: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(18), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(16), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + labelColor: colorScheme.onSurface, + unselectedLabelColor: colorScheme.onSurfaceVariant, + tabs: [ + _TabLabel( + icon: Icons.text_fields_rounded, + label: 'Văn bản', + compact: isCompactTabs, + ), + _TabLabel( + icon: Icons.palette_outlined, + label: 'Giao diện', + compact: isCompactTabs, + ), + _TabLabel( + icon: Icons.tune_rounded, + label: 'Bố cục', + compact: isCompactTabs, + ), + _TabLabel( + icon: Icons.record_voice_over_outlined, + label: 'TTS', + compact: isCompactTabs, + ), + ], ), - labelColor: Theme.of(context).colorScheme.onSecondaryContainer, - unselectedLabelColor: Theme.of(context).colorScheme.onSurfaceVariant, - tabs: const [ - Tab(child: _TabLabel(icon: Icons.text_fields, label: 'Văn bản')), - Tab(child: _TabLabel(icon: Icons.palette_outlined, label: 'Giao diện')), - Tab(child: _TabLabel(icon: Icons.view_day_outlined, label: 'Bố cục')), - Tab(child: _TabLabel(icon: Icons.record_voice_over_outlined, label: 'TTS')), - ], ), ), Expanded( @@ -656,8 +695,14 @@ class _ReaderScreenState extends ConsumerState { ButtonSegment(value: 'sans', label: Text('Không chân')), ButtonSegment(value: 'mono', label: Text('Đơn cách')), ], - selected: {settings.fontFamily}, - onSelectionChanged: (s) => update(settings.copyWith(fontFamily: s.first)), + selected: { + {'serif', 'sans', 'mono'}.contains(settings.fontFamily) + ? settings.fontFamily + : 'serif', + }, + onSelectionChanged: (s) => update( + settings.copyWith(fontFamily: s.first), + ), ), const SizedBox(height: 12), _LabeledSlider( @@ -700,62 +745,45 @@ class _ReaderScreenState extends ConsumerState { children: [ _SettingsSection( title: 'Giao diện đọc', - child: Wrap( - spacing: 12, - runSpacing: 12, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _PresetChip( - label: 'Sáng', - value: 'paper', - selected: settings.themePreset == 'paper', - onTap: () => update(settings.copyWith(themePreset: 'paper')), + Text( + 'Màu nền', + style: Theme.of(context).textTheme.labelLarge, ), - _PresetChip( - label: 'Sepia', - value: 'sepia', - selected: settings.themePreset == 'sepia', - onTap: () => update(settings.copyWith(themePreset: 'sepia')), + const SizedBox(height: 8), + Wrap( + spacing: 10, + runSpacing: 10, + children: _backgroundColorChoices.map((color) { + return _ColorOptionChip( + color: color, + selected: settings.backgroundColorValue == color.value, + onTap: () => update( + settings.copyWith(backgroundColorValue: color.value), + ), + ); + }).toList(), ), - _PresetChip( - label: 'Ban đêm', - value: 'night', - selected: settings.themePreset == 'night', - onTap: () => update(settings.copyWith(themePreset: 'night')), + const SizedBox(height: 14), + Text( + 'Màu chữ', + style: Theme.of(context).textTheme.labelLarge, ), - ], - ), - ), - _SettingsSection( - title: 'Mẫu nhanh', - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - FilledButton.tonal( - onPressed: () => update(const ReadingSettings()), - child: const Text('Mặc định'), - ), - FilledButton.tonal( - onPressed: () => update( - settings.copyWith( - themePreset: 'night', - fontSize: 19, - lineHeight: 1.9, - textAlign: 'justify', - ), - ), - child: const Text('Đọc đêm'), - ), - FilledButton.tonal( - onPressed: () => update( - settings.copyWith( - themePreset: 'sepia', - fontSize: 18, - lineHeight: 1.8, - textAlign: 'justify', - ), - ), - child: const Text('Thư giãn'), + const SizedBox(height: 8), + Wrap( + spacing: 10, + runSpacing: 10, + children: _textColorChoices.map((color) { + return _ColorOptionChip( + color: color, + selected: settings.textColorValue == color.value, + onTap: () => update( + settings.copyWith(textColorValue: color.value), + ), + ); + }).toList(), ), ], ), @@ -854,32 +882,84 @@ class _ReaderScreenState extends ConsumerState { }).toList(), ), const SizedBox(height: 12), - SwitchListTile.adaptive( - contentPadding: EdgeInsets.zero, - title: const Text('Chạy nền cho TTS'), - subtitle: const Text( - 'Tiếp tục đọc khi chuyển app hoặc tắt màn hình (Android)', - ), - value: tts.backgroundModeEnabled, - onChanged: ttsNotifier.setBackgroundModeEnabled, - ), - if (tts.backgroundModeEnabled) - ListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Loại trừ tối ưu pin'), - subtitle: Text( - tts.batteryOptimizationIgnored - ? 'Đã bật: Android sẽ ít khả năng dừng TTS khi chạy nền.' - : 'Nên bật để Android không chặn TTS khi tắt màn hình.', + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .secondaryContainer + .withAlpha(90), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, ), - trailing: tts.batteryOptimizationIgnored - ? const Icon(Icons.verified, color: Colors.green) - : OutlinedButton( - onPressed: ttsNotifier - .ensureBatteryOptimizationIgnored, - child: const Text('Bật ngay'), - ), ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Điều kiện bắt buộc để TTS chạy ổn định', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + Row( + children: [ + Icon( + tts.backgroundModeEnabled + ? Icons.check_circle + : Icons.radio_button_unchecked, + size: 18, + color: tts.backgroundModeEnabled + ? Colors.green + : Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'Bật chạy nền cho TTS', + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + Icon( + tts.batteryOptimizationIgnored + ? Icons.check_circle + : Icons.radio_button_unchecked, + size: 18, + color: tts.batteryOptimizationIgnored + ? Colors.green + : Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'Loại trừ tối ưu pin', + ), + ), + ], + ), + const SizedBox(height: 10), + Align( + alignment: Alignment.centerRight, + child: OutlinedButton( + onPressed: () async { + await ttsNotifier.setBackgroundModeEnabled(true); + await ttsNotifier.ensureBatteryOptimizationIgnored(); + }, + child: const Text('Bật ngay'), + ), + ), + ], + ), + ), const SizedBox(height: 12), if (tts.availableVietnameseVoices.isNotEmpty) DropdownButtonFormField( @@ -951,24 +1031,9 @@ class _ReaderScreenState extends ConsumerState { // Side-effects for TTS state changes (navigation, auto-start). ref.listen(ttsProvider, _onTtsStateChanged); - Color readerBackground; - Color readerTextColor; - Color readerMutedColor; - - switch (settings.themePreset) { - case 'night': - readerBackground = const Color(0xFF101418); - readerTextColor = const Color(0xFFE6EAF2); - readerMutedColor = const Color(0xFFA5B0C5); - case 'sepia': - readerBackground = const Color(0xFFF6EAD7); - readerTextColor = const Color(0xFF3B2F23); - readerMutedColor = const Color(0xFF7A6753); - default: - readerBackground = const Color(0xFFFFFEF8); - readerTextColor = const Color(0xFF111111); - readerMutedColor = const Color(0xFF555555); - } + final readerBackground = Color(settings.backgroundColorValue); + final readerTextColor = Color(settings.textColorValue); + final readerMutedColor = readerTextColor.withAlpha(170); return Scaffold( body: chapterAsync.when( @@ -1002,11 +1067,7 @@ class _ReaderScreenState extends ConsumerState { fontSize: settings.fontSize, height: settings.lineHeight, letterSpacing: settings.letterSpacing, - fontFamily: settings.fontFamily == 'serif' - ? 'Georgia' - : settings.fontFamily == 'mono' - ? 'Courier' - : null, + fontFamily: _resolveReaderFontFamily(settings.fontFamily), ); final paragraphHighlightStyle = paragraphStyle.copyWith( backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(80), @@ -1019,13 +1080,9 @@ class _ReaderScreenState extends ConsumerState { _initializeChapterSession(chapter); }); - return GestureDetector( - behavior: HitTestBehavior.opaque, - onHorizontalDragEnd: (details) => - _handleHorizontalSwipeEnd(details, chapter), - child: ColoredBox( - color: readerBackground, - child: Column( + return ColoredBox( + color: readerBackground, + child: Column( children: [ ValueListenableBuilder( valueListenable: _readingProgress, @@ -1202,7 +1259,6 @@ class _ReaderScreenState extends ConsumerState { ), ], ), - ), ); }, ), @@ -1360,25 +1416,18 @@ class _TopBar extends StatelessWidget { } } -Color _surfaceForPreset(String preset) { - switch (preset) { - case 'night': - return const Color(0xFF101418); - case 'sepia': - return const Color(0xFFF6EAD7); +String? _resolveReaderFontFamily(String fontFamily) { + switch (fontFamily) { + case 'serif': + case 'georgia': + return 'Georgia'; + case 'mono': + return 'Courier'; + case 'roboto': + return 'Roboto'; + case 'sans': default: - return const Color(0xFFFFFEF8); - } -} - -Color _textForPreset(String preset) { - switch (preset) { - case 'night': - return const Color(0xFFE6EAF2); - case 'sepia': - return const Color(0xFF3B2F23); - default: - return const Color(0xFF111111); + return null; } } @@ -1455,16 +1504,14 @@ class _LabeledSlider extends StatelessWidget { } } -class _PresetChip extends StatelessWidget { - const _PresetChip({ - required this.label, - required this.value, +class _ColorOptionChip extends StatelessWidget { + const _ColorOptionChip({ + required this.color, required this.selected, required this.onTap, }); - final String label; - final String value; + final Color color; final bool selected; final VoidCallback onTap; @@ -1472,59 +1519,29 @@ class _PresetChip extends StatelessWidget { Widget build(BuildContext context) { return InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(18), - child: Container( - width: 132, - padding: const EdgeInsets.all(12), + borderRadius: BorderRadius.circular(999), + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + width: 34, + height: 34, decoration: BoxDecoration( - color: _surfaceForPreset(value), - borderRadius: BorderRadius.circular(18), + shape: BoxShape.circle, + color: color, border: Border.all( color: selected ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.outlineVariant, - width: selected ? 2 : 1, + width: selected ? 3 : 1, ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - height: 60, - decoration: BoxDecoration( - color: _surfaceForPreset(value), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: _textForPreset(value).withAlpha(40)), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Aa', - style: TextStyle( - color: _textForPreset(value), - fontWeight: FontWeight.w700, - fontSize: 18, - ), - ), - const Spacer(), - Container( - height: 3, - width: 54, - decoration: BoxDecoration( - color: _textForPreset(value).withAlpha(110), - borderRadius: BorderRadius.circular(99), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 8), - Text(label, style: Theme.of(context).textTheme.labelLarge), - ], + boxShadow: selected + ? [ + BoxShadow( + color: Theme.of(context).colorScheme.primary.withAlpha(60), + blurRadius: 8, + spreadRadius: 1, + ), + ] + : null, ), ), ); @@ -1532,20 +1549,50 @@ class _PresetChip extends StatelessWidget { } class _TabLabel extends StatelessWidget { - const _TabLabel({required this.icon, required this.label}); + const _TabLabel({ + required this.icon, + required this.label, + this.compact = false, + }); final IconData icon; final String label; + final bool compact; @override Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16), - const SizedBox(width: 6), - Text(label), - ], + final textStyle = Theme.of(context).textTheme.labelLarge?.copyWith( + fontSize: compact ? 12.5 : 13.5, + fontWeight: FontWeight.w600, + letterSpacing: -0.1, + ); + + return SizedBox( + height: double.infinity, + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!compact) ...[ + Icon(icon, size: 16), + const SizedBox(width: 6), + ], + Text( + label, + maxLines: 1, + overflow: TextOverflow.fade, + softWrap: false, + style: textStyle, + ), + ], + ), + ), + ), + ), ); } } diff --git a/lib/features/reader/providers/reader_provider.dart b/lib/features/reader/providers/reader_provider.dart index c1cb67b..442005f 100644 --- a/lib/features/reader/providers/reader_provider.dart +++ b/lib/features/reader/providers/reader_provider.dart @@ -103,14 +103,20 @@ class ReaderNotifier extends StateNotifier { } catch (_) {} } - DateTime? _lastUpdate; - Future _debounceUpdate(double offset) async { - final now = DateTime.now(); - if (_lastUpdate != null && now.difference(_lastUpdate!).inSeconds < 3) return; - _lastUpdate = now; - if (state != null) { - await _persistProgress(state!.chapterId, state!.chapterNumber, offset); - } + Timer? _debounceTimer; + void _debounceUpdate(double offset) { + _debounceTimer?.cancel(); + _debounceTimer = Timer(const Duration(seconds: 3), () { + if (state != null) { + unawaited(_persistProgress(state!.chapterId, state!.chapterNumber, offset)); + } + }); + } + + @override + void dispose() { + _debounceTimer?.cancel(); + super.dispose(); } } diff --git a/lib/features/reader/providers/tts_provider.dart b/lib/features/reader/providers/tts_provider.dart new file mode 100644 index 0000000..b1b4895 --- /dev/null +++ b/lib/features/reader/providers/tts_provider.dart @@ -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 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? 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 { + late FlutterTts _tts; + + @override + TtsState build() { + _tts = FlutterTts(); + _initTts(); + ref.onDispose(() async { + await _tts.stop(); + }); + return const TtsState(); + } + + Future _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 _speakCurrent() async { + if (state.sentences.isEmpty) return; + if (state.status != TtsStatus.playing) return; + final sentence = state.sentences[state.currentSentenceIndex]; + await _tts.speak(sentence); + } + + Future play(List sentences) async { + await _tts.stop(); + state = state.copyWith( + sentences: sentences, + currentSentenceIndex: 0, + status: TtsStatus.playing, + ); + await _speakCurrent(); + } + + Future pause() async { + state = state.copyWith(status: TtsStatus.paused); + await _tts.pause(); + } + + Future resume() async { + state = state.copyWith(status: TtsStatus.playing); + await _speakCurrent(); + } + + Future stop() async { + state = state.copyWith(status: TtsStatus.stopped, currentSentenceIndex: 0); + await _tts.stop(); + } + + Future setSpeechRate(double rate) async { + await _tts.setSpeechRate(rate); + state = state.copyWith(speechRate: rate); + } +} + +final ttsProvider = NotifierProvider(TtsNotifier.new); diff --git a/lib/shared/widgets/app_shell.dart b/lib/shared/widgets/app_shell.dart index 516b790..8319aea 100644 --- a/lib/shared/widgets/app_shell.dart +++ b/lib/shared/widgets/app_shell.dart @@ -8,44 +8,122 @@ class AppShell extends StatelessWidget { final Widget child; - int _indexForLocation(String location) { - if (location.startsWith(RouteNames.search)) return 1; - if (location.startsWith(RouteNames.bookshelf)) return 2; - if (location.startsWith(RouteNames.genres)) return 3; - if (location.startsWith(RouteNames.profile)) return 4; - return 0; + String _tabForLocation(String location) { + if (location.startsWith(RouteNames.bookshelf)) return RouteNames.bookshelf; + if (location.startsWith(RouteNames.genres)) return RouteNames.genres; + if (location.startsWith(RouteNames.profile)) return RouteNames.profile; + return RouteNames.home; } @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; final location = GoRouterState.of(context).uri.path; - final selectedIndex = _indexForLocation(location); + final selectedTab = _tabForLocation(location); return Scaffold( body: child, - bottomNavigationBar: NavigationBar( - selectedIndex: selectedIndex, - onDestinationSelected: (index) { - switch (index) { - case 0: - context.go(RouteNames.home); - case 1: - context.go(RouteNames.search); - case 2: - context.go(RouteNames.bookshelf); - case 3: - context.go(RouteNames.genres); - case 4: - context.go(RouteNames.profile); - } - }, - destinations: const [ - NavigationDestination(icon: Icon(Icons.home_outlined), label: 'Home'), - NavigationDestination(icon: Icon(Icons.search), label: 'Tim kiem'), - NavigationDestination(icon: Icon(Icons.bookmark_border), label: 'Tu sach'), - NavigationDestination(icon: Icon(Icons.category_outlined), label: 'The loai'), - NavigationDestination(icon: Icon(Icons.person_outline), label: 'Tai khoan'), - ], + bottomNavigationBar: Container( + decoration: BoxDecoration( + color: colorScheme.surface, + border: Border( + top: BorderSide(color: colorScheme.outlineVariant.withAlpha(80)), + ), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 8, 10, 6), + child: Row( + children: [ + _ShellNavItem( + icon: Icons.home_rounded, + label: 'Trang chủ', + selected: selectedTab == RouteNames.home, + onTap: () => context.go(RouteNames.home), + ), + _ShellNavItem( + icon: Icons.layers_rounded, + label: 'Tủ sách', + selected: selectedTab == RouteNames.bookshelf, + onTap: () => context.go(RouteNames.bookshelf), + ), + _ShellNavItem( + icon: Icons.category_rounded, + label: 'Thể loại', + selected: selectedTab == RouteNames.genres, + onTap: () => context.go(RouteNames.genres), + ), + _ShellNavItem( + icon: Icons.person_rounded, + label: 'Tài khoản', + selected: selectedTab == RouteNames.profile, + onTap: () => context.go(RouteNames.profile), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _ShellNavItem extends StatelessWidget { + const _ShellNavItem({ + required this.icon, + required this.label, + required this.selected, + required this.onTap, + }); + + final IconData icon; + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final activeColor = const Color(0xFF14B8A6); + final inactiveColor = colorScheme.onSurfaceVariant; + + return Expanded( + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(18), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: selected ? activeColor.withAlpha(28) : Colors.transparent, + shape: BoxShape.circle, + ), + child: Icon( + icon, + size: 22, + color: selected ? activeColor : inactiveColor, + ), + ), + const SizedBox(height: 4), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: selected ? const Color(0xFFF7B500) : inactiveColor, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ], + ), + ), ), ); } diff --git a/lib/shared/widgets/main_app_header.dart b/lib/shared/widgets/main_app_header.dart new file mode 100644 index 0000000..55fb111 --- /dev/null +++ b/lib/shared/widgets/main_app_header.dart @@ -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!, + ], + ], + ), + ), + ); + } +} + + + + + diff --git a/pubspec.yaml b/pubspec.yaml index d3d93fe..fe63684 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.1+2 +version: 1.0.2+3 environment: sdk: ^3.11.3 @@ -78,6 +78,9 @@ flutter: # the material Icons class. uses-material-design: true + assets: + - assets/app_icon.png + # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg