generated from dellevin/template
优化编辑新增界面
This commit is contained in:
391
lib/pages/book/book_add_page.dart
Normal file
391
lib/pages/book/book_add_page.dart
Normal file
@@ -0,0 +1,391 @@
|
||||
import 'dart:io';
|
||||
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 'package:uuid/uuid.dart';
|
||||
import '../../widgets/fade_in_local_image.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../models/data_models.dart';
|
||||
import '../../utils/toast_util.dart';
|
||||
import '../../utils/image_path_helper.dart';
|
||||
import '../../widgets/genre_selector_page.dart';
|
||||
|
||||
class BookAddPage extends StatefulWidget {
|
||||
final VoidCallback? onCancel;
|
||||
final String? initialStatus;
|
||||
const BookAddPage({super.key, this.onCancel, this.initialStatus});
|
||||
|
||||
@override
|
||||
State<BookAddPage> createState() => _BookAddPageState();
|
||||
}
|
||||
|
||||
class _BookAddPageState extends State<BookAddPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
late TextEditingController _titleCtrl;
|
||||
late TextEditingController _summaryCtrl;
|
||||
late TextEditingController _ratingCtrl;
|
||||
late TextEditingController _publisherCtrl;
|
||||
late TextEditingController _isbnCtrl;
|
||||
List<String> _authors = [];
|
||||
List<String> _translators = [];
|
||||
List<String> _alternateTitles = [];
|
||||
List<String> _genres = [];
|
||||
String? _coverPath;
|
||||
String _status = 'want_to_read';
|
||||
DateTime? _publishDate;
|
||||
DateTime? _startDate;
|
||||
DateTime? _finishDate;
|
||||
bool _isDownloading = false;
|
||||
String? _tempId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tempId = const Uuid().v4();
|
||||
_status = widget.initialStatus ?? 'want_to_read';
|
||||
_titleCtrl = TextEditingController();
|
||||
_summaryCtrl = TextEditingController();
|
||||
_ratingCtrl = TextEditingController();
|
||||
_publisherCtrl = TextEditingController();
|
||||
_isbnCtrl = TextEditingController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleCtrl.dispose();
|
||||
_summaryCtrl.dispose();
|
||||
_ratingCtrl.dispose();
|
||||
_publisherCtrl.dispose();
|
||||
_isbnCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final hasCover = _coverPath != null && _coverPath!.isNotEmpty;
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
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: () => widget.onCancel?.call()),
|
||||
Expanded(child: Text('添加书籍',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface))),
|
||||
FilledButton.icon(onPressed: _save,
|
||||
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: _showCoverOptions, 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: _coverPath, 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 (_isDownloading) 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(() => _coverPath = null),
|
||||
child: Text('移除封面', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))))),
|
||||
const SizedBox(height: 20),
|
||||
_label('状态', 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: [
|
||||
_statusChip('想读', 'want_to_read', colors), _statusChip('在读', 'reading', colors),
|
||||
_statusChip('已读', 'read', colors), _statusChip('弃读', 'abandoned', colors),
|
||||
])),
|
||||
const SizedBox(height: 16),
|
||||
_label('评分', colors), const SizedBox(height: 6),
|
||||
_buildRatingRow(colors),
|
||||
])),
|
||||
// 右侧表单
|
||||
Expanded(child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(0, 20, 24, 80),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
_field('名称', _titleCtrl, hint: '书籍名称', required: true), const SizedBox(height: 16),
|
||||
_chipField('别名', _alternateTitles, onTap: () async {
|
||||
final r = await GenreSelectorPage.show(context: context, title: '添加别名', existingTags: [], initialSelected: _alternateTitles, hint: '输入别名');
|
||||
if (r != null) setState(() => _alternateTitles = r);
|
||||
}), const SizedBox(height: 16),
|
||||
_chipField('作者', _authors, onTap: () async {
|
||||
final p = context.read<AppProvider>(); final d = p.books.map((b) => b.authors).toList();
|
||||
final r = await GenreSelectorPage.show(context: context, title: '选择作者', existingTagsFuture: compute(_collectUnique, d), initialSelected: _authors, hint: '如:余华');
|
||||
if (r != null) setState(() => _authors = r);
|
||||
}), const SizedBox(height: 16),
|
||||
_chipField('译者', _translators, onTap: () async {
|
||||
final p = context.read<AppProvider>(); final d = p.books.map((b) => b.translators).toList();
|
||||
final r = await GenreSelectorPage.show(context: context, title: '选择译者', existingTagsFuture: compute(_collectUnique, d), initialSelected: _translators, hint: '如:李继宏');
|
||||
if (r != null) setState(() => _translators = r);
|
||||
}), const SizedBox(height: 16),
|
||||
_chipField('类型', _genres, onTap: () async {
|
||||
final p = context.read<AppProvider>();
|
||||
final tags = await p.getTags('book_genre', excludeHidden: true);
|
||||
final names = tags.map((t) => t['name'] as String).toList();
|
||||
if (!mounted) return;
|
||||
final r = await GenreSelectorPage.show(context: context, title: '选择类型', existingTags: names, initialSelected: _genres, hint: '如:小说、历史');
|
||||
if (r != null) setState(() => _genres = r);
|
||||
}), const SizedBox(height: 16),
|
||||
_field('出版社', _publisherCtrl, hint: '出版社名称'), const SizedBox(height: 16),
|
||||
_field('ISBN', _isbnCtrl, hint: 'ISBN编号'), const SizedBox(height: 16),
|
||||
Row(children: [
|
||||
Expanded(child: _dateField('出版日期', _publishDate, (d) => setState(() => _publishDate = d))),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _dateField('开始日期', _startDate, (d) => setState(() => _startDate = d), clearable: true)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _dateField('读完日期', _finishDate, (d) => setState(() => _finishDate = d), clearable: true)),
|
||||
]), const SizedBox(height: 16),
|
||||
_label('简介', 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)))),
|
||||
]),
|
||||
)),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _label(String l, ColorScheme c) => Text(l, style: TextStyle(fontSize: 12, color: c.onSurface.withValues(alpha: 0.4)));
|
||||
Widget _statusChip(String label, String value, ColorScheme c) {
|
||||
final sel = _status == value;
|
||||
return GestureDetector(onTap: () => setState(() => _status = value),
|
||||
child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
decoration: BoxDecoration(color: sel ? c.surface : Colors.transparent, borderRadius: BorderRadius.circular(6),
|
||||
boxShadow: sel ? [BoxShadow(color: c.onSurface.withValues(alpha: 0.03), blurRadius: 4, offset: const Offset(0, 2))] : null),
|
||||
child: Text(label, style: TextStyle(fontSize: 13, fontWeight: sel ? FontWeight.w500 : FontWeight.normal,
|
||||
color: sel ? c.onSurface : c.onSurface.withValues(alpha: 0.4)))));
|
||||
}
|
||||
|
||||
Widget _buildRatingRow(ColorScheme colors) {
|
||||
return Row(children: [
|
||||
...List.generate(5, (i) {
|
||||
final sv = i + 1; final cr = double.tryParse(_ratingCtrl.text) ?? 0; final sr = cr / 2;
|
||||
final f = sv <= sr; final h = sv == sr.ceil() && sr % 1 != 0;
|
||||
return GestureDetector(onTap: () => setState(() => _ratingCtrl.text = (sv * 2).toString()),
|
||||
child: Padding(padding: const EdgeInsets.symmetric(horizontal: 1),
|
||||
child: Icon(h ? Icons.star_half : (f ? Icons.star : Icons.star_border), size: 20, color: (f || h) ? 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))),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _field(String label, TextEditingController ctrl, {String hint = '', bool required = false}) {
|
||||
final c = Theme.of(context).colorScheme;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(required ? '$label *' : label, style: TextStyle(fontSize: 12, color: c.onSurface.withValues(alpha: 0.4))),
|
||||
const SizedBox(height: 6),
|
||||
TextFormField(controller: ctrl, style: TextStyle(fontSize: 14, color: c.onSurface),
|
||||
validator: required ? (v) => (v == null || v.trim().isEmpty) ? '请输入$label' : null : null,
|
||||
decoration: InputDecoration(hintText: hint, hintStyle: TextStyle(color: c.onSurface.withValues(alpha: 0.25)),
|
||||
filled: true, fillColor: c.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 _chipField(String label, List<String> chips, {required VoidCallback onTap}) {
|
||||
final c = Theme.of(context).colorScheme;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(label, style: TextStyle(fontSize: 12, color: c.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: c.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(8)),
|
||||
child: chips.isEmpty
|
||||
? Text('点击选择$label', style: TextStyle(fontSize: 14, color: c.onSurface.withValues(alpha: 0.25)))
|
||||
: Wrap(spacing: 4, runSpacing: 4, children: chips.map((e) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(4)),
|
||||
child: Text(e, style: TextStyle(fontSize: 12, color: c.onSurface)))).toList()))),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _dateField(String label, DateTime? date, ValueChanged<DateTime?> onChanged, {bool clearable = false}) {
|
||||
final c = Theme.of(context).colorScheme; final has = date != null;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(label, style: TextStyle(fontSize: 12, color: c.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: c.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(8)),
|
||||
child: Row(children: [
|
||||
Icon(Icons.calendar_today_outlined, size: 14, color: c.onSurface.withValues(alpha: 0.4)),
|
||||
const SizedBox(width: 8),
|
||||
Text(has ? '${date!.year}.${date!.month.toString().padLeft(2, '0')}.${date!.day.toString().padLeft(2, '0')}' : '选择日期',
|
||||
style: TextStyle(fontSize: 14, color: has ? c.onSurface : c.onSurface.withValues(alpha: 0.25))),
|
||||
const Spacer(),
|
||||
if (clearable && has) GestureDetector(onTap: () => onChanged(null),
|
||||
child: Icon(Icons.close, size: 14, color: c.onSurface.withValues(alpha: 0.3))),
|
||||
]))),
|
||||
]);
|
||||
}
|
||||
|
||||
void _showCoverOptions() {
|
||||
final c = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(context: context, backgroundColor: c.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: c.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: c.onSurface)))),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(leading: Icon(Icons.photo_library_outlined, color: c.onSurface.withValues(alpha: 0.6)),
|
||||
title: Text('从相册选择'), onTap: () { Navigator.pop(ctx); _pickCover(); }),
|
||||
ListTile(leading: Icon(Icons.link_outlined, color: c.onSurface.withValues(alpha: 0.6)),
|
||||
title: Text('网络链接'), onTap: () { Navigator.pop(ctx); _pickCoverFromUrl(); }),
|
||||
]))));
|
||||
}
|
||||
|
||||
Future<void> _pickCover() 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(_tempId!, fileName);
|
||||
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
|
||||
await File(picked.path).copy(targetPath);
|
||||
if (mounted) setState(() => _coverPath = targetPath);
|
||||
} catch (e) { if (mounted) ToastUtil.show(context, '选择封面失败: $e'); }
|
||||
}
|
||||
|
||||
Future<void> _pickCoverFromUrl() async {
|
||||
final ctrl = TextEditingController();
|
||||
final ok = await showDialog<bool>(context: context, builder: (ctx) {
|
||||
final c = Theme.of(ctx).colorScheme;
|
||||
return AlertDialog(backgroundColor: c.surface, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
title: Text('添加网络图片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: c.onSurface)),
|
||||
content: TextField(controller: ctrl, keyboardType: TextInputType.url, style: TextStyle(fontSize: 14, color: c.onSurface),
|
||||
decoration: InputDecoration(hintText: 'https://example.com/image.jpg', filled: true, fillColor: c.surfaceContainerHigh,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none))),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消')),
|
||||
ElevatedButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定')),
|
||||
]);
|
||||
});
|
||||
final url = ctrl.text.trim(); ctrl.dispose();
|
||||
if (ok != true || url.isEmpty) return;
|
||||
await _downloadCover(url);
|
||||
}
|
||||
|
||||
Future<void> _downloadCover(String url) async {
|
||||
setState(() => _isDownloading = true);
|
||||
try {
|
||||
final res = await http.get(Uri.parse(url), headers: {
|
||||
'User-Agent': 'Mozilla/5.0', 'Accept': 'image/*,*/*;q=0.8', 'Referer': Uri.parse(url).replace(path: '/').toString(),
|
||||
});
|
||||
if (res.statusCode != 200) throw Exception('HTTP ${res.statusCode}');
|
||||
final fileName = 'cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
final targetPath = await ImagePathHelper.instance.getBookCoverPath(_tempId!, fileName);
|
||||
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
|
||||
await File(targetPath).writeAsBytes(res.bodyBytes);
|
||||
if (mounted) setState(() => _coverPath = targetPath);
|
||||
} catch (e) { if (mounted) ToastUtil.show(context, '下载失败: $e'); }
|
||||
finally { if (mounted) setState(() => _isDownloading = false); }
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
try {
|
||||
final bookId = const Uuid().v4();
|
||||
// 移动封面到正式目录
|
||||
String? finalCoverPath;
|
||||
if (_coverPath != null) {
|
||||
final normalized = _coverPath!.replaceAll('\\', '/');
|
||||
if (!normalized.contains('/books/$bookId/')) {
|
||||
final fileName = p.basename(_coverPath!);
|
||||
final newPath = await ImagePathHelper.instance.getBookCoverPath(bookId, fileName);
|
||||
await ImagePathHelper.instance.ensureDirExists(p.dirname(newPath));
|
||||
final src = File(_coverPath!);
|
||||
if (await src.exists()) { await src.rename(newPath); finalCoverPath = newPath; }
|
||||
// 清理临时目录
|
||||
final tempDir = Directory(p.dirname(_coverPath!));
|
||||
if (await tempDir.exists()) { try { await tempDir.delete(recursive: true); } catch (_) {} }
|
||||
} else { finalCoverPath = _coverPath; }
|
||||
}
|
||||
final rating = _ratingCtrl.text.isNotEmpty ? double.tryParse(_ratingCtrl.text) : null;
|
||||
final publisher = _publisherCtrl.text.trim().isEmpty ? null : _publisherCtrl.text.trim();
|
||||
final isbn = _isbnCtrl.text.trim().isEmpty ? null : _isbnCtrl.text.trim();
|
||||
final now = DateTime.now();
|
||||
final book = Book(
|
||||
id: bookId, title: _titleCtrl.text.trim(), coverPath: finalCoverPath,
|
||||
authors: _authors, translators: _translators, alternateTitles: _alternateTitles,
|
||||
genres: _genres, publisher: publisher, isbn: isbn,
|
||||
summary: _summaryCtrl.text.trim(), rating: rating,
|
||||
status: _status, publishDate: _publishDate, startDate: _startDate, finishDate: _finishDate,
|
||||
createdAt: now, updatedAt: now,
|
||||
);
|
||||
await context.read<AppProvider>().addBook(book);
|
||||
await context.read<AppProvider>().loadBooks();
|
||||
if (!mounted) return;
|
||||
context.read<AppProvider>().finishAdding();
|
||||
ToastUtil.show(context, '添加成功');
|
||||
} catch (e) { if (mounted) ToastUtil.show(context, '保存失败: $e'); }
|
||||
}
|
||||
|
||||
static List<String> _collectUnique(List<List<String>> lists) {
|
||||
final s = <String>{}; for (final l in lists) { s.addAll(l); } return s.toList()..sort();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
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 'book_reviews_page.dart';
|
||||
import 'book_excerpts_page.dart';
|
||||
import 'book_share_page.dart';
|
||||
@@ -37,12 +43,37 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
final ValueNotifier<bool> _showTitle = ValueNotifier(false);
|
||||
ScrollController? _overlayScrollController;
|
||||
|
||||
// ─── 编辑模式 ───
|
||||
bool _isEditing = false;
|
||||
final _editFormKey = GlobalKey<FormState>();
|
||||
late TextEditingController _titleCtrl;
|
||||
late TextEditingController _summaryCtrl;
|
||||
late TextEditingController _ratingCtrl;
|
||||
late TextEditingController _publisherCtrl;
|
||||
late TextEditingController _isbnCtrl;
|
||||
List<String> _editAuthors = [];
|
||||
List<String> _editTranslators = [];
|
||||
List<String> _editGenres = [];
|
||||
List<String> _editAlternateTitles = [];
|
||||
String? _editCoverPath;
|
||||
String _editStatus = 'want_to_read';
|
||||
DateTime? _editPublishDate;
|
||||
DateTime? _editStartDate;
|
||||
DateTime? _editFinishDate;
|
||||
bool _editIsDownloading = false;
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_coverOffset.dispose();
|
||||
_draggingCover.dispose();
|
||||
_showTitle.dispose();
|
||||
_overlayScrollController?.dispose();
|
||||
_titleCtrl.dispose();
|
||||
_summaryCtrl.dispose();
|
||||
_ratingCtrl.dispose();
|
||||
_publisherCtrl.dispose();
|
||||
_isbnCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -51,6 +82,45 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
super.initState();
|
||||
_detailStyle = UserPrefs().detailPageStyle;
|
||||
_coverOffset.value = UserPrefs().getCoverOffset(widget.book.id);
|
||||
_initEditControllers();
|
||||
}
|
||||
|
||||
void _initEditControllers() {
|
||||
final b = widget.book;
|
||||
_titleCtrl = TextEditingController(text: b.title);
|
||||
_summaryCtrl = TextEditingController(text: b.summary ?? '');
|
||||
_ratingCtrl = TextEditingController(text: b.rating?.toString() ?? '');
|
||||
_publisherCtrl = TextEditingController(text: b.publisher ?? '');
|
||||
_isbnCtrl = TextEditingController(text: b.isbn ?? '');
|
||||
_editAuthors = List.from(b.authors);
|
||||
_editTranslators = List.from(b.translators);
|
||||
_editGenres = List.from(b.genres);
|
||||
_editAlternateTitles = List.from(b.alternateTitles);
|
||||
_editCoverPath = b.coverPath;
|
||||
_editStatus = b.status;
|
||||
_editPublishDate = b.publishDate;
|
||||
_editStartDate = b.startDate;
|
||||
_editFinishDate = b.finishDate;
|
||||
}
|
||||
|
||||
void _enterEditMode() {
|
||||
final latest = context.read<AppProvider>().books
|
||||
.where((b) => b.id == widget.book.id).firstOrNull ?? widget.book;
|
||||
_titleCtrl.text = latest.title;
|
||||
_summaryCtrl.text = latest.summary ?? '';
|
||||
_ratingCtrl.text = latest.rating?.toString() ?? '';
|
||||
_publisherCtrl.text = latest.publisher ?? '';
|
||||
_isbnCtrl.text = latest.isbn ?? '';
|
||||
_editAuthors = List.from(latest.authors);
|
||||
_editTranslators = List.from(latest.translators);
|
||||
_editGenres = List.from(latest.genres);
|
||||
_editAlternateTitles = List.from(latest.alternateTitles);
|
||||
_editCoverPath = latest.coverPath;
|
||||
_editStatus = latest.status;
|
||||
_editPublishDate = latest.publishDate;
|
||||
_editStartDate = latest.startDate;
|
||||
_editFinishDate = latest.finishDate;
|
||||
setState(() => _isEditing = true);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -71,6 +141,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
|
||||
/// 桌面端左右分栏布局
|
||||
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,
|
||||
@@ -143,13 +214,12 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
],
|
||||
_buildEpubProgressBar(book),
|
||||
const SizedBox(height: 16),
|
||||
Row(children: [
|
||||
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)),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
_buildStatusTag(book),
|
||||
]),
|
||||
@@ -263,7 +333,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _navigateToEdit(context),
|
||||
onPressed: _enterEditMode,
|
||||
icon: const Icon(Icons.edit_outlined, size: 16),
|
||||
label: const Text('编辑'),
|
||||
style: FilledButton.styleFrom(
|
||||
@@ -278,6 +348,375 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 桌面端编辑模式 ──────────────────────────────────────────
|
||||
|
||||
Widget _buildDesktopEditStyle(Book book, ColorScheme colors) {
|
||||
final hasCover = _editCoverPath != null && _editCoverPath!.isNotEmpty;
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
body: Form(
|
||||
key: _editFormKey,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(color: colors.surface,
|
||||
border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5))),
|
||||
child: Row(children: [
|
||||
IconButton(icon: Icon(Icons.close, color: colors.onSurface, size: 18),
|
||||
onPressed: () => setState(() => _isEditing = false)),
|
||||
Expanded(child: Text('编辑书籍',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface))),
|
||||
FilledButton.icon(onPressed: _saveEdit,
|
||||
icon: const Icon(Icons.check, size: 16), label: const Text('保存'),
|
||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)))),
|
||||
const SizedBox(width: 12),
|
||||
]),
|
||||
),
|
||||
Expanded(
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
// 左侧:封面 + 状态/评分
|
||||
Container(width: 240, padding: const EdgeInsets.all(20), child: Column(children: [
|
||||
GestureDetector(onTap: _showEditCoverOptions, child: Container(
|
||||
width: 200, height: 280,
|
||||
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(alignment: Alignment.center, children: [
|
||||
hasCover ? FadeInLocalImage(path: _editCoverPath, fit: BoxFit.cover)
|
||||
: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Icon(Icons.image_outlined, size: 32, color: colors.onSurface.withValues(alpha: 0.25)),
|
||||
const SizedBox(height: 8),
|
||||
Text('点击添加封面', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))),
|
||||
]),
|
||||
if (_editIsDownloading) Container(color: Colors.black.withValues(alpha: 0.4),
|
||||
child: const CircularProgressIndicator(strokeWidth: 2, color: Colors.white)),
|
||||
]),
|
||||
)),
|
||||
if (hasCover) Padding(padding: const EdgeInsets.only(top: 8),
|
||||
child: GestureDetector(onTap: () => setState(() => _editCoverPath = null),
|
||||
child: Text('移除封面', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))))),
|
||||
const SizedBox(height: 20),
|
||||
_buildEditSectionLabel('状态', colors),
|
||||
const SizedBox(height: 6),
|
||||
Container(padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(6)),
|
||||
child: Wrap(spacing: 0, runSpacing: 4, children: [
|
||||
_buildEditStatusChip('想读', 'want_to_read', colors),
|
||||
_buildEditStatusChip('在读', 'reading', colors),
|
||||
_buildEditStatusChip('已读', 'read', colors),
|
||||
_buildEditStatusChip('弃读', 'abandoned', colors),
|
||||
])),
|
||||
const SizedBox(height: 16),
|
||||
_buildEditSectionLabel('评分', colors),
|
||||
const SizedBox(height: 6),
|
||||
Row(children: [
|
||||
...List.generate(5, (i) {
|
||||
final starVal = i + 1;
|
||||
final currentRating = double.tryParse(_ratingCtrl.text) ?? 0;
|
||||
final starRating = currentRating / 2;
|
||||
final isFilled = starVal <= starRating;
|
||||
final isHalf = starVal == starRating.ceil() && starRating % 1 != 0;
|
||||
return GestureDetector(onTap: () => setState(() => _ratingCtrl.text = (starVal * 2).toString()),
|
||||
child: Padding(padding: const EdgeInsets.symmetric(horizontal: 1),
|
||||
child: Icon(isHalf ? Icons.star_half : (isFilled ? Icons.star : Icons.star_border),
|
||||
size: 20, color: (isFilled || isHalf) ? const Color(0xFFFFB800) : colors.outline)));
|
||||
}),
|
||||
const SizedBox(width: 8),
|
||||
Container(width: 48, height: 28,
|
||||
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(6)),
|
||||
child: TextFormField(controller: _ratingCtrl,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
textAlign: TextAlign.center, inputFormatters: [_RatingInputFormatter()],
|
||||
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface),
|
||||
decoration: InputDecoration(hintText: '0-10', hintStyle: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.25)),
|
||||
border: InputBorder.none, contentPadding: const EdgeInsets.symmetric(vertical: 6), isDense: true),
|
||||
onChanged: (_) => setState(() {}))),
|
||||
if (_ratingCtrl.text.isNotEmpty) ...[
|
||||
const SizedBox(width: 4),
|
||||
GestureDetector(onTap: () => setState(() => _ratingCtrl.clear()),
|
||||
child: Icon(Icons.close, size: 14, color: colors.onSurface.withValues(alpha: 0.3))),
|
||||
],
|
||||
]),
|
||||
])),
|
||||
// 右侧:可滚动表单
|
||||
Expanded(child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(0, 20, 24, 80),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
_buildEditField('名称', _titleCtrl, hint: '书籍名称', required: true),
|
||||
const SizedBox(height: 16),
|
||||
_buildEditChipField('作者', _editAuthors, onTap: () async {
|
||||
final provider = context.read<AppProvider>();
|
||||
final data = provider.books.map((b) => b.authors).toList();
|
||||
final result = await GenreSelectorPage.show(context: context, title: '选择作者',
|
||||
existingTagsFuture: compute(_collectUnique, data), initialSelected: _editAuthors, hint: '如:余华、莫言');
|
||||
if (result != null) setState(() => _editAuthors = result);
|
||||
}),
|
||||
const SizedBox(height: 16),
|
||||
_buildEditChipField('译者', _editTranslators, onTap: () async {
|
||||
final provider = context.read<AppProvider>();
|
||||
final data = provider.books.map((b) => b.translators).toList();
|
||||
final result = await GenreSelectorPage.show(context: context, title: '选择译者',
|
||||
existingTagsFuture: compute(_collectUnique, data), initialSelected: _editTranslators, hint: '如:林少华');
|
||||
if (result != null) setState(() => _editTranslators = result);
|
||||
}),
|
||||
const SizedBox(height: 16),
|
||||
_buildEditChipField('别名', _editAlternateTitles, onTap: () async {
|
||||
final result = await GenreSelectorPage.show(context: context, title: '添加别名',
|
||||
existingTags: [], initialSelected: _editAlternateTitles, hint: '输入别名');
|
||||
if (result != null) setState(() => _editAlternateTitles = result);
|
||||
}),
|
||||
const SizedBox(height: 16),
|
||||
_buildEditChipField('类型', _editGenres, onTap: () async {
|
||||
final provider = context.read<AppProvider>();
|
||||
final tags = await provider.getTags('book_genre', excludeHidden: true);
|
||||
final names = tags.map((t) => t['name'] as String).toList();
|
||||
if (!mounted) return;
|
||||
final result = await GenreSelectorPage.show(context: context, title: '选择类型',
|
||||
existingTags: names, initialSelected: _editGenres, hint: '如:小说、科幻');
|
||||
if (result != null) setState(() => _editGenres = result);
|
||||
}),
|
||||
const SizedBox(height: 16),
|
||||
_buildEditField('出版社', _publisherCtrl, hint: '出版社名称'),
|
||||
const SizedBox(height: 16),
|
||||
_buildEditField('ISBN', _isbnCtrl, hint: 'ISBN编号'),
|
||||
const SizedBox(height: 16),
|
||||
Row(children: [
|
||||
Expanded(child: _buildEditDateField('出版时间', _editPublishDate, (d) => setState(() => _editPublishDate = d))),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _buildEditDateField('开始阅读', _editStartDate, (d) => setState(() => _editStartDate = d), clearable: true)),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
_buildEditDateField('读完日期', _editFinishDate, (d) => setState(() => _editFinishDate = d), clearable: true),
|
||||
const SizedBox(height: 16),
|
||||
_buildEditSectionLabel('简介', colors),
|
||||
const SizedBox(height: 6),
|
||||
Container(constraints: const BoxConstraints(minHeight: 120),
|
||||
child: TextFormField(controller: _summaryCtrl, maxLines: null,
|
||||
style: TextStyle(fontSize: 14, color: colors.onSurface, height: 1.6),
|
||||
decoration: InputDecoration(hintText: '写下书籍简介...',
|
||||
hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)),
|
||||
filled: true, fillColor: colors.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none),
|
||||
contentPadding: const EdgeInsets.all(12)))),
|
||||
]),
|
||||
)),
|
||||
]),
|
||||
),
|
||||
Container(height: 48,
|
||||
decoration: BoxDecoration(color: colors.surface,
|
||||
border: Border(top: BorderSide(color: colors.outlineVariant, width: 0.5))),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [
|
||||
OutlinedButton.icon(onPressed: () => _showDeleteDialog(context),
|
||||
icon: Icon(Icons.delete_outline, size: 16, color: colors.error),
|
||||
label: Text('删除', style: TextStyle(color: colors.error)),
|
||||
style: OutlinedButton.styleFrom(side: BorderSide(color: colors.error.withValues(alpha: 0.3)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)))),
|
||||
])),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEditSectionLabel(String label, ColorScheme colors) {
|
||||
return Text(label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4)));
|
||||
}
|
||||
|
||||
Widget _buildEditStatusChip(String label, String value, ColorScheme colors) {
|
||||
final selected = _editStatus == value;
|
||||
return GestureDetector(onTap: () => setState(() => _editStatus = value),
|
||||
child: Container(padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: selected ? colors.surface : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
boxShadow: selected ? [BoxShadow(color: colors.onSurface.withValues(alpha: 0.03), blurRadius: 4, offset: const Offset(0, 2))] : null),
|
||||
child: Text(label, style: TextStyle(fontSize: 12, fontWeight: selected ? FontWeight.w500 : FontWeight.normal,
|
||||
color: selected ? colors.onSurface : colors.onSurface.withValues(alpha: 0.4)))));
|
||||
}
|
||||
|
||||
Widget _buildEditField(String label, TextEditingController ctrl, {String hint = '', bool required = false}) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(required ? '$label *' : label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
const SizedBox(height: 6),
|
||||
TextFormField(controller: ctrl, style: TextStyle(fontSize: 14, color: colors.onSurface),
|
||||
validator: required ? (v) => (v == null || v.trim().isEmpty) ? '请输入$label' : null : null,
|
||||
decoration: InputDecoration(hintText: hint, hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)),
|
||||
filled: true, fillColor: colors.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), isDense: true)),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _buildEditChipField(String label, List<String> chips, {required VoidCallback onTap}) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
const SizedBox(height: 6),
|
||||
GestureDetector(onTap: onTap,
|
||||
child: Container(width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(color: colors.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(8)),
|
||||
child: chips.isEmpty
|
||||
? Text('点击选择$label', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.25)))
|
||||
: Wrap(spacing: 4, runSpacing: 4, children: chips.map((c) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(4)),
|
||||
child: Text(c, style: TextStyle(fontSize: 12, color: colors.onSurface)))).toList()))),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _buildEditDateField(String label, DateTime? date, ValueChanged<DateTime?> onChanged, {bool clearable = false}) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final hasDate = date != null;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
const SizedBox(height: 6),
|
||||
GestureDetector(onTap: () async {
|
||||
final picked = await showDatePicker(context: context, initialDate: date ?? DateTime.now(),
|
||||
firstDate: DateTime(1900), lastDate: DateTime.now().add(const Duration(days: 365 * 5)));
|
||||
if (picked != null) onChanged(picked);
|
||||
},
|
||||
child: Container(width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(color: colors.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(8)),
|
||||
child: Row(children: [
|
||||
Icon(Icons.calendar_today_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.4)),
|
||||
const SizedBox(width: 8),
|
||||
Text(hasDate ? '${date!.year}.${date!.month.toString().padLeft(2, '0')}.${date!.day.toString().padLeft(2, '0')}' : '选择日期',
|
||||
style: TextStyle(fontSize: 14, color: hasDate ? colors.onSurface : colors.onSurface.withValues(alpha: 0.25))),
|
||||
const Spacer(),
|
||||
if (clearable && hasDate) GestureDetector(onTap: () => onChanged(null),
|
||||
child: Icon(Icons.close, size: 14, color: colors.onSurface.withValues(alpha: 0.3))),
|
||||
]))),
|
||||
]);
|
||||
}
|
||||
|
||||
void _showEditCoverOptions() {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(context: context, backgroundColor: colors.surface,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
||||
builder: (ctx) => SafeArea(child: Padding(padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Container(width: 40, height: 4, decoration: BoxDecoration(color: colors.outline, borderRadius: BorderRadius.circular(2))),
|
||||
const SizedBox(height: 20),
|
||||
Padding(padding: const EdgeInsets.symmetric(horizontal: 24), child: Align(alignment: Alignment.centerLeft,
|
||||
child: Text('添加封面', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)))),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(leading: Icon(Icons.photo_library_outlined, color: colors.onSurface.withValues(alpha: 0.6)),
|
||||
title: Text('从相册选择', style: TextStyle(color: colors.onSurface)),
|
||||
onTap: () { Navigator.pop(ctx); _pickEditCover(); }),
|
||||
ListTile(leading: Icon(Icons.link_outlined, color: colors.onSurface.withValues(alpha: 0.6)),
|
||||
title: Text('网络链接', style: TextStyle(color: colors.onSurface)),
|
||||
onTap: () { Navigator.pop(ctx); _pickEditCoverFromUrl(); }),
|
||||
]))));
|
||||
}
|
||||
|
||||
Future<void> _pickEditCover() async {
|
||||
try {
|
||||
final XFile? picked = await _picker.pickImage(source: ImageSource.gallery, maxWidth: 800, maxHeight: 1200, imageQuality: 85);
|
||||
if (picked == null) return;
|
||||
final fileName = 'cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
final targetPath = await ImagePathHelper.instance.getBookCoverPath(widget.book.id, fileName);
|
||||
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
|
||||
await File(picked.path).copy(targetPath);
|
||||
if (mounted) setState(() => _editCoverPath = targetPath);
|
||||
} catch (e) {
|
||||
if (mounted) ToastUtil.show(context, '选择封面失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickEditCoverFromUrl() async {
|
||||
final urlCtrl = TextEditingController();
|
||||
final confirmed = await showDialog<bool>(context: context, builder: (ctx) {
|
||||
final colors = Theme.of(ctx).colorScheme;
|
||||
return AlertDialog(backgroundColor: colors.surface, elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
title: Text('添加网络图片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('请输入图片链接地址', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))),
|
||||
const SizedBox(height: 12),
|
||||
TextField(controller: urlCtrl, keyboardType: TextInputType.url, style: TextStyle(fontSize: 14, color: colors.onSurface),
|
||||
decoration: InputDecoration(hintText: 'https://example.com/image.jpg',
|
||||
hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)),
|
||||
filled: true, fillColor: colors.surfaceContainerHigh,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: colors.primary, width: 1)))),
|
||||
]),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))),
|
||||
ElevatedButton(onPressed: () => Navigator.pop(ctx, true),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)),
|
||||
child: const Text('确定')),
|
||||
]);
|
||||
});
|
||||
final url = urlCtrl.text.trim(); urlCtrl.dispose();
|
||||
if (confirmed != true || url.isEmpty) return;
|
||||
await _downloadEditCoverFromUrl(url);
|
||||
}
|
||||
|
||||
Future<void> _downloadEditCoverFromUrl(String url) async {
|
||||
setState(() => _editIsDownloading = true);
|
||||
try {
|
||||
final response = await http.get(Uri.parse(url), headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15',
|
||||
'Accept': 'image/avif,image/webp,image/apng,*/*;q=0.8',
|
||||
'Referer': Uri.parse(url).replace(path: '/').toString(),
|
||||
});
|
||||
if (response.statusCode != 200) throw Exception('下载失败: HTTP ${response.statusCode}');
|
||||
final contentType = response.headers['content-type'];
|
||||
if (contentType != null && !contentType.startsWith('image/')) throw Exception('链接返回的不是图片');
|
||||
if (response.bodyBytes.length > 10 * 1024 * 1024) throw Exception('图片太大');
|
||||
final fileName = 'cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
final targetPath = await ImagePathHelper.instance.getBookCoverPath(widget.book.id, fileName);
|
||||
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
|
||||
await File(targetPath).writeAsBytes(response.bodyBytes);
|
||||
if (mounted) setState(() => _editCoverPath = targetPath);
|
||||
} catch (e) {
|
||||
if (mounted) ToastUtil.show(context, '下载失败: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _editIsDownloading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveEdit() async {
|
||||
if (!_editFormKey.currentState!.validate()) return;
|
||||
try {
|
||||
final rating = _ratingCtrl.text.isNotEmpty ? double.tryParse(_ratingCtrl.text) : null;
|
||||
final updated = widget.book.copyWith(
|
||||
title: _titleCtrl.text.trim(),
|
||||
coverPath: _editCoverPath,
|
||||
authors: _editAuthors,
|
||||
translators: _editTranslators,
|
||||
alternateTitles: _editAlternateTitles,
|
||||
genres: _editGenres,
|
||||
publisher: _publisherCtrl.text.trim().isNotEmpty ? _publisherCtrl.text.trim() : null,
|
||||
isbn: _isbnCtrl.text.trim().isNotEmpty ? _isbnCtrl.text.trim() : null,
|
||||
summary: _summaryCtrl.text.trim(),
|
||||
rating: rating,
|
||||
status: _editStatus,
|
||||
publishDate: _editPublishDate,
|
||||
startDate: _editStartDate,
|
||||
finishDate: _editFinishDate,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
await context.read<AppProvider>().updateBook(updated);
|
||||
if (!mounted) return;
|
||||
ToastUtil.show(context, '更新成功');
|
||||
setState(() => _isEditing = false);
|
||||
} catch (e) {
|
||||
if (mounted) ToastUtil.show(context, '保存失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> _collectUnique(List<List<String>> lists) {
|
||||
final s = <String>{};
|
||||
for (final l in lists) { s.addAll(l); }
|
||||
return s.toList()..sort();
|
||||
}
|
||||
|
||||
Widget _buildDesktopInfoRow(String label, String value, ColorScheme colors) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
@@ -1678,3 +2117,15 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../../widgets/fade_in_local_image.dart';
|
||||
import '../../widgets/master_detail_scaffold.dart';
|
||||
import '../../widgets/detail_placeholder.dart';
|
||||
import 'book_detail_page.dart';
|
||||
import 'book_add_page.dart';
|
||||
|
||||
/// 阅读标签页(分页 + 触底加载)
|
||||
class BookTabPage extends StatefulWidget {
|
||||
@@ -157,11 +158,14 @@ class _BookTabPageState extends State<BookTabPage> {
|
||||
if (!isWideContent) return masterContent;
|
||||
|
||||
final selectedBook = provider.selectedBook;
|
||||
final detailWidget = provider.isAdding && provider.addingType == 1
|
||||
? BookAddPage(onCancel: () => provider.cancelAdding())
|
||||
: selectedBook != null
|
||||
? BookDetailPage(book: selectedBook, embedded: true)
|
||||
: const DetailPlaceholder(icon: Icons.menu_book_outlined, message: '选择一本书查看详情');
|
||||
return MasterDetailScaffold(
|
||||
master: masterContent,
|
||||
detail: selectedBook != null
|
||||
? BookDetailPage(book: selectedBook, embedded: true)
|
||||
: const DetailPlaceholder(icon: Icons.menu_book_outlined, message: '选择一本书查看详情'),
|
||||
detail: detailWidget,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user