Files
reader-app/lib/features/search/presentation/search_screen.dart
T
virtus 1afff18f4d 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.
2026-03-30 11:38:04 +07:00

255 lines
8.3 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart';
import '../../../core/models/novel_model.dart';
import '../../novel/providers/novels_provider.dart';
import '../../genres/providers/genres_provider.dart';
class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key});
@override
ConsumerState<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends ConsumerState<SearchScreen> {
final _controller = TextEditingController();
Timer? _debounce;
String? _selectedGenre;
String? _selectedStatus;
String _sort = 'latest';
final _statuses = const [
('Đang ra', 'ongoing'),
('Đã hoàn thành', 'completed'),
('Tạm dừng', 'hiatus'),
];
final _sorts = const [
('Mới nhất', 'latest'),
('Phổ biến', 'popular'),
('Đánh giá', 'rating'),
('Tên A-Z', 'name'),
];
@override
void dispose() {
_controller.dispose();
_debounce?.cancel();
super.dispose();
}
void _onQueryChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 500), _applyFilters);
}
void _applyFilters() {
ref.read(novelsProvider.notifier).updateParams(
BrowseParams(
query: _controller.text.trim().isEmpty ? null : _controller.text.trim(),
genre: _selectedGenre,
status: _selectedStatus,
sort: _sort,
),
);
}
@override
Widget build(BuildContext context) {
final genresAsync = ref.watch(genresProvider);
final novelsAsync = ref.watch(novelsProvider);
return Scaffold(
appBar: AppBar(title: const Text('Tìm kiếm')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: TextField(
controller: _controller,
onChanged: _onQueryChanged,
decoration: InputDecoration(
hintText: 'Tên truyện, tác giả...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_controller.clear();
_applyFilters();
},
)
: null,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
isDense: true,
),
textInputAction: TextInputAction.search,
onSubmitted: (_) => _applyFilters(),
),
),
// Filter chips row
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
// Genre filter
genresAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, error) => const SizedBox.shrink(),
data: (genres) => _FilterChipDropdown(
label: _selectedGenre == null
? 'Thể loại'
: genres.firstWhere((g) => g.slug == _selectedGenre, orElse: () => genres.first).name,
selected: _selectedGenre != null,
items: genres
.map((g) => PopupMenuItem(value: g.slug, child: Text(g.name)))
.toList(),
onSelected: (v) {
setState(() => _selectedGenre = _selectedGenre == v ? null : v);
_applyFilters();
},
onClear: () {
setState(() => _selectedGenre = null);
_applyFilters();
},
),
),
const SizedBox(width: 8),
// Status filter
_FilterChipDropdown(
label: _selectedStatus == null
? 'Trạng thái'
: _statuses.firstWhere((s) => s.$2 == _selectedStatus, orElse: () => _statuses.first).$1,
selected: _selectedStatus != null,
items: _statuses
.map((s) => PopupMenuItem(value: s.$2, child: Text(s.$1)))
.toList(),
onSelected: (v) {
setState(() => _selectedStatus = _selectedStatus == v ? null : v);
_applyFilters();
},
onClear: () {
setState(() => _selectedStatus = null);
_applyFilters();
},
),
const SizedBox(width: 8),
// Sort
_FilterChipDropdown(
label: _sorts.firstWhere((s) => s.$2 == _sort).$1,
selected: _sort != 'latest',
items: _sorts
.map((s) => PopupMenuItem(value: s.$2, child: Text(s.$1)))
.toList(),
onSelected: (v) {
if (v != null) {
setState(() => _sort = v);
_applyFilters();
}
},
onClear: null,
),
],
),
),
const SizedBox(height: 8),
Expanded(
child: novelsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Lỗi: $e')),
data: (result) {
if (result.items.isEmpty) {
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]),
);
},
),
),
],
),
);
}
}
class _FilterChipDropdown extends StatelessWidget {
final String label;
final bool selected;
final List<PopupMenuEntry<String>> items;
final void Function(String?)? onSelected;
final VoidCallback? onClear;
const _FilterChipDropdown({
required this.label,
required this.selected,
required this.items,
required this.onSelected,
required this.onClear,
});
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
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,
),
);
}
}
class _NovelListTile extends StatelessWidget {
final NovelModel novel;
const _NovelListTile({required this.novel});
@override
Widget build(BuildContext context) {
return ListTile(
leading: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: novel.coverUrl != null
? CachedNetworkImage(
imageUrl: novel.coverUrl!,
width: 44,
height: 60,
fit: BoxFit.cover,
)
: Container(
width: 44,
height: 60,
color: Theme.of(context).colorScheme.primaryContainer,
child: const Icon(Icons.menu_book, size: 20),
),
),
title: Text(novel.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
novel.authorName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: novel.rating > 0
? Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.star, size: 14, color: Colors.amber),
Text(novel.rating.toStringAsFixed(1)),
],
)
: null,
onTap: () => context.push(RouteNames.novelDetail(novel.id)),
);
}
}