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:
@@ -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=
|
||||||
@@ -43,3 +43,6 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
|
# Local mobile runtime defines
|
||||||
|
.env.mobile
|
||||||
|
|||||||
@@ -30,3 +30,88 @@ Flutter mobile app for reading novels, synced with the existing web platform.
|
|||||||
flutter pub get
|
flutter pub get
|
||||||
flutter run
|
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
|
||||||
|
```
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ plugins {
|
|||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
|
id("com.google.gms.google-services")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<application
|
<application
|
||||||
android:label="reader_app"
|
android:label="reader_app"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ plugins {
|
|||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.11.1" apply false
|
id("com.android.application") version "8.11.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.20" 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")
|
include(":app")
|
||||||
|
|||||||
@@ -26,6 +26,11 @@
|
|||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
@@ -49,6 +54,10 @@
|
|||||||
</dict>
|
</dict>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ class ReaderApp extends ConsumerWidget {
|
|||||||
title: 'Reader App',
|
title: 'Reader App',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.lightTheme,
|
theme: AppTheme.lightTheme,
|
||||||
|
darkTheme: AppTheme.darkTheme,
|
||||||
|
themeMode: ThemeMode.system,
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteNames.readerPath,
|
path: RouteNames.readerPath,
|
||||||
builder: (_, state) => ReaderScreen(
|
builder: (_, state) => ReaderScreen(
|
||||||
chapterId: state.pathParameters['chapterId'] ?? '',
|
chapterId: Uri.decodeComponent(state.pathParameters['chapterId'] ?? ''),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class RouteNames {
|
|||||||
|
|
||||||
// Navigation helpers
|
// Navigation helpers
|
||||||
static String novelDetail(String id) => '/novel/$id';
|
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}) {
|
static String commentsFor(String novelId, {String? chapterId}) {
|
||||||
final base = '/comments/$novelId';
|
final base = '/comments/$novelId';
|
||||||
return chapterId != null ? '$base?chapterId=$chapterId' : base;
|
return chapterId != null ? '$base?chapterId=$chapterId' : base;
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
class AppConfig {
|
class AppConfig {
|
||||||
AppConfig._();
|
AppConfig._();
|
||||||
|
|
||||||
static const String baseUrl = String.fromEnvironment(
|
static const String _baseUrlFromEnv = String.fromEnvironment('BASE_URL');
|
||||||
'BASE_URL',
|
|
||||||
defaultValue: 'https://localhost:3000',
|
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(
|
static const String googleClientId = String.fromEnvironment(
|
||||||
'GOOGLE_CLIENT_ID',
|
'GOOGLE_CLIENT_ID',
|
||||||
defaultValue: '',
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,13 +41,24 @@ class NovelModel extends Equatable {
|
|||||||
final SeriesModel? series;
|
final SeriesModel? series;
|
||||||
final LatestChapterInfo? latestChapter;
|
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(
|
factory NovelModel.fromJson(Map<String, dynamic> json) => NovelModel(
|
||||||
id: json['id'] as String,
|
id: _stringValue(json['id']),
|
||||||
title: json['title'] as String,
|
title: _stringValue(json['title'], fallback: 'Không rõ tiêu đề'),
|
||||||
slug: json['slug'] as String,
|
slug: _stringValue(json['slug']),
|
||||||
authorName: json['authorName'] as String,
|
authorName: _stringValue(json['authorName'], fallback: 'Chưa rõ tác giả'),
|
||||||
status: json['status'] as String,
|
status: _stringValue(json['status'], fallback: 'Đang ra'),
|
||||||
totalChapters: (json['totalChapters'] as num).toInt(),
|
totalChapters: _intValue(json['totalChapters']),
|
||||||
originalTitle: json['originalTitle'] as String?,
|
originalTitle: json['originalTitle'] as String?,
|
||||||
description: json['description'] as String?,
|
description: json['description'] as String?,
|
||||||
coverUrl: json['coverUrl'] as String?,
|
coverUrl: json['coverUrl'] as String?,
|
||||||
|
|||||||
@@ -4,24 +4,40 @@ class ReadingSettings {
|
|||||||
this.lineHeight = 1.8,
|
this.lineHeight = 1.8,
|
||||||
this.letterSpacing = 0,
|
this.letterSpacing = 0,
|
||||||
this.fontFamily = 'serif',
|
this.fontFamily = 'serif',
|
||||||
|
this.themePreset = 'paper',
|
||||||
|
this.horizontalPadding = 20,
|
||||||
|
this.paragraphSpacing = 24,
|
||||||
|
this.textAlign = 'justify',
|
||||||
});
|
});
|
||||||
|
|
||||||
final double fontSize;
|
final double fontSize;
|
||||||
final double lineHeight;
|
final double lineHeight;
|
||||||
final double letterSpacing;
|
final double letterSpacing;
|
||||||
final String fontFamily;
|
final String fontFamily;
|
||||||
|
final String themePreset;
|
||||||
|
final double horizontalPadding;
|
||||||
|
final double paragraphSpacing;
|
||||||
|
final String textAlign;
|
||||||
|
|
||||||
ReadingSettings copyWith({
|
ReadingSettings copyWith({
|
||||||
double? fontSize,
|
double? fontSize,
|
||||||
double? lineHeight,
|
double? lineHeight,
|
||||||
double? letterSpacing,
|
double? letterSpacing,
|
||||||
String? fontFamily,
|
String? fontFamily,
|
||||||
|
String? themePreset,
|
||||||
|
double? horizontalPadding,
|
||||||
|
double? paragraphSpacing,
|
||||||
|
String? textAlign,
|
||||||
}) =>
|
}) =>
|
||||||
ReadingSettings(
|
ReadingSettings(
|
||||||
fontSize: fontSize ?? this.fontSize,
|
fontSize: fontSize ?? this.fontSize,
|
||||||
lineHeight: lineHeight ?? this.lineHeight,
|
lineHeight: lineHeight ?? this.lineHeight,
|
||||||
letterSpacing: letterSpacing ?? this.letterSpacing,
|
letterSpacing: letterSpacing ?? this.letterSpacing,
|
||||||
fontFamily: fontFamily ?? this.fontFamily,
|
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(
|
factory ReadingSettings.fromJson(Map<String, dynamic> json) => ReadingSettings(
|
||||||
@@ -29,6 +45,10 @@ class ReadingSettings {
|
|||||||
lineHeight: (json['lineHeight'] as num?)?.toDouble() ?? 1.8,
|
lineHeight: (json['lineHeight'] as num?)?.toDouble() ?? 1.8,
|
||||||
letterSpacing: (json['letterSpacing'] as num?)?.toDouble() ?? 0,
|
letterSpacing: (json['letterSpacing'] as num?)?.toDouble() ?? 0,
|
||||||
fontFamily: json['fontFamily'] as String? ?? 'serif',
|
fontFamily: json['fontFamily'] as String? ?? 'serif',
|
||||||
|
themePreset: json['themePreset'] as String? ?? 'paper',
|
||||||
|
horizontalPadding: (json['horizontalPadding'] as num?)?.toDouble() ?? 20,
|
||||||
|
paragraphSpacing: (json['paragraphSpacing'] as num?)?.toDouble() ?? 24,
|
||||||
|
textAlign: json['textAlign'] as String? ?? 'justify',
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
@@ -36,5 +56,9 @@ class ReadingSettings {
|
|||||||
'lineHeight': lineHeight,
|
'lineHeight': lineHeight,
|
||||||
'letterSpacing': letterSpacing,
|
'letterSpacing': letterSpacing,
|
||||||
'fontFamily': fontFamily,
|
'fontFamily': fontFamily,
|
||||||
|
'themePreset': themePreset,
|
||||||
|
'horizontalPadding': horizontalPadding,
|
||||||
|
'paragraphSpacing': paragraphSpacing,
|
||||||
|
'textAlign': textAlign,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../storage/secure_store.dart';
|
import '../storage/secure_store.dart';
|
||||||
|
|
||||||
@@ -18,12 +19,28 @@ class ApiClient {
|
|||||||
dio.interceptors.add(
|
dio.interceptors.add(
|
||||||
InterceptorsWrapper(
|
InterceptorsWrapper(
|
||||||
onRequest: (options, handler) async {
|
onRequest: (options, handler) async {
|
||||||
|
debugPrint('[API] ${options.method} ${options.baseUrl}${options.path}');
|
||||||
final token = await _secureStore.getAccessToken();
|
final token = await _secureStore.getAccessToken();
|
||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
options.headers['Authorization'] = 'Bearer $token';
|
options.headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
handler.next(options);
|
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);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ class LocalStore {
|
|||||||
static const _kLineHeight = 'reader_line_height';
|
static const _kLineHeight = 'reader_line_height';
|
||||||
static const _kLetterSpacing = 'reader_letter_spacing';
|
static const _kLetterSpacing = 'reader_letter_spacing';
|
||||||
static const _kFontFamily = 'reader_font_family';
|
static const _kFontFamily = 'reader_font_family';
|
||||||
|
static const _kThemePreset = 'reader_theme_preset';
|
||||||
|
static const _kHorizontalPadding = 'reader_horizontal_padding';
|
||||||
|
static const _kParagraphSpacing = 'reader_paragraph_spacing';
|
||||||
|
static const _kTextAlign = 'reader_text_align';
|
||||||
static const _kProgressChapterId = 'progress_chapter_id_';
|
static const _kProgressChapterId = 'progress_chapter_id_';
|
||||||
static const _kProgressChapterNum = 'progress_chapter_num_';
|
static const _kProgressChapterNum = 'progress_chapter_num_';
|
||||||
static const _kProgressOffset = 'progress_offset_';
|
static const _kProgressOffset = 'progress_offset_';
|
||||||
@@ -19,6 +23,10 @@ class LocalStore {
|
|||||||
await prefs.setDouble(_kLineHeight, settings.lineHeight);
|
await prefs.setDouble(_kLineHeight, settings.lineHeight);
|
||||||
await prefs.setDouble(_kLetterSpacing, settings.letterSpacing);
|
await prefs.setDouble(_kLetterSpacing, settings.letterSpacing);
|
||||||
await prefs.setString(_kFontFamily, settings.fontFamily);
|
await prefs.setString(_kFontFamily, settings.fontFamily);
|
||||||
|
await prefs.setString(_kThemePreset, settings.themePreset);
|
||||||
|
await prefs.setDouble(_kHorizontalPadding, settings.horizontalPadding);
|
||||||
|
await prefs.setDouble(_kParagraphSpacing, settings.paragraphSpacing);
|
||||||
|
await prefs.setString(_kTextAlign, settings.textAlign);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ReadingSettings?> loadReadingSettings() async {
|
Future<ReadingSettings?> loadReadingSettings() async {
|
||||||
@@ -29,6 +37,10 @@ class LocalStore {
|
|||||||
lineHeight: prefs.getDouble(_kLineHeight) ?? 1.8,
|
lineHeight: prefs.getDouble(_kLineHeight) ?? 1.8,
|
||||||
letterSpacing: prefs.getDouble(_kLetterSpacing) ?? 0,
|
letterSpacing: prefs.getDouble(_kLetterSpacing) ?? 0,
|
||||||
fontFamily: prefs.getString(_kFontFamily) ?? 'serif',
|
fontFamily: prefs.getString(_kFontFamily) ?? 'serif',
|
||||||
|
themePreset: prefs.getString(_kThemePreset) ?? 'paper',
|
||||||
|
horizontalPadding: prefs.getDouble(_kHorizontalPadding) ?? 20,
|
||||||
|
paragraphSpacing: prefs.getDouble(_kParagraphSpacing) ?? 24,
|
||||||
|
textAlign: prefs.getString(_kTextAlign) ?? 'justify',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,4 +16,18 @@ class AppTheme {
|
|||||||
foregroundColor: Color(0xFF121826),
|
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 '../../../app/router/route_names.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
|
|
||||||
class LoginScreen extends ConsumerWidget {
|
class LoginScreen extends ConsumerStatefulWidget {
|
||||||
const LoginScreen({super.key});
|
const LoginScreen({super.key});
|
||||||
|
|
||||||
@override
|
@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);
|
final authState = ref.watch(authProvider);
|
||||||
|
|
||||||
ref.listen<AuthState>(authProvider, (_, next) {
|
ref.listen<AuthState>(authProvider, (_, next) {
|
||||||
@@ -42,6 +59,16 @@ class LoginScreen extends ConsumerWidget {
|
|||||||
Text(errorMsg, style: const TextStyle(color: Colors.red)),
|
Text(errorMsg, style: const TextStyle(color: Colors.red)),
|
||||||
const SizedBox(height: 16),
|
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(
|
FilledButton.icon(
|
||||||
onPressed: isLoading
|
onPressed: isLoading
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:google_sign_in/google_sign_in.dart';
|
import 'package:google_sign_in/google_sign_in.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../../../core/config/app_config.dart';
|
import '../../../core/config/app_config.dart';
|
||||||
import '../../../core/models/user_model.dart';
|
import '../../../core/models/user_model.dart';
|
||||||
@@ -38,11 +40,27 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
|
|
||||||
SecureStore get _store => _ref.read(secureStoreProvider);
|
SecureStore get _store => _ref.read(secureStoreProvider);
|
||||||
|
|
||||||
final GoogleSignIn _googleSignIn = GoogleSignIn(
|
GoogleSignIn get _googleSignIn => GoogleSignIn(
|
||||||
clientId: AppConfig.googleClientId.isNotEmpty ? AppConfig.googleClientId : null,
|
// 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'],
|
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 {
|
Future<void> _restore() async {
|
||||||
final token = await _store.getAccessToken();
|
final token = await _store.getAccessToken();
|
||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
@@ -66,6 +84,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
Future<void> signInWithGoogle() async {
|
Future<void> signInWithGoogle() async {
|
||||||
try {
|
try {
|
||||||
state = AuthLoading();
|
state = AuthLoading();
|
||||||
|
_logGoogleSignInConfig();
|
||||||
final account = await _googleSignIn.signIn();
|
final account = await _googleSignIn.signIn();
|
||||||
if (account == null) {
|
if (account == null) {
|
||||||
state = AuthUnauthenticated();
|
state = AuthUnauthenticated();
|
||||||
@@ -94,10 +113,28 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
state = AuthAuthenticated(
|
state = AuthAuthenticated(
|
||||||
UserModel.fromJson(data['user'] as Map<String, dynamic>),
|
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';
|
final msg = (e.response?.data as Map?)?['error'] ?? e.message ?? 'Login failed';
|
||||||
state = AuthError(msg.toString());
|
state = AuthError(msg.toString());
|
||||||
} catch (e) {
|
} catch (e, st) {
|
||||||
|
debugPrint('[AUTH][UNEXPECTED][ERROR] $e');
|
||||||
|
debugPrintStack(stackTrace: st);
|
||||||
state = AuthError(e.toString());
|
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 Text('Vui lòng đăng nhập để xem tủ sách'),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => context.push(RouteNames.login),
|
onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(),
|
||||||
child: const Text('Đăng nhập'),
|
child: const Text('Đăng nhập bằng Google'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ class _CommentsScreenState extends ConsumerState<CommentsScreen> {
|
|||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
itemCount: comments.length,
|
itemCount: comments.length,
|
||||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
separatorBuilder: (_, separatorIndex) =>
|
||||||
|
const Divider(height: 1),
|
||||||
itemBuilder: (context, index) =>
|
itemBuilder: (context, index) =>
|
||||||
_CommentTile(comment: comments[index]),
|
_CommentTile(comment: comments[index]),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
const Icon(Icons.error_outline, size: 48),
|
const Icon(Icons.error_outline, size: 48),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text('Lỗi tải dữ liệu', style: Theme.of(context).textTheme.bodyLarge),
|
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(
|
TextButton(
|
||||||
onPressed: () => ref.invalidate(homeProvider),
|
onPressed: () => ref.invalidate(homeProvider),
|
||||||
child: const Text('Thử lại'),
|
child: const Text('Thử lại'),
|
||||||
@@ -138,8 +148,9 @@ class _CarouselCard extends StatelessWidget {
|
|||||||
CachedNetworkImage(
|
CachedNetworkImage(
|
||||||
imageUrl: novel.coverUrl!,
|
imageUrl: novel.coverUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: (_, __) => Container(color: Colors.grey[200]),
|
placeholder: (_, imageUrl) => Container(color: Colors.grey[200]),
|
||||||
errorWidget: (_, __, ___) => Container(color: Colors.grey[300]),
|
errorWidget: (_, imageUrl, error) =>
|
||||||
|
Container(color: Colors.grey[300]),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Container(color: Theme.of(context).colorScheme.primaryContainer),
|
Container(color: Theme.of(context).colorScheme.primaryContainer),
|
||||||
@@ -188,7 +199,7 @@ class _NovelHorizontalList extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
itemCount: novels.length,
|
itemCount: novels.length,
|
||||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
separatorBuilder: (_, separatorIndex) => const SizedBox(width: 12),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final novel = novels[index];
|
final novel = novels[index];
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
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/models/novel_model.dart';
|
||||||
import '../../../core/network/providers.dart';
|
import '../../../core/network/providers.dart';
|
||||||
|
|
||||||
@@ -17,22 +19,46 @@ class HomeData {
|
|||||||
final homeProvider = FutureProvider<HomeData>((ref) async {
|
final homeProvider = FutureProvider<HomeData>((ref) async {
|
||||||
final client = ref.read(apiClientProvider);
|
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': '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': 'latest', 'limit': '20', 'page': '1'}),
|
||||||
client.dio.get('/api/novels/browse', queryParameters: {'sort': 'rating', 'limit': '10', 'page': '1'}),
|
client.dio.get('/api/novels/browse', queryParameters: {'sort': 'rating', 'limit': '10', 'page': '1'}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
List<NovelModel> parseItems(dynamic res) {
|
List<NovelModel> parseItems(Response<dynamic> res, String feedName) {
|
||||||
final data = res.data as Map<String, dynamic>;
|
final raw = res.data;
|
||||||
return (data['items'] as List)
|
if (raw is! Map<String, dynamic>) {
|
||||||
.map((e) => NovelModel.fromJson(e as Map<String, dynamic>))
|
throw FormatException('Feed $feedName response is not a JSON object: ${raw.runtimeType}');
|
||||||
.toList();
|
}
|
||||||
|
|
||||||
|
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(
|
return HomeData(
|
||||||
hot: parseItems(results[0]),
|
hot: parseItems(results[0], 'popular'),
|
||||||
latest: parseItems(results[1]),
|
latest: parseItems(results[1], 'latest'),
|
||||||
topRated: parseItems(results[2]),
|
topRated: parseItems(results[2], 'rating'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class NovelDetailScreen extends ConsumerWidget {
|
|||||||
// Read button
|
// Read button
|
||||||
chaptersAsync.when(
|
chaptersAsync.when(
|
||||||
loading: () => const SizedBox.shrink(),
|
loading: () => const SizedBox.shrink(),
|
||||||
error: (_, __) => const SizedBox.shrink(),
|
error: (_, error) => const SizedBox.shrink(),
|
||||||
data: (chapters) {
|
data: (chapters) {
|
||||||
if (chapters.isEmpty) return const SizedBox.shrink();
|
if (chapters.isEmpty) return const SizedBox.shrink();
|
||||||
final first = chapters.first;
|
final first = chapters.first;
|
||||||
@@ -102,7 +102,8 @@ class NovelDetailScreen extends ConsumerWidget {
|
|||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
error: (_, __) => const SliverToBoxAdapter(child: SizedBox.shrink()),
|
error: (_, error) =>
|
||||||
|
const SliverToBoxAdapter(child: SizedBox.shrink()),
|
||||||
data: (chapters) => SliverList(
|
data: (chapters) => SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) {
|
(context, index) {
|
||||||
|
|||||||
@@ -13,11 +13,18 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
final bookshelfAsync = ref.watch(bookshelfProvider);
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Tài khoản')),
|
appBar: AppBar(title: const Text('Tài khoản')),
|
||||||
body: authState.maybeWhen(
|
body: switch (authState) {
|
||||||
authenticated: (user) => SingleChildScrollView(
|
AuthAuthenticated(:final user) => SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -32,21 +39,19 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 40,
|
radius: 40,
|
||||||
backgroundImage: user.image != null
|
backgroundImage:
|
||||||
? NetworkImage(user.image!)
|
user.image != null ? NetworkImage(user.image!) : null,
|
||||||
: null,
|
|
||||||
child: user.image == null
|
child: user.image == null
|
||||||
? Text(
|
? Text(
|
||||||
user.name.isNotEmpty
|
displayName[0].toUpperCase(),
|
||||||
? user.name[0].toUpperCase()
|
style:
|
||||||
: '?',
|
Theme.of(context).textTheme.headlineMedium,
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
user.name,
|
displayName,
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -68,7 +73,7 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
child: _buildStatCard(
|
child: _buildStatCard(
|
||||||
context: context,
|
context: context,
|
||||||
label: 'Sách Đánh Dấu',
|
label: 'Sách Đánh Dấu',
|
||||||
count: bookshelfAsync.whenData((b) => b.length).value ?? 0,
|
count: bookmarkedCount,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -76,10 +81,7 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
child: _buildStatCard(
|
child: _buildStatCard(
|
||||||
context: context,
|
context: context,
|
||||||
label: 'Đang Đọc',
|
label: 'Đang Đọc',
|
||||||
count: bookshelfAsync
|
count: bookmarkedCount,
|
||||||
.whenData((b) => b.where((x) => true).length)
|
|
||||||
.value ??
|
|
||||||
0,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -103,7 +105,7 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await ref.read(authProvider.notifier).signOut();
|
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),
|
icon: const Icon(Icons.logout),
|
||||||
label: const Text('Đăng Xuất'),
|
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)
|
if (!tts.isPlaying)
|
||||||
IconButton.filled(
|
IconButton.filled(
|
||||||
icon: const Icon(Icons.play_arrow),
|
icon: const Icon(Icons.play_arrow),
|
||||||
onPressed: () => notifier.startReading(
|
onPressed: () {
|
||||||
|
if (tts.status == TtsStatus.paused) {
|
||||||
|
notifier.resume();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifier.startReading(
|
||||||
content,
|
content,
|
||||||
paragraphIndex: tts.paragraphIndex,
|
paragraphIndex: tts.paragraphIndex,
|
||||||
),
|
);
|
||||||
|
},
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
IconButton.filled(
|
IconButton.filled(
|
||||||
@@ -68,6 +74,23 @@ class TtsPlayerWidget extends ConsumerWidget {
|
|||||||
style: Theme.of(context).textTheme.labelSmall,
|
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 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/models/chapter_model.dart';
|
import '../../../core/models/chapter_model.dart';
|
||||||
import '../../../core/models/reading_settings.dart';
|
import '../../../core/models/reading_settings.dart';
|
||||||
@@ -21,8 +22,10 @@ final chapterProvider =
|
|||||||
unawaited(offlineCache.saveChapter(chapter));
|
unawaited(offlineCache.saveChapter(chapter));
|
||||||
return chapter;
|
return chapter;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
debugPrint('[READER][CHAPTER][ERROR] Failed to load chapterId=$chapterId from network, trying cache');
|
||||||
final cached = await offlineCache.loadChapter(chapterId);
|
final cached = await offlineCache.loadChapter(chapterId);
|
||||||
if (cached != null) return cached;
|
if (cached != null) return cached;
|
||||||
|
debugPrint('[READER][CHAPTER][ERROR] No cache for chapterId=$chapterId');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -57,7 +60,6 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
|
|||||||
chapterNumber: chapterNumber,
|
chapterNumber: chapterNumber,
|
||||||
scrollOffset: 0,
|
scrollOffset: 0,
|
||||||
);
|
);
|
||||||
_persistProgress(chapterId, chapterNumber, 0);
|
|
||||||
}
|
}
|
||||||
void updateScroll(double offset) {
|
void updateScroll(double offset) {
|
||||||
if (state == null) return;
|
if (state == null) return;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_tts/flutter_tts.dart';
|
import 'package:flutter_tts/flutter_tts.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
|
||||||
|
|
||||||
enum TtsStatus { idle, playing, paused }
|
enum TtsStatus { idle, playing, paused }
|
||||||
|
|
||||||
@@ -9,12 +10,16 @@ class TtsState {
|
|||||||
final int paragraphIndex;
|
final int paragraphIndex;
|
||||||
final int totalParagraphs;
|
final int totalParagraphs;
|
||||||
final double speed;
|
final double speed;
|
||||||
|
final String language;
|
||||||
|
final String? voiceName;
|
||||||
|
|
||||||
const TtsState({
|
const TtsState({
|
||||||
this.status = TtsStatus.idle,
|
this.status = TtsStatus.idle,
|
||||||
this.paragraphIndex = 0,
|
this.paragraphIndex = 0,
|
||||||
this.totalParagraphs = 0,
|
this.totalParagraphs = 0,
|
||||||
this.speed = 1.0,
|
this.speed = 1.0,
|
||||||
|
this.language = 'vi-VN',
|
||||||
|
this.voiceName,
|
||||||
});
|
});
|
||||||
|
|
||||||
TtsState copyWith({
|
TtsState copyWith({
|
||||||
@@ -22,12 +27,17 @@ class TtsState {
|
|||||||
int? paragraphIndex,
|
int? paragraphIndex,
|
||||||
int? totalParagraphs,
|
int? totalParagraphs,
|
||||||
double? speed,
|
double? speed,
|
||||||
|
String? language,
|
||||||
|
String? voiceName,
|
||||||
|
bool clearVoiceName = false,
|
||||||
}) =>
|
}) =>
|
||||||
TtsState(
|
TtsState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
paragraphIndex: paragraphIndex ?? this.paragraphIndex,
|
paragraphIndex: paragraphIndex ?? this.paragraphIndex,
|
||||||
totalParagraphs: totalParagraphs ?? this.totalParagraphs,
|
totalParagraphs: totalParagraphs ?? this.totalParagraphs,
|
||||||
speed: speed ?? this.speed,
|
speed: speed ?? this.speed,
|
||||||
|
language: language ?? this.language,
|
||||||
|
voiceName: clearVoiceName ? null : (voiceName ?? this.voiceName),
|
||||||
);
|
);
|
||||||
|
|
||||||
bool get isPlaying => status == TtsStatus.playing;
|
bool get isPlaying => status == TtsStatus.playing;
|
||||||
@@ -36,17 +46,42 @@ class TtsState {
|
|||||||
class TtsNotifier extends StateNotifier<TtsState> {
|
class TtsNotifier extends StateNotifier<TtsState> {
|
||||||
final FlutterTts _tts = FlutterTts();
|
final FlutterTts _tts = FlutterTts();
|
||||||
List<String> _paragraphs = [];
|
List<String> _paragraphs = [];
|
||||||
|
bool _initialized = false;
|
||||||
|
Future<void>? _initFuture;
|
||||||
|
|
||||||
TtsNotifier() : super(const TtsState()) {
|
TtsNotifier() : super(const TtsState()) {
|
||||||
_init();
|
_initFuture = _init();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _init() async {
|
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.setSpeechRate(1.0);
|
||||||
await _tts.setVolume(1.0);
|
await _tts.setVolume(1.0);
|
||||||
await _tts.setPitch(1.0);
|
await _tts.setPitch(1.0);
|
||||||
|
|
||||||
|
_tts.setStartHandler(() {
|
||||||
|
state = state.copyWith(status: TtsStatus.playing);
|
||||||
|
});
|
||||||
|
|
||||||
_tts.setCompletionHandler(() {
|
_tts.setCompletionHandler(() {
|
||||||
if (state.status == TtsStatus.playing) {
|
if (state.status == TtsStatus.playing) {
|
||||||
_next();
|
_next();
|
||||||
@@ -55,12 +90,49 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
|
|
||||||
_tts.setErrorHandler((msg) {
|
_tts.setErrorHandler((msg) {
|
||||||
state = state.copyWith(status: TtsStatus.idle);
|
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].
|
/// Start reading from [content] starting at optional [paragraphIndex].
|
||||||
Future<void> startReading(String content, {int paragraphIndex = 0}) async {
|
Future<void> startReading(String content, {int paragraphIndex = 0}) async {
|
||||||
|
if (!_initialized) {
|
||||||
|
await (_initFuture ?? _init());
|
||||||
|
}
|
||||||
|
|
||||||
_paragraphs = content
|
_paragraphs = content
|
||||||
.split(RegExp(r'\n+'))
|
.split(RegExp(r'\n+'))
|
||||||
.map((p) => p.trim())
|
.map((p) => p.trim())
|
||||||
@@ -75,14 +147,12 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
paragraphIndex: validIndex,
|
paragraphIndex: validIndex,
|
||||||
totalParagraphs: _paragraphs.length,
|
totalParagraphs: _paragraphs.length,
|
||||||
);
|
);
|
||||||
await WakelockPlus.enable();
|
|
||||||
await _speak(validIndex);
|
await _speak(validIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _speak(int index) async {
|
Future<void> _speak(int index) async {
|
||||||
if (index >= _paragraphs.length) {
|
if (index >= _paragraphs.length) {
|
||||||
state = state.copyWith(status: TtsStatus.idle);
|
state = state.copyWith(status: TtsStatus.idle);
|
||||||
await WakelockPlus.disable();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _tts.setSpeechRate(state.speed);
|
await _tts.setSpeechRate(state.speed);
|
||||||
@@ -93,7 +163,6 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
final next = state.paragraphIndex + 1;
|
final next = state.paragraphIndex + 1;
|
||||||
if (next >= state.totalParagraphs) {
|
if (next >= state.totalParagraphs) {
|
||||||
state = state.copyWith(status: TtsStatus.idle, paragraphIndex: 0);
|
state = state.copyWith(status: TtsStatus.idle, paragraphIndex: 0);
|
||||||
await WakelockPlus.disable();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state = state.copyWith(paragraphIndex: next);
|
state = state.copyWith(paragraphIndex: next);
|
||||||
@@ -103,20 +172,18 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
Future<void> pause() async {
|
Future<void> pause() async {
|
||||||
await _tts.pause();
|
await _tts.pause();
|
||||||
state = state.copyWith(status: TtsStatus.paused);
|
state = state.copyWith(status: TtsStatus.paused);
|
||||||
await WakelockPlus.disable();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> resume() async {
|
Future<void> resume() async {
|
||||||
if (state.status != TtsStatus.paused) return;
|
if (state.status != TtsStatus.paused) return;
|
||||||
state = state.copyWith(status: TtsStatus.playing);
|
state = state.copyWith(status: TtsStatus.playing);
|
||||||
await WakelockPlus.enable();
|
// Use paragraph-level resume for consistent behavior across engines.
|
||||||
await _speak(state.paragraphIndex);
|
await _speak(state.paragraphIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
await _tts.stop();
|
await _tts.stop();
|
||||||
state = state.copyWith(status: TtsStatus.idle, paragraphIndex: 0);
|
state = state.copyWith(status: TtsStatus.idle, paragraphIndex: 0);
|
||||||
await WakelockPlus.disable();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> skipForward() async {
|
Future<void> skipForward() async {
|
||||||
@@ -126,6 +193,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
|
|
||||||
Future<void> skipBack() async {
|
Future<void> skipBack() async {
|
||||||
await _tts.stop();
|
await _tts.stop();
|
||||||
|
if (state.totalParagraphs <= 0) return;
|
||||||
final prev = (state.paragraphIndex - 1).clamp(0, state.totalParagraphs - 1);
|
final prev = (state.paragraphIndex - 1).clamp(0, state.totalParagraphs - 1);
|
||||||
state = state.copyWith(paragraphIndex: prev);
|
state = state.copyWith(paragraphIndex: prev);
|
||||||
if (state.status == TtsStatus.playing) await _speak(prev);
|
if (state.status == TtsStatus.playing) await _speak(prev);
|
||||||
@@ -139,7 +207,6 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_tts.stop();
|
_tts.stop();
|
||||||
WakelockPlus.disable();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
// Genre filter
|
// Genre filter
|
||||||
genresAsync.when(
|
genresAsync.when(
|
||||||
loading: () => const SizedBox.shrink(),
|
loading: () => const SizedBox.shrink(),
|
||||||
error: (_, __) => const SizedBox.shrink(),
|
error: (_, error) => const SizedBox.shrink(),
|
||||||
data: (genres) => _FilterChipDropdown(
|
data: (genres) => _FilterChipDropdown(
|
||||||
label: _selectedGenre == null
|
label: _selectedGenre == null
|
||||||
? 'Thể loại'
|
? 'Thể loại'
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
style: TextStyle(color: Colors.red)),
|
style: TextStyle(color: Colors.red)),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await ref.read(authProvider.notifier).signOut();
|
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:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
@@ -11,15 +13,23 @@ class SplashScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SplashScreenState extends State<SplashScreen> {
|
class _SplashScreenState extends State<SplashScreen> {
|
||||||
|
Timer? _redirectTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
Future<void>.delayed(const Duration(milliseconds: 700), () {
|
_redirectTimer = Timer(const Duration(milliseconds: 700), () {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.go(RouteNames.home);
|
context.go(RouteNames.home);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_redirectTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const Scaffold(
|
return const Scaffold(
|
||||||
|
|||||||
+31
-1
@@ -1,9 +1,39 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'app/app.dart';
|
import 'app/app.dart';
|
||||||
|
import 'core/logging/app_provider_observer.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
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_secure_storage_macos
|
||||||
import flutter_tts
|
import flutter_tts
|
||||||
import google_sign_in_ios
|
import google_sign_in_ios
|
||||||
import package_info_plus
|
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
import wakelock_plus
|
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||||
FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin"))
|
FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin"))
|
||||||
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -480,22 +480,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
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:
|
path:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -797,22 +781,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.2"
|
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:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ dependencies:
|
|||||||
flutter_secure_storage: ^9.2.4
|
flutter_secure_storage: ^9.2.4
|
||||||
google_sign_in: ^6.2.1
|
google_sign_in: ^6.2.1
|
||||||
flutter_tts: ^4.2.3
|
flutter_tts: ^4.2.3
|
||||||
wakelock_plus: ^1.4.0
|
|
||||||
cached_network_image: ^3.4.1
|
cached_network_image: ^3.4.1
|
||||||
connectivity_plus: ^6.1.4
|
connectivity_plus: ^6.1.4
|
||||||
equatable: ^2.0.7
|
equatable: ^2.0.7
|
||||||
|
|||||||
Executable
+45
@@ -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
|
||||||
@@ -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)."
|
||||||
@@ -6,9 +6,7 @@ import 'package:reader_app/app/app.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
testWidgets('Reader app renders', (WidgetTester tester) async {
|
testWidgets('Reader app renders', (WidgetTester tester) async {
|
||||||
await tester.pumpWidget(const ProviderScope(child: ReaderApp()));
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user