Files
MookNote/lib/pages/book/book_detail_page.dart
2026-08-09 01:22:35 +08:00

2262 lines
94 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart' as p;
import 'package:provider/provider.dart';
import 'package:http/http.dart' as http;
import '../../widgets/fade_in_local_image.dart';
import '../../providers/app_provider.dart';
import '../../models/data_models.dart';
import '../../utils/toast_util.dart';
import '../../utils/user_prefs.dart';
import '../../utils/image_path_helper.dart';
import '../../utils/responsive.dart';
import '../../widgets/genre_selector_page.dart';
import '../../widgets/work_people_section.dart';
import '../../widgets/character_preview_section.dart';
import '../../widgets/character_info_sheet.dart';
import 'book_reviews_page.dart';
import 'book_excerpts_page.dart';
import 'book_share_page.dart';
import '../character/character_list_page.dart';
import '../../data/epub/reader_dao.dart';
import '../epub_reader/epub_highlights_page.dart';
import '../epub_reader/reader_screen.dart';
/// 书籍详情页 - 极简主义设计
class BookDetailPage extends StatefulWidget {
final Book book;
final bool embedded;
const BookDetailPage({super.key, required this.book, this.embedded = false});
@override
State<BookDetailPage> createState() => _BookDetailPageState();
}
class _BookDetailPageState extends State<BookDetailPage> {
late int _detailStyle;
final ValueNotifier<double> _coverOffset = ValueNotifier(0.0);
double _coverDragStartOffset = 0.0;
final ValueNotifier<bool> _draggingCover = ValueNotifier(false);
final GlobalKey _coverImageKey = GlobalKey();
double _coverImageHeight = 0.0;
final ValueNotifier<bool> _showTitle = ValueNotifier(false);
ScrollController? _overlayScrollController;
// ─── 角色预览 ───
List<dynamic> _characters = [];
// ─── 编辑模式 ───
bool _isEditing = false;
final _editFormKey = GlobalKey<FormState>();
late TextEditingController _titleCtrl;
late TextEditingController _summaryCtrl;
late TextEditingController _ratingCtrl;
late TextEditingController _publisherCtrl;
late TextEditingController _isbnCtrl;
List<String> _editAuthors = [];
List<String> _editTranslators = [];
List<String> _editGenres = [];
List<String> _editAlternateTitles = [];
String? _editCoverPath;
String _editStatus = 'want_to_read';
DateTime? _editPublishDate;
DateTime? _editStartDate;
DateTime? _editFinishDate;
bool _editIsDownloading = false;
final ImagePicker _picker = ImagePicker();
@override
void dispose() {
_coverOffset.dispose();
_draggingCover.dispose();
_showTitle.dispose();
_overlayScrollController?.dispose();
_titleCtrl.dispose();
_summaryCtrl.dispose();
_ratingCtrl.dispose();
_publisherCtrl.dispose();
_isbnCtrl.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
_detailStyle = UserPrefs().detailPageStyle;
_coverOffset.value = UserPrefs().getCoverOffset(widget.book.id);
_initEditControllers();
_loadCharacters();
}
Future<void> _loadCharacters() async {
final list = await context.read<AppProvider>().getBookCharacters(widget.book.id);
if (!mounted) return;
setState(() => _characters = list);
}
void _initEditControllers() {
final b = widget.book;
_titleCtrl = TextEditingController(text: b.title);
_summaryCtrl = TextEditingController(text: b.summary ?? '');
_ratingCtrl = TextEditingController(text: b.rating?.toString() ?? '');
_publisherCtrl = TextEditingController(text: b.publisher ?? '');
_isbnCtrl = TextEditingController(text: b.isbn ?? '');
_editAuthors = List.from(b.authors);
_editTranslators = List.from(b.translators);
_editGenres = List.from(b.genres);
_editAlternateTitles = List.from(b.alternateTitles);
_editCoverPath = b.coverPath;
_editStatus = b.status;
_editPublishDate = b.publishDate;
_editStartDate = b.startDate;
_editFinishDate = b.finishDate;
}
void _enterEditMode() {
final latest = context.read<AppProvider>().books
.where((b) => b.id == widget.book.id).firstOrNull ?? widget.book;
_titleCtrl.text = latest.title;
_summaryCtrl.text = latest.summary ?? '';
_ratingCtrl.text = latest.rating?.toString() ?? '';
_publisherCtrl.text = latest.publisher ?? '';
_isbnCtrl.text = latest.isbn ?? '';
_editAuthors = List.from(latest.authors);
_editTranslators = List.from(latest.translators);
_editGenres = List.from(latest.genres);
_editAlternateTitles = List.from(latest.alternateTitles);
_editCoverPath = latest.coverPath;
_editStatus = latest.status;
_editPublishDate = latest.publishDate;
_editStartDate = latest.startDate;
_editFinishDate = latest.finishDate;
setState(() => _isEditing = true);
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final book = context.watch<AppProvider>().books
.where((b) => b.id == widget.book.id)
.firstOrNull ?? widget.book;
if (Breakpoint.isDesktop(context)) {
return _buildDesktopStyle(book, colors);
}
if (_detailStyle == 1) {
return _buildOverlayStyle(book, colors);
}
return _buildStandardStyle(book, colors);
}
/// 桌面端左右分栏布局
Widget _buildDesktopStyle(Book book, ColorScheme colors) {
if (_isEditing) return _buildDesktopEditStyle(book, colors);
final hasCover = book.coverPath != null && book.coverPath!.isNotEmpty;
return Scaffold(
backgroundColor: colors.surface,
body: Column(
children: [
// 顶栏
Container(
height: 48,
decoration: BoxDecoration(
color: colors.surface,
border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5)),
),
child: Row(children: [
IconButton(
icon: Icon(Icons.arrow_back, color: colors.onSurface, size: 18),
onPressed: widget.embedded
? () => context.read<AppProvider>().selectBook(null)
: () => Navigator.pop(context),
),
Expanded(
child: Text(book.title,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface),
maxLines: 1, overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 4),
]),
),
// 主体:左封面 + 右信息
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 左侧封面
Container(
width: 240,
padding: const EdgeInsets.all(20),
child: Column(
children: [
Container(
width: 200,
height: 280,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
boxShadow: hasCover
? [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 12, offset: const Offset(0, 4))]
: null,
),
clipBehavior: Clip.antiAlias,
child: hasCover
? FadeInLocalImage(path: book.coverPath, fit: BoxFit.cover)
: Center(child: Icon(Icons.menu_book, size: 48, color: colors.onSurface.withValues(alpha: 0.25))),
),
],
),
),
// 右侧信息(可滚动)
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(0, 20, 24, 80),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(book.title,
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: colors.onSurface, height: 1.3)),
if (book.alternateTitles.isNotEmpty) ...[
const SizedBox(height: 8),
Text(book.alternateTitles.join(' / '),
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4), height: 1.4)),
],
_buildEpubProgressBar(book),
const SizedBox(height: 16),
Wrap(spacing: 6, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [
if (book.rating != null) ...[
Icon(Icons.star, size: 20, color: colors.onSurface),
const SizedBox(width: 4),
Text(book.rating!.toStringAsFixed(1),
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
],
_buildStatusTag(book),
]),
const SizedBox(height: 8),
Text('添加于 ${_formatDate(book.createdAt)}',
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
Divider(height: 32, thickness: 0.5, color: colors.outline),
// 详细信息
_buildDesktopInfoRow('作者', book.authors.join(''), colors),
if (book.translators.isNotEmpty)
_buildDesktopInfoRow('译者', book.translators.join(''), colors),
if (book.genres.isNotEmpty) ...[
const SizedBox(height: 8),
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(width: 56, child: Text('类型', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))),
Expanded(child: Wrap(spacing: 8, runSpacing: 8,
children: book.genres.map((g) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(16)),
child: Text(g, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))),
)).toList(),
)),
]),
],
if (book.isbn != null && book.isbn!.isNotEmpty)
_buildDesktopInfoRow('ISBN', book.isbn!, colors),
if (book.publisher != null && book.publisher!.isNotEmpty)
_buildDesktopInfoRow('出版社', book.publisher!, colors),
if (book.publishDate != null)
_buildDesktopInfoRow('出版时间', '${book.publishDate!.year}${book.publishDate!.month.toString().padLeft(2, '0')}', colors),
if (book.startDate != null || book.finishDate != null) ...[
const SizedBox(height: 8),
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(width: 56, child: Text('阅读日期', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))),
Expanded(child: Wrap(spacing: 12, runSpacing: 8, children: [
if (book.startDate != null) _buildDateChip('开始', book.startDate!, false),
if (book.finishDate != null) _buildDateChip('读完', book.finishDate!, false),
])),
]),
],
if (book.readCount > 0) ...[
const SizedBox(height: 8),
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(width: 56, child: Text('阅读次数', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))),
Expanded(child: Text('${book.readCount}', style: TextStyle(fontSize: 13, color: colors.onSurface))),
]),
],
CharacterPreviewSection(
characters: _characters,
onTap: _openCharacterSheet,
),
WorkPeopleSection(workId: book.id, workType: 'book'),
if (book.summary != null && book.summary!.isNotEmpty) ...[
Divider(height: 32, thickness: 0.5, color: colors.outline),
Row(children: [
Container(width: 4, height: 16, decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2))),
const SizedBox(width: 8),
Text('简介', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
]),
const SizedBox(height: 12),
Text(book.summary!, style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.8)),
],
Divider(height: 32, thickness: 0.5, color: colors.outline),
Row(children: [
Container(width: 4, height: 16, decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2))),
const SizedBox(width: 8),
Text('更多', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
]),
const SizedBox(height: 16),
_buildExtraSectionItem(
icon: Icons.rate_review_outlined,
title: '书评',
subtitleFuture: context.read<AppProvider>().getBookReviewCount(book.id),
emptyText: '暂无书评',
unit: '条书评',
onTap: () => _navigateToReviews(book),
),
const SizedBox(height: 12),
_buildExtraSectionItem(
icon: Icons.format_quote_outlined,
title: '摘抄',
subtitleFuture: context.read<AppProvider>().getBookExcerptCount(book.id),
emptyText: '暂无摘抄',
unit: '条摘抄',
onTap: () => _navigateToExcerpts(book),
),
const SizedBox(height: 12),
_buildExtraSectionItem(
icon: Icons.highlight_outlined,
title: '句读',
subtitleFuture: _getEpubHighlightCount(book.id),
emptyText: '暂无句读',
unit: '条句读',
onTap: () => _navigateToEpubHighlights(book),
),
const SizedBox(height: 12),
_buildExtraSectionItem(
icon: Icons.people_outline,
title: '角色',
subtitleFuture: context.read<AppProvider>().getBookCharacterCount(book.id),
emptyText: '暂无角色',
unit: '个角色',
onTap: () => _navigateToCharacters(book),
),
],
),
),
),
],
),
),
// 底部操作栏
Container(
height: 56,
decoration: BoxDecoration(
color: colors.surface,
border: Border(top: BorderSide(color: colors.outlineVariant, width: 0.5)),
),
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_buildEpubReadButtonBar(book, colors),
OutlinedButton.icon(
onPressed: () => _showDeleteDialog(context),
icon: Icon(Icons.delete_outline, size: 16, color: colors.error),
label: Text('删除', style: TextStyle(color: colors.error)),
style: OutlinedButton.styleFrom(
side: BorderSide(color: colors.error.withValues(alpha: 0.3)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
const SizedBox(width: 12),
FilledButton.icon(
onPressed: _enterEditMode,
icon: const Icon(Icons.edit_outlined, size: 16),
label: const Text('编辑'),
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
],
),
),
],
),
);
}
// ─── 桌面端编辑模式 ──────────────────────────────────────────
Widget _buildDesktopEditStyle(Book book, ColorScheme colors) {
final hasCover = _editCoverPath != null && _editCoverPath!.isNotEmpty;
return Scaffold(
backgroundColor: colors.surface,
body: Form(
key: _editFormKey,
child: Column(
children: [
Container(
height: 48,
decoration: BoxDecoration(color: colors.surface,
border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5))),
child: Row(children: [
IconButton(icon: Icon(Icons.close, color: colors.onSurface, size: 18),
onPressed: () => setState(() => _isEditing = false)),
Expanded(child: Text('编辑书籍',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface))),
FilledButton.icon(onPressed: _saveEdit,
icon: const Icon(Icons.check, size: 16), label: const Text('保存'),
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)))),
const SizedBox(width: 12),
]),
),
Expanded(
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
// 左侧:封面 + 状态/评分
Container(width: 240, padding: const EdgeInsets.all(20), child: Column(children: [
GestureDetector(onTap: _showEditCoverOptions, child: Container(
width: 200, height: 280,
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)),
clipBehavior: Clip.antiAlias,
child: Stack(alignment: Alignment.center, children: [
hasCover ? FadeInLocalImage(path: _editCoverPath, fit: BoxFit.cover)
: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.image_outlined, size: 32, color: colors.onSurface.withValues(alpha: 0.25)),
const SizedBox(height: 8),
Text('点击添加封面', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))),
]),
if (_editIsDownloading) Container(color: Colors.black.withValues(alpha: 0.4),
child: const CircularProgressIndicator(strokeWidth: 2, color: Colors.white)),
]),
)),
if (hasCover) Padding(padding: const EdgeInsets.only(top: 8),
child: GestureDetector(onTap: () => setState(() => _editCoverPath = null),
child: Text('移除封面', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))))),
const SizedBox(height: 20),
_buildEditSectionLabel('状态', colors),
const SizedBox(height: 6),
Container(padding: const EdgeInsets.all(2),
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(6)),
child: Wrap(spacing: 0, runSpacing: 4, children: [
_buildEditStatusChip('想读', 'want_to_read', colors),
_buildEditStatusChip('在读', 'reading', colors),
_buildEditStatusChip('已读', 'read', colors),
_buildEditStatusChip('弃读', 'abandoned', colors),
])),
const SizedBox(height: 16),
_buildEditSectionLabel('评分', colors),
const SizedBox(height: 6),
Row(children: [
...List.generate(5, (i) {
final starVal = i + 1;
final currentRating = double.tryParse(_ratingCtrl.text) ?? 0;
final starRating = currentRating / 2;
final isFilled = starVal <= starRating;
final isHalf = starVal == starRating.ceil() && starRating % 1 != 0;
return GestureDetector(onTap: () => setState(() => _ratingCtrl.text = (starVal * 2).toString()),
child: Padding(padding: const EdgeInsets.symmetric(horizontal: 1),
child: Icon(isHalf ? Icons.star_half : (isFilled ? Icons.star : Icons.star_border),
size: 20, color: (isFilled || isHalf) ? const Color(0xFFFFB800) : colors.outline)));
}),
const SizedBox(width: 8),
Container(width: 48, height: 28,
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(6)),
child: TextFormField(controller: _ratingCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
textAlign: TextAlign.center, inputFormatters: [_RatingInputFormatter()],
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface),
decoration: InputDecoration(hintText: '0-10', hintStyle: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.25)),
border: InputBorder.none, contentPadding: const EdgeInsets.symmetric(vertical: 6), isDense: true),
onChanged: (_) => setState(() {}))),
if (_ratingCtrl.text.isNotEmpty) ...[
const SizedBox(width: 4),
GestureDetector(onTap: () => setState(() => _ratingCtrl.clear()),
child: Icon(Icons.close, size: 14, color: colors.onSurface.withValues(alpha: 0.3))),
],
]),
])),
// 右侧:可滚动表单
Expanded(child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(0, 20, 24, 80),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_buildEditField('名称', _titleCtrl, hint: '书籍名称', required: true),
const SizedBox(height: 16),
_buildEditChipField('作者', _editAuthors, 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: _editAuthors, hint: '如:余华、莫言');
if (result != null) setState(() => _editAuthors = result);
}),
const SizedBox(height: 16),
_buildEditChipField('译者', _editTranslators, onTap: () async {
final provider = context.read<AppProvider>();
final data = provider.books.map((b) => b.translators).toList();
final result = await GenreSelectorPage.show(context: context, title: '选择译者',
existingTagsFuture: compute(_collectUnique, data), initialSelected: _editTranslators, hint: '如:林少华');
if (result != null) setState(() => _editTranslators = result);
}),
const SizedBox(height: 16),
_buildEditChipField('别名', _editAlternateTitles, onTap: () async {
final result = await GenreSelectorPage.show(context: context, title: '添加别名',
existingTags: [], initialSelected: _editAlternateTitles, hint: '输入别名');
if (result != null) setState(() => _editAlternateTitles = result);
}),
const SizedBox(height: 16),
_buildEditChipField('类型', _editGenres, onTap: () async {
final provider = context.read<AppProvider>();
final tags = await provider.getTags('book_genre', excludeHidden: true);
final names = tags.map((t) => t['name'] as String).toList();
if (!mounted) return;
final result = await GenreSelectorPage.show(context: context, title: '选择类型',
existingTags: names, initialSelected: _editGenres, hint: '如:小说、科幻');
if (result != null) setState(() => _editGenres = result);
}),
const SizedBox(height: 16),
_buildEditField('出版社', _publisherCtrl, hint: '出版社名称'),
const SizedBox(height: 16),
_buildEditField('ISBN', _isbnCtrl, hint: 'ISBN编号'),
const SizedBox(height: 16),
Row(children: [
Expanded(child: _buildEditDateField('出版时间', _editPublishDate, (d) => setState(() => _editPublishDate = d))),
const SizedBox(width: 12),
Expanded(child: _buildEditDateField('开始阅读', _editStartDate, (d) => setState(() => _editStartDate = d), clearable: true)),
]),
const SizedBox(height: 16),
_buildEditDateField('读完日期', _editFinishDate, (d) => setState(() => _editFinishDate = d), clearable: true),
const SizedBox(height: 16),
_buildEditSectionLabel('简介', colors),
const SizedBox(height: 6),
Container(constraints: const BoxConstraints(minHeight: 120),
child: TextFormField(controller: _summaryCtrl, maxLines: null,
style: TextStyle(fontSize: 14, color: colors.onSurface, height: 1.6),
decoration: InputDecoration(hintText: '写下书籍简介...',
hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)),
filled: true, fillColor: colors.surfaceContainerHighest.withValues(alpha: 0.5),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none),
contentPadding: const EdgeInsets.all(12)))),
]),
)),
]),
),
Container(height: 48,
decoration: BoxDecoration(color: colors.surface,
border: Border(top: BorderSide(color: colors.outlineVariant, width: 0.5))),
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [
OutlinedButton.icon(onPressed: () => _showDeleteDialog(context),
icon: Icon(Icons.delete_outline, size: 16, color: colors.error),
label: Text('删除', style: TextStyle(color: colors.error)),
style: OutlinedButton.styleFrom(side: BorderSide(color: colors.error.withValues(alpha: 0.3)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)))),
])),
],
),
),
);
}
Widget _buildEditSectionLabel(String label, ColorScheme colors) {
return Text(label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4)));
}
Widget _buildEditStatusChip(String label, String value, ColorScheme colors) {
final selected = _editStatus == value;
return GestureDetector(onTap: () => setState(() => _editStatus = value),
child: Container(padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: selected ? colors.surface : Colors.transparent,
borderRadius: BorderRadius.circular(6),
boxShadow: selected ? [BoxShadow(color: colors.onSurface.withValues(alpha: 0.03), blurRadius: 4, offset: const Offset(0, 2))] : null),
child: Text(label, style: TextStyle(fontSize: 12, fontWeight: selected ? FontWeight.w500 : FontWeight.normal,
color: selected ? colors.onSurface : colors.onSurface.withValues(alpha: 0.4)))));
}
Widget _buildEditField(String label, TextEditingController ctrl, {String hint = '', bool required = false}) {
final colors = Theme.of(context).colorScheme;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(required ? '$label *' : label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 6),
TextFormField(controller: ctrl, style: TextStyle(fontSize: 14, color: colors.onSurface),
validator: required ? (v) => (v == null || v.trim().isEmpty) ? '请输入$label' : null : null,
decoration: InputDecoration(hintText: hint, hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)),
filled: true, fillColor: colors.surfaceContainerHighest.withValues(alpha: 0.5),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), isDense: true)),
]);
}
Widget _buildEditChipField(String label, List<String> chips, {required VoidCallback onTap}) {
final colors = Theme.of(context).colorScheme;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 6),
GestureDetector(onTap: onTap,
child: Container(width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(color: colors.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(8)),
child: chips.isEmpty
? Text('点击选择$label', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.25)))
: Wrap(spacing: 4, runSpacing: 4, children: chips.map((c) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(4)),
child: Text(c, style: TextStyle(fontSize: 12, color: colors.onSurface)))).toList()))),
]);
}
Widget _buildEditDateField(String label, DateTime? date, ValueChanged<DateTime?> onChanged, {bool clearable = false}) {
final colors = Theme.of(context).colorScheme;
final hasDate = date != null;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 6),
GestureDetector(onTap: () async {
final picked = await showDatePicker(context: context, initialDate: date ?? DateTime.now(),
firstDate: DateTime(1900), lastDate: DateTime.now().add(const Duration(days: 365 * 5)));
if (picked != null) onChanged(picked);
},
child: Container(width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(color: colors.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(8)),
child: Row(children: [
Icon(Icons.calendar_today_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.4)),
const SizedBox(width: 8),
Expanded(child: Text(hasDate ? '${date!.year}.${date!.month.toString().padLeft(2, '0')}.${date!.day.toString().padLeft(2, '0')}' : '\u9009\u62E9\u65E5\u671F',
style: TextStyle(fontSize: 14, color: hasDate ? colors.onSurface : colors.onSurface.withValues(alpha: 0.25)), overflow: TextOverflow.ellipsis)),
if (clearable && hasDate) GestureDetector(onTap: () => onChanged(null),
child: Icon(Icons.close, size: 14, color: colors.onSurface.withValues(alpha: 0.3))),
]))),
]);
}
void _showEditCoverOptions() {
final colors = Theme.of(context).colorScheme;
showModalBottomSheet(context: context, backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => SafeArea(child: Padding(padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(width: 40, height: 4, decoration: BoxDecoration(color: colors.outline, borderRadius: BorderRadius.circular(2))),
const SizedBox(height: 20),
Padding(padding: const EdgeInsets.symmetric(horizontal: 24), child: Align(alignment: Alignment.centerLeft,
child: Text('添加封面', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)))),
const SizedBox(height: 16),
ListTile(leading: Icon(Icons.photo_library_outlined, color: colors.onSurface.withValues(alpha: 0.6)),
title: Text('从相册选择', style: TextStyle(color: colors.onSurface)),
onTap: () { Navigator.pop(ctx); _pickEditCover(); }),
ListTile(leading: Icon(Icons.link_outlined, color: colors.onSurface.withValues(alpha: 0.6)),
title: Text('网络链接', style: TextStyle(color: colors.onSurface)),
onTap: () { Navigator.pop(ctx); _pickEditCoverFromUrl(); }),
]))));
}
Future<void> _pickEditCover() async {
try {
final XFile? picked = await _picker.pickImage(source: ImageSource.gallery, maxWidth: 800, maxHeight: 1200, imageQuality: 85);
if (picked == null) return;
final fileName = 'cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
final targetPath = await ImagePathHelper.instance.getBookCoverPath(widget.book.id, fileName);
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
await File(picked.path).copy(targetPath);
if (mounted) setState(() => _editCoverPath = targetPath);
} catch (e) {
if (mounted) ToastUtil.show(context, '选择封面失败: $e');
}
}
Future<void> _pickEditCoverFromUrl() async {
final urlCtrl = TextEditingController();
final confirmed = await showDialog<bool>(context: context, builder: (ctx) {
final colors = Theme.of(ctx).colorScheme;
return AlertDialog(backgroundColor: colors.surface, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Text('添加网络图片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('请输入图片链接地址', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))),
const SizedBox(height: 12),
TextField(controller: urlCtrl, keyboardType: TextInputType.url, style: TextStyle(fontSize: 14, color: colors.onSurface),
decoration: InputDecoration(hintText: 'https://example.com/image.jpg',
hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)),
filled: true, fillColor: colors.surfaceContainerHigh,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: colors.primary, width: 1)))),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))),
ElevatedButton(onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)),
child: const Text('确定')),
]);
});
final url = urlCtrl.text.trim(); urlCtrl.dispose();
if (confirmed != true || url.isEmpty) return;
await _downloadEditCoverFromUrl(url);
}
Future<void> _downloadEditCoverFromUrl(String url) async {
setState(() => _editIsDownloading = true);
try {
final response = await http.get(Uri.parse(url), headers: {
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15',
'Accept': 'image/avif,image/webp,image/apng,*/*;q=0.8',
'Referer': Uri.parse(url).replace(path: '/').toString(),
});
if (response.statusCode != 200) throw Exception('下载失败: HTTP ${response.statusCode}');
final contentType = response.headers['content-type'];
if (contentType != null && !contentType.startsWith('image/')) throw Exception('链接返回的不是图片');
if (response.bodyBytes.length > 10 * 1024 * 1024) throw Exception('图片太大');
final fileName = 'cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
final targetPath = await ImagePathHelper.instance.getBookCoverPath(widget.book.id, fileName);
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
await File(targetPath).writeAsBytes(response.bodyBytes);
if (mounted) setState(() => _editCoverPath = targetPath);
} catch (e) {
if (mounted) ToastUtil.show(context, '下载失败: $e');
} finally {
if (mounted) setState(() => _editIsDownloading = false);
}
}
Future<void> _saveEdit() async {
if (!_editFormKey.currentState!.validate()) return;
try {
final rating = _ratingCtrl.text.isNotEmpty ? double.tryParse(_ratingCtrl.text) : null;
final updated = widget.book.copyWith(
title: _titleCtrl.text.trim(),
coverPath: _editCoverPath,
authors: _editAuthors,
translators: _editTranslators,
alternateTitles: _editAlternateTitles,
genres: _editGenres,
publisher: _publisherCtrl.text.trim().isNotEmpty ? _publisherCtrl.text.trim() : null,
isbn: _isbnCtrl.text.trim().isNotEmpty ? _isbnCtrl.text.trim() : null,
summary: _summaryCtrl.text.trim(),
rating: rating,
status: _editStatus,
publishDate: _editPublishDate,
startDate: _editStartDate,
finishDate: _editFinishDate,
updatedAt: DateTime.now(),
);
await context.read<AppProvider>().updateBook(updated);
if (!mounted) return;
ToastUtil.show(context, '更新成功');
setState(() => _isEditing = false);
} catch (e) {
if (mounted) ToastUtil.show(context, '保存失败: $e');
}
}
static List<String> _collectUnique(List<List<String>> lists) {
final s = <String>{};
for (final l in lists) { s.addAll(l); }
return s.toList()..sort();
}
Widget _buildDesktopInfoRow(String label, String value, ColorScheme colors) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 56, child: Text(label, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))),
Expanded(child: Text(value, style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.5))),
],
),
);
}
/// EPUB 阅读按钮(底部栏样式)
Widget _buildEpubReadButtonBar(Book book, ColorScheme colors) {
return FutureBuilder<Map<String, dynamic>?>(
future: ReaderDao().getReaderBookByBookId(book.id),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data == null) {
return const SizedBox.shrink();
}
final readerBook = snapshot.data!;
return Row(children: [
FilledButton.tonalIcon(
onPressed: () {
if (Platform.isWindows) {
ToastUtil.show(context, 'Windows 桌面客户端暂不支持 EPUB 阅读功能');
return;
}
Navigator.push(context, MaterialPageRoute(
builder: (_) => ReaderScreen(
bookId: readerBook['id'] as String,
filePath: readerBook['file_path'] as String,
title: readerBook['title'] as String? ?? '',
coverPath: readerBook['cover_path'] as String?,
bookData: readerBook,
),
));
},
icon: const Icon(Icons.auto_stories_outlined, size: 16),
label: const Text('EPUB 阅读'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF6750A4).withValues(alpha: 0.15),
foregroundColor: const Color(0xFF6750A4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
const SizedBox(width: 12),
]);
},
);
}
/// 标准样式
Widget _buildStandardStyle(Book book, ColorScheme colors) {
final topSafe = MediaQuery.of(context).padding.top;
return Scaffold(
backgroundColor: colors.surface,
body: Stack(
children: [
// 整体可滚动(封面 + 内容一起滑动)
Padding(
padding: EdgeInsets.only(top: topSafe + 48),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 封面图
SizedBox(
height: 320,
width: double.infinity,
child: _buildCoverSection(book),
),
// 详细信息
_buildBasicInfo(book),
Divider(height: 0.5, thickness: 0.5, color: colors.outline),
_buildAuthorsSection(book),
if (book.translators.isNotEmpty) _buildTranslatorsSection(book),
if (book.genres.isNotEmpty) _buildGenresSection(book),
if (book.isbn != null && book.isbn!.isNotEmpty) _buildIsbnSection(book),
if (book.publisher != null && book.publisher!.isNotEmpty) _buildPublisherSection(book),
if (book.publishDate != null) _buildPublishDateSection(book),
if (book.startDate != null || book.finishDate != null || book.readCount > 0) _buildReadingDatesSection(book),
CharacterPreviewSection(
characters: _characters,
onTap: _openCharacterSheet,
),
WorkPeopleSection(workId: book.id, workType: 'book'),
if (book.summary != null && book.summary!.isNotEmpty) _buildSummarySection(book),
Divider(height: 0.5, thickness: 0.5, color: colors.outline),
_buildExtraSections(book),
const SizedBox(height: 120),
],
),
),
),
_buildStandardTopBar(book.title, colors),
Positioned(right: 16, bottom: 24, child: _buildFloatingActionButtons(book)),
],
),
);
}
/// 毛玻璃层叠样式:海报背景 + 毛玻璃 + 内容
Widget _buildOverlayStyle(Book book, ColorScheme colors) {
final screenH = MediaQuery.of(context).size.height;
final hasCover = book.coverPath != null && book.coverPath!.isNotEmpty;
_overlayScrollController ??= ScrollController()..addListener(() {
final show = (_overlayScrollController?.offset ?? 0) > 10;
if (_showTitle.value != show) _showTitle.value = show;
});
return Scaffold(
body: Stack(
children: [
// 海报背景(高度不够时重复拼接)
if (hasCover)
Positioned.fill(
child: Image(
image: FileImage(File(book.coverPath!)),
fit: BoxFit.cover,
width: double.infinity,
height: screenH,
repeat: ImageRepeat.repeatY,
),
)
else
Container(color: colors.surfaceContainerHighest),
// 毛玻璃层
ClipRect(
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 25, sigmaY: 25),
child: Container(color: Colors.black.withValues(alpha: 0.3)),
),
),
// 内容
SafeArea(
child: Column(
children: [
// 顶部栏:只在滚动后显示标题
SizedBox(
height: 48,
child: Row(children: [
const SizedBox(width: 4),
IconButton(
icon: Icon(widget.embedded ? Icons.arrow_back : Icons.arrow_back_ios_new, color: Colors.white, size: 18),
onPressed: widget.embedded
? () => context.read<AppProvider>().selectBook(null)
: () => Navigator.pop(context),
),
ValueListenableBuilder<bool>(
valueListenable: _showTitle,
builder: (_, show, __) => AnimatedOpacity(
opacity: show ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.5),
child: Text(book.title,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white),
maxLines: 1, overflow: TextOverflow.ellipsis),
),
),
),
const Spacer(),
_buildStyleButton(),
]),
),
// 可滚动内容
Expanded(
child: SingleChildScrollView(
controller: _overlayScrollController,
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 封面缩略图 + 标题
_buildOverlayHeader(book),
const SizedBox(height: 20),
// 信息区块:无毛玻璃
_buildAuthorsSection(book),
if (book.translators.isNotEmpty) _buildTranslatorsSection(book),
if (book.isbn != null && book.isbn!.isNotEmpty) _buildIsbnSection(book),
if (book.publisher != null && book.publisher!.isNotEmpty) _buildPublisherSection(book),
if (book.publishDate != null) _buildPublishDateSection(book),
if (book.startDate != null || book.finishDate != null || book.readCount > 0) _buildReadingDatesSection(book),
// 类型标签毛玻璃
if (book.genres.isNotEmpty) _buildGenresSection(book),
CharacterPreviewSection(
characters: _characters,
onTap: _openCharacterSheet,
isOverlay: true,
),
// 关联人物
WorkPeopleSection(workId: book.id, workType: 'book'),
// 简介:内部已有毛玻璃卡片
if (book.summary != null && book.summary!.isNotEmpty) ...[
const SizedBox(height: 12),
_buildSummarySection(book),
],
const SizedBox(height: 12),
const SizedBox(height: 12),
// 书评、书摘毛玻璃
_buildExtraSectionsOverlay(book),
],
),
),
),
],
),
),
Positioned(right: 16, bottom: 24, child: _buildFloatingActionButtons(book)),
],
),
);
}
/// 叠层模式顶部:封面小图 + 标题/评分
Widget _buildOverlayHeader(Book book) {
final hasCover = book.coverPath != null && book.coverPath!.isNotEmpty;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 封面缩略图
Container(
width: 100, height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 12, offset: const Offset(0, 4))],
),
clipBehavior: Clip.antiAlias,
child: hasCover
? FadeInLocalImage(path: book.coverPath, fit: BoxFit.cover)
: Container(color: Colors.white24, child: const Icon(Icons.menu_book, color: Colors.white38, size: 32)),
),
const SizedBox(width: 16),
// 标题 + 信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(book.title, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white)),
if (book.authors.isNotEmpty) ...[
const SizedBox(height: 6),
Text(book.authors.join(' / '), style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: 0.6))),
],
const SizedBox(height: 12),
Row(children: [
if (book.rating != null && book.rating! > 0) ...[
const Icon(Icons.star, size: 16, color: Color(0xFFFFB800)),
const SizedBox(width: 4),
Text(book.rating!.toStringAsFixed(1), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFFFFB800))),
const SizedBox(width: 16),
],
_statusChip(book.status),
]),
],
),
),
],
);
}
Widget _buildFloatingActionButtons(Book book) {
final colors = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildFloatingButton(
icon: Icons.edit_outlined,
onPressed: () => _navigateToEdit(context),
tooltip: '编辑',
backgroundColor: colors.primary,
foregroundColor: colors.onPrimary,
),
const SizedBox(height: 12),
_buildFloatingButton(
icon: Icons.people_outline,
onPressed: () => _navigateToCharacters(book),
tooltip: '角色',
backgroundColor: colors.secondaryContainer,
foregroundColor: colors.onSecondaryContainer,
),
const SizedBox(height: 12),
_buildFloatingButton(
icon: Icons.delete_outline,
onPressed: () => _showDeleteDialog(context),
tooltip: '删除',
backgroundColor: colors.error,
foregroundColor: colors.onError,
),
const SizedBox(height: 12),
_buildEpubReadButton(book),
if (!Platform.isWindows) ...[
const SizedBox(height: 12),
_buildFloatingButton(
icon: Icons.share_outlined,
onPressed: () => _showSharePoster(book),
tooltip: '分享海报',
backgroundColor: const Color(0xFF4CAF50),
foregroundColor: Colors.white,
),
],
],
);
}
/// EPUB 阅读悬浮按钮(仅有关联 EPUB 时显示)
Widget _buildEpubReadButton(Book book) {
return FutureBuilder<Map<String, dynamic>?>(
future: ReaderDao().getReaderBookByBookId(book.id),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data == null) {
return const SizedBox.shrink();
}
final readerBook = snapshot.data!;
return _buildFloatingButton(
icon: Icons.auto_stories_outlined,
onPressed: () {
if (Platform.isWindows) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Windows 桌面客户端暂不支持 EPUB 阅读功能')),
);
return;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ReaderScreen(
bookId: readerBook['id'] as String,
filePath: readerBook['file_path'] as String,
title: readerBook['title'] as String? ?? '',
coverPath: readerBook['cover_path'] as String?,
bookData: readerBook,
),
),
);
},
tooltip: 'EPUB 阅读',
backgroundColor: const Color(0xFF6750A4),
foregroundColor: Colors.white,
);
},
);
}
Widget _buildFloatingButton({
required IconData icon,
required VoidCallback onPressed,
required String tooltip,
required Color backgroundColor,
required Color foregroundColor,
}) {
return Tooltip(
message: tooltip,
child: GestureDetector(
onTap: onPressed,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: backgroundColor,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: backgroundColor.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Icon(icon, size: 18, color: foregroundColor),
),
),
);
}
/// 固定在顶部的导航栏(纯色,与主体一致)
Widget _buildStandardTopBar(String title, ColorScheme colors) {
final topPadding = MediaQuery.of(context).padding.top;
return Positioned(
top: 0, left: 0, right: 0,
child: Container(
padding: EdgeInsets.only(top: topPadding),
color: colors.surface,
child: SizedBox(
height: 48,
child: Row(children: [
const SizedBox(width: 4),
IconButton(
icon: Icon(widget.embedded ? Icons.arrow_back : Icons.arrow_back_ios_new, color: colors.onSurface, size: 18),
onPressed: widget.embedded
? () => context.read<AppProvider>().selectBook(null)
: () => Navigator.pop(context),
),
const SizedBox(width: 4),
Expanded(
child: Text(title,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface),
maxLines: 1, overflow: TextOverflow.ellipsis),
),
_buildStyleButton(color: colors.onSurface),
]),
),
),
);
}
Widget _buildCoverSection(Book book) {
final colors = Theme.of(context).colorScheme;
final hasCover = book.coverPath != null && book.coverPath!.isNotEmpty;
return LayoutBuilder(
builder: (context, constraints) {
final containerH = constraints.maxHeight;
return GestureDetector(
onLongPressStart: hasCover ? (_) {
HapticFeedback.mediumImpact();
final ctx = _coverImageKey.currentContext;
if (ctx != null) {
final box = ctx.findRenderObject() as RenderBox?;
if (box != null) _coverImageHeight = box.size.height;
}
_draggingCover.value = true;
_coverDragStartOffset = _coverOffset.value;
} : null,
onLongPressMoveUpdate: hasCover ? (d) {
final raw = _coverDragStartOffset + d.offsetFromOrigin.dy;
final imgH = _coverImageHeight > 0 ? _coverImageHeight : containerH;
final minOffset = -(imgH - containerH).clamp(0, double.infinity);
_coverOffset.value = (raw.clamp(minOffset, 0.0) as double);
} : null,
onLongPressEnd: hasCover ? (_) {
_draggingCover.value = false;
final offset = _coverOffset.value;
UserPrefs().setCoverOffset(widget.book.id, offset);
context.read<AppProvider>().updateBookCoverOffset(widget.book.id, offset);
} : null,
child: ValueListenableBuilder<double>(
valueListenable: _coverOffset,
builder: (context, offset, _) {
return Stack(
fit: StackFit.expand,
children: [
if (hasCover)
ClipRect(
child: Stack(
children: [
Positioned(
top: offset,
left: 0, right: 0,
child: FadeInLocalImage(
key: _coverImageKey,
path: book.coverPath,
fit: BoxFit.fitWidth,
width: constraints.maxWidth,
),
),
],
),
)
else
_buildCoverPlaceholder(),
// 底部渐变 + 拖动遮罩
ValueListenableBuilder<bool>(
valueListenable: _draggingCover,
builder: (context, dragging, _) {
return Stack(
children: [
if (!dragging)
Positioned(
left: 0, right: 0, bottom: 0,
child: IgnorePointer(
child: Container(
height: 60,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colors.surface.withValues(alpha: 0),
colors.surface,
],
),
),
),
),
),
if (dragging) ...[
Positioned.fill(
child: Container(color: Colors.black.withValues(alpha: 0.3)),
),
Positioned(
left: 0, right: 0, bottom: 20,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(20),
),
child: const Text('上下滑动调整图片位置',
style: TextStyle(fontSize: 13, color: Colors.white70)),
),
),
),
],
],
);
},
),
],
);
},
),
);
},
);
}
Widget _buildCoverPlaceholder() {
final colors = Theme.of(context).colorScheme;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.menu_book,
size: 64,
color: colors.onSurface.withValues(alpha: 0.25),
),
const SizedBox(height: 16),
Text(
'暂无封面',
style: TextStyle(
fontSize: 14,
color: colors.onSurface.withValues(alpha: 0.4),
),
),
],
),
);
}
/// 样式选择按钮
Widget _buildStyleButton({Color color = Colors.white}) {
return IconButton(
icon: Icon(Icons.tune, color: color, size: 20),
tooltip: '切换样式',
onPressed: _showStylePicker,
);
}
void _showStylePicker() {
final colors = Theme.of(context).colorScheme;
const names = ['默认样式', '毛玻璃层叠'];
const icons = [Icons.article_outlined, Icons.blur_on_outlined];
const subtitles = ['标准封面顶部布局', '封面背景 + 毛玻璃卡片'];
showModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(width: 36, height: 4, decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))),
const SizedBox(height: 20),
Align(alignment: Alignment.centerLeft, child: Text('详情页样式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface))),
const SizedBox(height: 12),
for (int i = 0; i < names.length; i++) ...[
if (i > 0) Divider(height: 0.5, color: colors.outlineVariant),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)),
child: Icon(icons[i], size: 20, color: _detailStyle == i ? colors.primary : colors.onSurface.withValues(alpha: 0.6))),
title: Text(names[i], style: TextStyle(fontSize: 13, fontWeight: _detailStyle == i ? FontWeight.w600 : FontWeight.w500, color: colors.onSurface)),
subtitle: Text(subtitles[i], style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
trailing: _detailStyle == i
? Icon(Icons.check_circle, size: 20, color: colors.primary)
: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)),
onTap: () { setState(() => _detailStyle = i); UserPrefs().setDetailPageStyle(i); Navigator.pop(ctx); },
),
],
const SizedBox(height: 12),
]),
),
);
}
Widget _statusChip(String status) {
final colors = Theme.of(context).colorScheme;
final (label, bg, fg) = switch (status) {
'read' => ('已读', colors.primary, colors.onPrimary),
'reading' => ('在读', colors.outlineVariant, colors.onSurface.withValues(alpha: 0.6)),
'want_to_read' => ('想读', colors.surfaceContainerHighest, colors.onSurface.withValues(alpha: 0.4)),
'abandoned' => ('弃读', colors.error.withValues(alpha: 0.15), colors.error),
_ => ('', colors.surfaceContainerHighest, colors.onSurface.withValues(alpha: 0.3)),
};
if (label.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(12)),
child: Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: fg)),
);
}
/// EPUB 阅读进度条(仅有关联 EPUB 时显示)
Widget _buildEpubProgressBar(Book book) {
final colors = Theme.of(context).colorScheme;
return FutureBuilder<Map<String, dynamic>?>(
future: ReaderDao().getReaderBookByBookId(book.id),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data == null) {
return const SizedBox.shrink();
}
final progress = (snapshot.data!['reading_percentage'] as num?)?.toDouble() ?? 0.0;
if (progress <= 0) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 12),
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: progress,
minHeight: 3,
backgroundColor: colors.surfaceContainerHighest,
),
),
);
},
);
}
Widget _buildBasicInfo(Book book) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
book.title,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
color: colors.onSurface,
height: 1.3,
),
),
if (book.alternateTitles.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
book.alternateTitles.join(' / '),
style: TextStyle(
fontSize: 14,
color: colors.onSurface.withValues(alpha: 0.4),
height: 1.4,
),
),
],
// EPUB 阅读进度条
_buildEpubProgressBar(book),
const SizedBox(height: 16),
Row(
children: [
if (book.rating != null) ...[
Icon(
Icons.star,
size: 20,
color: colors.onSurface,
),
const SizedBox(width: 4),
Text(
book.rating!.toStringAsFixed(1),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: colors.onSurface,
),
),
const SizedBox(width: 16),
],
_buildStatusTag(book),
],
),
const SizedBox(height: 8),
Text(
'添加于 ${_formatDate(book.createdAt)}',
style: TextStyle(
fontSize: 12,
color: colors.onSurface.withValues(alpha: 0.4),
),
),
],
),
);
}
Widget _buildStatusTag(Book book) {
final colors = Theme.of(context).colorScheme;
String label;
Color bgColor;
Color textColor;
switch (book.status) {
case 'read':
label = '已读';
bgColor = colors.primary;
textColor = colors.onPrimary;
break;
case 'reading':
label = '在读';
bgColor = colors.outlineVariant;
textColor = colors.onSurface.withValues(alpha: 0.6);
break;
case 'want_to_read':
label = '想读';
bgColor = colors.surfaceContainerHighest;
textColor = colors.onSurface.withValues(alpha: 0.4);
break;
case 'abandoned':
label = '弃读';
bgColor = colors.error.withValues(alpha: 0.15);
textColor = colors.error;
break;
default:
label = '未知';
bgColor = colors.outlineVariant;
textColor = colors.onSurface.withValues(alpha: 0.25);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(6),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
color: textColor,
fontWeight: FontWeight.w600,
),
),
);
}
Widget _buildAuthorsSection(Book book) {
final isOverlay = _detailStyle == 1;
final colors = Theme.of(context).colorScheme;
return Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: isOverlay ? 5 : 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 64,
child: Text(
'作者',
style: TextStyle(
fontSize: 13,
color: isOverlay ? const Color(0x66FFFFFF) : colors.onSurface.withValues(alpha: 0.4),
),
),
),
Expanded(
child: Text(
book.authors.join(''),
style: TextStyle(
fontSize: 15,
color: isOverlay ? Colors.white : colors.onSurface,
height: 1.5,
),
),
),
],
),
);
}
Widget _buildTranslatorsSection(Book book) {
final isOverlay = _detailStyle == 1;
final colors = Theme.of(context).colorScheme;
return Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: isOverlay ? 5 : 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 64,
child: Text(
'译者',
style: TextStyle(
fontSize: 13,
color: isOverlay ? const Color(0x66FFFFFF) : colors.onSurface.withValues(alpha: 0.4),
),
),
),
Expanded(
child: Text(
book.translators.join(''),
style: TextStyle(
fontSize: 15,
color: isOverlay ? Colors.white : colors.onSurface,
height: 1.5,
),
),
),
],
),
);
}
Widget _buildIsbnSection(Book book) {
final isOverlay = _detailStyle == 1;
final colors = Theme.of(context).colorScheme;
return Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: isOverlay ? 5 : 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 64,
child: Text(
'ISBN',
style: TextStyle(
fontSize: 13,
color: isOverlay ? const Color(0x66FFFFFF) : colors.onSurface.withValues(alpha: 0.4),
),
),
),
Expanded(
child: Text(
book.isbn!,
style: TextStyle(
fontSize: 15,
color: isOverlay ? Colors.white : colors.onSurface,
height: 1.5,
),
),
),
],
),
);
}
Widget _buildPublisherSection(Book book) {
final isOverlay = _detailStyle == 1;
final colors = Theme.of(context).colorScheme;
return Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: isOverlay ? 5 : 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 64,
child: Text(
'出版社',
style: TextStyle(
fontSize: 13,
color: isOverlay ? const Color(0x66FFFFFF) : colors.onSurface.withValues(alpha: 0.4),
),
),
),
Expanded(
child: Text(
book.publisher!,
style: TextStyle(
fontSize: 15,
color: isOverlay ? Colors.white : colors.onSurface,
height: 1.5,
),
),
),
],
),
);
}
Widget _buildPublishDateSection(Book book) {
final isOverlay = _detailStyle == 1;
final colors = Theme.of(context).colorScheme;
return Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: isOverlay ? 5 : 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 64,
child: Text(
'出版时间',
style: TextStyle(
fontSize: 13,
color: isOverlay ? const Color(0x66FFFFFF) : colors.onSurface.withValues(alpha: 0.4),
),
),
),
Expanded(
child: Text(
'${book.publishDate!.year}${book.publishDate!.month.toString().padLeft(2, '0')}',
style: TextStyle(
fontSize: 15,
color: isOverlay ? Colors.white : colors.onSurface,
height: 1.5,
),
),
),
],
),
);
}
Widget _buildReadingDatesSection(Book book) {
final isOverlay = _detailStyle == 1;
final colors = Theme.of(context).colorScheme;
return Column(
children: [
if (book.startDate != null || book.finishDate != null)
Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: isOverlay ? 5 : 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 64,
child: Text(
'阅读日期',
style: TextStyle(
fontSize: 13,
color: isOverlay ? const Color(0x66FFFFFF) : colors.onSurface.withValues(alpha: 0.4),
),
),
),
Expanded(
child: Wrap(
spacing: 12,
runSpacing: 8,
children: [
if (book.startDate != null)
_buildDateChip('开始', book.startDate!, isOverlay),
if (book.finishDate != null)
_buildDateChip('读完', book.finishDate!, isOverlay),
],
),
),
],
),
),
if (book.readCount > 0)
Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: isOverlay ? 5 : 4),
child: Row(
children: [
SizedBox(
width: 64,
child: Text(
'阅读次数',
style: TextStyle(
fontSize: 13,
color: isOverlay ? const Color(0x66FFFFFF) : colors.onSurface.withValues(alpha: 0.4),
),
),
),
Expanded(
child: Text(
'${book.readCount}',
style: TextStyle(fontSize: 13, color: isOverlay ? Colors.white70 : colors.onSurface),
),
),
],
),
),
],
);
}
Widget _buildDateChip(String label, DateTime date, bool isOverlay) {
final colors = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: isOverlay ? Colors.white.withValues(alpha: 0.12) : colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'$label ${_formatDate(date)}',
style: TextStyle(
fontSize: 13,
color: isOverlay ? Colors.white.withValues(alpha: 0.85) : colors.onSurface.withValues(alpha: 0.7),
),
),
);
}
Widget _buildGenresSection(Book book) {
final isOverlay = _detailStyle == 1;
return Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: isOverlay ? 5 : 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 48,
child: Text('类型', style: TextStyle(fontSize: 13,
color: isOverlay ? const Color(0x66FFFFFF) : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4))),
),
Expanded(
child: Wrap(
spacing: 8, runSpacing: 8,
children: book.genres.map((g) => _buildGenreChip(g, isOverlay)).toList(),
),
),
],
),
);
}
Widget _buildGenreChip(String label, bool isOverlay) {
if (isOverlay) {
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(16),
),
child: Text(label, style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: 0.85))),
),
),
);
}
final colors = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
),
child: Text(label, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))),
);
}
Widget _buildSummarySection(Book book) {
final isOverlay = _detailStyle == 1;
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: isOverlay ? Colors.white : colors.onSurface,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Text(
'简介',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: isOverlay ? Colors.white : colors.onSurface,
),
),
],
),
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 15, sigmaY: 15),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
),
child: Text(
book.summary!,
style: TextStyle(
fontSize: 15,
color: isOverlay ? Colors.white : colors.onSurface,
height: 1.8,
),
),
),
),
),
],
),
);
}
Widget _buildExtraSections(Book book) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: colors.onSurface,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Text(
'更多',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: colors.onSurface,
),
),
],
),
const SizedBox(height: 16),
_buildExtraSectionItem(
icon: Icons.rate_review_outlined,
title: '书评',
subtitleFuture: context.read<AppProvider>().getBookReviewCount(book.id),
emptyText: '暂无书评',
unit: '条书评',
onTap: () => _navigateToReviews(book),
),
const SizedBox(height: 12),
_buildExtraSectionItem(
icon: Icons.format_quote_outlined,
title: '摘抄',
subtitleFuture: context.read<AppProvider>().getBookExcerptCount(book.id),
emptyText: '暂无摘抄',
unit: '条摘抄',
onTap: () => _navigateToExcerpts(book),
),
const SizedBox(height: 12),
_buildExtraSectionItem(
icon: Icons.highlight_outlined,
title: '句读',
subtitleFuture: _getEpubHighlightCount(book.id),
emptyText: '暂无句读',
unit: '条句读',
onTap: () => _navigateToEpubHighlights(book),
),
const SizedBox(height: 12),
_buildExtraSectionItem(
icon: Icons.people_outline,
title: '角色',
subtitleFuture: context.read<AppProvider>().getBookCharacterCount(book.id),
emptyText: '暂无角色',
unit: '个角色',
onTap: () => _navigateToCharacters(book),
),
],
),
);
}
/// EPUB 阅读入口(通过关联的 reader_books
/// 叠层模式:书评、摘抄各自独立毛玻璃卡片
Widget _buildExtraSectionsOverlay(Book book) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
_buildFrostedExtraItem(
icon: Icons.rate_review_outlined,
title: '书评',
subtitleFuture: context.read<AppProvider>().getBookReviewCount(book.id),
emptyText: '暂无书评',
unit: '条书评',
onTap: () => _navigateToReviews(book),
),
const SizedBox(height: 12),
_buildFrostedExtraItem(
icon: Icons.format_quote_outlined,
title: '摘抄',
subtitleFuture: context.read<AppProvider>().getBookExcerptCount(book.id),
emptyText: '暂无摘抄',
unit: '条摘抄',
onTap: () => _navigateToExcerpts(book),
),
const SizedBox(height: 12),
_buildFrostedExtraItem(
icon: Icons.highlight_outlined,
title: '句读',
subtitleFuture: _getEpubHighlightCount(book.id),
emptyText: '暂无句读',
unit: '条句读',
onTap: () => _navigateToEpubHighlights(book),
),
const SizedBox(height: 12),
_buildFrostedExtraItem(
icon: Icons.people_outline,
title: '角色',
subtitleFuture: context.read<AppProvider>().getBookCharacterCount(book.id),
emptyText: '暂无角色',
unit: '个角色',
onTap: () => _navigateToCharacters(book),
),
],
),
);
}
/// 毛玻璃书评/摘抄卡片
Widget _buildFrostedExtraItem({
required IconData icon,
required String title,
required Future<int> subtitleFuture,
required String emptyText,
required String unit,
required VoidCallback onTap,
}) {
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 15, sigmaY: 15),
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(children: [
Icon(icon, size: 20, color: Colors.white.withValues(alpha: 0.7)),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Colors.white)),
FutureBuilder<int>(
future: subtitleFuture,
builder: (ctx, snap) {
final count = snap.data ?? 0;
return Text(count > 0 ? '$count $unit' : emptyText,
style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.5)));
},
),
]),
),
Icon(Icons.chevron_right, size: 16, color: Colors.white.withValues(alpha: 0.3)),
]),
),
),
),
),
),
);
}
Widget _buildExtraSectionItem({
required IconData icon,
required String title,
required Future<int> subtitleFuture,
required String emptyText,
required String unit,
required VoidCallback onTap,
}) {
final colors = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
child: Icon(
icon,
size: 20,
color: colors.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: colors.onSurface,
),
),
const SizedBox(height: 4),
FutureBuilder<int>(
future: subtitleFuture,
builder: (context, snapshot) {
final count = snapshot.data ?? 0;
return Text(
count > 0 ? '$count $unit' : emptyText,
style: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.4),
),
);
},
),
],
),
),
Icon(
Icons.chevron_right,
color: colors.onSurface.withValues(alpha: 0.25),
),
],
),
),
);
}
void _navigateToReviews(Book book) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BookReviewsPage(book: book),
),
);
}
void _navigateToExcerpts(Book book) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BookExcerptsPage(book: book),
),
);
}
void _navigateToCharacters(Book book) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BookCharactersPage(book: book),
),
).then((_) => _loadCharacters());
}
Future<void> _openCharacterSheet(dynamic character) async {
final needRefresh = await CharacterInfoSheet.show(
context,
entityType: 'book',
entityId: widget.book.id,
character: character,
);
if (needRefresh == true) _loadCharacters();
}
/// 获取关联 EPUB 的句读(高亮)数量
Future<int> _getEpubHighlightCount(String bookId) async {
final readerBook = await ReaderDao().getReaderBookByBookId(bookId);
if (readerBook == null) return 0;
final highlights = await ReaderDao().getHighlightsByBookId(readerBook['id'] as String);
return highlights.where((h) => h['color'] != 'excerpt').length;
}
/// 跳转到 EPUB 句读管理页
void _navigateToEpubHighlights(Book book) async {
final readerBook = await ReaderDao().getReaderBookByBookId(book.id);
if (!mounted) return;
if (readerBook == null) {
ToastUtil.show(context, '该书籍尚未关联EPUB数据请关联后使用');
return;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EpubHighlightsPage(
bookId: readerBook['id'] as String,
book: readerBook,
),
),
);
}
String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
void _navigateToEdit(BuildContext context) {
final provider = context.read<AppProvider>();
Navigator.pushNamed(context, '/book-form', arguments: widget.book).then((_) {
provider.setEditRefresh(widget.book.id);
provider.loadBooks();
});
}
void _showDeleteDialog(BuildContext context) {
final colors = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => 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(
'确定要删除"${widget.book.title}"吗?删除后可在回收站恢复。',
style: TextStyle(
fontSize: 14,
color: colors.onSurface.withValues(alpha: 0.6),
height: 1.5,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
style: TextButton.styleFrom(
foregroundColor: colors.onSurface.withValues(alpha: 0.6),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () async {
await context.read<AppProvider>().removeBook(widget.book.id);
if (!mounted || !context.mounted) return;
Navigator.pop(context);
if (widget.embedded) {
context.read<AppProvider>().selectBook(null);
} else {
Navigator.pop(context);
}
if (mounted && context.mounted) {
ToastUtil.show(context, '已删除');
}
},
style: ElevatedButton.styleFrom(
backgroundColor: colors.error,
foregroundColor: colors.onError,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('删除'),
),
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
}
void _showSharePoster(Book book) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BookSharePage(book: book),
),
);
}
}
class _RatingInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
final text = newValue.text;
if (text.isEmpty) return newValue;
if (!RegExp(r'^\d{0,2}\.?\d{0,1}$').hasMatch(text)) return oldValue;
final n = double.tryParse(text);
if (n != null && n > 10) return oldValue;
return newValue;
}
}