Files
MookNote/lib/pages/book/book_detail_page.dart
DelLevin-Home 5a6bd51864 更新readme
2026-06-19 19:16:32 +08:00

1197 lines
40 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/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../widgets/fade_in_local_image.dart';
import 'package:share_plus/share_plus.dart';
import 'package:cross_file/cross_file.dart';
import '../../providers/app_provider.dart';
import '../../models/data_models.dart';
import '../../utils/toast_util.dart';
import '../../utils/user_prefs.dart';
import 'book_reviews_page.dart';
import 'book_excerpts_page.dart';
import 'book_share_page.dart';
/// 书籍详情页 - 极简主义设计
class BookDetailPage extends StatefulWidget {
final Book book;
const BookDetailPage({super.key, required this.book});
@override
State<BookDetailPage> createState() => _BookDetailPageState();
}
class _BookDetailPageState extends State<BookDetailPage> {
late int _detailStyle;
final ValueNotifier<double> _coverOffset = ValueNotifier(0.0);
double _coverDragStartOffset = 0.0;
bool _draggingCover = false;
final GlobalKey _coverImageKey = GlobalKey();
double _coverImageHeight = 0.0;
@override
void dispose() {
_coverOffset.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
_detailStyle = UserPrefs().detailPageStyle;
}
@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 (_detailStyle == 1) {
return _buildOverlayStyle(book, colors);
}
return _buildStandardStyle(book, colors);
}
/// 标准样式
Widget _buildStandardStyle(Book book, ColorScheme colors) {
final topSafe = MediaQuery.of(context).padding.top;
return Scaffold(
backgroundColor: colors.surface,
body: Stack(
children: [
// 图片从导航栏下方开始,固定在顶部
Column(
children: [
SizedBox(height: topSafe + 48),
SizedBox(
height: 320,
width: double.infinity,
child: _buildCoverSection(book),
),
],
),
// 可滚动内容区域从图片底部开始
Positioned(
top: topSafe + 48 + 320,
left: 0, right: 0, bottom: 0,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildBasicInfo(book),
Divider(height: 0.5, thickness: 0.5, color: colors.outline),
_buildAuthorsSection(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),
Divider(height: 0.5, thickness: 0.5, color: colors.outline),
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;
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: const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 18),
onPressed: () => Navigator.pop(context),
),
const Spacer(),
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(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 封面缩略图 + 标题
_buildOverlayHeader(book),
const SizedBox(height: 20),
// 信息区块:无毛玻璃
_buildAuthorsSection(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.genres.isNotEmpty) _buildGenresSection(book),
// 简介:内部已有毛玻璃卡片
if (book.summary != null && book.summary!.isNotEmpty) ...[
const SizedBox(height: 12),
_buildSummarySection(book),
],
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.delete_outline,
onPressed: () => _showDeleteDialog(context),
tooltip: '删除',
backgroundColor: colors.error,
foregroundColor: colors.onError,
),
const SizedBox(height: 12),
_buildFloatingButton(
icon: Icons.share_outlined,
onPressed: () => _showSharePoster(book),
tooltip: '分享海报',
backgroundColor: const Color(0xFF4CAF50),
foregroundColor: Colors.white,
),
],
);
}
Widget _buildFloatingButton({
required IconData icon,
required VoidCallback onPressed,
required String tooltip,
required Color backgroundColor,
required Color foregroundColor,
}) {
return Material(
color: Colors.transparent,
child: Ink(
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: IconButton(
icon: Icon(icon, size: 18, color: foregroundColor),
onPressed: onPressed,
padding: EdgeInsets.zero,
tooltip: tooltip,
),
),
);
}
/// 固定在顶部的导航栏(纯色,与主体一致)
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(Icons.arrow_back_ios_new, color: colors.onSurface, size: 18),
onPressed: () => 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;
}
setState(() => _draggingCover = 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 ? (_) {
setState(() => _draggingCover = false);
} : 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(),
if (!_draggingCover)
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 (_draggingCover) ...[
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 = ['默认样式', '毛玻璃层叠'];
showModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('详情页样式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(height: 16),
Wrap(spacing: 10, runSpacing: 10, children: List.generate(names.length, (i) {
final selected = _detailStyle == i;
return GestureDetector(
onTap: () { setState(() => _detailStyle = i); UserPrefs().setDetailPageStyle(i); Navigator.pop(ctx); },
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
decoration: BoxDecoration(
color: selected ? colors.primary : colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: selected ? colors.primary : colors.outline, width: 0.5),
),
child: Text(names[i], style: TextStyle(
fontSize: 14, fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6),
)),
),
);
})),
]),
),
);
}
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)),
_ => ('', 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)),
);
}
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,
),
),
],
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;
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 _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 _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),
),
],
),
);
}
/// 叠层模式:书评、摘抄各自独立毛玻璃卡片
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),
),
],
),
);
}
/// 毛玻璃书评/摘抄卡片
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),
),
);
}
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.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) return;
Navigator.pop(context);
Navigator.pop(context);
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),
),
);
}
Future<void> _downloadCover(Book book) async {
if (book.coverPath == null || book.coverPath!.isEmpty) {
ToastUtil.show(context, '没有可下载的封面');
return;
}
try {
final sourceFile = File(book.coverPath!);
if (!await sourceFile.exists()) {
ToastUtil.show(context, '封面文件不存在');
return;
}
final timestamp = DateTime.now().millisecondsSinceEpoch;
final fileName = '${book.title}_${timestamp}_封面.jpg';
final tempDir = await Directory.systemTemp.createTemp();
final tempFile = File('${tempDir.path}/$fileName');
await sourceFile.copy(tempFile.path);
await Share.shareXFiles(
[XFile(tempFile.path)],
subject: '${book.title} 封面',
text: '下载自 MookNote',
);
} catch (e) {
ToastUtil.show(context, '下载失败: $e');
}
}
void _showSharePoster(Book book) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BookSharePage(book: book),
),
);
}
}