Files
MookNote/lib/pages/movies/movie_detail_page.dart
2026-05-23 17:25:48 +08:00

818 lines
23 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import 'package:share_plus/share_plus.dart';
import 'package:cross_file/cross_file.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../providers/app_provider.dart';
import '../../models/data_models.dart';
import '../../utils/toast_util.dart';
import 'movie_reviews_page.dart';
import 'movie_posters_page.dart';
import 'movie_share_page.dart';
/// 影视详情页 - 极简主义设计
class MovieDetailPage extends StatefulWidget {
final Movie movie;
const MovieDetailPage({super.key, required this.movie});
@override
State<MovieDetailPage> createState() => _MovieDetailPageState();
}
class _MovieDetailPageState extends State<MovieDetailPage> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
// 页面获得焦点时刷新数据
_refreshMovieData();
}
void _refreshMovieData() {
final provider = context.read<AppProvider>();
// 强制刷新当前影视数据
provider.loadMovies();
}
@override
Widget build(BuildContext context) {
// 从 Provider 获取最新的 movie 数据,实现动态刷新
final movie = context.watch<AppProvider>().movies
.where((m) => m.id == widget.movie.id)
.firstOrNull ?? widget.movie;
return Scaffold(
backgroundColor: Colors.white,
body: Stack(
children: [
CustomScrollView(
slivers: [
// 顶部海报区域
_buildSliverAppBar(movie),
// 内容区域
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 基本信息
_buildBasicInfo(movie),
const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
// 导演
if (movie.directors.isNotEmpty)
_buildDirectorsSection(movie),
// 编剧
if (movie.writers.isNotEmpty)
_buildWritersSection(movie),
// 主演
if (movie.actors.isNotEmpty)
_buildActorsSection(movie),
// 类型
if (movie.genres.isNotEmpty)
_buildGenresSection(movie),
const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
// 简介
if (movie.summary != null && movie.summary!.isNotEmpty)
_buildSummarySection(movie),
const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
// 影评和海报墙入口
_buildExtraSections(movie),
const SizedBox(height: 120),
],
),
),
],
),
// 右下角悬浮按钮组
Positioned(
right: 16,
bottom: 24,
child: _buildFloatingActionButtons(movie),
),
],
),
);
}
/// 构建右下角悬浮按钮组
Widget _buildFloatingActionButtons(Movie movie) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildFloatingButton(
icon: Icons.edit_outlined,
onPressed: () => _navigateToEdit(context),
tooltip: '编辑',
),
const SizedBox(height: 12),
_buildFloatingButton(
icon: Icons.delete_outline,
onPressed: () => _showDeleteDialog(context),
tooltip: '删除',
backgroundColor: Colors.red,
),
const SizedBox(height: 12),
_buildFloatingButton(
icon: Icons.share_outlined,
onPressed: () => _showSharePoster(movie),
tooltip: '分享海报',
backgroundColor: const Color(0xFF4CAF50),
),
],
);
}
/// 构建单个悬浮按钮
Widget _buildFloatingButton({
required IconData icon,
required VoidCallback onPressed,
required String tooltip,
Color backgroundColor = const Color(0xFF1A1A1A),
}) {
return Material(
color: Colors.transparent,
child: Ink(
width: 40,
height: 40,
decoration: BoxDecoration(
color: backgroundColor,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: backgroundColor.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: IconButton(
icon: Icon(icon, size: 18, color: Colors.white),
onPressed: onPressed,
tooltip: tooltip,
),
),
);
}
/// 构建带背景的返回按钮
Widget _buildBackButton() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => Navigator.pop(context),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(8),
child: const Icon(
Icons.arrow_back,
color: Colors.white,
size: 22,
),
),
),
),
);
}
/// 构建顶部 AppBar
Widget _buildSliverAppBar(Movie movie) {
return SliverAppBar(
expandedHeight: 320,
pinned: true,
backgroundColor: const Color(0xFFF5F5F5),
leading: _buildBackButton(),
flexibleSpace: FlexibleSpaceBar(
background: _buildPosterSection(movie),
),
// 右上角按钮已移到右下角悬浮按钮
);
}
/// 构建海报区域
Widget _buildPosterSection(Movie movie) {
return SizedBox.expand(
child: movie.posterPath != null && movie.posterPath!.isNotEmpty
? Image.file(
File(movie.posterPath!),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildPosterPlaceholder(),
)
: _buildPosterPlaceholder(),
);
}
Widget _buildPosterPlaceholder() {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.movie_outlined,
size: 64,
color: Color(0xFFCCCCCC),
),
SizedBox(height: 16),
Text(
'暂无海报',
style: TextStyle(
fontSize: 14,
color: Color(0xFF999999),
),
),
],
),
);
}
/// 构建基本信息
Widget _buildBasicInfo(Movie movie) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 影视名称
Text(
movie.title,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
height: 1.3,
),
),
// 别名(显示在主名称下面,用 / 分隔)
if (movie.alternateTitles.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
movie.alternateTitles.join(' / '),
style: const TextStyle(
fontSize: 14,
color: Color(0xFF999999),
height: 1.4,
),
),
],
const SizedBox(height: 16),
// 评分和状态
Row(
children: [
if (movie.rating != null) ...[
const Icon(
Icons.star,
size: 20,
color: Color(0xFF1A1A1A),
),
const SizedBox(width: 4),
Text(
movie.rating!.toStringAsFixed(1),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(width: 16),
],
_buildStatusTag(movie),
],
),
const SizedBox(height: 8),
// 上映日期
if (movie.releaseDate != null)
Text(
'${movie.releaseDate!.year}${movie.releaseDate!.month.toString().padLeft(2, '0')}月上映',
style: const TextStyle(
fontSize: 14,
color: Color(0xFF999999),
),
),
const SizedBox(height: 8),
// 观看日期
if (movie.watchDate != null)
Text(
'观看于 ${_formatDate(movie.watchDate!)}',
style: const TextStyle(
fontSize: 14,
color: Color(0xFF999999),
),
),
],
),
);
}
/// 构建状态标签
Widget _buildStatusTag(Movie movie) {
String label;
Color bgColor;
Color textColor;
switch (movie.status) {
case 'watched':
label = '已看';
bgColor = const Color(0xFF1A1A1A);
textColor = Colors.white;
break;
case 'watching':
label = '在看';
bgColor = const Color(0xFFF0F0F0);
textColor = const Color(0xFF666666);
break;
case 'want_to_watch':
label = '想看';
bgColor = const Color(0xFFF5F5F5);
textColor = const Color(0xFF999999);
break;
default:
label = '未知';
bgColor = const Color(0xFFEEEEEE);
textColor = const Color(0xFFCCCCCC);
}
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 _buildDirectorsSection(Movie movie) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
width: 48,
child: Text(
'导演',
style: TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
),
),
Expanded(
child: Text(
movie.directors.join(''),
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
height: 1.5,
),
),
),
],
),
);
}
/// 构建编剧区域
Widget _buildWritersSection(Movie movie) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
width: 48,
child: Text(
'编剧',
style: TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
),
),
Expanded(
child: Text(
movie.writers.join(''),
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
height: 1.5,
),
),
),
],
),
);
}
/// 构建主演区域
Widget _buildActorsSection(Movie movie) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
width: 48,
child: Text(
'主演',
style: TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
),
),
Expanded(
child: Text(
movie.actors.join(''),
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
height: 1.5,
),
),
),
],
),
);
}
/// 构建类型区域
Widget _buildGenresSection(Movie movie) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
width: 48,
child: Text(
'类型',
style: TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
),
),
Expanded(
child: Wrap(
spacing: 8,
runSpacing: 8,
children: movie.genres.map((genre) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(4),
),
child: Text(
genre,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF666666),
),
),
);
}).toList(),
),
),
],
),
);
}
/// 构建简介区域
Widget _buildSummarySection(Movie movie) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
const Text(
'简介',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(12),
),
child: Text(
movie.summary!,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
height: 1.8,
),
),
),
],
),
);
}
/// 构建额外功能区域(影评、海报墙)
Widget _buildExtraSections(Movie movie) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
const Text(
'更多',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
],
),
const SizedBox(height: 16),
// 影评入口
_buildExtraSectionItem(
icon: Icons.rate_review_outlined,
title: '影评',
subtitleFuture: context.read<AppProvider>().getMovieReviewCount(movie.id),
emptyText: '暂无影评',
unit: '条影评',
onTap: () => _navigateToReviews(movie),
),
const SizedBox(height: 12),
// 海报墙入口
_buildExtraSectionItem(
icon: Icons.photo_library_outlined,
title: '海报墙',
subtitleFuture: context.read<AppProvider>().getMoviePosterCount(movie.id),
emptyText: '暂无海报',
unit: '张海报',
onTap: () => _navigateToPosters(movie),
),
],
),
);
}
/// 构建更多区域项
Widget _buildExtraSectionItem({
required IconData icon,
required String title,
required Future<int> subtitleFuture,
required String emptyText,
required String unit,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Icon(
icon,
size: 20,
color: const Color(0xFF666666),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(height: 4),
FutureBuilder<int>(
future: subtitleFuture,
builder: (context, snapshot) {
final count = snapshot.data ?? 0;
return Text(
count > 0 ? '$count $unit' : emptyText,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
);
},
),
],
),
),
const Icon(
Icons.chevron_right,
color: Color(0xFFCCCCCC),
),
],
),
),
);
}
void _navigateToReviews(Movie movie) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MovieReviewsPage(movie: movie),
),
);
}
void _navigateToPosters(Movie movie) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MoviePostersPage(movie: movie),
),
);
}
/// 格式化日期
String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
/// 跳转到编辑页面
void _navigateToEdit(BuildContext context) {
Navigator.pushNamed(context, '/movie-form', arguments: widget.movie).then((_) {
context.read<AppProvider>().loadMovies();
});
}
/// 显示删除对话框
void _showDeleteDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: const Text(
'确认删除',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
content: Text(
'确定要删除"${widget.movie.title}"吗?删除后可在回收站恢复。',
style: const TextStyle(
fontSize: 14,
color: Color(0xFF666666),
height: 1.5,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF666666),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () async {
await context.read<AppProvider>().removeMovie(widget.movie.id);
if (!mounted) return;
Navigator.pop(context);
Navigator.pop(context);
ToastUtil.show(context, '已删除');
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('删除'),
),
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
}
/// 请求存储权限
Future<bool> _requestStoragePermission() async {
// Android 13+ 使用新的权限
if (Platform.isAndroid) {
final sdkInt = await _getAndroidSdkInt();
if (sdkInt >= 33) {
// Android 13+ 使用 READ_MEDIA_IMAGES
final status = await Permission.photos.request();
return status.isGranted;
} else {
// Android 12 及以下使用存储权限
var status = await Permission.storage.request();
if (status.isDenied) {
status = await Permission.storage.request();
}
return status.isGranted;
}
}
// iOS 不需要额外权限来保存到应用沙盒
return true;
}
/// 获取 Android SDK 版本
Future<int> _getAndroidSdkInt() async {
// 简化处理,实际可以通过 platform channel 获取
// 这里默认返回较低版本,使用传统存储权限
return 30;
}
/// 显示分享海报页面
void _showSharePoster(Movie movie) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MovieSharePage(movie: movie),
),
);
}
}