优化epub功能和清除缓存功能

This commit is contained in:
DelLevin-Home
2026-07-05 03:13:06 +08:00
parent 1529c7d7cb
commit 82aca12402
10 changed files with 714 additions and 142 deletions

View File

@@ -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<EpubDetailPage> {
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?;
// 作者:优先用 authorsJSON 数组),否则用 author
final authorsJson = _book['authors'] as String? ?? '';
String author;
if (authorsJson.isNotEmpty) {
try {
author = List<String>.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<EpubDetailPage> {
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<EpubDetailPage> {
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(

View File

@@ -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<EpubEditPage> {
final ReaderDao _dao = ReaderDao();
late TextEditingController _titleCtrl;
late TextEditingController _authorCtrl;
late TextEditingController _summaryCtrl;
late TextEditingController _publisherCtrl;
late TextEditingController _isbnCtrl;
List<String> _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? ?? '');
// 解析多作者:优先用 authorsJSON 数组),否则从 author逗号分隔解析
final authorsJson = widget.book['authors'] as String? ?? '';
if (authorsJson.isNotEmpty) {
try {
_authors = List<String>.from(jsonDecode(authorsJson));
} catch (_) {
_authors = _parseAuthorField(authorsJson);
}
} else {
final authorStr = widget.book['author'] as String? ?? '';
_authors = _parseAuthorField(authorStr);
}
}
List<String> _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<EpubEditPage> {
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<EpubEditPage> {
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<EpubEditPage> {
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<EpubEditPage> {
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<EpubEditPage> {
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<AppProvider>();
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<String>(
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<EpubEditPage> {
// ─── 构建组件 ────────────────────────────────────────────────
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<EpubEditPage> {
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<String> _collectUnique(List<List<String>> lists) {
final s = <String>{};
for (final l in lists) {
s.addAll(l);
}
return s.toList()..sort();
}

View File

@@ -63,6 +63,13 @@ class _EpubHighlightsPageState extends State<EpubHighlightsPage> {
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,

View File

@@ -20,7 +20,10 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
final ReaderDao _dao = ReaderDao();
final EpubService _service = EpubService();
List<Map<String, dynamic>> _books = [];
List<Map<String, dynamic>> _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<EpubLibraryPage> {
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<void> _pickAndImport() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
@@ -138,6 +169,12 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
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<EpubLibraryPage> {
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<EpubLibraryPage> {
? 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<EpubLibraryPage> {
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,

View File

@@ -112,9 +112,17 @@ class FootnotePopupOverlayState extends State<FootnotePopupOverlay>
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('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.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();

View File

@@ -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(

View File

@@ -2022,30 +2022,58 @@ class _SettingsPageState extends State<SettingsPage> {
);
}
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<AppProvider>();
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<SettingsPage> {
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<void> _clearCacheData(BuildContext context) async {
try {
showDialog(
@@ -2302,6 +2351,154 @@ class _SettingsPageState extends State<SettingsPage> {
} catch (_) {}
return count;
}
// ─── 扫描方法(只统计不删除) ──────────────────────────────────────────────
/// 返回 (文件数, 总字节数)
Future<(int, int)> _scanImageDirectory(Set<String> 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 = <String>{};
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<int> _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<int> _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;
}
}
// ─── 功能设置 ───