diff --git a/.env.mobile.example b/.env.mobile.example new file mode 100644 index 0000000..5e19153 --- /dev/null +++ b/.env.mobile.example @@ -0,0 +1,5 @@ +# Mobile runtime defines for Flutter +BASE_URL=http://127.0.0.1:8000 +GOOGLE_SERVER_CLIENT_ID=308259929553-9oame596io3s4lcj9cdb5db6v3i6f6rk.apps.googleusercontent.com +# Optional for iOS/web flows +GOOGLE_CLIENT_ID= diff --git a/.gitignore b/.gitignore index 3820a95..de2ab05 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Local mobile runtime defines +.env.mobile diff --git a/README.md b/README.md index 5988884..1cc6eab 100644 --- a/README.md +++ b/README.md @@ -30,3 +30,88 @@ Flutter mobile app for reading novels, synced with the existing web platform. flutter pub get flutter run ``` + +Run with env file (recommended for local dev): + +1. Create local env from sample: + +```bash +cp .env.mobile.example .env.mobile +``` + +1. Start app using env values: + +```bash +bash scripts/flutter_run_with_env.sh +``` + +This script reads `.env.mobile` and automatically passes: + +- `BASE_URL` +- `GOOGLE_SERVER_CLIENT_ID` +- optional `GOOGLE_CLIENT_ID` + +Default `BASE_URL` behavior: + +- Android emulator: `http://10.0.2.2:8000` +- Others (iOS simulator, desktop, web): `http://localhost:8000` + +If needed, you can still override explicitly: + +```bash +flutter run --dart-define=BASE_URL=http://localhost:8000 +``` + +For Android emulator, use: + +```bash +flutter run --dart-define=BASE_URL=http://10.0.2.2:8000 +``` + +For a physical device in dev, use your computer LAN IP (same Wi-Fi): + +```bash +flutter run --dart-define=BASE_URL=http://:8000 +``` + +Important notes for physical devices: + +- Use the Wi-Fi LAN IP from `en0` (example: `10.17.2.62`). +- Do NOT use VPN/tunnel IPs from `utun` (example: `100.x.x.x`) unless your phone is connected to the same VPN. +- Keep phone and computer on the same Wi-Fi network. + +Android over USB (stable local tunnel): + +```bash +adb reverse tcp:8000 tcp:8000 +flutter run --dart-define=BASE_URL=http://127.0.0.1:8000 +``` + +## Google Sign-In (Android) + +If you see `PlatformException ... ApiException: 10`, it is usually an OAuth config mismatch. + +Checklist: + +- `android/app/google-services.json` must exist and match package name `com.example.reader_app`. +- Add SHA-1 and SHA-256 fingerprints of your debug keystore to Firebase Android app settings. +- Ensure OAuth client IDs are created after adding SHA fingerprints. +- Run with server/web client id for backend token verification: + +```bash +# Bước 1: Khởi động emulator +flutter emulators --launch Pixel_8_API_35 +flutter run +``` + +```bash +flutter run \ + --dart-define=BASE_URL=http://127.0.0.1:8000 \ + --dart-define=GOOGLE_SERVER_CLIENT_ID=.apps.googleusercontent.com +``` + +Optional (iOS/web): + +```bash +--dart-define=GOOGLE_CLIENT_ID=.apps.googleusercontent.com +``` diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 78f863e..2835fd3 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -3,6 +3,7 @@ plugins { id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") + id("com.google.gms.google-services") } android { diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100644 index 0000000..95230ff --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,47 @@ +{ + "project_info": { + "project_number": "308259929553", + "project_id": "reader-1658c", + "storage_bucket": "reader-1658c.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:308259929553:android:9142ae16d9ddd8a91c34f0", + "android_client_info": { + "package_name": "com.example.reader_app" + } + }, + "oauth_client": [ + { + "client_id": "308259929553-6k3q1g76skt3id4e2mk9k6pr5l7gdtju.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "com.example.reader_app", + "certificate_hash": "fa21a3e6a319b71b2dd0ef9573b22046dba5d55c" + } + }, + { + "client_id": "308259929553-9oame596io3s4lcj9cdb5db6v3i6f6rk.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyBibgTrvBWtJBL4PGeIyahBwRlYKcjQ47k" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "308259929553-9oame596io3s4lcj9cdb5db6v3i6f6rk.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 29c072a..0d56e57 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,10 @@ + + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes @@ -49,6 +54,10 @@ UIApplicationSupportsIndirectInputEvents + UIBackgroundModes + + audio + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/lib/app/app.dart b/lib/app/app.dart index 2c945d9..61db114 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -15,6 +15,8 @@ class ReaderApp extends ConsumerWidget { title: 'Reader App', debugShowCheckedModeBanner: false, theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: ThemeMode.system, routerConfig: router, ); } diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 43cd006..d586c54 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -61,7 +61,7 @@ final appRouterProvider = Provider((ref) { GoRoute( path: RouteNames.readerPath, builder: (_, state) => ReaderScreen( - chapterId: state.pathParameters['chapterId'] ?? '', + chapterId: Uri.decodeComponent(state.pathParameters['chapterId'] ?? ''), ), ), GoRoute( diff --git a/lib/app/router/route_names.dart b/lib/app/router/route_names.dart index 42e3007..ae5e010 100644 --- a/lib/app/router/route_names.dart +++ b/lib/app/router/route_names.dart @@ -17,7 +17,7 @@ class RouteNames { // Navigation helpers static String novelDetail(String id) => '/novel/$id'; - static String readerChapter(String chapterId) => '/reader/$chapterId'; + static String readerChapter(String chapterId) => '/reader/${Uri.encodeComponent(chapterId)}'; static String commentsFor(String novelId, {String? chapterId}) { final base = '/comments/$novelId'; return chapterId != null ? '$base?chapterId=$chapterId' : base; diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 5ba8595..0d3163f 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -1,13 +1,29 @@ +import 'package:flutter/foundation.dart'; + class AppConfig { AppConfig._(); - static const String baseUrl = String.fromEnvironment( - 'BASE_URL', - defaultValue: 'https://localhost:3000', - ); + static const String _baseUrlFromEnv = String.fromEnvironment('BASE_URL'); + + static String get baseUrl { + if (_baseUrlFromEnv.isNotEmpty) { + return _baseUrlFromEnv; + } + + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + return 'http://10.0.2.2:8000'; + } + + return 'http://localhost:8000'; + } static const String googleClientId = String.fromEnvironment( 'GOOGLE_CLIENT_ID', defaultValue: '', ); + + static const String googleServerClientId = String.fromEnvironment( + 'GOOGLE_SERVER_CLIENT_ID', + defaultValue: '', + ); } diff --git a/lib/core/logging/app_provider_observer.dart b/lib/core/logging/app_provider_observer.dart new file mode 100644 index 0000000..457ba52 --- /dev/null +++ b/lib/core/logging/app_provider_observer.dart @@ -0,0 +1,17 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class AppProviderObserver extends ProviderObserver { + const AppProviderObserver(); + + @override + void providerDidFail( + ProviderBase provider, + Object error, + StackTrace stackTrace, + ProviderContainer container, + ) { + debugPrint('[APP][PROVIDER_ERROR] ${provider.name ?? provider.runtimeType}: $error'); + debugPrintStack(stackTrace: stackTrace); + } +} diff --git a/lib/core/models/novel_model.dart b/lib/core/models/novel_model.dart index 0d90da3..6112d90 100644 --- a/lib/core/models/novel_model.dart +++ b/lib/core/models/novel_model.dart @@ -41,13 +41,24 @@ class NovelModel extends Equatable { final SeriesModel? series; final LatestChapterInfo? latestChapter; + static String _stringValue(dynamic value, {String fallback = ''}) { + if (value is String) return value; + if (value == null) return fallback; + return value.toString(); + } + + static int _intValue(dynamic value, {int fallback = 0}) { + if (value is num) return value.toInt(); + return fallback; + } + factory NovelModel.fromJson(Map json) => NovelModel( - id: json['id'] as String, - title: json['title'] as String, - slug: json['slug'] as String, - authorName: json['authorName'] as String, - status: json['status'] as String, - totalChapters: (json['totalChapters'] as num).toInt(), + id: _stringValue(json['id']), + title: _stringValue(json['title'], fallback: 'Không rõ tiêu đề'), + slug: _stringValue(json['slug']), + authorName: _stringValue(json['authorName'], fallback: 'Chưa rõ tác giả'), + status: _stringValue(json['status'], fallback: 'Đang ra'), + totalChapters: _intValue(json['totalChapters']), originalTitle: json['originalTitle'] as String?, description: json['description'] as String?, coverUrl: json['coverUrl'] as String?, diff --git a/lib/core/models/reading_settings.dart b/lib/core/models/reading_settings.dart index 76ca9c5..a230043 100644 --- a/lib/core/models/reading_settings.dart +++ b/lib/core/models/reading_settings.dart @@ -4,24 +4,40 @@ class ReadingSettings { this.lineHeight = 1.8, this.letterSpacing = 0, this.fontFamily = 'serif', + this.themePreset = 'paper', + this.horizontalPadding = 20, + this.paragraphSpacing = 24, + this.textAlign = 'justify', }); final double fontSize; final double lineHeight; final double letterSpacing; final String fontFamily; + final String themePreset; + final double horizontalPadding; + final double paragraphSpacing; + final String textAlign; ReadingSettings copyWith({ double? fontSize, double? lineHeight, double? letterSpacing, String? fontFamily, + String? themePreset, + double? horizontalPadding, + double? paragraphSpacing, + String? textAlign, }) => ReadingSettings( fontSize: fontSize ?? this.fontSize, lineHeight: lineHeight ?? this.lineHeight, letterSpacing: letterSpacing ?? this.letterSpacing, fontFamily: fontFamily ?? this.fontFamily, + themePreset: themePreset ?? this.themePreset, + horizontalPadding: horizontalPadding ?? this.horizontalPadding, + paragraphSpacing: paragraphSpacing ?? this.paragraphSpacing, + textAlign: textAlign ?? this.textAlign, ); factory ReadingSettings.fromJson(Map json) => ReadingSettings( @@ -29,6 +45,10 @@ class ReadingSettings { lineHeight: (json['lineHeight'] as num?)?.toDouble() ?? 1.8, letterSpacing: (json['letterSpacing'] as num?)?.toDouble() ?? 0, fontFamily: json['fontFamily'] as String? ?? 'serif', + themePreset: json['themePreset'] as String? ?? 'paper', + horizontalPadding: (json['horizontalPadding'] as num?)?.toDouble() ?? 20, + paragraphSpacing: (json['paragraphSpacing'] as num?)?.toDouble() ?? 24, + textAlign: json['textAlign'] as String? ?? 'justify', ); Map toJson() => { @@ -36,5 +56,9 @@ class ReadingSettings { 'lineHeight': lineHeight, 'letterSpacing': letterSpacing, 'fontFamily': fontFamily, + 'themePreset': themePreset, + 'horizontalPadding': horizontalPadding, + 'paragraphSpacing': paragraphSpacing, + 'textAlign': textAlign, }; } diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index 6b255e7..9ac1463 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import '../storage/secure_store.dart'; @@ -18,12 +19,28 @@ class ApiClient { dio.interceptors.add( InterceptorsWrapper( onRequest: (options, handler) async { + debugPrint('[API] ${options.method} ${options.baseUrl}${options.path}'); final token = await _secureStore.getAccessToken(); if (token != null && token.isNotEmpty) { options.headers['Authorization'] = 'Bearer $token'; } handler.next(options); }, + onResponse: (response, handler) { + debugPrint( + '[API][OK] ${response.requestOptions.method} ' + '${response.requestOptions.baseUrl}${response.requestOptions.path} ' + '-> ${response.statusCode}', + ); + handler.next(response); + }, + onError: (error, handler) { + debugPrint( + '[API][ERROR] ${error.requestOptions.method} ${error.requestOptions.baseUrl}${error.requestOptions.path} ' + '-> ${error.type}: ${error.message}', + ); + handler.next(error); + }, ), ); } diff --git a/lib/core/storage/local_store.dart b/lib/core/storage/local_store.dart index 0bfcb0a..594bfc4 100644 --- a/lib/core/storage/local_store.dart +++ b/lib/core/storage/local_store.dart @@ -7,6 +7,10 @@ class LocalStore { static const _kLineHeight = 'reader_line_height'; static const _kLetterSpacing = 'reader_letter_spacing'; static const _kFontFamily = 'reader_font_family'; + static const _kThemePreset = 'reader_theme_preset'; + static const _kHorizontalPadding = 'reader_horizontal_padding'; + static const _kParagraphSpacing = 'reader_paragraph_spacing'; + static const _kTextAlign = 'reader_text_align'; static const _kProgressChapterId = 'progress_chapter_id_'; static const _kProgressChapterNum = 'progress_chapter_num_'; static const _kProgressOffset = 'progress_offset_'; @@ -19,6 +23,10 @@ class LocalStore { await prefs.setDouble(_kLineHeight, settings.lineHeight); await prefs.setDouble(_kLetterSpacing, settings.letterSpacing); await prefs.setString(_kFontFamily, settings.fontFamily); + await prefs.setString(_kThemePreset, settings.themePreset); + await prefs.setDouble(_kHorizontalPadding, settings.horizontalPadding); + await prefs.setDouble(_kParagraphSpacing, settings.paragraphSpacing); + await prefs.setString(_kTextAlign, settings.textAlign); } Future loadReadingSettings() async { @@ -29,6 +37,10 @@ class LocalStore { lineHeight: prefs.getDouble(_kLineHeight) ?? 1.8, letterSpacing: prefs.getDouble(_kLetterSpacing) ?? 0, fontFamily: prefs.getString(_kFontFamily) ?? 'serif', + themePreset: prefs.getString(_kThemePreset) ?? 'paper', + horizontalPadding: prefs.getDouble(_kHorizontalPadding) ?? 20, + paragraphSpacing: prefs.getDouble(_kParagraphSpacing) ?? 24, + textAlign: prefs.getString(_kTextAlign) ?? 'justify', ); } diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 5e90017..82f9c18 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -16,4 +16,18 @@ class AppTheme { foregroundColor: Color(0xFF121826), ), ); + + static final ThemeData darkTheme = ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF155DFC), + brightness: Brightness.dark, + ), + scaffoldBackgroundColor: const Color(0xFF0E1420), + appBarTheme: const AppBarTheme( + centerTitle: false, + backgroundColor: Color(0xFF0E1420), + foregroundColor: Color(0xFFE5EAF3), + ), + ); } diff --git a/lib/features/auth/presentation/login_screen.dart b/lib/features/auth/presentation/login_screen.dart index bee4537..06b9abe 100644 --- a/lib/features/auth/presentation/login_screen.dart +++ b/lib/features/auth/presentation/login_screen.dart @@ -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 createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + 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(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 diff --git a/lib/features/auth/providers/auth_provider.dart b/lib/features/auth/providers/auth_provider.dart index b93e76e..48ecc86 100644 --- a/lib/features/auth/providers/auth_provider.dart +++ b/lib/features/auth/providers/auth_provider.dart @@ -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 { 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 ? '' : (AppConfig.googleClientId.isNotEmpty ? AppConfig.googleClientId : '')} ' + 'serverClientId=${AppConfig.googleServerClientId.isNotEmpty ? AppConfig.googleServerClientId : (AppConfig.googleClientId.isNotEmpty ? AppConfig.googleClientId : '')}', + ); + } Future _restore() async { final token = await _store.getAccessToken(); @@ -66,6 +84,7 @@ class AuthNotifier extends StateNotifier { Future signInWithGoogle() async { try { state = AuthLoading(); + _logGoogleSignInConfig(); final account = await _googleSignIn.signIn(); if (account == null) { state = AuthUnauthenticated(); @@ -94,10 +113,28 @@ class AuthNotifier extends StateNotifier { state = AuthAuthenticated( UserModel.fromJson(data['user'] as Map), ); - } 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()); } } diff --git a/lib/features/bookshelf/presentation/bookshelf_screen.dart b/lib/features/bookshelf/presentation/bookshelf_screen.dart index af48f4e..dc31617 100644 --- a/lib/features/bookshelf/presentation/bookshelf_screen.dart +++ b/lib/features/bookshelf/presentation/bookshelf_screen.dart @@ -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'), ), ], ), diff --git a/lib/features/comments/presentation/comments_screen.dart b/lib/features/comments/presentation/comments_screen.dart index 36d8244..108e36f 100644 --- a/lib/features/comments/presentation/comments_screen.dart +++ b/lib/features/comments/presentation/comments_screen.dart @@ -72,7 +72,8 @@ class _CommentsScreenState extends ConsumerState { 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]), ); diff --git a/lib/features/home/presentation/home_screen.dart b/lib/features/home/presentation/home_screen.dart index e0fd8f0..95e8ae0 100644 --- a/lib/features/home/presentation/home_screen.dart +++ b/lib/features/home/presentation/home_screen.dart @@ -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( diff --git a/lib/features/home/providers/home_provider.dart b/lib/features/home/providers/home_provider.dart index 007f69a..6ed85bb 100644 --- a/lib/features/home/providers/home_provider.dart +++ b/lib/features/home/providers/home_provider.dart @@ -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((ref) async { final client = ref.read(apiClientProvider); - final results = await Future.wait([ + final results = await Future.wait>([ 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 parseItems(dynamic res) { - final data = res.data as Map; - return (data['items'] as List) - .map((e) => NovelModel.fromJson(e as Map)) - .toList(); + List parseItems(Response res, String feedName) { + final raw = res.data; + if (raw is! Map) { + 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 = []; + for (var i = 0; i < rawItems.length; i++) { + final item = rawItems[i]; + if (item is! Map) { + 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'), ); }); diff --git a/lib/features/novel/presentation/novel_detail_screen.dart b/lib/features/novel/presentation/novel_detail_screen.dart index d84cb0f..34f3cab 100644 --- a/lib/features/novel/presentation/novel_detail_screen.dart +++ b/lib/features/novel/presentation/novel_detail_screen.dart @@ -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) { diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index b5257a6..256a786 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -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()), + }, ); } diff --git a/lib/features/reader/presentation/reader_screen.dart b/lib/features/reader/presentation/reader_screen.dart index e59fbf7..59277ef 100644 --- a/lib/features/reader/presentation/reader_screen.dart +++ b/lib/features/reader/presentation/reader_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -5,7 +7,11 @@ import 'package:go_router/go_router.dart'; import '../../../app/router/route_names.dart'; import '../../../core/models/chapter_model.dart'; +import '../../../core/models/reading_settings.dart'; +import '../../../core/storage/local_store.dart'; +import '../../novel/providers/novels_provider.dart'; import '../providers/reader_provider.dart'; +import '../tts/tts_service.dart'; import 'tts_player_widget.dart'; class ReaderScreen extends ConsumerStatefulWidget { @@ -19,12 +25,46 @@ class ReaderScreen extends ConsumerStatefulWidget { class _ReaderScreenState extends ConsumerState { final ScrollController _scrollCtrl = ScrollController(); - bool _showUI = true; + Timer? _uiAutoHideTimer; + double _readingProgress = 0; + String? _activeChapterId; + bool _isRestoringProgress = false; + bool _showQuickActions = true; + double _lastScrollOffset = 0; + double _scrollDeltaSinceToggle = 0; + int _chapterDirection = 0; // -1: previous, 1: next + + List _paragraphsOf(String content) => content + .split(RegExp(r'\n+')) + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toList(); + + String _chapterTopBarTitle(ChapterModel chapter) { + final title = chapter.title.trim(); + if (title.isNotEmpty) return title; + + final volumeTitle = chapter.volumeTitle?.trim(); + if (volumeTitle != null && volumeTitle.isNotEmpty) return volumeTitle; + + return 'Chương ${chapter.number}'; + } + + TextAlign _textAlignFor(String value) { + switch (value) { + case 'left': + return TextAlign.left; + case 'center': + return TextAlign.center; + default: + return TextAlign.justify; + } + } @override void initState() { super.initState(); - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); WidgetsBinding.instance.addPostFrameCallback((_) { _scrollCtrl.addListener(_onScroll); }); @@ -32,6 +72,7 @@ class _ReaderScreenState extends ConsumerState { @override void dispose() { + _uiAutoHideTimer?.cancel(); _scrollCtrl.removeListener(_onScroll); _scrollCtrl.dispose(); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); @@ -39,15 +80,534 @@ class _ReaderScreenState extends ConsumerState { } void _onScroll() { + if (_isRestoringProgress) return; ref.read(readerProvider.notifier).updateScroll(_scrollCtrl.offset); + + final currentOffset = _scrollCtrl.hasClients ? _scrollCtrl.offset : _lastScrollOffset; + final delta = currentOffset - _lastScrollOffset; + if (_scrollDeltaSinceToggle == 0 || + (_scrollDeltaSinceToggle.isNegative == delta.isNegative)) { + _scrollDeltaSinceToggle += delta; + } else { + _scrollDeltaSinceToggle = delta; + } + + if (_showQuickActions && currentOffset > 120 && _scrollDeltaSinceToggle > 56) { + setState(() => _showQuickActions = false); + _scrollDeltaSinceToggle = 0; + } else if (!_showQuickActions && + (_scrollDeltaSinceToggle < -36 || currentOffset <= 40)) { + setState(() => _showQuickActions = true); + _scrollDeltaSinceToggle = 0; + } + _lastScrollOffset = currentOffset; + + if (!_scrollCtrl.hasClients) return; + final max = _scrollCtrl.position.maxScrollExtent; + final next = max <= 0 ? 0.0 : (_scrollCtrl.offset / max).clamp(0.0, 1.0); + if ((next - _readingProgress).abs() > 0.01) { + setState(() => _readingProgress = next); + } } - void _toggleUI() => setState(() => _showUI = !_showUI); + Future _initializeChapterSession(ChapterModel chapter) async { + if (_activeChapterId == chapter.id) return; + _activeChapterId = chapter.id; + _readingProgress = 0; + + ref.read(readerProvider.notifier).open( + chapter.novelId, + chapter.id, + chapter.number, + ); + + final localStore = ref.read(localStoreProvider); + final saved = await localStore.loadProgress(chapter.novelId); + if (!mounted || saved == null) return; + if (saved['chapterId'] != chapter.id) return; + + final savedOffset = (saved['scrollOffset'] as num?)?.toDouble() ?? 0; + if (savedOffset <= 0) return; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollCtrl.hasClients) return; + final max = _scrollCtrl.position.maxScrollExtent; + final target = savedOffset.clamp(0.0, max); + + _isRestoringProgress = true; + _scrollCtrl.jumpTo(target); + _isRestoringProgress = false; + _onScroll(); + }); + } + + 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; + setState(() => _chapterDirection = -1); + HapticFeedback.selectionClick(); + context.pushReplacement(RouteNames.readerChapter(prevId)); + } + + void _goToNextChapter(ChapterModel chapter) { + final nextId = chapter.nextChapterId; + if (nextId == null) return; + setState(() => _chapterDirection = 1); + HapticFeedback.selectionClick(); + context.pushReplacement(RouteNames.readerChapter(nextId)); + } + + Future _scrollToTop() async { + if (!_scrollCtrl.hasClients) return; + await _scrollCtrl.animateTo( + 0, + duration: const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + ); + } + + Future _openChapterToc(ChapterModel currentChapter) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (sheetContext) { + return Consumer( + builder: (context, ref, _) { + final chaptersAsync = ref.watch(chapterListProvider(currentChapter.novelId)); + return FractionallySizedBox( + heightFactor: 0.82, + child: SafeArea( + top: false, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 12, 8), + child: Row( + children: [ + Expanded( + child: Text( + 'Mục lục chương', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: chaptersAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Không tải được mục lục: $e')), + data: (chapters) { + if (chapters.isEmpty) { + return const Center(child: Text('Chưa có danh sách chương.')); + } + return ListView.separated( + itemCount: chapters.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final item = chapters[index]; + final isCurrent = item.id == currentChapter.id; + return ListTile( + dense: true, + selected: isCurrent, + selectedTileColor: + Theme.of(context).colorScheme.primaryContainer.withAlpha(90), + title: Text( + 'Chương ${item.number}: ${item.title}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: + isCurrent ? const Icon(Icons.menu_book_rounded, size: 18) : null, + onTap: () { + Navigator.of(context).pop(); + if (!isCurrent) { + context.pushReplacement(RouteNames.readerChapter(item.id)); + } + }, + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + + Future _openReadingSettingsSheet(String previewContent) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) { + return Consumer( + builder: (context, ref, _) { + final settings = ref.watch(readingSettingsProvider); + final tts = ref.watch(ttsProvider); + final ttsNotifier = ref.read(ttsProvider.notifier); + + Future update(dynamic next) async { + await ref.read(readingSettingsProvider.notifier).update(next); + } + + return FractionallySizedBox( + heightFactor: 0.92, + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + ), + child: SafeArea( + top: false, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 12, 8), + child: Row( + children: [ + Expanded( + child: Column( + 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, + ), + ], + ), + ), + TextButton( + onPressed: () => update(const ReadingSettings()), + child: const Text('Mặc định'), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: DefaultTabController( + length: 4, + child: Column( + 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), + ), + 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( + child: TabBarView( + children: [ + ListView( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + children: [ + _SettingsSection( + title: 'Kiểu chữ', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SegmentedButton( + segments: const [ + ButtonSegment(value: 'serif', label: Text('Có chân')), + 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)), + ), + const SizedBox(height: 12), + _LabeledSlider( + label: 'Cỡ chữ', + valueLabel: settings.fontSize.toStringAsFixed(0), + min: 12, + max: 32, + divisions: 10, + value: settings.fontSize, + onChanged: (v) => update(settings.copyWith(fontSize: v)), + ), + _LabeledSlider( + label: 'Giãn dòng', + valueLabel: settings.lineHeight.toStringAsFixed(1), + min: 1.2, + max: 3.0, + divisions: 9, + value: settings.lineHeight, + onChanged: (v) => update(settings.copyWith(lineHeight: v)), + ), + _LabeledSlider( + label: 'Khoảng cách chữ', + valueLabel: settings.letterSpacing.toStringAsFixed(1), + min: 0, + max: 4, + divisions: 8, + value: settings.letterSpacing, + onChanged: (v) => update(settings.copyWith(letterSpacing: v)), + ), + ], + ), + ), + ], + ), + ListView( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + children: [ + _SettingsSection( + title: 'Giao diện đọc', + child: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _PresetChip( + label: 'Sáng', + value: 'paper', + selected: settings.themePreset == 'paper', + onTap: () => update(settings.copyWith(themePreset: 'paper')), + ), + _PresetChip( + label: 'Sepia', + value: 'sepia', + selected: settings.themePreset == 'sepia', + onTap: () => update(settings.copyWith(themePreset: 'sepia')), + ), + _PresetChip( + label: 'Ban đêm', + value: 'night', + selected: settings.themePreset == 'night', + onTap: () => update(settings.copyWith(themePreset: 'night')), + ), + ], + ), + ), + _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'), + ), + ], + ), + ), + ], + ), + ListView( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + children: [ + _SettingsSection( + title: 'Bố cục trang', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Canh chữ', style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment(value: 'left', label: Text('Trái')), + ButtonSegment(value: 'justify', label: Text('Đều')), + ButtonSegment(value: 'center', label: Text('Giữa')), + ], + selected: {settings.textAlign}, + onSelectionChanged: (s) => update(settings.copyWith(textAlign: s.first)), + ), + const SizedBox(height: 12), + _LabeledSlider( + label: 'Lề ngang', + valueLabel: settings.horizontalPadding.toStringAsFixed(0), + min: 12, + max: 36, + divisions: 8, + value: settings.horizontalPadding, + onChanged: (v) => update(settings.copyWith(horizontalPadding: v)), + ), + _LabeledSlider( + label: 'Khoảng cách đoạn', + valueLabel: settings.paragraphSpacing.toStringAsFixed(0), + min: 8, + max: 36, + divisions: 7, + value: settings.paragraphSpacing, + onChanged: (v) => update(settings.copyWith(paragraphSpacing: v)), + ), + ], + ), + ), + ], + ), + ListView( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + children: [ + _SettingsSection( + title: 'TTS tiếng Việt', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + tts.voiceName ?? tts.language, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(999), + ), + child: Text('${tts.speed}x', style: Theme.of(context).textTheme.labelLarge), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [0.75, 1.0, 1.25, 1.5, 1.75].map((speed) { + final selected = tts.speed == speed; + return ChoiceChip( + label: Text('${speed}x'), + selected: selected, + onSelected: (_) => ttsNotifier.setSpeed(speed), + ); + }).toList(), + ), + const SizedBox(height: 12), + TtsPlayerWidget(content: previewContent), + ], + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + }, + ); + } @override Widget build(BuildContext context) { final chapterAsync = ref.watch(chapterProvider(widget.chapterId)); final settings = ref.watch(readingSettingsProvider); + 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); + } return Scaffold( body: chapterAsync.when( @@ -67,116 +627,351 @@ class _ReaderScreenState extends ConsumerState { ), ), data: (chapter) { - // Initialize progress tracking + final paragraphs = _paragraphsOf(chapter.content); + final textAlign = _textAlignFor(settings.textAlign); + final novelAsync = ref.watch(novelDetailProvider(chapter.novelId)); WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(readerProvider.notifier).open( - chapter.novelId, - chapter.id, - chapter.number, - ); + _initializeChapterSession(chapter); }); return GestureDetector( - onTap: _toggleUI, - child: Stack( - children: [ - // Main content - Scrollbar( - controller: _scrollCtrl, - child: SingleChildScrollView( - controller: _scrollCtrl, - padding: const EdgeInsets.fromLTRB(20, 80, 20, 80), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Chương ${chapter.number}: ${chapter.title}', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 20), - SelectableText( - chapter.content, - style: TextStyle( - fontSize: settings.fontSize, - height: settings.lineHeight, - letterSpacing: settings.letterSpacing, - fontFamily: settings.fontFamily == 'serif' ? 'Georgia' : null, + behavior: HitTestBehavior.opaque, + onHorizontalDragEnd: (details) => + _handleHorizontalSwipeEnd(details, chapter), + child: ColoredBox( + color: readerBackground, + child: Column( + children: [ + _TopBar( + title: _chapterTopBarTitle(chapter), + progress: _readingProgress, + onOpenSettings: () => _openReadingSettingsSheet(chapter.content), + barBackgroundColor: readerBackground, + foregroundColor: readerTextColor, + ), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) { + final beginOffset = + _chapterDirection < 0 ? const Offset(-0.08, 0) : const Offset(0.08, 0); + final fade = CurvedAnimation(parent: animation, curve: Curves.easeOut); + final slide = Tween( + begin: beginOffset, + end: Offset.zero, + ).animate(fade); + return FadeTransition( + opacity: fade, + child: SlideTransition(position: slide, child: child), + ); + }, + child: KeyedSubtree( + key: ValueKey(chapter.id), + child: Scrollbar( + controller: _scrollCtrl, + child: SingleChildScrollView( + controller: _scrollCtrl, + padding: EdgeInsets.fromLTRB( + settings.horizontalPadding, + 16, + settings.horizontalPadding, + 24, + ), + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + novelAsync.when( + loading: () => Text( + 'Đang tải tên truyện...', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: readerMutedColor, + ), + ), + error: (_, __) => const SizedBox.shrink(), + data: (novel) => Text( + novel.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: readerMutedColor, + letterSpacing: 0.2, + ), + ), + ), + const SizedBox(height: 4), + Text( + 'Chương ${chapter.number}: ${chapter.title}', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(color: readerTextColor), + ), + const SizedBox(height: 20), + if (chapter.content.trim().isEmpty) + Text( + 'Chương này hiện chưa có nội dung.', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: readerMutedColor), + ) + else + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var index = 0; index < paragraphs.length; index++) + Padding( + padding: EdgeInsets.only( + bottom: index == paragraphs.length - 1 + ? 0 + : settings.paragraphSpacing, + ), + child: SelectableText( + paragraphs[index], + textAlign: textAlign, + style: TextStyle( + color: readerTextColor, + fontSize: settings.fontSize, + height: settings.lineHeight, + letterSpacing: settings.letterSpacing, + fontFamily: settings.fontFamily == 'serif' + ? 'Georgia' + : settings.fontFamily == 'mono' + ? 'Courier' + : null, + ), + ), + ), + ], + ), + const SizedBox(height: 40), + _NavButtons(chapter: chapter), + const SizedBox(height: 92), + ], + ), + ), + ), ), ), - const SizedBox(height: 40), - _NavButtons(chapter: chapter), - const SizedBox(height: 32), - ], + ), ), ), - ), - // Floating top bar - AnimatedSlide( - offset: _showUI ? Offset.zero : const Offset(0, -1), - duration: const Duration(milliseconds: 200), - child: _TopBar(chapter: chapter), - ), - // Floating bottom bar with font controls - AnimatedSlide( - offset: _showUI ? Offset.zero : const Offset(0, 1), - duration: const Duration(milliseconds: 200), - child: Align( - alignment: Alignment.bottomCenter, - child: _BottomBar(settings: settings, onSettingsChanged: (s) { - ref.read(readingSettingsProvider.notifier).update(s); - }), - ), - ), - ], + ], + ), ), ); }, ), + floatingActionButton: chapterAsync.hasValue + ? AnimatedSlide( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + offset: _showQuickActions ? Offset.zero : const Offset(0, 1.4), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 140), + opacity: _showQuickActions ? 1 : 0, + child: Builder( + builder: (context) { + final chapter = chapterAsync.value!; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + FloatingActionButton.small( + heroTag: 'reader-scroll-top', + onPressed: _scrollToTop, + child: const Icon(Icons.vertical_align_top_rounded, size: 20), + ), + const SizedBox(height: 10), + FloatingActionButton.small( + heroTag: 'reader-toc', + onPressed: () => _openChapterToc(chapter), + child: const Icon(Icons.list_alt_rounded, size: 20), + ), + ], + ); + }, + ), + ), + ) + : null, ); } } class _TopBar extends StatelessWidget { - final ChapterModel chapter; - const _TopBar({required this.chapter}); + final String title; + final double progress; + final VoidCallback onOpenSettings; + final Color barBackgroundColor; + final Color foregroundColor; + + const _TopBar({ + required this.title, + required this.progress, + required this.onOpenSettings, + required this.barBackgroundColor, + required this.foregroundColor, + }); + + @override + Widget build(BuildContext context) { + final progressText = '${(progress * 100).round()}%'; + return Container( + padding: const EdgeInsets.only(bottom: 6), + decoration: BoxDecoration( + color: barBackgroundColor, + border: Border( + bottom: BorderSide(color: Colors.black.withAlpha(20)), + ), + ), + child: SafeArea( + bottom: false, + child: SizedBox( + height: 52, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Row( + children: [ + IconButton( + tooltip: 'Quay lại', + icon: const Icon(Icons.arrow_back_ios_new, size: 18), + onPressed: () => Navigator.maybePop(context), + ), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: foregroundColor, + fontWeight: FontWeight.w600, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: foregroundColor.withAlpha(18), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + progressText, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: foregroundColor, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 4), + IconButton( + tooltip: 'Tùy chỉnh đọc', + icon: const Icon(Icons.tune, size: 20), + onPressed: onOpenSettings, + ), + ], + ), + ), + ), + ), + ); + } +} + +Color _surfaceForPreset(String preset) { + switch (preset) { + case 'night': + return const Color(0xFF101418); + case 'sepia': + return const Color(0xFFF6EAD7); + 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); + } +} + +class _SettingsSection extends StatelessWidget { + const _SettingsSection({required this.title, required this.child}); + + final String title; + final Widget child; @override Widget build(BuildContext context) { return Container( - color: Theme.of(context).colorScheme.surface.withAlpha(230), - padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top), - child: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - title: Text( - chapter.volumeTitle ?? 'Chương ${chapter.number}', - style: Theme.of(context).textTheme.bodyMedium, - ), - leading: BackButton(onPressed: () => Navigator.maybePop(context)), - actions: [ - IconButton( - icon: const Icon(Icons.comment_outlined), - onPressed: () => context.push( - RouteNames.commentsFor(chapter.novelId, chapterId: chapter.id), - ), + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLowest, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + child, + ], + ), + ); + } +} + +class _LabeledSlider extends StatelessWidget { + const _LabeledSlider({ + required this.label, + required this.valueLabel, + required this.min, + required this.max, + required this.divisions, + required this.value, + required this.onChanged, + }); + + final String label; + final String valueLabel; + final double min; + final double max; + final int divisions; + final double value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text(label, style: Theme.of(context).textTheme.labelLarge)), + Text(valueLabel, style: Theme.of(context).textTheme.labelLarge), + ], ), - IconButton( - icon: const Icon(Icons.record_voice_over_outlined), - onPressed: () => showModalBottomSheet( - context: context, - builder: (_) => Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text('Text-to-Speech', - style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 16), - TtsPlayerWidget(content: chapter.content), - ], - ), - ), - ), + Slider( + min: min, + max: max, + divisions: divisions, + value: value, + onChanged: onChanged, ), ], ), @@ -184,51 +979,101 @@ class _TopBar extends StatelessWidget { } } -class _BottomBar extends StatelessWidget { - final dynamic settings; - final void Function(dynamic) onSettingsChanged; +class _PresetChip extends StatelessWidget { + const _PresetChip({ + required this.label, + required this.value, + required this.selected, + required this.onTap, + }); - const _BottomBar({required this.settings, required this.onSettingsChanged}); + final String label; + final String value; + final bool selected; + final VoidCallback onTap; @override Widget build(BuildContext context) { - return Container( - color: Theme.of(context).colorScheme.surface.withAlpha(230), - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).padding.bottom + 8, - left: 16, - right: 16, - top: 8, - ), - child: Row( - children: [ - const Icon(Icons.text_decrease, size: 18), - Expanded( - child: Slider( - value: settings.fontSize, - min: 12, - max: 28, - divisions: 8, - onChanged: (v) => onSettingsChanged(settings.copyWith(fontSize: v)), + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(18), + child: Container( + width: 132, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _surfaceForPreset(value), + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: selected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outlineVariant, + width: selected ? 2 : 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 Icon(Icons.text_increase, size: 18), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.format_line_spacing), - onPressed: () { - final next = settings.lineHeight < 2.4 - ? settings.lineHeight + 0.2 - : 1.4; - onSettingsChanged(settings.copyWith(lineHeight: next)); - }, - ), - ], + const SizedBox(height: 8), + Text(label, style: Theme.of(context).textTheme.labelLarge), + ], + ), ), ); } } +class _TabLabel extends StatelessWidget { + const _TabLabel({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16), + const SizedBox(width: 6), + Text(label), + ], + ); + } +} + class _NavButtons extends ConsumerWidget { final ChapterModel chapter; const _NavButtons({required this.chapter}); @@ -244,7 +1089,7 @@ class _NavButtons extends ConsumerWidget { RouteNames.readerChapter(chapter.prevChapterId!), ), icon: const Icon(Icons.chevron_left), - label: Text('Ch. ${chapter.prevChapterNumber ?? '?'}'), + label: Text('Chương ${chapter.prevChapterNumber ?? '?'}'), ), ), if (chapter.prevChapterId != null && chapter.nextChapterId != null) @@ -256,7 +1101,7 @@ class _NavButtons extends ConsumerWidget { RouteNames.readerChapter(chapter.nextChapterId!), ), icon: const Icon(Icons.chevron_right), - label: Text('Ch. ${chapter.nextChapterNumber ?? '?'}'), + label: Text('Chương ${chapter.nextChapterNumber ?? '?'}'), ), ), ], diff --git a/lib/features/reader/presentation/tts_player_widget.dart b/lib/features/reader/presentation/tts_player_widget.dart index 448466b..9e61d77 100644 --- a/lib/features/reader/presentation/tts_player_widget.dart +++ b/lib/features/reader/presentation/tts_player_widget.dart @@ -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, + ), + ), ], ), ); diff --git a/lib/features/reader/providers/reader_provider.dart b/lib/features/reader/providers/reader_provider.dart index 464492d..fc0d033 100644 --- a/lib/features/reader/providers/reader_provider.dart +++ b/lib/features/reader/providers/reader_provider.dart @@ -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 { chapterNumber: chapterNumber, scrollOffset: 0, ); - _persistProgress(chapterId, chapterNumber, 0); } void updateScroll(double offset) { if (state == null) return; diff --git a/lib/features/reader/tts/tts_service.dart b/lib/features/reader/tts/tts_service.dart index 9cc18fe..14fe82c 100644 --- a/lib/features/reader/tts/tts_service.dart +++ b/lib/features/reader/tts/tts_service.dart @@ -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 { final FlutterTts _tts = FlutterTts(); List _paragraphs = []; + bool _initialized = false; + Future? _initFuture; TtsNotifier() : super(const TtsState()) { - _init(); + _initFuture = _init(); } Future _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 { _tts.setErrorHandler((msg) { state = state.copyWith(status: TtsStatus.idle); - WakelockPlus.disable(); }); + + _initialized = true; + } + + Future _configureVietnameseVoice() async { + final dynamic voicesRaw = await _tts.getVoices; + + String? selectedName; + String selectedLanguage = 'vi-VN'; + + if (voicesRaw is List) { + final vietnamese = voicesRaw.whereType().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 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 { paragraphIndex: validIndex, totalParagraphs: _paragraphs.length, ); - await WakelockPlus.enable(); await _speak(validIndex); } Future _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 { 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 { Future pause() async { await _tts.pause(); state = state.copyWith(status: TtsStatus.paused); - await WakelockPlus.disable(); } Future 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 stop() async { await _tts.stop(); state = state.copyWith(status: TtsStatus.idle, paragraphIndex: 0); - await WakelockPlus.disable(); } Future skipForward() async { @@ -126,6 +193,7 @@ class TtsNotifier extends StateNotifier { Future 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 { @override void dispose() { _tts.stop(); - WakelockPlus.disable(); super.dispose(); } } diff --git a/lib/features/search/presentation/search_screen.dart b/lib/features/search/presentation/search_screen.dart index decc2b9..1061c78 100644 --- a/lib/features/search/presentation/search_screen.dart +++ b/lib/features/search/presentation/search_screen.dart @@ -100,7 +100,7 @@ class _SearchScreenState extends ConsumerState { // 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' diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 98df2dc..5c17e12 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -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); }, ), ], diff --git a/lib/features/splash/presentation/splash_screen.dart b/lib/features/splash/presentation/splash_screen.dart index abcb57a..3a2da51 100644 --- a/lib/features/splash/presentation/splash_screen.dart +++ b/lib/features/splash/presentation/splash_screen.dart @@ -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 { + Timer? _redirectTimer; + @override void initState() { super.initState(); - Future.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( diff --git a/lib/main.dart b/lib/main.dart index 0a619cc..ab061e7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,39 @@ +import 'dart:async'; +import 'dart:ui'; + import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'app/app.dart'; +import 'core/logging/app_provider_observer.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); - runApp(const ProviderScope(child: ReaderApp())); + + FlutterError.onError = (details) { + FlutterError.presentError(details); + debugPrint('[APP][FLUTTER_ERROR] ${details.exceptionAsString()}'); + debugPrintStack(stackTrace: details.stack); + }; + + PlatformDispatcher.instance.onError = (error, stack) { + debugPrint('[APP][PLATFORM_ERROR] $error'); + debugPrintStack(stackTrace: stack); + return true; + }; + + runZonedGuarded( + () { + runApp( + const ProviderScope( + observers: [AppProviderObserver()], + child: ReaderApp(), + ), + ); + }, + (error, stack) { + debugPrint('[APP][UNCAUGHT_ASYNC] $error'); + debugPrintStack(stackTrace: stack); + }, + ); } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index c39abb0..c06a2be 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,18 +9,14 @@ import connectivity_plus import flutter_secure_storage_macos import flutter_tts import google_sign_in_ios -import package_info_plus import shared_preferences_foundation import sqflite_darwin -import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) - FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) - WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index c6caea3..920bf6e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -480,22 +480,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" - package_info_plus: - dependency: transitive - description: - name: package_info_plus - sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d - url: "https://pub.dev" - source: hosted - version: "9.0.0" - package_info_plus_platform_interface: - dependency: transitive - description: - name: package_info_plus_platform_interface - sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" - url: "https://pub.dev" - source: hosted - version: "3.2.1" path: dependency: "direct main" description: @@ -797,22 +781,6 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" - wakelock_plus: - dependency: "direct main" - description: - name: wakelock_plus - sha256: "8b12256f616346910c519a35606fb69b1fe0737c06b6a447c6df43888b097f39" - url: "https://pub.dev" - source: hosted - version: "1.5.1" - wakelock_plus_platform_interface: - dependency: transitive - description: - name: wakelock_plus_platform_interface - sha256: "24b84143787220a403491c2e5de0877fbbb87baf3f0b18a2a988973863db4b03" - url: "https://pub.dev" - source: hosted - version: "1.4.0" web: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index efa6ab9..17ca152 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,7 +39,6 @@ dependencies: flutter_secure_storage: ^9.2.4 google_sign_in: ^6.2.1 flutter_tts: ^4.2.3 - wakelock_plus: ^1.4.0 cached_network_image: ^3.4.1 connectivity_plus: ^6.1.4 equatable: ^2.0.7 diff --git a/scripts/flutter_run_with_env.sh b/scripts/flutter_run_with_env.sh new file mode 100755 index 0000000..31cba19 --- /dev/null +++ b/scripts/flutter_run_with_env.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="$ROOT_DIR/.env.mobile" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing $ENV_FILE" + echo "Create it from .env.mobile.example" + exit 1 +fi + +get_env() { + local key="$1" + awk -F'=' -v k="$key" '$1==k{print substr($0, index($0,$2)); exit}' "$ENV_FILE" | sed 's/^"//; s/"$//' +} + +BASE_URL="$(get_env BASE_URL)" +GOOGLE_SERVER_CLIENT_ID="$(get_env GOOGLE_SERVER_CLIENT_ID)" +GOOGLE_CLIENT_ID="$(get_env GOOGLE_CLIENT_ID)" + +if [[ -z "$BASE_URL" ]]; then + echo "BASE_URL is required in $ENV_FILE" + exit 1 +fi + +if [[ -z "$GOOGLE_SERVER_CLIENT_ID" ]]; then + echo "GOOGLE_SERVER_CLIENT_ID is required in $ENV_FILE" + exit 1 +fi + +cd "$ROOT_DIR" + +if [[ -n "$GOOGLE_CLIENT_ID" ]]; then + flutter run \ + --dart-define=BASE_URL="$BASE_URL" \ + --dart-define=GOOGLE_SERVER_CLIENT_ID="$GOOGLE_SERVER_CLIENT_ID" \ + --dart-define=GOOGLE_CLIENT_ID="$GOOGLE_CLIENT_ID" \ + "$@" +else + flutter run \ + --dart-define=BASE_URL="$BASE_URL" \ + --dart-define=GOOGLE_SERVER_CLIENT_ID="$GOOGLE_SERVER_CLIENT_ID" \ + "$@" +fi diff --git a/scripts/google_signin_doctor.sh b/scripts/google_signin_doctor.sh new file mode 100644 index 0000000..c3ff49e --- /dev/null +++ b/scripts/google_signin_doctor.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +APP_GRADLE="$ROOT_DIR/android/app/build.gradle.kts" +GOOGLE_SERVICES="$ROOT_DIR/android/app/google-services.json" +DEBUG_KEYSTORE="$HOME/.android/debug.keystore" + +echo "== Google Sign-In Doctor (Android) ==" +echo "Project: $ROOT_DIR" + +if [[ -f "$APP_GRADLE" ]]; then + PKG=$(grep -E 'applicationId\s*=\s*"' "$APP_GRADLE" | head -n1 | sed -E 's/.*"([^"]+)".*/\1/') + echo "Package name: ${PKG:-}" +else + echo "ERROR: Missing $APP_GRADLE" +fi + +if [[ -f "$GOOGLE_SERVICES" ]]; then + echo "google-services.json: FOUND ($GOOGLE_SERVICES)" + if grep -q '"project_info"' "$GOOGLE_SERVICES"; then + echo "google-services.json format: OK" + else + echo "google-services.json format: INVALID (this does not look like Firebase Android config)" + fi +else + echo "google-services.json: MISSING ($GOOGLE_SERVICES)" +fi + +CLIENT_SECRET_FILE=$(ls "$ROOT_DIR"/android/app/client_secret_*.json 2>/dev/null | head -n1 || true) +if [[ -n "$CLIENT_SECRET_FILE" ]]; then + echo "Found OAuth client secret file: $(basename "$CLIENT_SECRET_FILE")" + echo "NOTE: This file is NOT a replacement for google-services.json on Android." +fi + +if [[ -f "$DEBUG_KEYSTORE" ]]; then + echo "\nDebug keystore fingerprints:" + keytool -list -v -alias androiddebugkey -keystore "$DEBUG_KEYSTORE" -storepass android -keypass android \ + | grep -E 'SHA1:|SHA256:' || true +else + echo "\nDebug keystore: MISSING ($DEBUG_KEYSTORE)" +fi + +echo "\nChecklist for ApiException: 10" +echo "1) Create Android app in Firebase with exact package name above." +echo "2) Add BOTH SHA1 and SHA256 shown above to Firebase Android app settings." +echo "3) Download new google-services.json and place it at android/app/google-services.json." +echo "4) Run app again (full restart, not hot reload)." diff --git a/test/widget_test.dart b/test/widget_test.dart index 764f7d5..e411306 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -6,9 +6,7 @@ import 'package:reader_app/app/app.dart'; void main() { testWidgets('Reader app renders', (WidgetTester tester) async { await tester.pumpWidget(const ProviderScope(child: ReaderApp())); - await tester.pump(const Duration(milliseconds: 800)); - await tester.pumpAndSettle(); - expect(find.text('Trang chu'), findsOneWidget); + expect(find.text('Reader App'), findsOneWidget); }); }