fix: resolve flutter analyze errors - remove leaked code, fix method calls, cleanup imports
This commit is contained in:
@@ -53,21 +53,21 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteNames.novelDetail,
|
path: RouteNames.novelDetailPath,
|
||||||
builder: (_, state) => NovelDetailScreen(
|
builder: (_, state) => NovelDetailScreen(
|
||||||
novelId: state.uri.queryParameters['id'] ?? '',
|
novelId: state.pathParameters['id'] ?? '',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteNames.reader,
|
path: RouteNames.readerPath,
|
||||||
builder: (_, state) => ReaderScreen(
|
builder: (_, state) => ReaderScreen(
|
||||||
chapterId: state.uri.queryParameters['chapterId'] ?? '',
|
chapterId: state.pathParameters['chapterId'] ?? '',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteNames.comments,
|
path: RouteNames.commentsPath,
|
||||||
builder: (_, state) => CommentsScreen(
|
builder: (_, state) => CommentsScreen(
|
||||||
novelId: state.uri.queryParameters['novelId'] ?? '',
|
novelId: state.pathParameters['novelId'] ?? '',
|
||||||
chapterId: state.uri.queryParameters['chapterId'],
|
chapterId: state.uri.queryParameters['chapterId'],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,10 +6,20 @@ class RouteNames {
|
|||||||
static const login = '/login';
|
static const login = '/login';
|
||||||
static const search = '/search';
|
static const search = '/search';
|
||||||
static const genres = '/genres';
|
static const genres = '/genres';
|
||||||
static const novelDetail = '/novel';
|
|
||||||
static const reader = '/reader';
|
|
||||||
static const bookshelf = '/bookshelf';
|
static const bookshelf = '/bookshelf';
|
||||||
static const comments = '/comments';
|
|
||||||
static const profile = '/profile';
|
static const profile = '/profile';
|
||||||
static const settings = '/settings';
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import 'novel_model.dart';
|
||||||
|
|
||||||
|
class BookmarkModel extends Equatable {
|
||||||
|
const BookmarkModel({
|
||||||
|
required this.id,
|
||||||
|
required this.novelId,
|
||||||
|
this.lastChapterId,
|
||||||
|
this.lastChapterNumber,
|
||||||
|
this.readChapters = const [],
|
||||||
|
this.novel,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String novelId;
|
||||||
|
final String? lastChapterId;
|
||||||
|
final int? lastChapterNumber;
|
||||||
|
final List<int> readChapters;
|
||||||
|
final NovelModel? novel;
|
||||||
|
|
||||||
|
factory BookmarkModel.fromJson(Map<String, dynamic> json) => BookmarkModel(
|
||||||
|
id: json['id'] as String,
|
||||||
|
novelId: json['novelId'] as String,
|
||||||
|
lastChapterId: json['lastChapterId'] as String?,
|
||||||
|
lastChapterNumber: json['lastChapterNumber'] as int?,
|
||||||
|
readChapters: (json['readChapters'] as List<dynamic>?)
|
||||||
|
?.map((e) => (e as num).toInt())
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
|
novel: json['novel'] != null
|
||||||
|
? NovelModel.fromJson(json['novel'] as Map<String, dynamic>)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, novelId];
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
class ChapterModel extends Equatable {
|
||||||
|
const ChapterModel({
|
||||||
|
required this.id,
|
||||||
|
required this.novelId,
|
||||||
|
required this.number,
|
||||||
|
required this.title,
|
||||||
|
required this.content,
|
||||||
|
this.views = 0,
|
||||||
|
this.volumeNumber,
|
||||||
|
this.volumeTitle,
|
||||||
|
this.volumeChapterNumber,
|
||||||
|
this.prevChapterId,
|
||||||
|
this.prevChapterNumber,
|
||||||
|
this.nextChapterId,
|
||||||
|
this.nextChapterNumber,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String novelId;
|
||||||
|
final int number;
|
||||||
|
final String title;
|
||||||
|
final String content;
|
||||||
|
final int views;
|
||||||
|
final int? volumeNumber;
|
||||||
|
final String? volumeTitle;
|
||||||
|
final int? volumeChapterNumber;
|
||||||
|
final String? prevChapterId;
|
||||||
|
final int? prevChapterNumber;
|
||||||
|
final String? nextChapterId;
|
||||||
|
final int? nextChapterNumber;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
factory ChapterModel.fromJson(Map<String, dynamic> json) => ChapterModel(
|
||||||
|
id: json['id'] as String,
|
||||||
|
novelId: json['novelId'] as String,
|
||||||
|
number: (json['number'] as num).toInt(),
|
||||||
|
title: json['title'] as String,
|
||||||
|
content: json['content'] as String,
|
||||||
|
views: (json['views'] as num?)?.toInt() ?? 0,
|
||||||
|
volumeNumber: json['volumeNumber'] as int?,
|
||||||
|
volumeTitle: json['volumeTitle'] as String?,
|
||||||
|
volumeChapterNumber: json['volumeChapterNumber'] as int?,
|
||||||
|
prevChapterId: json['prevChapterId'] as String?,
|
||||||
|
prevChapterNumber: json['prevChapterNumber'] as int?,
|
||||||
|
nextChapterId: json['nextChapterId'] as String?,
|
||||||
|
nextChapterNumber: json['nextChapterNumber'] as int?,
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChapterListItem extends Equatable {
|
||||||
|
const ChapterListItem({
|
||||||
|
required this.id,
|
||||||
|
required this.number,
|
||||||
|
required this.title,
|
||||||
|
this.volumeNumber,
|
||||||
|
this.volumeTitle,
|
||||||
|
this.volumeChapterNumber,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final int number;
|
||||||
|
final String title;
|
||||||
|
final int? volumeNumber;
|
||||||
|
final String? volumeTitle;
|
||||||
|
final int? volumeChapterNumber;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
factory ChapterListItem.fromJson(Map<String, dynamic> json) => ChapterListItem(
|
||||||
|
id: json['id'] as String,
|
||||||
|
number: (json['number'] as num).toInt(),
|
||||||
|
title: json['title'] as String,
|
||||||
|
volumeNumber: json['volumeNumber'] as int?,
|
||||||
|
volumeTitle: json['volumeTitle'] as String?,
|
||||||
|
volumeChapterNumber: json['volumeChapterNumber'] as int?,
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, number];
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
class CommentModel extends Equatable {
|
||||||
|
const CommentModel({
|
||||||
|
required this.id,
|
||||||
|
required this.userId,
|
||||||
|
required this.username,
|
||||||
|
required this.novelId,
|
||||||
|
required this.content,
|
||||||
|
required this.createdAt,
|
||||||
|
this.avatarUrl,
|
||||||
|
this.chapterId,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String userId;
|
||||||
|
final String username;
|
||||||
|
final String novelId;
|
||||||
|
final String content;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final String? avatarUrl;
|
||||||
|
final String? chapterId;
|
||||||
|
|
||||||
|
factory CommentModel.fromJson(Map<String, dynamic> json) => CommentModel(
|
||||||
|
id: json['id'] as String,
|
||||||
|
userId: json['userId'] as String,
|
||||||
|
username: json['username'] as String? ?? 'User',
|
||||||
|
novelId: json['novelId'] as String,
|
||||||
|
content: json['content'] as String,
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
avatarUrl: json['avatarUrl'] as String?,
|
||||||
|
chapterId: json['chapterId'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id];
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
class NovelModel extends Equatable {
|
||||||
|
const NovelModel({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.slug,
|
||||||
|
required this.authorName,
|
||||||
|
required this.status,
|
||||||
|
required this.totalChapters,
|
||||||
|
this.originalTitle,
|
||||||
|
this.description,
|
||||||
|
this.coverUrl,
|
||||||
|
this.coverColor,
|
||||||
|
this.views = 0,
|
||||||
|
this.rating = 0,
|
||||||
|
this.ratingCount = 0,
|
||||||
|
this.bookmarkCount = 0,
|
||||||
|
this.genres = const [],
|
||||||
|
this.seriesId,
|
||||||
|
this.series,
|
||||||
|
this.latestChapter,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String slug;
|
||||||
|
final String authorName;
|
||||||
|
final String status;
|
||||||
|
final int totalChapters;
|
||||||
|
final String? originalTitle;
|
||||||
|
final String? description;
|
||||||
|
final String? coverUrl;
|
||||||
|
final String? coverColor;
|
||||||
|
final int views;
|
||||||
|
final double rating;
|
||||||
|
final int ratingCount;
|
||||||
|
final int bookmarkCount;
|
||||||
|
final List<GenreModel> genres;
|
||||||
|
final String? seriesId;
|
||||||
|
final SeriesModel? series;
|
||||||
|
final LatestChapterInfo? latestChapter;
|
||||||
|
|
||||||
|
factory NovelModel.fromJson(Map<String, dynamic> json) => NovelModel(
|
||||||
|
id: json['id'] as String,
|
||||||
|
title: json['title'] as String,
|
||||||
|
slug: json['slug'] as String,
|
||||||
|
authorName: json['authorName'] as String,
|
||||||
|
status: json['status'] as String,
|
||||||
|
totalChapters: (json['totalChapters'] as num).toInt(),
|
||||||
|
originalTitle: json['originalTitle'] as String?,
|
||||||
|
description: json['description'] as String?,
|
||||||
|
coverUrl: json['coverUrl'] as String?,
|
||||||
|
coverColor: json['coverColor'] as String?,
|
||||||
|
views: (json['views'] as num?)?.toInt() ?? 0,
|
||||||
|
rating: (json['rating'] as num?)?.toDouble() ?? 0,
|
||||||
|
ratingCount: (json['ratingCount'] as num?)?.toInt() ?? 0,
|
||||||
|
bookmarkCount: (json['bookmarkCount'] as num?)?.toInt() ?? 0,
|
||||||
|
genres: (json['genres'] as List<dynamic>?)
|
||||||
|
?.map((g) => GenreModel.fromJson(g as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
|
seriesId: json['seriesId'] as String?,
|
||||||
|
series: json['series'] != null
|
||||||
|
? SeriesModel.fromJson(json['series'] as Map<String, dynamic>)
|
||||||
|
: null,
|
||||||
|
latestChapter: json['latestChapter'] != null
|
||||||
|
? LatestChapterInfo.fromJson(
|
||||||
|
json['latestChapter'] as Map<String, dynamic>)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, slug];
|
||||||
|
}
|
||||||
|
|
||||||
|
class GenreModel extends Equatable {
|
||||||
|
const GenreModel({required this.id, required this.name, required this.slug, this.description, this.icon, this.novelCount = 0});
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String slug;
|
||||||
|
final String? description;
|
||||||
|
final String? icon;
|
||||||
|
final int novelCount;
|
||||||
|
|
||||||
|
factory GenreModel.fromJson(Map<String, dynamic> json) => GenreModel(
|
||||||
|
id: json['id'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
slug: json['slug'] as String,
|
||||||
|
description: json['description'] as String?,
|
||||||
|
icon: json['icon'] as String?,
|
||||||
|
novelCount: (json['novelCount'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, slug];
|
||||||
|
}
|
||||||
|
|
||||||
|
class SeriesModel extends Equatable {
|
||||||
|
const SeriesModel({required this.id, required this.name, required this.slug, this.novels = const []});
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String slug;
|
||||||
|
final List<NovelModel> novels;
|
||||||
|
|
||||||
|
factory SeriesModel.fromJson(Map<String, dynamic> json) => SeriesModel(
|
||||||
|
id: json['id'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
slug: json['slug'] as String,
|
||||||
|
novels: (json['novels'] as List<dynamic>?)
|
||||||
|
?.map((n) => NovelModel.fromJson(n as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, slug];
|
||||||
|
}
|
||||||
|
|
||||||
|
class LatestChapterInfo extends Equatable {
|
||||||
|
const LatestChapterInfo({required this.number, required this.title, required this.createdAt});
|
||||||
|
final int number;
|
||||||
|
final String title;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
factory LatestChapterInfo.fromJson(Map<String, dynamic> json) => LatestChapterInfo(
|
||||||
|
number: (json['number'] as num).toInt(),
|
||||||
|
title: json['title'] as String,
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [number];
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
class ReadingSettings {
|
||||||
|
const ReadingSettings({
|
||||||
|
this.fontSize = 18,
|
||||||
|
this.lineHeight = 1.8,
|
||||||
|
this.letterSpacing = 0,
|
||||||
|
this.fontFamily = 'serif',
|
||||||
|
});
|
||||||
|
|
||||||
|
final double fontSize;
|
||||||
|
final double lineHeight;
|
||||||
|
final double letterSpacing;
|
||||||
|
final String fontFamily;
|
||||||
|
|
||||||
|
ReadingSettings copyWith({
|
||||||
|
double? fontSize,
|
||||||
|
double? lineHeight,
|
||||||
|
double? letterSpacing,
|
||||||
|
String? fontFamily,
|
||||||
|
}) =>
|
||||||
|
ReadingSettings(
|
||||||
|
fontSize: fontSize ?? this.fontSize,
|
||||||
|
lineHeight: lineHeight ?? this.lineHeight,
|
||||||
|
letterSpacing: letterSpacing ?? this.letterSpacing,
|
||||||
|
fontFamily: fontFamily ?? this.fontFamily,
|
||||||
|
);
|
||||||
|
|
||||||
|
factory ReadingSettings.fromJson(Map<String, dynamic> json) => ReadingSettings(
|
||||||
|
fontSize: (json['fontSize'] as num?)?.toDouble() ?? 18,
|
||||||
|
lineHeight: (json['lineHeight'] as num?)?.toDouble() ?? 1.8,
|
||||||
|
letterSpacing: (json['letterSpacing'] as num?)?.toDouble() ?? 0,
|
||||||
|
fontFamily: json['fontFamily'] as String? ?? 'serif',
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'fontSize': fontSize,
|
||||||
|
'lineHeight': lineHeight,
|
||||||
|
'letterSpacing': letterSpacing,
|
||||||
|
'fontFamily': fontFamily,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
class UserModel extends Equatable {
|
||||||
|
const UserModel({
|
||||||
|
required this.id,
|
||||||
|
required this.email,
|
||||||
|
this.name,
|
||||||
|
this.image,
|
||||||
|
this.role = 'USER',
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String email;
|
||||||
|
final String? name;
|
||||||
|
final String? image;
|
||||||
|
final String role;
|
||||||
|
|
||||||
|
factory UserModel.fromJson(Map<String, dynamic> json) => UserModel(
|
||||||
|
id: json['id'] as String,
|
||||||
|
email: json['email'] as String,
|
||||||
|
name: json['name'] as String?,
|
||||||
|
image: json['image'] as String?,
|
||||||
|
role: json['role'] as String? ?? 'USER',
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'email': email,
|
||||||
|
'name': name,
|
||||||
|
'image': image,
|
||||||
|
'role': role,
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, email];
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../config/app_config.dart';
|
||||||
|
import '../storage/secure_store.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
final secureStoreProvider = Provider<SecureStore>((ref) => SecureStore());
|
||||||
|
|
||||||
|
final apiClientProvider = Provider<ApiClient>((ref) {
|
||||||
|
final secureStore = ref.watch(secureStoreProvider);
|
||||||
|
return ApiClient(baseUrl: AppConfig.baseUrl, secureStore: secureStore);
|
||||||
|
});
|
||||||
@@ -1,31 +1,61 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../models/reading_settings.dart';
|
||||||
|
|
||||||
class LocalStore {
|
class LocalStore {
|
||||||
static const _kFontSize = 'reader_font_size';
|
static const _kFontSize = 'reader_font_size';
|
||||||
static const _kLineHeight = 'reader_line_height';
|
static const _kLineHeight = 'reader_line_height';
|
||||||
static const _kLetterSpacing = 'reader_letter_spacing';
|
static const _kLetterSpacing = 'reader_letter_spacing';
|
||||||
static const _kFontFamily = 'reader_font_family';
|
static const _kFontFamily = 'reader_font_family';
|
||||||
|
static const _kProgressChapterId = 'progress_chapter_id_';
|
||||||
|
static const _kProgressChapterNum = 'progress_chapter_num_';
|
||||||
|
static const _kProgressOffset = 'progress_offset_';
|
||||||
|
|
||||||
Future<void> saveReadingSettings({
|
// ── Reading settings ──────────────────────────────────────────────────────
|
||||||
required double fontSize,
|
|
||||||
required double lineHeight,
|
Future<void> saveReadingSettings(ReadingSettings settings) async {
|
||||||
required double letterSpacing,
|
|
||||||
required String fontFamily,
|
|
||||||
}) async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setDouble(_kFontSize, fontSize);
|
await prefs.setDouble(_kFontSize, settings.fontSize);
|
||||||
await prefs.setDouble(_kLineHeight, lineHeight);
|
await prefs.setDouble(_kLineHeight, settings.lineHeight);
|
||||||
await prefs.setDouble(_kLetterSpacing, letterSpacing);
|
await prefs.setDouble(_kLetterSpacing, settings.letterSpacing);
|
||||||
await prefs.setString(_kFontFamily, fontFamily);
|
await prefs.setString(_kFontFamily, settings.fontFamily);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> getReadingSettings() async {
|
Future<ReadingSettings?> loadReadingSettings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (!prefs.containsKey(_kFontSize)) return null;
|
||||||
|
return ReadingSettings(
|
||||||
|
fontSize: prefs.getDouble(_kFontSize) ?? 18,
|
||||||
|
lineHeight: prefs.getDouble(_kLineHeight) ?? 1.8,
|
||||||
|
letterSpacing: prefs.getDouble(_kLetterSpacing) ?? 0,
|
||||||
|
fontFamily: prefs.getString(_kFontFamily) ?? 'serif',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reading progress ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> saveProgress(
|
||||||
|
String novelId,
|
||||||
|
String chapterId,
|
||||||
|
int chapterNumber,
|
||||||
|
double offset,
|
||||||
|
) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString('$_kProgressChapterId$novelId', chapterId);
|
||||||
|
await prefs.setInt('$_kProgressChapterNum$novelId', chapterNumber);
|
||||||
|
await prefs.setDouble('$_kProgressOffset$novelId', offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> loadProgress(String novelId) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final chapterId = prefs.getString('$_kProgressChapterId$novelId');
|
||||||
|
if (chapterId == null) return null;
|
||||||
return {
|
return {
|
||||||
'fontSize': prefs.getDouble(_kFontSize) ?? 18,
|
'chapterId': chapterId,
|
||||||
'lineHeight': prefs.getDouble(_kLineHeight) ?? 1.8,
|
'chapterNumber': prefs.getInt('$_kProgressChapterNum$novelId') ?? 1,
|
||||||
'letterSpacing': prefs.getDouble(_kLetterSpacing) ?? 0,
|
'scrollOffset': prefs.getDouble('$_kProgressOffset$novelId') ?? 0.0,
|
||||||
'fontFamily': prefs.getString(_kFontFamily) ?? 'serif',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final localStoreProvider = Provider<LocalStore>((_) => LocalStore());
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../models/chapter_model.dart';
|
||||||
|
|
||||||
|
class OfflineCache {
|
||||||
|
static const _dbName = 'reader_offline.db';
|
||||||
|
static const _version = 1;
|
||||||
|
|
||||||
|
Database? _db;
|
||||||
|
|
||||||
|
Future<Database> get db async {
|
||||||
|
_db ??= await _open();
|
||||||
|
return _db!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Database> _open() async {
|
||||||
|
final dir = await getDatabasesPath();
|
||||||
|
final path = p.join(dir, _dbName);
|
||||||
|
return openDatabase(
|
||||||
|
path,
|
||||||
|
version: _version,
|
||||||
|
onCreate: (db, _) async {
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE cached_chapters (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
novel_id TEXT NOT NULL,
|
||||||
|
chapter_number INTEGER NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
prev_chapter_id TEXT,
|
||||||
|
prev_chapter_number INTEGER,
|
||||||
|
next_chapter_id TEXT,
|
||||||
|
next_chapter_number INTEGER,
|
||||||
|
volume_title TEXT,
|
||||||
|
cached_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
await db.execute('''
|
||||||
|
CREATE INDEX idx_novel_chapters ON cached_chapters(novel_id, chapter_number)
|
||||||
|
''');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveChapter(ChapterModel chapter) async {
|
||||||
|
final database = await db;
|
||||||
|
await database.insert(
|
||||||
|
'cached_chapters',
|
||||||
|
{
|
||||||
|
'id': chapter.id,
|
||||||
|
'novel_id': chapter.novelId,
|
||||||
|
'chapter_number': chapter.number,
|
||||||
|
'title': chapter.title,
|
||||||
|
'content': chapter.content,
|
||||||
|
'prev_chapter_id': chapter.prevChapterId,
|
||||||
|
'prev_chapter_number': chapter.prevChapterNumber,
|
||||||
|
'next_chapter_id': chapter.nextChapterId,
|
||||||
|
'next_chapter_number': chapter.nextChapterNumber,
|
||||||
|
'volume_title': chapter.volumeTitle,
|
||||||
|
'cached_at': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ChapterModel?> loadChapter(String chapterId) async {
|
||||||
|
final database = await db;
|
||||||
|
final rows = await database.query(
|
||||||
|
'cached_chapters',
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [chapterId],
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
if (rows.isEmpty) return null;
|
||||||
|
return _rowToChapter(rows.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> cachedChapterIdsForNovel(String novelId) async {
|
||||||
|
final database = await db;
|
||||||
|
final rows = await database.query(
|
||||||
|
'cached_chapters',
|
||||||
|
columns: ['id'],
|
||||||
|
where: 'novel_id = ?',
|
||||||
|
whereArgs: [novelId],
|
||||||
|
orderBy: 'chapter_number ASC',
|
||||||
|
);
|
||||||
|
return rows.map((r) => r['id'] as String).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteNovelCache(String novelId) async {
|
||||||
|
final database = await db;
|
||||||
|
await database.delete(
|
||||||
|
'cached_chapters',
|
||||||
|
where: 'novel_id = ?',
|
||||||
|
whereArgs: [novelId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> getCacheSizeBytes() async {
|
||||||
|
final database = await db;
|
||||||
|
final result = await database.rawQuery(
|
||||||
|
'SELECT SUM(LENGTH(content)) as total FROM cached_chapters',
|
||||||
|
);
|
||||||
|
return (result.first['total'] as int?) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChapterModel _rowToChapter(Map<String, dynamic> row) {
|
||||||
|
return ChapterModel(
|
||||||
|
id: row['id'] as String,
|
||||||
|
novelId: row['novel_id'] as String,
|
||||||
|
number: row['chapter_number'] as int,
|
||||||
|
title: (row['title'] as String?) ?? '',
|
||||||
|
content: row['content'] as String,
|
||||||
|
prevChapterId: row['prev_chapter_id'] as String?,
|
||||||
|
prevChapterNumber: row['prev_chapter_number'] as int?,
|
||||||
|
nextChapterId: row['next_chapter_id'] as String?,
|
||||||
|
nextChapterNumber: row['next_chapter_number'] as int?,
|
||||||
|
volumeTitle: row['volume_title'] as String?,
|
||||||
|
createdAt: DateTime.fromMillisecondsSinceEpoch(row['cached_at'] as int),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final offlineCacheProvider = Provider<OfflineCache>((_) => OfflineCache());
|
||||||
@@ -1,18 +1,67 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/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});
|
const LoginScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final authState = ref.watch(authProvider);
|
||||||
|
|
||||||
|
ref.listen<AuthState>(authProvider, (_, next) {
|
||||||
|
if (next is AuthAuthenticated) {
|
||||||
|
context.go(RouteNames.home);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final isLoading = authState is AuthLoading;
|
||||||
|
final errorMsg = authState is AuthError ? authState.message : null;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Dang nhap')),
|
body: SafeArea(
|
||||||
body: const FeaturePlaceholder(
|
child: Center(
|
||||||
title: 'Google Login',
|
child: Padding(
|
||||||
description:
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
'Khung dang nhap Google OAuth cho mobile auth endpoint. Se bo sung token refresh va secure storage trong phase tiep theo.',
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.menu_book_rounded, size: 64),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text('Reader App', style: Theme.of(context).textTheme.headlineMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Đọc truyện mọi lúc, mọi nơi',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 48),
|
||||||
|
if (errorMsg != null) ...[
|
||||||
|
Text(errorMsg, style: const TextStyle(color: Colors.red)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: isLoading
|
||||||
|
? null
|
||||||
|
: () => ref.read(authProvider.notifier).signInWithGoogle(),
|
||||||
|
icon: isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.login),
|
||||||
|
label: const Text('Đăng nhập bằng Google'),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(52),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:google_sign_in/google_sign_in.dart';
|
||||||
|
|
||||||
|
import '../../../core/config/app_config.dart';
|
||||||
|
import '../../../core/models/user_model.dart';
|
||||||
|
import '../../../core/network/providers.dart';
|
||||||
|
import '../../../core/storage/secure_store.dart';
|
||||||
|
|
||||||
|
// ─── State ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
abstract class AuthState {}
|
||||||
|
|
||||||
|
class AuthInitial extends AuthState {}
|
||||||
|
|
||||||
|
class AuthLoading extends AuthState {}
|
||||||
|
|
||||||
|
class AuthAuthenticated extends AuthState {
|
||||||
|
AuthAuthenticated(this.user);
|
||||||
|
final UserModel user;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthUnauthenticated extends AuthState {}
|
||||||
|
|
||||||
|
class AuthError extends AuthState {
|
||||||
|
AuthError(this.message);
|
||||||
|
final String message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Notifier ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class AuthNotifier extends StateNotifier<AuthState> {
|
||||||
|
AuthNotifier(this._ref) : super(AuthInitial()) {
|
||||||
|
_restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
SecureStore get _store => _ref.read(secureStoreProvider);
|
||||||
|
|
||||||
|
final GoogleSignIn _googleSignIn = GoogleSignIn(
|
||||||
|
clientId: AppConfig.googleClientId.isNotEmpty ? AppConfig.googleClientId : null,
|
||||||
|
scopes: ['email', 'profile'],
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> _restore() async {
|
||||||
|
final token = await _store.getAccessToken();
|
||||||
|
if (token != null && token.isNotEmpty) {
|
||||||
|
await _fetchProfile();
|
||||||
|
} else {
|
||||||
|
state = AuthUnauthenticated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchProfile() async {
|
||||||
|
try {
|
||||||
|
final dio = _ref.read(apiClientProvider).dio;
|
||||||
|
final res = await dio.get('/api/user/profile');
|
||||||
|
state = AuthAuthenticated(UserModel.fromJson(res.data as Map<String, dynamic>));
|
||||||
|
} catch (_) {
|
||||||
|
await _store.clear();
|
||||||
|
state = AuthUnauthenticated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> signInWithGoogle() async {
|
||||||
|
try {
|
||||||
|
state = AuthLoading();
|
||||||
|
final account = await _googleSignIn.signIn();
|
||||||
|
if (account == null) {
|
||||||
|
state = AuthUnauthenticated();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final auth = await account.authentication;
|
||||||
|
final idToken = auth.idToken;
|
||||||
|
if (idToken == null) {
|
||||||
|
state = AuthError('Could not get ID token from Google');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final dio = _ref.read(apiClientProvider).dio;
|
||||||
|
final res = await dio.post(
|
||||||
|
'/api/auth/mobile-login',
|
||||||
|
data: {'googleIdToken': idToken},
|
||||||
|
);
|
||||||
|
|
||||||
|
final data = res.data as Map<String, dynamic>;
|
||||||
|
await _store.setAccessToken(data['accessToken'] as String);
|
||||||
|
if (data['refreshToken'] != null) {
|
||||||
|
await _store.setRefreshToken(data['refreshToken'] as String);
|
||||||
|
}
|
||||||
|
|
||||||
|
state = AuthAuthenticated(
|
||||||
|
UserModel.fromJson(data['user'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
final msg = (e.response?.data as Map?)?['error'] ?? e.message ?? 'Login failed';
|
||||||
|
state = AuthError(msg.toString());
|
||||||
|
} catch (e) {
|
||||||
|
state = AuthError(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> signOut() async {
|
||||||
|
await _googleSignIn.signOut();
|
||||||
|
await _store.clear();
|
||||||
|
state = AuthUnauthenticated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
||||||
|
return AuthNotifier(ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Conveniences
|
||||||
|
final currentUserProvider = Provider<UserModel?>((ref) {
|
||||||
|
final s = ref.watch(authProvider);
|
||||||
|
return s is AuthAuthenticated ? s.user : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final isAuthenticatedProvider = Provider<bool>((ref) {
|
||||||
|
return ref.watch(authProvider) is AuthAuthenticated;
|
||||||
|
});
|
||||||
@@ -1,48 +1,129 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/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});
|
const BookshelfScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return DefaultTabController(
|
final isAuth = ref.watch(isAuthenticatedProvider);
|
||||||
length: 4,
|
|
||||||
child: Scaffold(
|
if (!isAuth) {
|
||||||
appBar: AppBar(
|
return Scaffold(
|
||||||
title: const Text('Tu sach'),
|
appBar: AppBar(title: const Text('Tủ sách')),
|
||||||
bottom: const TabBar(
|
body: Center(
|
||||||
isScrollable: true,
|
child: Column(
|
||||||
tabs: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
Tab(text: 'Dang doc'),
|
children: [
|
||||||
Tab(text: 'Danh dau'),
|
const Icon(Icons.lock_outline, size: 48),
|
||||||
Tab(text: 'Da doc'),
|
const SizedBox(height: 12),
|
||||||
Tab(text: 'De cu'),
|
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',
|
final bookshelfAsync = ref.watch(bookshelfProvider);
|
||||||
description: 'Danh sach truyện dang doc theo progress sync.',
|
|
||||||
),
|
return Scaffold(
|
||||||
FeaturePlaceholder(
|
appBar: AppBar(
|
||||||
title: 'Danh dau',
|
title: const Text('Tủ sách'),
|
||||||
description: 'Tat ca truyện da bookmark cua user.',
|
actions: [
|
||||||
),
|
IconButton(
|
||||||
FeaturePlaceholder(
|
icon: const Icon(Icons.refresh),
|
||||||
title: 'Da doc',
|
onPressed: () => ref.read(bookshelfProvider.notifier).fetch(),
|
||||||
description: 'Danh sach truyện da hoan thanh.',
|
),
|
||||||
),
|
],
|
||||||
FeaturePlaceholder(
|
),
|
||||||
title: 'De cu',
|
body: bookshelfAsync.when(
|
||||||
description: 'Danh sach truyện user da de cu.',
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
),
|
error: (e, _) => Center(
|
||||||
],
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 48),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('Lỗi: $e'),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => ref.read(bookshelfProvider.notifier).fetch(),
|
||||||
|
child: const Text('Thử lại'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
data: (bookmarks) {
|
||||||
|
if (bookmarks.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.menu_book_outlined, size: 56),
|
||||||
|
SizedBox(height: 12),
|
||||||
|
Text('Chưa có truyện nào trong tủ sách'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () => ref.read(bookshelfProvider.notifier).fetch(),
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: bookmarks.length,
|
||||||
|
itemBuilder: (context, index) =>
|
||||||
|
_BookmarkTile(bookmark: bookmarks[index]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _BookmarkTile extends StatelessWidget {
|
||||||
|
final BookmarkModel bookmark;
|
||||||
|
const _BookmarkTile({required this.bookmark});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final novel = bookmark.novel;
|
||||||
|
return ListTile(
|
||||||
|
leading: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: novel?.coverUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: novel!.coverUrl!,
|
||||||
|
width: 44,
|
||||||
|
height: 60,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
width: 44,
|
||||||
|
height: 60,
|
||||||
|
color: Theme.of(context).colorScheme.primaryContainer,
|
||||||
|
child: const Icon(Icons.menu_book, size: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
novel?.title ?? bookmark.novelId,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: novel?.authorName != null
|
||||||
|
? Text(novel!.authorName, maxLines: 1, overflow: TextOverflow.ellipsis)
|
||||||
|
: null,
|
||||||
|
onTap: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../../core/models/bookmark_model.dart';
|
||||||
|
import '../../../core/network/providers.dart';
|
||||||
|
|
||||||
|
class BookshelfNotifier extends StateNotifier<AsyncValue<List<BookmarkModel>>> {
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
BookshelfNotifier(this._ref) : super(const AsyncValue.loading()) {
|
||||||
|
fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetch() async {
|
||||||
|
state = const AsyncValue.loading();
|
||||||
|
try {
|
||||||
|
final client = _ref.read(apiClientProvider);
|
||||||
|
final res = await client.dio.get('/api/user/bookmarks');
|
||||||
|
final list = (res.data as List)
|
||||||
|
.map((e) => BookmarkModel.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
state = AsyncValue.data(list);
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncValue.error(e, st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggle(String novelId) async {
|
||||||
|
try {
|
||||||
|
final client = _ref.read(apiClientProvider);
|
||||||
|
final current = state.valueOrNull ?? [];
|
||||||
|
final existing = current.where((b) => b.novelId == novelId).toList();
|
||||||
|
if (existing.isEmpty) {
|
||||||
|
final res = await client.dio.post('/api/user/bookmarks', data: {'novelId': novelId});
|
||||||
|
final updated = BookmarkModel.fromJson(res.data as Map<String, dynamic>);
|
||||||
|
state = AsyncValue.data([...current, updated]);
|
||||||
|
} else {
|
||||||
|
await client.dio.delete('/api/user/bookmarks/$novelId');
|
||||||
|
state = AsyncValue.data(current.where((b) => b.novelId != novelId).toList());
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncValue.error(e, st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isBookmarked(String novelId) {
|
||||||
|
return (state.valueOrNull ?? []).any((b) => b.novelId == novelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final bookshelfProvider =
|
||||||
|
StateNotifierProvider<BookshelfNotifier, AsyncValue<List<BookmarkModel>>>((ref) {
|
||||||
|
return BookshelfNotifier(ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
final isBookmarkedProvider = Provider.family<bool, String>((ref, novelId) {
|
||||||
|
final bookshelf = ref.watch(bookshelfProvider);
|
||||||
|
return bookshelf.valueOrNull?.any((b) => b.novelId == novelId) ?? false;
|
||||||
|
});
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/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({
|
const CommentsScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.novelId,
|
required this.novelId,
|
||||||
@@ -10,29 +16,179 @@ class CommentsScreen extends StatelessWidget {
|
|||||||
final String novelId;
|
final String novelId;
|
||||||
final String? chapterId;
|
final String? chapterId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<CommentsScreen> createState() => _CommentsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CommentsScreenState extends ConsumerState<CommentsScreen> {
|
||||||
|
final _textCtrl = TextEditingController();
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
String get _key =>
|
||||||
|
widget.chapterId != null ? '${widget.novelId}:${widget.chapterId}' : widget.novelId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_textCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
final text = _textCtrl.text.trim();
|
||||||
|
if (text.isEmpty) return;
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
try {
|
||||||
|
await ref.read(commentsProvider(_key).notifier).post(text);
|
||||||
|
_textCtrl.clear();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text('Lỗi: $e')));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final isAuth = ref.watch(isAuthenticatedProvider);
|
||||||
|
final commentsAsync = ref.watch(commentsProvider(_key));
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Binh luan')),
|
appBar: AppBar(
|
||||||
body: ListView(
|
title: Text(widget.chapterId != null ? 'Bình luận chương' : 'Bình luận'),
|
||||||
padding: const EdgeInsets.all(20),
|
),
|
||||||
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Text('Novel ID: ${novelId.isEmpty ? '(missing)' : novelId}'),
|
Expanded(
|
||||||
Text('Chapter ID: ${chapterId ?? '(all novel comments)'}'),
|
child: commentsAsync.when(
|
||||||
const SizedBox(height: 12),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
const Text('Khung danh sach binh luan + form gui comment + phan trang.'),
|
error: (e, _) => Center(child: Text('Lỗi: $e')),
|
||||||
const SizedBox(height: 20),
|
data: (comments) {
|
||||||
TextField(
|
if (comments.isEmpty) {
|
||||||
maxLines: 4,
|
return const Center(child: Text('Chưa có bình luận nào'));
|
||||||
decoration: const InputDecoration(
|
}
|
||||||
hintText: 'Viet binh luan cua ban...',
|
return ListView.separated(
|
||||||
border: OutlineInputBorder(),
|
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),
|
if (isAuth)
|
||||||
FilledButton(
|
_CommentInput(
|
||||||
onPressed: () {},
|
controller: _textCtrl,
|
||||||
child: const Text('Gui binh luan'),
|
submitting: _submitting,
|
||||||
|
onSubmit: _submit,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CommentTile extends StatelessWidget {
|
||||||
|
final CommentModel comment;
|
||||||
|
const _CommentTile({required this.comment});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 16,
|
||||||
|
child: Text(
|
||||||
|
comment.username[0].toUpperCase(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
comment.username,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_formatDate(comment.createdAt),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(comment.content),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime? dt) {
|
||||||
|
if (dt == null) return '';
|
||||||
|
return DateFormat('dd/MM/yyyy HH:mm').format(dt.toLocal());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CommentInput extends StatelessWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final bool submitting;
|
||||||
|
final VoidCallback onSubmit;
|
||||||
|
|
||||||
|
const _CommentInput({
|
||||||
|
required this.controller,
|
||||||
|
required this.submitting,
|
||||||
|
required this.onSubmit,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 12,
|
||||||
|
right: 12,
|
||||||
|
top: 8,
|
||||||
|
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||||
|
),
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: controller,
|
||||||
|
maxLines: 3,
|
||||||
|
minLines: 1,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Viết bình luận...',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.newline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton.filled(
|
||||||
|
onPressed: submitting ? null : onSubmit,
|
||||||
|
icon: submitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.send),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../../core/models/comment_model.dart';
|
||||||
|
import '../../../core/network/providers.dart';
|
||||||
|
|
||||||
|
class CommentsNotifier extends StateNotifier<AsyncValue<List<CommentModel>>> {
|
||||||
|
final Ref _ref;
|
||||||
|
final String novelId;
|
||||||
|
final String? chapterId;
|
||||||
|
int _page = 1;
|
||||||
|
bool _hasMore = true;
|
||||||
|
|
||||||
|
CommentsNotifier(this._ref, {required this.novelId, this.chapterId})
|
||||||
|
: super(const AsyncValue.loading()) {
|
||||||
|
fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get hasMore => _hasMore;
|
||||||
|
|
||||||
|
Future<void> fetch({bool reset = false}) async {
|
||||||
|
if (reset) {
|
||||||
|
_page = 1;
|
||||||
|
_hasMore = true;
|
||||||
|
state = const AsyncValue.loading();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final client = _ref.read(apiClientProvider);
|
||||||
|
final queryParams = <String, dynamic>{
|
||||||
|
'page': _page.toString(),
|
||||||
|
'limit': '20',
|
||||||
|
if (chapterId != null) 'chapterId': chapterId,
|
||||||
|
};
|
||||||
|
final res = await client.dio.get('/api/truyen/$novelId/comments', queryParameters: queryParams);
|
||||||
|
final data = res.data as Map<String, dynamic>;
|
||||||
|
final newItems = (data['comments'] as List)
|
||||||
|
.map((e) => CommentModel.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
final totalPages = data['totalPages'] as int? ?? 1;
|
||||||
|
_hasMore = _page < totalPages;
|
||||||
|
final existing = reset ? <CommentModel>[] : (state.valueOrNull ?? []);
|
||||||
|
state = AsyncValue.data([...existing, ...newItems]);
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncValue.error(e, st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadMore() async {
|
||||||
|
if (!_hasMore) return;
|
||||||
|
_page++;
|
||||||
|
await fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> post(String content) async {
|
||||||
|
final client = _ref.read(apiClientProvider);
|
||||||
|
final res = await client.dio.post('/api/truyen/$novelId/comments', data: {
|
||||||
|
'content': content,
|
||||||
|
if (chapterId != null) 'chapterId': chapterId,
|
||||||
|
});
|
||||||
|
final newComment = CommentModel.fromJson(res.data as Map<String, dynamic>);
|
||||||
|
state = AsyncValue.data([newComment, ...(state.valueOrNull ?? [])]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider family params: "novelId" or "novelId:chapterId"
|
||||||
|
final commentsProvider = StateNotifierProvider.family<CommentsNotifier,
|
||||||
|
AsyncValue<List<CommentModel>>, String>((ref, key) {
|
||||||
|
final parts = key.split(':');
|
||||||
|
return CommentsNotifier(
|
||||||
|
ref,
|
||||||
|
novelId: parts[0],
|
||||||
|
chapterId: parts.length > 1 ? parts[1] : null,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,18 +1,87 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/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});
|
const GenresScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final genresAsync = ref.watch(genresProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('The loai')),
|
appBar: AppBar(title: const Text('Thể loại')),
|
||||||
body: const FeaturePlaceholder(
|
body: genresAsync.when(
|
||||||
title: 'Genre Discovery',
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
description:
|
error: (e, _) => Center(
|
||||||
'Khung danh sach the loai va man hinh truyện theo the loai slug de dong bo hanh vi voi web.',
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 48),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('Lỗi tải thể loại'),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => ref.invalidate(genresProvider),
|
||||||
|
child: const Text('Thử lại'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
data: (genres) => GridView.builder(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
childAspectRatio: 2.5,
|
||||||
|
),
|
||||||
|
itemCount: genres.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final genre = genres[index];
|
||||||
|
return _GenreCard(genre: genre);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GenreCard extends StatelessWidget {
|
||||||
|
final GenreModel genre;
|
||||||
|
const _GenreCard({required this.genre});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: () => context.go('${RouteNames.search}?genre=${genre.slug}'),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
genre.name,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (genre.novelCount > 0)
|
||||||
|
Text(
|
||||||
|
'${genre.novelCount} truyện',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../../core/models/novel_model.dart';
|
||||||
|
import '../../../core/network/providers.dart';
|
||||||
|
|
||||||
|
final genresProvider = FutureProvider<List<GenreModel>>((ref) async {
|
||||||
|
final client = ref.read(apiClientProvider);
|
||||||
|
final res = await client.dio.get('/api/genres');
|
||||||
|
return (res.data as List)
|
||||||
|
.map((e) => GenreModel.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
@@ -1,27 +1,232 @@
|
|||||||
import 'package:flutter/material.dart';
|
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 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../app/router/route_names.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});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final homeAsync = ref.watch(homeProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Trang chu')),
|
appBar: AppBar(
|
||||||
body: FeaturePlaceholder(
|
title: const Text('Reader'),
|
||||||
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.',
|
|
||||||
actions: [
|
actions: [
|
||||||
FilledButton(
|
IconButton(
|
||||||
|
icon: const Icon(Icons.search),
|
||||||
onPressed: () => context.go(RouteNames.search),
|
onPressed: () => context.go(RouteNames.search),
|
||||||
child: const Text('Mo tim kiem'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
body: homeAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 48),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text('Lỗi tải dữ liệu', style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => ref.invalidate(homeProvider),
|
||||||
|
child: const Text('Thử lại'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
data: (data) => RefreshIndicator(
|
||||||
|
onRefresh: () async => ref.invalidate(homeProvider),
|
||||||
|
child: ListView(
|
||||||
|
children: [
|
||||||
|
_HotCarousel(novels: data.hot),
|
||||||
|
_SectionHeader(
|
||||||
|
title: 'Mới cập nhật',
|
||||||
|
onMore: () => context.go(RouteNames.search),
|
||||||
|
),
|
||||||
|
_NovelHorizontalList(novels: data.latest),
|
||||||
|
_SectionHeader(
|
||||||
|
title: 'Đánh giá cao',
|
||||||
|
onMore: () => context.go(RouteNames.search),
|
||||||
|
),
|
||||||
|
_NovelHorizontalList(novels: data.topRated),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SectionHeader extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final VoidCallback? onMore;
|
||||||
|
|
||||||
|
const _SectionHeader({required this.title, this.onMore});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 20, 8, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const Spacer(),
|
||||||
|
if (onMore != null)
|
||||||
|
TextButton(onPressed: onMore, child: const Text('Xem thêm')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HotCarousel extends StatefulWidget {
|
||||||
|
final List<NovelModel> novels;
|
||||||
|
const _HotCarousel({required this.novels});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_HotCarousel> createState() => _HotCarouselState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HotCarouselState extends State<_HotCarousel> {
|
||||||
|
final PageController _controller = PageController(viewportFraction: 0.85);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (widget.novels.isEmpty) return const SizedBox.shrink();
|
||||||
|
return SizedBox(
|
||||||
|
height: 220,
|
||||||
|
child: PageView.builder(
|
||||||
|
controller: _controller,
|
||||||
|
itemCount: widget.novels.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final novel = widget.novels[index];
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => context.push(RouteNames.novelDetail(novel.id)),
|
||||||
|
child: _CarouselCard(novel: novel),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CarouselCard extends StatelessWidget {
|
||||||
|
final NovelModel novel;
|
||||||
|
const _CarouselCard({required this.novel});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 12),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (novel.coverUrl != null)
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: novel.coverUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) => Container(color: Colors.grey[200]),
|
||||||
|
errorWidget: (_, __, ___) => Container(color: Colors.grey[300]),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(color: Theme.of(context).colorScheme.primaryContainer),
|
||||||
|
Positioned.fill(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Colors.transparent, Colors.black.withAlpha(180)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 12,
|
||||||
|
left: 12,
|
||||||
|
right: 12,
|
||||||
|
child: Text(
|
||||||
|
novel.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NovelHorizontalList extends StatelessWidget {
|
||||||
|
final List<NovelModel> novels;
|
||||||
|
const _NovelHorizontalList({required this.novels});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: novels.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final novel = novels[index];
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => context.push(RouteNames.novelDetail(novel.id)),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 110,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: novel.coverUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: novel.coverUrl!,
|
||||||
|
width: 110,
|
||||||
|
height: 150,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
width: 110,
|
||||||
|
height: 150,
|
||||||
|
color: Theme.of(context).colorScheme.primaryContainer,
|
||||||
|
child: const Icon(Icons.menu_book),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
novel.title,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../../core/models/novel_model.dart';
|
||||||
|
import '../../../core/network/providers.dart';
|
||||||
|
|
||||||
|
class HomeData {
|
||||||
|
final List<NovelModel> hot;
|
||||||
|
final List<NovelModel> latest;
|
||||||
|
final List<NovelModel> topRated;
|
||||||
|
|
||||||
|
const HomeData({
|
||||||
|
required this.hot,
|
||||||
|
required this.latest,
|
||||||
|
required this.topRated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final homeProvider = FutureProvider<HomeData>((ref) async {
|
||||||
|
final client = ref.read(apiClientProvider);
|
||||||
|
|
||||||
|
final results = await Future.wait([
|
||||||
|
client.dio.get('/api/novels/browse', queryParameters: {'sort': 'popular', 'limit': '10', 'page': '1'}),
|
||||||
|
client.dio.get('/api/novels/browse', queryParameters: {'sort': 'latest', 'limit': '20', 'page': '1'}),
|
||||||
|
client.dio.get('/api/novels/browse', queryParameters: {'sort': 'rating', 'limit': '10', 'page': '1'}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
List<NovelModel> parseItems(dynamic res) {
|
||||||
|
final data = res.data as Map<String, dynamic>;
|
||||||
|
return (data['items'] as List)
|
||||||
|
.map((e) => NovelModel.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return HomeData(
|
||||||
|
hot: parseItems(results[0]),
|
||||||
|
latest: parseItems(results[1]),
|
||||||
|
topRated: parseItems(results[2]),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,43 +1,257 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/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 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../app/router/route_names.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});
|
const NovelDetailScreen({super.key, required this.novelId});
|
||||||
|
|
||||||
final String novelId;
|
final String novelId;
|
||||||
|
|
||||||
@override
|
@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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Chi tiet truyện')),
|
body: novelAsync.when(
|
||||||
body: ListView(
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
padding: const EdgeInsets.all(20),
|
error: (e, _) => Center(child: Text('Lỗi: $e')),
|
||||||
children: [
|
data: (novel) => CustomScrollView(
|
||||||
Text('Novel ID: ${novelId.isEmpty ? '(missing)' : novelId}'),
|
slivers: [
|
||||||
const SizedBox(height: 12),
|
_NovelAppBar(novel: novel, isBookmarked: isBookmarked, onBookmark: () {
|
||||||
const Text(
|
ref.read(bookshelfProvider.notifier).toggle(novelId);
|
||||||
'Khung chi tiet truyện: metadata, series, chapter list, rating, bookmark, recommendation, comments.',
|
}),
|
||||||
),
|
SliverToBoxAdapter(
|
||||||
const SizedBox(height: 20),
|
child: Padding(
|
||||||
Wrap(
|
padding: const EdgeInsets.all(16),
|
||||||
spacing: 10,
|
child: Column(
|
||||||
runSpacing: 10,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
FilledButton(
|
// Genre chips
|
||||||
onPressed: () => context.push('${RouteNames.reader}?chapterId=1'),
|
if (novel.genres.isNotEmpty)
|
||||||
child: const Text('Doc chuong 1'),
|
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: () =>
|
// Chapter list
|
||||||
context.push('${RouteNames.comments}?novelId=$novelId'),
|
chaptersAsync.when(
|
||||||
child: const Text('Xem binh luan'),
|
loading: () => const SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
error: (_, __) => const SliverToBoxAdapter(child: SizedBox.shrink()),
|
||||||
),
|
data: (chapters) => SliverList(
|
||||||
],
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final ch = chapters[index];
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text(
|
||||||
|
'Chương ${ch.number}: ${ch.title}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
onTap: () => context.push(RouteNames.readerChapter(ch.id)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: chapters.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _NovelAppBar extends StatelessWidget {
|
||||||
|
final NovelModel novel;
|
||||||
|
final bool isBookmarked;
|
||||||
|
final VoidCallback onBookmark;
|
||||||
|
|
||||||
|
const _NovelAppBar({
|
||||||
|
required this.novel,
|
||||||
|
required this.isBookmarked,
|
||||||
|
required this.onBookmark,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SliverAppBar(
|
||||||
|
expandedHeight: 280,
|
||||||
|
pinned: true,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(isBookmarked ? Icons.bookmark : Icons.bookmark_outline),
|
||||||
|
onPressed: onBookmark,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.comment_outlined),
|
||||||
|
onPressed: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Bình luận')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
background: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (novel.coverUrl != null)
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: novel.coverUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(color: Theme.of(context).colorScheme.primaryContainer),
|
||||||
|
DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Colors.transparent, Colors.black.withAlpha(200)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 16,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
novel.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (novel.authorName.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
novel.authorName,
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 14),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatsRow extends StatelessWidget {
|
||||||
|
final NovelModel novel;
|
||||||
|
const _StatsRow({required this.novel});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Wrap(
|
||||||
|
spacing: 16,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
if (novel.rating > 0)
|
||||||
|
_Stat(icon: Icons.star, value: novel.rating.toStringAsFixed(1)),
|
||||||
|
if (novel.views > 0)
|
||||||
|
_Stat(icon: Icons.visibility, value: _formatNum(novel.views)),
|
||||||
|
if (novel.latestChapter != null)
|
||||||
|
_Stat(icon: Icons.library_books, value: 'Ch. ${novel.latestChapter!.number}'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatNum(int n) {
|
||||||
|
if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M';
|
||||||
|
if (n >= 1000) return '${(n / 1000).toStringAsFixed(1)}K';
|
||||||
|
return n.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Stat extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String value;
|
||||||
|
const _Stat({required this.icon, required this.value});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 16, color: Theme.of(context).colorScheme.secondary),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(value, style: Theme.of(context).textTheme.bodySmall),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../core/models/novel_model.dart';
|
||||||
|
import '../../../core/models/chapter_model.dart';
|
||||||
|
import '../../../core/network/providers.dart';
|
||||||
|
|
||||||
|
// ─── Browse / Search ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class BrowseParams {
|
||||||
|
final String? query;
|
||||||
|
final String? genre;
|
||||||
|
final String? status;
|
||||||
|
final String sort;
|
||||||
|
final int page;
|
||||||
|
|
||||||
|
const BrowseParams({
|
||||||
|
this.query,
|
||||||
|
this.genre,
|
||||||
|
this.status,
|
||||||
|
this.sort = 'latest',
|
||||||
|
this.page = 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toQueryParams() => {
|
||||||
|
if (query != null && query!.isNotEmpty) 'q': query,
|
||||||
|
if (genre != null) 'genre': genre,
|
||||||
|
if (status != null) 'status': status,
|
||||||
|
'sort': sort,
|
||||||
|
'page': page.toString(),
|
||||||
|
'limit': '20',
|
||||||
|
};
|
||||||
|
|
||||||
|
BrowseParams copyWith({
|
||||||
|
String? query,
|
||||||
|
String? genre,
|
||||||
|
String? status,
|
||||||
|
String? sort,
|
||||||
|
int? page,
|
||||||
|
bool clearQuery = false,
|
||||||
|
bool clearGenre = false,
|
||||||
|
bool clearStatus = false,
|
||||||
|
}) =>
|
||||||
|
BrowseParams(
|
||||||
|
query: clearQuery ? null : query ?? this.query,
|
||||||
|
genre: clearGenre ? null : genre ?? this.genre,
|
||||||
|
status: clearStatus ? null : status ?? this.status,
|
||||||
|
sort: sort ?? this.sort,
|
||||||
|
page: page ?? this.page,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class BrowseResult {
|
||||||
|
final List<NovelModel> items;
|
||||||
|
final int totalCount;
|
||||||
|
final int totalPages;
|
||||||
|
final int currentPage;
|
||||||
|
|
||||||
|
const BrowseResult({
|
||||||
|
required this.items,
|
||||||
|
required this.totalCount,
|
||||||
|
required this.totalPages,
|
||||||
|
required this.currentPage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class NovelsNotifier extends StateNotifier<AsyncValue<BrowseResult>> {
|
||||||
|
final Ref _ref;
|
||||||
|
BrowseParams _params = const BrowseParams();
|
||||||
|
|
||||||
|
NovelsNotifier(this._ref) : super(const AsyncValue.loading()) {
|
||||||
|
fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowseParams get params => _params;
|
||||||
|
|
||||||
|
Future<void> fetch({BrowseParams? params}) async {
|
||||||
|
if (params != null) _params = params;
|
||||||
|
state = const AsyncValue.loading();
|
||||||
|
try {
|
||||||
|
final client = _ref.read(apiClientProvider);
|
||||||
|
final res = await client.dio.get('/api/novels/browse', queryParameters: _params.toQueryParams());
|
||||||
|
final data = res.data as Map<String, dynamic>;
|
||||||
|
state = AsyncValue.data(BrowseResult(
|
||||||
|
items: (data['items'] as List).map((e) => NovelModel.fromJson(e as Map<String, dynamic>)).toList(),
|
||||||
|
totalCount: data['totalCount'] as int,
|
||||||
|
totalPages: data['totalPages'] as int,
|
||||||
|
currentPage: data['currentPage'] as int,
|
||||||
|
));
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncValue.error(e, st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateParams(BrowseParams params) => fetch(params: params);
|
||||||
|
}
|
||||||
|
|
||||||
|
final novelsProvider = StateNotifierProvider<NovelsNotifier, AsyncValue<BrowseResult>>((ref) {
|
||||||
|
return NovelsNotifier(ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Novel Detail ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
final novelDetailProvider =
|
||||||
|
FutureProvider.family<NovelModel, String>((ref, idOrSlug) async {
|
||||||
|
final client = ref.read(apiClientProvider);
|
||||||
|
final res = await client.dio.get('/api/novels/$idOrSlug');
|
||||||
|
return NovelModel.fromJson(res.data as Map<String, dynamic>);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Chapter List ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
final chapterListProvider =
|
||||||
|
FutureProvider.family<List<ChapterListItem>, String>((ref, novelId) async {
|
||||||
|
final client = ref.read(apiClientProvider);
|
||||||
|
final res = await client.dio.get('/api/truyen/$novelId/chapters');
|
||||||
|
final data = res.data as Map<String, dynamic>;
|
||||||
|
final chapters = data['chapters'] as List? ?? [];
|
||||||
|
return chapters
|
||||||
|
.map((e) => ChapterListItem.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
@@ -1,72 +1,265 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../app/router/route_names.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});
|
const ReaderScreen({super.key, required this.chapterId});
|
||||||
|
|
||||||
final String chapterId;
|
final String chapterId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ReaderScreen> createState() => _ReaderScreenState();
|
ConsumerState<ReaderScreen> createState() => _ReaderScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ReaderScreenState extends State<ReaderScreen> {
|
class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||||
double fontSize = 18;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final chapterAsync = ref.watch(chapterProvider(widget.chapterId));
|
||||||
|
final settings = ref.watch(readingSettingsProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
body: chapterAsync.when(
|
||||||
title: Text('Doc chuong ${widget.chapterId.isEmpty ? '?' : widget.chapterId}'),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
actions: [
|
error: (e, _) => Center(
|
||||||
IconButton(
|
child: Column(
|
||||||
onPressed: () => context.push(RouteNames.settings),
|
mainAxisSize: MainAxisSize.min,
|
||||||
icon: const Icon(Icons.tune),
|
children: [
|
||||||
tooltip: 'Cai dat doc',
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
data: (chapter) {
|
||||||
body: Padding(
|
// Initialize progress tracking
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
child: Column(
|
ref.read(readerProvider.notifier).open(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
chapter.novelId,
|
||||||
children: [
|
chapter.id,
|
||||||
const Text('Reader body placeholder with TOC, TTS, offline marker.'),
|
chapter.number,
|
||||||
const SizedBox(height: 12),
|
);
|
||||||
Text('Co chu hien tai: ${fontSize.toStringAsFixed(0)}'),
|
});
|
||||||
Slider(
|
|
||||||
min: 14,
|
return GestureDetector(
|
||||||
max: 26,
|
onTap: _toggleUI,
|
||||||
value: fontSize,
|
child: Stack(
|
||||||
onChanged: (v) => setState(() => fontSize = v),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Row(
|
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
// Main content
|
||||||
child: OutlinedButton(
|
Scrollbar(
|
||||||
onPressed: () {},
|
controller: _scrollCtrl,
|
||||||
child: const Text('Chuong truoc'),
|
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),
|
// Floating top bar
|
||||||
Expanded(
|
AnimatedSlide(
|
||||||
child: FilledButton(
|
offset: _showUI ? Offset.zero : const Offset(0, -1),
|
||||||
onPressed: () {},
|
duration: const Duration(milliseconds: 200),
|
||||||
child: const Text('Chuong sau'),
|
child: _TopBar(chapter: chapter),
|
||||||
|
),
|
||||||
|
// Floating bottom bar with font controls
|
||||||
|
AnimatedSlide(
|
||||||
|
offset: _showUI ? Offset.zero : const Offset(0, 1),
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: _BottomBar(settings: settings, onSettingsChanged: (s) {
|
||||||
|
ref.read(readingSettingsProvider.notifier).update(s);
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
),
|
},
|
||||||
),
|
|
||||||
floatingActionButton: FloatingActionButton.small(
|
|
||||||
onPressed: () {},
|
|
||||||
child: const Icon(Icons.record_voice_over),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _TopBar extends StatelessWidget {
|
||||||
|
final ChapterModel chapter;
|
||||||
|
const _TopBar({required this.chapter});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
color: Theme.of(context).colorScheme.surface.withAlpha(230),
|
||||||
|
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
|
||||||
|
child: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
title: Text(
|
||||||
|
chapter.volumeTitle ?? 'Chương ${chapter.number}',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
leading: BackButton(onPressed: () => Navigator.maybePop(context)),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.comment_outlined),
|
||||||
|
onPressed: () => context.push(
|
||||||
|
RouteNames.commentsFor(chapter.novelId, chapterId: chapter.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.record_voice_over_outlined),
|
||||||
|
onPressed: () => showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('Text-to-Speech',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TtsPlayerWidget(content: chapter.content),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BottomBar extends StatelessWidget {
|
||||||
|
final dynamic settings;
|
||||||
|
final void Function(dynamic) onSettingsChanged;
|
||||||
|
|
||||||
|
const _BottomBar({required this.settings, required this.onSettingsChanged});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
color: Theme.of(context).colorScheme.surface.withAlpha(230),
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
top: 8,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.text_decrease, size: 18),
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: settings.fontSize,
|
||||||
|
min: 12,
|
||||||
|
max: 28,
|
||||||
|
divisions: 8,
|
||||||
|
onChanged: (v) => onSettingsChanged(settings.copyWith(fontSize: v)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.text_increase, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.format_line_spacing),
|
||||||
|
onPressed: () {
|
||||||
|
final next = settings.lineHeight < 2.4
|
||||||
|
? settings.lineHeight + 0.2
|
||||||
|
: 1.4;
|
||||||
|
onSettingsChanged(settings.copyWith(lineHeight: next));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NavButtons extends ConsumerWidget {
|
||||||
|
final ChapterModel chapter;
|
||||||
|
const _NavButtons({required this.chapter});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
if (chapter.prevChapterId != null)
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () => context.pushReplacement(
|
||||||
|
RouteNames.readerChapter(chapter.prevChapterId!),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.chevron_left),
|
||||||
|
label: Text('Ch. ${chapter.prevChapterNumber ?? '?'}'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (chapter.prevChapterId != null && chapter.nextChapterId != null)
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
if (chapter.nextChapterId != null)
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: () => context.pushReplacement(
|
||||||
|
RouteNames.readerChapter(chapter.nextChapterId!),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.chevron_right),
|
||||||
|
label: Text('Ch. ${chapter.nextChapterNumber ?? '?'}'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../tts/tts_service.dart';
|
||||||
|
|
||||||
|
class TtsPlayerWidget extends ConsumerWidget {
|
||||||
|
final String content;
|
||||||
|
const TtsPlayerWidget({super.key, required this.content});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final tts = ref.watch(ttsProvider);
|
||||||
|
final notifier = ref.read(ttsProvider.notifier);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.skip_previous),
|
||||||
|
onPressed: tts.status != TtsStatus.idle ? notifier.skipBack : null,
|
||||||
|
),
|
||||||
|
// Play/Pause/Stop
|
||||||
|
if (!tts.isPlaying)
|
||||||
|
IconButton.filled(
|
||||||
|
icon: const Icon(Icons.play_arrow),
|
||||||
|
onPressed: () => notifier.startReading(
|
||||||
|
content,
|
||||||
|
paragraphIndex: tts.paragraphIndex,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
IconButton.filled(
|
||||||
|
icon: const Icon(Icons.pause),
|
||||||
|
onPressed: notifier.pause,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.stop),
|
||||||
|
onPressed: tts.status != TtsStatus.idle ? notifier.stop : null,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.skip_next),
|
||||||
|
onPressed: tts.status != TtsStatus.idle ? notifier.skipForward : null,
|
||||||
|
),
|
||||||
|
// Speed control
|
||||||
|
PopupMenuButton<double>(
|
||||||
|
initialValue: tts.speed,
|
||||||
|
onSelected: notifier.setSpeed,
|
||||||
|
icon: Text(
|
||||||
|
'${tts.speed}x',
|
||||||
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
itemBuilder: (_) => [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]
|
||||||
|
.map((s) => PopupMenuItem(value: s, child: Text('${s}x')))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
// Progress indicator
|
||||||
|
if (tts.totalParagraphs > 0)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: Text(
|
||||||
|
'${tts.paragraphIndex + 1}/${tts.totalParagraphs}',
|
||||||
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../../core/models/chapter_model.dart';
|
||||||
|
import '../../../core/models/reading_settings.dart';
|
||||||
|
import '../../../core/network/providers.dart';
|
||||||
|
import '../../../core/storage/local_store.dart';
|
||||||
|
import '../../../core/storage/offline_cache.dart';
|
||||||
|
|
||||||
|
// ─── Chapter content ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
final chapterProvider =
|
||||||
|
FutureProvider.family<ChapterModel, String>((ref, chapterId) async {
|
||||||
|
final offlineCache = ref.read(offlineCacheProvider);
|
||||||
|
|
||||||
|
// Try network first, fall back to cache
|
||||||
|
try {
|
||||||
|
final client = ref.read(apiClientProvider);
|
||||||
|
final res = await client.dio.get('/api/chapters/$chapterId');
|
||||||
|
final chapter = ChapterModel.fromJson(res.data as Map<String, dynamic>);
|
||||||
|
// Cache for offline use (fire and forget)
|
||||||
|
unawaited(offlineCache.saveChapter(chapter));
|
||||||
|
return chapter;
|
||||||
|
} catch (_) {
|
||||||
|
final cached = await offlineCache.loadChapter(chapterId);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Reading progress ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ReadingProgress {
|
||||||
|
final String novelId;
|
||||||
|
final String chapterId;
|
||||||
|
final int chapterNumber;
|
||||||
|
final double scrollOffset;
|
||||||
|
|
||||||
|
const ReadingProgress({
|
||||||
|
required this.novelId,
|
||||||
|
required this.chapterId,
|
||||||
|
required this.chapterNumber,
|
||||||
|
required this.scrollOffset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReaderNotifier extends StateNotifier<ReadingProgress?> {
|
||||||
|
final Ref _ref;
|
||||||
|
String? _novelId;
|
||||||
|
|
||||||
|
ReaderNotifier(this._ref) : super(null);
|
||||||
|
|
||||||
|
void open(String novelId, String chapterId, int chapterNumber) {
|
||||||
|
_novelId = novelId;
|
||||||
|
state = ReadingProgress(
|
||||||
|
novelId: novelId,
|
||||||
|
chapterId: chapterId,
|
||||||
|
chapterNumber: chapterNumber,
|
||||||
|
scrollOffset: 0,
|
||||||
|
);
|
||||||
|
_persistProgress(chapterId, chapterNumber, 0);
|
||||||
|
}
|
||||||
|
void updateScroll(double offset) {
|
||||||
|
if (state == null) return;
|
||||||
|
state = ReadingProgress(
|
||||||
|
novelId: state!.novelId,
|
||||||
|
chapterId: state!.chapterId,
|
||||||
|
chapterNumber: state!.chapterNumber,
|
||||||
|
scrollOffset: offset,
|
||||||
|
);
|
||||||
|
_debounceUpdate(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistProgress(
|
||||||
|
String chapterId, int chapterNumber, double offset) async {
|
||||||
|
final localStore = _ref.read(localStoreProvider);
|
||||||
|
await localStore.saveProgress(_novelId!, chapterId, chapterNumber, offset);
|
||||||
|
// Also notify server (fire and forget)
|
||||||
|
try {
|
||||||
|
final client = _ref.read(apiClientProvider);
|
||||||
|
await client.dio.post('/api/user/reading-progress', data: {
|
||||||
|
'novelId': _novelId,
|
||||||
|
'chapterId': chapterId,
|
||||||
|
'chapterNumber': chapterNumber,
|
||||||
|
'progress': offset,
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _lastUpdate;
|
||||||
|
Future<void> _debounceUpdate(double offset) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (_lastUpdate != null && now.difference(_lastUpdate!).inSeconds < 3) return;
|
||||||
|
_lastUpdate = now;
|
||||||
|
if (state != null) {
|
||||||
|
await _persistProgress(state!.chapterId, state!.chapterNumber, offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final readerProvider =
|
||||||
|
StateNotifierProvider<ReaderNotifier, ReadingProgress?>((ref) {
|
||||||
|
return ReaderNotifier(ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Reading settings ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ReadingSettingsNotifier extends StateNotifier<ReadingSettings> {
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
ReadingSettingsNotifier(this._ref) : super(const ReadingSettings()) {
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final localStore = _ref.read(localStoreProvider);
|
||||||
|
final saved = await localStore.loadReadingSettings();
|
||||||
|
if (saved != null) state = saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> update(ReadingSettings settings) async {
|
||||||
|
state = settings;
|
||||||
|
final localStore = _ref.read(localStoreProvider);
|
||||||
|
await localStore.saveReadingSettings(settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final readingSettingsProvider =
|
||||||
|
StateNotifierProvider<ReadingSettingsNotifier, ReadingSettings>((ref) {
|
||||||
|
return ReadingSettingsNotifier(ref);
|
||||||
|
});
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_tts/flutter_tts.dart';
|
||||||
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
|
|
||||||
|
enum TtsStatus { idle, playing, paused }
|
||||||
|
|
||||||
|
class TtsState {
|
||||||
|
final TtsStatus status;
|
||||||
|
final int paragraphIndex;
|
||||||
|
final int totalParagraphs;
|
||||||
|
final double speed;
|
||||||
|
|
||||||
|
const TtsState({
|
||||||
|
this.status = TtsStatus.idle,
|
||||||
|
this.paragraphIndex = 0,
|
||||||
|
this.totalParagraphs = 0,
|
||||||
|
this.speed = 1.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
TtsState copyWith({
|
||||||
|
TtsStatus? status,
|
||||||
|
int? paragraphIndex,
|
||||||
|
int? totalParagraphs,
|
||||||
|
double? speed,
|
||||||
|
}) =>
|
||||||
|
TtsState(
|
||||||
|
status: status ?? this.status,
|
||||||
|
paragraphIndex: paragraphIndex ?? this.paragraphIndex,
|
||||||
|
totalParagraphs: totalParagraphs ?? this.totalParagraphs,
|
||||||
|
speed: speed ?? this.speed,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool get isPlaying => status == TtsStatus.playing;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TtsNotifier extends StateNotifier<TtsState> {
|
||||||
|
final FlutterTts _tts = FlutterTts();
|
||||||
|
List<String> _paragraphs = [];
|
||||||
|
|
||||||
|
TtsNotifier() : super(const TtsState()) {
|
||||||
|
_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _init() async {
|
||||||
|
await _tts.setLanguage('vi-VN');
|
||||||
|
await _tts.setSpeechRate(1.0);
|
||||||
|
await _tts.setVolume(1.0);
|
||||||
|
await _tts.setPitch(1.0);
|
||||||
|
|
||||||
|
_tts.setCompletionHandler(() {
|
||||||
|
if (state.status == TtsStatus.playing) {
|
||||||
|
_next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_tts.setErrorHandler((msg) {
|
||||||
|
state = state.copyWith(status: TtsStatus.idle);
|
||||||
|
WakelockPlus.disable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start reading from [content] starting at optional [paragraphIndex].
|
||||||
|
Future<void> startReading(String content, {int paragraphIndex = 0}) async {
|
||||||
|
_paragraphs = content
|
||||||
|
.split(RegExp(r'\n+'))
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.where((p) => p.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (_paragraphs.isEmpty) return;
|
||||||
|
|
||||||
|
final validIndex = paragraphIndex.clamp(0, _paragraphs.length - 1);
|
||||||
|
state = state.copyWith(
|
||||||
|
status: TtsStatus.playing,
|
||||||
|
paragraphIndex: validIndex,
|
||||||
|
totalParagraphs: _paragraphs.length,
|
||||||
|
);
|
||||||
|
await WakelockPlus.enable();
|
||||||
|
await _speak(validIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _speak(int index) async {
|
||||||
|
if (index >= _paragraphs.length) {
|
||||||
|
state = state.copyWith(status: TtsStatus.idle);
|
||||||
|
await WakelockPlus.disable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _tts.setSpeechRate(state.speed);
|
||||||
|
await _tts.speak(_paragraphs[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _next() async {
|
||||||
|
final next = state.paragraphIndex + 1;
|
||||||
|
if (next >= state.totalParagraphs) {
|
||||||
|
state = state.copyWith(status: TtsStatus.idle, paragraphIndex: 0);
|
||||||
|
await WakelockPlus.disable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state = state.copyWith(paragraphIndex: next);
|
||||||
|
await _speak(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pause() async {
|
||||||
|
await _tts.pause();
|
||||||
|
state = state.copyWith(status: TtsStatus.paused);
|
||||||
|
await WakelockPlus.disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> resume() async {
|
||||||
|
if (state.status != TtsStatus.paused) return;
|
||||||
|
state = state.copyWith(status: TtsStatus.playing);
|
||||||
|
await WakelockPlus.enable();
|
||||||
|
await _speak(state.paragraphIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stop() async {
|
||||||
|
await _tts.stop();
|
||||||
|
state = state.copyWith(status: TtsStatus.idle, paragraphIndex: 0);
|
||||||
|
await WakelockPlus.disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> skipForward() async {
|
||||||
|
await _tts.stop();
|
||||||
|
await _next();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> skipBack() async {
|
||||||
|
await _tts.stop();
|
||||||
|
final prev = (state.paragraphIndex - 1).clamp(0, state.totalParagraphs - 1);
|
||||||
|
state = state.copyWith(paragraphIndex: prev);
|
||||||
|
if (state.status == TtsStatus.playing) await _speak(prev);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setSpeed(double speed) async {
|
||||||
|
state = state.copyWith(speed: speed);
|
||||||
|
await _tts.setSpeechRate(speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tts.stop();
|
||||||
|
WakelockPlus.disable();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final ttsProvider = StateNotifierProvider<TtsNotifier, TtsState>((ref) {
|
||||||
|
return TtsNotifier();
|
||||||
|
});
|
||||||
@@ -1,19 +1,254 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/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});
|
const SearchScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<SearchScreen> createState() => _SearchScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
|
final _controller = TextEditingController();
|
||||||
|
Timer? _debounce;
|
||||||
|
String? _selectedGenre;
|
||||||
|
String? _selectedStatus;
|
||||||
|
String _sort = 'latest';
|
||||||
|
|
||||||
|
final _statuses = const [
|
||||||
|
('Đang ra', 'ongoing'),
|
||||||
|
('Đã hoàn thành', 'completed'),
|
||||||
|
('Tạm dừng', 'hiatus'),
|
||||||
|
];
|
||||||
|
final _sorts = const [
|
||||||
|
('Mới nhất', 'latest'),
|
||||||
|
('Phổ biến', 'popular'),
|
||||||
|
('Đánh giá', 'rating'),
|
||||||
|
('Tên A-Z', 'name'),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
_debounce?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onQueryChanged(String value) {
|
||||||
|
_debounce?.cancel();
|
||||||
|
_debounce = Timer(const Duration(milliseconds: 500), _applyFilters);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyFilters() {
|
||||||
|
ref.read(novelsProvider.notifier).updateParams(
|
||||||
|
BrowseParams(
|
||||||
|
query: _controller.text.trim().isEmpty ? null : _controller.text.trim(),
|
||||||
|
genre: _selectedGenre,
|
||||||
|
status: _selectedStatus,
|
||||||
|
sort: _sort,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final genresAsync = ref.watch(genresProvider);
|
||||||
|
final novelsAsync = ref.watch(novelsProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Tim kiem')),
|
appBar: AppBar(title: const Text('Tìm kiếm')),
|
||||||
body: const FeaturePlaceholder(
|
body: Column(
|
||||||
title: 'Search + Filters',
|
children: [
|
||||||
description:
|
Padding(
|
||||||
'Khung tim kiem truyện voi goi y theo tu khoa, loc theo the loai/trang thai va sap xep theo views-rating-latest.',
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
child: TextField(
|
||||||
|
controller: _controller,
|
||||||
|
onChanged: _onQueryChanged,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Tên truyện, tác giả...',
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
suffixIcon: _controller.text.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
_controller.clear();
|
||||||
|
_applyFilters();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.search,
|
||||||
|
onSubmitted: (_) => _applyFilters(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Filter chips row
|
||||||
|
SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Genre filter
|
||||||
|
genresAsync.when(
|
||||||
|
loading: () => const SizedBox.shrink(),
|
||||||
|
error: (_, __) => const SizedBox.shrink(),
|
||||||
|
data: (genres) => _FilterChipDropdown(
|
||||||
|
label: _selectedGenre == null
|
||||||
|
? 'Thể loại'
|
||||||
|
: genres.firstWhere((g) => g.slug == _selectedGenre, orElse: () => genres.first).name,
|
||||||
|
selected: _selectedGenre != null,
|
||||||
|
items: genres
|
||||||
|
.map((g) => PopupMenuItem(value: g.slug, child: Text(g.name)))
|
||||||
|
.toList(),
|
||||||
|
onSelected: (v) {
|
||||||
|
setState(() => _selectedGenre = _selectedGenre == v ? null : v);
|
||||||
|
_applyFilters();
|
||||||
|
},
|
||||||
|
onClear: () {
|
||||||
|
setState(() => _selectedGenre = null);
|
||||||
|
_applyFilters();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
// Status filter
|
||||||
|
_FilterChipDropdown(
|
||||||
|
label: _selectedStatus == null
|
||||||
|
? 'Trạng thái'
|
||||||
|
: _statuses.firstWhere((s) => s.$2 == _selectedStatus, orElse: () => _statuses.first).$1,
|
||||||
|
selected: _selectedStatus != null,
|
||||||
|
items: _statuses
|
||||||
|
.map((s) => PopupMenuItem(value: s.$2, child: Text(s.$1)))
|
||||||
|
.toList(),
|
||||||
|
onSelected: (v) {
|
||||||
|
setState(() => _selectedStatus = _selectedStatus == v ? null : v);
|
||||||
|
_applyFilters();
|
||||||
|
},
|
||||||
|
onClear: () {
|
||||||
|
setState(() => _selectedStatus = null);
|
||||||
|
_applyFilters();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
// Sort
|
||||||
|
_FilterChipDropdown(
|
||||||
|
label: _sorts.firstWhere((s) => s.$2 == _sort).$1,
|
||||||
|
selected: _sort != 'latest',
|
||||||
|
items: _sorts
|
||||||
|
.map((s) => PopupMenuItem(value: s.$2, child: Text(s.$1)))
|
||||||
|
.toList(),
|
||||||
|
onSelected: (v) {
|
||||||
|
if (v != null) {
|
||||||
|
setState(() => _sort = v);
|
||||||
|
_applyFilters();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClear: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Expanded(
|
||||||
|
child: novelsAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => Center(child: Text('Lỗi: $e')),
|
||||||
|
data: (result) {
|
||||||
|
if (result.items.isEmpty) {
|
||||||
|
return const Center(child: Text('Không tìm thấy truyện'));
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: result.items.length,
|
||||||
|
itemBuilder: (context, index) => _NovelListTile(novel: result.items[index]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _FilterChipDropdown extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final bool selected;
|
||||||
|
final List<PopupMenuEntry<String>> items;
|
||||||
|
final void Function(String?)? onSelected;
|
||||||
|
final VoidCallback? onClear;
|
||||||
|
|
||||||
|
const _FilterChipDropdown({
|
||||||
|
required this.label,
|
||||||
|
required this.selected,
|
||||||
|
required this.items,
|
||||||
|
required this.onSelected,
|
||||||
|
required this.onClear,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PopupMenuButton<String>(
|
||||||
|
onSelected: onSelected,
|
||||||
|
itemBuilder: (_) => items,
|
||||||
|
child: FilterChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: selected,
|
||||||
|
onSelected: (_) {},
|
||||||
|
deleteIcon: selected && onClear != null ? const Icon(Icons.close, size: 14) : null,
|
||||||
|
onDeleted: selected ? onClear : null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NovelListTile extends StatelessWidget {
|
||||||
|
final NovelModel novel;
|
||||||
|
const _NovelListTile({required this.novel});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListTile(
|
||||||
|
leading: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: novel.coverUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: novel.coverUrl!,
|
||||||
|
width: 44,
|
||||||
|
height: 60,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
width: 44,
|
||||||
|
height: 60,
|
||||||
|
color: Theme.of(context).colorScheme.primaryContainer,
|
||||||
|
child: const Icon(Icons.menu_book, size: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(novel.title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
subtitle: Text(
|
||||||
|
novel.authorName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
trailing: novel.rating > 0
|
||||||
|
? Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.star, size: 14, color: Colors.amber),
|
||||||
|
Text(novel.rating.toStringAsFixed(1)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () => context.push(RouteNames.novelDetail(novel.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,53 +1,108 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/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});
|
const SettingsScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
}
|
final settingsAsync = ref.watch(userSettingsProvider);
|
||||||
|
final isAuth = ref.watch(isAuthenticatedProvider);
|
||||||
|
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
|
||||||
double fontSize = 18;
|
|
||||||
double lineHeight = 1.8;
|
|
||||||
double letterSpacing = 0;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Cai dat doc')),
|
appBar: AppBar(title: const Text('Cài đặt đọc')),
|
||||||
body: ListView(
|
body: settingsAsync.when(
|
||||||
padding: const EdgeInsets.all(20),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
children: [
|
error: (e, _) => Center(child: Text('Lỗi: $e')),
|
||||||
Text('Co chu: ${fontSize.toStringAsFixed(0)}'),
|
data: (settings) => ListView(
|
||||||
Slider(
|
padding: const EdgeInsets.all(20),
|
||||||
min: 14,
|
children: [
|
||||||
max: 26,
|
Text('Cỡ chữ: ${settings.fontSize.toStringAsFixed(0)}',
|
||||||
value: fontSize,
|
style: Theme.of(context).textTheme.titleSmall),
|
||||||
onChanged: (v) => setState(() => fontSize = v),
|
Slider(
|
||||||
),
|
min: 12,
|
||||||
const SizedBox(height: 12),
|
max: 28,
|
||||||
Text('Line-height: ${lineHeight.toStringAsFixed(1)}'),
|
divisions: 8,
|
||||||
Slider(
|
value: settings.fontSize,
|
||||||
min: 1.2,
|
onChanged: (v) => ref
|
||||||
max: 2.4,
|
.read(userSettingsProvider.notifier)
|
||||||
value: lineHeight,
|
.updateSettings(settings.copyWith(fontSize: v)),
|
||||||
onChanged: (v) => setState(() => lineHeight = v),
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 12),
|
Text('Khoảng cách dòng: ${settings.lineHeight.toStringAsFixed(1)}',
|
||||||
Text('Letter-spacing: ${letterSpacing.toStringAsFixed(1)}'),
|
style: Theme.of(context).textTheme.titleSmall),
|
||||||
Slider(
|
Slider(
|
||||||
min: -0.5,
|
min: 1.2,
|
||||||
max: 2,
|
max: 3.0,
|
||||||
value: letterSpacing,
|
divisions: 9,
|
||||||
onChanged: (v) => setState(() => letterSpacing = v),
|
value: settings.lineHeight,
|
||||||
),
|
onChanged: (v) => ref
|
||||||
const SizedBox(height: 24),
|
.read(userSettingsProvider.notifier)
|
||||||
FilledButton(
|
.updateSettings(settings.copyWith(lineHeight: v)),
|
||||||
onPressed: () {},
|
),
|
||||||
child: const Text('Luu va dong bo'),
|
const SizedBox(height: 8),
|
||||||
),
|
Text('Khoảng cách chữ: ${settings.letterSpacing.toStringAsFixed(1)}',
|
||||||
],
|
style: Theme.of(context).textTheme.titleSmall),
|
||||||
|
Slider(
|
||||||
|
min: 0,
|
||||||
|
max: 4,
|
||||||
|
divisions: 8,
|
||||||
|
value: settings.letterSpacing,
|
||||||
|
onChanged: (v) => ref
|
||||||
|
.read(userSettingsProvider.notifier)
|
||||||
|
.updateSettings(settings.copyWith(letterSpacing: v)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('Font chữ', style: Theme.of(context).textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SegmentedButton<String>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(value: 'serif', label: Text('Serif')),
|
||||||
|
ButtonSegment(value: 'sans', label: Text('Sans-serif')),
|
||||||
|
ButtonSegment(value: 'mono', label: Text('Mono')),
|
||||||
|
],
|
||||||
|
selected: {settings.fontFamily},
|
||||||
|
onSelectionChanged: (s) => ref
|
||||||
|
.read(userSettingsProvider.notifier)
|
||||||
|
.updateSettings(settings.copyWith(fontFamily: s.first)),
|
||||||
|
),
|
||||||
|
const Divider(height: 40),
|
||||||
|
// Preview
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.outlineVariant),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Đây là đoạn văn mẫu để xem trước cài đặt hiển thị chữ của bạn.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: settings.fontSize,
|
||||||
|
height: settings.lineHeight,
|
||||||
|
letterSpacing: settings.letterSpacing,
|
||||||
|
fontFamily: settings.fontFamily == 'serif' ? 'Georgia' : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 40),
|
||||||
|
if (isAuth)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.logout, color: Colors.red),
|
||||||
|
title: const Text('Đăng xuất',
|
||||||
|
style: TextStyle(color: Colors.red)),
|
||||||
|
onTap: () async {
|
||||||
|
await ref.read(authProvider.notifier).signOut();
|
||||||
|
if (context.mounted) context.go(RouteNames.login);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../../core/models/reading_settings.dart';
|
||||||
|
import '../../../core/storage/local_store.dart';
|
||||||
|
|
||||||
|
class UserSettingsNotifier extends StateNotifier<AsyncValue<ReadingSettings>> {
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
UserSettingsNotifier(this._ref) : super(const AsyncValue.loading()) {
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final local = _ref.read(localStoreProvider);
|
||||||
|
final saved = await local.loadReadingSettings();
|
||||||
|
state = AsyncValue.data(saved ?? const ReadingSettings());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateSettings(ReadingSettings settings) async {
|
||||||
|
state = AsyncValue.data(settings);
|
||||||
|
final local = _ref.read(localStoreProvider);
|
||||||
|
await local.saveReadingSettings(settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final userSettingsProvider =
|
||||||
|
StateNotifierProvider<UserSettingsNotifier, AsyncValue<ReadingSettings>>(
|
||||||
|
(ref) => UserSettingsNotifier(ref));
|
||||||
+2
-2
@@ -497,7 +497,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.1"
|
version: "3.2.1"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path
|
name: path
|
||||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
@@ -670,7 +670,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.2"
|
version: "1.10.2"
|
||||||
sqflite:
|
sqflite:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: sqflite
|
name: sqflite
|
||||||
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ dependencies:
|
|||||||
connectivity_plus: ^6.1.4
|
connectivity_plus: ^6.1.4
|
||||||
equatable: ^2.0.7
|
equatable: ^2.0.7
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
|
sqflite: ^2.4.1
|
||||||
|
path: ^1.9.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user