fix: resolve flutter analyze errors - remove leaked code, fix method calls, cleanup imports

This commit is contained in:
2026-03-23 16:55:54 +07:00
parent 4f202936fa
commit 71f1feaf98
33 changed files with 2851 additions and 224 deletions
@@ -1,18 +1,67 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../shared/widgets/feature_placeholder.dart';
import '../../../app/router/route_names.dart';
import '../providers/auth_provider.dart';
class LoginScreen extends StatelessWidget {
class LoginScreen extends ConsumerWidget {
const LoginScreen({super.key});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
ref.listen<AuthState>(authProvider, (_, next) {
if (next is AuthAuthenticated) {
context.go(RouteNames.home);
}
});
final isLoading = authState is AuthLoading;
final errorMsg = authState is AuthError ? authState.message : null;
return Scaffold(
appBar: AppBar(title: const Text('Dang nhap')),
body: const FeaturePlaceholder(
title: 'Google Login',
description:
'Khung dang nhap Google OAuth cho mobile auth endpoint. Se bo sung token refresh va secure storage trong phase tiep theo.',
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.menu_book_rounded, size: 64),
const SizedBox(height: 20),
Text('Reader App', style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 8),
Text(
'Đọc truyện mọi lúc, mọi nơi',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 48),
if (errorMsg != null) ...[
Text(errorMsg, style: const TextStyle(color: Colors.red)),
const SizedBox(height: 16),
],
FilledButton.icon(
onPressed: isLoading
? null
: () => ref.read(authProvider.notifier).signInWithGoogle(),
icon: isLoading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.login),
label: const Text('Đăng nhập bằng Google'),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
),
),
],
),
),
),
),
);
}
@@ -0,0 +1,124 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_sign_in/google_sign_in.dart';
import '../../../core/config/app_config.dart';
import '../../../core/models/user_model.dart';
import '../../../core/network/providers.dart';
import '../../../core/storage/secure_store.dart';
// ─── State ────────────────────────────────────────────────────────────────────
abstract class AuthState {}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState {
AuthAuthenticated(this.user);
final UserModel user;
}
class AuthUnauthenticated extends AuthState {}
class AuthError extends AuthState {
AuthError(this.message);
final String message;
}
// ─── Notifier ─────────────────────────────────────────────────────────────────
class AuthNotifier extends StateNotifier<AuthState> {
AuthNotifier(this._ref) : super(AuthInitial()) {
_restore();
}
final Ref _ref;
SecureStore get _store => _ref.read(secureStoreProvider);
final GoogleSignIn _googleSignIn = GoogleSignIn(
clientId: AppConfig.googleClientId.isNotEmpty ? AppConfig.googleClientId : null,
scopes: ['email', 'profile'],
);
Future<void> _restore() async {
final token = await _store.getAccessToken();
if (token != null && token.isNotEmpty) {
await _fetchProfile();
} else {
state = AuthUnauthenticated();
}
}
Future<void> _fetchProfile() async {
try {
final dio = _ref.read(apiClientProvider).dio;
final res = await dio.get('/api/user/profile');
state = AuthAuthenticated(UserModel.fromJson(res.data as Map<String, dynamic>));
} catch (_) {
await _store.clear();
state = AuthUnauthenticated();
}
}
Future<void> signInWithGoogle() async {
try {
state = AuthLoading();
final account = await _googleSignIn.signIn();
if (account == null) {
state = AuthUnauthenticated();
return;
}
final auth = await account.authentication;
final idToken = auth.idToken;
if (idToken == null) {
state = AuthError('Could not get ID token from Google');
return;
}
final dio = _ref.read(apiClientProvider).dio;
final res = await dio.post(
'/api/auth/mobile-login',
data: {'googleIdToken': idToken},
);
final data = res.data as Map<String, dynamic>;
await _store.setAccessToken(data['accessToken'] as String);
if (data['refreshToken'] != null) {
await _store.setRefreshToken(data['refreshToken'] as String);
}
state = AuthAuthenticated(
UserModel.fromJson(data['user'] as Map<String, dynamic>),
);
} on DioException catch (e) {
final msg = (e.response?.data as Map?)?['error'] ?? e.message ?? 'Login failed';
state = AuthError(msg.toString());
} catch (e) {
state = AuthError(e.toString());
}
}
Future<void> signOut() async {
await _googleSignIn.signOut();
await _store.clear();
state = AuthUnauthenticated();
}
}
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier(ref);
});
// Conveniences
final currentUserProvider = Provider<UserModel?>((ref) {
final s = ref.watch(authProvider);
return s is AuthAuthenticated ? s.user : null;
});
final isAuthenticatedProvider = Provider<bool>((ref) {
return ref.watch(authProvider) is AuthAuthenticated;
});
@@ -1,48 +1,129 @@
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 '../../../shared/widgets/feature_placeholder.dart';
import '../../../app/router/route_names.dart';
import '../../../core/models/bookmark_model.dart';
import '../providers/bookshelf_provider.dart';
import '../../auth/providers/auth_provider.dart';
class BookshelfScreen extends StatelessWidget {
class BookshelfScreen extends ConsumerWidget {
const BookshelfScreen({super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 4,
child: Scaffold(
appBar: AppBar(
title: const Text('Tu sach'),
bottom: const TabBar(
isScrollable: true,
tabs: [
Tab(text: 'Dang doc'),
Tab(text: 'Danh dau'),
Tab(text: 'Da doc'),
Tab(text: 'De cu'),
Widget build(BuildContext context, WidgetRef ref) {
final isAuth = ref.watch(isAuthenticatedProvider);
if (!isAuth) {
return Scaffold(
appBar: AppBar(title: const Text('T sách')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.lock_outline, size: 48),
const SizedBox(height: 12),
const Text('Vui lòng đăng nhập để xem tủ sách'),
const SizedBox(height: 16),
FilledButton(
onPressed: () => context.push(RouteNames.login),
child: const Text('Đăng nhập'),
),
],
),
),
body: const TabBarView(
children: [
FeaturePlaceholder(
title: 'Dang doc',
description: 'Danh sach truyện dang doc theo progress sync.',
),
FeaturePlaceholder(
title: 'Danh dau',
description: 'Tat ca truyện da bookmark cua user.',
),
FeaturePlaceholder(
title: 'Da doc',
description: 'Danh sach truyện da hoan thanh.',
),
FeaturePlaceholder(
title: 'De cu',
description: 'Danh sach truyện user da de cu.',
),
],
);
}
final bookshelfAsync = ref.watch(bookshelfProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Tủ sách'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => ref.read(bookshelfProvider.notifier).fetch(),
),
],
),
body: bookshelfAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48),
const SizedBox(height: 8),
Text('Lỗi: $e'),
TextButton(
onPressed: () => ref.read(bookshelfProvider.notifier).fetch(),
child: const Text('Thử lại'),
),
],
),
),
data: (bookmarks) {
if (bookmarks.isEmpty) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.menu_book_outlined, size: 56),
SizedBox(height: 12),
Text('Chưa có truyện nào trong tủ sách'),
],
),
);
}
return RefreshIndicator(
onRefresh: () => ref.read(bookshelfProvider.notifier).fetch(),
child: ListView.builder(
itemCount: bookmarks.length,
itemBuilder: (context, index) =>
_BookmarkTile(bookmark: bookmarks[index]),
),
);
},
),
);
}
}
class _BookmarkTile extends StatelessWidget {
final BookmarkModel bookmark;
const _BookmarkTile({required this.bookmark});
@override
Widget build(BuildContext context) {
final novel = bookmark.novel;
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 ?? bookmark.novelId,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: novel?.authorName != null
? Text(novel!.authorName, maxLines: 1, overflow: TextOverflow.ellipsis)
: null,
onTap: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
);
}
}
@@ -0,0 +1,57 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/bookmark_model.dart';
import '../../../core/network/providers.dart';
class BookshelfNotifier extends StateNotifier<AsyncValue<List<BookmarkModel>>> {
final Ref _ref;
BookshelfNotifier(this._ref) : super(const AsyncValue.loading()) {
fetch();
}
Future<void> fetch() async {
state = const AsyncValue.loading();
try {
final client = _ref.read(apiClientProvider);
final res = await client.dio.get('/api/user/bookmarks');
final list = (res.data as List)
.map((e) => BookmarkModel.fromJson(e as Map<String, dynamic>))
.toList();
state = AsyncValue.data(list);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
Future<void> toggle(String novelId) async {
try {
final client = _ref.read(apiClientProvider);
final current = state.valueOrNull ?? [];
final existing = current.where((b) => b.novelId == novelId).toList();
if (existing.isEmpty) {
final res = await client.dio.post('/api/user/bookmarks', data: {'novelId': novelId});
final updated = BookmarkModel.fromJson(res.data as Map<String, dynamic>);
state = AsyncValue.data([...current, updated]);
} else {
await client.dio.delete('/api/user/bookmarks/$novelId');
state = AsyncValue.data(current.where((b) => b.novelId != novelId).toList());
}
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
bool isBookmarked(String novelId) {
return (state.valueOrNull ?? []).any((b) => b.novelId == novelId);
}
}
final bookshelfProvider =
StateNotifierProvider<BookshelfNotifier, AsyncValue<List<BookmarkModel>>>((ref) {
return BookshelfNotifier(ref);
});
final isBookmarkedProvider = Provider.family<bool, String>((ref, novelId) {
final bookshelf = ref.watch(bookshelfProvider);
return bookshelf.valueOrNull?.any((b) => b.novelId == novelId) ?? false;
});
@@ -1,6 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
class CommentsScreen extends StatelessWidget {
import '../../../core/models/comment_model.dart';
import '../../auth/providers/auth_provider.dart';
import '../providers/comments_provider.dart';
class CommentsScreen extends ConsumerStatefulWidget {
const CommentsScreen({
super.key,
required this.novelId,
@@ -10,29 +16,179 @@ class CommentsScreen extends StatelessWidget {
final String novelId;
final String? chapterId;
@override
ConsumerState<CommentsScreen> createState() => _CommentsScreenState();
}
class _CommentsScreenState extends ConsumerState<CommentsScreen> {
final _textCtrl = TextEditingController();
bool _submitting = false;
String get _key =>
widget.chapterId != null ? '${widget.novelId}:${widget.chapterId}' : widget.novelId;
@override
void dispose() {
_textCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
final text = _textCtrl.text.trim();
if (text.isEmpty) return;
setState(() => _submitting = true);
try {
await ref.read(commentsProvider(_key).notifier).post(text);
_textCtrl.clear();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Lỗi: $e')));
}
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
final isAuth = ref.watch(isAuthenticatedProvider);
final commentsAsync = ref.watch(commentsProvider(_key));
return Scaffold(
appBar: AppBar(title: const Text('Binh luan')),
body: ListView(
padding: const EdgeInsets.all(20),
appBar: AppBar(
title: Text(widget.chapterId != null ? 'Bình luận chương' : 'Bình luận'),
),
body: Column(
children: [
Text('Novel ID: ${novelId.isEmpty ? '(missing)' : novelId}'),
Text('Chapter ID: ${chapterId ?? '(all novel comments)'}'),
const SizedBox(height: 12),
const Text('Khung danh sach binh luan + form gui comment + phan trang.'),
const SizedBox(height: 20),
TextField(
maxLines: 4,
decoration: const InputDecoration(
hintText: 'Viet binh luan cua ban...',
border: OutlineInputBorder(),
Expanded(
child: commentsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Lỗi: $e')),
data: (comments) {
if (comments.isEmpty) {
return const Center(child: Text('Chưa có bình luận nào'));
}
return ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: comments.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) =>
_CommentTile(comment: comments[index]),
);
},
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: () {},
child: const Text('Gui binh luan'),
if (isAuth)
_CommentInput(
controller: _textCtrl,
submitting: _submitting,
onSubmit: _submit,
),
],
),
);
}
}
class _CommentTile extends StatelessWidget {
final CommentModel comment;
const _CommentTile({required this.comment});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
radius: 16,
child: Text(
comment.username[0].toUpperCase(),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
comment.username,
style: const TextStyle(fontWeight: FontWeight.w600),
),
Text(
_formatDate(comment.createdAt),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
const SizedBox(height: 8),
Text(comment.content),
],
),
);
}
String _formatDate(DateTime? dt) {
if (dt == null) return '';
return DateFormat('dd/MM/yyyy HH:mm').format(dt.toLocal());
}
}
class _CommentInput extends StatelessWidget {
final TextEditingController controller;
final bool submitting;
final VoidCallback onSubmit;
const _CommentInput({
required this.controller,
required this.submitting,
required this.onSubmit,
});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.only(
left: 12,
right: 12,
top: 8,
bottom: MediaQuery.of(context).padding.bottom + 8,
),
color: Theme.of(context).colorScheme.surface,
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
maxLines: 3,
minLines: 1,
decoration: InputDecoration(
hintText: 'Viết bình luận...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
isDense: true,
),
textInputAction: TextInputAction.newline,
),
),
const SizedBox(width: 8),
IconButton.filled(
onPressed: submitting ? null : onSubmit,
icon: submitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
),
],
),
@@ -0,0 +1,72 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/comment_model.dart';
import '../../../core/network/providers.dart';
class CommentsNotifier extends StateNotifier<AsyncValue<List<CommentModel>>> {
final Ref _ref;
final String novelId;
final String? chapterId;
int _page = 1;
bool _hasMore = true;
CommentsNotifier(this._ref, {required this.novelId, this.chapterId})
: super(const AsyncValue.loading()) {
fetch();
}
bool get hasMore => _hasMore;
Future<void> fetch({bool reset = false}) async {
if (reset) {
_page = 1;
_hasMore = true;
state = const AsyncValue.loading();
}
try {
final client = _ref.read(apiClientProvider);
final queryParams = <String, dynamic>{
'page': _page.toString(),
'limit': '20',
if (chapterId != null) 'chapterId': chapterId,
};
final res = await client.dio.get('/api/truyen/$novelId/comments', queryParameters: queryParams);
final data = res.data as Map<String, dynamic>;
final newItems = (data['comments'] as List)
.map((e) => CommentModel.fromJson(e as Map<String, dynamic>))
.toList();
final totalPages = data['totalPages'] as int? ?? 1;
_hasMore = _page < totalPages;
final existing = reset ? <CommentModel>[] : (state.valueOrNull ?? []);
state = AsyncValue.data([...existing, ...newItems]);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
Future<void> loadMore() async {
if (!_hasMore) return;
_page++;
await fetch();
}
Future<void> post(String content) async {
final client = _ref.read(apiClientProvider);
final res = await client.dio.post('/api/truyen/$novelId/comments', data: {
'content': content,
if (chapterId != null) 'chapterId': chapterId,
});
final newComment = CommentModel.fromJson(res.data as Map<String, dynamic>);
state = AsyncValue.data([newComment, ...(state.valueOrNull ?? [])]);
}
}
// Provider family params: "novelId" or "novelId:chapterId"
final commentsProvider = StateNotifierProvider.family<CommentsNotifier,
AsyncValue<List<CommentModel>>, String>((ref, key) {
final parts = key.split(':');
return CommentsNotifier(
ref,
novelId: parts[0],
chapterId: parts.length > 1 ? parts[1] : null,
);
});
@@ -1,18 +1,87 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../shared/widgets/feature_placeholder.dart';
import '../../../app/router/route_names.dart';
import '../../../core/models/novel_model.dart';
import '../providers/genres_provider.dart';
class GenresScreen extends StatelessWidget {
class GenresScreen extends ConsumerWidget {
const GenresScreen({super.key});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final genresAsync = ref.watch(genresProvider);
return Scaffold(
appBar: AppBar(title: const Text('The loai')),
body: const FeaturePlaceholder(
title: 'Genre Discovery',
description:
'Khung danh sach the loai va man hinh truyện theo the loai slug de dong bo hanh vi voi web.',
appBar: AppBar(title: const Text('Th loi')),
body: genresAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48),
const SizedBox(height: 8),
Text('Lỗi tải thể loại'),
TextButton(
onPressed: () => ref.invalidate(genresProvider),
child: const Text('Thử lại'),
),
],
),
),
data: (genres) => GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 2.5,
),
itemCount: genres.length,
itemBuilder: (context, index) {
final genre = genres[index];
return _GenreCard(genre: genre);
},
),
),
);
}
}
class _GenreCard extends StatelessWidget {
final GenreModel genre;
const _GenreCard({required this.genre});
@override
Widget build(BuildContext context) {
return Card(
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => context.go('${RouteNames.search}?genre=${genre.slug}'),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
genre.name,
style: Theme.of(context).textTheme.titleSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (genre.novelCount > 0)
Text(
'${genre.novelCount} truyện',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
),
);
}
@@ -0,0 +1,11 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/novel_model.dart';
import '../../../core/network/providers.dart';
final genresProvider = FutureProvider<List<GenreModel>>((ref) async {
final client = ref.read(apiClientProvider);
final res = await client.dio.get('/api/genres');
return (res.data as List)
.map((e) => GenreModel.fromJson(e as Map<String, dynamic>))
.toList();
});
+215 -10
View File
@@ -1,27 +1,232 @@
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 '../../../shared/widgets/feature_placeholder.dart';
import '../../../core/models/novel_model.dart';
import '../providers/home_provider.dart';
class HomeScreen extends StatelessWidget {
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final homeAsync = ref.watch(homeProvider);
return Scaffold(
appBar: AppBar(title: const Text('Trang chu')),
body: FeaturePlaceholder(
title: 'Home Feed',
description:
'Khung trang chu cho carousel hot, random grid, bang de cu, bang xep hang, truyện moi cap nhat va comments gan day.',
appBar: AppBar(
title: const Text('Reader'),
actions: [
FilledButton(
IconButton(
icon: const Icon(Icons.search),
onPressed: () => context.go(RouteNames.search),
child: const Text('Mo tim kiem'),
),
],
),
body: homeAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48),
const SizedBox(height: 12),
Text('Lỗi tải dữ liệu', style: Theme.of(context).textTheme.bodyLarge),
TextButton(
onPressed: () => ref.invalidate(homeProvider),
child: const Text('Thử lại'),
),
],
),
),
data: (data) => RefreshIndicator(
onRefresh: () async => ref.invalidate(homeProvider),
child: ListView(
children: [
_HotCarousel(novels: data.hot),
_SectionHeader(
title: 'Mới cập nhật',
onMore: () => context.go(RouteNames.search),
),
_NovelHorizontalList(novels: data.latest),
_SectionHeader(
title: 'Đánh giá cao',
onMore: () => context.go(RouteNames.search),
),
_NovelHorizontalList(novels: data.topRated),
const SizedBox(height: 16),
],
),
),
),
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
final VoidCallback? onMore;
const _SectionHeader({required this.title, this.onMore});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 8, 8),
child: Row(
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const Spacer(),
if (onMore != null)
TextButton(onPressed: onMore, child: const Text('Xem thêm')),
],
),
);
}
class _HotCarousel extends StatefulWidget {
final List<NovelModel> novels;
const _HotCarousel({required this.novels});
@override
State<_HotCarousel> createState() => _HotCarouselState();
}
class _HotCarouselState extends State<_HotCarousel> {
final PageController _controller = PageController(viewportFraction: 0.85);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.novels.isEmpty) return const SizedBox.shrink();
return SizedBox(
height: 220,
child: PageView.builder(
controller: _controller,
itemCount: widget.novels.length,
itemBuilder: (context, index) {
final novel = widget.novels[index];
return GestureDetector(
onTap: () => context.push(RouteNames.novelDetail(novel.id)),
child: _CarouselCard(novel: novel),
);
},
),
);
}
}
class _CarouselCard extends StatelessWidget {
final NovelModel novel;
const _CarouselCard({required this.novel});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 12),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
fit: StackFit.expand,
children: [
if (novel.coverUrl != null)
CachedNetworkImage(
imageUrl: novel.coverUrl!,
fit: BoxFit.cover,
placeholder: (_, __) => Container(color: Colors.grey[200]),
errorWidget: (_, __, ___) => Container(color: Colors.grey[300]),
)
else
Container(color: Theme.of(context).colorScheme.primaryContainer),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withAlpha(180)],
),
),
),
),
Positioned(
bottom: 12,
left: 12,
right: 12,
child: Text(
novel.title,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}
class _NovelHorizontalList extends StatelessWidget {
final List<NovelModel> novels;
const _NovelHorizontalList({required this.novels});
@override
Widget build(BuildContext context) {
return SizedBox(
height: 200,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
itemCount: novels.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final novel = novels[index];
return GestureDetector(
onTap: () => context.push(RouteNames.novelDetail(novel.id)),
child: SizedBox(
width: 110,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: novel.coverUrl != null
? CachedNetworkImage(
imageUrl: novel.coverUrl!,
width: 110,
height: 150,
fit: BoxFit.cover,
)
: Container(
width: 110,
height: 150,
color: Theme.of(context).colorScheme.primaryContainer,
child: const Icon(Icons.menu_book),
),
),
const SizedBox(height: 4),
Text(
novel.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
);
},
),
);
}
}
@@ -0,0 +1,38 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/novel_model.dart';
import '../../../core/network/providers.dart';
class HomeData {
final List<NovelModel> hot;
final List<NovelModel> latest;
final List<NovelModel> topRated;
const HomeData({
required this.hot,
required this.latest,
required this.topRated,
});
}
final homeProvider = FutureProvider<HomeData>((ref) async {
final client = ref.read(apiClientProvider);
final results = await Future.wait([
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': 'rating', 'limit': '10', 'page': '1'}),
]);
List<NovelModel> parseItems(dynamic res) {
final data = res.data as Map<String, dynamic>;
return (data['items'] as List)
.map((e) => NovelModel.fromJson(e as Map<String, dynamic>))
.toList();
}
return HomeData(
hot: parseItems(results[0]),
latest: parseItems(results[1]),
topRated: parseItems(results[2]),
);
});
@@ -1,43 +1,257 @@
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 '../../bookshelf/providers/bookshelf_provider.dart';
import '../providers/novels_provider.dart';
class NovelDetailScreen extends StatelessWidget {
class NovelDetailScreen extends ConsumerWidget {
const NovelDetailScreen({super.key, required this.novelId});
final String novelId;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final novelAsync = ref.watch(novelDetailProvider(novelId));
final chaptersAsync = ref.watch(chapterListProvider(novelId));
final isBookmarked = ref.watch(isBookmarkedProvider(novelId));
return Scaffold(
appBar: AppBar(title: const Text('Chi tiet truyện')),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
Text('Novel ID: ${novelId.isEmpty ? '(missing)' : novelId}'),
const SizedBox(height: 12),
const Text(
'Khung chi tiet truyện: metadata, series, chapter list, rating, bookmark, recommendation, comments.',
),
const SizedBox(height: 20),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
FilledButton(
onPressed: () => context.push('${RouteNames.reader}?chapterId=1'),
child: const Text('Doc chuong 1'),
body: novelAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Lỗi: $e')),
data: (novel) => CustomScrollView(
slivers: [
_NovelAppBar(novel: novel, isBookmarked: isBookmarked, onBookmark: () {
ref.read(bookshelfProvider.notifier).toggle(novelId);
}),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Genre chips
if (novel.genres.isNotEmpty)
Wrap(
spacing: 6,
runSpacing: 4,
children: novel.genres
.map((g) => Chip(
label: Text(g.name),
padding: EdgeInsets.zero,
labelPadding: const EdgeInsets.symmetric(horizontal: 8),
))
.toList(),
),
const SizedBox(height: 12),
// Description
if (novel.description != null)
Text(novel.description!, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 16),
// Stats row
_StatsRow(novel: novel),
const SizedBox(height: 16),
// Read button
chaptersAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
data: (chapters) {
if (chapters.isEmpty) return const SizedBox.shrink();
final first = chapters.first;
return FilledButton.icon(
onPressed: () => context.push(
RouteNames.readerChapter(first.id),
),
icon: const Icon(Icons.menu_book),
label: Text(
'Đọc Chương ${first.number}: ${first.title}',
),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
);
},
),
const SizedBox(height: 24),
// Chapter list header
Row(
children: [
Text('Danh sách chương', style: Theme.of(context).textTheme.titleMedium),
const Spacer(),
chaptersAsync.whenOrNull(
data: (chapters) => Text(
'${chapters.length} chương',
style: Theme.of(context).textTheme.bodySmall,
),
) ?? const SizedBox.shrink(),
],
),
],
),
),
OutlinedButton(
onPressed: () =>
context.push('${RouteNames.comments}?novelId=$novelId'),
child: const Text('Xem binh luan'),
),
// Chapter list
chaptersAsync.when(
loading: () => const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
),
],
),
],
error: (_, __) => const SliverToBoxAdapter(child: SizedBox.shrink()),
data: (chapters) => SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final ch = chapters[index];
return ListTile(
dense: true,
title: Text(
'Chương ${ch.number}: ${ch.title}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () => context.push(RouteNames.readerChapter(ch.id)),
);
},
childCount: chapters.length,
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
),
),
);
}
}
class _NovelAppBar extends StatelessWidget {
final NovelModel novel;
final bool isBookmarked;
final VoidCallback onBookmark;
const _NovelAppBar({
required this.novel,
required this.isBookmarked,
required this.onBookmark,
});
@override
Widget build(BuildContext context) {
return SliverAppBar(
expandedHeight: 280,
pinned: true,
actions: [
IconButton(
icon: Icon(isBookmarked ? Icons.bookmark : Icons.bookmark_outline),
onPressed: onBookmark,
),
IconButton(
icon: const Icon(Icons.comment_outlined),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => Scaffold(
appBar: AppBar(title: const Text('Bình luận')),
),
),
),
),
],
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
if (novel.coverUrl != null)
CachedNetworkImage(
imageUrl: novel.coverUrl!,
fit: BoxFit.cover,
)
else
Container(color: Theme.of(context).colorScheme.primaryContainer),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withAlpha(200)],
),
),
),
Positioned(
bottom: 16,
left: 16,
right: 16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
novel.title,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
if (novel.authorName.isNotEmpty)
Text(
novel.authorName,
style: const TextStyle(color: Colors.white70, fontSize: 14),
),
],
),
),
],
),
),
);
}
}
class _StatsRow extends StatelessWidget {
final NovelModel novel;
const _StatsRow({required this.novel});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 16,
runSpacing: 8,
children: [
if (novel.rating > 0)
_Stat(icon: Icons.star, value: novel.rating.toStringAsFixed(1)),
if (novel.views > 0)
_Stat(icon: Icons.visibility, value: _formatNum(novel.views)),
if (novel.latestChapter != null)
_Stat(icon: Icons.library_books, value: 'Ch. ${novel.latestChapter!.number}'),
],
);
}
String _formatNum(int n) {
if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M';
if (n >= 1000) return '${(n / 1000).toStringAsFixed(1)}K';
return n.toString();
}
}
class _Stat extends StatelessWidget {
final IconData icon;
final String value;
const _Stat({required this.icon, required this.value});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: Theme.of(context).colorScheme.secondary),
const SizedBox(width: 4),
Text(value, style: Theme.of(context).textTheme.bodySmall),
],
);
}
}
@@ -0,0 +1,121 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/novel_model.dart';
import '../../../core/models/chapter_model.dart';
import '../../../core/network/providers.dart';
// ─── Browse / Search ──────────────────────────────────────────────────────────
class BrowseParams {
final String? query;
final String? genre;
final String? status;
final String sort;
final int page;
const BrowseParams({
this.query,
this.genre,
this.status,
this.sort = 'latest',
this.page = 1,
});
Map<String, dynamic> toQueryParams() => {
if (query != null && query!.isNotEmpty) 'q': query,
if (genre != null) 'genre': genre,
if (status != null) 'status': status,
'sort': sort,
'page': page.toString(),
'limit': '20',
};
BrowseParams copyWith({
String? query,
String? genre,
String? status,
String? sort,
int? page,
bool clearQuery = false,
bool clearGenre = false,
bool clearStatus = false,
}) =>
BrowseParams(
query: clearQuery ? null : query ?? this.query,
genre: clearGenre ? null : genre ?? this.genre,
status: clearStatus ? null : status ?? this.status,
sort: sort ?? this.sort,
page: page ?? this.page,
);
}
class BrowseResult {
final List<NovelModel> items;
final int totalCount;
final int totalPages;
final int currentPage;
const BrowseResult({
required this.items,
required this.totalCount,
required this.totalPages,
required this.currentPage,
});
}
class NovelsNotifier extends StateNotifier<AsyncValue<BrowseResult>> {
final Ref _ref;
BrowseParams _params = const BrowseParams();
NovelsNotifier(this._ref) : super(const AsyncValue.loading()) {
fetch();
}
BrowseParams get params => _params;
Future<void> 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<String, dynamic>;
state = AsyncValue.data(BrowseResult(
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) {
state = AsyncValue.error(e, st);
}
}
Future<void> updateParams(BrowseParams params) => fetch(params: params);
}
final novelsProvider = StateNotifierProvider<NovelsNotifier, AsyncValue<BrowseResult>>((ref) {
return NovelsNotifier(ref);
});
// ─── Novel Detail ─────────────────────────────────────────────────────────────
final novelDetailProvider =
FutureProvider.family<NovelModel, String>((ref, idOrSlug) async {
final client = ref.read(apiClientProvider);
final res = await client.dio.get('/api/novels/$idOrSlug');
return NovelModel.fromJson(res.data as Map<String, dynamic>);
});
// ─── Chapter List ─────────────────────────────────────────────────────────────
final chapterListProvider =
FutureProvider.family<List<ChapterListItem>, String>((ref, novelId) async {
final client = ref.read(apiClientProvider);
final res = await client.dio.get('/api/truyen/$novelId/chapters');
final data = res.data as Map<String, dynamic>;
final chapters = data['chapters'] as List? ?? [];
return chapters
.map((e) => ChapterListItem.fromJson(e as Map<String, dynamic>))
.toList();
});
@@ -1,72 +1,265 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart';
import '../../../core/models/chapter_model.dart';
import '../providers/reader_provider.dart';
import 'tts_player_widget.dart';
class ReaderScreen extends StatefulWidget {
class ReaderScreen extends ConsumerStatefulWidget {
const ReaderScreen({super.key, required this.chapterId});
final String chapterId;
@override
State<ReaderScreen> createState() => _ReaderScreenState();
ConsumerState<ReaderScreen> createState() => _ReaderScreenState();
}
class _ReaderScreenState extends State<ReaderScreen> {
double fontSize = 18;
class _ReaderScreenState extends ConsumerState<ReaderScreen> {
final ScrollController _scrollCtrl = ScrollController();
bool _showUI = true;
@override
void initState() {
super.initState();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollCtrl.addListener(_onScroll);
});
}
@override
void dispose() {
_scrollCtrl.removeListener(_onScroll);
_scrollCtrl.dispose();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
void _onScroll() {
ref.read(readerProvider.notifier).updateScroll(_scrollCtrl.offset);
}
void _toggleUI() => setState(() => _showUI = !_showUI);
@override
Widget build(BuildContext context) {
final chapterAsync = ref.watch(chapterProvider(widget.chapterId));
final settings = ref.watch(readingSettingsProvider);
return Scaffold(
appBar: AppBar(
title: Text('Doc chuong ${widget.chapterId.isEmpty ? '?' : widget.chapterId}'),
actions: [
IconButton(
onPressed: () => context.push(RouteNames.settings),
icon: const Icon(Icons.tune),
tooltip: 'Cai dat doc',
body: chapterAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48),
const SizedBox(height: 8),
Text('Lỗi tải chương: $e'),
FilledButton(
onPressed: () => ref.invalidate(chapterProvider(widget.chapterId)),
child: const Text('Thử lại'),
),
],
),
],
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Reader body placeholder with TOC, TTS, offline marker.'),
const SizedBox(height: 12),
Text('Co chu hien tai: ${fontSize.toStringAsFixed(0)}'),
Slider(
min: 14,
max: 26,
value: fontSize,
onChanged: (v) => setState(() => fontSize = v),
),
const Spacer(),
Row(
),
data: (chapter) {
// Initialize progress tracking
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(readerProvider.notifier).open(
chapter.novelId,
chapter.id,
chapter.number,
);
});
return GestureDetector(
onTap: _toggleUI,
child: Stack(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {},
child: const Text('Chuong truoc'),
// Main content
Scrollbar(
controller: _scrollCtrl,
child: SingleChildScrollView(
controller: _scrollCtrl,
padding: const EdgeInsets.fromLTRB(20, 80, 20, 80),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Chương ${chapter.number}: ${chapter.title}',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 20),
SelectableText(
chapter.content,
style: TextStyle(
fontSize: settings.fontSize,
height: settings.lineHeight,
letterSpacing: settings.letterSpacing,
fontFamily: settings.fontFamily == 'serif' ? 'Georgia' : null,
),
),
const SizedBox(height: 40),
_NavButtons(chapter: chapter),
const SizedBox(height: 32),
],
),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () {},
child: const Text('Chuong sau'),
// Floating top bar
AnimatedSlide(
offset: _showUI ? Offset.zero : const Offset(0, -1),
duration: const Duration(milliseconds: 200),
child: _TopBar(chapter: chapter),
),
// Floating bottom bar with font controls
AnimatedSlide(
offset: _showUI ? Offset.zero : const Offset(0, 1),
duration: const Duration(milliseconds: 200),
child: Align(
alignment: Alignment.bottomCenter,
child: _BottomBar(settings: settings, onSettingsChanged: (s) {
ref.read(readingSettingsProvider.notifier).update(s);
}),
),
),
],
),
],
),
),
floatingActionButton: FloatingActionButton.small(
onPressed: () {},
child: const Icon(Icons.record_voice_over),
);
},
),
);
}
}
class _TopBar extends StatelessWidget {
final ChapterModel chapter;
const _TopBar({required this.chapter});
@override
Widget build(BuildContext context) {
return Container(
color: Theme.of(context).colorScheme.surface.withAlpha(230),
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
child: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: Text(
chapter.volumeTitle ?? 'Chương ${chapter.number}',
style: Theme.of(context).textTheme.bodyMedium,
),
leading: BackButton(onPressed: () => Navigator.maybePop(context)),
actions: [
IconButton(
icon: const Icon(Icons.comment_outlined),
onPressed: () => context.push(
RouteNames.commentsFor(chapter.novelId, chapterId: chapter.id),
),
),
IconButton(
icon: const Icon(Icons.record_voice_over_outlined),
onPressed: () => showModalBottomSheet(
context: context,
builder: (_) => Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Text-to-Speech',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
TtsPlayerWidget(content: chapter.content),
],
),
),
),
),
],
),
);
}
}
class _BottomBar extends StatelessWidget {
final dynamic settings;
final void Function(dynamic) onSettingsChanged;
const _BottomBar({required this.settings, required this.onSettingsChanged});
@override
Widget build(BuildContext context) {
return Container(
color: Theme.of(context).colorScheme.surface.withAlpha(230),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + 8,
left: 16,
right: 16,
top: 8,
),
child: Row(
children: [
const Icon(Icons.text_decrease, size: 18),
Expanded(
child: Slider(
value: settings.fontSize,
min: 12,
max: 28,
divisions: 8,
onChanged: (v) => onSettingsChanged(settings.copyWith(fontSize: v)),
),
),
const Icon(Icons.text_increase, size: 18),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.format_line_spacing),
onPressed: () {
final next = settings.lineHeight < 2.4
? settings.lineHeight + 0.2
: 1.4;
onSettingsChanged(settings.copyWith(lineHeight: next));
},
),
],
),
);
}
}
class _NavButtons extends ConsumerWidget {
final ChapterModel chapter;
const _NavButtons({required this.chapter});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Row(
children: [
if (chapter.prevChapterId != null)
Expanded(
child: OutlinedButton.icon(
onPressed: () => context.pushReplacement(
RouteNames.readerChapter(chapter.prevChapterId!),
),
icon: const Icon(Icons.chevron_left),
label: Text('Ch. ${chapter.prevChapterNumber ?? '?'}'),
),
),
if (chapter.prevChapterId != null && chapter.nextChapterId != null)
const SizedBox(width: 12),
if (chapter.nextChapterId != null)
Expanded(
child: FilledButton.icon(
onPressed: () => context.pushReplacement(
RouteNames.readerChapter(chapter.nextChapterId!),
),
icon: const Icon(Icons.chevron_right),
label: Text('Ch. ${chapter.nextChapterNumber ?? '?'}'),
),
),
],
);
}
}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../tts/tts_service.dart';
class TtsPlayerWidget extends ConsumerWidget {
final String content;
const TtsPlayerWidget({super.key, required this.content});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tts = ref.watch(ttsProvider);
final notifier = ref.read(ttsProvider.notifier);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(24),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.skip_previous),
onPressed: tts.status != TtsStatus.idle ? notifier.skipBack : null,
),
// Play/Pause/Stop
if (!tts.isPlaying)
IconButton.filled(
icon: const Icon(Icons.play_arrow),
onPressed: () => notifier.startReading(
content,
paragraphIndex: tts.paragraphIndex,
),
)
else
IconButton.filled(
icon: const Icon(Icons.pause),
onPressed: notifier.pause,
),
IconButton(
icon: const Icon(Icons.stop),
onPressed: tts.status != TtsStatus.idle ? notifier.stop : null,
),
IconButton(
icon: const Icon(Icons.skip_next),
onPressed: tts.status != TtsStatus.idle ? notifier.skipForward : null,
),
// Speed control
PopupMenuButton<double>(
initialValue: tts.speed,
onSelected: notifier.setSpeed,
icon: Text(
'${tts.speed}x',
style: Theme.of(context).textTheme.labelSmall,
),
itemBuilder: (_) => [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]
.map((s) => PopupMenuItem(value: s, child: Text('${s}x')))
.toList(),
),
// Progress indicator
if (tts.totalParagraphs > 0)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
'${tts.paragraphIndex + 1}/${tts.totalParagraphs}',
style: Theme.of(context).textTheme.labelSmall,
),
),
],
),
);
}
}
@@ -0,0 +1,130 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/chapter_model.dart';
import '../../../core/models/reading_settings.dart';
import '../../../core/network/providers.dart';
import '../../../core/storage/local_store.dart';
import '../../../core/storage/offline_cache.dart';
// ─── Chapter content ─────────────────────────────────────────────────────────
final chapterProvider =
FutureProvider.family<ChapterModel, String>((ref, chapterId) async {
final offlineCache = ref.read(offlineCacheProvider);
// Try network first, fall back to cache
try {
final client = ref.read(apiClientProvider);
final res = await client.dio.get('/api/chapters/$chapterId');
final chapter = ChapterModel.fromJson(res.data as Map<String, dynamic>);
// Cache for offline use (fire and forget)
unawaited(offlineCache.saveChapter(chapter));
return chapter;
} catch (_) {
final cached = await offlineCache.loadChapter(chapterId);
if (cached != null) return cached;
rethrow;
}
});
// ─── Reading progress ─────────────────────────────────────────────────────────
class ReadingProgress {
final String novelId;
final String chapterId;
final int chapterNumber;
final double scrollOffset;
const ReadingProgress({
required this.novelId,
required this.chapterId,
required this.chapterNumber,
required this.scrollOffset,
});
}
class ReaderNotifier extends StateNotifier<ReadingProgress?> {
final Ref _ref;
String? _novelId;
ReaderNotifier(this._ref) : super(null);
void open(String novelId, String chapterId, int chapterNumber) {
_novelId = novelId;
state = ReadingProgress(
novelId: novelId,
chapterId: chapterId,
chapterNumber: chapterNumber,
scrollOffset: 0,
);
_persistProgress(chapterId, chapterNumber, 0);
}
void updateScroll(double offset) {
if (state == null) return;
state = ReadingProgress(
novelId: state!.novelId,
chapterId: state!.chapterId,
chapterNumber: state!.chapterNumber,
scrollOffset: offset,
);
_debounceUpdate(offset);
}
Future<void> _persistProgress(
String chapterId, int chapterNumber, double offset) async {
final localStore = _ref.read(localStoreProvider);
await localStore.saveProgress(_novelId!, chapterId, chapterNumber, offset);
// Also notify server (fire and forget)
try {
final client = _ref.read(apiClientProvider);
await client.dio.post('/api/user/reading-progress', data: {
'novelId': _novelId,
'chapterId': chapterId,
'chapterNumber': chapterNumber,
'progress': offset,
});
} catch (_) {}
}
DateTime? _lastUpdate;
Future<void> _debounceUpdate(double offset) async {
final now = DateTime.now();
if (_lastUpdate != null && now.difference(_lastUpdate!).inSeconds < 3) return;
_lastUpdate = now;
if (state != null) {
await _persistProgress(state!.chapterId, state!.chapterNumber, offset);
}
}
}
final readerProvider =
StateNotifierProvider<ReaderNotifier, ReadingProgress?>((ref) {
return ReaderNotifier(ref);
});
// ─── Reading settings ─────────────────────────────────────────────────────────
class ReadingSettingsNotifier extends StateNotifier<ReadingSettings> {
final Ref _ref;
ReadingSettingsNotifier(this._ref) : super(const ReadingSettings()) {
_load();
}
Future<void> _load() async {
final localStore = _ref.read(localStoreProvider);
final saved = await localStore.loadReadingSettings();
if (saved != null) state = saved;
}
Future<void> update(ReadingSettings settings) async {
state = settings;
final localStore = _ref.read(localStoreProvider);
await localStore.saveReadingSettings(settings);
}
}
final readingSettingsProvider =
StateNotifierProvider<ReadingSettingsNotifier, ReadingSettings>((ref) {
return ReadingSettingsNotifier(ref);
});
+149
View File
@@ -0,0 +1,149 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
enum TtsStatus { idle, playing, paused }
class TtsState {
final TtsStatus status;
final int paragraphIndex;
final int totalParagraphs;
final double speed;
const TtsState({
this.status = TtsStatus.idle,
this.paragraphIndex = 0,
this.totalParagraphs = 0,
this.speed = 1.0,
});
TtsState copyWith({
TtsStatus? status,
int? paragraphIndex,
int? totalParagraphs,
double? speed,
}) =>
TtsState(
status: status ?? this.status,
paragraphIndex: paragraphIndex ?? this.paragraphIndex,
totalParagraphs: totalParagraphs ?? this.totalParagraphs,
speed: speed ?? this.speed,
);
bool get isPlaying => status == TtsStatus.playing;
}
class TtsNotifier extends StateNotifier<TtsState> {
final FlutterTts _tts = FlutterTts();
List<String> _paragraphs = [];
TtsNotifier() : super(const TtsState()) {
_init();
}
Future<void> _init() async {
await _tts.setLanguage('vi-VN');
await _tts.setSpeechRate(1.0);
await _tts.setVolume(1.0);
await _tts.setPitch(1.0);
_tts.setCompletionHandler(() {
if (state.status == TtsStatus.playing) {
_next();
}
});
_tts.setErrorHandler((msg) {
state = state.copyWith(status: TtsStatus.idle);
WakelockPlus.disable();
});
}
/// Start reading from [content] starting at optional [paragraphIndex].
Future<void> startReading(String content, {int paragraphIndex = 0}) async {
_paragraphs = content
.split(RegExp(r'\n+'))
.map((p) => p.trim())
.where((p) => p.isNotEmpty)
.toList();
if (_paragraphs.isEmpty) return;
final validIndex = paragraphIndex.clamp(0, _paragraphs.length - 1);
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: validIndex,
totalParagraphs: _paragraphs.length,
);
await WakelockPlus.enable();
await _speak(validIndex);
}
Future<void> _speak(int index) async {
if (index >= _paragraphs.length) {
state = state.copyWith(status: TtsStatus.idle);
await WakelockPlus.disable();
return;
}
await _tts.setSpeechRate(state.speed);
await _tts.speak(_paragraphs[index]);
}
Future<void> _next() async {
final next = state.paragraphIndex + 1;
if (next >= state.totalParagraphs) {
state = state.copyWith(status: TtsStatus.idle, paragraphIndex: 0);
await WakelockPlus.disable();
return;
}
state = state.copyWith(paragraphIndex: next);
await _speak(next);
}
Future<void> pause() async {
await _tts.pause();
state = state.copyWith(status: TtsStatus.paused);
await WakelockPlus.disable();
}
Future<void> resume() async {
if (state.status != TtsStatus.paused) return;
state = state.copyWith(status: TtsStatus.playing);
await WakelockPlus.enable();
await _speak(state.paragraphIndex);
}
Future<void> stop() async {
await _tts.stop();
state = state.copyWith(status: TtsStatus.idle, paragraphIndex: 0);
await WakelockPlus.disable();
}
Future<void> skipForward() async {
await _tts.stop();
await _next();
}
Future<void> skipBack() async {
await _tts.stop();
final prev = (state.paragraphIndex - 1).clamp(0, state.totalParagraphs - 1);
state = state.copyWith(paragraphIndex: prev);
if (state.status == TtsStatus.playing) await _speak(prev);
}
Future<void> setSpeed(double speed) async {
state = state.copyWith(speed: speed);
await _tts.setSpeechRate(speed);
}
@override
void dispose() {
_tts.stop();
WakelockPlus.disable();
super.dispose();
}
}
final ttsProvider = StateNotifierProvider<TtsNotifier, TtsState>((ref) {
return TtsNotifier();
});
@@ -1,19 +1,254 @@
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 '../../../shared/widgets/feature_placeholder.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 StatelessWidget {
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('Tim kiem')),
body: const FeaturePlaceholder(
title: 'Search + Filters',
description:
'Khung tim kiem truyện voi goi y theo tu khoa, loc theo the loai/trang thai va sap xep theo views-rating-latest.',
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: (_, __) => 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)),
);
}
}
@@ -1,53 +1,108 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
class SettingsScreen extends StatefulWidget {
import '../../../app/router/route_names.dart';
import '../../auth/providers/auth_provider.dart';
import '../providers/settings_provider.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
Widget build(BuildContext context, WidgetRef ref) {
final settingsAsync = ref.watch(userSettingsProvider);
final isAuth = ref.watch(isAuthenticatedProvider);
class _SettingsScreenState extends State<SettingsScreen> {
double fontSize = 18;
double lineHeight = 1.8;
double letterSpacing = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Cai dat doc')),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
Text('Co chu: ${fontSize.toStringAsFixed(0)}'),
Slider(
min: 14,
max: 26,
value: fontSize,
onChanged: (v) => setState(() => fontSize = v),
),
const SizedBox(height: 12),
Text('Line-height: ${lineHeight.toStringAsFixed(1)}'),
Slider(
min: 1.2,
max: 2.4,
value: lineHeight,
onChanged: (v) => setState(() => lineHeight = v),
),
const SizedBox(height: 12),
Text('Letter-spacing: ${letterSpacing.toStringAsFixed(1)}'),
Slider(
min: -0.5,
max: 2,
value: letterSpacing,
onChanged: (v) => setState(() => letterSpacing = v),
),
const SizedBox(height: 24),
FilledButton(
onPressed: () {},
child: const Text('Luu va dong bo'),
),
],
appBar: AppBar(title: const Text('Cài đặt đọc')),
body: settingsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Lỗi: $e')),
data: (settings) => ListView(
padding: const EdgeInsets.all(20),
children: [
Text('Cỡ chữ: ${settings.fontSize.toStringAsFixed(0)}',
style: Theme.of(context).textTheme.titleSmall),
Slider(
min: 12,
max: 28,
divisions: 8,
value: settings.fontSize,
onChanged: (v) => ref
.read(userSettingsProvider.notifier)
.updateSettings(settings.copyWith(fontSize: v)),
),
const SizedBox(height: 8),
Text('Khoảng cách dòng: ${settings.lineHeight.toStringAsFixed(1)}',
style: Theme.of(context).textTheme.titleSmall),
Slider(
min: 1.2,
max: 3.0,
divisions: 9,
value: settings.lineHeight,
onChanged: (v) => ref
.read(userSettingsProvider.notifier)
.updateSettings(settings.copyWith(lineHeight: v)),
),
const SizedBox(height: 8),
Text('Khoảng cách chữ: ${settings.letterSpacing.toStringAsFixed(1)}',
style: Theme.of(context).textTheme.titleSmall),
Slider(
min: 0,
max: 4,
divisions: 8,
value: settings.letterSpacing,
onChanged: (v) => ref
.read(userSettingsProvider.notifier)
.updateSettings(settings.copyWith(letterSpacing: v)),
),
const SizedBox(height: 8),
Text('Font chữ', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
SegmentedButton<String>(
segments: const [
ButtonSegment(value: 'serif', label: Text('Serif')),
ButtonSegment(value: 'sans', label: Text('Sans-serif')),
ButtonSegment(value: 'mono', label: Text('Mono')),
],
selected: {settings.fontFamily},
onSelectionChanged: (s) => ref
.read(userSettingsProvider.notifier)
.updateSettings(settings.copyWith(fontFamily: s.first)),
),
const Divider(height: 40),
// Preview
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Đây là đoạn văn mẫu để xem trước cài đặt hiển thị chữ của bạn.',
style: TextStyle(
fontSize: settings.fontSize,
height: settings.lineHeight,
letterSpacing: settings.letterSpacing,
fontFamily: settings.fontFamily == 'serif' ? 'Georgia' : null,
),
),
),
const Divider(height: 40),
if (isAuth)
ListTile(
leading: const Icon(Icons.logout, color: Colors.red),
title: const Text('Đăng xuất',
style: TextStyle(color: Colors.red)),
onTap: () async {
await ref.read(authProvider.notifier).signOut();
if (context.mounted) context.go(RouteNames.login);
},
),
],
),
),
);
}
@@ -0,0 +1,27 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/reading_settings.dart';
import '../../../core/storage/local_store.dart';
class UserSettingsNotifier extends StateNotifier<AsyncValue<ReadingSettings>> {
final Ref _ref;
UserSettingsNotifier(this._ref) : super(const AsyncValue.loading()) {
_load();
}
Future<void> _load() async {
final local = _ref.read(localStoreProvider);
final saved = await local.loadReadingSettings();
state = AsyncValue.data(saved ?? const ReadingSettings());
}
Future<void> updateSettings(ReadingSettings settings) async {
state = AsyncValue.data(settings);
final local = _ref.read(localStoreProvider);
await local.saveReadingSettings(settings);
}
}
final userSettingsProvider =
StateNotifierProvider<UserSettingsNotifier, AsyncValue<ReadingSettings>>(
(ref) => UserSettingsNotifier(ref));