diff --git a/android/.kotlin/sessions/kotlin-compiler-1550729391791673956.salive b/android/.kotlin/sessions/kotlin-compiler-1550729391791673956.salive new file mode 100644 index 0000000..e69de29 diff --git a/lib/pages/epub_reader/epub_detail_page.dart b/lib/pages/epub_reader/epub_detail_page.dart index 77e762e..9ee13e9 100644 --- a/lib/pages/epub_reader/epub_detail_page.dart +++ b/lib/pages/epub_reader/epub_detail_page.dart @@ -2,7 +2,6 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import '../../utils/epub/reader_dao.dart'; import '../../utils/epub/epub_parser.dart'; @@ -126,8 +125,24 @@ class _EpubDetailPageState extends State { final colors = Theme.of(context).colorScheme; final progress = (_book['reading_percentage'] as num?)?.toDouble() ?? 0.0; final title = _book['title'] as String? ?? ''; - final author = _book['author'] as String? ?? ''; final coverPath = _linkedBookCoverPath ?? _book['cover_path'] as String?; + // 作者:优先用 authors(JSON 数组),否则用 author + final authorsJson = _book['authors'] as String? ?? ''; + String author; + if (authorsJson.isNotEmpty) { + try { + author = List.from(jsonDecode(authorsJson)).join('、'); + } catch (_) { + author = _book['author'] as String? ?? ''; + } + } else { + author = _book['author'] as String? ?? ''; + } + // 简介:优先用 summary 字段,否则用 EPUB 解析的 description + final summary = _book['summary'] as String? ?? ''; + final description = summary.isNotEmpty ? summary : (_bookInfo?.description ?? ''); + final publisher = _book['publisher'] as String? ?? ''; + final isbn = _book['isbn'] as String? ?? ''; return Scaffold( backgroundColor: colors.surface, @@ -213,13 +228,13 @@ class _EpubDetailPageState extends State { Divider(height: 0.5, thickness: 0.5, color: colors.outline), // ── 描述 ── - if (_bookInfo?.description != null && _bookInfo!.description!.isNotEmpty) ...[ + if (description.isNotEmpty) ...[ _buildSectionHeader('简介', colors), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: GestureDetector( onTap: () => setState(() => _descriptionExpanded = !_descriptionExpanded), - child: Text(_stripHtmlTags(_bookInfo!.description!), + child: Text(_stripHtmlTags(description), maxLines: _descriptionExpanded ? null : 4, overflow: _descriptionExpanded ? null : TextOverflow.ellipsis, style: TextStyle(fontSize: 14, height: 1.7, color: colors.onSurface)), @@ -228,6 +243,33 @@ class _EpubDetailPageState extends State { Divider(height: 0.5, thickness: 0.5, color: colors.outline), ], + // ── 出版信息 ── + if (publisher.isNotEmpty || isbn.isNotEmpty) ...[ + _buildSectionHeader('出版信息', colors), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Wrap( + spacing: 16, + runSpacing: 6, + children: [ + if (publisher.isNotEmpty) + Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.business_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), + const SizedBox(width: 4), + Text(publisher, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.7))), + ]), + if (isbn.isNotEmpty) + Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.qr_code_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), + const SizedBox(width: 4), + Text(isbn, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.7))), + ]), + ], + ), + ), + Divider(height: 0.5, thickness: 0.5, color: colors.outline), + ], + // ── 关联书籍 ── _buildSectionHeader('关联书籍', colors), Padding( diff --git a/lib/pages/epub_reader/epub_edit_page.dart b/lib/pages/epub_reader/epub_edit_page.dart index a639a6b..ad7708d 100644 --- a/lib/pages/epub_reader/epub_edit_page.dart +++ b/lib/pages/epub_reader/epub_edit_page.dart @@ -1,11 +1,16 @@ +import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; 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 'package:provider/provider.dart'; +import '../../providers/app_provider.dart'; import '../../utils/epub/reader_dao.dart'; +import '../../widgets/genre_selector_page.dart'; import '../../widgets/text_input_panel.dart'; /// EPUB 书籍编辑页 @@ -27,19 +32,44 @@ class _EpubEditPageState extends State { final ReaderDao _dao = ReaderDao(); late TextEditingController _titleCtrl; - late TextEditingController _authorCtrl; + late TextEditingController _summaryCtrl; + late TextEditingController _publisherCtrl; + late TextEditingController _isbnCtrl; + List _authors = []; @override void initState() { super.initState(); _titleCtrl = TextEditingController(text: widget.book['title'] as String? ?? ''); - _authorCtrl = TextEditingController(text: widget.book['author'] as String? ?? ''); + _summaryCtrl = TextEditingController(text: widget.book['summary'] as String? ?? ''); + _publisherCtrl = TextEditingController(text: widget.book['publisher'] as String? ?? ''); + _isbnCtrl = TextEditingController(text: widget.book['isbn'] as String? ?? ''); + + // 解析多作者:优先用 authors(JSON 数组),否则从 author(逗号分隔)解析 + final authorsJson = widget.book['authors'] as String? ?? ''; + if (authorsJson.isNotEmpty) { + try { + _authors = List.from(jsonDecode(authorsJson)); + } catch (_) { + _authors = _parseAuthorField(authorsJson); + } + } else { + final authorStr = widget.book['author'] as String? ?? ''; + _authors = _parseAuthorField(authorStr); + } + } + + List _parseAuthorField(String text) { + if (text.isEmpty) return []; + return text.split(RegExp(r'[,、/]')).map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); } @override void dispose() { _titleCtrl.dispose(); - _authorCtrl.dispose(); + _summaryCtrl.dispose(); + _publisherCtrl.dispose(); + _isbnCtrl.dispose(); super.dispose(); } @@ -48,7 +78,11 @@ class _EpubEditPageState extends State { if (newTitle.isEmpty) return; await _dao.updateReaderBook(widget.bookId, { 'title': newTitle, - 'author': _authorCtrl.text.trim(), + 'author': _authors.join('、'), + 'authors': jsonEncode(_authors), + 'summary': _summaryCtrl.text.trim(), + 'publisher': _publisherCtrl.text.trim(), + 'isbn': _isbnCtrl.text.trim(), 'updated_at': DateTime.now().toIso8601String(), }); if (mounted) Navigator.pop(context, true); @@ -129,10 +163,13 @@ class _EpubEditPageState extends State { if (mounted) Navigator.pop(context, true); } + bool get _hasLinkedBook => (widget.book['book_id'] as String? ?? '').isNotEmpty; + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final coverPath = widget.book['cover_path'] as String?; + final halfWidth = (MediaQuery.of(context).size.width - 52) / 2; return Scaffold( backgroundColor: colors.surface, @@ -156,7 +193,7 @@ class _EpubEditPageState extends State { body: ListView( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), children: [ - // 封面选择 + // 封面 Center(child: _buildCoverPicker(coverPath, colors)), const SizedBox(height: 24), // 信息卡片 @@ -164,8 +201,9 @@ class _EpubEditPageState extends State { spacing: 12, runSpacing: 12, children: [ + // 标题 SizedBox( - width: (MediaQuery.of(context).size.width - 52) / 2, + width: halfWidth, height: 90, child: _buildInfoCard( label: '标题', @@ -184,46 +222,94 @@ class _EpubEditPageState extends State { colors: colors, ), ), + // 作者(多选) SizedBox( - width: (MediaQuery.of(context).size.width - 52) / 2, + width: halfWidth, height: 90, child: _buildInfoCard( label: '作者', - value: _authorCtrl.text, + value: _authors.isEmpty ? '' : '${_authors.length}人:${_authors.join('、')}', icon: Icons.person_outline, + onTap: () async { + final provider = context.read(); + final data = provider.books.map((b) => b.authors).toList(); + final result = await GenreSelectorPage.show( + context: context, + title: '选择作者', + existingTagsFuture: compute(_collectUnique, data), + initialSelected: _authors, + hint: '如:余华、莫言', + ); + if (result != null) setState(() => _authors = result); + }, + colors: colors, + ), + ), + // 出版社 + SizedBox( + width: halfWidth, + height: 90, + child: _buildInfoCard( + label: '出版社', + value: _publisherCtrl.text, + icon: Icons.business_outlined, onTap: () async { final result = await TextInputPanel.show( context: context, - title: '作者', - initialValue: _authorCtrl.text, - hint: '请输入作者', + title: '出版社', + initialValue: _publisherCtrl.text, + hint: '请输入出版社', ); - if (result != null) setState(() => _authorCtrl.text = result); + if (result != null) setState(() => _publisherCtrl.text = result); + }, + colors: colors, + ), + ), + // ISBN + SizedBox( + width: halfWidth, + height: 90, + child: _buildInfoCard( + label: 'ISBN', + value: _isbnCtrl.text, + icon: Icons.qr_code_outlined, + onTap: () async { + final result = await TextInputPanel.show( + context: context, + title: 'ISBN', + initialValue: _isbnCtrl.text, + hint: '请输入ISBN编号', + keyboardType: TextInputType.number, + ); + if (result != null) setState(() => _isbnCtrl.text = result); + }, + colors: colors, + ), + ), + // 简介(全宽) + SizedBox( + width: double.infinity, + child: _buildInfoCard( + label: '简介', + value: _summaryCtrl.text, + icon: Icons.description_outlined, + height: 160, + scrollable: true, + onTap: () async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => _SummaryEditorPage(initialText: _summaryCtrl.text), + ), + ); + if (result != null) setState(() => _summaryCtrl.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, - )), - ]), + const SizedBox(height: 48), ], ), ); @@ -232,40 +318,130 @@ class _EpubEditPageState extends State { // ─── 构建组件 ──────────────────────────────────────────────── 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), + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + 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.auto_stories_outlined, size: 36, + color: colors.onSurface.withValues(alpha: 0.2)), + ], + ), ), - 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))), - ], + if (!_hasLinkedBook) ...[ + const SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: _pickCover, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add_photo_alternate_outlined, size: 14, + color: colors.onSurface.withValues(alpha: 0.6)), + const SizedBox(width: 4), + Text('更换封面', + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.6))), + ], + ), + ), ), - ), + const SizedBox(width: 8), + GestureDetector( + onTap: _revertCover, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.undo, size: 14, + color: colors.onSurface.withValues(alpha: 0.6)), + const SizedBox(width: 4), + Text('恢复上次', + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.6))), + ], + ), + ), + ), + ], + ), + ], + if (_hasLinkedBook) ...[ + const SizedBox(height: 8), + Text('封面由关联书籍提供', + style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.35))), + ], + ], ); } Widget _buildInfoCard({ - required String label, required String value, required IconData icon, - required VoidCallback onTap, required ColorScheme colors, + required String label, + required String value, + required IconData icon, + required VoidCallback onTap, + required ColorScheme colors, bool required = false, + double? height, + bool scrollable = false, }) { + final hasValue = value.isNotEmpty; + + Widget buildContent() { + if (scrollable && height != null) { + return Flexible( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Text( + hasValue ? value : '点击填写', + style: TextStyle( + fontSize: 14, + color: hasValue ? colors.onSurface : colors.onSurface.withValues(alpha: 0.2), + fontWeight: hasValue ? FontWeight.w500 : FontWeight.normal, + ), + ), + ), + ); + } + return Text( + hasValue ? value : '未设置', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + fontWeight: hasValue ? FontWeight.w500 : FontWeight.normal, + color: hasValue ? colors.onSurface : colors.onSurface.withValues(alpha: 0.2), + ), + ); + } + return GestureDetector( onTap: onTap, child: Container( + height: height, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colors.surfaceContainerHigh, @@ -278,61 +454,86 @@ class _EpubEditPageState extends State { 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))), + Text(label, + style: TextStyle( + fontSize: 11, + color: required ? colors.onSurface : colors.onSurface.withValues(alpha: 0.4), + fontWeight: required ? FontWeight.w500 : FontWeight.normal, + )), 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, - ), - ), + buildContent(), ], ), ), ); } +} - Widget _buildActionCard({ - required IconData icon, required String title, required String subtitle, - required Color color, required VoidCallback onTap, - }) { +/// 简介 编辑页 +class _SummaryEditorPage extends StatefulWidget { + final String initialText; + const _SummaryEditorPage({required this.initialText}); + + @override + State<_SummaryEditorPage> createState() => _SummaryEditorPageState(); +} + +class _SummaryEditorPageState extends State<_SummaryEditorPage> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialText); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { 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), + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar( + title: const Text('简介'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, _controller.text.trim()), + child: Text('完成', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.primary)), ), - 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))), - ], - )), - ]), + const SizedBox(width: 8), + ], + ), + body: TextField( + controller: _controller, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.6), + decoration: InputDecoration( + hintText: '写下书籍简介...', + hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.3)), + contentPadding: const EdgeInsets.all(20), + border: InputBorder.none, + ), ), ); } } + +/// 从多值字段列表中提取去重排序的唯一值(供 compute 使用) +List _collectUnique(List> lists) { + final s = {}; + for (final l in lists) { + s.addAll(l); + } + return s.toList()..sort(); +} diff --git a/lib/pages/epub_reader/epub_highlights_page.dart b/lib/pages/epub_reader/epub_highlights_page.dart index 52d484f..136da9a 100644 --- a/lib/pages/epub_reader/epub_highlights_page.dart +++ b/lib/pages/epub_reader/epub_highlights_page.dart @@ -63,6 +63,13 @@ class _EpubHighlightsPageState extends State { appBar: AppBar( title: Text('句读', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), actions: [ + IconButton( + icon: _isLoading + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : Icon(Icons.refresh_rounded, size: 22), + tooltip: '刷新', + onPressed: _isLoading ? null : _loadHighlights, + ), IconButton( icon: Icon( _isListMode ? Icons.grid_view_rounded : Icons.view_agenda_outlined, diff --git a/lib/pages/epub_reader/epub_library_page.dart b/lib/pages/epub_reader/epub_library_page.dart index acc21f3..ff15c8d 100644 --- a/lib/pages/epub_reader/epub_library_page.dart +++ b/lib/pages/epub_reader/epub_library_page.dart @@ -20,7 +20,10 @@ class _EpubLibraryPageState extends State { final ReaderDao _dao = ReaderDao(); final EpubService _service = EpubService(); List> _books = []; + List> _filteredBooks = []; bool _isLoading = true; + bool _isSearching = false; + final TextEditingController _searchCtrl = TextEditingController(); ViewMode _viewMode = UserPrefs().epubViewMode == 1 ? ViewMode.compact : ViewMode.relaxed; @@ -38,10 +41,38 @@ class _EpubLibraryPageState extends State { setState(() { _books = books; _isLoading = false; + _applyFilter(); }); } } + void _applyFilter() { + final query = _searchCtrl.text.trim().toLowerCase(); + if (query.isEmpty) { + _filteredBooks = _books; + } else { + _filteredBooks = _books.where((b) { + final title = (b['title'] as String? ?? '').toLowerCase(); + final author = (b['author'] as String? ?? '').toLowerCase(); + return title.contains(query) || author.contains(query); + }).toList(); + } + } + + void _onSearchChanged() { + setState(() => _applyFilter()); + } + + void _toggleSearch() { + setState(() { + _isSearching = !_isSearching; + if (!_isSearching) { + _searchCtrl.clear(); + _applyFilter(); + } + }); + } + Future _pickAndImport() async { final result = await FilePicker.platform.pickFiles( type: FileType.custom, @@ -138,6 +169,12 @@ class _EpubLibraryPageState extends State { UserPrefs().setEpubViewMode(_viewMode == ViewMode.compact ? 1 : 0); } + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; @@ -146,13 +183,30 @@ class _EpubLibraryPageState extends State { appBar: AppBar( backgroundColor: colors.surface, elevation: 0, - title: Text('EPUB 阅读', - style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface)), + title: _isSearching + ? TextField( + controller: _searchCtrl, + autofocus: true, + style: TextStyle(fontSize: 16, color: colors.onSurface), + decoration: InputDecoration( + hintText: '搜索书名或作者', + hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.35)), + border: InputBorder.none, + ), + onChanged: (_) => _onSearchChanged(), + ) + : Text('EPUB 阅读', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface)), leading: IconButton( - icon: const Icon(Icons.arrow_back, size: 20), - onPressed: () => Navigator.pop(context), + icon: Icon(_isSearching ? Icons.close : Icons.arrow_back, size: 20), + onPressed: _isSearching ? _toggleSearch : () => Navigator.pop(context), ), actions: [ + if (!_isSearching) + IconButton( + icon: Icon(Icons.search, size: 20, color: colors.onSurface.withValues(alpha: 0.6)), + onPressed: _toggleSearch, + ), IconButton( icon: Icon( _viewMode == ViewMode.relaxed @@ -174,7 +228,9 @@ class _EpubLibraryPageState extends State { ? Center(child: CircularProgressIndicator(color: colors.primary)) : _books.isEmpty ? _buildEmpty(colors) - : _buildGrid(colors), + : _filteredBooks.isEmpty + ? Center(child: Text('无搜索结果', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.35)))) + : _buildGrid(colors), ); } @@ -240,9 +296,9 @@ class _EpubLibraryPageState extends State { mainAxisSpacing: 16, childAspectRatio: 0.55, ), - itemCount: _books.length, + itemCount: _filteredBooks.length, itemBuilder: (context, index) { - final book = _books[index]; + final book = _filteredBooks[index]; return BookGridItem( book: book, viewMode: ViewMode.relaxed, diff --git a/lib/pages/epub_reader/footnote_popup.dart b/lib/pages/epub_reader/footnote_popup.dart index 0bc89e5..d427108 100644 --- a/lib/pages/epub_reader/footnote_popup.dart +++ b/lib/pages/epub_reader/footnote_popup.dart @@ -112,9 +112,17 @@ class FootnotePopupOverlayState extends State final isDark = Theme.of(context).brightness == Brightness.dark; - // Strip HTML tags for simple text display + // Strip HTML tags and decode entities for simple text display final plainText = widget.rawHtml .replaceAll(RegExp(r'<[^>]*>'), '') + .replaceAll(' ', ' ') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAllMapped(RegExp(r'&#(\d+);'), (m) => String.fromCharCode(int.parse(m[1]!))) + .replaceAllMapped(RegExp(r'&#x([0-9a-fA-F]+);'), (m) => String.fromCharCode(int.parse(m[1]!, radix: 16))) .replaceAll(RegExp(r'\s+'), ' ') .trim(); diff --git a/lib/pages/epub_reader/widgets/book_grid_item.dart b/lib/pages/epub_reader/widgets/book_grid_item.dart index 09b35d5..a7f3bb0 100644 --- a/lib/pages/epub_reader/widgets/book_grid_item.dart +++ b/lib/pages/epub_reader/widgets/book_grid_item.dart @@ -36,8 +36,9 @@ class BookGridItem extends StatelessWidget { // ─── mode helpers ───────────────────────────────────────────────────────── - /// Relaxed: cover + title + author, 右上角进度百分比。 + /// Relaxed: 封面卡片 + 标题 + 作者,底部进度条。 Widget _buildRelaxed(BuildContext context) { + final colors = Theme.of(context).colorScheme; final title = book['title'] as String? ?? ''; final author = book['author'] as String? ?? ''; final progress = _readingProgress; @@ -45,27 +46,67 @@ class BookGridItem extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded(child: _buildCoverStack(context, fit: StackFit.expand, extras: [ - if (progress > 0) _buildProgressBadge(context), - ])), - const SizedBox(height: 8), + Expanded( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: colors.shadow.withValues(alpha: 0.08), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: _buildCoverStack(context, fit: StackFit.expand, extras: [ + // 底部渐变背景 + 进度条 + if (progress > 0) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + height: 12, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black54], + ), + ), + alignment: Alignment.bottomCenter, + child: LinearProgressIndicator( + value: progress, + minHeight: 2.5, + backgroundColor: Colors.white24, + valueColor: const AlwaysStoppedAnimation(Colors.white70), + ), + ), + ), + ]), + ), + ), + const SizedBox(height: 6), Text( title, - maxLines: 2, + maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( + style: TextStyle( + fontSize: 13, fontWeight: FontWeight.w500, + color: colors.onSurface, ), ), if (author.isNotEmpty) ...[ - const SizedBox(height: 2), + const SizedBox(height: 1), Text( author, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 12, + fontSize: 11, + color: colors.onSurface.withValues(alpha: 0.45), ), ), ], @@ -139,7 +180,7 @@ class BookGridItem extends StatelessWidget { fit: fit, children: [ Container( - decoration: BoxDecoration(borderRadius: BorderRadius.circular(6)), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(8)), clipBehavior: Clip.antiAlias, child: hasCover ? Image.file( diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index f05c9c1..6235662 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -2022,30 +2022,58 @@ class _SettingsPageState extends State { ); } - void _showClearCacheDialog(BuildContext pageContext) { + void _showClearCacheDialog(BuildContext pageContext) async { final colors = Theme.of(context).colorScheme; + // 先扫描分析 + showDialog( + context: pageContext, + barrierDismissible: false, + builder: (_) => Center(child: CircularProgressIndicator(color: colors.primary)), + ); + + final appProvider = pageContext.read(); + final dbImagePaths = await _getAllDbImagePaths(appProvider); + + final imageInfo = await _scanImageDirectory(dbImagePaths); + final epubInfo = await _scanOrphanedEpubBooks(appProvider); + final tempInfo = await _scanTempDirectory(); + final emptyDirInfo = await _scanEmptyDirectories(); + + if (!pageContext.mounted) return; + Navigator.pop(pageContext); // 关闭 loading + + final totalSize = imageInfo.$2 + epubInfo.$2 + tempInfo.$2 + emptyDirInfo.$2; + final totalCount = imageInfo.$1 + epubInfo.$1 + tempInfo.$1 + emptyDirInfo.$1; + if (totalCount == 0) { + ToastUtil.show(pageContext, '没有需要清理的缓存'); + return; + } + showDialog( context: pageContext, builder: (dialogContext) => 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)), + title: Text('缓存分析', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('共发现 $totalCount 项可清理缓存,合计 ${_formatSize(totalSize)}', + style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5))), + const SizedBox(height: 14), + if (imageInfo.$1 > 0) _buildCacheItem('孤立图片', imageInfo.$1, imageInfo.$2, Icons.image_outlined, colors), + if (epubInfo.$1 > 0) _buildCacheItem('孤立电子书', epubInfo.$1, epubInfo.$2, Icons.menu_book_outlined, colors), + if (tempInfo.$1 > 0) _buildCacheItem('临时文件', tempInfo.$1, tempInfo.$2, Icons.folder_outlined, colors), + if (emptyDirInfo.$1 > 0) _buildCacheItem('空文件夹', emptyDirInfo.$1, emptyDirInfo.$2, Icons.folder_off_outlined, colors), + ], + ), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), - child: Text('取消', - style: TextStyle( - color: colors.onSurface.withValues(alpha: 0.6)))), + child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), ElevatedButton( onPressed: () async { Navigator.pop(dialogContext); @@ -2055,19 +2083,40 @@ class _SettingsPageState extends State { 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('清除'), + 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), + actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), ); } + Widget _buildCacheItem(String label, int count, int size, IconData icon, ColorScheme colors) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + children: [ + Icon(icon, size: 18, color: colors.onSurface.withValues(alpha: 0.4)), + const SizedBox(width: 10), + Expanded( + child: Text(label, + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface)), + ), + Text('$count项 ${_formatSize(size)}', + style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5))), + ], + ), + ); + } + + String _formatSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + Future _clearCacheData(BuildContext context) async { try { showDialog( @@ -2302,6 +2351,154 @@ class _SettingsPageState extends State { } catch (_) {} return count; } + + // ─── 扫描方法(只统计不删除) ────────────────────────────────────────────── + + /// 返回 (文件数, 总字节数) + Future<(int, int)> _scanImageDirectory(Set dbImagePaths) async { + int count = 0, totalSize = 0; + try { + final appDir = await getApplicationDocumentsDirectory(); + final imagesDir = Directory('${appDir.path}/images'); + if (!await imagesDir.exists()) return (0, 0); + await for (final entity in imagesDir.list(recursive: true, followLinks: false)) { + if (entity is File && + !dbImagePaths.contains(entity.path) && + !path.basename(entity.path).startsWith('avatar')) { + try { + totalSize += await entity.length(); + count++; + } catch (_) {} + } + } + } catch (_) {} + return (count, totalSize); + } + + Future<(int, int)> _scanOrphanedEpubBooks(AppProvider provider) async { + int count = 0, totalSize = 0; + try { + final db = await DatabaseHelper.instance.database; + final rows = await db.query('reader_books', columns: ['id', 'file_path', 'cover_path', 'is_deleted']); + final usedDirs = {}; + for (final r in rows) { + final isDeleted = r['is_deleted'] == 1 || r['is_deleted'] == true; + if (isDeleted) continue; + final id = r['id'] as String?; + if (id != null && id.isNotEmpty) usedDirs.add(id); + _collectEpubDirName(r['file_path'] as String?, usedDirs); + _collectEpubDirName(r['cover_path'] as String?, usedDirs); + } + final appDir = await getApplicationDocumentsDirectory(); + final possiblePaths = [ + '${appDir.path}/epub_books', + '/data/user/0/top.iletter.mooknote/app_flutter/epub_books', + ]; + for (final epubPath in possiblePaths) { + final epubDir = Directory(epubPath); + if (!await epubDir.exists()) continue; + await for (final entity in epubDir.list(followLinks: false)) { + if (entity is Directory) { + final dirName = path.basename(entity.path); + if (!usedDirs.contains(dirName)) { + try { + totalSize += await _dirSize(entity); + count++; + } catch (_) {} + } + } + } + } + } catch (_) {} + return (count, totalSize); + } + + Future<(int, int)> _scanTempDirectory() async { + int count = 0, totalSize = 0; + final now = DateTime.now(); + try { + final tempDir = await getTemporaryDirectory(); + if (await tempDir.exists()) { + await for (final entity in tempDir.list(followLinks: false)) { + if (entity is File) { + final name = path.basename(entity.path); + if (name.startsWith('book_poster_') || + name.startsWith('movie_poster_') || + name.startsWith('note_share_') || + name.startsWith('mooknote_download') || + name.startsWith('mooknote_bidir')) { + try { + final stat = await entity.stat(); + if (now.difference(stat.modified).inHours >= 1) { + totalSize += await entity.length(); + count++; + } + } catch (_) {} + } + } + } + } + } catch (_) {} + try { + final cacheDir = await getApplicationCacheDirectory(); + if (await cacheDir.exists()) { + await for (final entity in cacheDir.list(recursive: true, followLinks: false)) { + if (entity is File) { + try { + totalSize += await entity.length(); + count++; + } catch (_) {} + } + } + } + } catch (_) {} + return (count, totalSize); + } + + Future<(int, int)> _scanEmptyDirectories() async { + int count = 0; + try { + final appDir = await getApplicationDocumentsDirectory(); + final cacheDir = await getApplicationCacheDirectory(); + final dirs = [ + Directory('${appDir.path}/images'), + Directory('${appDir.path}/epub_books'), + cacheDir, + ]; + for (final dir in dirs) { + if (!await dir.exists()) continue; + count += await _countEmptyDirsRecursive(dir); + } + } catch (_) {} + return (count, 0); + } + + Future _dirSize(Directory dir) async { + int size = 0; + try { + await for (final entity in dir.list(recursive: true, followLinks: false)) { + if (entity is File) { + try { size += await entity.length(); } catch (_) {} + } + } + } catch (_) {} + return size; + } + + Future _countEmptyDirsRecursive(Directory dir) async { + int count = 0; + try { + final children = await dir.list(followLinks: false).toList(); + for (final child in children) { + if (child is Directory) { + count += await _countEmptyDirsRecursive(child); + final remaining = await child.list(followLinks: false).toList(); + if (remaining.isEmpty) count++; + } + } + } catch (_) {} + return count; + } } // ─── 功能设置 ─── diff --git a/lib/utils/database_helper.dart b/lib/utils/database_helper.dart index cf90d8b..da6543a 100644 --- a/lib/utils/database_helper.dart +++ b/lib/utils/database_helper.dart @@ -72,7 +72,7 @@ class DatabaseHelper { return await openDatabase( path, - version: 29, + version: 30, onCreate: _createDB, onUpgrade: _onUpgrade, ); @@ -262,6 +262,22 @@ class DatabaseHelper { await db.execute('ALTER TABLE books ADD COLUMN translators TEXT'); } } + if (oldVersion < 30) { + // reader_books 添加简介、出版社、ISBN、多作者字段 + final cols = await db.rawQuery('PRAGMA table_info(reader_books)'); + if (!cols.any((col) => col['name'] == 'summary')) { + await db.execute("ALTER TABLE reader_books ADD COLUMN summary TEXT DEFAULT ''"); + } + if (!cols.any((col) => col['name'] == 'publisher')) { + await db.execute("ALTER TABLE reader_books ADD COLUMN publisher TEXT DEFAULT ''"); + } + if (!cols.any((col) => col['name'] == 'isbn')) { + await db.execute("ALTER TABLE reader_books ADD COLUMN isbn TEXT DEFAULT ''"); + } + if (!cols.any((col) => col['name'] == 'authors')) { + await db.execute("ALTER TABLE reader_books ADD COLUMN authors TEXT DEFAULT ''"); + } + } } /// 升级books表到V27(添加阅读始末日期字段) @@ -783,6 +799,7 @@ class DatabaseHelper { id TEXT PRIMARY KEY, title TEXT NOT NULL, author TEXT DEFAULT '', + authors TEXT DEFAULT '', cover_path TEXT, file_path TEXT NOT NULL, file_name TEXT NOT NULL, @@ -790,6 +807,9 @@ class DatabaseHelper { last_read_cfi TEXT DEFAULT '', reading_percentage REAL DEFAULT 0.0, book_id TEXT DEFAULT '', + summary TEXT DEFAULT '', + publisher TEXT DEFAULT '', + isbn TEXT DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, is_deleted INTEGER DEFAULT 0 diff --git a/pubspec.yaml b/pubspec.yaml index 92d8656..0ae71b9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: mooknote description: "app for tracking movies, books, and notes" publish_to: 'none' -version: 0.2.3 +version: 0.2.4 environment: sdk: ^3.5.0