feat: Enhance chapter list and TTS functionality
Build Android APK / build-apk (push) Failing after 4m37s

- Introduced ChapterListQuery and ChapterListPage classes for better chapter management.
- Updated chapterListProvider to handle pagination and canonical ID resolution.
- Improved ReaderScreen with enhanced TTS features, including auto-scroll to active paragraph and better handling of TTS state.
- Added TtsPlayerWidget with compact mode and improved UI for TTS controls.
- Enhanced TtsService to manage speech segments and background mode for TTS.
- Implemented battery optimization checks for TTS background mode on Android.
- Updated main.dart to ensure proper error handling in a zoned environment.
This commit is contained in:
2026-04-07 18:49:29 +07:00
parent 1afff18f4d
commit 6946083aee
27 changed files with 1590 additions and 157 deletions
@@ -0,0 +1,14 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
class SessionExpiryNotifier extends StateNotifier<int> {
SessionExpiryNotifier() : super(0);
void notifyExpired() {
state = state + 1;
}
}
final sessionExpiryProvider =
StateNotifierProvider<SessionExpiryNotifier, int>((ref) {
return SessionExpiryNotifier();
});
+32 -11
View File
@@ -1,5 +1,26 @@
import 'package:equatable/equatable.dart';
int _toInt(dynamic value, {int fallback = 0}) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? fallback;
return fallback;
}
DateTime _toDateTime(dynamic value) {
if (value is DateTime) return value;
if (value is String && value.isNotEmpty) {
final parsed = DateTime.tryParse(value);
if (parsed != null) return parsed;
}
return DateTime.fromMillisecondsSinceEpoch(0);
}
int? _toNullableInt(dynamic value) {
if (value == null) return null;
return _toInt(value);
}
class ChapterModel extends Equatable {
const ChapterModel({
required this.id,
@@ -36,18 +57,18 @@ class ChapterModel extends Equatable {
factory ChapterModel.fromJson(Map<String, dynamic> json) => ChapterModel(
id: json['id'] as String,
novelId: json['novelId'] as String,
number: (json['number'] as num).toInt(),
number: _toInt(json['number']),
title: json['title'] as String,
content: json['content'] as String,
views: (json['views'] as num?)?.toInt() ?? 0,
volumeNumber: json['volumeNumber'] as int?,
views: _toInt(json['views']),
volumeNumber: _toNullableInt(json['volumeNumber']),
volumeTitle: json['volumeTitle'] as String?,
volumeChapterNumber: json['volumeChapterNumber'] as int?,
volumeChapterNumber: _toNullableInt(json['volumeChapterNumber']),
prevChapterId: json['prevChapterId'] as String?,
prevChapterNumber: json['prevChapterNumber'] as int?,
prevChapterNumber: _toNullableInt(json['prevChapterNumber']),
nextChapterId: json['nextChapterId'] as String?,
nextChapterNumber: json['nextChapterNumber'] as int?,
createdAt: DateTime.parse(json['createdAt'] as String),
nextChapterNumber: _toNullableInt(json['nextChapterNumber']),
createdAt: _toDateTime(json['createdAt']),
);
@override
@@ -75,12 +96,12 @@ class ChapterListItem extends Equatable {
factory ChapterListItem.fromJson(Map<String, dynamic> json) => ChapterListItem(
id: json['id'] as String,
number: (json['number'] as num).toInt(),
number: _toInt(json['number']),
title: json['title'] as String,
volumeNumber: json['volumeNumber'] as int?,
volumeNumber: _toNullableInt(json['volumeNumber']),
volumeTitle: json['volumeTitle'] as String?,
volumeChapterNumber: json['volumeChapterNumber'] as int?,
createdAt: DateTime.parse(json['createdAt'] as String),
volumeChapterNumber: _toNullableInt(json['volumeChapterNumber']),
createdAt: _toDateTime(json['createdAt']),
);
@override
+25
View File
@@ -7,6 +7,7 @@ class ApiClient {
ApiClient({
required String baseUrl,
required SecureStore secureStore,
this.onSessionExpired,
}) : _secureStore = secureStore,
dio = Dio(
BaseOptions(
@@ -35,6 +36,13 @@ class ApiClient {
handler.next(response);
},
onError: (error, handler) {
final statusCode = error.response?.statusCode;
final path = error.requestOptions.path;
if ((statusCode == 401 || statusCode == 403) && !_isAuthEndpoint(path)) {
_handleSessionExpired();
}
debugPrint(
'[API][ERROR] ${error.requestOptions.method} ${error.requestOptions.baseUrl}${error.requestOptions.path} '
'-> ${error.type}: ${error.message}',
@@ -47,4 +55,21 @@ class ApiClient {
final Dio dio;
final SecureStore _secureStore;
final VoidCallback? onSessionExpired;
DateTime? _lastSessionExpiredAt;
bool _isAuthEndpoint(String path) {
return path.contains('/api/auth/mobile-login');
}
void _handleSessionExpired() {
final now = DateTime.now();
if (_lastSessionExpiredAt != null &&
now.difference(_lastSessionExpiredAt!) < const Duration(seconds: 2)) {
return;
}
_lastSessionExpiredAt = now;
_secureStore.clear();
onSessionExpired?.call();
}
}
+6 -1
View File
@@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/session_expiry_notifier.dart';
import '../config/app_config.dart';
import '../storage/secure_store.dart';
import 'api_client.dart';
@@ -8,5 +9,9 @@ final secureStoreProvider = Provider<SecureStore>((ref) => SecureStore());
final apiClientProvider = Provider<ApiClient>((ref) {
final secureStore = ref.watch(secureStoreProvider);
return ApiClient(baseUrl: AppConfig.baseUrl, secureStore: secureStore);
return ApiClient(
baseUrl: AppConfig.baseUrl,
secureStore: secureStore,
onSessionExpired: () => ref.read(sessionExpiryProvider.notifier).notifyExpired(),
);
});