优化编辑新增界面

This commit is contained in:
DelLevin-Home
2026-07-13 23:57:08 +08:00
parent b4793a8d01
commit be72fd1286
22 changed files with 4241 additions and 45 deletions

View File

@@ -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/user_prefs.dart';
import '../../utils/toast_util.dart';
import '../../utils/image_path_helper.dart';
import '../../utils/responsive.dart';
import '../../widgets/genre_selector_page.dart';
import 'movie_reviews_page.dart';
import 'movie_posters_page.dart';
import 'movie_share_page.dart';
@@ -35,12 +41,69 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
final ValueNotifier<bool> _showTitle = ValueNotifier(false);
ScrollController? _overlayScrollController;
// ─── 编辑模式 ───
bool _isEditing = false;
final _editFormKey = GlobalKey<FormState>();
late TextEditingController _titleCtrl;
late TextEditingController _summaryCtrl;
late TextEditingController _ratingCtrl;
List<String> _editDirectors = [];
List<String> _editWriters = [];
List<String> _editActors = [];
List<String> _editGenres = [];
List<String> _editAlternateTitles = [];
String? _editPosterPath;
String _editStatus = 'want_to_watch';
String _editCategory = 'movie';
DateTime? _editReleaseDate;
DateTime? _editWatchDate;
bool _editIsDownloading = false;
final ImagePicker _picker = ImagePicker();
@override
void initState() {
super.initState();
_showExactDate = UserPrefs().showExactReleaseDate;
_detailStyle = UserPrefs().detailPageStyle;
_posterOffset.value = UserPrefs().getCoverOffset(widget.movie.id);
_initEditControllers();
}
void _initEditControllers() {
final m = widget.movie;
_titleCtrl = TextEditingController(text: m.title);
_summaryCtrl = TextEditingController(text: m.summary ?? '');
_ratingCtrl = TextEditingController(text: m.rating?.toString() ?? '');
_editDirectors = List.from(m.directors);
_editWriters = List.from(m.writers);
_editActors = List.from(m.actors);
_editGenres = List.from(m.genres);
_editAlternateTitles = List.from(m.alternateTitles);
_editPosterPath = m.posterPath;
_editStatus = m.status;
_editCategory = m.category;
_editReleaseDate = m.releaseDate;
_editWatchDate = m.watchDate;
}
void _enterEditMode() {
// 从 provider 获取最新数据
final latest = context.read<AppProvider>().movies
.where((m) => m.id == widget.movie.id).firstOrNull ?? widget.movie;
_titleCtrl.text = latest.title;
_summaryCtrl.text = latest.summary ?? '';
_ratingCtrl.text = latest.rating?.toString() ?? '';
_editDirectors = List.from(latest.directors);
_editWriters = List.from(latest.writers);
_editActors = List.from(latest.actors);
_editGenres = List.from(latest.genres);
_editAlternateTitles = List.from(latest.alternateTitles);
_editPosterPath = latest.posterPath;
_editStatus = latest.status;
_editCategory = latest.category;
_editReleaseDate = latest.releaseDate;
_editWatchDate = latest.watchDate;
setState(() => _isEditing = true);
}
@override
@@ -49,6 +112,9 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
_draggingPoster.dispose();
_showTitle.dispose();
_overlayScrollController?.dispose();
_titleCtrl.dispose();
_summaryCtrl.dispose();
_ratingCtrl.dispose();
super.dispose();
}
@@ -74,6 +140,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
/// 桌面端左右分栏布局
Widget _buildDesktopStyle(Movie movie, ColorScheme colors) {
if (_isEditing) return _buildDesktopEditStyle(movie, colors);
final hasPoster = movie.posterPath != null && movie.posterPath!.isNotEmpty;
return Scaffold(
backgroundColor: colors.surface,
@@ -147,16 +214,14 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
],
const SizedBox(height: 16),
// 评分 + 状态 + 分类
Row(children: [
Wrap(spacing: 6, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [
if (movie.rating != null) ...[
Icon(Icons.star, size: 20, color: colors.onSurface),
const SizedBox(width: 4),
Text(movie.rating!.toStringAsFixed(1),
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(width: 16),
],
_buildStatusTag(movie),
const SizedBox(width: 6),
_buildCategoryTag(movie),
]),
const SizedBox(height: 8),
@@ -261,7 +326,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
),
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(
@@ -276,6 +341,548 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
);
}
// ─── 桌面端编辑模式 ──────────────────────────────────────────
static const _categories = [
('电影', 'movie'), ('电视剧', 'tv'), ('动漫', 'anime'),
('综艺', 'variety'), ('纪录片', 'documentary'), ('微短剧', 'short'), ('其他', 'other'),
];
Widget _buildDesktopEditStyle(Movie movie, ColorScheme colors) {
final hasPoster = _editPosterPath != null && _editPosterPath!.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: [
hasPoster
? FadeInLocalImage(path: _editPosterPath, 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 (hasPoster)
Padding(
padding: const EdgeInsets.only(top: 8),
child: GestureDetector(
onTap: () => setState(() => _editPosterPath = 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: Row(mainAxisSize: MainAxisSize.min, children: [
_buildEditStatusChip('想看', 'want_to_watch', colors),
_buildEditStatusChip('在看', 'watching', colors),
_buildEditStatusChip('已看', 'watched', 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)),
),
],
]),
const SizedBox(height: 16),
// 分类
_buildEditSectionLabel('分类', colors),
const SizedBox(height: 6),
Wrap(spacing: 4, runSpacing: 4, children: _categories.map((c) {
final selected = _editCategory == c.$2;
return GestureDetector(
onTap: () => setState(() => _editCategory = c.$2),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: selected ? colors.primary : colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Text(c.$1, style: TextStyle(
fontSize: 11, fontWeight: selected ? FontWeight.w500 : FontWeight.normal,
color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5),
)),
),
);
}).toList()),
],
),
),
// 右侧:可滚动表单
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('别名', _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('导演', _editDirectors, onTap: () async {
final provider = context.read<AppProvider>();
final data = provider.movies.map((m) => m.directors).toList();
final result = await GenreSelectorPage.show(
context: context, title: '选择导演',
existingTagsFuture: compute(_collectUnique, data),
initialSelected: _editDirectors, hint: '如:张艺谋、李安',
);
if (result != null) setState(() => _editDirectors = result);
}),
const SizedBox(height: 16),
// 编剧
_buildEditChipField('编剧', _editWriters, onTap: () async {
final provider = context.read<AppProvider>();
final data = provider.movies.map((m) => m.writers).toList();
final result = await GenreSelectorPage.show(
context: context, title: '选择编剧',
existingTagsFuture: compute(_collectUnique, data),
initialSelected: _editWriters, hint: '如:刘慈欣、王家卫',
);
if (result != null) setState(() => _editWriters = result);
}),
const SizedBox(height: 16),
// 主演
_buildEditChipField('主演', _editActors, onTap: () async {
final provider = context.read<AppProvider>();
final data = provider.movies.map((m) => m.actors).toList();
final result = await GenreSelectorPage.show(
context: context, title: '选择主演',
existingTagsFuture: compute(_collectUnique, data),
initialSelected: _editActors, hint: '如:梁朝伟、周星驰',
);
if (result != null) setState(() => _editActors = result);
}),
const SizedBox(height: 16),
// 类型
_buildEditChipField('类型', _editGenres, onTap: () async {
final provider = context.read<AppProvider>();
final tags = await provider.getTags('movie_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),
// 日期行
Row(children: [
Expanded(child: _buildEditDateField('上映日期', _editReleaseDate, (d) => setState(() => _editReleaseDate = d))),
const SizedBox(width: 12),
Expanded(child: _buildEditDateField('观看日期', _editWatchDate, (d) => setState(() => _editWatchDate = 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: 16, 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: 13, 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 = 'poster_${DateTime.now().millisecondsSinceEpoch}.jpg';
final targetPath = await ImagePathHelper.instance.getMoviePosterPath(widget.movie.id, fileName);
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
await File(picked.path).copy(targetPath);
if (mounted) setState(() => _editPosterPath = 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 = 'poster_${DateTime.now().millisecondsSinceEpoch}.jpg';
final targetPath = await ImagePathHelper.instance.getMoviePosterPath(widget.movie.id, fileName);
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
await File(targetPath).writeAsBytes(response.bodyBytes);
if (mounted) setState(() => _editPosterPath = 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.movie.copyWith(
title: _titleCtrl.text.trim(),
posterPath: _editPosterPath,
releaseDate: _editReleaseDate,
directors: _editDirectors,
writers: _editWriters,
actors: _editActors,
genres: _editGenres,
alternateTitles: _editAlternateTitles,
summary: _summaryCtrl.text.trim(),
rating: rating,
status: _editStatus,
category: _editCategory,
watchDate: _editWatchDate,
updatedAt: DateTime.now(),
);
await context.read<AppProvider>().updateMovie(updated);
if (!mounted) return;
ToastUtil.show(context, '更新成功');
setState(() => _isEditing = false);
} catch (e) {
if (mounted) ToastUtil.show(context, '保存失败: $e');
}
}
/// 从多值字段列表中提取去重排序的唯一值(供 compute 使用)
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),
@@ -1451,3 +2058,16 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
);
}
}
/// 评分输入格式化器:只允许 0-10最多1位小数
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;
}
}