diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 8864fe8..43cd006 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -53,21 +53,21 @@ final appRouterProvider = Provider((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'], ), ), diff --git a/lib/app/router/route_names.dart b/lib/app/router/route_names.dart index 89e21f9..42e3007 100644 --- a/lib/app/router/route_names.dart +++ b/lib/app/router/route_names.dart @@ -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; + } } diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart new file mode 100644 index 0000000..5ba8595 --- /dev/null +++ b/lib/core/config/app_config.dart @@ -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: '', + ); +} diff --git a/lib/core/models/bookmark_model.dart b/lib/core/models/bookmark_model.dart new file mode 100644 index 0000000..a83f747 --- /dev/null +++ b/lib/core/models/bookmark_model.dart @@ -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 readChapters; + final NovelModel? novel; + + factory BookmarkModel.fromJson(Map 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?) + ?.map((e) => (e as num).toInt()) + .toList() ?? + [], + novel: json['novel'] != null + ? NovelModel.fromJson(json['novel'] as Map) + : null, + ); + + @override + List get props => [id, novelId]; +} diff --git a/lib/core/models/chapter_model.dart b/lib/core/models/chapter_model.dart new file mode 100644 index 0000000..0fbbdc9 --- /dev/null +++ b/lib/core/models/chapter_model.dart @@ -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 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 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 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 get props => [id, number]; +} diff --git a/lib/core/models/comment_model.dart b/lib/core/models/comment_model.dart new file mode 100644 index 0000000..beb4460 --- /dev/null +++ b/lib/core/models/comment_model.dart @@ -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 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 get props => [id]; +} diff --git a/lib/core/models/novel_model.dart b/lib/core/models/novel_model.dart new file mode 100644 index 0000000..0d90da3 --- /dev/null +++ b/lib/core/models/novel_model.dart @@ -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 genres; + final String? seriesId; + final SeriesModel? series; + final LatestChapterInfo? latestChapter; + + factory NovelModel.fromJson(Map 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?) + ?.map((g) => GenreModel.fromJson(g as Map)) + .toList() ?? + [], + seriesId: json['seriesId'] as String?, + series: json['series'] != null + ? SeriesModel.fromJson(json['series'] as Map) + : null, + latestChapter: json['latestChapter'] != null + ? LatestChapterInfo.fromJson( + json['latestChapter'] as Map) + : null, + ); + + @override + List 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 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 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 novels; + + factory SeriesModel.fromJson(Map json) => SeriesModel( + id: json['id'] as String, + name: json['name'] as String, + slug: json['slug'] as String, + novels: (json['novels'] as List?) + ?.map((n) => NovelModel.fromJson(n as Map)) + .toList() ?? + [], + ); + + @override + List 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 json) => LatestChapterInfo( + number: (json['number'] as num).toInt(), + title: json['title'] as String, + createdAt: DateTime.parse(json['createdAt'] as String), + ); + + @override + List get props => [number]; +} diff --git a/lib/core/models/reading_settings.dart b/lib/core/models/reading_settings.dart new file mode 100644 index 0000000..76ca9c5 --- /dev/null +++ b/lib/core/models/reading_settings.dart @@ -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 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 toJson() => { + 'fontSize': fontSize, + 'lineHeight': lineHeight, + 'letterSpacing': letterSpacing, + 'fontFamily': fontFamily, + }; +} diff --git a/lib/core/models/user_model.dart b/lib/core/models/user_model.dart new file mode 100644 index 0000000..88f242d --- /dev/null +++ b/lib/core/models/user_model.dart @@ -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 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 toJson() => { + 'id': id, + 'email': email, + 'name': name, + 'image': image, + 'role': role, + }; + + @override + List get props => [id, email]; +} diff --git a/lib/core/network/providers.dart b/lib/core/network/providers.dart new file mode 100644 index 0000000..1d31d7d --- /dev/null +++ b/lib/core/network/providers.dart @@ -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((ref) => SecureStore()); + +final apiClientProvider = Provider((ref) { + final secureStore = ref.watch(secureStoreProvider); + return ApiClient(baseUrl: AppConfig.baseUrl, secureStore: secureStore); +}); diff --git a/lib/core/storage/local_store.dart b/lib/core/storage/local_store.dart index 7d8fcf4..0bfcb0a 100644 --- a/lib/core/storage/local_store.dart +++ b/lib/core/storage/local_store.dart @@ -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 saveReadingSettings({ - required double fontSize, - required double lineHeight, - required double letterSpacing, - required String fontFamily, - }) async { + // ── Reading settings ────────────────────────────────────────────────────── + + Future 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> getReadingSettings() async { + Future 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 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?> 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()); diff --git a/lib/core/storage/offline_cache.dart b/lib/core/storage/offline_cache.dart new file mode 100644 index 0000000..68f6e2c --- /dev/null +++ b/lib/core/storage/offline_cache.dart @@ -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 get db async { + _db ??= await _open(); + return _db!; + } + + Future _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 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 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> 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 deleteNovelCache(String novelId) async { + final database = await db; + await database.delete( + 'cached_chapters', + where: 'novel_id = ?', + whereArgs: [novelId], + ); + } + + Future 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 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()); diff --git a/lib/features/auth/presentation/login_screen.dart b/lib/features/auth/presentation/login_screen.dart index b4c38dd..bee4537 100644 --- a/lib/features/auth/presentation/login_screen.dart +++ b/lib/features/auth/presentation/login_screen.dart @@ -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(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), + ), + ), + ], + ), + ), + ), ), ); } diff --git a/lib/features/auth/providers/auth_provider.dart b/lib/features/auth/providers/auth_provider.dart new file mode 100644 index 0000000..b93e76e --- /dev/null +++ b/lib/features/auth/providers/auth_provider.dart @@ -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 { + 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 _restore() async { + final token = await _store.getAccessToken(); + if (token != null && token.isNotEmpty) { + await _fetchProfile(); + } else { + state = AuthUnauthenticated(); + } + } + + Future _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)); + } catch (_) { + await _store.clear(); + state = AuthUnauthenticated(); + } + } + + Future 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; + 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), + ); + } 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 signOut() async { + await _googleSignIn.signOut(); + await _store.clear(); + state = AuthUnauthenticated(); + } +} + +final authProvider = StateNotifierProvider((ref) { + return AuthNotifier(ref); +}); + +// Conveniences +final currentUserProvider = Provider((ref) { + final s = ref.watch(authProvider); + return s is AuthAuthenticated ? s.user : null; +}); + +final isAuthenticatedProvider = Provider((ref) { + return ref.watch(authProvider) is AuthAuthenticated; +}); diff --git a/lib/features/bookshelf/presentation/bookshelf_screen.dart b/lib/features/bookshelf/presentation/bookshelf_screen.dart index 1a05755..af48f4e 100644 --- a/lib/features/bookshelf/presentation/bookshelf_screen.dart +++ b/lib/features/bookshelf/presentation/bookshelf_screen.dart @@ -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)), + ); + } +} diff --git a/lib/features/bookshelf/providers/bookshelf_provider.dart b/lib/features/bookshelf/providers/bookshelf_provider.dart new file mode 100644 index 0000000..3f3fa58 --- /dev/null +++ b/lib/features/bookshelf/providers/bookshelf_provider.dart @@ -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>> { + final Ref _ref; + + BookshelfNotifier(this._ref) : super(const AsyncValue.loading()) { + fetch(); + } + + Future 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)) + .toList(); + state = AsyncValue.data(list); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + Future 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); + 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>>((ref) { + return BookshelfNotifier(ref); +}); + +final isBookmarkedProvider = Provider.family((ref, novelId) { + final bookshelf = ref.watch(bookshelfProvider); + return bookshelf.valueOrNull?.any((b) => b.novelId == novelId) ?? false; +}); diff --git a/lib/features/comments/presentation/comments_screen.dart b/lib/features/comments/presentation/comments_screen.dart index f584675..36d8244 100644 --- a/lib/features/comments/presentation/comments_screen.dart +++ b/lib/features/comments/presentation/comments_screen.dart @@ -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 createState() => _CommentsScreenState(); +} + +class _CommentsScreenState extends ConsumerState { + 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 _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), ), ], ), diff --git a/lib/features/comments/providers/comments_provider.dart b/lib/features/comments/providers/comments_provider.dart new file mode 100644 index 0000000..fb26ce4 --- /dev/null +++ b/lib/features/comments/providers/comments_provider.dart @@ -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>> { + 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 fetch({bool reset = false}) async { + if (reset) { + _page = 1; + _hasMore = true; + state = const AsyncValue.loading(); + } + try { + final client = _ref.read(apiClientProvider); + final queryParams = { + '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; + final newItems = (data['comments'] as List) + .map((e) => CommentModel.fromJson(e as Map)) + .toList(); + final totalPages = data['totalPages'] as int? ?? 1; + _hasMore = _page < totalPages; + final existing = reset ? [] : (state.valueOrNull ?? []); + state = AsyncValue.data([...existing, ...newItems]); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + Future loadMore() async { + if (!_hasMore) return; + _page++; + await fetch(); + } + + Future 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); + state = AsyncValue.data([newComment, ...(state.valueOrNull ?? [])]); + } +} + +// Provider family params: "novelId" or "novelId:chapterId" +final commentsProvider = StateNotifierProvider.family>, String>((ref, key) { + final parts = key.split(':'); + return CommentsNotifier( + ref, + novelId: parts[0], + chapterId: parts.length > 1 ? parts[1] : null, + ); +}); diff --git a/lib/features/genres/presentation/genres_screen.dart b/lib/features/genres/presentation/genres_screen.dart index ddfa347..fd333c4 100644 --- a/lib/features/genres/presentation/genres_screen.dart +++ b/lib/features/genres/presentation/genres_screen.dart @@ -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ể loại')), + 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, + ), + ), + ], + ), + ), ), ); } diff --git a/lib/features/genres/providers/genres_provider.dart b/lib/features/genres/providers/genres_provider.dart new file mode 100644 index 0000000..f50738c --- /dev/null +++ b/lib/features/genres/providers/genres_provider.dart @@ -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>((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)) + .toList(); +}); diff --git a/lib/features/home/presentation/home_screen.dart b/lib/features/home/presentation/home_screen.dart index 0105ff4..e0fd8f0 100644 --- a/lib/features/home/presentation/home_screen.dart +++ b/lib/features/home/presentation/home_screen.dart @@ -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 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 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, + ), + ], + ), + ), + ); + }, + ), ); } } diff --git a/lib/features/home/providers/home_provider.dart b/lib/features/home/providers/home_provider.dart new file mode 100644 index 0000000..007f69a --- /dev/null +++ b/lib/features/home/providers/home_provider.dart @@ -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 hot; + final List latest; + final List topRated; + + const HomeData({ + required this.hot, + required this.latest, + required this.topRated, + }); +} + +final homeProvider = FutureProvider((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 parseItems(dynamic res) { + final data = res.data as Map; + return (data['items'] as List) + .map((e) => NovelModel.fromJson(e as Map)) + .toList(); + } + + return HomeData( + hot: parseItems(results[0]), + latest: parseItems(results[1]), + topRated: parseItems(results[2]), + ); +}); diff --git a/lib/features/novel/presentation/novel_detail_screen.dart b/lib/features/novel/presentation/novel_detail_screen.dart index f603158..d84cb0f 100644 --- a/lib/features/novel/presentation/novel_detail_screen.dart +++ b/lib/features/novel/presentation/novel_detail_screen.dart @@ -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), + ], + ); + } +} diff --git a/lib/features/novel/providers/novels_provider.dart b/lib/features/novel/providers/novels_provider.dart new file mode 100644 index 0000000..4de447a --- /dev/null +++ b/lib/features/novel/providers/novels_provider.dart @@ -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 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 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> { + final Ref _ref; + BrowseParams _params = const BrowseParams(); + + NovelsNotifier(this._ref) : super(const AsyncValue.loading()) { + fetch(); + } + + BrowseParams get params => _params; + + Future fetch({BrowseParams? params}) async { + if (params != null) _params = params; + state = const AsyncValue.loading(); + try { + final client = _ref.read(apiClientProvider); + final res = await client.dio.get('/api/novels/browse', queryParameters: _params.toQueryParams()); + final data = res.data as Map; + state = AsyncValue.data(BrowseResult( + items: (data['items'] as List).map((e) => NovelModel.fromJson(e as Map)).toList(), + totalCount: data['totalCount'] as int, + totalPages: data['totalPages'] as int, + currentPage: data['currentPage'] as int, + )); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + Future updateParams(BrowseParams params) => fetch(params: params); +} + +final novelsProvider = StateNotifierProvider>((ref) { + return NovelsNotifier(ref); +}); + +// ─── Novel Detail ───────────────────────────────────────────────────────────── + +final novelDetailProvider = + FutureProvider.family((ref, idOrSlug) async { + final client = ref.read(apiClientProvider); + final res = await client.dio.get('/api/novels/$idOrSlug'); + return NovelModel.fromJson(res.data as Map); +}); + +// ─── Chapter List ───────────────────────────────────────────────────────────── + +final chapterListProvider = + FutureProvider.family, 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; + final chapters = data['chapters'] as List? ?? []; + return chapters + .map((e) => ChapterListItem.fromJson(e as Map)) + .toList(); +}); diff --git a/lib/features/reader/presentation/reader_screen.dart b/lib/features/reader/presentation/reader_screen.dart index d83c87e..e59fbf7 100644 --- a/lib/features/reader/presentation/reader_screen.dart +++ b/lib/features/reader/presentation/reader_screen.dart @@ -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 createState() => _ReaderScreenState(); + ConsumerState createState() => _ReaderScreenState(); } -class _ReaderScreenState extends State { - double fontSize = 18; +class _ReaderScreenState extends ConsumerState { + 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 ?? '?'}'), + ), + ), + ], + ); + } +} diff --git a/lib/features/reader/presentation/tts_player_widget.dart b/lib/features/reader/presentation/tts_player_widget.dart new file mode 100644 index 0000000..448466b --- /dev/null +++ b/lib/features/reader/presentation/tts_player_widget.dart @@ -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( + 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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/reader/providers/reader_provider.dart b/lib/features/reader/providers/reader_provider.dart new file mode 100644 index 0000000..464492d --- /dev/null +++ b/lib/features/reader/providers/reader_provider.dart @@ -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((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); + // 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 { + 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 _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 _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((ref) { + return ReaderNotifier(ref); +}); + +// ─── Reading settings ───────────────────────────────────────────────────────── + +class ReadingSettingsNotifier extends StateNotifier { + final Ref _ref; + + ReadingSettingsNotifier(this._ref) : super(const ReadingSettings()) { + _load(); + } + + Future _load() async { + final localStore = _ref.read(localStoreProvider); + final saved = await localStore.loadReadingSettings(); + if (saved != null) state = saved; + } + + Future update(ReadingSettings settings) async { + state = settings; + final localStore = _ref.read(localStoreProvider); + await localStore.saveReadingSettings(settings); + } +} + +final readingSettingsProvider = + StateNotifierProvider((ref) { + return ReadingSettingsNotifier(ref); +}); diff --git a/lib/features/reader/tts/tts_service.dart b/lib/features/reader/tts/tts_service.dart new file mode 100644 index 0000000..9cc18fe --- /dev/null +++ b/lib/features/reader/tts/tts_service.dart @@ -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 { + final FlutterTts _tts = FlutterTts(); + List _paragraphs = []; + + TtsNotifier() : super(const TtsState()) { + _init(); + } + + Future _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 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 _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 _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 pause() async { + await _tts.pause(); + state = state.copyWith(status: TtsStatus.paused); + await WakelockPlus.disable(); + } + + Future resume() async { + if (state.status != TtsStatus.paused) return; + state = state.copyWith(status: TtsStatus.playing); + await WakelockPlus.enable(); + await _speak(state.paragraphIndex); + } + + Future stop() async { + await _tts.stop(); + state = state.copyWith(status: TtsStatus.idle, paragraphIndex: 0); + await WakelockPlus.disable(); + } + + Future skipForward() async { + await _tts.stop(); + await _next(); + } + + Future 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 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((ref) { + return TtsNotifier(); +}); diff --git a/lib/features/search/presentation/search_screen.dart b/lib/features/search/presentation/search_screen.dart index 2bfc1ba..decc2b9 100644 --- a/lib/features/search/presentation/search_screen.dart +++ b/lib/features/search/presentation/search_screen.dart @@ -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 createState() => _SearchScreenState(); +} + +class _SearchScreenState extends ConsumerState { + 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> 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( + 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)), + ); + } +} diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 0214bb6..98df2dc 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -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 createState() => _SettingsScreenState(); -} + Widget build(BuildContext context, WidgetRef ref) { + final settingsAsync = ref.watch(userSettingsProvider); + final isAuth = ref.watch(isAuthenticatedProvider); -class _SettingsScreenState extends State { - 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( + 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); + }, + ), + ], + ), ), ); } diff --git a/lib/features/settings/providers/settings_provider.dart b/lib/features/settings/providers/settings_provider.dart new file mode 100644 index 0000000..e9f9945 --- /dev/null +++ b/lib/features/settings/providers/settings_provider.dart @@ -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> { + final Ref _ref; + + UserSettingsNotifier(this._ref) : super(const AsyncValue.loading()) { + _load(); + } + + Future _load() async { + final local = _ref.read(localStoreProvider); + final saved = await local.loadReadingSettings(); + state = AsyncValue.data(saved ?? const ReadingSettings()); + } + + Future updateSettings(ReadingSettings settings) async { + state = AsyncValue.data(settings); + final local = _ref.read(localStoreProvider); + await local.saveReadingSettings(settings); + } +} + +final userSettingsProvider = + StateNotifierProvider>( + (ref) => UserSettingsNotifier(ref)); diff --git a/pubspec.lock b/pubspec.lock index 9eb97ed..c6caea3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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 diff --git a/pubspec.yaml b/pubspec.yaml index f9b04d0..efa6ab9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: