From 5f434ae3fa1642f0b012d57c967d5e48d2583994 Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Sun, 28 Jun 2026 02:25:07 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=BC=98=E5=8C=96=EF=BC=8Cep?= =?UTF-8?q?ub=E9=98=85=E8=AF=BB=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/models/data_models.dart | 6 + lib/models/note_plus_models.dart | 13 +- lib/pages/book/book_excerpts_page.dart | 17 +- lib/pages/book/book_review_detail_page.dart | 43 +- lib/pages/book/book_review_form_page.dart | 457 ++++++++++-------- lib/pages/book/book_reviews_page.dart | 17 +- lib/pages/epub_reader/book_link_page.dart | 5 +- lib/pages/epub_reader/control_panel.dart | 29 ++ lib/pages/epub_reader/epub_detail_page.dart | 220 +++++---- lib/pages/epub_reader/epub_edit_page.dart | 338 +++++++++++++ lib/pages/epub_reader/epub_library_page.dart | 123 ++++- lib/pages/epub_reader/reader_screen.dart | 107 ++++ lib/pages/epub_reader/reader_style_sheet.dart | 241 +++++++++ lib/pages/epub_reader/toc_drawer.dart | 212 +++++++- .../epub_reader/widgets/book_grid_item.dart | 24 +- lib/pages/home_page.dart | 46 +- lib/pages/movies/movie_detail_page.dart | 31 ++ lib/pages/movies/movie_form_page.dart | 53 ++ lib/pages/movies/movie_posters_page.dart | 17 +- .../movies/movie_review_detail_page.dart | 16 +- lib/pages/movies/movie_reviews_page.dart | 17 +- lib/pages/note_plus/note_plus_form_page.dart | 2 +- lib/pages/recycle_bin_page.dart | 35 +- lib/pages/statistics_page.dart | 76 +-- lib/providers/app_provider.dart | 43 +- lib/utils/database_helper.dart | 36 +- lib/utils/epub/epub_service.dart | 24 +- lib/utils/epub/epub_theme.dart | 29 ++ lib/utils/epub/reader_dao.dart | 28 ++ lib/utils/epub/reader_settings.dart | 87 +++- lib/utils/movie/movie_review_dao.dart | 32 ++ lib/utils/sync/auto_backup_service.dart | 55 ++- lib/utils/sync/backup_service.dart | 107 ++-- lib/utils/sync/webdav_service.dart | 11 +- lib/utils/theme/app_theme.dart | 38 ++ 35 files changed, 2110 insertions(+), 525 deletions(-) create mode 100644 lib/pages/epub_reader/epub_edit_page.dart diff --git a/lib/models/data_models.dart b/lib/models/data_models.dart index 6187da3..b9c2a10 100644 --- a/lib/models/data_models.dart +++ b/lib/models/data_models.dart @@ -46,6 +46,7 @@ class Movie { final String? summary; // 剧情简介 final double? rating; // 评分 1-10 final String status; // watched/want_to_watch/watching + final String category; // 影视分类: movie/tv/anime/variety/documentary/short final DateTime? watchDate; // 观看日期 final DateTime createdAt; final DateTime updatedAt; @@ -65,6 +66,7 @@ class Movie { this.summary, this.rating, required this.status, + this.category = 'movie', this.watchDate, required this.createdAt, required this.updatedAt, @@ -86,6 +88,7 @@ class Movie { summary: json['summary'], rating: json['rating']?.toDouble(), status: json['status'] ?? 'want_to_watch', + category: json['category'] ?? 'movie', watchDate: _safeParseDate(json['watch_date']), createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!, updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!, @@ -108,6 +111,7 @@ class Movie { 'summary': summary, 'rating': rating, 'status': status, + 'category': category, 'watch_date': watchDate?.toUtc().toIso8601String(), 'created_at': createdAt.toUtc().toIso8601String(), 'updated_at': updatedAt.toUtc().toIso8601String(), @@ -142,6 +146,7 @@ class Movie { Object? summary = _copyWithNull, Object? rating = _copyWithNull, String? status, + String? category, DateTime? watchDate, DateTime? createdAt, DateTime? updatedAt, @@ -161,6 +166,7 @@ class Movie { summary: summary is _CopyWithNullSentinel ? this.summary : (summary as String?), rating: rating is _CopyWithNullSentinel ? this.rating : (rating as double?), status: status ?? this.status, + category: category ?? this.category, watchDate: watchDate ?? this.watchDate, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, diff --git a/lib/models/note_plus_models.dart b/lib/models/note_plus_models.dart index a5fa61e..e893091 100644 --- a/lib/models/note_plus_models.dart +++ b/lib/models/note_plus_models.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:uuid/uuid.dart'; /// 块类型枚举 @@ -220,9 +221,15 @@ class NotePlusDocument { /// 从 JSON(DB 行)创建 factory NotePlusDocument.fromJson(Map json) { final blocksJson = json['blocks_json'] as String? ?? '[]'; - final blocksList = (jsonDecode(blocksJson) as List) - .map((b) => NoteBlock.fromJson(b as Map)) - .toList(); + List blocksList; + try { + blocksList = (jsonDecode(blocksJson) as List) + .map((b) => NoteBlock.fromJson(b as Map)) + .toList(); + } catch (e) { + debugPrint('[NotePlusDocument] blocks_json 解析失败,使用默认空文档: $e'); + blocksList = []; + } return NotePlusDocument( id: json['id'] as String?, diff --git a/lib/pages/book/book_excerpts_page.dart b/lib/pages/book/book_excerpts_page.dart index 3fb4d42..1b960a1 100644 --- a/lib/pages/book/book_excerpts_page.dart +++ b/lib/pages/book/book_excerpts_page.dart @@ -296,24 +296,31 @@ class _BookExcerptsPageState extends State { return AlertDialog( backgroundColor: colors.surface, elevation: 0, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), - title: const Text('确认删除'), - content: const Text('确定要删除这条摘抄吗?'), + 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(context), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), ), - TextButton( + ElevatedButton( onPressed: () async { await context.read().removeBookExcerpt(excerpt.id); Navigator.pop(context); _loadExcerpts(); 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), ); }, ); diff --git a/lib/pages/book/book_review_detail_page.dart b/lib/pages/book/book_review_detail_page.dart index de66ab0..9935b14 100644 --- a/lib/pages/book/book_review_detail_page.dart +++ b/lib/pages/book/book_review_detail_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../models/data_models.dart'; import '../../providers/app_provider.dart'; +import '../../utils/toast_util.dart'; import 'book_review_form_page.dart'; /// 书评详情页 @@ -55,12 +56,15 @@ class _BookReviewDetailPageState extends State { appBar: AppBar( title: const Text('书评详情'), actions: [ - // 编辑按钮 IconButton( icon: const Icon(Icons.edit_outlined), 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( @@ -168,6 +172,41 @@ class _BookReviewDetailPageState extends State { ); } + Future _deleteReview() async { + final colors = Theme.of(context).colorScheme; + final confirmed = await 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: 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().removeBookReview(_review.id); + if (mounted) { ToastUtil.show(context, '已删除'); Navigator.pop(context); } + } + } + String _formatDate(DateTime date) { return '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}'; } diff --git a/lib/pages/book/book_review_form_page.dart b/lib/pages/book/book_review_form_page.dart index 3e43e43..13be280 100644 --- a/lib/pages/book/book_review_form_page.dart +++ b/lib/pages/book/book_review_form_page.dart @@ -1,20 +1,17 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../providers/app_provider.dart'; +import '../../widgets/fade_in_local_image.dart'; import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; -import 'package:uuid/uuid.dart'; -/// 添加/编辑书评页面 - 极简设计 +/// 添加/编辑书评页面 class BookReviewFormPage extends StatefulWidget { final String bookId; final BookReview? review; - const BookReviewFormPage({ - super.key, - required this.bookId, - this.review, - }); + const BookReviewFormPage({super.key, required this.bookId, this.review}); @override State createState() => _BookReviewFormPageState(); @@ -30,11 +27,10 @@ class _BookReviewFormPageState extends State { @override void initState() { super.initState(); - final review = widget.review; - _contentController = TextEditingController(text: review?.content ?? ''); - _reviewerController = TextEditingController(text: review?.reviewer ?? ''); - _sourceController = TextEditingController(text: review?.source ?? ''); - _reviewType = review?.reviewType ?? 1; + _contentController = TextEditingController(text: widget.review?.content ?? ''); + _reviewerController = TextEditingController(text: widget.review?.reviewer ?? ''); + _sourceController = TextEditingController(text: widget.review?.source ?? ''); + _reviewType = widget.review?.reviewType ?? 1; } @override @@ -45,107 +41,90 @@ class _BookReviewFormPageState extends State { super.dispose(); } + Book? _getBook() { + return context.read().books.where((b) => b.id == widget.bookId).firstOrNull; + } + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final isEdit = widget.review != null; + final book = _getBook(); return Scaffold( backgroundColor: colors.surface, appBar: AppBar( title: Text(isEdit ? '编辑书评' : '写书评'), - actions: [ - TextButton( - onPressed: _saveReview, - child: const Text( - '保存', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), - const SizedBox(width: 8), - ], ), body: Form( key: _formKey, child: Column( 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( - child: TextFormField( - controller: _contentController, - maxLines: null, - expands: true, - textAlignVertical: TextAlignVertical.top, - style: TextStyle( - fontSize: 16, - color: colors.onSurface, - height: 1.7, + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── 书籍信息卡片 ────────────────── + if (book != null) _buildBookCard(book, colors), + const SizedBox(height: 20), + + // ── 类型选择 ────────────────────── + 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, - contentPadding: const EdgeInsets.all(16), + child: Text(isEdit ? '更新书评' : '保存书评', + 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 { ); } - /// 构建类型选择器 - 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), - ), - ], - ), - ), - ); - } + // ═══════════════════════════════════════════════════════════════════ + // 书籍信息卡片 + // ═══════════════════════════════════════════════════════════════════ - /// 显示类型选择 - void _showTypeSelector() { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), - builder: (context) { - final colors = Theme.of(context).colorScheme; - return Container( - color: colors.surface, - child: SafeArea( + Widget _buildBookCard(Book book, ColorScheme colors) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Container( + 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( - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - ListTile( - title: const Text('短评'), - trailing: _reviewType == 1 - ? Icon(Icons.check, color: colors.onSurface) - : null, - onTap: () { - setState(() => _reviewType = 1); - Navigator.pop(context); - }, - ), - Divider(height: 0.5, color: colors.outline), - ListTile( - title: const Text('长评'), - trailing: _reviewType == 2 - ? Icon(Icons.check, color: colors.onSurface) - : null, - onTap: () { - setState(() => _reviewType = 2); - Navigator.pop(context); - }, - ), + Text(book.title, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface), maxLines: 2, overflow: TextOverflow.ellipsis), + if (book.authors.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(book.authors.take(2).join(' / '), + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5)), + maxLines: 1, overflow: TextOverflow.ellipsis), + ], + if (book.rating != null) ...[ + const SizedBox(height: 4), + Row(children: [ + Icon(Icons.star, size: 14, color: const Color(0xFFFFB800)), + const SizedBox(width: 2), + Text('${book.rating}', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.6))), + ]), + ], ], ), ), - ); - }, + ], + ), ); } + // ═══════════════════════════════════════════════════════════════════ + // 类型选择器(分段按钮) + // ═══════════════════════════════════════════════════════════════════ + + Widget _buildTypeSelector(ColorScheme colors) { + return SegmentedButton( + 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 _saveReview() async { - if (!_formKey.currentState!.validate()) { - return; - } + if (!_formKey.currentState!.validate()) return; try { - final now = DateTime.now(); + final now = DateTime.now(); - if (widget.review == null) { - final newReview = BookReview( - id: const Uuid().v4(), - bookId: widget.bookId, - content: _contentController.text.trim(), - reviewer: _reviewerController.text.trim(), - source: _sourceController.text.trim(), - reviewType: _reviewType, - isDeleted: false, - createdAt: now, - updatedAt: now, - ); - await context.read().addBookReview(newReview); - } else { - final updatedReview = widget.review!.copyWith( - content: _contentController.text.trim(), - reviewer: _reviewerController.text.trim(), - source: _sourceController.text.trim(), - reviewType: _reviewType, - updatedAt: now, - ); - await context.read().updateBookReview(updatedReview); - } + if (widget.review == null) { + final newReview = BookReview( + id: now.millisecondsSinceEpoch.toString(), + bookId: widget.bookId, + content: _contentController.text.trim(), + reviewer: _reviewerController.text.trim(), + source: _sourceController.text.trim(), + reviewType: _reviewType, + isDeleted: false, + createdAt: now, + updatedAt: now, + ); + await context.read().addBookReview(newReview); + } else { + final updatedReview = widget.review!.copyWith( + content: _contentController.text.trim(), + reviewer: _reviewerController.text.trim(), + source: _sourceController.text.trim(), + reviewType: _reviewType, + updatedAt: now, + ); + await context.read().updateBookReview(updatedReview); + } - if (!mounted) return; - - ToastUtil.show(context, widget.review == null ? '添加成功' : '更新成功'); - - Navigator.pop(context); + if (!mounted) return; + ToastUtil.show(context, widget.review == null ? '添加成功' : '更新成功'); + Navigator.pop(context); } catch (e) { if (!mounted) return; ToastUtil.show(context, '保存失败: $e'); diff --git a/lib/pages/book/book_reviews_page.dart b/lib/pages/book/book_reviews_page.dart index ba906dc..5be2f86 100644 --- a/lib/pages/book/book_reviews_page.dart +++ b/lib/pages/book/book_reviews_page.dart @@ -323,24 +323,31 @@ class _BookReviewsPageState extends State { return AlertDialog( backgroundColor: colors.surface, elevation: 0, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), - title: const Text('确认删除'), - content: const Text('确定要删除这条书评吗?'), + 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(context), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), ), - TextButton( + ElevatedButton( onPressed: () async { await context.read().removeBookReview(review.id); Navigator.pop(context); _loadReviews(); 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), ); }, ); diff --git a/lib/pages/epub_reader/book_link_page.dart b/lib/pages/epub_reader/book_link_page.dart index 1ada310..d2fd4b2 100644 --- a/lib/pages/epub_reader/book_link_page.dart +++ b/lib/pages/epub_reader/book_link_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../utils/book/book_dao.dart'; import '../../utils/epub/reader_dao.dart'; +import '../../utils/toast_util.dart'; import '../../models/data_models.dart'; /// 选择关联书籍页面(带搜索功能) @@ -63,9 +64,7 @@ class _BookLinkPageState extends State { Future _selectBook(Book book) async { await _readerDao.linkToBook(widget.readerBookId, book.id); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('已关联《${book.title}》')), - ); + ToastUtil.show(context, '已关联《${book.title}》'); Navigator.pop(context, true); } } diff --git a/lib/pages/epub_reader/control_panel.dart b/lib/pages/epub_reader/control_panel.dart index 490774c..554161b 100644 --- a/lib/pages/epub_reader/control_panel.dart +++ b/lib/pages/epub_reader/control_panel.dart @@ -33,6 +33,13 @@ class ControlPanel extends StatefulWidget { final ValueChanged onMarginBottomChanged; final ValueChanged onMarginLeftChanged; final ValueChanged onMarginRightChanged; + final int themeIndex; + final int customBgColor; + final int customTextColor; + final ValueChanged onThemeIndexChanged; + final void Function(int bgColor, int textColor) onCustomColorChanged; + final bool currentPageHasBookmark; + final VoidCallback onBookmarkToggle; const ControlPanel({ super.key, @@ -64,6 +71,13 @@ class ControlPanel extends StatefulWidget { required this.onMarginBottomChanged, required this.onMarginLeftChanged, 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; @@ -246,6 +260,11 @@ class _ControlPanelState extends State { onMarginBottomChanged: widget.onMarginBottomChanged, onMarginLeftChanged: widget.onMarginLeftChanged, 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 { ), overflow: TextOverflow.ellipsis, ), + actions: [ + IconButton( + icon: Icon( + widget.currentPageHasBookmark + ? Icons.bookmark + : Icons.bookmark_outline, + ), + onPressed: widget.onBookmarkToggle, + ), + ], ), ), ), diff --git a/lib/pages/epub_reader/epub_detail_page.dart b/lib/pages/epub_reader/epub_detail_page.dart index 016f8b2..145ace0 100644 --- a/lib/pages/epub_reader/epub_detail_page.dart +++ b/lib/pages/epub_reader/epub_detail_page.dart @@ -7,7 +7,9 @@ import '../../utils/epub/epub_parser.dart'; import '../../utils/epub/reader_models.dart'; import '../../utils/book/book_dao.dart'; import '../../models/data_models.dart'; +import '../book/book_detail_page.dart'; import 'book_link_page.dart'; +import 'epub_edit_page.dart'; import 'reader_screen.dart'; /// EPUB 书籍详情页 @@ -68,80 +70,14 @@ class _EpubDetailPageState extends State { ).then((_) => _refreshBook()); } - // ─── 编辑对话框 ───────────────────────────────────────────── - - void _showEditDialog() { - final titleCtrl = TextEditingController(text: _book['title'] as String? ?? ''); - final authorCtrl = TextEditingController(text: _book['author'] as String? ?? ''); - 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('保存'), - ), - ], + void _navigateToEdit() async { + final changed = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EpubEditPage(bookId: widget.bookId, book: _book), ), ); + if (changed == true && mounted) await _refreshBook(); } // ─── Build ────────────────────────────────────────────────── @@ -166,7 +102,7 @@ class _EpubDetailPageState extends State { actions: [ IconButton( icon: Icon(Icons.edit_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.6)), - onPressed: _showEditDialog, + onPressed: _navigateToEdit, ), const SizedBox(width: 4), ], @@ -316,21 +252,35 @@ class _EpubDetailPageState extends State { Widget _buildLinkedBookCard(ColorScheme colors) { final linkedBookId = _book['book_id'] as String? ?? ''; if (linkedBookId.isEmpty) { - return InkWell( + return GestureDetector( onTap: _navigateToLinkPage, - borderRadius: BorderRadius.circular(10), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: colors.surfaceContainerHighest, + color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(10), + border: Border.all(color: colors.outlineVariant, width: 0.5), ), child: Row(children: [ - Icon(Icons.link_outlined, size: 18, color: colors.onSurface.withValues(alpha: 0.4)), - const SizedBox(width: 10), + Container( + 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( - child: Text('选择关联书籍', - style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.5))), + child: Column( + 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)), ]), @@ -342,35 +292,97 @@ class _EpubDetailPageState extends State { future: _bookDao.getBookById(linkedBookId), builder: (context, snapshot) { final linkedTitle = snapshot.data?.title ?? '未知书籍'; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(10), - ), - child: Row(children: [ - Icon(Icons.link_outlined, size: 18, color: colors.primary), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('已关联', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), - Text(linkedTitle, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface)), - ], + final linkedAuthor = snapshot.data?.authors.take(2).join(' / ') ?? ''; + return GestureDetector( + onTap: () => _showLinkedBookActions(colors, linkedTitle), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: colors.outlineVariant, width: 0.5), + ), + child: Row(children: [ + Container( + 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, size: 20, color: colors.primary), ), - ), - GestureDetector( - onTap: _unlinkBook, - child: Icon(Icons.close, size: 16, color: colors.onSurface.withValues(alpha: 0.35)), - ), - ]), + const SizedBox(width: 12), + Expanded( + child: Column( + 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 _navigateToLinkPage() async { final linked = await Navigator.push( context, diff --git a/lib/pages/epub_reader/epub_edit_page.dart b/lib/pages/epub_reader/epub_edit_page.dart new file mode 100644 index 0000000..a639a6b --- /dev/null +++ b/lib/pages/epub_reader/epub_edit_page.dart @@ -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 book; + + const EpubEditPage({ + super.key, + required this.bookId, + required this.book, + }); + + @override + State createState() => _EpubEditPageState(); +} + +class _EpubEditPageState extends State { + 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 _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 _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().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 _revertCover() async { + final appDir = await getApplicationDocumentsDirectory(); + final bookDir = Directory(p.join(appDir.path, 'epub_books', widget.bookId)); + + final existing = bookDir.listSync().whereType().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().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))), + ], + )), + ]), + ), + ); + } +} diff --git a/lib/pages/epub_reader/epub_library_page.dart b/lib/pages/epub_reader/epub_library_page.dart index c4ed3a6..0142bf5 100644 --- a/lib/pages/epub_reader/epub_library_page.dart +++ b/lib/pages/epub_reader/epub_library_page.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:file_picker/file_picker.dart'; import '../../utils/epub/reader_dao.dart'; @@ -48,6 +49,14 @@ class _EpubLibraryPageState extends State { if (result == null || result.files.isEmpty) return; final path = result.files.single.path; if (path == null) return; + if (!path.toLowerCase().endsWith('.epub')) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('仅支持导入 .epub 格式的文件')), + ); + } + return; + } if (!mounted) return; showDialog( @@ -210,29 +219,123 @@ class _EpubLibraryPageState extends State { } Widget _buildGrid(ColorScheme colors) { - final bool isRelaxed = _viewMode == ViewMode.relaxed; - final maxExtent = isRelaxed ? 180.0 : 120.0; - final aspectRatio = isRelaxed ? 0.55 : 0.68; - final spacing = isRelaxed ? 16.0 : 8.0; + final bool showList = _viewMode == ViewMode.compact; + if (showList) { + return _buildListView(colors); + } + return _buildGridView(colors); + } + + Widget _buildGridView(ColorScheme colors) { return GridView.builder( padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: maxExtent, - childAspectRatio: aspectRatio, - crossAxisSpacing: spacing, - mainAxisSpacing: spacing, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 12, + mainAxisSpacing: 16, + childAspectRatio: 0.55, ), itemCount: _books.length, itemBuilder: (context, index) { final book = _books[index]; return BookGridItem( book: book, - viewMode: _viewMode, + viewMode: ViewMode.relaxed, onTap: () => _openBook(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))), + ); + } } diff --git a/lib/pages/epub_reader/reader_screen.dart b/lib/pages/epub_reader/reader_screen.dart index 595f5d6..ad7b8ee 100644 --- a/lib/pages/epub_reader/reader_screen.dart +++ b/lib/pages/epub_reader/reader_screen.dart @@ -129,6 +129,9 @@ class _ReaderScreenState extends State bool styleDrawerOpen = false; AppLifecycleState? lastLifecycleState = AppLifecycleState.resumed; + // 书签 + final List> _bookmarks = []; + // Services final EpubStreamService _streamService = EpubStreamService(); final ReaderDao _readerDao = ReaderDao(); @@ -311,6 +314,7 @@ class _ReaderScreenState extends State currentSpineItemIndex = bookSession.initialChapterIndex; }); updateProgressDebounced(); + _loadBookmarks(); } } catch (e) { if (mounted) { @@ -322,6 +326,84 @@ class _ReaderScreenState extends State } } + // ─── 书签 ────────────────────────────────────────────────────── + + Future _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 _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 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 _deleteBookmark(int id) async { + await _readerDao.deleteBookmark(id); + await _loadBookmarks(); + } + void toggleControls() { if (showControls) { hideBottomNavigationBar(); @@ -399,6 +481,9 @@ class _ReaderScreenState extends State onTocItemSelected: navigateToTocItem, onCoverTap: navigateToFirstTocItemFirstPage, themeData: themeData, + bookmarks: _bookmarks, + onBookmarkTap: _jumpToBookmark, + onBookmarkDelete: _deleteBookmark, ), onDrawerChanged: (isOpened) { tocDrawerOpen = isOpened; @@ -530,6 +615,28 @@ class _ReaderScreenState extends State readerSettings.save(); 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, ), ], ), diff --git a/lib/pages/epub_reader/reader_style_sheet.dart b/lib/pages/epub_reader/reader_style_sheet.dart index a20e7ac..2857533 100644 --- a/lib/pages/epub_reader/reader_style_sheet.dart +++ b/lib/pages/epub_reader/reader_style_sheet.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../../utils/epub/epub_theme.dart'; import 'widgets/integer_stepper.dart'; import 'widgets/reader_scale_slider.dart'; @@ -18,6 +19,11 @@ class ReaderStyleSheet extends StatefulWidget { final ValueChanged onMarginLeftChanged; final ValueChanged onMarginRightChanged; final ValueChanged onFontSizeChanged; + final int themeIndex; + final int customBgColor; + final int customTextColor; + final ValueChanged onThemeIndexChanged; + final void Function(int bgColor, int textColor) onCustomColorChanged; const ReaderStyleSheet({ super.key, @@ -33,6 +39,11 @@ class ReaderStyleSheet extends StatefulWidget { required this.onMarginLeftChanged, required this.onMarginRightChanged, required this.onFontSizeChanged, + required this.themeIndex, + required this.customBgColor, + required this.customTextColor, + required this.onThemeIndexChanged, + required this.onCustomColorChanged, }); @override @@ -46,6 +57,9 @@ class _ReaderStyleSheetState extends State { late int _leftMargin; late int _rightMargin; late double _fontSize; + late int _themeIndex; + late int _customBgColor; + late int _customTextColor; static const int _marginMin = 0; static const int _marginMax = 64; @@ -62,6 +76,9 @@ class _ReaderStyleSheetState extends State { _leftMargin = widget.marginLeft.toInt(); _rightMargin = widget.marginRight.toInt(); _fontSize = widget.fontSize; + _themeIndex = widget.themeIndex; + _customBgColor = widget.customBgColor; + _customTextColor = widget.customTextColor; } @override @@ -257,6 +274,13 @@ class _ReaderStyleSheetState extends State { ), ], ), + + const SizedBox(height: 24), + + // -- 阅读主题 -- + const _SectionTitle(label: '阅读主题'), + const SizedBox(height: 10), + _buildThemePresets(colorScheme), ], ), ), @@ -264,6 +288,223 @@ class _ReaderStyleSheetState extends State { }, ); } + + 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 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) diff --git a/lib/pages/epub_reader/toc_drawer.dart b/lib/pages/epub_reader/toc_drawer.dart index 64796cf..9a8e848 100644 --- a/lib/pages/epub_reader/toc_drawer.dart +++ b/lib/pages/epub_reader/toc_drawer.dart @@ -29,6 +29,11 @@ class TocDrawer extends StatefulWidget { final VoidCallback? onCoverTap; final ThemeData themeData; + // 书签相关 + final List> bookmarks; + final void Function(Map bookmark)? onBookmarkTap; + final void Function(int bookmarkId)? onBookmarkDelete; + const TocDrawer({ super.key, required this.bookTitle, @@ -40,20 +45,25 @@ class TocDrawer extends StatefulWidget { required this.onTocItemSelected, this.onCoverTap, required this.themeData, + this.bookmarks = const [], + this.onBookmarkTap, + this.onBookmarkDelete, }); @override State createState() => _TocDrawerState(); } -class _TocDrawerState extends State { - final ScrollController _scrollController = ScrollController(); +class _TocDrawerState extends State with SingleTickerProviderStateMixin { + final ScrollController _tocScrollController = ScrollController(); final Set _expandedItems = {}; List<_TocRowItem> _visibleItems = []; + late TabController _tabController; @override void initState() { super.initState(); + _tabController = TabController(length: 2, vsync: this); _initExpansionState(); _regenerateVisibleItems(); @@ -80,7 +90,8 @@ class _TocDrawerState extends State { @override void dispose() { - _scrollController.dispose(); + _tocScrollController.dispose(); + _tabController.dispose(); super.dispose(); } @@ -161,10 +172,10 @@ class _TocDrawerState extends State { (row) => widget.activeTocItems.contains(row.item), ); - if (index != -1 && _scrollController.hasClients) { + if (index != -1 && _tocScrollController.hasClients) { final offset = (index * 56.0) - (56.0 * 4); - _scrollController.jumpTo( - offset.clamp(0.0, _scrollController.position.maxScrollExtent), + _tocScrollController.jumpTo( + offset.clamp(0.0, _tocScrollController.position.maxScrollExtent), ); } } @@ -172,6 +183,7 @@ class _TocDrawerState extends State { @override Widget build(BuildContext context) { final isDark = widget.themeData.brightness == Brightness.dark; + final colors = widget.themeData.colorScheme; return Drawer( backgroundColor: widget.themeData.scaffoldBackgroundColor, @@ -179,19 +191,36 @@ class _TocDrawerState extends State { child: Column( children: [ _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( - child: ListView.builder( - controller: _scrollController, - 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); - }, + child: TabBarView( + controller: _tabController, + children: [ + _buildTocTab(context, isDark), + _buildBookmarkTab(context, isDark), + ], ), ), ], @@ -200,6 +229,24 @@ class _TocDrawerState extends State { ); } + // ─── 目录 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) { final item = row.item; // 用 activeTocItems 匹配,或者直接用 spineIndex 匹配当前章节 @@ -266,6 +313,129 @@ class _TocDrawerState extends State { ); } + // ─── 书签 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 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) { const authorText = ''; @@ -273,12 +443,6 @@ class _TocDrawerState extends State { padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: widget.themeData.colorScheme.surface, - border: Border( - bottom: BorderSide( - color: widget.themeData.colorScheme.outline, - width: 1, - ), - ), ), child: GestureDetector( onTap: () { diff --git a/lib/pages/epub_reader/widgets/book_grid_item.dart b/lib/pages/epub_reader/widgets/book_grid_item.dart index 19a2e3d..09b35d5 100644 --- a/lib/pages/epub_reader/widgets/book_grid_item.dart +++ b/lib/pages/epub_reader/widgets/book_grid_item.dart @@ -36,7 +36,7 @@ class BookGridItem extends StatelessWidget { // ─── mode helpers ───────────────────────────────────────────────────────── - /// Relaxed: cover + title + author + progress bar. + /// Relaxed: cover + title + author, 右上角进度百分比。 Widget _buildRelaxed(BuildContext context) { final title = book['title'] as String? ?? ''; final author = book['author'] as String? ?? ''; @@ -45,8 +45,10 @@ class BookGridItem extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded(child: _buildCoverStack(context, fit: StackFit.expand)), - const SizedBox(height: 12), + Expanded(child: _buildCoverStack(context, fit: StackFit.expand, extras: [ + if (progress > 0) _buildProgressBadge(context), + ])), + const SizedBox(height: 8), Text( title, maxLines: 2, @@ -55,8 +57,8 @@ class BookGridItem extends StatelessWidget { fontWeight: FontWeight.w500, ), ), - const SizedBox(height: 4), - if (author.isNotEmpty) + if (author.isNotEmpty) ...[ + const SizedBox(height: 2), Text( author, maxLines: 1, @@ -66,17 +68,7 @@ class BookGridItem extends StatelessWidget { fontSize: 12, ), ), - if (progress > 0) - Padding( - padding: const EdgeInsets.only(top: 8), - child: ClipRRect( - borderRadius: BorderRadius.circular(2), - child: LinearProgressIndicator( - value: progress, - minHeight: 3, - ), - ), - ), + ], ], ); } diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 902f4bd..732bb13 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -18,6 +18,15 @@ class HomePage extends StatefulWidget { class _HomePageState extends State { final PageController _pageController = PageController(); bool _isSwitchingPage = false; + int _lastNavIndex = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _lastNavIndex = context.read().bottomNavIndex; + }); + } @override void dispose() { @@ -25,6 +34,27 @@ class _HomePageState extends State { 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 Widget build(BuildContext context) { return Scaffold( @@ -34,21 +64,7 @@ class _HomePageState extends State { body: Consumer( builder: (context, provider, child) { - final currentPage = provider.bottomNavIndex == 0 ? 0 : 1; - 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); - } - }); - } - }); - } + _onNavIndexChanged(provider); return NotificationListener( onNotification: (notification) { diff --git a/lib/pages/movies/movie_detail_page.dart b/lib/pages/movies/movie_detail_page.dart index 093110b..c9ee56c 100644 --- a/lib/pages/movies/movie_detail_page.dart +++ b/lib/pages/movies/movie_detail_page.dart @@ -598,6 +598,8 @@ class _MovieDetailPageState extends State { const SizedBox(width: 16), ], _buildStatusTag(movie), + const SizedBox(width: 6), + _buildCategoryTag(movie), ], ), const SizedBox(height: 8), @@ -679,6 +681,35 @@ class _MovieDetailPageState extends State { ); } + 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) { final isOverlay = _detailStyle == 1; final colors = Theme.of(context).colorScheme; diff --git a/lib/pages/movies/movie_form_page.dart b/lib/pages/movies/movie_form_page.dart index cd9f711..4c7a9ab 100644 --- a/lib/pages/movies/movie_form_page.dart +++ b/lib/pages/movies/movie_form_page.dart @@ -50,6 +50,7 @@ class _MovieFormPageState extends State { List _alternateTitles = []; String? _posterPath; String _status = 'want_to_watch'; + String _category = 'movie'; DateTime? _releaseDate; DateTime? _watchDate; bool _isDownloading = false; @@ -85,6 +86,7 @@ class _MovieFormPageState extends State { _alternateTitles = List.from(movie.alternateTitles); _posterPath = movie.posterPath; _status = movie.status; + _category = movie.category; _releaseDate = movie.releaseDate; _watchDate = movie.watchDate; } else if (widget.initialStatus != null) { @@ -831,11 +833,60 @@ class _MovieFormPageState extends State { ], ], ), + 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) { final isSelected = _status == value; @@ -1292,6 +1343,7 @@ class _MovieFormPageState extends State { summary: _summaryController.text.trim(), rating: rating, status: _status, + category: _category, watchDate: _watchDate, createdAt: now, updatedAt: now, @@ -1311,6 +1363,7 @@ class _MovieFormPageState extends State { summary: _summaryController.text.trim(), rating: rating, status: _status, + category: _category, watchDate: _watchDate, updatedAt: now, ); diff --git a/lib/pages/movies/movie_posters_page.dart b/lib/pages/movies/movie_posters_page.dart index 1e01f9b..b2689a7 100644 --- a/lib/pages/movies/movie_posters_page.dart +++ b/lib/pages/movies/movie_posters_page.dart @@ -533,24 +533,31 @@ class _MoviePostersPageState extends State { return AlertDialog( backgroundColor: colors.surface, elevation: 0, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), - title: const Text('确认删除'), - content: const Text('确定要删除这张海报吗?'), + 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(context), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), ), - TextButton( + ElevatedButton( onPressed: () async { await context.read().removeMoviePoster(poster.id); Navigator.pop(context); _loadPosters(); 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), ); }, ); diff --git a/lib/pages/movies/movie_review_detail_page.dart b/lib/pages/movies/movie_review_detail_page.dart index 22f61e8..a8e5e1f 100644 --- a/lib/pages/movies/movie_review_detail_page.dart +++ b/lib/pages/movies/movie_review_detail_page.dart @@ -50,17 +50,27 @@ class _MovieReviewDetailPageState extends State { 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))), + 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.5)))), + 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))), + 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) { diff --git a/lib/pages/movies/movie_reviews_page.dart b/lib/pages/movies/movie_reviews_page.dart index bf0df69..a3323d6 100644 --- a/lib/pages/movies/movie_reviews_page.dart +++ b/lib/pages/movies/movie_reviews_page.dart @@ -323,24 +323,31 @@ class _MovieReviewsPageState extends State { return AlertDialog( backgroundColor: colors.surface, elevation: 0, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), - title: const Text('确认删除'), - content: const Text('确定要删除这条影评吗?'), + 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(context), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), ), - TextButton( + ElevatedButton( onPressed: () async { await context.read().removeMovieReview(review.id); Navigator.pop(context); _loadReviews(); 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), ); }, ); diff --git a/lib/pages/note_plus/note_plus_form_page.dart b/lib/pages/note_plus/note_plus_form_page.dart index 70f8cf8..dcfdd5d 100644 --- a/lib/pages/note_plus/note_plus_form_page.dart +++ b/lib/pages/note_plus/note_plus_form_page.dart @@ -283,7 +283,7 @@ class _NotePlusFormPageState extends State { top: false, child: Container( decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), + color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(14), ), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 3), diff --git a/lib/pages/recycle_bin_page.dart b/lib/pages/recycle_bin_page.dart index ffe2095..fa9a22b 100644 --- a/lib/pages/recycle_bin_page.dart +++ b/lib/pages/recycle_bin_page.dart @@ -12,7 +12,7 @@ class RecycleBinPage extends StatefulWidget { State createState() => _RecycleBinPageState(); } -enum _ItemType { movie, book, note } +enum _ItemType { movie, book, note, movieReview, bookReview } class _DeletedItem { 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')}', icon = Icons.description_outlined, 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 { @@ -67,12 +83,16 @@ class _RecycleBinPageState extends State { final movies = await provider.getDeletedMovies(); final books = await provider.getDeletedBooks(); final notes = await provider.getDeletedNotes(); + final movieReviews = await provider.getDeletedMovieReviews(); + final bookReviews = await provider.getDeletedBookReviews(); if (!mounted) return; setState(() { _allItems = [ for (final m in movies) _DeletedItem.movie(m), for (final b in books) _DeletedItem.book(b), 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; }); @@ -138,11 +158,14 @@ class _RecycleBinPageState extends State { ), child: Wrap( spacing: 8, + runSpacing: 8, children: [ _filterChip('全部', null), _filterChip('影视', _ItemType.movie), _filterChip('书籍', _ItemType.book), _filterChip('笔记', _ItemType.note), + _filterChip('影评', _ItemType.movieReview), + _filterChip('书评', _ItemType.bookReview), ], ), ); @@ -327,6 +350,12 @@ class _RecycleBinPageState extends State { case _ItemType.note: await provider.restoreNote(item.id); 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(); } @@ -342,6 +371,10 @@ class _RecycleBinPageState extends State { await provider.permanentDeleteBook(item.id); case _ItemType.note: await provider.permanentDeleteNote(item.id); + case _ItemType.movieReview: + await provider.permanentDeleteMovieReview(item.id); + case _ItemType.bookReview: + await provider.permanentDeleteBookReview(item.id); } _loadDeletedItems(); if (mounted) ToastUtil.show(context, '已彻底删除'); diff --git a/lib/pages/statistics_page.dart b/lib/pages/statistics_page.dart index bab5229..fa804ca 100644 --- a/lib/pages/statistics_page.dart +++ b/lib/pages/statistics_page.dart @@ -21,70 +21,90 @@ class _StatisticsPageState extends State { bool get _showBooks => UserPrefs().showBookTab; bool get _showNotes => UserPrefs().showNoteTab; + // 缓存过滤后的列表,避免每次 build 都重新过滤 + List? _cachedMovies; + List? _cachedBooks; + List? _cachedNotes; + List? _filteredMovies; + List? _filteredBooks; + List? _filteredNotes; + + (List, List, List) _getFilteredLists( + List movies, List books, List 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 Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; + final movies = context.select>((p) => p.movies); + final books = context.select>((p) => p.books); + final notes = context.select>((p) => p.notes); + final (fm, fb, fn) = _getFilteredLists(movies, books, notes); + return Scaffold( backgroundColor: colors.surface, appBar: AppBar(title: const Text('数据统计')), - body: Consumer( - builder: (context, provider, child) { - final movies = provider.movies.where((m) => !m.isDeleted).toList(); - final books = provider.books.where((b) => !b.isDeleted).toList(); - final notes = provider.notes.where((n) => !n.isDeleted).toList(); - - return ListView( - padding: const EdgeInsets.all(20), - children: [ - // 1. 总览 - _buildOverview(movies, books, notes), + body: ListView( + padding: const EdgeInsets.all(20), + children: [ + // 1. 总览 + _buildOverview(fm, fb, fn), const SizedBox(height: 28), // 2. 状态分布 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), ], 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), ], // 3. 习惯洞察 - _buildHabitsInsight(movies, books, notes), + _buildHabitsInsight(fm, fb, fn), const SizedBox(height: 28), // 4. 类型偏好雷达图 - _buildGenreRadar(movies, books), + _buildGenreRadar(fm, fb), const SizedBox(height: 28), // 5. 导演/作者 TOP 5 - _buildDirectorTop5(movies), + _buildDirectorTop5(fm), const SizedBox(height: 28), - _buildAuthorTop5(books), + _buildAuthorTop5(fb), const SizedBox(height: 28), // 6. 高分之最 - _buildTopRated(movies, books), + _buildTopRated(fm, fb), const SizedBox(height: 28), // 7. 评分分布 - _buildRatingDistribution(movies, books), + _buildRatingDistribution(fm, fb), const SizedBox(height: 28), // 8. 年度趋势 - _buildYearlyTrend(movies, books, notes), + _buildYearlyTrend(fm, fb, fn), const SizedBox(height: 28), // 9. 星期分布 - _buildWeekdayDistribution(movies, books, notes), + _buildWeekdayDistribution(fm, fb, fn), const SizedBox(height: 28), // 10. 累计增长 - _buildCumulativeGrowth(movies, books, notes), + _buildCumulativeGrowth(fm, fb, fn), const SizedBox(height: 28), // 11. 标签词云 - _buildTagCloud(movies, books, notes), + _buildTagCloud(fm, fb, fn), const SizedBox(height: 28), // 12+13. 马拉松 + 标签之最 - _buildFunStats(movies, books, notes), + _buildFunStats(fm, fb, fn), const SizedBox(height: 80), ], - ); - }, - ), - ); + ), + ); } // ─── 1. 总览区域 ────────────────────────────────────────────────────── diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index facbd74..e858dec 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -1,3 +1,4 @@ +import 'dart:collection'; import 'package:flutter/material.dart'; import '../models/data_models.dart'; import '../utils/movie/movie_dao.dart'; @@ -147,9 +148,9 @@ class AppProvider extends ChangeNotifier { ThemeMode get themeMode => _themeMode; int get colorSchemeIndex => _colorSchemeIndex; String get fontFamily => _fontFamily; - List get movies => _movies; - List get books => _books; - List get notes => _notes; + List get movies => UnmodifiableListView(_movies); + List get books => UnmodifiableListView(_books); + List get notes => UnmodifiableListView(_notes); // 根据状态获取影视列表 List getMoviesByStatus(String status) { @@ -490,6 +491,8 @@ class AppProvider extends ChangeNotifier { final deletedMovies = await getDeletedMovies(); final deletedBooks = await getDeletedBooks(); final deletedNotes = await getDeletedNotes(); + final deletedMovieReviews = await getDeletedMovieReviews(); + final deletedBookReviews = await getDeletedBookReviews(); for (final movie in deletedMovies) { await permanentDeleteMovie(movie.id); @@ -500,12 +503,44 @@ class AppProvider extends ChangeNotifier { for (final note in deletedNotes) { 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 loadBooks(); await loadNotes(); } + // ========== 影评书评回收站 ========== + + Future> getDeletedMovieReviews() async { + return await _reviewDao.getDeletedReviews(); + } + + Future restoreMovieReview(String id) async { + await _reviewDao.restoreReview(id); + } + + Future permanentDeleteMovieReview(String id) async { + await _reviewDao.permanentDeleteReview(id); + } + + Future> getDeletedBookReviews() async { + return await _bookReviewDao.getDeletedReviews(); + } + + Future restoreBookReview(String id) async { + await _bookReviewDao.restoreReview(id); + } + + Future permanentDeleteBookReview(String id) async { + await _bookReviewDao.permanentDeleteReview(id); + } + // ========== 标签管理方法 ========== Future>> getTags(String type, {bool excludeHidden = false}) async { diff --git a/lib/utils/database_helper.dart b/lib/utils/database_helper.dart index 49b7224..c8aa011 100644 --- a/lib/utils/database_helper.dart +++ b/lib/utils/database_helper.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; import 'package:flutter/foundation.dart'; @@ -7,7 +8,7 @@ import '../models/data_models.dart'; class DatabaseHelper { static final DatabaseHelper instance = DatabaseHelper._init(); static Database? _database; - static bool _isReopening = false; + static Completer? _reopenCompleter; DatabaseHelper._init(); @@ -19,24 +20,30 @@ class DatabaseHelper { /// 重新打开数据库(用于 WebDAV 同步后) Future reopenDatabase() async { - // 防止并发重开 - if (_isReopening) return; - _isReopening = true; + // 如果已有重开在进行,等待它完成即可 + if (_reopenCompleter != null) { + return _reopenCompleter!.future; + } + _reopenCompleter = Completer(); try { if (_database != null) { await _database!.close(); _database = null; } _database = await _initDB('mooknote.db'); + _reopenCompleter!.complete(); + } catch (e) { + _reopenCompleter!.completeError(e); + rethrow; } finally { - _isReopening = false; + _reopenCompleter = null; } } Future get database async { - // 等待重开完成 - while (_isReopening) { - await Future.delayed(const Duration(milliseconds: 50)); + // 如果正在重开,等待完成(无忙等待) + if (_reopenCompleter != null) { + await _reopenCompleter!.future; } if (_database != null) return _database!; _database = await _initDB('mooknote.db'); @@ -49,7 +56,7 @@ class DatabaseHelper { return await openDatabase( path, - version: 24, + version: 25, onCreate: _createDB, onUpgrade: _onUpgrade, ); @@ -215,6 +222,13 @@ class DatabaseHelper { 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和出版时间字段) @@ -531,12 +545,13 @@ class DatabaseHelper { summary TEXT, rating REAL, status TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'movie', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, is_deleted INTEGER DEFAULT 0 ) '''); - + // 迁移旧数据(尽可能保留) for (final row in oldData) { try { @@ -585,6 +600,7 @@ class DatabaseHelper { summary TEXT, rating REAL, status TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'movie', watch_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, diff --git a/lib/utils/epub/epub_service.dart b/lib/utils/epub/epub_service.dart index c7ad346..d3aeee5 100644 --- a/lib/utils/epub/epub_service.dart +++ b/lib/utils/epub/epub_service.dart @@ -22,9 +22,9 @@ class EpubService { // 复制 EPUB 到永久存储(FilePicker 临时文件会被清理) final appDir = await getApplicationDocumentsDirectory(); - final booksDir = Directory(p.join(appDir.path, 'epub_books')); - if (!await booksDir.exists()) await booksDir.create(recursive: true); - final permanentPath = p.join(booksDir.path, '$bookId.epub'); + final bookDir = Directory(p.join(appDir.path, 'epub_books', bookId)); + if (!await bookDir.exists()) await bookDir.create(recursive: true); + final permanentPath = p.join(bookDir.path, 'book.epub'); await File(sourcePath).copy(permanentPath); // 从永久副本解析 @@ -97,9 +97,9 @@ class EpubService { final coverFile = File(p.join(extractDir, coverRelPath)); if (!await coverFile.exists()) return null; - // 保存到应用文档目录 + // 保存到 epub_books/{bookId}/ 目录下 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); final ext = p.extension(coverFile.path).toLowerCase(); final destPath = p.join(coverDir, 'cover$ext'); @@ -142,19 +142,11 @@ class EpubService { if (await dir.exists()) await dir.delete(recursive: true); } catch (_) {} - // 清理封面 + // 清理 epub_books/{bookId}/ 目录(epub + 封面) try { final appDir = await getApplicationDocumentsDirectory(); - final coverDir = p.join(appDir.path, 'images', 'books', bookId); - final dir = Directory(coverDir); - 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(); + final bookDir = Directory(p.join(appDir.path, 'epub_books', bookId)); + if (await bookDir.exists()) await bookDir.delete(recursive: true); } catch (_) {} // 软删除数据库记录 diff --git a/lib/utils/epub/epub_theme.dart b/lib/utils/epub/epub_theme.dart index d86b92a..1a0d112 100644 --- a/lib/utils/epub/epub_theme.dart +++ b/lib/utils/epub/epub_theme.dart @@ -1,6 +1,35 @@ import 'package:flutter/material.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 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 { final double zoom; final bool shouldOverrideTextColor; diff --git a/lib/utils/epub/reader_dao.dart b/lib/utils/epub/reader_dao.dart index 546c060..f11e42b 100644 --- a/lib/utils/epub/reader_dao.dart +++ b/lib/utils/epub/reader_dao.dart @@ -133,4 +133,32 @@ class ReaderDao { final db = await _db.database; return db.delete('book_annotations', where: 'id = ?', whereArgs: [id]); } + + // ─── bookmarks ────────────────────────────────────────────────── + + /// 获取某本书的所有书签 + Future>> 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 insertBookmark(Map bookmark) async { + final db = await _db.database; + return db.insert('book_annotations', { + ...bookmark, + 'type': 'bookmark', + }); + } + + /// 删除书签 + Future deleteBookmark(int id) async { + final db = await _db.database; + return db.delete('book_annotations', where: 'id = ?', whereArgs: [id]); + } } diff --git a/lib/utils/epub/reader_settings.dart b/lib/utils/epub/reader_settings.dart index 4b27667..0525fb6 100644 --- a/lib/utils/epub/reader_settings.dart +++ b/lib/utils/epub/reader_settings.dart @@ -28,6 +28,15 @@ class ReaderSettings { /// When true, volume up/down keys turn pages in the reader. 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({ this.zoom = 1.0, this.followAppTheme = true, @@ -40,6 +49,9 @@ class ReaderSettings { this.fontFileName, this.overrideFontFamily = false, this.volumeKeyTurnsPage = false, + this.themeIndex = 0, + this.customBgColor = 0xFFFFFFFF, + this.customTextColor = 0xFF1A1A1A, }); // Sentinel: lets copyWith(fontFileName: null) mean "set to null" rather than @@ -58,6 +70,9 @@ class ReaderSettings { Object? fontFileName = _kUnset, bool? overrideFontFamily, bool? volumeKeyTurnsPage, + int? themeIndex, + int? customBgColor, + int? customTextColor, }) { return ReaderSettings( zoom: zoom ?? this.zoom, @@ -73,21 +88,78 @@ class ReaderSettings { : fontFileName as String?, overrideFontFamily: overrideFontFamily ?? this.overrideFontFamily, volumeKeyTurnsPage: volumeKeyTurnsPage ?? this.volumeKeyTurnsPage, + themeIndex: themeIndex ?? this.themeIndex, + customBgColor: customBgColor ?? this.customBgColor, + customTextColor: customTextColor ?? this.customTextColor, ); } 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( zoom: zoom, - shouldOverrideTextColor: true, + shouldOverrideTextColor: shouldOverride, colorScheme: colorScheme, padding: EdgeInsets.only( - top: marginTop, - bottom: marginBottom, - left: marginLeft, - right: marginRight, + top: marginTop, bottom: marginBottom, + left: marginLeft, right: marginRight, ), fontFileName: fontFileName, overrideFontFamily: overrideFontFamily, @@ -115,6 +187,9 @@ class ReaderSettings { } await prefs.setBool('${_kPrefix}overrideFontFamily', overrideFontFamily); 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 load() async { diff --git a/lib/utils/movie/movie_review_dao.dart b/lib/utils/movie/movie_review_dao.dart index 0c2a9b7..dc268d3 100644 --- a/lib/utils/movie/movie_review_dao.dart +++ b/lib/utils/movie/movie_review_dao.dart @@ -100,4 +100,36 @@ class MovieReviewDao { ); return List.generate(maps.length, (i) => MovieReview.fromJson(maps[i])); }); + + /// 获取所有已删除的影评 + Future> getDeletedReviews() => _wrap('getDeletedReviews', () async { + final db = await _dbHelper.database; + final List> 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 restoreReview(String id) => _wrap('restoreReview', () async { + final db = await _dbHelper.database; + await db.update( + 'movie_reviews', + {'is_deleted': 0}, + where: 'id = ?', + whereArgs: [id], + ); + }); + + /// 彻底删除影评 + Future permanentDeleteReview(String id) => _wrap('permanentDeleteReview', () async { + final db = await _dbHelper.database; + await db.delete( + 'movie_reviews', + where: 'id = ?', + whereArgs: [id], + ); + }); } diff --git a/lib/utils/sync/auto_backup_service.dart b/lib/utils/sync/auto_backup_service.dart index cfc18f9..e3395f2 100644 --- a/lib/utils/sync/auto_backup_service.dart +++ b/lib/utils/sync/auto_backup_service.dart @@ -105,35 +105,54 @@ class AutoBackupService { /// 获取备份目录(下载目录/mooknote) Future _getBackupDirectory() async { try { - // 尝试获取下载目录 - Directory? downloadDir; - 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(); if (externalDir != null) { - // 通常路径是 /storage/emulated/0/Android/data/.../files - // 我们需要找到真正的下载目录 - final path = externalDir.path; - final downloadPath = path.replaceAll( - '/Android/data/${externalDir.uri.pathSegments[externalDir.uri.pathSegments.length - 3]}/files', - '/Download', - ); - downloadDir = Directory('$downloadPath/$_backupDirName'); + final segments = externalDir.uri.pathSegments; + if (segments.length >= 3) { + final pkg = segments[segments.length - 3]; + final downloadPath = externalDir.path.replaceAll( + '/Android/data/$pkg/files', + '/Download', + ); + return Directory('$downloadPath/$_backupDirName'); + } } + + // 优先级 4: 降级到 app 内部目录 + final appDir = await getApplicationDocumentsDirectory(); + return Directory('${appDir.path}/$_backupDirName'); + } else if (Platform.isIOS) { - // iOS: 使用文档目录 final docDir = await getApplicationDocumentsDirectory(); - downloadDir = Directory('${docDir.path}/$_backupDirName'); + return Directory('${docDir.path}/$_backupDirName'); } else { - // 桌面端: 使用下载目录 + // 桌面端 final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; 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) { debugPrint('AutoBackup: 获取备份目录失败 - $e'); return null; diff --git a/lib/utils/sync/backup_service.dart b/lib/utils/sync/backup_service.dart index e2c95ce..c6a40c0 100644 --- a/lib/utils/sync/backup_service.dart +++ b/lib/utils/sync/backup_service.dart @@ -1,6 +1,5 @@ import 'dart:convert'; import 'dart:io'; -import 'package:archive/archive.dart'; import 'package:archive/archive_io.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; @@ -89,57 +88,73 @@ class BackupService { }, }; - // 创建 ZIP - final archive = Archive(); - final jsonString = const JsonEncoder.withIndent(' ').convert(backupData); - final jsonBytes = Uint8List.fromList(utf8.encode(jsonString)); - archive.addFile(ArchiveFile('data.json', jsonBytes.length, jsonBytes)); + // 创建 ZIP(逐文件写入磁盘,避免全部加载到内存) + final tempDir = await getTemporaryDirectory(); + final tempZipPath = path.join(tempDir.path, 'mooknote_backup_temp.zip'); + final encoder = ZipFileEncoder(); + encoder.create(tempZipPath); - int imageCount = 0; - final appDir = await getApplicationDocumentsDirectory(); - final imagesRoot = path.join(appDir.path, 'images'); + try { + // data.json + 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) { - final file = File(imagePath); - if (await file.exists()) { - 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++; - } - } + int imageCount = 0; + final appDir = await getApplicationDocumentsDirectory(); + final imagesRoot = path.join(appDir.path, 'images'); - // 收集 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 bytes = await entity.readAsBytes(); - final relativePath = entity.path.substring(epubRoot.length + 1); - archive.addFile(ArchiveFile('epub_books/$relativePath', bytes.length, bytes)); - epubCount++; + for (final imagePath in imagePaths) { + final file = File(imagePath); + if (await file.exists()) { + String relativePath; + if (imagePath.startsWith(imagesRoot)) { + relativePath = imagePath.substring(imagesRoot.length + 1); + } else { + relativePath = path.basename(imagePath); + } + encoder.addFile(file, 'images/$relativePath'); + 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, - ); } // ─── 手动导出 ───────────────────────────────────────── diff --git a/lib/utils/sync/webdav_service.dart b/lib/utils/sync/webdav_service.dart index 570b3a6..cb010b2 100644 --- a/lib/utils/sync/webdav_service.dart +++ b/lib/utils/sync/webdav_service.dart @@ -311,11 +311,16 @@ class WebDAVService { } 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( - success: true, - message: '同步完成', + success: anySuccess, + message: anySuccess ? '同步完成' : '同步未完成,未传输任何数据', lastSyncTime: DateTime.now(), uploadedFiles: uploadedFiles, downloadedFiles: downloadedFiles, diff --git a/lib/utils/theme/app_theme.dart b/lib/utils/theme/app_theme.dart index b57f701..d27a581 100644 --- a/lib/utils/theme/app_theme.dart +++ b/lib/utils/theme/app_theme.dart @@ -127,6 +127,44 @@ class AppTheme { selectedLabelStyle: TextStyle(fontFamily: _fontFamily, fontSize: 11, fontWeight: _medium), 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, + ), + ), ); }