generated from dellevin/template
2523 lines
105 KiB
Dart
2523 lines
105 KiB
Dart
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';
|
||
import '../../widgets/app_overlay.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);
|
||
}
|
||
return switch (_detailStyle) {
|
||
1 => _buildOverlayStyle(book, colors),
|
||
2 => _buildMinimalLayeredStyle(book, colors),
|
||
_ => _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;
|
||
appModalBottomSheet(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 appDialog<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', isOverlay: true),
|
||
// 简介:内部已有毛玻璃卡片
|
||
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 _buildMinimalLayeredStyle(Book book, ColorScheme colors) {
|
||
final safeTop = MediaQuery.of(context).padding.top;
|
||
return Scaffold(
|
||
backgroundColor: colors.surface,
|
||
body: Stack(
|
||
children: [
|
||
// 整体可滚动(封面卡片 + 内容一起滑动)
|
||
Padding(
|
||
padding: EdgeInsets.only(top: safeTop + 48),
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 120),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Center(child: _buildLayeredCover(book)),
|
||
const SizedBox(height: 20),
|
||
SizedBox(width: double.infinity, child: _buildLayeredHeader(book)),
|
||
const SizedBox(height: 20),
|
||
_buildLayeredInfoRow('作者', book.authors.join(','), colors),
|
||
if (book.translators.isNotEmpty)
|
||
_buildLayeredInfoRow('译者', book.translators.join(','), colors),
|
||
if (book.isbn != null && book.isbn!.isNotEmpty)
|
||
_buildLayeredInfoRow('ISBN', book.isbn!, colors),
|
||
if (book.publisher != null && book.publisher!.isNotEmpty)
|
||
_buildLayeredInfoRow('出版社', book.publisher!, colors),
|
||
if (book.publishDate != null)
|
||
_buildLayeredInfoRow('出版时间', '${book.publishDate!.year}年${book.publishDate!.month.toString().padLeft(2, '0')}月', colors),
|
||
if (book.startDate != null || book.finishDate != null || book.readCount > 0)
|
||
_buildLayeredReadingDates(book),
|
||
if (book.genres.isNotEmpty)
|
||
_buildLayeredGenres(book),
|
||
CharacterPreviewSection(
|
||
characters: _characters,
|
||
onTap: _openCharacterSheet,
|
||
),
|
||
WorkPeopleSection(workId: book.id, workType: 'book'),
|
||
if (book.summary != null && book.summary!.isNotEmpty)
|
||
_buildLayeredSummary(book),
|
||
const SizedBox(height: 20),
|
||
_buildLayeredExtraSections(book),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
_buildStandardTopBar(book.title, colors),
|
||
Positioned(right: 16, bottom: 24, child: _buildFloatingActionButtons(book)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 浅色极简:居中封面卡片
|
||
Widget _buildLayeredCover(Book book) {
|
||
final colors = Theme.of(context).colorScheme;
|
||
final hasCover = book.coverPath != null && book.coverPath!.isNotEmpty;
|
||
return Container(
|
||
width: 160, height: 230,
|
||
decoration: BoxDecoration(
|
||
color: colors.surfaceContainerHighest,
|
||
borderRadius: BorderRadius.circular(16),
|
||
boxShadow: hasCover
|
||
? [BoxShadow(color: Colors.black.withValues(alpha: 0.12), blurRadius: 20, offset: const Offset(0, 8))]
|
||
: 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))),
|
||
);
|
||
}
|
||
|
||
/// 浅色极简:居中标题 + 评分/状态
|
||
Widget _buildLayeredHeader(Book book) {
|
||
final colors = Theme.of(context).colorScheme;
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
Text(book.title,
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: colors.onSurface, height: 1.3)),
|
||
if (book.alternateTitles.isNotEmpty) ...[
|
||
const SizedBox(height: 6),
|
||
Text(book.alternateTitles.join(' / '),
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4), height: 1.4)),
|
||
],
|
||
_buildEpubProgressBar(book),
|
||
const SizedBox(height: 12),
|
||
Wrap(spacing: 8, runSpacing: 8, alignment: WrapAlignment.center, 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))),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 浅色极简:信息卡片(作者/译者/ISBN/出版社/出版时间)
|
||
Widget _buildLayeredInfoRow(String label, String value, ColorScheme colors) {
|
||
return Container(
|
||
width: double.infinity,
|
||
margin: const EdgeInsets.only(bottom: 10),
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||
decoration: BoxDecoration(
|
||
color: colors.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: colors.outlineVariant, width: 0.5),
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SizedBox(width: 64, 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))),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 浅色极简:阅读日期/次数卡片
|
||
Widget _buildLayeredReadingDates(Book book) {
|
||
final colors = Theme.of(context).colorScheme;
|
||
return Container(
|
||
width: double.infinity,
|
||
margin: const EdgeInsets.only(bottom: 10),
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||
decoration: BoxDecoration(
|
||
color: colors.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: colors.outlineVariant, width: 0.5),
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SizedBox(width: 64, 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)
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(6)),
|
||
child: Text('共 ${book.readCount} 次', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.7))),
|
||
),
|
||
]),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 浅色极简:类型卡片
|
||
Widget _buildLayeredGenres(Book book) {
|
||
final colors = Theme.of(context).colorScheme;
|
||
return Container(
|
||
width: double.infinity,
|
||
margin: const EdgeInsets.only(bottom: 10),
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||
decoration: BoxDecoration(
|
||
color: colors.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: colors.outlineVariant, width: 0.5),
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SizedBox(width: 64, 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.surface, borderRadius: BorderRadius.circular(16)),
|
||
child: Text(g, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))),
|
||
)).toList(),
|
||
)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 浅色极简:简介卡片
|
||
Widget _buildLayeredSummary(Book book) {
|
||
final colors = Theme.of(context).colorScheme;
|
||
return Container(
|
||
width: double.infinity,
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: colors.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: colors.outlineVariant, width: 0.5),
|
||
),
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Row(children: [
|
||
Container(width: 4, height: 14, 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)),
|
||
]),
|
||
);
|
||
}
|
||
|
||
/// 浅色极简:更多(书评/摘抄/句读/角色)
|
||
Widget _buildLayeredExtraSections(Book book) {
|
||
final colors = Theme.of(context).colorScheme;
|
||
return 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: 12),
|
||
_buildExtraSectionItem(
|
||
icon: Icons.rate_review_outlined,
|
||
title: '书评',
|
||
subtitleFuture: context.read<AppProvider>().getBookReviewCount(book.id),
|
||
emptyText: '暂无书评',
|
||
unit: '条书评',
|
||
onTap: () => _navigateToReviews(book),
|
||
),
|
||
const SizedBox(height: 10),
|
||
_buildExtraSectionItem(
|
||
icon: Icons.format_quote_outlined,
|
||
title: '摘抄',
|
||
subtitleFuture: context.read<AppProvider>().getBookExcerptCount(book.id),
|
||
emptyText: '暂无摘抄',
|
||
unit: '条摘抄',
|
||
onTap: () => _navigateToExcerpts(book),
|
||
),
|
||
const SizedBox(height: 10),
|
||
_buildExtraSectionItem(
|
||
icon: Icons.highlight_outlined,
|
||
title: '句读',
|
||
subtitleFuture: _getEpubHighlightCount(book.id),
|
||
emptyText: '暂无句读',
|
||
unit: '条句读',
|
||
onTap: () => _navigateToEpubHighlights(book),
|
||
),
|
||
const SizedBox(height: 10),
|
||
_buildExtraSectionItem(
|
||
icon: Icons.people_outline,
|
||
title: '角色',
|
||
subtitleFuture: context.read<AppProvider>().getBookCharacterCount(book.id),
|
||
emptyText: '暂无角色',
|
||
unit: '个角色',
|
||
onTap: () => _navigateToCharacters(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, Icons.layers_outlined];
|
||
const subtitles = ['标准封面顶部布局', '封面背景 + 毛玻璃卡片', '封面卡片 + 浅色信息卡片层叠'];
|
||
appModalBottomSheet(
|
||
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;
|
||
appDialog(
|
||
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;
|
||
}
|
||
}
|