优化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(