feat: Enhance search functionality with initial query parameters and infinite scrolling
This commit is contained in:
@@ -13,6 +13,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"oauth_client": [
|
"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_id": "308259929553-fd8teopc4chi2jjd8kr5vn9inn35ar6j.apps.googleusercontent.com",
|
||||||
"client_type": 1,
|
"client_type": 1,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
@@ -36,7 +37,16 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteNames.search,
|
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(
|
GoRoute(
|
||||||
path: RouteNames.genres,
|
path: RouteNames.genres,
|
||||||
|
|||||||
@@ -23,11 +23,40 @@ class BrowseParams {
|
|||||||
this.page = 1,
|
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<String, dynamic> toQueryParams() => {
|
Map<String, dynamic> toQueryParams() => {
|
||||||
if (query != null && query!.isNotEmpty) 'q': query,
|
if (query != null && query!.isNotEmpty) 'q': query,
|
||||||
if (genre != null) 'genre': genre,
|
if (genre != null) 'genre': genre,
|
||||||
if (status != null) 'status': status,
|
if (_normalizedStatus() != null) 'status': _normalizedStatus(),
|
||||||
'sort': sort,
|
'sort': _normalizedSort(),
|
||||||
'page': page.toString(),
|
'page': page.toString(),
|
||||||
'limit': '20',
|
'limit': '20',
|
||||||
};
|
};
|
||||||
@@ -56,18 +85,39 @@ class BrowseResult {
|
|||||||
final int totalCount;
|
final int totalCount;
|
||||||
final int totalPages;
|
final int totalPages;
|
||||||
final int currentPage;
|
final int currentPage;
|
||||||
|
final bool isLoadingMore;
|
||||||
|
|
||||||
const BrowseResult({
|
const BrowseResult({
|
||||||
required this.items,
|
required this.items,
|
||||||
required this.totalCount,
|
required this.totalCount,
|
||||||
required this.totalPages,
|
required this.totalPages,
|
||||||
required this.currentPage,
|
required this.currentPage,
|
||||||
|
this.isLoadingMore = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool get hasMore => currentPage < totalPages;
|
||||||
|
|
||||||
|
BrowseResult copyWith({
|
||||||
|
List<NovelModel>? 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<AsyncValue<BrowseResult>> {
|
class NovelsNotifier extends StateNotifier<AsyncValue<BrowseResult>> {
|
||||||
final Ref _ref;
|
final Ref _ref;
|
||||||
BrowseParams _params = const BrowseParams();
|
BrowseParams _params = const BrowseParams();
|
||||||
|
bool _isLoadingMore = false;
|
||||||
|
|
||||||
NovelsNotifier(this._ref) : super(const AsyncValue.loading()) {
|
NovelsNotifier(this._ref) : super(const AsyncValue.loading()) {
|
||||||
fetch();
|
fetch();
|
||||||
@@ -75,25 +125,53 @@ class NovelsNotifier extends StateNotifier<AsyncValue<BrowseResult>> {
|
|||||||
|
|
||||||
BrowseParams get params => _params;
|
BrowseParams get params => _params;
|
||||||
|
|
||||||
|
Future<BrowseResult> _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<String, dynamic>;
|
||||||
|
return BrowseResult(
|
||||||
|
items: (data['items'] as List).map((e) => NovelModel.fromJson(e as Map<String, dynamic>)).toList(),
|
||||||
|
totalCount: (data['totalCount'] as num?)?.toInt() ?? 0,
|
||||||
|
totalPages: (data['totalPages'] as num?)?.toInt() ?? 1,
|
||||||
|
currentPage: (data['currentPage'] as num?)?.toInt() ?? params.page,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> fetch({BrowseParams? params}) async {
|
Future<void> fetch({BrowseParams? params}) async {
|
||||||
if (params != null) _params = params;
|
if (params != null) _params = params;
|
||||||
state = const AsyncValue.loading();
|
state = const AsyncValue.loading();
|
||||||
try {
|
try {
|
||||||
final client = _ref.read(apiClientProvider);
|
final firstPageParams = _params.copyWith(page: 1);
|
||||||
final res = await client.dio.get('/api/novels/browse', queryParameters: _params.toQueryParams());
|
final result = await _fetchPage(firstPageParams);
|
||||||
final data = res.data as Map<String, dynamic>;
|
_params = firstPageParams;
|
||||||
state = AsyncValue.data(BrowseResult(
|
state = AsyncValue.data(result);
|
||||||
items: (data['items'] as List).map((e) => NovelModel.fromJson(e as Map<String, dynamic>)).toList(),
|
|
||||||
totalCount: data['totalCount'] as int,
|
|
||||||
totalPages: data['totalPages'] as int,
|
|
||||||
currentPage: data['currentPage'] as int,
|
|
||||||
));
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
state = AsyncValue.error(e, st);
|
state = AsyncValue.error(e, st);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateParams(BrowseParams params) => fetch(params: params);
|
Future<void> updateParams(BrowseParams params) => fetch(params: params);
|
||||||
|
|
||||||
|
Future<void> 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<NovelsNotifier, AsyncValue<BrowseResult>>((ref) {
|
final novelsProvider = StateNotifierProvider<NovelsNotifier, AsyncValue<BrowseResult>>((ref) {
|
||||||
|
|||||||
@@ -367,6 +367,18 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
List<_TtsSegment> _buildSegments(
|
||||||
String content, {
|
String content, {
|
||||||
String? title,
|
String? title,
|
||||||
@@ -376,15 +388,18 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
|
|
||||||
final titleText = title?.trim();
|
final titleText = title?.trim();
|
||||||
if (includeTitle && titleText != null && titleText.isNotEmpty) {
|
if (includeTitle && titleText != null && titleText.isNotEmpty) {
|
||||||
|
final sanitizedTitle = _sanitizeForTts(titleText);
|
||||||
|
if (sanitizedTitle.isNotEmpty) {
|
||||||
segments.add(
|
segments.add(
|
||||||
_TtsSegment(
|
_TtsSegment(
|
||||||
text: titleText,
|
text: sanitizedTitle,
|
||||||
paragraphIndex: -1,
|
paragraphIndex: -1,
|
||||||
start: -1,
|
start: -1,
|
||||||
end: -1,
|
end: -1,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final paragraphs = content
|
final paragraphs = content
|
||||||
.split(RegExp(r'\n+'))
|
.split(RegExp(r'\n+'))
|
||||||
@@ -400,6 +415,8 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
for (final match in sentenceMatches) {
|
for (final match in sentenceMatches) {
|
||||||
final sentence = match.group(0)?.trim() ?? '';
|
final sentence = match.group(0)?.trim() ?? '';
|
||||||
if (sentence.isEmpty) continue;
|
if (sentence.isEmpty) continue;
|
||||||
|
final sanitizedSentence = _sanitizeForTts(sentence);
|
||||||
|
if (sanitizedSentence.isEmpty) continue;
|
||||||
|
|
||||||
var start = paragraph.indexOf(sentence, cursor);
|
var start = paragraph.indexOf(sentence, cursor);
|
||||||
if (start < 0) {
|
if (start < 0) {
|
||||||
@@ -411,7 +428,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
|
|
||||||
segments.add(
|
segments.add(
|
||||||
_TtsSegment(
|
_TtsSegment(
|
||||||
text: sentence,
|
text: sanitizedSentence,
|
||||||
paragraphIndex: pIndex,
|
paragraphIndex: pIndex,
|
||||||
start: start,
|
start: start,
|
||||||
end: end,
|
end: end,
|
||||||
|
|||||||
@@ -10,7 +10,18 @@ import '../../novel/providers/novels_provider.dart';
|
|||||||
import '../../genres/providers/genres_provider.dart';
|
import '../../genres/providers/genres_provider.dart';
|
||||||
|
|
||||||
class SearchScreen extends ConsumerStatefulWidget {
|
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
|
@override
|
||||||
ConsumerState<SearchScreen> createState() => _SearchScreenState();
|
ConsumerState<SearchScreen> createState() => _SearchScreenState();
|
||||||
@@ -18,6 +29,7 @@ class SearchScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _SearchScreenState extends ConsumerState<SearchScreen> {
|
class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
final _controller = TextEditingController();
|
final _controller = TextEditingController();
|
||||||
|
final _scrollController = ScrollController();
|
||||||
Timer? _debounce;
|
Timer? _debounce;
|
||||||
String? _selectedGenre;
|
String? _selectedGenre;
|
||||||
String? _selectedStatus;
|
String? _selectedStatus;
|
||||||
@@ -35,13 +47,61 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
('Tên A-Z', 'name'),
|
('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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_scrollController
|
||||||
|
..removeListener(_onScroll)
|
||||||
|
..dispose();
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
super.dispose();
|
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) {
|
void _onQueryChanged(String value) {
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
_debounce = Timer(const Duration(milliseconds: 500), _applyFilters);
|
_debounce = Timer(const Duration(milliseconds: 500), _applyFilters);
|
||||||
@@ -167,8 +227,25 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
return const Center(child: Text('Không tìm thấy truyện'));
|
return const Center(child: Text('Không tìm thấy truyện'));
|
||||||
}
|
}
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: result.items.length,
|
controller: _scrollController,
|
||||||
itemBuilder: (context, index) => _NovelListTile(novel: result.items[index]),
|
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,6 +276,7 @@ class _FilterChipDropdown extends StatelessWidget {
|
|||||||
return PopupMenuButton<String>(
|
return PopupMenuButton<String>(
|
||||||
onSelected: onSelected,
|
onSelected: onSelected,
|
||||||
itemBuilder: (_) => items,
|
itemBuilder: (_) => items,
|
||||||
|
child: IgnorePointer(
|
||||||
child: FilterChip(
|
child: FilterChip(
|
||||||
label: Text(label),
|
label: Text(label),
|
||||||
selected: selected,
|
selected: selected,
|
||||||
@@ -206,6 +284,7 @@ class _FilterChipDropdown extends StatelessWidget {
|
|||||||
deleteIcon: selected && onClear != null ? const Icon(Icons.close, size: 14) : null,
|
deleteIcon: selected && onClear != null ? const Icon(Icons.close, size: 14) : null,
|
||||||
onDeleted: selected ? onClear : null,
|
onDeleted: selected ? onClear : null,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user