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
View File
@@ -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=
+3
View File
@@ -43,3 +43,6 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
# Local mobile runtime defines
.env.mobile
+85
View File
@@ -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://<YOUR_LAN_IP>: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=<YOUR_WEB_CLIENT_ID>.apps.googleusercontent.com
```
Optional (iOS/web):
```bash
--dart-define=GOOGLE_CLIENT_ID=<YOUR_IOS_OR_WEB_CLIENT_ID>.apps.googleusercontent.com
```
+1
View File
@@ -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 {
+47
View File
@@ -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"
}
+3 -1
View File
@@ -1,8 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="reader_app"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
+1
View File
@@ -21,6 +21,7 @@ plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
id("com.google.gms.google-services") version "4.4.2" apply false
}
include(":app")
+9
View File
@@ -26,6 +26,11 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
@@ -49,6 +54,10 @@
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
+2
View File
@@ -15,6 +15,8 @@ class ReaderApp extends ConsumerWidget {
title: 'Reader App',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system,
routerConfig: router,
);
}
+1 -1
View File
@@ -61,7 +61,7 @@ final appRouterProvider = Provider<GoRouter>((ref) {
GoRoute(
path: RouteNames.readerPath,
builder: (_, state) => ReaderScreen(
chapterId: state.pathParameters['chapterId'] ?? '',
chapterId: Uri.decodeComponent(state.pathParameters['chapterId'] ?? ''),
),
),
GoRoute(
+1 -1
View File
@@ -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;
+20 -4
View File
@@ -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: '',
);
}
@@ -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<Object?> provider,
Object error,
StackTrace stackTrace,
ProviderContainer container,
) {
debugPrint('[APP][PROVIDER_ERROR] ${provider.name ?? provider.runtimeType}: $error');
debugPrintStack(stackTrace: stackTrace);
}
}
+17 -6
View File
@@ -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<String, dynamic> 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?,
+24
View File
@@ -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<String, dynamic> 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<String, dynamic> toJson() => {
@@ -36,5 +56,9 @@ class ReadingSettings {
'lineHeight': lineHeight,
'letterSpacing': letterSpacing,
'fontFamily': fontFamily,
'themePreset': themePreset,
'horizontalPadding': horizontalPadding,
'paragraphSpacing': paragraphSpacing,
'textAlign': textAlign,
};
}
+17
View File
@@ -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);
},
),
);
}
+12
View File
@@ -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<ReadingSettings?> 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',
);
}
+14
View File
@@ -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),
),
);
}
@@ -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
+41 -4
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,11 +40,27 @@ class AuthNotifier extends StateNotifier<AuthState> {
SecureStore get _store => _ref.read(secureStoreProvider);
final GoogleSignIn _googleSignIn = GoogleSignIn(
clientId: AppConfig.googleClientId.isNotEmpty ? AppConfig.googleClientId : null,
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();
if (token != null && token.isNotEmpty) {
@@ -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,11 +13,18 @@ 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(
body: switch (authState) {
AuthAuthenticated(:final user) => SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
@@ -32,21 +39,19 @@ class ProfileScreen extends ConsumerWidget {
children: [
CircleAvatar(
radius: 40,
backgroundImage: user.image != null
? NetworkImage(user.image!)
: null,
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,
displayName[0].toUpperCase(),
style:
Theme.of(context).textTheme.headlineMedium,
)
: null,
),
const SizedBox(height: 12),
Text(
user.name,
displayName,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
@@ -68,7 +73,7 @@ class ProfileScreen extends ConsumerWidget {
child: _buildStatCard(
context: context,
label: 'Sách Đánh Dấu',
count: bookshelfAsync.whenData((b) => b.length).value ?? 0,
count: bookmarkedCount,
),
),
const SizedBox(width: 12),
@@ -76,10 +81,7 @@ class ProfileScreen extends ConsumerWidget {
child: _buildStatCard(
context: context,
label: 'Đang Đọc',
count: bookshelfAsync
.whenData((b) => b.where((x) => true).length)
.value ??
0,
count: bookmarkedCount,
),
),
],
@@ -103,7 +105,7 @@ class ProfileScreen extends ConsumerWidget {
child: OutlinedButton.icon(
onPressed: () async {
await ref.read(authProvider.notifier).signOut();
if (context.mounted) context.go(RouteNames.login);
if (context.mounted) context.go(RouteNames.home);
},
icon: const Icon(Icons.logout),
label: const Text('Đăng Xuất'),
@@ -112,8 +114,29 @@ class ProfileScreen extends ConsumerWidget {
],
),
),
orElse: () => const Center(child: CircularProgressIndicator()),
AuthError(:final message) => Center(child: Text(message)),
AuthUnauthenticated() => Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton(
onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(),
child: const Text('Đăng nhập bằng Google'),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => context.push(RouteNames.settings),
icon: const Icon(Icons.tune),
label: const Text('Mở Cài Đặt Đọc'),
),
],
),
),
),
_ => const Center(child: CircularProgressIndicator()),
},
);
}
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(
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(
+31 -1
View File
@@ -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);
},
);
}
@@ -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"))
}
-32
View File
@@ -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:
-1
View File
@@ -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
+45
View File
@@ -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
+48
View File
@@ -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:-<not-found>}"
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)."
+1 -3
View File
@@ -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);
});
}