feat: Enhance TTS player functionality and UI

- Added resume functionality to TTS player when paused.
- Display voice name or language in TTS player UI.
- Improved error handling in reader provider with debug messages.
- Updated TTS service to configure Vietnamese voice and handle platform-specific audio settings.
- Removed wakelock dependency and related code.
- Fixed search screen error handling.
- Updated settings screen to navigate to home after sign out.
- Improved splash screen with timer management.
- Enhanced main app error handling with logging.
- Removed unused package_info_plus and wakelock_plus dependencies.
- Added environment variable support for mobile runtime.
- Integrated Google Sign-In configuration for Android.
- Created logging observer for Riverpod providers.
- Added scripts for environment setup and Google Sign-In validation.
This commit is contained in:
2026-03-30 11:38:04 +07:00
parent 8da9c4152c
commit 1afff18f4d
40 changed files with 1735 additions and 312 deletions
@@ -5,11 +5,28 @@ import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart';
import '../providers/auth_provider.dart';
class LoginScreen extends ConsumerWidget {
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
bool _startedSignIn = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _startedSignIn) return;
_startedSignIn = true;
ref.read(authProvider.notifier).signInWithGoogle();
});
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
ref.listen<AuthState>(authProvider, (_, next) {
@@ -42,6 +59,16 @@ class LoginScreen extends ConsumerWidget {
Text(errorMsg, style: const TextStyle(color: Colors.red)),
const SizedBox(height: 16),
],
if (authState is AuthLoading) ...[
const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(height: 12),
const Text('Đang mở Google Sign-In...'),
const SizedBox(height: 20),
],
FilledButton.icon(
onPressed: isLoading
? null
+43 -6
View File
@@ -1,6 +1,8 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:flutter/services.dart';
import '../../../core/config/app_config.dart';
import '../../../core/models/user_model.dart';
@@ -38,10 +40,26 @@ class AuthNotifier extends StateNotifier<AuthState> {
SecureStore get _store => _ref.read(secureStoreProvider);
final GoogleSignIn _googleSignIn = GoogleSignIn(
clientId: AppConfig.googleClientId.isNotEmpty ? AppConfig.googleClientId : null,
scopes: ['email', 'profile'],
);
GoogleSignIn get _googleSignIn => GoogleSignIn(
// clientId should be set for iOS/web only. Android reads from google-services.json.
clientId: (!kIsWeb && defaultTargetPlatform == TargetPlatform.android)
? null
: (AppConfig.googleClientId.isNotEmpty ? AppConfig.googleClientId : null),
// ID token for backend verification typically requires a Web OAuth client id.
serverClientId: AppConfig.googleServerClientId.isNotEmpty
? AppConfig.googleServerClientId
: (AppConfig.googleClientId.isNotEmpty ? AppConfig.googleClientId : null),
scopes: ['email', 'profile'],
);
void _logGoogleSignInConfig() {
final isAndroid = !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
debugPrint(
'[AUTH][GOOGLE][CONFIG] platform=${isAndroid ? 'android' : (kIsWeb ? 'web' : defaultTargetPlatform.name)} '
'clientId=${isAndroid ? '<android-default>' : (AppConfig.googleClientId.isNotEmpty ? AppConfig.googleClientId : '<empty>')} '
'serverClientId=${AppConfig.googleServerClientId.isNotEmpty ? AppConfig.googleServerClientId : (AppConfig.googleClientId.isNotEmpty ? AppConfig.googleClientId : '<empty>')}',
);
}
Future<void> _restore() async {
final token = await _store.getAccessToken();
@@ -66,6 +84,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
Future<void> signInWithGoogle() async {
try {
state = AuthLoading();
_logGoogleSignInConfig();
final account = await _googleSignIn.signIn();
if (account == null) {
state = AuthUnauthenticated();
@@ -94,10 +113,28 @@ class AuthNotifier extends StateNotifier<AuthState> {
state = AuthAuthenticated(
UserModel.fromJson(data['user'] as Map<String, dynamic>),
);
} on DioException catch (e) {
} on PlatformException catch (e, st) {
debugPrint('[AUTH][GOOGLE][ERROR] code=${e.code} message=${e.message} details=${e.details}');
debugPrintStack(stackTrace: st);
final raw = '${e.code} ${e.message ?? ''} ${e.details ?? ''}'.toLowerCase();
if (raw.contains('10') || raw.contains('developer_error')) {
state = AuthError(
'Google Sign-In lỗi cấu hình (code 10). Cần kiểm tra package name, SHA-1/SHA-256 và google-services.json cho Android.',
);
} else {
state = AuthError('Google Sign-In thất bại: ${e.message ?? e.code}');
}
} on DioException catch (e, st) {
debugPrint('[AUTH][API][ERROR] type=${e.type} message=${e.message}');
if (e.response != null) {
debugPrint('[AUTH][API][ERROR] status=${e.response?.statusCode} data=${e.response?.data}');
}
debugPrintStack(stackTrace: st);
final msg = (e.response?.data as Map?)?['error'] ?? e.message ?? 'Login failed';
state = AuthError(msg.toString());
} catch (e) {
} catch (e, st) {
debugPrint('[AUTH][UNEXPECTED][ERROR] $e');
debugPrintStack(stackTrace: st);
state = AuthError(e.toString());
}
}
@@ -27,8 +27,8 @@ class BookshelfScreen extends ConsumerWidget {
const Text('Vui lòng đăng nhập để xem tủ sách'),
const SizedBox(height: 16),
FilledButton(
onPressed: () => context.push(RouteNames.login),
child: const Text('Đăng nhập'),
onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(),
child: const Text('Đăng nhập bằng Google'),
),
],
),
@@ -72,7 +72,8 @@ class _CommentsScreenState extends ConsumerState<CommentsScreen> {
return ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: comments.length,
separatorBuilder: (_, __) => const Divider(height: 1),
separatorBuilder: (_, separatorIndex) =>
const Divider(height: 1),
itemBuilder: (context, index) =>
_CommentTile(comment: comments[index]),
);
@@ -33,6 +33,16 @@ class HomeScreen extends ConsumerWidget {
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,
),
),
TextButton(
onPressed: () => ref.invalidate(homeProvider),
child: const Text('Thử lại'),
@@ -138,8 +148,9 @@ class _CarouselCard extends StatelessWidget {
CachedNetworkImage(
imageUrl: novel.coverUrl!,
fit: BoxFit.cover,
placeholder: (_, __) => Container(color: Colors.grey[200]),
errorWidget: (_, __, ___) => Container(color: Colors.grey[300]),
placeholder: (_, imageUrl) => Container(color: Colors.grey[200]),
errorWidget: (_, imageUrl, error) =>
Container(color: Colors.grey[300]),
)
else
Container(color: Theme.of(context).colorScheme.primaryContainer),
@@ -188,7 +199,7 @@ class _NovelHorizontalList extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
itemCount: novels.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
separatorBuilder: (_, separatorIndex) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final novel = novels[index];
return GestureDetector(
+35 -9
View File
@@ -1,4 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/foundation.dart';
import 'package:dio/dio.dart';
import '../../../core/models/novel_model.dart';
import '../../../core/network/providers.dart';
@@ -17,22 +19,46 @@ class HomeData {
final homeProvider = FutureProvider<HomeData>((ref) async {
final client = ref.read(apiClientProvider);
final results = await Future.wait([
final results = await Future.wait<Response<dynamic>>([
client.dio.get('/api/novels/browse', queryParameters: {'sort': 'popular', 'limit': '10', 'page': '1'}),
client.dio.get('/api/novels/browse', queryParameters: {'sort': 'latest', 'limit': '20', 'page': '1'}),
client.dio.get('/api/novels/browse', queryParameters: {'sort': 'rating', 'limit': '10', 'page': '1'}),
]);
List<NovelModel> parseItems(dynamic res) {
final data = res.data as Map<String, dynamic>;
return (data['items'] as List)
.map((e) => NovelModel.fromJson(e as Map<String, dynamic>))
.toList();
List<NovelModel> parseItems(Response<dynamic> res, String feedName) {
final raw = res.data;
if (raw is! Map<String, dynamic>) {
throw FormatException('Feed $feedName response is not a JSON object: ${raw.runtimeType}');
}
final rawItems = raw['items'];
if (rawItems is! List) {
throw FormatException('Feed $feedName missing items list');
}
final parsed = <NovelModel>[];
for (var i = 0; i < rawItems.length; i++) {
final item = rawItems[i];
if (item is! Map<String, dynamic>) {
debugPrint('[HOME][SKIP] $feedName item#$i has invalid type: ${item.runtimeType}');
continue;
}
try {
parsed.add(NovelModel.fromJson(item));
} catch (e) {
final id = item['id'];
debugPrint('[HOME][SKIP] $feedName item#$i id=$id parse failed: $e');
}
}
debugPrint('[HOME] $feedName parsed ${parsed.length}/${rawItems.length} items');
return parsed;
}
return HomeData(
hot: parseItems(results[0]),
latest: parseItems(results[1]),
topRated: parseItems(results[2]),
hot: parseItems(results[0], 'popular'),
latest: parseItems(results[1], 'latest'),
topRated: parseItems(results[2], 'rating'),
);
});
@@ -58,7 +58,7 @@ class NovelDetailScreen extends ConsumerWidget {
// Read button
chaptersAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
error: (_, error) => const SizedBox.shrink(),
data: (chapters) {
if (chapters.isEmpty) return const SizedBox.shrink();
final first = chapters.first;
@@ -102,7 +102,8 @@ class NovelDetailScreen extends ConsumerWidget {
child: Center(child: CircularProgressIndicator()),
),
),
error: (_, __) => const SliverToBoxAdapter(child: SizedBox.shrink()),
error: (_, error) =>
const SliverToBoxAdapter(child: SizedBox.shrink()),
data: (chapters) => SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
@@ -13,107 +13,130 @@ class ProfileScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
final bookshelfAsync = ref.watch(bookshelfProvider);
final bookmarkedCount =
bookshelfAsync.maybeWhen(data: (items) => items.length, orElse: () => 0);
final displayName = authState is AuthAuthenticated
? ((authState.user.name != null && authState.user.name!.trim().isNotEmpty)
? authState.user.name!.trim()
: authState.user.email)
: '';
return Scaffold(
appBar: AppBar(title: const Text('Tài khoản')),
body: authState.maybeWhen(
authenticated: (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: 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),
),
child: Column(
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,
),
const SizedBox(height: 12),
Text(
displayName,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
user.email,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
child: Column(
const SizedBox(height: 24),
// Stats Cards
Row(
children: [
CircleAvatar(
radius: 40,
backgroundImage: user.image != null
? NetworkImage(user.image!)
: null,
child: user.image == null
? Text(
user.name.isNotEmpty
? user.name[0].toUpperCase()
: '?',
style: Theme.of(context).textTheme.headlineMedium,
)
: null,
Expanded(
child: _buildStatCard(
context: context,
label: 'Sách Đánh Dấu',
count: bookmarkedCount,
),
),
const SizedBox(height: 12),
Text(
user.name,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
user.email,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
context: context,
label: 'Đang Đọc',
count: bookmarkedCount,
),
),
],
),
),
const SizedBox(height: 24),
const SizedBox(height: 24),
// Stats Cards
Row(
children: [
Expanded(
child: _buildStatCard(
context: context,
label: 'Sách Đánh Dấu',
count: bookshelfAsync.whenData((b) => b.length).value ?? 0,
),
// 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(width: 12),
Expanded(
child: _buildStatCard(
context: context,
label: 'Đang Đọc',
count: bookshelfAsync
.whenData((b) => b.where((x) => true).length)
.value ??
0,
),
),
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 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.login);
},
icon: const Icon(Icons.logout),
label: const Text('Đăng Xuất'),
),
),
],
),
),
),
orElse: () => const Center(child: CircularProgressIndicator()),
),
_ => const Center(child: CircularProgressIndicator()),
},
);
}
File diff suppressed because it is too large Load Diff
@@ -29,10 +29,16 @@ class TtsPlayerWidget extends ConsumerWidget {
if (!tts.isPlaying)
IconButton.filled(
icon: const Icon(Icons.play_arrow),
onPressed: () => notifier.startReading(
content,
paragraphIndex: tts.paragraphIndex,
),
onPressed: () {
if (tts.status == TtsStatus.paused) {
notifier.resume();
return;
}
notifier.startReading(
content,
paragraphIndex: tts.paragraphIndex,
);
},
)
else
IconButton.filled(
@@ -68,6 +74,23 @@ class TtsPlayerWidget extends ConsumerWidget {
style: Theme.of(context).textTheme.labelSmall,
),
),
if (tts.voiceName != null)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
tts.voiceName!,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall,
),
)
else
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
tts.language,
style: Theme.of(context).textTheme.labelSmall,
),
),
],
),
);
@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/chapter_model.dart';
import '../../../core/models/reading_settings.dart';
@@ -21,8 +22,10 @@ final chapterProvider =
unawaited(offlineCache.saveChapter(chapter));
return chapter;
} catch (_) {
debugPrint('[READER][CHAPTER][ERROR] Failed to load chapterId=$chapterId from network, trying cache');
final cached = await offlineCache.loadChapter(chapterId);
if (cached != null) return cached;
debugPrint('[READER][CHAPTER][ERROR] No cache for chapterId=$chapterId');
rethrow;
}
});
@@ -57,7 +60,6 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
chapterNumber: chapterNumber,
scrollOffset: 0,
);
_persistProgress(chapterId, chapterNumber, 0);
}
void updateScroll(double offset) {
if (state == null) return;
+78 -11
View File
@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
enum TtsStatus { idle, playing, paused }
@@ -9,12 +10,16 @@ class TtsState {
final int paragraphIndex;
final int totalParagraphs;
final double speed;
final String language;
final String? voiceName;
const TtsState({
this.status = TtsStatus.idle,
this.paragraphIndex = 0,
this.totalParagraphs = 0,
this.speed = 1.0,
this.language = 'vi-VN',
this.voiceName,
});
TtsState copyWith({
@@ -22,12 +27,17 @@ class TtsState {
int? paragraphIndex,
int? totalParagraphs,
double? speed,
String? language,
String? voiceName,
bool clearVoiceName = false,
}) =>
TtsState(
status: status ?? this.status,
paragraphIndex: paragraphIndex ?? this.paragraphIndex,
totalParagraphs: totalParagraphs ?? this.totalParagraphs,
speed: speed ?? this.speed,
language: language ?? this.language,
voiceName: clearVoiceName ? null : (voiceName ?? this.voiceName),
);
bool get isPlaying => status == TtsStatus.playing;
@@ -36,17 +46,42 @@ class TtsState {
class TtsNotifier extends StateNotifier<TtsState> {
final FlutterTts _tts = FlutterTts();
List<String> _paragraphs = [];
bool _initialized = false;
Future<void>? _initFuture;
TtsNotifier() : super(const TtsState()) {
_init();
_initFuture = _init();
}
Future<void> _init() async {
await _tts.setLanguage('vi-VN');
await _tts.awaitSpeakCompletion(true);
await _tts.setSharedInstance(true);
if (Platform.isIOS) {
await _tts.setIosAudioCategory(
IosTextToSpeechAudioCategory.playback,
[
IosTextToSpeechAudioCategoryOptions.allowBluetooth,
IosTextToSpeechAudioCategoryOptions.allowBluetoothA2DP,
IosTextToSpeechAudioCategoryOptions.mixWithOthers,
],
IosTextToSpeechAudioMode.defaultMode,
);
}
if (Platform.isAndroid) {
await _tts.setAudioAttributesForNavigation();
}
await _configureVietnameseVoice();
await _tts.setSpeechRate(1.0);
await _tts.setVolume(1.0);
await _tts.setPitch(1.0);
_tts.setStartHandler(() {
state = state.copyWith(status: TtsStatus.playing);
});
_tts.setCompletionHandler(() {
if (state.status == TtsStatus.playing) {
_next();
@@ -55,12 +90,49 @@ class TtsNotifier extends StateNotifier<TtsState> {
_tts.setErrorHandler((msg) {
state = state.copyWith(status: TtsStatus.idle);
WakelockPlus.disable();
});
_initialized = true;
}
Future<void> _configureVietnameseVoice() async {
final dynamic voicesRaw = await _tts.getVoices;
String? selectedName;
String selectedLanguage = 'vi-VN';
if (voicesRaw is List) {
final vietnamese = voicesRaw.whereType<Map>().where((voice) {
final locale = (voice['locale'] ?? voice['language'] ?? '').toString().toLowerCase();
return locale.startsWith('vi');
}).toList();
if (vietnamese.isNotEmpty) {
final preferred = vietnamese.firstWhere(
(voice) =>
(voice['name']?.toString().toLowerCase().contains('female') ?? false) ||
(voice['name']?.toString().toLowerCase().contains('natural') ?? false),
orElse: () => vietnamese.first,
);
selectedName = preferred['name']?.toString();
selectedLanguage =
(preferred['locale'] ?? preferred['language'] ?? 'vi-VN').toString();
}
}
await _tts.setLanguage(selectedLanguage);
if (selectedName != null) {
await _tts.setVoice({'name': selectedName, 'locale': selectedLanguage});
}
state = state.copyWith(language: selectedLanguage, voiceName: selectedName);
}
/// Start reading from [content] starting at optional [paragraphIndex].
Future<void> startReading(String content, {int paragraphIndex = 0}) async {
if (!_initialized) {
await (_initFuture ?? _init());
}
_paragraphs = content
.split(RegExp(r'\n+'))
.map((p) => p.trim())
@@ -75,14 +147,12 @@ class TtsNotifier extends StateNotifier<TtsState> {
paragraphIndex: validIndex,
totalParagraphs: _paragraphs.length,
);
await WakelockPlus.enable();
await _speak(validIndex);
}
Future<void> _speak(int index) async {
if (index >= _paragraphs.length) {
state = state.copyWith(status: TtsStatus.idle);
await WakelockPlus.disable();
return;
}
await _tts.setSpeechRate(state.speed);
@@ -93,7 +163,6 @@ class TtsNotifier extends StateNotifier<TtsState> {
final next = state.paragraphIndex + 1;
if (next >= state.totalParagraphs) {
state = state.copyWith(status: TtsStatus.idle, paragraphIndex: 0);
await WakelockPlus.disable();
return;
}
state = state.copyWith(paragraphIndex: next);
@@ -103,20 +172,18 @@ class TtsNotifier extends StateNotifier<TtsState> {
Future<void> pause() async {
await _tts.pause();
state = state.copyWith(status: TtsStatus.paused);
await WakelockPlus.disable();
}
Future<void> resume() async {
if (state.status != TtsStatus.paused) return;
state = state.copyWith(status: TtsStatus.playing);
await WakelockPlus.enable();
// Use paragraph-level resume for consistent behavior across engines.
await _speak(state.paragraphIndex);
}
Future<void> stop() async {
await _tts.stop();
state = state.copyWith(status: TtsStatus.idle, paragraphIndex: 0);
await WakelockPlus.disable();
}
Future<void> skipForward() async {
@@ -126,6 +193,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
Future<void> skipBack() async {
await _tts.stop();
if (state.totalParagraphs <= 0) return;
final prev = (state.paragraphIndex - 1).clamp(0, state.totalParagraphs - 1);
state = state.copyWith(paragraphIndex: prev);
if (state.status == TtsStatus.playing) await _speak(prev);
@@ -139,7 +207,6 @@ class TtsNotifier extends StateNotifier<TtsState> {
@override
void dispose() {
_tts.stop();
WakelockPlus.disable();
super.dispose();
}
}
@@ -100,7 +100,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
// Genre filter
genresAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
error: (_, error) => const SizedBox.shrink(),
data: (genres) => _FilterChipDropdown(
label: _selectedGenre == null
? 'Thể loại'
@@ -98,7 +98,7 @@ class SettingsScreen extends ConsumerWidget {
style: TextStyle(color: Colors.red)),
onTap: () async {
await ref.read(authProvider.notifier).signOut();
if (context.mounted) context.go(RouteNames.login);
if (context.mounted) context.go(RouteNames.home);
},
),
],
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
@@ -11,15 +13,23 @@ class SplashScreen extends StatefulWidget {
}
class _SplashScreenState extends State<SplashScreen> {
Timer? _redirectTimer;
@override
void initState() {
super.initState();
Future<void>.delayed(const Duration(milliseconds: 700), () {
_redirectTimer = Timer(const Duration(milliseconds: 700), () {
if (!mounted) return;
context.go(RouteNames.home);
});
}
@override
void dispose() {
_redirectTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return const Scaffold(