import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; import 'package:http/http.dart' as http; import '../../widgets/fade_in_local_image.dart'; import '../../providers/app_provider.dart'; import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; import '../../utils/user_prefs.dart'; import '../../utils/image_path_helper.dart'; import '../../utils/responsive.dart'; import '../../widgets/genre_selector_page.dart'; import '../../widgets/work_people_section.dart'; import '../../widgets/character_preview_section.dart'; import '../../widgets/character_info_sheet.dart'; import 'book_reviews_page.dart'; import 'book_excerpts_page.dart'; import 'book_share_page.dart'; import '../character/character_list_page.dart'; import '../../data/epub/reader_dao.dart'; import '../epub_reader/epub_highlights_page.dart'; import '../epub_reader/reader_screen.dart'; /// 书籍详情页 - 极简主义设计 class BookDetailPage extends StatefulWidget { final Book book; final bool embedded; const BookDetailPage({super.key, required this.book, this.embedded = false}); @override State createState() => _BookDetailPageState(); } class _BookDetailPageState extends State { late int _detailStyle; final ValueNotifier _coverOffset = ValueNotifier(0.0); double _coverDragStartOffset = 0.0; final ValueNotifier _draggingCover = ValueNotifier(false); final GlobalKey _coverImageKey = GlobalKey(); double _coverImageHeight = 0.0; final ValueNotifier _showTitle = ValueNotifier(false); ScrollController? _overlayScrollController; // ─── 角色预览 ─── List _characters = []; // ─── 编辑模式 ─── bool _isEditing = false; final _editFormKey = GlobalKey(); late TextEditingController _titleCtrl; late TextEditingController _summaryCtrl; late TextEditingController _ratingCtrl; late TextEditingController _publisherCtrl; late TextEditingController _isbnCtrl; List _editAuthors = []; List _editTranslators = []; List _editGenres = []; List _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 _loadCharacters() async { final list = await context.read().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().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().books .where((b) => b.id == widget.book.id) .firstOrNull ?? widget.book; if (Breakpoint.isDesktop(context)) { return _buildDesktopStyle(book, colors); } if (_detailStyle == 1) { return _buildOverlayStyle(book, colors); } return _buildStandardStyle(book, colors); } /// 桌面端左右分栏布局 Widget _buildDesktopStyle(Book book, ColorScheme colors) { if (_isEditing) return _buildDesktopEditStyle(book, colors); final hasCover = book.coverPath != null && book.coverPath!.isNotEmpty; return Scaffold( backgroundColor: colors.surface, body: Column( children: [ // 顶栏 Container( height: 48, decoration: BoxDecoration( color: colors.surface, border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5)), ), child: Row(children: [ IconButton( icon: Icon(Icons.arrow_back, color: colors.onSurface, size: 18), onPressed: widget.embedded ? () => context.read().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().getBookReviewCount(book.id), emptyText: '暂无书评', unit: '条书评', onTap: () => _navigateToReviews(book), ), const SizedBox(height: 12), _buildExtraSectionItem( icon: Icons.format_quote_outlined, title: '摘抄', subtitleFuture: context.read().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().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(); 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(); 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(); 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 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 onChanged, {bool clearable = false}) { final colors = Theme.of(context).colorScheme; final hasDate = date != null; return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), const SizedBox(height: 6), GestureDetector(onTap: () async { final picked = await showDatePicker(context: context, initialDate: date ?? DateTime.now(), firstDate: DateTime(1900), lastDate: DateTime.now().add(const Duration(days: 365 * 5))); if (picked != null) onChanged(picked); }, child: Container(width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration(color: colors.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(8)), child: Row(children: [ Icon(Icons.calendar_today_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), const SizedBox(width: 8), Expanded(child: Text(hasDate ? '${date!.year}.${date!.month.toString().padLeft(2, '0')}.${date!.day.toString().padLeft(2, '0')}' : '\u9009\u62E9\u65E5\u671F', style: TextStyle(fontSize: 14, color: hasDate ? colors.onSurface : colors.onSurface.withValues(alpha: 0.25)), overflow: TextOverflow.ellipsis)), if (clearable && hasDate) GestureDetector(onTap: () => onChanged(null), child: Icon(Icons.close, size: 14, color: colors.onSurface.withValues(alpha: 0.3))), ]))), ]); } void _showEditCoverOptions() { final colors = Theme.of(context).colorScheme; showModalBottomSheet(context: context, backgroundColor: colors.surface, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), builder: (ctx) => SafeArea(child: Padding(padding: const EdgeInsets.symmetric(vertical: 16), child: Column(mainAxisSize: MainAxisSize.min, children: [ Container(width: 40, height: 4, decoration: BoxDecoration(color: colors.outline, borderRadius: BorderRadius.circular(2))), const SizedBox(height: 20), Padding(padding: const EdgeInsets.symmetric(horizontal: 24), child: Align(alignment: Alignment.centerLeft, child: Text('添加封面', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)))), const SizedBox(height: 16), ListTile(leading: Icon(Icons.photo_library_outlined, color: colors.onSurface.withValues(alpha: 0.6)), title: Text('从相册选择', style: TextStyle(color: colors.onSurface)), onTap: () { Navigator.pop(ctx); _pickEditCover(); }), ListTile(leading: Icon(Icons.link_outlined, color: colors.onSurface.withValues(alpha: 0.6)), title: Text('网络链接', style: TextStyle(color: colors.onSurface)), onTap: () { Navigator.pop(ctx); _pickEditCoverFromUrl(); }), ])))); } Future _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 _pickEditCoverFromUrl() async { final urlCtrl = TextEditingController(); final confirmed = await showDialog(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 _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 _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().updateBook(updated); if (!mounted) return; ToastUtil.show(context, '更新成功'); setState(() => _isEditing = false); } catch (e) { if (mounted) ToastUtil.show(context, '保存失败: $e'); } } static List _collectUnique(List> lists) { final s = {}; 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?>( 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().selectBook(null) : () => Navigator.pop(context), ), ValueListenableBuilder( valueListenable: _showTitle, builder: (_, show, __) => AnimatedOpacity( opacity: show ? 1.0 : 0.0, duration: const Duration(milliseconds: 200), child: ConstrainedBox( constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.5), child: Text(book.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white), maxLines: 1, overflow: TextOverflow.ellipsis), ), ), ), const Spacer(), _buildStyleButton(), ]), ), // 可滚动内容 Expanded( child: SingleChildScrollView( controller: _overlayScrollController, padding: const EdgeInsets.fromLTRB(16, 8, 16, 100), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 封面缩略图 + 标题 _buildOverlayHeader(book), const SizedBox(height: 20), // 信息区块:无毛玻璃 _buildAuthorsSection(book), if (book.translators.isNotEmpty) _buildTranslatorsSection(book), if (book.isbn != null && book.isbn!.isNotEmpty) _buildIsbnSection(book), if (book.publisher != null && book.publisher!.isNotEmpty) _buildPublisherSection(book), if (book.publishDate != null) _buildPublishDateSection(book), if (book.startDate != null || book.finishDate != null || book.readCount > 0) _buildReadingDatesSection(book), // 类型标签毛玻璃 if (book.genres.isNotEmpty) _buildGenresSection(book), CharacterPreviewSection( characters: _characters, onTap: _openCharacterSheet, isOverlay: true, ), // 关联人物 WorkPeopleSection(workId: book.id, workType: 'book'), // 简介:内部已有毛玻璃卡片 if (book.summary != null && book.summary!.isNotEmpty) ...[ const SizedBox(height: 12), _buildSummarySection(book), ], const SizedBox(height: 12), const SizedBox(height: 12), // 书评、书摘毛玻璃 _buildExtraSectionsOverlay(book), ], ), ), ), ], ), ), Positioned(right: 16, bottom: 24, child: _buildFloatingActionButtons(book)), ], ), ); } /// 叠层模式顶部:封面小图 + 标题/评分 Widget _buildOverlayHeader(Book book) { final hasCover = book.coverPath != null && book.coverPath!.isNotEmpty; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 封面缩略图 Container( width: 100, height: 140, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 12, offset: const Offset(0, 4))], ), clipBehavior: Clip.antiAlias, child: hasCover ? FadeInLocalImage(path: book.coverPath, fit: BoxFit.cover) : Container(color: Colors.white24, child: const Icon(Icons.menu_book, color: Colors.white38, size: 32)), ), const SizedBox(width: 16), // 标题 + 信息 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 4), Text(book.title, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white)), if (book.authors.isNotEmpty) ...[ const SizedBox(height: 6), Text(book.authors.join(' / '), style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: 0.6))), ], const SizedBox(height: 12), Row(children: [ if (book.rating != null && book.rating! > 0) ...[ const Icon(Icons.star, size: 16, color: Color(0xFFFFB800)), const SizedBox(width: 4), Text(book.rating!.toStringAsFixed(1), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFFFFB800))), const SizedBox(width: 16), ], _statusChip(book.status), ]), ], ), ), ], ); } Widget _buildFloatingActionButtons(Book book) { final colors = Theme.of(context).colorScheme; return Column( mainAxisSize: MainAxisSize.min, children: [ _buildFloatingButton( icon: Icons.edit_outlined, onPressed: () => _navigateToEdit(context), tooltip: '编辑', backgroundColor: colors.primary, foregroundColor: colors.onPrimary, ), const SizedBox(height: 12), _buildFloatingButton( icon: Icons.people_outline, onPressed: () => _navigateToCharacters(book), tooltip: '角色', backgroundColor: colors.secondaryContainer, foregroundColor: colors.onSecondaryContainer, ), const SizedBox(height: 12), _buildFloatingButton( icon: Icons.delete_outline, onPressed: () => _showDeleteDialog(context), tooltip: '删除', backgroundColor: colors.error, foregroundColor: colors.onError, ), const SizedBox(height: 12), _buildEpubReadButton(book), if (!Platform.isWindows) ...[ const SizedBox(height: 12), _buildFloatingButton( icon: Icons.share_outlined, onPressed: () => _showSharePoster(book), tooltip: '分享海报', backgroundColor: const Color(0xFF4CAF50), foregroundColor: Colors.white, ), ], ], ); } /// EPUB 阅读悬浮按钮(仅有关联 EPUB 时显示) Widget _buildEpubReadButton(Book book) { return FutureBuilder?>( 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().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().updateBookCoverOffset(widget.book.id, offset); } : null, child: ValueListenableBuilder( 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( valueListenable: _draggingCover, builder: (context, dragging, _) { return Stack( children: [ if (!dragging) Positioned( left: 0, right: 0, bottom: 0, child: IgnorePointer( child: Container( height: 60, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ colors.surface.withValues(alpha: 0), colors.surface, ], ), ), ), ), ), if (dragging) ...[ Positioned.fill( child: Container(color: Colors.black.withValues(alpha: 0.3)), ), Positioned( left: 0, right: 0, bottom: 20, child: Center( child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(20), ), child: const Text('上下滑动调整图片位置', style: TextStyle(fontSize: 13, color: Colors.white70)), ), ), ), ], ], ); }, ), ], ); }, ), ); }, ); } Widget _buildCoverPlaceholder() { final colors = Theme.of(context).colorScheme; return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.menu_book, size: 64, color: colors.onSurface.withValues(alpha: 0.25), ), const SizedBox(height: 16), Text( '暂无封面', style: TextStyle( fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4), ), ), ], ), ); } /// 样式选择按钮 Widget _buildStyleButton({Color color = Colors.white}) { return IconButton( icon: Icon(Icons.tune, color: color, size: 20), tooltip: '切换样式', onPressed: _showStylePicker, ); } void _showStylePicker() { final colors = Theme.of(context).colorScheme; const names = ['默认样式', '毛玻璃层叠']; const icons = [Icons.article_outlined, Icons.blur_on_outlined]; const subtitles = ['标准封面顶部布局', '封面背景 + 毛玻璃卡片']; showModalBottomSheet( context: context, backgroundColor: colors.surface, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), builder: (ctx) => Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), child: Column(mainAxisSize: MainAxisSize.min, children: [ Container(width: 36, height: 4, decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))), const SizedBox(height: 20), Align(alignment: Alignment.centerLeft, child: Text('详情页样式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface))), const SizedBox(height: 12), for (int i = 0; i < names.length; i++) ...[ if (i > 0) Divider(height: 0.5, color: colors.outlineVariant), ListTile( contentPadding: EdgeInsets.zero, leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), child: Icon(icons[i], size: 20, color: _detailStyle == i ? colors.primary : colors.onSurface.withValues(alpha: 0.6))), title: Text(names[i], style: TextStyle(fontSize: 13, fontWeight: _detailStyle == i ? FontWeight.w600 : FontWeight.w500, color: colors.onSurface)), subtitle: Text(subtitles[i], style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), trailing: _detailStyle == i ? Icon(Icons.check_circle, size: 20, color: colors.primary) : Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)), onTap: () { setState(() => _detailStyle = i); UserPrefs().setDetailPageStyle(i); Navigator.pop(ctx); }, ), ], const SizedBox(height: 12), ]), ), ); } Widget _statusChip(String status) { final colors = Theme.of(context).colorScheme; final (label, bg, fg) = switch (status) { 'read' => ('已读', colors.primary, colors.onPrimary), 'reading' => ('在读', colors.outlineVariant, colors.onSurface.withValues(alpha: 0.6)), 'want_to_read' => ('想读', colors.surfaceContainerHighest, colors.onSurface.withValues(alpha: 0.4)), 'abandoned' => ('弃读', colors.error.withValues(alpha: 0.15), colors.error), _ => ('', colors.surfaceContainerHighest, colors.onSurface.withValues(alpha: 0.3)), }; if (label.isEmpty) return const SizedBox.shrink(); return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(12)), child: Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: fg)), ); } /// EPUB 阅读进度条(仅有关联 EPUB 时显示) Widget _buildEpubProgressBar(Book book) { final colors = Theme.of(context).colorScheme; return FutureBuilder?>( 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().getBookReviewCount(book.id), emptyText: '暂无书评', unit: '条书评', onTap: () => _navigateToReviews(book), ), const SizedBox(height: 12), _buildExtraSectionItem( icon: Icons.format_quote_outlined, title: '摘抄', subtitleFuture: context.read().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().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().getBookReviewCount(book.id), emptyText: '暂无书评', unit: '条书评', onTap: () => _navigateToReviews(book), ), const SizedBox(height: 12), _buildFrostedExtraItem( icon: Icons.format_quote_outlined, title: '摘抄', subtitleFuture: context.read().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().getBookCharacterCount(book.id), emptyText: '暂无角色', unit: '个角色', onTap: () => _navigateToCharacters(book), ), ], ), ); } /// 毛玻璃书评/摘抄卡片 Widget _buildFrostedExtraItem({ required IconData icon, required String title, required Future 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( 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 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( 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 _openCharacterSheet(dynamic character) async { final needRefresh = await CharacterInfoSheet.show( context, entityType: 'book', entityId: widget.book.id, character: character, ); if (needRefresh == true) _loadCharacters(); } /// 获取关联 EPUB 的句读(高亮)数量 Future _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(); Navigator.pushNamed(context, '/book-form', arguments: widget.book).then((_) { provider.setEditRefresh(widget.book.id); provider.loadBooks(); }); } void _showDeleteDialog(BuildContext context) { final colors = Theme.of(context).colorScheme; showDialog( context: context, builder: (context) => AlertDialog( backgroundColor: colors.surface, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), title: Text( '确认删除', style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface, ), ), content: Text( '确定要删除"${widget.book.title}"吗?删除后可在回收站恢复。', style: TextStyle( fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5, ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), style: TextButton.styleFrom( foregroundColor: colors.onSurface.withValues(alpha: 0.6), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), ), child: const Text('取消'), ), ElevatedButton( onPressed: () async { await context.read().removeBook(widget.book.id); if (!mounted || !context.mounted) return; Navigator.pop(context); if (widget.embedded) { context.read().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; } }