代码优化,epub阅读标签

This commit is contained in:
DelLevin-Home
2026-06-28 02:25:07 +08:00
parent c810a1a381
commit 5f434ae3fa
35 changed files with 2110 additions and 525 deletions

View File

@@ -46,6 +46,7 @@ class Movie {
final String? summary; // 剧情简介 final String? summary; // 剧情简介
final double? rating; // 评分 1-10 final double? rating; // 评分 1-10
final String status; // watched/want_to_watch/watching final String status; // watched/want_to_watch/watching
final String category; // 影视分类: movie/tv/anime/variety/documentary/short
final DateTime? watchDate; // 观看日期 final DateTime? watchDate; // 观看日期
final DateTime createdAt; final DateTime createdAt;
final DateTime updatedAt; final DateTime updatedAt;
@@ -65,6 +66,7 @@ class Movie {
this.summary, this.summary,
this.rating, this.rating,
required this.status, required this.status,
this.category = 'movie',
this.watchDate, this.watchDate,
required this.createdAt, required this.createdAt,
required this.updatedAt, required this.updatedAt,
@@ -86,6 +88,7 @@ class Movie {
summary: json['summary'], summary: json['summary'],
rating: json['rating']?.toDouble(), rating: json['rating']?.toDouble(),
status: json['status'] ?? 'want_to_watch', status: json['status'] ?? 'want_to_watch',
category: json['category'] ?? 'movie',
watchDate: _safeParseDate(json['watch_date']), watchDate: _safeParseDate(json['watch_date']),
createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!, createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!,
updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!, updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!,
@@ -108,6 +111,7 @@ class Movie {
'summary': summary, 'summary': summary,
'rating': rating, 'rating': rating,
'status': status, 'status': status,
'category': category,
'watch_date': watchDate?.toUtc().toIso8601String(), 'watch_date': watchDate?.toUtc().toIso8601String(),
'created_at': createdAt.toUtc().toIso8601String(), 'created_at': createdAt.toUtc().toIso8601String(),
'updated_at': updatedAt.toUtc().toIso8601String(), 'updated_at': updatedAt.toUtc().toIso8601String(),
@@ -142,6 +146,7 @@ class Movie {
Object? summary = _copyWithNull, Object? summary = _copyWithNull,
Object? rating = _copyWithNull, Object? rating = _copyWithNull,
String? status, String? status,
String? category,
DateTime? watchDate, DateTime? watchDate,
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt, DateTime? updatedAt,
@@ -161,6 +166,7 @@ class Movie {
summary: summary is _CopyWithNullSentinel ? this.summary : (summary as String?), summary: summary is _CopyWithNullSentinel ? this.summary : (summary as String?),
rating: rating is _CopyWithNullSentinel ? this.rating : (rating as double?), rating: rating is _CopyWithNullSentinel ? this.rating : (rating as double?),
status: status ?? this.status, status: status ?? this.status,
category: category ?? this.category,
watchDate: watchDate ?? this.watchDate, watchDate: watchDate ?? this.watchDate,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,

View File

@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
/// 块类型枚举 /// 块类型枚举
@@ -220,9 +221,15 @@ class NotePlusDocument {
/// 从 JSONDB 行)创建 /// 从 JSONDB 行)创建
factory NotePlusDocument.fromJson(Map<String, dynamic> json) { factory NotePlusDocument.fromJson(Map<String, dynamic> json) {
final blocksJson = json['blocks_json'] as String? ?? '[]'; final blocksJson = json['blocks_json'] as String? ?? '[]';
final blocksList = (jsonDecode(blocksJson) as List<dynamic>) List<NoteBlock> blocksList;
.map((b) => NoteBlock.fromJson(b as Map<String, dynamic>)) try {
.toList(); blocksList = (jsonDecode(blocksJson) as List<dynamic>)
.map((b) => NoteBlock.fromJson(b as Map<String, dynamic>))
.toList();
} catch (e) {
debugPrint('[NotePlusDocument] blocks_json 解析失败,使用默认空文档: $e');
blocksList = [];
}
return NotePlusDocument( return NotePlusDocument(
id: json['id'] as String?, id: json['id'] as String?,

View File

@@ -296,24 +296,31 @@ class _BookExcerptsPageState extends State<BookExcerptsPage> {
return AlertDialog( return AlertDialog(
backgroundColor: colors.surface, backgroundColor: colors.surface,
elevation: 0, elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: const Text('确认删除'), title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
content: const Text('确定要删除这条摘抄吗?'), content: Text('确定要删除这条摘抄吗?删除后可在回收站恢复。',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))),
), ),
TextButton( ElevatedButton(
onPressed: () async { onPressed: () async {
await context.read<AppProvider>().removeBookExcerpt(excerpt.id); await context.read<AppProvider>().removeBookExcerpt(excerpt.id);
Navigator.pop(context); Navigator.pop(context);
_loadExcerpts(); _loadExcerpts();
ToastUtil.show(context, '已删除'); ToastUtil.show(context, '已删除');
}, },
child: Text('删除', style: TextStyle(color: colors.error)), style: ElevatedButton.styleFrom(
backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('删除'),
), ),
], ],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
); );
}, },
); );

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../models/data_models.dart'; import '../../models/data_models.dart';
import '../../providers/app_provider.dart'; import '../../providers/app_provider.dart';
import '../../utils/toast_util.dart';
import 'book_review_form_page.dart'; import 'book_review_form_page.dart';
/// 书评详情页 /// 书评详情页
@@ -55,12 +56,15 @@ class _BookReviewDetailPageState extends State<BookReviewDetailPage> {
appBar: AppBar( appBar: AppBar(
title: const Text('书评详情'), title: const Text('书评详情'),
actions: [ actions: [
// 编辑按钮
IconButton( IconButton(
icon: const Icon(Icons.edit_outlined), icon: const Icon(Icons.edit_outlined),
onPressed: () => _navigateToEdit(context), onPressed: () => _navigateToEdit(context),
), ),
const SizedBox(width: 8), IconButton(
icon: Icon(Icons.delete_outline, size: 20, color: colors.error.withValues(alpha: 0.7)),
onPressed: _deleteReview,
),
const SizedBox(width: 4),
], ],
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
@@ -168,6 +172,41 @@ class _BookReviewDetailPageState extends State<BookReviewDetailPage> {
); );
} }
Future<void> _deleteReview() async {
final colors = Theme.of(context).colorScheme;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: colors.surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
content: Text('确定要删除这条书评吗?删除后可在回收站恢复。',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(
backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('删除'),
),
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
if (confirmed == true) {
await context.read<AppProvider>().removeBookReview(_review.id);
if (mounted) { ToastUtil.show(context, '已删除'); Navigator.pop(context); }
}
}
String _formatDate(DateTime date) { String _formatDate(DateTime date) {
return '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}'; return '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}';
} }

View File

@@ -1,20 +1,17 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../providers/app_provider.dart'; import '../../providers/app_provider.dart';
import '../../widgets/fade_in_local_image.dart';
import '../../models/data_models.dart'; import '../../models/data_models.dart';
import '../../utils/toast_util.dart'; import '../../utils/toast_util.dart';
import 'package:uuid/uuid.dart';
/// 添加/编辑书评页面 - 极简设计 /// 添加/编辑书评页面
class BookReviewFormPage extends StatefulWidget { class BookReviewFormPage extends StatefulWidget {
final String bookId; final String bookId;
final BookReview? review; final BookReview? review;
const BookReviewFormPage({ const BookReviewFormPage({super.key, required this.bookId, this.review});
super.key,
required this.bookId,
this.review,
});
@override @override
State<BookReviewFormPage> createState() => _BookReviewFormPageState(); State<BookReviewFormPage> createState() => _BookReviewFormPageState();
@@ -30,11 +27,10 @@ class _BookReviewFormPageState extends State<BookReviewFormPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final review = widget.review; _contentController = TextEditingController(text: widget.review?.content ?? '');
_contentController = TextEditingController(text: review?.content ?? ''); _reviewerController = TextEditingController(text: widget.review?.reviewer ?? '');
_reviewerController = TextEditingController(text: review?.reviewer ?? ''); _sourceController = TextEditingController(text: widget.review?.source ?? '');
_sourceController = TextEditingController(text: review?.source ?? ''); _reviewType = widget.review?.reviewType ?? 1;
_reviewType = review?.reviewType ?? 1;
} }
@override @override
@@ -45,107 +41,90 @@ class _BookReviewFormPageState extends State<BookReviewFormPage> {
super.dispose(); super.dispose();
} }
Book? _getBook() {
return context.read<AppProvider>().books.where((b) => b.id == widget.bookId).firstOrNull;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
final isEdit = widget.review != null; final isEdit = widget.review != null;
final book = _getBook();
return Scaffold( return Scaffold(
backgroundColor: colors.surface, backgroundColor: colors.surface,
appBar: AppBar( appBar: AppBar(
title: Text(isEdit ? '编辑书评' : '写书评'), title: Text(isEdit ? '编辑书评' : '写书评'),
actions: [
TextButton(
onPressed: _saveReview,
child: const Text(
'保存',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
],
), ),
body: Form( body: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
children: [ children: [
// 顶部信息栏
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: colors.outline, width: 0.5),
),
),
child: Row(
children: [
// 类型选择
_buildTypeSelector(colors),
const SizedBox(width: 16),
// 评论人
Expanded(
child: TextField(
controller: _reviewerController,
style: TextStyle(fontSize: 14, color: colors.onSurface),
decoration: InputDecoration(
hintText: '评论人',
hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.25)),
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
),
),
const SizedBox(width: 16),
// 来源
SizedBox(
width: 100,
child: TextField(
controller: _sourceController,
style: TextStyle(fontSize: 14, color: colors.onSurface),
decoration: InputDecoration(
hintText: '来源',
hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.25)),
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
),
),
],
),
),
// 评论内容区域
Expanded( Expanded(
child: TextFormField( child: SingleChildScrollView(
controller: _contentController, padding: const EdgeInsets.all(16),
maxLines: null, child: Column(
expands: true, crossAxisAlignment: CrossAxisAlignment.start,
textAlignVertical: TextAlignVertical.top, children: [
style: TextStyle( // ── 书籍信息卡片 ──────────────────
fontSize: 16, if (book != null) _buildBookCard(book, colors),
color: colors.onSurface, const SizedBox(height: 20),
height: 1.7,
// ── 类型选择 ──────────────────────
Text('书评类型', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.5))),
const SizedBox(height: 8),
_buildTypeSelector(colors),
const SizedBox(height: 20),
// ── 元信息 ────────────────────────
_buildMetaField(
icon: Icons.person_outline,
hint: '评论人(选填)',
controller: _reviewerController,
colors: colors,
),
const SizedBox(height: 12),
_buildMetaField(
icon: Icons.link,
hint: '来源(选填)',
controller: _sourceController,
colors: colors,
),
const SizedBox(height: 20),
// ── 评论内容 ──────────────────────
Text('评论内容', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.5))),
const SizedBox(height: 8),
_buildContentField(colors),
],
), ),
decoration: InputDecoration( ),
hintText: '写下你的书评...', ),
hintStyle: TextStyle(
fontSize: 16, // ── 底部保存按钮 ──────────────────────
color: colors.onSurface.withValues(alpha: 0.25), Container(
padding: EdgeInsets.only(
left: 16, right: 16, top: 12,
bottom: MediaQuery.of(context).padding.bottom + 12,
),
decoration: BoxDecoration(
color: colors.surface,
border: Border(top: BorderSide(color: colors.outlineVariant, width: 0.5)),
),
child: SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: _saveReview,
style: ElevatedButton.styleFrom(
backgroundColor: colors.primary,
foregroundColor: colors.onPrimary,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
), ),
border: InputBorder.none, child: Text(isEdit ? '更新书评' : '保存书评',
contentPadding: const EdgeInsets.all(16), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
), ),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '请输入评论内容';
}
return null;
},
), ),
), ),
], ],
@@ -154,117 +133,211 @@ class _BookReviewFormPageState extends State<BookReviewFormPage> {
); );
} }
/// 构建类型选择器 // ═══════════════════════════════════════════════════════════════════
Widget _buildTypeSelector(ColorScheme colors) { // 书籍信息卡片
return GestureDetector( // ═══════════════════════════════════════════════════════════════════
onTap: () => _showTypeSelector(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
border: Border.all(color: colors.outline),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_reviewType == 1 ? '短评' : '长评',
style: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(width: 4),
Icon(
Icons.arrow_drop_down,
size: 16,
color: colors.onSurface.withValues(alpha: 0.4),
),
],
),
),
);
}
/// 显示类型选择 Widget _buildBookCard(Book book, ColorScheme colors) {
void _showTypeSelector() { return Container(
showModalBottomSheet( padding: const EdgeInsets.all(12),
context: context, decoration: BoxDecoration(
backgroundColor: Colors.transparent, color: colors.surfaceContainerHighest,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), borderRadius: BorderRadius.circular(12),
builder: (context) { ),
final colors = Theme.of(context).colorScheme; child: Row(
return Container( children: [
color: colors.surface, Container(
child: SafeArea( width: 56, height: 72,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: colors.outlineVariant,
),
clipBehavior: Clip.antiAlias,
child: book.coverPath != null && book.coverPath!.isNotEmpty && File(book.coverPath!).existsSync()
? FadeInLocalImage(path: book.coverPath, fit: BoxFit.cover,
errorWidget: Icon(Icons.book_outlined, size: 24, color: colors.onSurface.withValues(alpha: 0.25)))
: Icon(Icons.book_outlined, size: 24, color: colors.onSurface.withValues(alpha: 0.25)),
),
const SizedBox(width: 14),
Expanded(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ListTile( Text(book.title, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface), maxLines: 2, overflow: TextOverflow.ellipsis),
title: const Text('短评'), if (book.authors.isNotEmpty) ...[
trailing: _reviewType == 1 const SizedBox(height: 4),
? Icon(Icons.check, color: colors.onSurface) Text(book.authors.take(2).join(' / '),
: null, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5)),
onTap: () { maxLines: 1, overflow: TextOverflow.ellipsis),
setState(() => _reviewType = 1); ],
Navigator.pop(context); if (book.rating != null) ...[
}, const SizedBox(height: 4),
), Row(children: [
Divider(height: 0.5, color: colors.outline), Icon(Icons.star, size: 14, color: const Color(0xFFFFB800)),
ListTile( const SizedBox(width: 2),
title: const Text('长评'), Text('${book.rating}', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.6))),
trailing: _reviewType == 2 ]),
? Icon(Icons.check, color: colors.onSurface) ],
: null,
onTap: () {
setState(() => _reviewType = 2);
Navigator.pop(context);
},
),
], ],
), ),
), ),
); ],
}, ),
); );
} }
// ═══════════════════════════════════════════════════════════════════
// 类型选择器(分段按钮)
// ═══════════════════════════════════════════════════════════════════
Widget _buildTypeSelector(ColorScheme colors) {
return SegmentedButton<int>(
segments: const [
ButtonSegment(value: 1, label: Text('短评'), icon: Icon(Icons.short_text)),
ButtonSegment(value: 2, label: Text('长评'), icon: Icon(Icons.menu_book)),
],
selected: {_reviewType},
onSelectionChanged: (v) => setState(() => _reviewType = v.first),
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return colors.primary;
return colors.surfaceContainerHighest;
}),
foregroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return colors.onPrimary;
return colors.onSurface.withValues(alpha: 0.6);
}),
iconColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return colors.onPrimary;
return colors.onSurface.withValues(alpha: 0.4);
}),
),
);
}
// ═══════════════════════════════════════════════════════════════════
// 元信息输入
// ═══════════════════════════════════════════════════════════════════
Widget _buildMetaField({
required IconData icon,
required String hint,
required TextEditingController controller,
required ColorScheme colors,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(icon, size: 18, color: colors.onSurface.withValues(alpha: 0.35)),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: controller,
style: TextStyle(fontSize: 14, color: colors.onSurface),
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
);
}
// ═══════════════════════════════════════════════════════════════════
// 内容输入区 + 字数统计
// ═══════════════════════════════════════════════════════════════════
Widget _buildContentField(ColorScheme colors) {
return Container(
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: Column(
children: [
TextFormField(
controller: _contentController,
maxLines: 10,
minLines: 6,
textAlignVertical: TextAlignVertical.top,
style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.7),
decoration: InputDecoration(
hintText: '写下你的书评...',
hintStyle: TextStyle(fontSize: 15, color: colors.onSurface.withValues(alpha: 0.25)),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.all(14),
),
validator: (value) {
if (value == null || value.trim().isEmpty) return '请输入评论内容';
return null;
},
onChanged: (_) => setState(() {}),
),
Padding(
padding: const EdgeInsets.only(right: 14, bottom: 10),
child: Align(
alignment: Alignment.centerRight,
child: Text(
'${_contentController.text.length}',
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3)),
),
),
),
],
),
);
}
// ═══════════════════════════════════════════════════════════════════
// 保存
// ═══════════════════════════════════════════════════════════════════
Future<void> _saveReview() async { Future<void> _saveReview() async {
if (!_formKey.currentState!.validate()) { if (!_formKey.currentState!.validate()) return;
return;
}
try { try {
final now = DateTime.now(); final now = DateTime.now();
if (widget.review == null) { if (widget.review == null) {
final newReview = BookReview( final newReview = BookReview(
id: const Uuid().v4(), id: now.millisecondsSinceEpoch.toString(),
bookId: widget.bookId, bookId: widget.bookId,
content: _contentController.text.trim(), content: _contentController.text.trim(),
reviewer: _reviewerController.text.trim(), reviewer: _reviewerController.text.trim(),
source: _sourceController.text.trim(), source: _sourceController.text.trim(),
reviewType: _reviewType, reviewType: _reviewType,
isDeleted: false, isDeleted: false,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
); );
await context.read<AppProvider>().addBookReview(newReview); await context.read<AppProvider>().addBookReview(newReview);
} else { } else {
final updatedReview = widget.review!.copyWith( final updatedReview = widget.review!.copyWith(
content: _contentController.text.trim(), content: _contentController.text.trim(),
reviewer: _reviewerController.text.trim(), reviewer: _reviewerController.text.trim(),
source: _sourceController.text.trim(), source: _sourceController.text.trim(),
reviewType: _reviewType, reviewType: _reviewType,
updatedAt: now, updatedAt: now,
); );
await context.read<AppProvider>().updateBookReview(updatedReview); await context.read<AppProvider>().updateBookReview(updatedReview);
} }
if (!mounted) return; if (!mounted) return;
ToastUtil.show(context, widget.review == null ? '添加成功' : '更新成功');
ToastUtil.show(context, widget.review == null ? '添加成功' : '更新成功'); Navigator.pop(context);
Navigator.pop(context);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ToastUtil.show(context, '保存失败: $e'); ToastUtil.show(context, '保存失败: $e');

View File

@@ -323,24 +323,31 @@ class _BookReviewsPageState extends State<BookReviewsPage> {
return AlertDialog( return AlertDialog(
backgroundColor: colors.surface, backgroundColor: colors.surface,
elevation: 0, elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: const Text('确认删除'), title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
content: const Text('确定要删除这条书评吗?'), content: Text('确定要删除这条书评吗?删除后可在回收站恢复。',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))),
), ),
TextButton( ElevatedButton(
onPressed: () async { onPressed: () async {
await context.read<AppProvider>().removeBookReview(review.id); await context.read<AppProvider>().removeBookReview(review.id);
Navigator.pop(context); Navigator.pop(context);
_loadReviews(); _loadReviews();
ToastUtil.show(context, '已删除'); ToastUtil.show(context, '已删除');
}, },
child: Text('删除', style: TextStyle(color: colors.error)), style: ElevatedButton.styleFrom(
backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('删除'),
), ),
], ],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
); );
}, },
); );

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../utils/book/book_dao.dart'; import '../../utils/book/book_dao.dart';
import '../../utils/epub/reader_dao.dart'; import '../../utils/epub/reader_dao.dart';
import '../../utils/toast_util.dart';
import '../../models/data_models.dart'; import '../../models/data_models.dart';
/// 选择关联书籍页面(带搜索功能) /// 选择关联书籍页面(带搜索功能)
@@ -63,9 +64,7 @@ class _BookLinkPageState extends State<BookLinkPage> {
Future<void> _selectBook(Book book) async { Future<void> _selectBook(Book book) async {
await _readerDao.linkToBook(widget.readerBookId, book.id); await _readerDao.linkToBook(widget.readerBookId, book.id);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ToastUtil.show(context, '已关联《${book.title}');
SnackBar(content: Text('已关联《${book.title}')),
);
Navigator.pop(context, true); Navigator.pop(context, true);
} }
} }

View File

@@ -33,6 +33,13 @@ class ControlPanel extends StatefulWidget {
final ValueChanged<double> onMarginBottomChanged; final ValueChanged<double> onMarginBottomChanged;
final ValueChanged<double> onMarginLeftChanged; final ValueChanged<double> onMarginLeftChanged;
final ValueChanged<double> onMarginRightChanged; final ValueChanged<double> onMarginRightChanged;
final int themeIndex;
final int customBgColor;
final int customTextColor;
final ValueChanged<int> onThemeIndexChanged;
final void Function(int bgColor, int textColor) onCustomColorChanged;
final bool currentPageHasBookmark;
final VoidCallback onBookmarkToggle;
const ControlPanel({ const ControlPanel({
super.key, super.key,
@@ -64,6 +71,13 @@ class ControlPanel extends StatefulWidget {
required this.onMarginBottomChanged, required this.onMarginBottomChanged,
required this.onMarginLeftChanged, required this.onMarginLeftChanged,
required this.onMarginRightChanged, required this.onMarginRightChanged,
required this.themeIndex,
required this.customBgColor,
required this.customTextColor,
required this.onThemeIndexChanged,
required this.onCustomColorChanged,
required this.currentPageHasBookmark,
required this.onBookmarkToggle,
}); });
bool get isVertical => direction == 1; bool get isVertical => direction == 1;
@@ -246,6 +260,11 @@ class _ControlPanelState extends State<ControlPanel> {
onMarginBottomChanged: widget.onMarginBottomChanged, onMarginBottomChanged: widget.onMarginBottomChanged,
onMarginLeftChanged: widget.onMarginLeftChanged, onMarginLeftChanged: widget.onMarginLeftChanged,
onMarginRightChanged: widget.onMarginRightChanged, onMarginRightChanged: widget.onMarginRightChanged,
themeIndex: widget.themeIndex,
customBgColor: widget.customBgColor,
customTextColor: widget.customTextColor,
onThemeIndexChanged: widget.onThemeIndexChanged,
onCustomColorChanged: widget.onCustomColorChanged,
), ),
), ),
], ],
@@ -293,6 +312,16 @@ class _ControlPanelState extends State<ControlPanel> {
), ),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
actions: [
IconButton(
icon: Icon(
widget.currentPageHasBookmark
? Icons.bookmark
: Icons.bookmark_outline,
),
onPressed: widget.onBookmarkToggle,
),
],
), ),
), ),
), ),

View File

@@ -7,7 +7,9 @@ import '../../utils/epub/epub_parser.dart';
import '../../utils/epub/reader_models.dart'; import '../../utils/epub/reader_models.dart';
import '../../utils/book/book_dao.dart'; import '../../utils/book/book_dao.dart';
import '../../models/data_models.dart'; import '../../models/data_models.dart';
import '../book/book_detail_page.dart';
import 'book_link_page.dart'; import 'book_link_page.dart';
import 'epub_edit_page.dart';
import 'reader_screen.dart'; import 'reader_screen.dart';
/// EPUB 书籍详情页 /// EPUB 书籍详情页
@@ -68,80 +70,14 @@ class _EpubDetailPageState extends State<EpubDetailPage> {
).then((_) => _refreshBook()); ).then((_) => _refreshBook());
} }
// ─── 编辑对话框 ───────────────────────────────────────────── void _navigateToEdit() async {
final changed = await Navigator.push<bool>(
void _showEditDialog() { context,
final titleCtrl = TextEditingController(text: _book['title'] as String? ?? ''); MaterialPageRoute(
final authorCtrl = TextEditingController(text: _book['author'] as String? ?? ''); builder: (_) => EpubEditPage(bookId: widget.bookId, book: _book),
final colors = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: colors.surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Text('编辑书籍信息',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: titleCtrl,
decoration: InputDecoration(
labelText: '标题',
labelStyle: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 12),
TextField(
controller: authorCtrl,
decoration: InputDecoration(
labelText: '作者',
labelStyle: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
style: const TextStyle(fontSize: 14),
),
],
),
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
actions: [
TextButton(
style: TextButton.styleFrom(
foregroundColor: colors.onSurface.withValues(alpha: 0.6),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
onPressed: () => Navigator.pop(ctx),
child: const Text('取消'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: colors.primary,
foregroundColor: colors.onPrimary,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
onPressed: () async {
final newTitle = titleCtrl.text.trim();
final newAuthor = authorCtrl.text.trim();
if (newTitle.isEmpty) return;
await _dao.updateReaderBook(widget.bookId, {
'title': newTitle,
'author': newAuthor,
'updated_at': DateTime.now().toIso8601String(),
});
if (ctx.mounted) Navigator.pop(ctx);
await _refreshBook();
},
child: const Text('保存'),
),
],
), ),
); );
if (changed == true && mounted) await _refreshBook();
} }
// ─── Build ────────────────────────────────────────────────── // ─── Build ──────────────────────────────────────────────────
@@ -166,7 +102,7 @@ class _EpubDetailPageState extends State<EpubDetailPage> {
actions: [ actions: [
IconButton( IconButton(
icon: Icon(Icons.edit_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.6)), icon: Icon(Icons.edit_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.6)),
onPressed: _showEditDialog, onPressed: _navigateToEdit,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
], ],
@@ -316,21 +252,35 @@ class _EpubDetailPageState extends State<EpubDetailPage> {
Widget _buildLinkedBookCard(ColorScheme colors) { Widget _buildLinkedBookCard(ColorScheme colors) {
final linkedBookId = _book['book_id'] as String? ?? ''; final linkedBookId = _book['book_id'] as String? ?? '';
if (linkedBookId.isEmpty) { if (linkedBookId.isEmpty) {
return InkWell( return GestureDetector(
onTap: _navigateToLinkPage, onTap: _navigateToLinkPage,
borderRadius: BorderRadius.circular(10),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainerHighest, color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.outlineVariant, width: 0.5),
), ),
child: Row(children: [ child: Row(children: [
Icon(Icons.link_outlined, size: 18, color: colors.onSurface.withValues(alpha: 0.4)), Container(
const SizedBox(width: 10), width: 40, height: 40,
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
child: Icon(Icons.link_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.6)),
),
const SizedBox(width: 12),
Expanded( Expanded(
child: Text('选择关联书籍', child: Column(
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.5))), crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('关联书籍', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(height: 4),
Text('点击选择要关联的书籍', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))),
],
),
), ),
Icon(Icons.chevron_right, size: 18, color: colors.onSurface.withValues(alpha: 0.25)), Icon(Icons.chevron_right, size: 18, color: colors.onSurface.withValues(alpha: 0.25)),
]), ]),
@@ -342,35 +292,97 @@ class _EpubDetailPageState extends State<EpubDetailPage> {
future: _bookDao.getBookById(linkedBookId), future: _bookDao.getBookById(linkedBookId),
builder: (context, snapshot) { builder: (context, snapshot) {
final linkedTitle = snapshot.data?.title ?? '未知书籍'; final linkedTitle = snapshot.data?.title ?? '未知书籍';
return Container( final linkedAuthor = snapshot.data?.authors.take(2).join(' / ') ?? '';
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), return GestureDetector(
decoration: BoxDecoration( onTap: () => _showLinkedBookActions(colors, linkedTitle),
color: colors.surfaceContainerHighest, child: Container(
borderRadius: BorderRadius.circular(10), padding: const EdgeInsets.all(16),
), decoration: BoxDecoration(
child: Row(children: [ color: colors.surfaceContainerHigh,
Icon(Icons.link_outlined, size: 18, color: colors.primary), borderRadius: BorderRadius.circular(10),
const SizedBox(width: 10), border: Border.all(color: colors.outlineVariant, width: 0.5),
Expanded( ),
child: Column( child: Row(children: [
crossAxisAlignment: CrossAxisAlignment.start, Container(
children: [ width: 40, height: 40,
Text('已关联', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), decoration: BoxDecoration(
Text(linkedTitle, maxLines: 1, overflow: TextOverflow.ellipsis, color: colors.surface,
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface)), borderRadius: BorderRadius.circular(8),
], border: Border.all(color: colors.outlineVariant, width: 0.5),
),
child: Icon(Icons.link, size: 20, color: colors.primary),
), ),
), const SizedBox(width: 12),
GestureDetector( Expanded(
onTap: _unlinkBook, child: Column(
child: Icon(Icons.close, size: 16, color: colors.onSurface.withValues(alpha: 0.35)), crossAxisAlignment: CrossAxisAlignment.start,
), children: [
]), Text(linkedTitle, maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(height: 4),
Text(
linkedAuthor.isNotEmpty ? '已关联 · $linkedAuthor' : '已关联',
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)),
),
],
),
),
Icon(Icons.chevron_right, size: 18, color: colors.onSurface.withValues(alpha: 0.25)),
]),
),
); );
}, },
); );
} }
void _showLinkedBookActions(ColorScheme colors, String title) {
showModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => SafeArea(
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(width: 36, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 16),
decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Align(alignment: Alignment.centerLeft,
child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface))),
),
const SizedBox(height: 12),
Divider(height: 0.5, color: colors.outlineVariant),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
leading: Container(width: 36, height: 36,
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)),
child: Icon(Icons.menu_book_outlined, size: 18, color: colors.onSurface.withValues(alpha: 0.6))),
title: Text('查看书籍详情', style: TextStyle(fontSize: 13, color: colors.onSurface)),
onTap: () async {
Navigator.pop(ctx);
final bookId = _book['book_id'] as String? ?? '';
if (bookId.isEmpty) return;
final book = await _bookDao.getBookById(bookId);
if (book != null && mounted) {
Navigator.push(context, MaterialPageRoute(builder: (_) => BookDetailPage(book: book)));
}
},
),
Divider(height: 0.5, indent: 20, endIndent: 20, color: colors.outlineVariant),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
leading: Container(width: 36, height: 36,
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)),
child: Icon(Icons.link_off, size: 18, color: colors.error)),
title: Text('取消关联', style: TextStyle(fontSize: 13, color: colors.error)),
onTap: () { Navigator.pop(ctx); _unlinkBook(); },
),
const SizedBox(height: 8),
]),
),
);
}
Future<void> _navigateToLinkPage() async { Future<void> _navigateToLinkPage() async {
final linked = await Navigator.push<bool>( final linked = await Navigator.push<bool>(
context, context,

View File

@@ -0,0 +1,338 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../utils/epub/reader_dao.dart';
import '../../widgets/text_input_panel.dart';
/// EPUB 书籍编辑页
class EpubEditPage extends StatefulWidget {
final String bookId;
final Map<String, dynamic> book;
const EpubEditPage({
super.key,
required this.bookId,
required this.book,
});
@override
State<EpubEditPage> createState() => _EpubEditPageState();
}
class _EpubEditPageState extends State<EpubEditPage> {
final ReaderDao _dao = ReaderDao();
late TextEditingController _titleCtrl;
late TextEditingController _authorCtrl;
@override
void initState() {
super.initState();
_titleCtrl = TextEditingController(text: widget.book['title'] as String? ?? '');
_authorCtrl = TextEditingController(text: widget.book['author'] as String? ?? '');
}
@override
void dispose() {
_titleCtrl.dispose();
_authorCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
final newTitle = _titleCtrl.text.trim();
if (newTitle.isEmpty) return;
await _dao.updateReaderBook(widget.bookId, {
'title': newTitle,
'author': _authorCtrl.text.trim(),
'updated_at': DateTime.now().toIso8601String(),
});
if (mounted) Navigator.pop(context, true);
}
Future<void> _pickCover() async {
final picker = ImagePicker();
final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 85);
if (picked == null || !mounted) return;
final appDir = await getApplicationDocumentsDirectory();
final bookDir = Directory(p.join(appDir.path, 'epub_books', widget.bookId));
if (!await bookDir.exists()) await bookDir.create(recursive: true);
final existing = bookDir.listSync().whereType<File>().where((f) {
final name = p.basenameWithoutExtension(f.path);
return name.startsWith('cover_') && RegExp(r'^cover_\d+$').hasMatch(name);
}).toList();
int nextIndex = 1;
if (existing.isNotEmpty) {
final indices = existing.map((f) {
return int.tryParse(p.basenameWithoutExtension(f.path).substring(6)) ?? 0;
}).toList();
indices.sort();
nextIndex = indices.last + 1;
}
final ext = p.extension(picked.path).toLowerCase();
final destPath = p.join(bookDir.path, 'cover_$nextIndex$ext');
await File(picked.path).copy(destPath);
await _dao.updateReaderBook(widget.bookId, {
'cover_path': destPath,
'updated_at': DateTime.now().toIso8601String(),
});
if (mounted) Navigator.pop(context, true);
}
Future<void> _revertCover() async {
final appDir = await getApplicationDocumentsDirectory();
final bookDir = Directory(p.join(appDir.path, 'epub_books', widget.bookId));
final existing = bookDir.listSync().whereType<File>().where((f) {
final name = p.basenameWithoutExtension(f.path);
return name.startsWith('cover_') && RegExp(r'^cover_\d+$').hasMatch(name);
}).toList();
if (existing.isEmpty) return;
existing.sort((a, b) {
final ia = int.tryParse(p.basenameWithoutExtension(a.path).substring(6)) ?? 0;
final ib = int.tryParse(p.basenameWithoutExtension(b.path).substring(6)) ?? 0;
return ia.compareTo(ib);
});
final currentMax = existing.last;
final currentIndex = int.tryParse(p.basenameWithoutExtension(currentMax.path).substring(6)) ?? 0;
await currentMax.delete();
if (currentIndex <= 1) {
final coverFile = bookDir.listSync().whereType<File>().where((f) {
final name = p.basenameWithoutExtension(f.path);
return name == 'cover';
}).firstOrNull;
await _dao.updateReaderBook(widget.bookId, {
'cover_path': coverFile?.path ?? '',
'updated_at': DateTime.now().toIso8601String(),
});
} else {
final prev = existing.where((f) {
final idx = int.tryParse(p.basenameWithoutExtension(f.path).substring(6)) ?? 0;
return idx == currentIndex - 1;
}).firstOrNull;
await _dao.updateReaderBook(widget.bookId, {
'cover_path': prev?.path ?? '',
'updated_at': DateTime.now().toIso8601String(),
});
}
if (mounted) Navigator.pop(context, true);
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final coverPath = widget.book['cover_path'] as String?;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
backgroundColor: colors.surface,
elevation: 0,
title: Text('编辑书籍信息',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface)),
leading: IconButton(
icon: Icon(Icons.close, size: 20, color: colors.onSurface.withValues(alpha: 0.6)),
onPressed: () => Navigator.pop(context),
),
actions: [
TextButton(
onPressed: _save,
child: Text('保存', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.primary)),
),
const SizedBox(width: 4),
],
),
body: ListView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
children: [
// 封面选择
Center(child: _buildCoverPicker(coverPath, colors)),
const SizedBox(height: 24),
// 信息卡片
Wrap(
spacing: 12,
runSpacing: 12,
children: [
SizedBox(
width: (MediaQuery.of(context).size.width - 52) / 2,
height: 90,
child: _buildInfoCard(
label: '标题',
value: _titleCtrl.text,
required: true,
icon: Icons.auto_stories_outlined,
onTap: () async {
final result = await TextInputPanel.show(
context: context,
title: '书名',
initialValue: _titleCtrl.text,
hint: '请输入书名',
);
if (result != null) setState(() => _titleCtrl.text = result);
},
colors: colors,
),
),
SizedBox(
width: (MediaQuery.of(context).size.width - 52) / 2,
height: 90,
child: _buildInfoCard(
label: '作者',
value: _authorCtrl.text,
icon: Icons.person_outline,
onTap: () async {
final result = await TextInputPanel.show(
context: context,
title: '作者',
initialValue: _authorCtrl.text,
hint: '请输入作者',
);
if (result != null) setState(() => _authorCtrl.text = result);
},
colors: colors,
),
),
],
),
const SizedBox(height: 24),
// 封面操作
Text('封面操作',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600,
color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 10),
Row(children: [
Expanded(child: _buildActionCard(
icon: Icons.add_photo_alternate_outlined, title: '更换封面', subtitle: '从相册选择',
color: colors.primary,
onTap: _pickCover,
)),
const SizedBox(width: 10),
Expanded(child: _buildActionCard(
icon: Icons.undo, title: '恢复上次', subtitle: '回退到上一个封面',
color: colors.onSurface.withValues(alpha: 0.5),
onTap: _revertCover,
)),
]),
],
),
);
}
// ─── 构建组件 ────────────────────────────────────────────────
Widget _buildCoverPicker(String? coverPath, ColorScheme colors) {
return GestureDetector(
onTap: _pickCover,
child: Container(
width: 110, height: 154,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
clipBehavior: Clip.antiAlias,
child: coverPath != null && coverPath.isNotEmpty && File(coverPath).existsSync()
? Image.file(File(coverPath), fit: BoxFit.cover)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_photo_alternate_outlined, size: 28,
color: colors.onSurface.withValues(alpha: 0.25)),
const SizedBox(height: 6),
Text('点击更换',
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))),
],
),
),
);
}
Widget _buildInfoCard({
required String label, required String value, required IconData icon,
required VoidCallback onTap, required ColorScheme colors,
bool required = false,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(icon, size: 14, color: colors.onSurface.withValues(alpha: 0.4)),
const SizedBox(width: 6),
Text(label, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
if (required)
Text(' *', style: TextStyle(fontSize: 11, color: colors.error)),
]),
const Spacer(),
Text(
value.isEmpty ? '未设置' : value,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w500,
color: value.isEmpty ? colors.onSurface.withValues(alpha: 0.2) : colors.onSurface,
),
),
],
),
),
);
}
Widget _buildActionCard({
required IconData icon, required String title, required String subtitle,
required Color color, required VoidCallback onTap,
}) {
final colors = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
child: Row(children: [
Container(
width: 36, height: 36,
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
child: Icon(icon, size: 18, color: color),
),
const SizedBox(width: 10),
Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)),
const SizedBox(height: 2),
Text(subtitle, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
],
)),
]),
),
);
}
}

View File

@@ -1,3 +1,4 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import '../../utils/epub/reader_dao.dart'; import '../../utils/epub/reader_dao.dart';
@@ -48,6 +49,14 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
if (result == null || result.files.isEmpty) return; if (result == null || result.files.isEmpty) return;
final path = result.files.single.path; final path = result.files.single.path;
if (path == null) return; if (path == null) return;
if (!path.toLowerCase().endsWith('.epub')) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('仅支持导入 .epub 格式的文件')),
);
}
return;
}
if (!mounted) return; if (!mounted) return;
showDialog( showDialog(
@@ -210,29 +219,123 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
} }
Widget _buildGrid(ColorScheme colors) { Widget _buildGrid(ColorScheme colors) {
final bool isRelaxed = _viewMode == ViewMode.relaxed; final bool showList = _viewMode == ViewMode.compact;
final maxExtent = isRelaxed ? 180.0 : 120.0;
final aspectRatio = isRelaxed ? 0.55 : 0.68;
final spacing = isRelaxed ? 16.0 : 8.0;
if (showList) {
return _buildListView(colors);
}
return _buildGridView(colors);
}
Widget _buildGridView(ColorScheme colors) {
return GridView.builder( return GridView.builder(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
maxCrossAxisExtent: maxExtent, crossAxisCount: 3,
childAspectRatio: aspectRatio, crossAxisSpacing: 12,
crossAxisSpacing: spacing, mainAxisSpacing: 16,
mainAxisSpacing: spacing, childAspectRatio: 0.55,
), ),
itemCount: _books.length, itemCount: _books.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final book = _books[index]; final book = _books[index];
return BookGridItem( return BookGridItem(
book: book, book: book,
viewMode: _viewMode, viewMode: ViewMode.relaxed,
onTap: () => _openBook(book), onTap: () => _openBook(book),
onLongPress: () => _deleteBook(book), onLongPress: () => _deleteBook(book),
); );
}, },
); );
} }
Widget _buildListView(ColorScheme colors) {
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
itemCount: _books.length,
itemBuilder: (context, index) {
final book = _books[index];
final title = book['title'] as String? ?? '';
final author = book['author'] as String? ?? '';
final coverPath = book['cover_path'] as String?;
final progress = (book['reading_percentage'] as num?)?.toDouble() ?? 0.0;
return GestureDetector(
onTap: () => _openBook(book),
onLongPress: () => _deleteBook(book),
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
child: Row(
children: [
// 封面
SizedBox(
width: 56, height: 80,
child: _buildCover(coverPath, colors),
),
const SizedBox(width: 14),
// 信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
if (author.isNotEmpty) ...[
const SizedBox(height: 4),
Text(author, maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5))),
],
const SizedBox(height: 8),
// 标签
Wrap(spacing: 6, runSpacing: 4, children: [
_buildTag('EPUB', colors),
if (progress > 0) _buildTag('${(progress * 100).toInt()}%', colors),
]),
],
),
),
Icon(Icons.chevron_right, size: 18, color: colors.onSurface.withValues(alpha: 0.25)),
],
),
),
);
},
);
}
Widget _buildCover(String? path, ColorScheme colors) {
if (path != null && path.isNotEmpty && File(path).existsSync()) {
return ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.file(File(path), fit: BoxFit.cover,
width: double.infinity, height: double.infinity),
);
}
return Container(
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Icon(Icons.auto_stories_outlined, size: 24,
color: colors.onSurface.withValues(alpha: 0.25)),
);
}
Widget _buildTag(String label, ColorScheme colors) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Text(label,
style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.5))),
);
}
} }

View File

@@ -129,6 +129,9 @@ class _ReaderScreenState extends State<ReaderScreen>
bool styleDrawerOpen = false; bool styleDrawerOpen = false;
AppLifecycleState? lastLifecycleState = AppLifecycleState.resumed; AppLifecycleState? lastLifecycleState = AppLifecycleState.resumed;
// 书签
final List<Map<String, dynamic>> _bookmarks = [];
// Services // Services
final EpubStreamService _streamService = EpubStreamService(); final EpubStreamService _streamService = EpubStreamService();
final ReaderDao _readerDao = ReaderDao(); final ReaderDao _readerDao = ReaderDao();
@@ -311,6 +314,7 @@ class _ReaderScreenState extends State<ReaderScreen>
currentSpineItemIndex = bookSession.initialChapterIndex; currentSpineItemIndex = bookSession.initialChapterIndex;
}); });
updateProgressDebounced(); updateProgressDebounced();
_loadBookmarks();
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
@@ -322,6 +326,84 @@ class _ReaderScreenState extends State<ReaderScreen>
} }
} }
// ─── 书签 ──────────────────────────────────────────────────────
Future<void> _loadBookmarks() async {
final list = await _readerDao.getBookmarksByBookId(widget.bookId);
if (mounted) setState(() => _bookmarks..clear()..addAll(list));
}
bool get _currentPageHasBookmark {
final cfi = '$currentSpineItemIndex:${(currentPageInChapter / (totalPagesInChapter > 0 ? totalPagesInChapter : 1)).toStringAsFixed(4)}';
return _bookmarks.any((bm) => (bm['cfi'] as String? ?? '') == cfi);
}
Future<void> _toggleBookmark() async {
// 检查当前页是否已有书签
final cfi = '$currentSpineItemIndex:${(currentPageInChapter / (totalPagesInChapter > 0 ? totalPagesInChapter : 1)).toStringAsFixed(4)}';
final existing = _bookmarks.where((bm) => (bm['cfi'] as String? ?? '') == cfi).firstOrNull;
if (existing != null) {
// 删除已有书签
await _readerDao.deleteBookmark(existing['id'] as int);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已移除书签'), duration: Duration(seconds: 1)),
);
}
} else {
// 添加书签
final chapterTitle = bookSession.spine.isNotEmpty &&
currentSpineItemIndex < bookSession.spine.length
? bookSession.spine[currentSpineItemIndex].href
: '';
// 尝试从 TOC 找更友好的标题
String title = chapterTitle;
for (final toc in bookSession.toc) {
if (toc.spineIndex == currentSpineItemIndex) {
title = toc.label;
break;
}
}
await _readerDao.insertBookmark({
'book_id': widget.bookId,
'content': title,
'cfi': cfi,
'chapter': currentSpineItemIndex.toString(),
'created_at': DateTime.now().toIso8601String(),
'updated_at': DateTime.now().toIso8601String(),
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已添加书签'), duration: Duration(seconds: 1)),
);
}
}
await _loadBookmarks();
}
void _jumpToBookmark(Map<String, dynamic> bookmark) {
final cfi = bookmark['cfi'] as String? ?? '';
if (cfi.isEmpty) return;
final parts = cfi.split(':');
if (parts.isEmpty) return;
final chapterIndex = int.tryParse(parts[0]) ?? 0;
final scrollRatio = parts.length >= 2 ? (double.tryParse(parts[1]) ?? 0.0) : 0.0;
if (chapterIndex >= 0 && chapterIndex < bookSession.spine.length) {
setState(() {
currentSpineItemIndex = chapterIndex;
});
loadCarousel(restoreScrollRatio: scrollRatio);
}
}
Future<void> _deleteBookmark(int id) async {
await _readerDao.deleteBookmark(id);
await _loadBookmarks();
}
void toggleControls() { void toggleControls() {
if (showControls) { if (showControls) {
hideBottomNavigationBar(); hideBottomNavigationBar();
@@ -399,6 +481,9 @@ class _ReaderScreenState extends State<ReaderScreen>
onTocItemSelected: navigateToTocItem, onTocItemSelected: navigateToTocItem,
onCoverTap: navigateToFirstTocItemFirstPage, onCoverTap: navigateToFirstTocItemFirstPage,
themeData: themeData, themeData: themeData,
bookmarks: _bookmarks,
onBookmarkTap: _jumpToBookmark,
onBookmarkDelete: _deleteBookmark,
), ),
onDrawerChanged: (isOpened) { onDrawerChanged: (isOpened) {
tocDrawerOpen = isOpened; tocDrawerOpen = isOpened;
@@ -530,6 +615,28 @@ class _ReaderScreenState extends State<ReaderScreen>
readerSettings.save(); readerSettings.save();
updateWebViewTheme(); updateWebViewTheme();
}, },
themeIndex: readerSettings.themeIndex,
customBgColor: readerSettings.customBgColor,
customTextColor: readerSettings.customTextColor,
onThemeIndexChanged: (index) {
setState(() {
readerSettings = readerSettings.copyWith(themeIndex: index);
});
readerSettings.save();
updateWebViewTheme();
},
onCustomColorChanged: (bgColor, textColor) {
setState(() {
readerSettings = readerSettings.copyWith(
customBgColor: bgColor,
customTextColor: textColor,
);
});
readerSettings.save();
updateWebViewTheme();
},
currentPageHasBookmark: _currentPageHasBookmark,
onBookmarkToggle: _toggleBookmark,
), ),
], ],
), ),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../utils/epub/epub_theme.dart';
import 'widgets/integer_stepper.dart'; import 'widgets/integer_stepper.dart';
import 'widgets/reader_scale_slider.dart'; import 'widgets/reader_scale_slider.dart';
@@ -18,6 +19,11 @@ class ReaderStyleSheet extends StatefulWidget {
final ValueChanged<double> onMarginLeftChanged; final ValueChanged<double> onMarginLeftChanged;
final ValueChanged<double> onMarginRightChanged; final ValueChanged<double> onMarginRightChanged;
final ValueChanged<double> onFontSizeChanged; final ValueChanged<double> onFontSizeChanged;
final int themeIndex;
final int customBgColor;
final int customTextColor;
final ValueChanged<int> onThemeIndexChanged;
final void Function(int bgColor, int textColor) onCustomColorChanged;
const ReaderStyleSheet({ const ReaderStyleSheet({
super.key, super.key,
@@ -33,6 +39,11 @@ class ReaderStyleSheet extends StatefulWidget {
required this.onMarginLeftChanged, required this.onMarginLeftChanged,
required this.onMarginRightChanged, required this.onMarginRightChanged,
required this.onFontSizeChanged, required this.onFontSizeChanged,
required this.themeIndex,
required this.customBgColor,
required this.customTextColor,
required this.onThemeIndexChanged,
required this.onCustomColorChanged,
}); });
@override @override
@@ -46,6 +57,9 @@ class _ReaderStyleSheetState extends State<ReaderStyleSheet> {
late int _leftMargin; late int _leftMargin;
late int _rightMargin; late int _rightMargin;
late double _fontSize; late double _fontSize;
late int _themeIndex;
late int _customBgColor;
late int _customTextColor;
static const int _marginMin = 0; static const int _marginMin = 0;
static const int _marginMax = 64; static const int _marginMax = 64;
@@ -62,6 +76,9 @@ class _ReaderStyleSheetState extends State<ReaderStyleSheet> {
_leftMargin = widget.marginLeft.toInt(); _leftMargin = widget.marginLeft.toInt();
_rightMargin = widget.marginRight.toInt(); _rightMargin = widget.marginRight.toInt();
_fontSize = widget.fontSize; _fontSize = widget.fontSize;
_themeIndex = widget.themeIndex;
_customBgColor = widget.customBgColor;
_customTextColor = widget.customTextColor;
} }
@override @override
@@ -257,6 +274,13 @@ class _ReaderStyleSheetState extends State<ReaderStyleSheet> {
), ),
], ],
), ),
const SizedBox(height: 24),
// -- 阅读主题 --
const _SectionTitle(label: '阅读主题'),
const SizedBox(height: 10),
_buildThemePresets(colorScheme),
], ],
), ),
), ),
@@ -264,6 +288,223 @@ class _ReaderStyleSheetState extends State<ReaderStyleSheet> {
}, },
); );
} }
Widget _buildThemePresets(ColorScheme colorScheme) {
final presets = ReaderThemePresets.presets;
return SizedBox(
height: 56,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: presets.length + 1, // +1 for custom
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (ctx, i) {
final isSelected = _themeIndex == i;
final borderColor = isSelected
? colorScheme.primary
: colorScheme.outlineVariant;
if (i < presets.length) {
final preset = presets[i];
return GestureDetector(
onTap: () {
setState(() => _themeIndex = i);
widget.onThemeIndexChanged(i);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 56,
height: 56,
decoration: BoxDecoration(
color: i == 0
? colorScheme.surfaceContainerHighest
: preset.surface,
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: borderColor,
width: isSelected ? 2.5 : 1,
),
),
child: Center(
child: isSelected
? Icon(
Icons.check,
size: 20,
color: i == 0
? colorScheme.onSurface
: preset.onSurface,
)
: i == 0
? Icon(
Icons.phone_android,
size: 18,
color: colorScheme.onSurfaceVariant,
)
: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: preset.onSurface,
borderRadius: BorderRadius.circular(8),
),
),
),
),
);
}
// Custom color chip
return GestureDetector(
onTap: () => _showCustomColorPicker(ctx),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 56,
height: 56,
decoration: BoxDecoration(
color: Color(_customBgColor),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: borderColor,
width: isSelected ? 2.5 : 1,
),
),
child: Center(
child: isSelected
? Icon(Icons.check, size: 20, color: Color(_customTextColor))
: Icon(Icons.palette_outlined, size: 18, color: Color(_customTextColor)),
),
),
);
},
),
);
}
void _showCustomColorPicker(BuildContext context) {
final cs = Theme.of(context).colorScheme;
Color bgColor = Color(_customBgColor);
Color textColor = Color(_customTextColor);
showDialog(
context: context,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setDialogState) {
return AlertDialog(
backgroundColor: cs.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Text('自定义颜色', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: cs.onSurface)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Preview
Container(
width: double.infinity,
height: 64,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outlineVariant, width: 0.5),
),
child: Center(
child: Text('预览文字 Aa 字体',
style: TextStyle(color: textColor, fontSize: 16)),
),
),
const SizedBox(height: 20),
// Background color
_buildColorRow(
label: '背景色',
color: bgColor,
onChanged: (c) => setDialogState(() => bgColor = c),
cs: cs,
),
const SizedBox(height: 12),
// Text color
_buildColorRow(
label: '文字色',
color: textColor,
onChanged: (c) => setDialogState(() => textColor = c),
cs: cs,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text('取消', style: TextStyle(color: cs.onSurfaceVariant)),
),
TextButton(
onPressed: () {
setState(() {
_customBgColor = bgColor.value;
_customTextColor = textColor.value;
_themeIndex = 9;
});
widget.onCustomColorChanged(bgColor.value, textColor.value);
widget.onThemeIndexChanged(9);
Navigator.pop(ctx);
},
child: Text('确定', style: TextStyle(fontWeight: FontWeight.w600, color: cs.primary)),
),
],
);
},
);
},
);
}
Widget _buildColorRow({
required String label,
required Color color,
required ValueChanged<Color> onChanged,
required ColorScheme cs,
}) {
return Row(
children: [
Text(label, style: TextStyle(fontSize: 13, color: cs.onSurfaceVariant)),
const Spacer(),
// Preset color chips
..._presetColors.map((c) {
final selected = c.$2 == color;
return GestureDetector(
onTap: () => onChanged(c.$2),
child: Container(
width: 28,
height: 28,
margin: const EdgeInsets.only(left: 6),
decoration: BoxDecoration(
color: c.$2,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: selected ? cs.primary : cs.outlineVariant,
width: selected ? 2 : 1,
),
),
child: selected
? Icon(Icons.check, size: 14,
color: ThemeData.estimateBrightnessForColor(c.$2) == Brightness.dark
? Colors.white : Colors.black)
: null,
),
);
}),
],
);
}
static const _presetColors = [
('白色', Color(0xFFFFFFFF)),
('浅灰', Color(0xFFF5F5F5)),
('护眼', Color(0xFFF4ECD8)),
('抹茶', Color(0xFFF6FBF5)),
('樱花', Color(0xFFFFF8F8)),
('浅蓝', Color(0xFFF0F4FF)),
('深灰', Color(0xFF333333)),
('深褐', Color(0xFF2C2418)),
('深蓝', Color(0xFF1A2A3A)),
('深绿', Color(0xFF1A2E1A)),
('黑色', Color(0xFF1A1A1A)),
('深红', Color(0xFF3A1A1A)),
];
} }
/// Section title (equivalent to lumina's SettingsSectionTitle) /// Section title (equivalent to lumina's SettingsSectionTitle)

View File

@@ -29,6 +29,11 @@ class TocDrawer extends StatefulWidget {
final VoidCallback? onCoverTap; final VoidCallback? onCoverTap;
final ThemeData themeData; final ThemeData themeData;
// 书签相关
final List<Map<String, dynamic>> bookmarks;
final void Function(Map<String, dynamic> bookmark)? onBookmarkTap;
final void Function(int bookmarkId)? onBookmarkDelete;
const TocDrawer({ const TocDrawer({
super.key, super.key,
required this.bookTitle, required this.bookTitle,
@@ -40,20 +45,25 @@ class TocDrawer extends StatefulWidget {
required this.onTocItemSelected, required this.onTocItemSelected,
this.onCoverTap, this.onCoverTap,
required this.themeData, required this.themeData,
this.bookmarks = const [],
this.onBookmarkTap,
this.onBookmarkDelete,
}); });
@override @override
State<TocDrawer> createState() => _TocDrawerState(); State<TocDrawer> createState() => _TocDrawerState();
} }
class _TocDrawerState extends State<TocDrawer> { class _TocDrawerState extends State<TocDrawer> with SingleTickerProviderStateMixin {
final ScrollController _scrollController = ScrollController(); final ScrollController _tocScrollController = ScrollController();
final Set<TocEntry> _expandedItems = {}; final Set<TocEntry> _expandedItems = {};
List<_TocRowItem> _visibleItems = []; List<_TocRowItem> _visibleItems = [];
late TabController _tabController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 2, vsync: this);
_initExpansionState(); _initExpansionState();
_regenerateVisibleItems(); _regenerateVisibleItems();
@@ -80,7 +90,8 @@ class _TocDrawerState extends State<TocDrawer> {
@override @override
void dispose() { void dispose() {
_scrollController.dispose(); _tocScrollController.dispose();
_tabController.dispose();
super.dispose(); super.dispose();
} }
@@ -161,10 +172,10 @@ class _TocDrawerState extends State<TocDrawer> {
(row) => widget.activeTocItems.contains(row.item), (row) => widget.activeTocItems.contains(row.item),
); );
if (index != -1 && _scrollController.hasClients) { if (index != -1 && _tocScrollController.hasClients) {
final offset = (index * 56.0) - (56.0 * 4); final offset = (index * 56.0) - (56.0 * 4);
_scrollController.jumpTo( _tocScrollController.jumpTo(
offset.clamp(0.0, _scrollController.position.maxScrollExtent), offset.clamp(0.0, _tocScrollController.position.maxScrollExtent),
); );
} }
} }
@@ -172,6 +183,7 @@ class _TocDrawerState extends State<TocDrawer> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = widget.themeData.brightness == Brightness.dark; final isDark = widget.themeData.brightness == Brightness.dark;
final colors = widget.themeData.colorScheme;
return Drawer( return Drawer(
backgroundColor: widget.themeData.scaffoldBackgroundColor, backgroundColor: widget.themeData.scaffoldBackgroundColor,
@@ -179,19 +191,36 @@ class _TocDrawerState extends State<TocDrawer> {
child: Column( child: Column(
children: [ children: [
_buildHeader(context, isDark), _buildHeader(context, isDark),
// Tab bar
Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: colors.outlineVariant, width: 0.5),
),
),
child: TabBar(
controller: _tabController,
labelColor: colors.onSurface,
unselectedLabelColor: colors.onSurfaceVariant,
labelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
unselectedLabelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
indicatorColor: colors.onSurface,
indicatorSize: TabBarIndicatorSize.label,
indicatorWeight: 2,
dividerColor: Colors.transparent,
tabs: const [
Tab(text: '目录'),
Tab(text: '书签'),
],
),
),
Expanded( Expanded(
child: ListView.builder( child: TabBarView(
controller: _scrollController, controller: _tabController,
itemCount: _visibleItems.length + 1, children: [
itemExtent: 56.0, _buildTocTab(context, isDark),
itemBuilder: (context, index) { _buildBookmarkTab(context, isDark),
if (index == _visibleItems.length) { ],
return const SizedBox(height: 56);
}
final row = _visibleItems[index];
return _buildRowItem(context, row, isDark);
},
), ),
), ),
], ],
@@ -200,6 +229,24 @@ class _TocDrawerState extends State<TocDrawer> {
); );
} }
// ─── 目录 Tab ────────────────────────────────────────────────
Widget _buildTocTab(BuildContext context, bool isDark) {
return ListView.builder(
controller: _tocScrollController,
itemCount: _visibleItems.length + 1,
itemExtent: 56.0,
itemBuilder: (context, index) {
if (index == _visibleItems.length) {
return const SizedBox(height: 56);
}
final row = _visibleItems[index];
return _buildRowItem(context, row, isDark);
},
);
}
Widget _buildRowItem(BuildContext context, _TocRowItem row, bool isDark) { Widget _buildRowItem(BuildContext context, _TocRowItem row, bool isDark) {
final item = row.item; final item = row.item;
// 用 activeTocItems 匹配,或者直接用 spineIndex 匹配当前章节 // 用 activeTocItems 匹配,或者直接用 spineIndex 匹配当前章节
@@ -266,6 +313,129 @@ class _TocDrawerState extends State<TocDrawer> {
); );
} }
// ─── 书签 Tab ────────────────────────────────────────────────
Widget _buildBookmarkTab(BuildContext context, bool isDark) {
final colors = widget.themeData.colorScheme;
if (widget.bookmarks.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.bookmark_outline, size: 48, color: colors.onSurfaceVariant.withValues(alpha: 0.3)),
const SizedBox(height: 12),
Text('暂无书签', style: TextStyle(fontSize: 14, color: colors.onSurfaceVariant.withValues(alpha: 0.5))),
const SizedBox(height: 4),
Text('阅读时点击顶部书签图标添加', style: TextStyle(fontSize: 12, color: colors.onSurfaceVariant.withValues(alpha: 0.3))),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.only(bottom: 56),
itemCount: widget.bookmarks.length,
itemBuilder: (context, index) {
final bm = widget.bookmarks[index];
return _buildBookmarkItem(context, bm);
},
);
}
Widget _buildBookmarkItem(BuildContext context, Map<String, dynamic> bookmark) {
final colors = widget.themeData.colorScheme;
final content = bookmark['content'] as String? ?? '';
final cfi = bookmark['cfi'] as String? ?? '';
final createdAt = bookmark['created_at'] as String? ?? '';
final bookmarkId = bookmark['id'] as int;
// 解析页码信息
String pageInfo = '';
if (cfi.isNotEmpty) {
final parts = cfi.split(':');
if (parts.length >= 2) {
final chapterIdx = int.tryParse(parts[0]) ?? 0;
pageInfo = '${chapterIdx + 1}';
}
}
// 解析时间
String timeStr = '';
if (createdAt.isNotEmpty) {
try {
final dt = DateTime.parse(createdAt).toLocal();
timeStr = '${dt.month}/${dt.day} ${dt.hour}:${dt.minute.toString().padLeft(2, '0')}';
} catch (_) {}
}
return Dismissible(
key: ValueKey(bookmarkId),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 24),
color: colors.error,
child: Icon(Icons.delete_outline, color: colors.onError, size: 20),
),
onDismissed: (_) {
widget.onBookmarkDelete?.call(bookmarkId);
},
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
widget.onBookmarkTap?.call(bookmark);
Navigator.of(context).pop();
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: colors.outlineVariant, width: 0.5),
),
),
child: Row(
children: [
Icon(Icons.bookmark, size: 18, color: colors.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
content.isNotEmpty ? content : pageInfo,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: widget.themeData.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
if (pageInfo.isNotEmpty && content.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
pageInfo,
style: TextStyle(fontSize: 12, color: colors.onSurfaceVariant.withValues(alpha: 0.5)),
),
],
],
),
),
if (timeStr.isNotEmpty)
Text(
timeStr,
style: TextStyle(fontSize: 11, color: colors.onSurfaceVariant.withValues(alpha: 0.4)),
),
],
),
),
),
),
);
}
// ─── Header ──────────────────────────────────────────────────
Widget _buildHeader(BuildContext context, bool isDark) { Widget _buildHeader(BuildContext context, bool isDark) {
const authorText = ''; const authorText = '';
@@ -273,12 +443,6 @@ class _TocDrawerState extends State<TocDrawer> {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: widget.themeData.colorScheme.surface, color: widget.themeData.colorScheme.surface,
border: Border(
bottom: BorderSide(
color: widget.themeData.colorScheme.outline,
width: 1,
),
),
), ),
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () {

View File

@@ -36,7 +36,7 @@ class BookGridItem extends StatelessWidget {
// ─── mode helpers ───────────────────────────────────────────────────────── // ─── mode helpers ─────────────────────────────────────────────────────────
/// Relaxed: cover + title + author + progress bar. /// Relaxed: cover + title + author, 右上角进度百分比。
Widget _buildRelaxed(BuildContext context) { Widget _buildRelaxed(BuildContext context) {
final title = book['title'] as String? ?? ''; final title = book['title'] as String? ?? '';
final author = book['author'] as String? ?? ''; final author = book['author'] as String? ?? '';
@@ -45,8 +45,10 @@ class BookGridItem extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded(child: _buildCoverStack(context, fit: StackFit.expand)), Expanded(child: _buildCoverStack(context, fit: StackFit.expand, extras: [
const SizedBox(height: 12), if (progress > 0) _buildProgressBadge(context),
])),
const SizedBox(height: 8),
Text( Text(
title, title,
maxLines: 2, maxLines: 2,
@@ -55,8 +57,8 @@ class BookGridItem extends StatelessWidget {
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
const SizedBox(height: 4), if (author.isNotEmpty) ...[
if (author.isNotEmpty) const SizedBox(height: 2),
Text( Text(
author, author,
maxLines: 1, maxLines: 1,
@@ -66,17 +68,7 @@ class BookGridItem extends StatelessWidget {
fontSize: 12, fontSize: 12,
), ),
), ),
if (progress > 0) ],
Padding(
padding: const EdgeInsets.only(top: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: progress,
minHeight: 3,
),
),
),
], ],
); );
} }

View File

@@ -18,6 +18,15 @@ class HomePage extends StatefulWidget {
class _HomePageState extends State<HomePage> { class _HomePageState extends State<HomePage> {
final PageController _pageController = PageController(); final PageController _pageController = PageController();
bool _isSwitchingPage = false; bool _isSwitchingPage = false;
int _lastNavIndex = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_lastNavIndex = context.read<AppProvider>().bottomNavIndex;
});
}
@override @override
void dispose() { void dispose() {
@@ -25,6 +34,27 @@ class _HomePageState extends State<HomePage> {
super.dispose(); super.dispose();
} }
void _onNavIndexChanged(AppProvider provider) {
final currentPage = provider.bottomNavIndex == 0 ? 0 : 1;
if (currentPage == _lastNavIndex) return;
_lastNavIndex = currentPage;
if (!_pageController.hasClients) return;
if (_pageController.page?.round() == currentPage) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_isSwitchingPage = true;
_pageController.jumpToPage(currentPage);
Future.delayed(const Duration(milliseconds: 300), () {
if (mounted) {
_isSwitchingPage = false;
provider.setBottomNavVisible(true);
}
});
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -34,21 +64,7 @@ class _HomePageState extends State<HomePage> {
body: Consumer<AppProvider>( body: Consumer<AppProvider>(
builder: (context, provider, child) { builder: (context, provider, child) {
final currentPage = provider.bottomNavIndex == 0 ? 0 : 1; _onNavIndexChanged(provider);
if (_pageController.hasClients && _pageController.page?.round() != currentPage) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_isSwitchingPage = true;
_pageController.jumpToPage(currentPage);
Future.delayed(const Duration(milliseconds: 300), () {
if (mounted) {
_isSwitchingPage = false;
provider.setBottomNavVisible(true);
}
});
}
});
}
return NotificationListener<ScrollNotification>( return NotificationListener<ScrollNotification>(
onNotification: (notification) { onNotification: (notification) {

View File

@@ -598,6 +598,8 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
const SizedBox(width: 16), const SizedBox(width: 16),
], ],
_buildStatusTag(movie), _buildStatusTag(movie),
const SizedBox(width: 6),
_buildCategoryTag(movie),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -679,6 +681,35 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
); );
} }
Widget _buildCategoryTag(Movie movie) {
final colors = Theme.of(context).colorScheme;
const labels = {
'movie': '电影',
'tv': '电视剧',
'anime': '动漫',
'variety': '综艺',
'documentary': '纪录片',
'short': '微短剧',
};
final label = labels[movie.category];
if (label == null) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
color: colors.onSurface.withValues(alpha: 0.5),
fontWeight: FontWeight.w500,
),
),
);
}
Widget _buildDirectorsSection(Movie movie) { Widget _buildDirectorsSection(Movie movie) {
final isOverlay = _detailStyle == 1; final isOverlay = _detailStyle == 1;
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;

View File

@@ -50,6 +50,7 @@ class _MovieFormPageState extends State<MovieFormPage> {
List<String> _alternateTitles = []; List<String> _alternateTitles = [];
String? _posterPath; String? _posterPath;
String _status = 'want_to_watch'; String _status = 'want_to_watch';
String _category = 'movie';
DateTime? _releaseDate; DateTime? _releaseDate;
DateTime? _watchDate; DateTime? _watchDate;
bool _isDownloading = false; bool _isDownloading = false;
@@ -85,6 +86,7 @@ class _MovieFormPageState extends State<MovieFormPage> {
_alternateTitles = List.from(movie.alternateTitles); _alternateTitles = List.from(movie.alternateTitles);
_posterPath = movie.posterPath; _posterPath = movie.posterPath;
_status = movie.status; _status = movie.status;
_category = movie.category;
_releaseDate = movie.releaseDate; _releaseDate = movie.releaseDate;
_watchDate = movie.watchDate; _watchDate = movie.watchDate;
} else if (widget.initialStatus != null) { } else if (widget.initialStatus != null) {
@@ -831,11 +833,60 @@ class _MovieFormPageState extends State<MovieFormPage> {
], ],
], ],
), ),
const SizedBox(height: 12),
// 分类
Row(
children: [
Text('分类', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(width: 12),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: _categories.map((c) {
final isSelected = _category == c.$2;
return GestureDetector(
onTap: () => setState(() => _category = c.$2),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: EdgeInsets.only(right: c == _categories.last ? 0 : 6),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: isSelected ? colors.primary : colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text(
c.$1,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal,
color: isSelected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5),
),
),
),
);
}).toList(),
),
),
),
],
),
], ],
), ),
); );
} }
/// 影视分类选项
static const _categories = [
('电影', 'movie'),
('电视剧', 'tv'),
('动漫', 'anime'),
('综艺', 'variety'),
('纪录片', 'documentary'),
('微短剧', 'short'),
];
/// 构建状态选项 /// 构建状态选项
Widget _buildStatusOption(String label, String value) { Widget _buildStatusOption(String label, String value) {
final isSelected = _status == value; final isSelected = _status == value;
@@ -1292,6 +1343,7 @@ class _MovieFormPageState extends State<MovieFormPage> {
summary: _summaryController.text.trim(), summary: _summaryController.text.trim(),
rating: rating, rating: rating,
status: _status, status: _status,
category: _category,
watchDate: _watchDate, watchDate: _watchDate,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -1311,6 +1363,7 @@ class _MovieFormPageState extends State<MovieFormPage> {
summary: _summaryController.text.trim(), summary: _summaryController.text.trim(),
rating: rating, rating: rating,
status: _status, status: _status,
category: _category,
watchDate: _watchDate, watchDate: _watchDate,
updatedAt: now, updatedAt: now,
); );

View File

@@ -533,24 +533,31 @@ class _MoviePostersPageState extends State<MoviePostersPage> {
return AlertDialog( return AlertDialog(
backgroundColor: colors.surface, backgroundColor: colors.surface,
elevation: 0, elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: const Text('确认删除'), title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
content: const Text('确定要删除这张海报吗?'), content: Text('确定要删除这张海报吗?删除后可在回收站恢复。',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))),
), ),
TextButton( ElevatedButton(
onPressed: () async { onPressed: () async {
await context.read<AppProvider>().removeMoviePoster(poster.id); await context.read<AppProvider>().removeMoviePoster(poster.id);
Navigator.pop(context); Navigator.pop(context);
_loadPosters(); _loadPosters();
ToastUtil.show(context, '已删除'); ToastUtil.show(context, '已删除');
}, },
child: Text('删除', style: TextStyle(color: colors.error)), style: ElevatedButton.styleFrom(
backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('删除'),
), ),
], ],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
); );
}, },
); );

View File

@@ -50,17 +50,27 @@ class _MovieReviewDetailPageState extends State<MovieReviewDetailPage> {
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
backgroundColor: colors.surface, backgroundColor: colors.surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
content: Text('确定要删除这条影评吗?', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))), content: Text('确定要删除这条影评吗?删除后可在回收站恢复。',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.5)))), TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))),
),
ElevatedButton( ElevatedButton(
onPressed: () => Navigator.pop(ctx, true), onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), style: ElevatedButton.styleFrom(
backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('删除'), child: const Text('删除'),
), ),
], ],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
), ),
); );
if (confirmed == true) { if (confirmed == true) {

View File

@@ -323,24 +323,31 @@ class _MovieReviewsPageState extends State<MovieReviewsPage> {
return AlertDialog( return AlertDialog(
backgroundColor: colors.surface, backgroundColor: colors.surface,
elevation: 0, elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: const Text('确认删除'), title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
content: const Text('确定要删除这条影评吗?'), content: Text('确定要删除这条影评吗?删除后可在回收站恢复。',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))),
), ),
TextButton( ElevatedButton(
onPressed: () async { onPressed: () async {
await context.read<AppProvider>().removeMovieReview(review.id); await context.read<AppProvider>().removeMovieReview(review.id);
Navigator.pop(context); Navigator.pop(context);
_loadReviews(); _loadReviews();
ToastUtil.show(context, '已删除'); ToastUtil.show(context, '已删除');
}, },
child: Text('删除', style: TextStyle(color: colors.error)), style: ElevatedButton.styleFrom(
backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('删除'),
), ),
], ],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
); );
}, },
); );

View File

@@ -283,7 +283,7 @@ class _NotePlusFormPageState extends State<NotePlusFormPage> {
top: false, top: false,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF5F5F5), color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
), ),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 3),

View File

@@ -12,7 +12,7 @@ class RecycleBinPage extends StatefulWidget {
State<RecycleBinPage> createState() => _RecycleBinPageState(); State<RecycleBinPage> createState() => _RecycleBinPageState();
} }
enum _ItemType { movie, book, note } enum _ItemType { movie, book, note, movieReview, bookReview }
class _DeletedItem { class _DeletedItem {
final _ItemType type; final _ItemType type;
@@ -45,6 +45,22 @@ class _DeletedItem {
subtitle = '删除于 ${n.updatedAt.year}.${n.updatedAt.month.toString().padLeft(2, '0')}.${n.updatedAt.day.toString().padLeft(2, '0')}', subtitle = '删除于 ${n.updatedAt.year}.${n.updatedAt.month.toString().padLeft(2, '0')}.${n.updatedAt.day.toString().padLeft(2, '0')}',
icon = Icons.description_outlined, icon = Icons.description_outlined,
typeLabel = '笔记'; typeLabel = '笔记';
_DeletedItem.movieReview(MovieReview r)
: type = _ItemType.movieReview,
id = r.id,
title = r.content.isNotEmpty ? r.content : '影评',
subtitle = '删除于 ${r.updatedAt.year}.${r.updatedAt.month.toString().padLeft(2, '0')}.${r.updatedAt.day.toString().padLeft(2, '0')}',
icon = Icons.rate_review_outlined,
typeLabel = '影评';
_DeletedItem.bookReview(BookReview r)
: type = _ItemType.bookReview,
id = r.id,
title = r.content.isNotEmpty ? r.content : '书评',
subtitle = '删除于 ${r.updatedAt.year}.${r.updatedAt.month.toString().padLeft(2, '0')}.${r.updatedAt.day.toString().padLeft(2, '0')}',
icon = Icons.rate_review_outlined,
typeLabel = '书评';
} }
class _RecycleBinPageState extends State<RecycleBinPage> { class _RecycleBinPageState extends State<RecycleBinPage> {
@@ -67,12 +83,16 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
final movies = await provider.getDeletedMovies(); final movies = await provider.getDeletedMovies();
final books = await provider.getDeletedBooks(); final books = await provider.getDeletedBooks();
final notes = await provider.getDeletedNotes(); final notes = await provider.getDeletedNotes();
final movieReviews = await provider.getDeletedMovieReviews();
final bookReviews = await provider.getDeletedBookReviews();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_allItems = [ _allItems = [
for (final m in movies) _DeletedItem.movie(m), for (final m in movies) _DeletedItem.movie(m),
for (final b in books) _DeletedItem.book(b), for (final b in books) _DeletedItem.book(b),
for (final n in notes) _DeletedItem.note(n), for (final n in notes) _DeletedItem.note(n),
for (final r in movieReviews) _DeletedItem.movieReview(r),
for (final r in bookReviews) _DeletedItem.bookReview(r),
]; ];
_isLoading = false; _isLoading = false;
}); });
@@ -138,11 +158,14 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
), ),
child: Wrap( child: Wrap(
spacing: 8, spacing: 8,
runSpacing: 8,
children: [ children: [
_filterChip('全部', null), _filterChip('全部', null),
_filterChip('影视', _ItemType.movie), _filterChip('影视', _ItemType.movie),
_filterChip('书籍', _ItemType.book), _filterChip('书籍', _ItemType.book),
_filterChip('笔记', _ItemType.note), _filterChip('笔记', _ItemType.note),
_filterChip('影评', _ItemType.movieReview),
_filterChip('书评', _ItemType.bookReview),
], ],
), ),
); );
@@ -327,6 +350,12 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
case _ItemType.note: case _ItemType.note:
await provider.restoreNote(item.id); await provider.restoreNote(item.id);
if (mounted) ToastUtil.show(context, '笔记已恢复'); if (mounted) ToastUtil.show(context, '笔记已恢复');
case _ItemType.movieReview:
await provider.restoreMovieReview(item.id);
if (mounted) ToastUtil.show(context, '影评已恢复');
case _ItemType.bookReview:
await provider.restoreBookReview(item.id);
if (mounted) ToastUtil.show(context, '书评已恢复');
} }
_loadDeletedItems(); _loadDeletedItems();
} }
@@ -342,6 +371,10 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
await provider.permanentDeleteBook(item.id); await provider.permanentDeleteBook(item.id);
case _ItemType.note: case _ItemType.note:
await provider.permanentDeleteNote(item.id); await provider.permanentDeleteNote(item.id);
case _ItemType.movieReview:
await provider.permanentDeleteMovieReview(item.id);
case _ItemType.bookReview:
await provider.permanentDeleteBookReview(item.id);
} }
_loadDeletedItems(); _loadDeletedItems();
if (mounted) ToastUtil.show(context, '已彻底删除'); if (mounted) ToastUtil.show(context, '已彻底删除');

View File

@@ -21,70 +21,90 @@ class _StatisticsPageState extends State<StatisticsPage> {
bool get _showBooks => UserPrefs().showBookTab; bool get _showBooks => UserPrefs().showBookTab;
bool get _showNotes => UserPrefs().showNoteTab; bool get _showNotes => UserPrefs().showNoteTab;
// 缓存过滤后的列表,避免每次 build 都重新过滤
List<Movie>? _cachedMovies;
List<Book>? _cachedBooks;
List<Note>? _cachedNotes;
List<Movie>? _filteredMovies;
List<Book>? _filteredBooks;
List<Note>? _filteredNotes;
(List<Movie>, List<Book>, List<Note>) _getFilteredLists(
List<Movie> movies, List<Book> books, List<Note> notes) {
if (!identical(movies, _cachedMovies) ||
!identical(books, _cachedBooks) ||
!identical(notes, _cachedNotes)) {
_cachedMovies = movies;
_cachedBooks = books;
_cachedNotes = notes;
_filteredMovies = movies.where((m) => !m.isDeleted).toList();
_filteredBooks = books.where((b) => !b.isDeleted).toList();
_filteredNotes = notes.where((n) => !n.isDeleted).toList();
}
return (_filteredMovies!, _filteredBooks!, _filteredNotes!);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
final movies = context.select<AppProvider, List<Movie>>((p) => p.movies);
final books = context.select<AppProvider, List<Book>>((p) => p.books);
final notes = context.select<AppProvider, List<Note>>((p) => p.notes);
final (fm, fb, fn) = _getFilteredLists(movies, books, notes);
return Scaffold( return Scaffold(
backgroundColor: colors.surface, backgroundColor: colors.surface,
appBar: AppBar(title: const Text('数据统计')), appBar: AppBar(title: const Text('数据统计')),
body: Consumer<AppProvider>( body: ListView(
builder: (context, provider, child) { padding: const EdgeInsets.all(20),
final movies = provider.movies.where((m) => !m.isDeleted).toList(); children: [
final books = provider.books.where((b) => !b.isDeleted).toList(); // 1. 总览
final notes = provider.notes.where((n) => !n.isDeleted).toList(); _buildOverview(fm, fb, fn),
return ListView(
padding: const EdgeInsets.all(20),
children: [
// 1. 总览
_buildOverview(movies, books, notes),
const SizedBox(height: 28), const SizedBox(height: 28),
// 2. 状态分布 // 2. 状态分布
if (_showMovies) ...[ if (_showMovies) ...[
_buildStatusSection('影视状态分布', movies, (m) => m.status, {'已看': 'watched', '在看': 'watching', '想看': 'want_to_watch'}), _buildStatusSection('影视状态分布', fm, (m) => m.status, {'已看': 'watched', '在看': 'watching', '想看': 'want_to_watch'}),
const SizedBox(height: 28), const SizedBox(height: 28),
], ],
if (_showBooks) ...[ if (_showBooks) ...[
_buildStatusSection('阅读状态分布', books, (b) => b.status, {'已读': 'read', '在读': 'reading', '想读': 'want_to_read'}), _buildStatusSection('阅读状态分布', fb, (b) => b.status, {'已读': 'read', '在读': 'reading', '想读': 'want_to_read'}),
const SizedBox(height: 28), const SizedBox(height: 28),
], ],
// 3. 习惯洞察 // 3. 习惯洞察
_buildHabitsInsight(movies, books, notes), _buildHabitsInsight(fm, fb, fn),
const SizedBox(height: 28), const SizedBox(height: 28),
// 4. 类型偏好雷达图 // 4. 类型偏好雷达图
_buildGenreRadar(movies, books), _buildGenreRadar(fm, fb),
const SizedBox(height: 28), const SizedBox(height: 28),
// 5. 导演/作者 TOP 5 // 5. 导演/作者 TOP 5
_buildDirectorTop5(movies), _buildDirectorTop5(fm),
const SizedBox(height: 28), const SizedBox(height: 28),
_buildAuthorTop5(books), _buildAuthorTop5(fb),
const SizedBox(height: 28), const SizedBox(height: 28),
// 6. 高分之最 // 6. 高分之最
_buildTopRated(movies, books), _buildTopRated(fm, fb),
const SizedBox(height: 28), const SizedBox(height: 28),
// 7. 评分分布 // 7. 评分分布
_buildRatingDistribution(movies, books), _buildRatingDistribution(fm, fb),
const SizedBox(height: 28), const SizedBox(height: 28),
// 8. 年度趋势 // 8. 年度趋势
_buildYearlyTrend(movies, books, notes), _buildYearlyTrend(fm, fb, fn),
const SizedBox(height: 28), const SizedBox(height: 28),
// 9. 星期分布 // 9. 星期分布
_buildWeekdayDistribution(movies, books, notes), _buildWeekdayDistribution(fm, fb, fn),
const SizedBox(height: 28), const SizedBox(height: 28),
// 10. 累计增长 // 10. 累计增长
_buildCumulativeGrowth(movies, books, notes), _buildCumulativeGrowth(fm, fb, fn),
const SizedBox(height: 28), const SizedBox(height: 28),
// 11. 标签词云 // 11. 标签词云
_buildTagCloud(movies, books, notes), _buildTagCloud(fm, fb, fn),
const SizedBox(height: 28), const SizedBox(height: 28),
// 12+13. 马拉松 + 标签之最 // 12+13. 马拉松 + 标签之最
_buildFunStats(movies, books, notes), _buildFunStats(fm, fb, fn),
const SizedBox(height: 80), const SizedBox(height: 80),
], ],
); ),
}, );
),
);
} }
// ─── 1. 总览区域 ────────────────────────────────────────────────────── // ─── 1. 总览区域 ──────────────────────────────────────────────────────

View File

@@ -1,3 +1,4 @@
import 'dart:collection';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/data_models.dart'; import '../models/data_models.dart';
import '../utils/movie/movie_dao.dart'; import '../utils/movie/movie_dao.dart';
@@ -147,9 +148,9 @@ class AppProvider extends ChangeNotifier {
ThemeMode get themeMode => _themeMode; ThemeMode get themeMode => _themeMode;
int get colorSchemeIndex => _colorSchemeIndex; int get colorSchemeIndex => _colorSchemeIndex;
String get fontFamily => _fontFamily; String get fontFamily => _fontFamily;
List<Movie> get movies => _movies; List<Movie> get movies => UnmodifiableListView(_movies);
List<Book> get books => _books; List<Book> get books => UnmodifiableListView(_books);
List<Note> get notes => _notes; List<Note> get notes => UnmodifiableListView(_notes);
// 根据状态获取影视列表 // 根据状态获取影视列表
List<Movie> getMoviesByStatus(String status) { List<Movie> getMoviesByStatus(String status) {
@@ -490,6 +491,8 @@ class AppProvider extends ChangeNotifier {
final deletedMovies = await getDeletedMovies(); final deletedMovies = await getDeletedMovies();
final deletedBooks = await getDeletedBooks(); final deletedBooks = await getDeletedBooks();
final deletedNotes = await getDeletedNotes(); final deletedNotes = await getDeletedNotes();
final deletedMovieReviews = await getDeletedMovieReviews();
final deletedBookReviews = await getDeletedBookReviews();
for (final movie in deletedMovies) { for (final movie in deletedMovies) {
await permanentDeleteMovie(movie.id); await permanentDeleteMovie(movie.id);
@@ -500,12 +503,44 @@ class AppProvider extends ChangeNotifier {
for (final note in deletedNotes) { for (final note in deletedNotes) {
await permanentDeleteNote(note.id); await permanentDeleteNote(note.id);
} }
for (final review in deletedMovieReviews) {
await _reviewDao.permanentDeleteReview(review.id);
}
for (final review in deletedBookReviews) {
await _bookReviewDao.permanentDeleteReview(review.id);
}
await loadMovies(); await loadMovies();
await loadBooks(); await loadBooks();
await loadNotes(); await loadNotes();
} }
// ========== 影评书评回收站 ==========
Future<List<MovieReview>> getDeletedMovieReviews() async {
return await _reviewDao.getDeletedReviews();
}
Future<void> restoreMovieReview(String id) async {
await _reviewDao.restoreReview(id);
}
Future<void> permanentDeleteMovieReview(String id) async {
await _reviewDao.permanentDeleteReview(id);
}
Future<List<BookReview>> getDeletedBookReviews() async {
return await _bookReviewDao.getDeletedReviews();
}
Future<void> restoreBookReview(String id) async {
await _bookReviewDao.restoreReview(id);
}
Future<void> permanentDeleteBookReview(String id) async {
await _bookReviewDao.permanentDeleteReview(id);
}
// ========== 标签管理方法 ========== // ========== 标签管理方法 ==========
Future<List<Map<String, dynamic>>> getTags(String type, {bool excludeHidden = false}) async { Future<List<Map<String, dynamic>>> getTags(String type, {bool excludeHidden = false}) async {

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -7,7 +8,7 @@ import '../models/data_models.dart';
class DatabaseHelper { class DatabaseHelper {
static final DatabaseHelper instance = DatabaseHelper._init(); static final DatabaseHelper instance = DatabaseHelper._init();
static Database? _database; static Database? _database;
static bool _isReopening = false; static Completer<void>? _reopenCompleter;
DatabaseHelper._init(); DatabaseHelper._init();
@@ -19,24 +20,30 @@ class DatabaseHelper {
/// 重新打开数据库(用于 WebDAV 同步后) /// 重新打开数据库(用于 WebDAV 同步后)
Future<void> reopenDatabase() async { Future<void> reopenDatabase() async {
// 防止并发重开 // 如果已有重开在进行,等待它完成即可
if (_isReopening) return; if (_reopenCompleter != null) {
_isReopening = true; return _reopenCompleter!.future;
}
_reopenCompleter = Completer<void>();
try { try {
if (_database != null) { if (_database != null) {
await _database!.close(); await _database!.close();
_database = null; _database = null;
} }
_database = await _initDB('mooknote.db'); _database = await _initDB('mooknote.db');
_reopenCompleter!.complete();
} catch (e) {
_reopenCompleter!.completeError(e);
rethrow;
} finally { } finally {
_isReopening = false; _reopenCompleter = null;
} }
} }
Future<Database> get database async { Future<Database> get database async {
// 等待重开完成 // 如果正在重开,等待完成(无忙等待)
while (_isReopening) { if (_reopenCompleter != null) {
await Future.delayed(const Duration(milliseconds: 50)); await _reopenCompleter!.future;
} }
if (_database != null) return _database!; if (_database != null) return _database!;
_database = await _initDB('mooknote.db'); _database = await _initDB('mooknote.db');
@@ -49,7 +56,7 @@ class DatabaseHelper {
return await openDatabase( return await openDatabase(
path, path,
version: 24, version: 25,
onCreate: _createDB, onCreate: _createDB,
onUpgrade: _onUpgrade, onUpgrade: _onUpgrade,
); );
@@ -215,6 +222,13 @@ class DatabaseHelper {
await db.execute("ALTER TABLE reader_books ADD COLUMN book_id TEXT DEFAULT ''"); await db.execute("ALTER TABLE reader_books ADD COLUMN book_id TEXT DEFAULT ''");
} }
} }
if (oldVersion < 25) {
// movies 添加 category 列(影视分类)
final cols = await db.rawQuery('PRAGMA table_info(movies)');
if (!cols.any((col) => col['name'] == 'category')) {
await db.execute("ALTER TABLE movies ADD COLUMN category TEXT NOT NULL DEFAULT 'movie'");
}
}
} }
/// 升级books表到V11添加ISBN和出版时间字段 /// 升级books表到V11添加ISBN和出版时间字段
@@ -531,12 +545,13 @@ class DatabaseHelper {
summary TEXT, summary TEXT,
rating REAL, rating REAL,
status TEXT NOT NULL, status TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'movie',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
is_deleted INTEGER DEFAULT 0 is_deleted INTEGER DEFAULT 0
) )
'''); ''');
// 迁移旧数据(尽可能保留) // 迁移旧数据(尽可能保留)
for (final row in oldData) { for (final row in oldData) {
try { try {
@@ -585,6 +600,7 @@ class DatabaseHelper {
summary TEXT, summary TEXT,
rating REAL, rating REAL,
status TEXT NOT NULL, status TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'movie',
watch_date TEXT, watch_date TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,

View File

@@ -22,9 +22,9 @@ class EpubService {
// 复制 EPUB 到永久存储FilePicker 临时文件会被清理) // 复制 EPUB 到永久存储FilePicker 临时文件会被清理)
final appDir = await getApplicationDocumentsDirectory(); final appDir = await getApplicationDocumentsDirectory();
final booksDir = Directory(p.join(appDir.path, 'epub_books')); final bookDir = Directory(p.join(appDir.path, 'epub_books', bookId));
if (!await booksDir.exists()) await booksDir.create(recursive: true); if (!await bookDir.exists()) await bookDir.create(recursive: true);
final permanentPath = p.join(booksDir.path, '$bookId.epub'); final permanentPath = p.join(bookDir.path, 'book.epub');
await File(sourcePath).copy(permanentPath); await File(sourcePath).copy(permanentPath);
// 从永久副本解析 // 从永久副本解析
@@ -97,9 +97,9 @@ class EpubService {
final coverFile = File(p.join(extractDir, coverRelPath)); final coverFile = File(p.join(extractDir, coverRelPath));
if (!await coverFile.exists()) return null; if (!await coverFile.exists()) return null;
// 保存到应用文档目录 // 保存到 epub_books/{bookId}/ 目录
final appDir = await getApplicationDocumentsDirectory(); final appDir = await getApplicationDocumentsDirectory();
final coverDir = p.join(appDir.path, 'images', 'books', bookId); final coverDir = p.join(appDir.path, 'epub_books', bookId);
await Directory(coverDir).create(recursive: true); await Directory(coverDir).create(recursive: true);
final ext = p.extension(coverFile.path).toLowerCase(); final ext = p.extension(coverFile.path).toLowerCase();
final destPath = p.join(coverDir, 'cover$ext'); final destPath = p.join(coverDir, 'cover$ext');
@@ -142,19 +142,11 @@ class EpubService {
if (await dir.exists()) await dir.delete(recursive: true); if (await dir.exists()) await dir.delete(recursive: true);
} catch (_) {} } catch (_) {}
// 清理封面 // 清理 epub_books/{bookId}/ 目录epub + 封面
try { try {
final appDir = await getApplicationDocumentsDirectory(); final appDir = await getApplicationDocumentsDirectory();
final coverDir = p.join(appDir.path, 'images', 'books', bookId); final bookDir = Directory(p.join(appDir.path, 'epub_books', bookId));
final dir = Directory(coverDir); if (await bookDir.exists()) await bookDir.delete(recursive: true);
if (await dir.exists()) await dir.delete(recursive: true);
} catch (_) {}
// 清理永久 EPUB 文件
try {
final appDir = await getApplicationDocumentsDirectory();
final epubFile = File(p.join(appDir.path, 'epub_books', '$bookId.epub'));
if (await epubFile.exists()) await epubFile.delete();
} catch (_) {} } catch (_) {}
// 软删除数据库记录 // 软删除数据库记录

View File

@@ -1,6 +1,35 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'reader_scripts.dart'; import 'reader_scripts.dart';
/// 阅读器主题预设
class ReaderThemePreset {
final String name;
final Color surface;
final Color onSurface;
final bool isDark;
const ReaderThemePreset({
required this.name,
required this.surface,
required this.onSurface,
this.isDark = false,
});
}
class ReaderThemePresets {
static const List<ReaderThemePreset> presets = [
ReaderThemePreset(name: '跟随App', surface: Colors.white, onSurface: Colors.black),
ReaderThemePreset(name: '纯白', surface: Color(0xFFFFFFFF), onSurface: Color(0xFF1A1A1A)),
ReaderThemePreset(name: '护眼', surface: Color(0xFFF4ECD8), onSurface: Color(0xFF5B4636)),
ReaderThemePreset(name: '抹茶', surface: Color(0xFFF6FBF5), onSurface: Color(0xFF2E3E2E)),
ReaderThemePreset(name: '樱花', surface: Color(0xFFFFF8F8), onSurface: Color(0xFF4A2030)),
ReaderThemePreset(name: '午夜蓝', surface: Color(0xFFF7F9FC), onSurface: Color(0xFF1A2A3A)),
ReaderThemePreset(name: '深色', surface: Color(0xFF191919), onSurface: Color(0xFFD4D4D4), isDark: true),
ReaderThemePreset(name: '深色护眼', surface: Color(0xFF1C1A18), onSurface: Color(0xFFC8B8A0), isDark: true),
ReaderThemePreset(name: '咖啡', surface: Color(0xFFFCF8F3), onSurface: Color(0xFF3E2E1E)),
];
}
class EpubTheme { class EpubTheme {
final double zoom; final double zoom;
final bool shouldOverrideTextColor; final bool shouldOverrideTextColor;

View File

@@ -133,4 +133,32 @@ class ReaderDao {
final db = await _db.database; final db = await _db.database;
return db.delete('book_annotations', where: 'id = ?', whereArgs: [id]); return db.delete('book_annotations', where: 'id = ?', whereArgs: [id]);
} }
// ─── bookmarks ──────────────────────────────────────────────────
/// 获取某本书的所有书签
Future<List<Map<String, dynamic>>> getBookmarksByBookId(String bookId) async {
final db = await _db.database;
return db.query(
'book_annotations',
where: 'book_id = ? AND type = ?',
whereArgs: [bookId, 'bookmark'],
orderBy: 'created_at DESC',
);
}
/// 插入书签
Future<int> insertBookmark(Map<String, dynamic> bookmark) async {
final db = await _db.database;
return db.insert('book_annotations', {
...bookmark,
'type': 'bookmark',
});
}
/// 删除书签
Future<int> deleteBookmark(int id) async {
final db = await _db.database;
return db.delete('book_annotations', where: 'id = ?', whereArgs: [id]);
}
} }

View File

@@ -28,6 +28,15 @@ class ReaderSettings {
/// When true, volume up/down keys turn pages in the reader. /// When true, volume up/down keys turn pages in the reader.
final bool volumeKeyTurnsPage; final bool volumeKeyTurnsPage;
/// Reader theme preset index (0 = follow app, 1-8 = presets, 9 = custom).
final int themeIndex;
/// Custom background color (ARGB int), used when themeIndex == 9.
final int customBgColor;
/// Custom text color (ARGB int), used when themeIndex == 9.
final int customTextColor;
const ReaderSettings({ const ReaderSettings({
this.zoom = 1.0, this.zoom = 1.0,
this.followAppTheme = true, this.followAppTheme = true,
@@ -40,6 +49,9 @@ class ReaderSettings {
this.fontFileName, this.fontFileName,
this.overrideFontFamily = false, this.overrideFontFamily = false,
this.volumeKeyTurnsPage = false, this.volumeKeyTurnsPage = false,
this.themeIndex = 0,
this.customBgColor = 0xFFFFFFFF,
this.customTextColor = 0xFF1A1A1A,
}); });
// Sentinel: lets copyWith(fontFileName: null) mean "set to null" rather than // Sentinel: lets copyWith(fontFileName: null) mean "set to null" rather than
@@ -58,6 +70,9 @@ class ReaderSettings {
Object? fontFileName = _kUnset, Object? fontFileName = _kUnset,
bool? overrideFontFamily, bool? overrideFontFamily,
bool? volumeKeyTurnsPage, bool? volumeKeyTurnsPage,
int? themeIndex,
int? customBgColor,
int? customTextColor,
}) { }) {
return ReaderSettings( return ReaderSettings(
zoom: zoom ?? this.zoom, zoom: zoom ?? this.zoom,
@@ -73,21 +88,78 @@ class ReaderSettings {
: fontFileName as String?, : fontFileName as String?,
overrideFontFamily: overrideFontFamily ?? this.overrideFontFamily, overrideFontFamily: overrideFontFamily ?? this.overrideFontFamily,
volumeKeyTurnsPage: volumeKeyTurnsPage ?? this.volumeKeyTurnsPage, volumeKeyTurnsPage: volumeKeyTurnsPage ?? this.volumeKeyTurnsPage,
themeIndex: themeIndex ?? this.themeIndex,
customBgColor: customBgColor ?? this.customBgColor,
customTextColor: customTextColor ?? this.customTextColor,
); );
} }
EpubTheme toEpubTheme(BuildContext context) { EpubTheme toEpubTheme(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; ColorScheme colorScheme;
bool shouldOverride = true;
if (themeIndex == 0) {
// 跟随 App 主题
colorScheme = Theme.of(context).colorScheme;
} else {
Color bg;
Color text;
bool isDark;
if (themeIndex == 9) {
// 自定义颜色
bg = Color(customBgColor);
text = Color(customTextColor);
isDark = ThemeData.estimateBrightnessForColor(bg) == Brightness.dark;
} else if (themeIndex >= 1 &&
themeIndex <= ReaderThemePresets.presets.length) {
final preset = ReaderThemePresets.presets[themeIndex];
bg = preset.surface;
text = preset.onSurface;
isDark = preset.isDark;
} else {
colorScheme = Theme.of(context).colorScheme;
return EpubTheme(
zoom: zoom,
shouldOverrideTextColor: true,
colorScheme: colorScheme,
padding: EdgeInsets.only(
top: marginTop, bottom: marginBottom,
left: marginLeft, right: marginRight,
),
fontFileName: fontFileName,
overrideFontFamily: overrideFontFamily,
);
}
colorScheme = ColorScheme(
brightness: isDark ? Brightness.dark : Brightness.light,
primary: text,
onPrimary: bg,
secondary: text,
onSecondary: bg,
error: const Color(0xFFDC2626),
onError: bg,
surface: bg,
onSurface: text,
surfaceContainerHighest: isDark ? const Color(0xFF2A2A2A) : const Color(0xFFF0F0F0),
surfaceContainerHigh: isDark ? const Color(0xFF222222) : const Color(0xFFFAFAFA),
surfaceContainer: isDark ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5),
surfaceContainerLow: isDark ? const Color(0xFF1A1A1A) : const Color(0xFFFAFAFA),
outline: isDark ? const Color(0xFF444444) : const Color(0xFFCCCCCC),
outlineVariant: isDark ? const Color(0xFF333333) : const Color(0xFFE5E5E5),
onSurfaceVariant: isDark ? const Color(0xFFAAAAAA) : const Color(0xFF666666),
primaryContainer: isDark ? const Color(0xFF2A2A2A) : const Color(0xFFF0F0F0),
);
}
return EpubTheme( return EpubTheme(
zoom: zoom, zoom: zoom,
shouldOverrideTextColor: true, shouldOverrideTextColor: shouldOverride,
colorScheme: colorScheme, colorScheme: colorScheme,
padding: EdgeInsets.only( padding: EdgeInsets.only(
top: marginTop, top: marginTop, bottom: marginBottom,
bottom: marginBottom, left: marginLeft, right: marginRight,
left: marginLeft,
right: marginRight,
), ),
fontFileName: fontFileName, fontFileName: fontFileName,
overrideFontFamily: overrideFontFamily, overrideFontFamily: overrideFontFamily,
@@ -115,6 +187,9 @@ class ReaderSettings {
} }
await prefs.setBool('${_kPrefix}overrideFontFamily', overrideFontFamily); await prefs.setBool('${_kPrefix}overrideFontFamily', overrideFontFamily);
await prefs.setBool('${_kPrefix}volumeKeyTurnsPage', volumeKeyTurnsPage); await prefs.setBool('${_kPrefix}volumeKeyTurnsPage', volumeKeyTurnsPage);
await prefs.setInt('${_kPrefix}themeIndex', themeIndex);
await prefs.setInt('${_kPrefix}customBgColor', customBgColor);
await prefs.setInt('${_kPrefix}customTextColor', customTextColor);
} }
static Future<ReaderSettings> load() async { static Future<ReaderSettings> load() async {

View File

@@ -100,4 +100,36 @@ class MovieReviewDao {
); );
return List.generate(maps.length, (i) => MovieReview.fromJson(maps[i])); return List.generate(maps.length, (i) => MovieReview.fromJson(maps[i]));
}); });
/// 获取所有已删除的影评
Future<List<MovieReview>> getDeletedReviews() => _wrap('getDeletedReviews', () async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'movie_reviews',
where: 'is_deleted = 1',
orderBy: 'updated_at DESC',
);
return List.generate(maps.length, (i) => MovieReview.fromJson(maps[i]));
});
/// 恢复已删除的影评
Future<void> restoreReview(String id) => _wrap('restoreReview', () async {
final db = await _dbHelper.database;
await db.update(
'movie_reviews',
{'is_deleted': 0},
where: 'id = ?',
whereArgs: [id],
);
});
/// 彻底删除影评
Future<void> permanentDeleteReview(String id) => _wrap('permanentDeleteReview', () async {
final db = await _dbHelper.database;
await db.delete(
'movie_reviews',
where: 'id = ?',
whereArgs: [id],
);
});
} }

View File

@@ -105,35 +105,54 @@ class AutoBackupService {
/// 获取备份目录(下载目录/mooknote /// 获取备份目录(下载目录/mooknote
Future<Directory?> _getBackupDirectory() async { Future<Directory?> _getBackupDirectory() async {
try { try {
// 尝试获取下载目录
Directory? downloadDir;
if (Platform.isAndroid) { if (Platform.isAndroid) {
// Android: 使用外部存储的下载目录 // 优先级 1: 官方 API 获取下载目录
try {
final dirs = await getExternalStorageDirectories(
type: StorageDirectory.downloads,
);
if (dirs != null && dirs.isNotEmpty) {
return Directory('${dirs.first.path}/$_backupDirName');
}
} catch (_) {}
// 优先级 2: 标准路径直接拼
final standardPath = '/storage/emulated/0/Download/$_backupDirName';
final standardDir = Directory(standardPath);
if (await standardDir.parent.exists()) {
return standardDir;
}
// 优先级 3: 从外部存储路径推导(旧逻辑兜底)
final externalDir = await getExternalStorageDirectory(); final externalDir = await getExternalStorageDirectory();
if (externalDir != null) { if (externalDir != null) {
// 通常路径是 /storage/emulated/0/Android/data/.../files final segments = externalDir.uri.pathSegments;
// 我们需要找到真正的下载目录 if (segments.length >= 3) {
final path = externalDir.path; final pkg = segments[segments.length - 3];
final downloadPath = path.replaceAll( final downloadPath = externalDir.path.replaceAll(
'/Android/data/${externalDir.uri.pathSegments[externalDir.uri.pathSegments.length - 3]}/files', '/Android/data/$pkg/files',
'/Download', '/Download',
); );
downloadDir = Directory('$downloadPath/$_backupDirName'); return Directory('$downloadPath/$_backupDirName');
}
} }
// 优先级 4: 降级到 app 内部目录
final appDir = await getApplicationDocumentsDirectory();
return Directory('${appDir.path}/$_backupDirName');
} else if (Platform.isIOS) { } else if (Platform.isIOS) {
// iOS: 使用文档目录
final docDir = await getApplicationDocumentsDirectory(); final docDir = await getApplicationDocumentsDirectory();
downloadDir = Directory('${docDir.path}/$_backupDirName'); return Directory('${docDir.path}/$_backupDirName');
} else { } else {
// 桌面端: 使用下载目录 // 桌面端
final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
if (home != null) { if (home != null) {
downloadDir = Directory('$home/Downloads/$_backupDirName'); return Directory('$home/Downloads/$_backupDirName');
} }
final docDir = await getApplicationDocumentsDirectory();
return Directory('${docDir.path}/$_backupDirName');
} }
return downloadDir;
} catch (e) { } catch (e) {
debugPrint('AutoBackup: 获取备份目录失败 - $e'); debugPrint('AutoBackup: 获取备份目录失败 - $e');
return null; return null;

View File

@@ -1,6 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:archive/archive.dart';
import 'package:archive/archive_io.dart'; import 'package:archive/archive_io.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -89,57 +88,73 @@ class BackupService {
}, },
}; };
// 创建 ZIP // 创建 ZIP(逐文件写入磁盘,避免全部加载到内存)
final archive = Archive(); final tempDir = await getTemporaryDirectory();
final jsonString = const JsonEncoder.withIndent(' ').convert(backupData); final tempZipPath = path.join(tempDir.path, 'mooknote_backup_temp.zip');
final jsonBytes = Uint8List.fromList(utf8.encode(jsonString)); final encoder = ZipFileEncoder();
archive.addFile(ArchiveFile('data.json', jsonBytes.length, jsonBytes)); encoder.create(tempZipPath);
int imageCount = 0; try {
final appDir = await getApplicationDocumentsDirectory(); // data.json
final imagesRoot = path.join(appDir.path, 'images'); final jsonString = const JsonEncoder.withIndent(' ').convert(backupData);
final jsonBytes = Uint8List.fromList(utf8.encode(jsonString));
final dataFile = File(path.join(tempDir.path, 'mooknote_data.json'));
await dataFile.writeAsBytes(jsonBytes);
encoder.addFile(dataFile, 'data.json');
await dataFile.delete();
for (final imagePath in imagePaths) { int imageCount = 0;
final file = File(imagePath); final appDir = await getApplicationDocumentsDirectory();
if (await file.exists()) { final imagesRoot = path.join(appDir.path, 'images');
final bytes = await file.readAsBytes();
String relativePath;
if (imagePath.startsWith(imagesRoot)) {
relativePath = imagePath.substring(imagesRoot.length + 1);
} else {
relativePath = path.basename(imagePath);
}
archive.addFile(ArchiveFile('images/$relativePath', bytes.length, bytes));
imageCount++;
}
}
// 收集 epub_books 目录下的 epub 文件 for (final imagePath in imagePaths) {
int epubCount = 0; final file = File(imagePath);
final epubRoot = path.join(appDir.path, 'epub_books'); if (await file.exists()) {
final epubDir = Directory(epubRoot); String relativePath;
if (await epubDir.exists()) { if (imagePath.startsWith(imagesRoot)) {
await for (final entity in epubDir.list(recursive: true)) { relativePath = imagePath.substring(imagesRoot.length + 1);
if (entity is File) { } else {
final bytes = await entity.readAsBytes(); relativePath = path.basename(imagePath);
final relativePath = entity.path.substring(epubRoot.length + 1); }
archive.addFile(ArchiveFile('epub_books/$relativePath', bytes.length, bytes)); encoder.addFile(file, 'images/$relativePath');
epubCount++; imageCount++;
} }
} }
// 收集 epub_books 目录下的 epub 文件
int epubCount = 0;
final epubRoot = path.join(appDir.path, 'epub_books');
final epubDir = Directory(epubRoot);
if (await epubDir.exists()) {
await for (final entity in epubDir.list(recursive: true)) {
if (entity is File) {
final relativePath = entity.path.substring(epubRoot.length + 1);
encoder.addFile(entity, 'epub_books/$relativePath');
epubCount++;
}
}
}
encoder.close();
// 读取最终 zip 文件
final zipFile = File(tempZipPath);
final zipBytes = await zipFile.readAsBytes();
await zipFile.delete();
return _ExportData(
zipBytes: Uint8List.fromList(zipBytes),
movieCount: movies.length,
bookCount: books.length,
noteCount: notes.length,
imageCount: imageCount,
epubCount: epubCount,
);
} catch (e) {
encoder.close();
try { await File(tempZipPath).delete(); } catch (_) {}
rethrow;
} }
final zipBytes = ZipEncoder().encode(archive);
if (zipBytes == null) throw Exception('压缩备份文件失败');
return _ExportData(
zipBytes: Uint8List.fromList(zipBytes),
movieCount: movies.length,
bookCount: books.length,
noteCount: notes.length,
imageCount: imageCount,
epubCount: epubCount,
);
} }
// ─── 手动导出 ───────────────────────────────────────── // ─── 手动导出 ─────────────────────────────────────────

View File

@@ -311,11 +311,16 @@ class WebDAVService {
} }
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastSyncKey, DateTime.now().toIso8601String());
// 仅在上传成功或下载成功时记录同步时间
final bool anySuccess = uploadedFiles > 0 || downloadedFiles > 0;
if (anySuccess) {
await prefs.setString(_lastSyncKey, DateTime.now().toIso8601String());
}
return SyncResult( return SyncResult(
success: true, success: anySuccess,
message: '同步完成', message: anySuccess ? '同步完成' : '同步未完成,未传输任何数据',
lastSyncTime: DateTime.now(), lastSyncTime: DateTime.now(),
uploadedFiles: uploadedFiles, uploadedFiles: uploadedFiles,
downloadedFiles: downloadedFiles, downloadedFiles: downloadedFiles,

View File

@@ -127,6 +127,44 @@ class AppTheme {
selectedLabelStyle: TextStyle(fontFamily: _fontFamily, fontSize: 11, fontWeight: _medium), selectedLabelStyle: TextStyle(fontFamily: _fontFamily, fontSize: 11, fontWeight: _medium),
unselectedLabelStyle: TextStyle(fontFamily: _fontFamily, fontSize: 11, fontWeight: _regular, color: scheme.onSurfaceVariant), unselectedLabelStyle: TextStyle(fontFamily: _fontFamily, fontSize: 11, fontWeight: _regular, color: scheme.onSurfaceVariant),
), ),
textTheme: TextTheme(
headlineLarge: TextStyle(
fontFamily: _fontFamily, fontSize: 32, fontWeight: _semibold,
color: scheme.onSurface, letterSpacing: 0, height: 1.2,
),
headlineMedium: TextStyle(
fontFamily: _fontFamily, fontSize: 24, fontWeight: _semibold,
color: scheme.onSurface, letterSpacing: 0, height: 1.3,
),
headlineSmall: TextStyle(
fontFamily: _fontFamily, fontSize: 20, fontWeight: _semibold,
color: scheme.onSurface, letterSpacing: 0, height: 1.4,
),
bodyLarge: TextStyle(
fontFamily: _fontFamily, fontSize: 16, fontWeight: _regular,
color: scheme.onSurface, height: 1.6,
),
bodyMedium: TextStyle(
fontFamily: _fontFamily, fontSize: 15, fontWeight: _regular,
color: scheme.onSurface, height: 1.5,
),
bodySmall: TextStyle(
fontFamily: _fontFamily, fontSize: 13, fontWeight: _regular,
color: scheme.onSurfaceVariant, height: 1.5,
),
labelLarge: TextStyle(
fontFamily: _fontFamily, fontSize: 14, fontWeight: _medium,
color: scheme.onSurface,
),
labelMedium: TextStyle(
fontFamily: _fontFamily, fontSize: 12, fontWeight: _medium,
color: scheme.onSurfaceVariant,
),
labelSmall: TextStyle(
fontFamily: _fontFamily, fontSize: 11, fontWeight: _medium,
color: scheme.onSurfaceVariant, letterSpacing: 0.3,
),
),
); );
} }