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
+6 -6
View File
@@ -53,21 +53,21 @@ final appRouterProvider = Provider<GoRouter>((ref) {
],
),
GoRoute(
path: RouteNames.novelDetail,
path: RouteNames.novelDetailPath,
builder: (_, state) => NovelDetailScreen(
novelId: state.uri.queryParameters['id'] ?? '',
novelId: state.pathParameters['id'] ?? '',
),
),
GoRoute(
path: RouteNames.reader,
path: RouteNames.readerPath,
builder: (_, state) => ReaderScreen(
chapterId: state.uri.queryParameters['chapterId'] ?? '',
chapterId: state.pathParameters['chapterId'] ?? '',
),
),
GoRoute(
path: RouteNames.comments,
path: RouteNames.commentsPath,
builder: (_, state) => CommentsScreen(
novelId: state.uri.queryParameters['novelId'] ?? '',
novelId: state.pathParameters['novelId'] ?? '',
chapterId: state.uri.queryParameters['chapterId'],
),
),
+13 -3
View File
@@ -6,10 +6,20 @@ class RouteNames {
static const login = '/login';
static const search = '/search';
static const genres = '/genres';
static const novelDetail = '/novel';
static const reader = '/reader';
static const bookshelf = '/bookshelf';
static const comments = '/comments';
static const profile = '/profile';
static const settings = '/settings';
// Path-param based routes
static const novelDetailPath = '/novel/:id';
static const readerPath = '/reader/:chapterId';
static const commentsPath = '/comments/:novelId';
// Navigation helpers
static String novelDetail(String id) => '/novel/$id';
static String readerChapter(String chapterId) => '/reader/$chapterId';
static String commentsFor(String novelId, {String? chapterId}) {
final base = '/comments/$novelId';
return chapterId != null ? '$base?chapterId=$chapterId' : base;
}
}
+13
View File
@@ -0,0 +1,13 @@
class AppConfig {
AppConfig._();
static const String baseUrl = String.fromEnvironment(
'BASE_URL',
defaultValue: 'https://localhost:3000',
);
static const String googleClientId = String.fromEnvironment(
'GOOGLE_CLIENT_ID',
defaultValue: '',
);
}
+38
View File
@@ -0,0 +1,38 @@
import 'package:equatable/equatable.dart';
import 'novel_model.dart';
class BookmarkModel extends Equatable {
const BookmarkModel({
required this.id,
required this.novelId,
this.lastChapterId,
this.lastChapterNumber,
this.readChapters = const [],
this.novel,
});
final String id;
final String novelId;
final String? lastChapterId;
final int? lastChapterNumber;
final List<int> readChapters;
final NovelModel? novel;
factory BookmarkModel.fromJson(Map<String, dynamic> json) => BookmarkModel(
id: json['id'] as String,
novelId: json['novelId'] as String,
lastChapterId: json['lastChapterId'] as String?,
lastChapterNumber: json['lastChapterNumber'] as int?,
readChapters: (json['readChapters'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList() ??
[],
novel: json['novel'] != null
? NovelModel.fromJson(json['novel'] as Map<String, dynamic>)
: null,
);
@override
List<Object?> get props => [id, novelId];
}
+88
View File
@@ -0,0 +1,88 @@
import 'package:equatable/equatable.dart';
class ChapterModel extends Equatable {
const ChapterModel({
required this.id,
required this.novelId,
required this.number,
required this.title,
required this.content,
this.views = 0,
this.volumeNumber,
this.volumeTitle,
this.volumeChapterNumber,
this.prevChapterId,
this.prevChapterNumber,
this.nextChapterId,
this.nextChapterNumber,
required this.createdAt,
});
final String id;
final String novelId;
final int number;
final String title;
final String content;
final int views;
final int? volumeNumber;
final String? volumeTitle;
final int? volumeChapterNumber;
final String? prevChapterId;
final int? prevChapterNumber;
final String? nextChapterId;
final int? nextChapterNumber;
final DateTime createdAt;
factory ChapterModel.fromJson(Map<String, dynamic> json) => ChapterModel(
id: json['id'] as String,
novelId: json['novelId'] as String,
number: (json['number'] as num).toInt(),
title: json['title'] as String,
content: json['content'] as String,
views: (json['views'] as num?)?.toInt() ?? 0,
volumeNumber: json['volumeNumber'] as int?,
volumeTitle: json['volumeTitle'] as String?,
volumeChapterNumber: json['volumeChapterNumber'] as int?,
prevChapterId: json['prevChapterId'] as String?,
prevChapterNumber: json['prevChapterNumber'] as int?,
nextChapterId: json['nextChapterId'] as String?,
nextChapterNumber: json['nextChapterNumber'] as int?,
createdAt: DateTime.parse(json['createdAt'] as String),
);
@override
List<Object?> get props => [id, number];
}
class ChapterListItem extends Equatable {
const ChapterListItem({
required this.id,
required this.number,
required this.title,
this.volumeNumber,
this.volumeTitle,
this.volumeChapterNumber,
required this.createdAt,
});
final String id;
final int number;
final String title;
final int? volumeNumber;
final String? volumeTitle;
final int? volumeChapterNumber;
final DateTime createdAt;
factory ChapterListItem.fromJson(Map<String, dynamic> json) => ChapterListItem(
id: json['id'] as String,
number: (json['number'] as num).toInt(),
title: json['title'] as String,
volumeNumber: json['volumeNumber'] as int?,
volumeTitle: json['volumeTitle'] as String?,
volumeChapterNumber: json['volumeChapterNumber'] as int?,
createdAt: DateTime.parse(json['createdAt'] as String),
);
@override
List<Object?> get props => [id, number];
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:equatable/equatable.dart';
class CommentModel extends Equatable {
const CommentModel({
required this.id,
required this.userId,
required this.username,
required this.novelId,
required this.content,
required this.createdAt,
this.avatarUrl,
this.chapterId,
});
final String id;
final String userId;
final String username;
final String novelId;
final String content;
final DateTime createdAt;
final String? avatarUrl;
final String? chapterId;
factory CommentModel.fromJson(Map<String, dynamic> json) => CommentModel(
id: json['id'] as String,
userId: json['userId'] as String,
username: json['username'] as String? ?? 'User',
novelId: json['novelId'] as String,
content: json['content'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
avatarUrl: json['avatarUrl'] as String?,
chapterId: json['chapterId'] as String?,
);
@override
List<Object?> get props => [id];
}
+134
View File
@@ -0,0 +1,134 @@
import 'package:equatable/equatable.dart';
class NovelModel extends Equatable {
const NovelModel({
required this.id,
required this.title,
required this.slug,
required this.authorName,
required this.status,
required this.totalChapters,
this.originalTitle,
this.description,
this.coverUrl,
this.coverColor,
this.views = 0,
this.rating = 0,
this.ratingCount = 0,
this.bookmarkCount = 0,
this.genres = const [],
this.seriesId,
this.series,
this.latestChapter,
});
final String id;
final String title;
final String slug;
final String authorName;
final String status;
final int totalChapters;
final String? originalTitle;
final String? description;
final String? coverUrl;
final String? coverColor;
final int views;
final double rating;
final int ratingCount;
final int bookmarkCount;
final List<GenreModel> genres;
final String? seriesId;
final SeriesModel? series;
final LatestChapterInfo? latestChapter;
factory NovelModel.fromJson(Map<String, dynamic> json) => NovelModel(
id: json['id'] as String,
title: json['title'] as String,
slug: json['slug'] as String,
authorName: json['authorName'] as String,
status: json['status'] as String,
totalChapters: (json['totalChapters'] as num).toInt(),
originalTitle: json['originalTitle'] as String?,
description: json['description'] as String?,
coverUrl: json['coverUrl'] as String?,
coverColor: json['coverColor'] as String?,
views: (json['views'] as num?)?.toInt() ?? 0,
rating: (json['rating'] as num?)?.toDouble() ?? 0,
ratingCount: (json['ratingCount'] as num?)?.toInt() ?? 0,
bookmarkCount: (json['bookmarkCount'] as num?)?.toInt() ?? 0,
genres: (json['genres'] as List<dynamic>?)
?.map((g) => GenreModel.fromJson(g as Map<String, dynamic>))
.toList() ??
[],
seriesId: json['seriesId'] as String?,
series: json['series'] != null
? SeriesModel.fromJson(json['series'] as Map<String, dynamic>)
: null,
latestChapter: json['latestChapter'] != null
? LatestChapterInfo.fromJson(
json['latestChapter'] as Map<String, dynamic>)
: null,
);
@override
List<Object?> get props => [id, slug];
}
class GenreModel extends Equatable {
const GenreModel({required this.id, required this.name, required this.slug, this.description, this.icon, this.novelCount = 0});
final String id;
final String name;
final String slug;
final String? description;
final String? icon;
final int novelCount;
factory GenreModel.fromJson(Map<String, dynamic> json) => GenreModel(
id: json['id'] as String,
name: json['name'] as String,
slug: json['slug'] as String,
description: json['description'] as String?,
icon: json['icon'] as String?,
novelCount: (json['novelCount'] as num?)?.toInt() ?? 0,
);
@override
List<Object?> get props => [id, slug];
}
class SeriesModel extends Equatable {
const SeriesModel({required this.id, required this.name, required this.slug, this.novels = const []});
final String id;
final String name;
final String slug;
final List<NovelModel> novels;
factory SeriesModel.fromJson(Map<String, dynamic> json) => SeriesModel(
id: json['id'] as String,
name: json['name'] as String,
slug: json['slug'] as String,
novels: (json['novels'] as List<dynamic>?)
?.map((n) => NovelModel.fromJson(n as Map<String, dynamic>))
.toList() ??
[],
);
@override
List<Object?> get props => [id, slug];
}
class LatestChapterInfo extends Equatable {
const LatestChapterInfo({required this.number, required this.title, required this.createdAt});
final int number;
final String title;
final DateTime createdAt;
factory LatestChapterInfo.fromJson(Map<String, dynamic> json) => LatestChapterInfo(
number: (json['number'] as num).toInt(),
title: json['title'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
);
@override
List<Object?> get props => [number];
}
+40
View File
@@ -0,0 +1,40 @@
class ReadingSettings {
const ReadingSettings({
this.fontSize = 18,
this.lineHeight = 1.8,
this.letterSpacing = 0,
this.fontFamily = 'serif',
});
final double fontSize;
final double lineHeight;
final double letterSpacing;
final String fontFamily;
ReadingSettings copyWith({
double? fontSize,
double? lineHeight,
double? letterSpacing,
String? fontFamily,
}) =>
ReadingSettings(
fontSize: fontSize ?? this.fontSize,
lineHeight: lineHeight ?? this.lineHeight,
letterSpacing: letterSpacing ?? this.letterSpacing,
fontFamily: fontFamily ?? this.fontFamily,
);
factory ReadingSettings.fromJson(Map<String, dynamic> json) => ReadingSettings(
fontSize: (json['fontSize'] as num?)?.toDouble() ?? 18,
lineHeight: (json['lineHeight'] as num?)?.toDouble() ?? 1.8,
letterSpacing: (json['letterSpacing'] as num?)?.toDouble() ?? 0,
fontFamily: json['fontFamily'] as String? ?? 'serif',
);
Map<String, dynamic> toJson() => {
'fontSize': fontSize,
'lineHeight': lineHeight,
'letterSpacing': letterSpacing,
'fontFamily': fontFamily,
};
}
+36
View File
@@ -0,0 +1,36 @@
import 'package:equatable/equatable.dart';
class UserModel extends Equatable {
const UserModel({
required this.id,
required this.email,
this.name,
this.image,
this.role = 'USER',
});
final String id;
final String email;
final String? name;
final String? image;
final String role;
factory UserModel.fromJson(Map<String, dynamic> json) => UserModel(
id: json['id'] as String,
email: json['email'] as String,
name: json['name'] as String?,
image: json['image'] as String?,
role: json['role'] as String? ?? 'USER',
);
Map<String, dynamic> toJson() => {
'id': id,
'email': email,
'name': name,
'image': image,
'role': role,
};
@override
List<Object?> get props => [id, email];
}
+12
View File
@@ -0,0 +1,12 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../config/app_config.dart';
import '../storage/secure_store.dart';
import 'api_client.dart';
final secureStoreProvider = Provider<SecureStore>((ref) => SecureStore());
final apiClientProvider = Provider<ApiClient>((ref) {
final secureStore = ref.watch(secureStoreProvider);
return ApiClient(baseUrl: AppConfig.baseUrl, secureStore: secureStore);
});
+45 -15
View File
@@ -1,31 +1,61 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/reading_settings.dart';
class LocalStore {
static const _kFontSize = 'reader_font_size';
static const _kLineHeight = 'reader_line_height';
static const _kLetterSpacing = 'reader_letter_spacing';
static const _kFontFamily = 'reader_font_family';
static const _kProgressChapterId = 'progress_chapter_id_';
static const _kProgressChapterNum = 'progress_chapter_num_';
static const _kProgressOffset = 'progress_offset_';
Future<void> saveReadingSettings({
required double fontSize,
required double lineHeight,
required double letterSpacing,
required String fontFamily,
}) async {
// ── Reading settings ──────────────────────────────────────────────────────
Future<void> saveReadingSettings(ReadingSettings settings) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_kFontSize, fontSize);
await prefs.setDouble(_kLineHeight, lineHeight);
await prefs.setDouble(_kLetterSpacing, letterSpacing);
await prefs.setString(_kFontFamily, fontFamily);
await prefs.setDouble(_kFontSize, settings.fontSize);
await prefs.setDouble(_kLineHeight, settings.lineHeight);
await prefs.setDouble(_kLetterSpacing, settings.letterSpacing);
await prefs.setString(_kFontFamily, settings.fontFamily);
}
Future<Map<String, dynamic>> getReadingSettings() async {
Future<ReadingSettings?> loadReadingSettings() async {
final prefs = await SharedPreferences.getInstance();
if (!prefs.containsKey(_kFontSize)) return null;
return ReadingSettings(
fontSize: prefs.getDouble(_kFontSize) ?? 18,
lineHeight: prefs.getDouble(_kLineHeight) ?? 1.8,
letterSpacing: prefs.getDouble(_kLetterSpacing) ?? 0,
fontFamily: prefs.getString(_kFontFamily) ?? 'serif',
);
}
// ── Reading progress ──────────────────────────────────────────────────────
Future<void> saveProgress(
String novelId,
String chapterId,
int chapterNumber,
double offset,
) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('$_kProgressChapterId$novelId', chapterId);
await prefs.setInt('$_kProgressChapterNum$novelId', chapterNumber);
await prefs.setDouble('$_kProgressOffset$novelId', offset);
}
Future<Map<String, dynamic>?> loadProgress(String novelId) async {
final prefs = await SharedPreferences.getInstance();
final chapterId = prefs.getString('$_kProgressChapterId$novelId');
if (chapterId == null) return null;
return {
'fontSize': prefs.getDouble(_kFontSize) ?? 18,
'lineHeight': prefs.getDouble(_kLineHeight) ?? 1.8,
'letterSpacing': prefs.getDouble(_kLetterSpacing) ?? 0,
'fontFamily': prefs.getString(_kFontFamily) ?? 'serif',
'chapterId': chapterId,
'chapterNumber': prefs.getInt('$_kProgressChapterNum$novelId') ?? 1,
'scrollOffset': prefs.getDouble('$_kProgressOffset$novelId') ?? 0.0,
};
}
}
final localStoreProvider = Provider<LocalStore>((_) => LocalStore());
+126
View File
@@ -0,0 +1,126 @@
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/chapter_model.dart';
class OfflineCache {
static const _dbName = 'reader_offline.db';
static const _version = 1;
Database? _db;
Future<Database> get db async {
_db ??= await _open();
return _db!;
}
Future<Database> _open() async {
final dir = await getDatabasesPath();
final path = p.join(dir, _dbName);
return openDatabase(
path,
version: _version,
onCreate: (db, _) async {
await db.execute('''
CREATE TABLE cached_chapters (
id TEXT PRIMARY KEY,
novel_id TEXT NOT NULL,
chapter_number INTEGER NOT NULL,
title TEXT,
content TEXT NOT NULL,
prev_chapter_id TEXT,
prev_chapter_number INTEGER,
next_chapter_id TEXT,
next_chapter_number INTEGER,
volume_title TEXT,
cached_at INTEGER NOT NULL
)
''');
await db.execute('''
CREATE INDEX idx_novel_chapters ON cached_chapters(novel_id, chapter_number)
''');
},
);
}
Future<void> saveChapter(ChapterModel chapter) async {
final database = await db;
await database.insert(
'cached_chapters',
{
'id': chapter.id,
'novel_id': chapter.novelId,
'chapter_number': chapter.number,
'title': chapter.title,
'content': chapter.content,
'prev_chapter_id': chapter.prevChapterId,
'prev_chapter_number': chapter.prevChapterNumber,
'next_chapter_id': chapter.nextChapterId,
'next_chapter_number': chapter.nextChapterNumber,
'volume_title': chapter.volumeTitle,
'cached_at': DateTime.now().millisecondsSinceEpoch,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<ChapterModel?> loadChapter(String chapterId) async {
final database = await db;
final rows = await database.query(
'cached_chapters',
where: 'id = ?',
whereArgs: [chapterId],
limit: 1,
);
if (rows.isEmpty) return null;
return _rowToChapter(rows.first);
}
Future<List<String>> cachedChapterIdsForNovel(String novelId) async {
final database = await db;
final rows = await database.query(
'cached_chapters',
columns: ['id'],
where: 'novel_id = ?',
whereArgs: [novelId],
orderBy: 'chapter_number ASC',
);
return rows.map((r) => r['id'] as String).toList();
}
Future<void> deleteNovelCache(String novelId) async {
final database = await db;
await database.delete(
'cached_chapters',
where: 'novel_id = ?',
whereArgs: [novelId],
);
}
Future<int> getCacheSizeBytes() async {
final database = await db;
final result = await database.rawQuery(
'SELECT SUM(LENGTH(content)) as total FROM cached_chapters',
);
return (result.first['total'] as int?) ?? 0;
}
ChapterModel _rowToChapter(Map<String, dynamic> row) {
return ChapterModel(
id: row['id'] as String,
novelId: row['novel_id'] as String,
number: row['chapter_number'] as int,
title: (row['title'] as String?) ?? '',
content: row['content'] as String,
prevChapterId: row['prev_chapter_id'] as String?,
prevChapterNumber: row['prev_chapter_number'] as int?,
nextChapterId: row['next_chapter_id'] as String?,
nextChapterNumber: row['next_chapter_number'] as int?,
volumeTitle: row['volume_title'] as String?,
createdAt: DateTime.fromMillisecondsSinceEpoch(row['cached_at'] as int),
);
}
}
final offlineCacheProvider = Provider<OfflineCache>((_) => OfflineCache());
@@ -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));
+2 -2
View File
@@ -497,7 +497,7 @@ packages:
source: hosted
version: "3.2.1"
path:
dependency: transitive
dependency: "direct main"
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
@@ -670,7 +670,7 @@ packages:
source: hosted
version: "1.10.2"
sqflite:
dependency: transitive
dependency: "direct main"
description:
name: sqflite
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
+2
View File
@@ -44,6 +44,8 @@ dependencies:
connectivity_plus: ^6.1.4
equatable: ^2.0.7
intl: ^0.20.2
sqflite: ^2.4.1
path: ^1.9.1
dev_dependencies:
flutter_test: