diff --git a/android/app/google-services.json b/android/app/google-services.json index 92d0075..5bfa5cb 100644 --- a/android/app/google-services.json +++ b/android/app/google-services.json @@ -13,6 +13,14 @@ } }, "oauth_client": [ + { + "client_id": "308259929553-58cnurk30t6stf9ebj7p5jv1b5gftr29.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "dev.fevirtus.reader", + "certificate_hash": "72eb1a744349efea8402128e2d9c98c989ec62b5" + } + }, { "client_id": "308259929553-fd8teopc4chi2jjd8kr5vn9inn35ar6j.apps.googleusercontent.com", "client_type": 1, diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index d586c54..2504573 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -36,7 +37,16 @@ final appRouterProvider = Provider((ref) { ), GoRoute( path: RouteNames.search, - builder: (context, state) => const SearchScreen(), + builder: (context, state) { + final query = state.uri.queryParameters; + return SearchScreen( + key: ValueKey(state.uri.toString()), + initialQuery: query['q'], + initialGenre: query['genre'], + initialStatus: query['status'], + initialSort: query['sort'] ?? 'latest', + ); + }, ), GoRoute( path: RouteNames.genres, diff --git a/lib/features/novel/providers/novels_provider.dart b/lib/features/novel/providers/novels_provider.dart index 98ee3fe..426588f 100644 --- a/lib/features/novel/providers/novels_provider.dart +++ b/lib/features/novel/providers/novels_provider.dart @@ -23,11 +23,40 @@ class BrowseParams { this.page = 1, }); + String? _normalizedStatus() { + final raw = status?.trim(); + if (raw == null || raw.isEmpty) return null; + switch (raw.toLowerCase()) { + case 'ongoing': + return 'Đang ra'; + case 'completed': + return 'Hoàn thành'; + case 'hiatus': + return 'Tạm ngưng'; + default: + return raw; + } + } + + String _normalizedSort() { + final raw = sort.trim(); + if (raw.isEmpty) return 'latest'; + switch (raw.toLowerCase()) { + case 'latest': + case 'popular': + case 'rating': + case 'name': + return raw.toLowerCase(); + default: + return 'latest'; + } + } + Map toQueryParams() => { if (query != null && query!.isNotEmpty) 'q': query, if (genre != null) 'genre': genre, - if (status != null) 'status': status, - 'sort': sort, + if (_normalizedStatus() != null) 'status': _normalizedStatus(), + 'sort': _normalizedSort(), 'page': page.toString(), 'limit': '20', }; @@ -56,18 +85,39 @@ class BrowseResult { final int totalCount; final int totalPages; final int currentPage; + final bool isLoadingMore; const BrowseResult({ required this.items, required this.totalCount, required this.totalPages, required this.currentPage, + this.isLoadingMore = false, }); + + bool get hasMore => currentPage < totalPages; + + BrowseResult copyWith({ + List? items, + int? totalCount, + int? totalPages, + int? currentPage, + bool? isLoadingMore, + }) { + return BrowseResult( + items: items ?? this.items, + totalCount: totalCount ?? this.totalCount, + totalPages: totalPages ?? this.totalPages, + currentPage: currentPage ?? this.currentPage, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + ); + } } class NovelsNotifier extends StateNotifier> { final Ref _ref; BrowseParams _params = const BrowseParams(); + bool _isLoadingMore = false; NovelsNotifier(this._ref) : super(const AsyncValue.loading()) { fetch(); @@ -75,25 +125,53 @@ class NovelsNotifier extends StateNotifier> { BrowseParams get params => _params; + Future _fetchPage(BrowseParams params) async { + final client = _ref.read(apiClientProvider); + final res = await client.dio.get('/api/novels/browse', queryParameters: params.toQueryParams()); + final data = res.data as Map; + return BrowseResult( + items: (data['items'] as List).map((e) => NovelModel.fromJson(e as Map)).toList(), + totalCount: (data['totalCount'] as num?)?.toInt() ?? 0, + totalPages: (data['totalPages'] as num?)?.toInt() ?? 1, + currentPage: (data['currentPage'] as num?)?.toInt() ?? params.page, + ); + } + Future fetch({BrowseParams? params}) async { if (params != null) _params = params; state = const AsyncValue.loading(); try { - final client = _ref.read(apiClientProvider); - final res = await client.dio.get('/api/novels/browse', queryParameters: _params.toQueryParams()); - final data = res.data as Map; - state = AsyncValue.data(BrowseResult( - items: (data['items'] as List).map((e) => NovelModel.fromJson(e as Map)).toList(), - totalCount: data['totalCount'] as int, - totalPages: data['totalPages'] as int, - currentPage: data['currentPage'] as int, - )); + final firstPageParams = _params.copyWith(page: 1); + final result = await _fetchPage(firstPageParams); + _params = firstPageParams; + state = AsyncValue.data(result); } catch (e, st) { state = AsyncValue.error(e, st); } } Future updateParams(BrowseParams params) => fetch(params: params); + + Future loadNextPage() async { + final current = state.valueOrNull; + if (current == null || !current.hasMore || _isLoadingMore) return; + + _isLoadingMore = true; + state = AsyncValue.data(current.copyWith(isLoadingMore: true)); + + try { + final nextParams = _params.copyWith(page: current.currentPage + 1); + final nextPage = await _fetchPage(nextParams); + _params = nextParams; + + final merged = [...current.items, ...nextPage.items]; + state = AsyncValue.data(nextPage.copyWith(items: merged, isLoadingMore: false)); + } catch (e, st) { + state = AsyncValue.error(e, st); + } finally { + _isLoadingMore = false; + } + } } final novelsProvider = StateNotifierProvider>((ref) { diff --git a/lib/features/reader/tts/tts_service.dart b/lib/features/reader/tts/tts_service.dart index 38c1adf..dc2e5f6 100644 --- a/lib/features/reader/tts/tts_service.dart +++ b/lib/features/reader/tts/tts_service.dart @@ -367,6 +367,18 @@ class TtsNotifier extends StateNotifier { ); } + String _sanitizeForTts(String raw) { + if (raw.isEmpty) return raw; + + // Keep natural sentence flow while removing symbols that are usually read out noisily. + final cleaned = raw + .replaceAll(RegExp(r'[_$#^*+=~`|<>\\\[\]{}]'), ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + + return cleaned; + } + List<_TtsSegment> _buildSegments( String content, { String? title, @@ -376,14 +388,17 @@ class TtsNotifier extends StateNotifier { final titleText = title?.trim(); if (includeTitle && titleText != null && titleText.isNotEmpty) { + final sanitizedTitle = _sanitizeForTts(titleText); + if (sanitizedTitle.isNotEmpty) { segments.add( _TtsSegment( - text: titleText, + text: sanitizedTitle, paragraphIndex: -1, start: -1, end: -1, ), ); + } } final paragraphs = content @@ -400,6 +415,8 @@ class TtsNotifier extends StateNotifier { for (final match in sentenceMatches) { final sentence = match.group(0)?.trim() ?? ''; if (sentence.isEmpty) continue; + final sanitizedSentence = _sanitizeForTts(sentence); + if (sanitizedSentence.isEmpty) continue; var start = paragraph.indexOf(sentence, cursor); if (start < 0) { @@ -411,7 +428,7 @@ class TtsNotifier extends StateNotifier { segments.add( _TtsSegment( - text: sentence, + text: sanitizedSentence, paragraphIndex: pIndex, start: start, end: end, diff --git a/lib/features/search/presentation/search_screen.dart b/lib/features/search/presentation/search_screen.dart index 1061c78..94987fc 100644 --- a/lib/features/search/presentation/search_screen.dart +++ b/lib/features/search/presentation/search_screen.dart @@ -10,7 +10,18 @@ import '../../novel/providers/novels_provider.dart'; import '../../genres/providers/genres_provider.dart'; class SearchScreen extends ConsumerStatefulWidget { - const SearchScreen({super.key}); + const SearchScreen({ + super.key, + this.initialQuery, + this.initialGenre, + this.initialStatus, + this.initialSort = 'latest', + }); + + final String? initialQuery; + final String? initialGenre; + final String? initialStatus; + final String initialSort; @override ConsumerState createState() => _SearchScreenState(); @@ -18,6 +29,7 @@ class SearchScreen extends ConsumerStatefulWidget { class _SearchScreenState extends ConsumerState { final _controller = TextEditingController(); + final _scrollController = ScrollController(); Timer? _debounce; String? _selectedGenre; String? _selectedStatus; @@ -35,13 +47,61 @@ class _SearchScreenState extends ConsumerState { ('Tên A-Z', 'name'), ]; + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + _syncFromInitialParams(applyImmediately: false); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _applyFilters(); + }); + } + + @override + void didUpdateWidget(covariant SearchScreen oldWidget) { + super.didUpdateWidget(oldWidget); + final hasRouteFilterChange = oldWidget.initialQuery != widget.initialQuery || + oldWidget.initialGenre != widget.initialGenre || + oldWidget.initialStatus != widget.initialStatus || + oldWidget.initialSort != widget.initialSort; + if (hasRouteFilterChange) { + _syncFromInitialParams(applyImmediately: true); + } + } + @override void dispose() { + _scrollController + ..removeListener(_onScroll) + ..dispose(); _controller.dispose(); _debounce?.cancel(); super.dispose(); } + void _onScroll() { + if (!_scrollController.hasClients) return; + final position = _scrollController.position; + if (position.pixels >= position.maxScrollExtent - 240) { + ref.read(novelsProvider.notifier).loadNextPage(); + } + } + + void _syncFromInitialParams({required bool applyImmediately}) { + final incomingQuery = widget.initialQuery?.trim(); + _controller.text = incomingQuery == null || incomingQuery.isEmpty ? '' : incomingQuery; + _selectedGenre = widget.initialGenre; + _selectedStatus = widget.initialStatus; + _sort = widget.initialSort; + if (applyImmediately) { + if (mounted) { + setState(() {}); + } + _applyFilters(); + } + } + void _onQueryChanged(String value) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 500), _applyFilters); @@ -167,8 +227,25 @@ class _SearchScreenState extends ConsumerState { return const Center(child: Text('Không tìm thấy truyện')); } return ListView.builder( - itemCount: result.items.length, - itemBuilder: (context, index) => _NovelListTile(novel: result.items[index]), + controller: _scrollController, + itemCount: result.items.length + (result.hasMore || result.isLoadingMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= result.items.length) { + if (result.isLoadingMore) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator()), + ); + } + // Trigger page load when user reaches the end. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ref.read(novelsProvider.notifier).loadNextPage(); + }); + return const SizedBox(height: 32); + } + return _NovelListTile(novel: result.items[index]); + }, ); }, ), @@ -199,12 +276,14 @@ class _FilterChipDropdown extends StatelessWidget { return PopupMenuButton( onSelected: onSelected, itemBuilder: (_) => items, - child: FilterChip( - label: Text(label), - selected: selected, - onSelected: (_) {}, - deleteIcon: selected && onClear != null ? const Icon(Icons.close, size: 14) : null, - onDeleted: selected ? onClear : null, + child: IgnorePointer( + child: FilterChip( + label: Text(label), + selected: selected, + onSelected: (_) {}, + deleteIcon: selected && onClear != null ? const Icon(Icons.close, size: 14) : null, + onDeleted: selected ? onClear : null, + ), ), ); }