新增影视的字段

This commit is contained in:
DelLevin-Home
2026-03-05 10:40:42 +08:00
parent adad6372a3
commit 44e5418031
10 changed files with 2440 additions and 592 deletions

View File

@@ -1,37 +1,65 @@
import 'dart:io';
import 'dart:convert';
/// 影视条目模型 /// 影视条目模型
class Movie { class Movie {
final String id; final String id;
final String title; final String title; // 影视名称
final String? poster; final String? posterPath; // 本地海报路径
final double? rating; final DateTime? releaseDate; // 上映时间
final int? year; final List<String> directors; // 导演列表
final String status; // 'watched', 'want_to_watch', 'watching' final List<String> writers; // 编剧列表
final DateTime? watchDate; final List<String> actors; // 主演列表
final String? note; final List<String> genres; // 类型
final List<String> alternateTitles; // 别名
final String? summary; // 剧情简介
final double? rating; // 评分 1-10
final String status; // watched/want_to_watch/watching
final DateTime createdAt;
final DateTime updatedAt;
final bool isDeleted;
Movie({ Movie({
required this.id, required this.id,
required this.title, required this.title,
this.poster, this.posterPath,
this.releaseDate,
this.directors = const [],
this.writers = const [],
this.actors = const [],
this.genres = const [],
this.alternateTitles = const [],
this.summary,
this.rating, this.rating,
this.year,
required this.status, required this.status,
this.watchDate, required this.createdAt,
this.note, required this.updatedAt,
this.isDeleted = false,
}); });
factory Movie.fromJson(Map<String, dynamic> json) { factory Movie.fromJson(Map<String, dynamic> json) {
return Movie( return Movie(
id: json['id'] ?? '', id: json['id'] ?? '',
title: json['title'] ?? '', title: json['title'] ?? '',
poster: json['poster'], posterPath: json['poster_path'],
rating: json['rating']?.toDouble(), releaseDate: json['release_date'] != null
year: json['year'], ? DateTime.parse(json['release_date'])
status: json['status'] ?? 'want_to_watch',
watchDate: json['watch_date'] != null
? DateTime.parse(json['watch_date'])
: null, : null,
note: json['note'], directors: _parseStringList(json['directors']),
writers: _parseStringList(json['writers']),
actors: _parseStringList(json['actors']),
genres: _parseStringList(json['genres']),
alternateTitles: _parseStringList(json['alternate_titles']),
summary: json['summary'],
rating: json['rating']?.toDouble(),
status: json['status'] ?? 'want_to_watch',
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'])
: DateTime.now(),
updatedAt: json['updated_at'] != null
? DateTime.parse(json['updated_at'])
: DateTime.now(),
isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true,
); );
} }
@@ -39,14 +67,85 @@ class Movie {
return { return {
'id': id, 'id': id,
'title': title, 'title': title,
'poster': poster, 'poster_path': posterPath,
'release_date': releaseDate?.toIso8601String(),
'directors': jsonEncode(directors),
'writers': jsonEncode(writers),
'actors': jsonEncode(actors),
'genres': jsonEncode(genres),
'alternate_titles': jsonEncode(alternateTitles),
'summary': summary,
'rating': rating, 'rating': rating,
'year': year,
'status': status, 'status': status,
'watch_date': watchDate?.toIso8601String(), 'created_at': createdAt.toIso8601String(),
'note': note, 'updated_at': updatedAt.toIso8601String(),
'is_deleted': isDeleted ? 1 : 0,
}; };
} }
/// 获取封面文件
File? get posterFile {
if (posterPath == null || posterPath!.isEmpty) return null;
return File(posterPath!);
}
/// 解析字符串列表
static List<String> _parseStringList(dynamic data) {
if (data == null) return [];
if (data is List) {
return data.map((e) => e.toString()).toList();
}
if (data is String) {
try {
// 尝试解析JSON字符串
final decoded = jsonDecode(data);
if (decoded is List) {
return decoded.map((e) => e.toString()).toList();
}
} catch (e) {
// 如果解析失败,按逗号分割
return data.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
}
}
return [];
}
/// 复制并修改
Movie copyWith({
String? id,
String? title,
String? posterPath,
DateTime? releaseDate,
List<String>? directors,
List<String>? writers,
List<String>? actors,
List<String>? genres,
List<String>? alternateTitles,
String? summary,
double? rating,
String? status,
DateTime? createdAt,
DateTime? updatedAt,
bool? isDeleted,
}) {
return Movie(
id: id ?? this.id,
title: title ?? this.title,
posterPath: posterPath ?? this.posterPath,
releaseDate: releaseDate ?? this.releaseDate,
directors: directors ?? this.directors,
writers: writers ?? this.writers,
actors: actors ?? this.actors,
genres: genres ?? this.genres,
alternateTitles: alternateTitles ?? this.alternateTitles,
summary: summary ?? this.summary,
rating: rating ?? this.rating,
status: status ?? this.status,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
isDeleted: isDeleted ?? this.isDeleted,
);
}
} }
/// 书籍条目模型 /// 书籍条目模型
@@ -146,3 +245,4 @@ class Note {
}; };
} }
} }

View File

@@ -1,78 +0,0 @@
/// 数据模型扩展 - 添加 copyWith 方法以便更新数据
library;
import 'data_models.dart';
/// Movie 扩展 - 添加 copyWith 方法
extension MovieExtension on Movie {
/// 创建副本并允许修改部分属性
Movie copyWith({
String? id,
String? title,
String? poster,
double? rating,
int? year,
String? status,
DateTime? watchDate,
String? note,
}) {
return Movie(
id: id ?? this.id,
title: title ?? this.title,
poster: poster ?? this.poster,
rating: rating ?? this.rating,
year: year ?? this.year,
status: status ?? this.status,
watchDate: watchDate ?? this.watchDate,
note: note ?? this.note,
);
}
}
/// Book 扩展 - 添加 copyWith 方法
extension BookExtension on Book {
/// 创建副本并允许修改部分属性
Book copyWith({
String? id,
String? title,
String? author,
String? cover,
double? rating,
String? status,
DateTime? readDate,
String? note,
}) {
return Book(
id: id ?? this.id,
title: title ?? this.title,
author: author ?? this.author,
cover: cover ?? this.cover,
rating: rating ?? this.rating,
status: status ?? this.status,
readDate: readDate ?? this.readDate,
note: note ?? this.note,
);
}
}
/// Note 扩展 - 添加 copyWith 方法
extension NoteExtension on Note {
/// 创建副本并允许修改部分属性
Note copyWith({
String? id,
String? title,
String? content,
List<String>? tags,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return Note(
id: id ?? this.id,
title: title ?? this.title,
content: content ?? this.content,
tags: tags ?? this.tags,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}

View File

@@ -1,3 +1,4 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
@@ -15,39 +16,138 @@ class MovieDetailPage extends StatefulWidget {
} }
class _MovieDetailPageState extends State<MovieDetailPage> { class _MovieDetailPageState extends State<MovieDetailPage> {
late Movie _movie;
@override
void initState() {
super.initState();
_movie = widget.movie;
}
void _refreshMovie() {
final provider = context.read<AppProvider>();
final updated = provider.movies.firstWhere(
(m) => m.id == _movie.id,
orElse: () => _movie,
);
setState(() {
_movie = updated;
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold( return Scaffold(
appBar: AppBar( backgroundColor: colorScheme.surface,
title: Text(widget.movie.title), body: CustomScrollView(
actions: [ slivers: [
IconButton( // 海报区域(可折叠)
icon: const Icon(Icons.edit), SliverAppBar(
onPressed: () => _navigateToEdit(context), expandedHeight: 320,
pinned: true,
backgroundColor: colorScheme.surface,
flexibleSpace: FlexibleSpaceBar(
background: _buildPosterSection(context),
),
leading: IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.3),
shape: BoxShape.circle,
),
child: const Icon(Icons.arrow_back, color: Colors.white, size: 20),
),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.3),
shape: BoxShape.circle,
),
child: const Icon(Icons.edit, color: Colors.white, size: 20),
),
onPressed: () => _navigateToEdit(context),
),
IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.3),
shape: BoxShape.circle,
),
child: const Icon(Icons.delete_outline, color: Colors.white, size: 20),
),
onPressed: () => _showDeleteDialog(context),
),
],
), ),
IconButton(
icon: const Icon(Icons.delete_outline), // 内容区域
onPressed: () => _showDeleteDialog(context), SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题和状态
_buildTitleSection(context),
const SizedBox(height: 20),
// 基本信息
_buildBasicInfoSection(context),
const SizedBox(height: 24),
// 导演
if (_movie.directors.isNotEmpty) ...[
_buildListSection(context, '导演', _movie.directors, Icons.videocam_outlined),
const SizedBox(height: 20),
],
// 编剧
if (_movie.writers.isNotEmpty) ...[
_buildListSection(context, '编剧', _movie.writers, Icons.edit_note_outlined),
const SizedBox(height: 20),
],
// 主演
if (_movie.actors.isNotEmpty) ...[
_buildListSection(context, '主演', _movie.actors, Icons.people_outline),
const SizedBox(height: 20),
],
// 类型
if (_movie.genres.isNotEmpty) ...[
_buildGenreSection(context),
const SizedBox(height: 20),
],
// 别名
if (_movie.alternateTitles.isNotEmpty) ...[
_buildListSection(context, '别名', _movie.alternateTitles, Icons.alternate_email_outlined),
const SizedBox(height: 20),
],
// 剧情简介
if (_movie.summary != null && _movie.summary!.isNotEmpty) ...[
_buildSummarySection(context),
],
const SizedBox(height: 40),
],
),
),
), ),
], ],
), ),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 海报区域
_buildPosterSection(context),
// 基本信息
_buildInfoSection(context),
// 笔记区域
if (widget.movie.note != null && widget.movie.note!.isNotEmpty)
_buildNoteSection(context),
],
),
),
); );
} }
@@ -55,226 +155,370 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
Widget _buildPosterSection(BuildContext context) { Widget _buildPosterSection(BuildContext context) {
return Container( return Container(
width: double.infinity, width: double.infinity,
height: 300, color: Colors.grey[300],
decoration: BoxDecoration( child: _movie.posterPath != null && _movie.posterPath!.isNotEmpty
color: Colors.grey[300], ? Image.file(
), File(_movie.posterPath!),
child: Stack( fit: BoxFit.cover,
children: [ errorBuilder: (context, error, stackTrace) => _buildPlaceholder(),
Center( )
child: Icon( : _buildPlaceholder(),
Icons.movie,
size: 80,
color: Colors.grey[500],
),
),
Positioned(
top: 16,
right: 16,
child: _buildStatusTag(context),
),
],
),
); );
} }
/// 构建状态标签 Widget _buildPlaceholder() {
Widget _buildStatusTag(BuildContext context) {
Color statusColor;
String statusText;
switch (widget.movie.status) {
case 'watched':
statusColor = AppTheme.watchedColor;
statusText = '已看';
break;
case 'want_to_watch':
statusColor = AppTheme.wantToWatchColor;
statusText = '想看';
break;
case 'watching':
statusColor = AppTheme.watchingColor;
statusText = '在看';
break;
default:
statusColor = Colors.grey;
statusText = '未知';
}
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), color: Colors.grey[300],
decoration: BoxDecoration( child: Center(
color: statusColor.withOpacity(0.9), child: Column(
borderRadius: BorderRadius.circular(20), mainAxisAlignment: MainAxisAlignment.center,
), children: [
child: Text( Icon(Icons.movie, size: 80, color: Colors.grey[500]),
statusText, const SizedBox(height: 12),
style: const TextStyle( Text(
fontSize: 14, '暂无海报',
color: Colors.white, style: TextStyle(color: Colors.grey[500], fontSize: 14),
fontWeight: FontWeight.bold, ),
],
), ),
), ),
); );
} }
/// 构建信息区域 /// 构建标题区域
Widget _buildInfoSection(BuildContext context) { Widget _buildTitleSection(BuildContext context) {
return Padding( final colorScheme = Theme.of(context).colorScheme;
padding: const EdgeInsets.all(16),
child: Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题
Text(
widget.movie.title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// 年份和评分
Row(
children: [
if (widget.movie.year != null) ...[
_buildInfoItem(
context,
icon: Icons.calendar_today,
label: '${widget.movie.year}',
),
const SizedBox(width: 16),
],
if (widget.movie.rating != null) ...[
_buildInfoItem(
context,
icon: Icons.star,
label: widget.movie.rating.toString(),
iconColor: Colors.amber[700],
textColor: Colors.amber[700],
),
],
],
),
const SizedBox(height: 16),
// 观看日期
if (widget.movie.watchDate != null)
_buildInfoItem(
context,
icon: Icons.event,
label: '观看日期:${_formatDate(widget.movie.watchDate!)}',
),
],
),
);
}
/// 构建信息项
Widget _buildInfoItem(
BuildContext context, {
required IconData icon,
required String label,
Color? iconColor,
Color? textColor,
}) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(icon, size: 18, color: iconColor), // 状态标签
const SizedBox(width: 4), Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: _getStatusColor().withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
_getStatusText(),
style: TextStyle(
fontSize: 12,
color: _getStatusColor(),
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 12),
// 标题
Text( Text(
label, _movie.title,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 24,
color: textColor ?? Theme.of(context).colorScheme.onSurfaceVariant, fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
height: 1.3,
), ),
), ),
], ],
); );
} }
/// 构建笔记区域 /// 构建基本信息区域
Widget _buildNoteSection(BuildContext context) { Widget _buildBasicInfoSection(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container( return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest.withOpacity(0.3),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Column( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( // 上映日期
children: [ if (_movie.releaseDate != null) ...[
Icon( Expanded(
Icons.edit_note, child: _buildInfoItem(
size: 20, context,
color: Theme.of(context).colorScheme.primary, icon: Icons.calendar_today_outlined,
label: '上映日期',
value: '${_movie.releaseDate!.year}.${_movie.releaseDate!.month.toString().padLeft(2, '0')}.${_movie.releaseDate!.day.toString().padLeft(2, '0')}',
), ),
const SizedBox(width: 8), ),
Text( Container(
'笔记', width: 1,
style: Theme.of(context).textTheme.titleMedium?.copyWith( height: 40,
fontWeight: FontWeight.bold, color: colorScheme.outline.withOpacity(0.2),
), ),
), ],
],
), // 评分
const SizedBox(height: 12), Expanded(
Text( child: _buildInfoItem(
widget.movie.note!, context,
style: Theme.of(context).textTheme.bodyMedium, icon: Icons.star_rounded,
label: '评分',
value: _movie.rating != null ? '${_movie.rating!.toStringAsFixed(1)}' : '暂无',
valueColor: _movie.rating != null ? Colors.amber[700] : null,
),
), ),
], ],
), ),
); );
} }
/// 格式化日期 Widget _buildInfoItem(
String _formatDate(DateTime date) { BuildContext context, {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; required IconData icon,
required String label,
required String value,
Color? valueColor,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
children: [
Icon(icon, size: 20, color: colorScheme.onSurfaceVariant),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant.withOpacity(0.7),
),
),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: valueColor ?? colorScheme.onSurface,
),
),
],
);
}
/// 构建列表区块(导演、编剧、演员、别名)
Widget _buildListSection(BuildContext context, String title, List<String> items, IconData icon) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 18, color: colorScheme.primary),
const SizedBox(width: 8),
Text(
title,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: items.map((item) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: colorScheme.outline.withOpacity(0.15),
width: 1,
),
),
child: Text(
item,
style: TextStyle(
fontSize: 13,
color: colorScheme.onSurface.withOpacity(0.85),
),
),
)).toList(),
),
],
);
}
/// 构建类型区块
Widget _buildGenreSection(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.local_movies_outlined, size: 18, color: colorScheme.primary),
const SizedBox(width: 8),
Text(
'类型',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: _movie.genres.map((genre) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: colorScheme.primary.withOpacity(0.3),
width: 1,
),
),
child: Text(
genre,
style: TextStyle(
fontSize: 13,
color: colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
)).toList(),
),
],
);
}
/// 构建剧情简介区块
Widget _buildSummarySection(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.article_outlined, size: 18, color: colorScheme.primary),
const SizedBox(width: 8),
Text(
'剧情简介',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Text(
_movie.summary!,
style: TextStyle(
fontSize: 14,
color: colorScheme.onSurface.withOpacity(0.8),
height: 1.7,
),
),
),
],
);
}
/// 获取状态颜色
Color _getStatusColor() {
switch (_movie.status) {
case 'watched':
return AppTheme.watchedColor;
case 'want_to_watch':
return AppTheme.wantToWatchColor;
case 'watching':
return AppTheme.watchingColor;
default:
return Colors.grey;
}
}
/// 获取状态文本
String _getStatusText() {
switch (_movie.status) {
case 'watched':
return '已看';
case 'want_to_watch':
return '想看';
case 'watching':
return '在看';
default:
return '未知';
}
} }
/// 跳转到编辑页面 /// 跳转到编辑页面
void _navigateToEdit(BuildContext context) { void _navigateToEdit(BuildContext context) {
Navigator.pushNamed(context, '/movie-form', arguments: widget.movie).then((_) { Navigator.pushNamed(context, '/movie-form', arguments: _movie).then((_) {
// 返回后刷新数据 _refreshMovie();
context.read<AppProvider>().loadMovies(); context.read<AppProvider>().loadMovies();
}); });
} }
/// 显示删除对话框 /// 显示删除对话框
void _showDeleteDialog(BuildContext context) { void _showDeleteDialog(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('确认删除'), backgroundColor: colorScheme.surface,
content: Text('确定要删除"${widget.movie.title}"吗?此操作不可恢复。'), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Text(
'确认删除',
style: TextStyle(color: colorScheme.onSurface),
),
content: Text(
'确定要删除"${_movie.title}"吗?此操作不可恢复。',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.7)),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('取消'), child: Text(
'取消',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
await context.read<AppProvider>().removeMovie(widget.movie.id); await context.read<AppProvider>().removeMovie(_movie.id);
if (!context.mounted) return; if (!context.mounted) return;
Navigator.pop(context); // 关闭对话框 Navigator.pop(context);
Navigator.pop(context); // 返回上一页 Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('已删除'), content: const Text('已删除'),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
backgroundColor: colorScheme.primary,
), ),
); );
}, },
child: const Text( child: Text(
'删除', '删除',
style: TextStyle(color: Colors.red), style: TextStyle(color: colorScheme.error),
), ),
), ),
], ],

View File

@@ -1,11 +1,15 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../models/data_models.dart'; import '../models/data_models.dart';
/// 添加/编辑影视记录页面 /// 添加/编辑影视记录页面
class MovieFormPage extends StatefulWidget { class MovieFormPage extends StatefulWidget {
final Movie? movie; // 如果为 null则是添加模式否则是编辑模式 final Movie? movie;
const MovieFormPage({super.key, this.movie}); const MovieFormPage({super.key, this.movie});
@@ -15,106 +19,158 @@ class MovieFormPage extends StatefulWidget {
class _MovieFormPageState extends State<MovieFormPage> { class _MovieFormPageState extends State<MovieFormPage> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final ImagePicker _picker = ImagePicker();
// 文本控制器
late TextEditingController _titleController; late TextEditingController _titleController;
late TextEditingController _yearController;
late TextEditingController _ratingController; late TextEditingController _ratingController;
late TextEditingController _noteController; late TextEditingController _summaryController;
// 列表控制器(导演、编剧、演员、类型、别名)
final List<TextEditingController> _directorControllers = [];
final List<TextEditingController> _writerControllers = [];
final List<TextEditingController> _actorControllers = [];
final List<TextEditingController> _genreControllers = [];
final List<TextEditingController> _alternateTitleControllers = [];
// 状态
late String _status; late String _status;
DateTime? _watchDate; DateTime? _releaseDate;
String? _posterPath;
bool _isLoading = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_titleController = TextEditingController(text: widget.movie?.title ?? ''); final movie = widget.movie;
_yearController = TextEditingController(text: widget.movie?.year?.toString() ?? '');
_ratingController = TextEditingController(text: widget.movie?.rating?.toString() ?? ''); _titleController = TextEditingController(text: movie?.title ?? '');
_noteController = TextEditingController(text: widget.movie?.note ?? ''); _ratingController = TextEditingController(text: movie?.rating?.toString() ?? '');
_status = widget.movie?.status ?? 'want_to_watch'; _summaryController = TextEditingController(text: movie?.summary ?? '');
_watchDate = widget.movie?.watchDate;
_status = movie?.status ?? 'want_to_watch';
_releaseDate = movie?.releaseDate;
_posterPath = movie?.posterPath;
// 初始化列表控制器
_initListControllers(movie?.directors ?? [], _directorControllers);
_initListControllers(movie?.writers ?? [], _writerControllers);
_initListControllers(movie?.actors ?? [], _actorControllers);
_initListControllers(movie?.genres ?? [], _genreControllers);
_initListControllers(movie?.alternateTitles ?? [], _alternateTitleControllers);
}
void _initListControllers(List<String> items, List<TextEditingController> controllers) {
if (items.isEmpty) {
controllers.add(TextEditingController());
} else {
for (final item in items) {
controllers.add(TextEditingController(text: item));
}
}
} }
@override @override
void dispose() { void dispose() {
_titleController.dispose(); _titleController.dispose();
_yearController.dispose();
_ratingController.dispose(); _ratingController.dispose();
_noteController.dispose(); _summaryController.dispose();
for (final c in _directorControllers) c.dispose();
for (final c in _writerControllers) c.dispose();
for (final c in _actorControllers) c.dispose();
for (final c in _genreControllers) c.dispose();
for (final c in _alternateTitleControllers) c.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isEdit = widget.movie != null; final isEdit = widget.movie != null;
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold( return Scaffold(
backgroundColor: colorScheme.surface,
appBar: AppBar( appBar: AppBar(
title: Text(isEdit ? '编辑影片' : '添加影片'), backgroundColor: colorScheme.surface,
elevation: 0,
centerTitle: true,
title: Text(
isEdit ? '编辑影片' : '添加影片',
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
leading: IconButton(
icon: Icon(Icons.arrow_back, color: colorScheme.onSurface),
onPressed: () => Navigator.pop(context),
),
actions: [ actions: [
IconButton( TextButton(
icon: const Icon(Icons.save), onPressed: _isLoading ? null : _saveMovie,
onPressed: _saveMovie, child: _isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.primary)
)
: Text('保存', style: TextStyle(color: colorScheme.primary)),
), ),
], ],
), ),
body: SingleChildScrollView( body: Form(
padding: const EdgeInsets.all(16), key: _formKey,
child: Form( child: SingleChildScrollView(
key: _formKey, padding: const EdgeInsets.all(20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 标题 // 海报上传区域
TextFormField( _buildPosterSection(),
const SizedBox(height: 24),
// 基本信息
_buildSectionTitle('基本信息'),
const SizedBox(height: 12),
// 影视名称
_buildTextField(
controller: _titleController, controller: _titleController,
decoration: const InputDecoration( label: '影视名称 *',
labelText: '影片名称 *', hint: '请输入影视名称',
hintText: '请输入影片名称',
prefixIcon: Icon(Icons.movie),
border: OutlineInputBorder(),
),
validator: (value) { validator: (value) {
if (value == null || value.trim().isEmpty) { if (value == null || value.trim().isEmpty) {
return '请输入影名称'; return '请输入影名称';
} }
return null; return null;
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// 年份和评分 // 上映日期和评分
Row( Row(
children: [ children: [
Expanded( Expanded(
child: TextFormField( child: _buildDatePicker(),
controller: _yearController,
decoration: const InputDecoration(
labelText: '年份',
hintText: '例如2024',
prefixIcon: Icon(Icons.calendar_today),
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: TextFormField( child: _buildTextField(
controller: _ratingController, controller: _ratingController,
decoration: const InputDecoration( label: '评分',
labelText: '评分', hint: '1-10',
hintText: '0-10',
prefixIcon: Icon(Icons.star),
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (value) { validator: (value) {
if (value != null && value.isNotEmpty) { if (value != null && value.isNotEmpty) {
final rating = double.tryParse(value); final rating = double.tryParse(value);
if (rating == null || rating < 0 || rating > 10) { if (rating == null || rating < 1 || rating > 10) {
return '评分必须在 0-10 之间'; return '评分1-10';
} }
} }
return null; return null;
@@ -123,99 +179,60 @@ class _MovieFormPageState extends State<MovieFormPage> {
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// 状态选择 // 状态选择
DropdownButtonFormField<String>( _buildStatusSelector(),
value: _status,
decoration: const InputDecoration( const SizedBox(height: 24),
labelText: '状态',
prefixIcon: Icon(Icons.check_circle_outline), // 别名
border: OutlineInputBorder(), _buildSectionTitle('别名'),
), const SizedBox(height: 8),
items: const [ _buildTagList(_alternateTitleControllers, '添加别名'),
DropdownMenuItem(value: 'watched', child: Text('已看')),
DropdownMenuItem(value: 'want_to_watch', child: Text('想看')), const SizedBox(height: 24),
DropdownMenuItem(value: 'watching', child: Text('在看')),
], // 导演
onChanged: (value) { _buildSectionTitle('导演'),
setState(() { const SizedBox(height: 8),
_status = value!; _buildTagList(_directorControllers, '添加导演'),
});
}, const SizedBox(height: 24),
),
// 编剧
const SizedBox(height: 16), _buildSectionTitle('编剧'),
const SizedBox(height: 8),
// 观看日期选择 _buildTagList(_writerControllers, '添加编剧'),
InkWell(
onTap: _selectWatchDate, const SizedBox(height: 24),
child: InputDecorator(
decoration: const InputDecoration( // 主演
labelText: '观看日期', _buildSectionTitle('主演'),
prefixIcon: Icon(Icons.event), const SizedBox(height: 8),
border: OutlineInputBorder(), _buildTagList(_actorControllers, '添加主演'),
),
child: Row( const SizedBox(height: 24),
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ // 类型
Text( _buildSectionTitle('类型'),
_watchDate != null const SizedBox(height: 8),
? '${_watchDate!.year}-${_watchDate!.month.toString().padLeft(2, '0')}-${_watchDate!.day.toString().padLeft(2, '0')}' _buildTagList(_genreControllers, '添加类型'),
: '选择日期',
style: TextStyle( const SizedBox(height: 24),
color: _watchDate != null
? Theme.of(context).colorScheme.onSurface // 剧情简介
: Theme.of(context).colorScheme.onSurfaceVariant, _buildSectionTitle('剧情简介'),
), const SizedBox(height: 12),
), _buildTextField(
if (_watchDate != null) controller: _summaryController,
IconButton( label: '',
icon: const Icon(Icons.clear, size: 20), hint: '请输入剧情简介...',
onPressed: () {
setState(() {
_watchDate = null;
});
},
),
],
),
),
),
const SizedBox(height: 16),
// 笔记
TextFormField(
controller: _noteController,
decoration: const InputDecoration(
labelText: '笔记',
hintText: '写下你的观后感...',
prefixIcon: Icon(Icons.edit_note),
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
maxLines: 5, maxLines: 5,
), ),
const SizedBox(height: 32), const SizedBox(height: 40),
// 保存按钮
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton.icon(
onPressed: _saveMovie,
icon: const Icon(Icons.save),
label: Text(isEdit ? '保存修改' : '添加记录'),
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
], ],
), ),
), ),
@@ -223,18 +240,408 @@ class _MovieFormPageState extends State<MovieFormPage> {
); );
} }
/// 选择观看日期 /// 构建海报上传区域
Future<void> _selectWatchDate() async { Widget _buildPosterSection() {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: GestureDetector(
onTap: _pickImage,
child: Container(
width: 140,
height: 200,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: colorScheme.outline.withOpacity(0.3),
width: 1,
),
),
child: _posterPath != null && _posterPath!.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
File(_posterPath!),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => _buildPlaceholder(colorScheme),
),
)
: _buildPlaceholder(colorScheme),
),
),
);
}
Widget _buildPlaceholder(ColorScheme colorScheme) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.add_photo_alternate_outlined,
size: 40,
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
),
const SizedBox(height: 8),
Text(
'上传海报',
style: TextStyle(
fontSize: 13,
color: colorScheme.onSurfaceVariant.withOpacity(0.6),
),
),
],
);
}
/// 构建区块标题
Widget _buildSectionTitle(String title) {
return Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
),
);
}
/// 构建文本输入框
Widget _buildTextField({
required TextEditingController controller,
required String label,
String? hint,
TextInputType? keyboardType,
String? Function(String?)? validator,
int maxLines = 1,
}) {
final colorScheme = Theme.of(context).colorScheme;
return TextFormField(
controller: controller,
keyboardType: keyboardType,
maxLines: maxLines,
validator: validator,
style: TextStyle(
fontSize: 15,
color: colorScheme.onSurface,
),
decoration: InputDecoration(
labelText: label.isEmpty ? null : label,
hintText: hint,
hintStyle: TextStyle(
fontSize: 14,
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
),
labelStyle: TextStyle(
fontSize: 14,
color: colorScheme.onSurfaceVariant,
),
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: colorScheme.outline.withOpacity(0.2),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: colorScheme.primary.withOpacity(0.5),
width: 1.5,
),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
);
}
/// 构建日期选择器
Widget _buildDatePicker() {
final colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: _selectReleaseDate,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: colorScheme.outline.withOpacity(0.2),
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.calendar_today_outlined,
size: 18,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
_releaseDate != null
? '${_releaseDate!.year}-${_releaseDate!.month.toString().padLeft(2, '0')}-${_releaseDate!.day.toString().padLeft(2, '0')}'
: '上映日期',
style: TextStyle(
fontSize: 15,
color: _releaseDate != null
? colorScheme.onSurface
: colorScheme.onSurfaceVariant.withOpacity(0.5),
),
),
),
if (_releaseDate != null)
GestureDetector(
onTap: () => setState(() => _releaseDate = null),
child: Icon(
Icons.close,
size: 18,
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
/// 构建状态选择器
Widget _buildStatusSelector() {
final colorScheme = Theme.of(context).colorScheme;
final statuses = [
{'value': 'watching', 'label': '在看', 'icon': Icons.play_circle_outline},
{'value': 'watched', 'label': '已看', 'icon': Icons.check_circle_outline},
{'value': 'want_to_watch', 'label': '想看', 'icon': Icons.bookmark_border},
];
return Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: colorScheme.outline.withOpacity(0.2),
width: 1,
),
),
child: Row(
children: statuses.map((status) {
final isSelected = _status == status['value'];
return Expanded(
child: GestureDetector(
onTap: () => setState(() => _status = status['value'] as String),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: isSelected ? colorScheme.primary.withOpacity(0.1) : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
status['icon'] as IconData,
size: 18,
color: isSelected ? colorScheme.primary : colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Text(
status['label'] as String,
style: TextStyle(
fontSize: 14,
color: isSelected ? colorScheme.primary : colorScheme.onSurfaceVariant,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
),
),
);
}).toList(),
),
);
}
/// 构建标签列表(导演、编剧、演员等)
Widget _buildTagList(List<TextEditingController> controllers, String addHint) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
children: [
...controllers.asMap().entries.map((entry) {
final index = entry.key;
final controller = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
style: TextStyle(
fontSize: 14,
color: colorScheme.onSurface,
),
decoration: InputDecoration(
hintText: addHint,
hintStyle: TextStyle(
fontSize: 13,
color: colorScheme.onSurfaceVariant.withOpacity(0.4),
),
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(
color: colorScheme.outline.withOpacity(0.15),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(
color: colorScheme.primary.withOpacity(0.4),
width: 1,
),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
),
),
if (controllers.length > 1)
IconButton(
icon: Icon(Icons.remove_circle_outline,
color: colorScheme.error.withOpacity(0.6),
size: 20
),
onPressed: () {
setState(() {
controller.dispose();
controllers.removeAt(index);
});
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
);
}),
// 添加按钮
GestureDetector(
onTap: () {
setState(() {
controllers.add(TextEditingController());
});
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
border: Border.all(
color: colorScheme.outline.withOpacity(0.2),
width: 1,
),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.add,
size: 18,
color: colorScheme.primary,
),
const SizedBox(width: 6),
Text(
addHint,
style: TextStyle(
fontSize: 13,
color: colorScheme.primary,
),
),
],
),
),
),
],
);
}
/// 选择图片
Future<void> _pickImage() async {
try {
final XFile? pickedFile = await _picker.pickImage(
source: ImageSource.gallery,
maxWidth: 800,
maxHeight: 1200,
imageQuality: 85,
);
if (pickedFile != null) {
// 复制图片到应用目录
final appDir = await getApplicationDocumentsDirectory();
final fileName = 'movie_poster_${DateTime.now().millisecondsSinceEpoch}.jpg';
final savedPath = path.join(appDir.path, 'posters', fileName);
// 创建目录
final posterDir = Directory(path.join(appDir.path, 'posters'));
if (!await posterDir.exists()) {
await posterDir.create(recursive: true);
}
// 复制文件
final sourceFile = File(pickedFile.path);
await sourceFile.copy(savedPath);
setState(() {
_posterPath = savedPath;
});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('选择图片失败: $e')),
);
}
}
}
/// 选择上映日期
Future<void> _selectReleaseDate() async {
final picked = await showDatePicker( final picked = await showDatePicker(
context: context, context: context,
initialDate: _watchDate ?? DateTime.now(), initialDate: _releaseDate ?? DateTime.now(),
firstDate: DateTime(1900), firstDate: DateTime(1900),
lastDate: DateTime.now(), lastDate: DateTime.now().add(const Duration(days: 365 * 5)),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: Theme.of(context).colorScheme.primary,
),
),
child: child!,
);
},
); );
if (picked != null) { if (picked != null) {
setState(() { setState(() {
_watchDate = picked; _releaseDate = picked;
}); });
} }
} }
@@ -245,46 +652,99 @@ class _MovieFormPageState extends State<MovieFormPage> {
return; return;
} }
final year = _yearController.text.isNotEmpty ? int.tryParse(_yearController.text) : null; setState(() => _isLoading = true);
final rating = _ratingController.text.isNotEmpty ? double.tryParse(_ratingController.text) : null;
if (widget.movie == null) { try {
// 添加新模式 final rating = _ratingController.text.isNotEmpty
final newMovie = Movie( ? double.tryParse(_ratingController.text)
id: DateTime.now().millisecondsSinceEpoch.toString(), : null;
title: _titleController.text.trim(),
year: year, // 收集列表数据
rating: rating, final directors = _collectNonEmptyTexts(_directorControllers);
status: _status, final writers = _collectNonEmptyTexts(_writerControllers);
watchDate: _watchDate, final actors = _collectNonEmptyTexts(_actorControllers);
note: _noteController.text.trim(), final genres = _collectNonEmptyTexts(_genreControllers);
final alternateTitles = _collectNonEmptyTexts(_alternateTitleControllers);
final now = DateTime.now();
if (widget.movie == null) {
// 添加新模式
final newMovie = Movie(
id: now.millisecondsSinceEpoch.toString(),
title: _titleController.text.trim(),
posterPath: _posterPath,
releaseDate: _releaseDate,
directors: directors,
writers: writers,
actors: actors,
genres: genres,
alternateTitles: alternateTitles,
summary: _summaryController.text.trim().isEmpty
? null
: _summaryController.text.trim(),
rating: rating,
status: _status,
createdAt: now,
updatedAt: now,
);
await context.read<AppProvider>().addMovie(newMovie);
} else {
// 编辑模式
final updatedMovie = widget.movie!.copyWith(
title: _titleController.text.trim(),
posterPath: _posterPath,
releaseDate: _releaseDate,
directors: directors,
writers: writers,
actors: actors,
genres: genres,
alternateTitles: alternateTitles,
summary: _summaryController.text.trim().isEmpty
? null
: _summaryController.text.trim(),
rating: rating,
status: _status,
updatedAt: now,
);
await context.read<AppProvider>().updateMovie(updatedMovie);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(widget.movie == null ? '添加成功' : '更新成功'),
behavior: SnackBarBehavior.floating,
backgroundColor: Theme.of(context).colorScheme.primary,
),
); );
await context.read<AppProvider>().addMovie(newMovie); Navigator.pop(context);
} else { } catch (e) {
// 编辑现有模式 if (mounted) {
final updatedMovie = Movie( ScaffoldMessenger.of(context).showSnackBar(
id: widget.movie!.id, SnackBar(
title: _titleController.text.trim(), content: Text('保存失败: $e'),
year: year, behavior: SnackBarBehavior.floating,
rating: rating, backgroundColor: Theme.of(context).colorScheme.error,
status: _status, ),
watchDate: _watchDate, );
note: _noteController.text.trim(), }
); } finally {
if (mounted) {
await context.read<AppProvider>().updateMovie(updatedMovie); setState(() => _isLoading = false);
}
} }
}
if (!mounted) return; /// 收集非空文本
List<String> _collectNonEmptyTexts(List<TextEditingController> controllers) {
ScaffoldMessenger.of(context).showSnackBar( return controllers
SnackBar( .map((c) => c.text.trim())
content: Text(widget.movie == null ? '添加成功' : '更新成功'), .where((text) => text.isNotEmpty)
behavior: SnackBarBehavior.floating, .toList();
),
);
Navigator.pop(context);
} }
} }

View File

@@ -0,0 +1,564 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart';
import '../providers/app_provider.dart';
import '../models/data_models.dart';
/// 添加/编辑影视记录页面Typecho 风格)
class MovieFormPage extends StatefulWidget {
final Movie? movie;
const MovieFormPage({super.key, this.movie});
@override
State<MovieFormPage> createState() => _MovieFormPageState();
}
class _MovieFormPageState extends State<MovieFormPage> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _titleController;
late TextEditingController _releaseDateController;
late TextEditingController _directorsController;
late TextEditingController _writersController;
late TextEditingController _actorsController;
late TextEditingController _genresController;
late TextEditingController _alternateTitlesController;
late TextEditingController _summaryController;
late TextEditingController _ratingController;
String _status = 'want_to_watch';
File? _posterImage;
bool _isLoading = false;
final ImagePicker _picker = ImagePicker();
@override
void initState() {
super.initState();
_titleController = TextEditingController(text: widget.movie?.title ?? '');
_releaseDateController = TextEditingController(
text: widget.movie?.releaseDate != null
? _formatDate(widget.movie!.releaseDate!)
: '',
);
_directorsController = TextEditingController(
text: (widget.movie?.directors ?? []).join(', '),
);
_writersController = TextEditingController(
text: (widget.movie?.writers ?? []).join(', '),
);
_actorsController = TextEditingController(
text: (widget.movie?.actors ?? []).join(', '),
);
_genresController = TextEditingController(
text: (widget.movie?.genres ?? []).join(', '),
);
_alternateTitlesController = TextEditingController(
text: (widget.movie?.alternateTitles ?? []).join(', '),
);
_summaryController = TextEditingController(text: widget.movie?.summary ?? '');
_ratingController = TextEditingController(
text: widget.movie?.rating?.toString() ?? '',
);
_status = widget.movie?.status ?? 'want_to_watch';
if (widget.movie?.posterPath != null) {
_posterImage = File(widget.movie!.posterPath!);
}
}
@override
void dispose() {
_titleController.dispose();
_releaseDateController.dispose();
_directorsController.dispose();
_writersController.dispose();
_actorsController.dispose();
_genresController.dispose();
_alternateTitlesController.dispose();
_summaryController.dispose();
_ratingController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isEdit = widget.movie != null;
return Scaffold(
appBar: AppBar(
title: Text(isEdit ? '编辑影视' : '添加影视'),
actions: [
IconButton(
icon: const Icon(Icons.save),
onPressed: _isLoading ? null : _saveMovie,
),
],
),
body: SingleChildScrollView(
child: Column(
children: [
// 封面上传区域
_buildCoverSection(),
const SizedBox(height: 24),
// 表单区域
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildBasicInfoSection(),
const SizedBox(height: 32),
_buildCastSection(),
const SizedBox(height: 32),
_buildDetailSection(),
const SizedBox(height: 48),
_buildSaveButton(isEdit),
const SizedBox(height: 32),
],
),
),
),
],
),
),
);
}
/// 构建封面上传区域
Widget _buildCoverSection() {
return GestureDetector(
onTap: _pickImage,
child: Container(
width: double.infinity,
height: 300,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: _posterImage != null
? Stack(
fit: StackFit.expand,
children: [
Image.file(
_posterImage!,
fit: BoxFit.cover,
),
Positioned(
top: 16,
right: 16,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.camera_alt,
color: Colors.white,
),
),
),
],
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.add_photo_alternate_outlined,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'点击上传海报',
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'建议尺寸2:3 比例',
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.6),
),
),
],
),
),
);
}
/// 构建基本信息区域
Widget _buildBasicInfoSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('基本信息'),
const SizedBox(height: 16),
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: '影视名称 *',
hintText: '请输入影视名称',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.title),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '请输入影视名称';
}
return null;
},
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _releaseDateController,
decoration: const InputDecoration(
labelText: '上映时间',
hintText: 'YYYY-MM-DD',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.calendar_today),
),
readOnly: true,
onTap: _selectReleaseDate,
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _ratingController,
decoration: const InputDecoration(
labelText: '评分',
hintText: '1-10',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.star),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (value != null && value.isNotEmpty) {
final rating = double.tryParse(value);
if (rating == null || rating < 1 || rating > 10) {
return '1-10';
}
}
return null;
},
),
),
],
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _status,
decoration: const InputDecoration(
labelText: '状态',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.check_circle_outline),
),
items: const [
DropdownMenuItem(value: 'watched', child: Text('已看')),
DropdownMenuItem(value: 'want_to_watch', child: Text('想看')),
DropdownMenuItem(value: 'watching', child: Text('在看')),
],
onChanged: (value) {
setState(() {
_status = value!;
});
},
),
],
);
}
/// 构建演职人员区域
Widget _buildCastSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('演职人员'),
const SizedBox(height: 16),
TextFormField(
controller: _directorsController,
decoration: const InputDecoration(
labelText: '导演',
hintText: '多个用逗号分隔',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _writersController,
decoration: const InputDecoration(
labelText: '编剧',
hintText: '多个用逗号分隔',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.edit_note),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _actorsController,
decoration: const InputDecoration(
labelText: '主演',
hintText: '多个用逗号分隔',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.people),
),
),
],
);
}
/// 构建详细信息区域
Widget _buildDetailSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('详细信息'),
const SizedBox(height: 16),
TextFormField(
controller: _genresController,
decoration: const InputDecoration(
labelText: '类型',
hintText: '多个用逗号分隔',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.category),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _alternateTitlesController,
decoration: const InputDecoration(
labelText: '别名',
hintText: '多个用逗号分隔',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.alt_route),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _summaryController,
decoration: const InputDecoration(
labelText: '剧情简介',
hintText: '请输入剧情简介...',
border: OutlineInputBorder(),
alignLabelWithHint: true,
prefixIcon: Icon(Icons.description),
),
maxLines: 6,
),
],
);
}
/// 构建保存按钮
Widget _buildSaveButton(bool isEdit) {
return SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton.icon(
onPressed: _isLoading ? null : _saveMovie,
icon: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: Text(isEdit ? '保存修改' : '添加记录'),
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
);
}
/// 构建区块标题
Widget _buildSectionTitle(String title) {
return Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
);
}
/// 选择图片
Future<void> _pickImage() async {
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
if (image != null) {
setState(() {
_posterImage = File(image.path);
});
}
}
/// 选择日期
Future<void> _selectReleaseDate() async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(1900),
lastDate: DateTime.now(),
);
if (picked != null) {
setState(() {
_releaseDateController.text = _formatDate(picked);
});
}
}
/// 格式化日期
String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
/// 保存影视记录
Future<void> _saveMovie() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isLoading = true;
});
try {
// 处理列表字段
final directors = _parseList(_directorsController.text);
final writers = _parseList(_writersController.text);
final actors = _parseList(_actorsController.text);
final genres = _parseList(_genresController.text);
final alternateTitles = _parseList(_alternateTitlesController.text);
// 处理评分
final rating = _ratingController.text.isNotEmpty
? double.parse(_ratingController.text)
: null;
// 处理日期
final releaseDate = _releaseDateController.text.isNotEmpty
? DateTime.tryParse(_releaseDateController.text)
: null;
// 处理图片
String? posterPath;
if (_posterImage != null) {
final dir = await getApplicationDocumentsDirectory();
final fileName = '${const Uuid().v4()}.jpg';
final savedImage = await _posterImage!.copy('${dir.path}/posters/$fileName');
posterPath = savedImage.path;
}
if (widget.movie == null) {
// 添加新模式
final now = DateTime.now();
final newMovie = Movie(
id: const Uuid().v4(),
title: _titleController.text.trim(),
posterPath: posterPath,
releaseDate: releaseDate,
directors: directors,
writers: writers,
actors: actors,
genres: genres,
alternateTitles: alternateTitles,
summary: _summaryController.text.trim(),
rating: rating,
status: _status,
createdAt: now,
updatedAt: now,
);
await context.read<AppProvider>().addMovie(newMovie);
} else {
// 编辑现有模式
final updatedMovie = Movie(
id: widget.movie!.id,
title: _titleController.text.trim(),
posterPath: posterPath ?? widget.movie!.posterPath,
releaseDate: releaseDate,
directors: directors,
writers: writers,
actors: actors,
genres: genres,
alternateTitles: alternateTitles,
summary: _summaryController.text.trim(),
rating: rating,
status: _status,
createdAt: widget.movie!.createdAt,
updatedAt: DateTime.now(),
);
await context.read<AppProvider>().updateMovie(updatedMovie);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(widget.movie == null ? '添加成功' : '更新成功'),
behavior: SnackBarBehavior.floating,
),
);
Navigator.pop(context);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('保存失败:$e'),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.red,
),
);
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
/// 解析列表
List<String> _parseList(String text) {
return text
.split(',')
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toList();
}
}

View File

@@ -20,11 +20,75 @@ class DatabaseHelper {
return await openDatabase( return await openDatabase(
path, path,
version: 1, version: 2,
onCreate: _createDB, onCreate: _createDB,
onUpgrade: _onUpgrade,
); );
} }
/// 数据库升级
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
if (oldVersion < 2) {
// 升级movies表结构
await _upgradeMoviesTableV2(db);
}
}
/// 升级movies表到V2
Future<void> _upgradeMoviesTableV2(Database db) async {
// 备份旧数据
final oldData = await db.query('movies');
// 删除旧表
await db.execute('DROP TABLE IF EXISTS movies');
// 创建新表
await db.execute('''
CREATE TABLE movies (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
poster_path TEXT,
release_date TEXT,
directors TEXT,
writers TEXT,
actors TEXT,
genres TEXT,
alternate_titles TEXT,
summary TEXT,
rating REAL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
is_deleted INTEGER DEFAULT 0
)
''');
// 迁移旧数据(尽可能保留)
for (final row in oldData) {
try {
await db.insert('movies', {
'id': row['id']?.toString() ?? DateTime.now().millisecondsSinceEpoch.toString(),
'title': row['title']?.toString() ?? '',
'poster_path': row['poster_path'],
'release_date': null,
'directors': '[]',
'writers': '[]',
'actors': '[]',
'genres': '[]',
'alternate_titles': '[]',
'summary': row['note'],
'rating': row['rating'],
'status': row['status'] ?? 'want_to_watch',
'created_at': row['created_at']?.toString() ?? DateTime.now().toIso8601String(),
'updated_at': DateTime.now().toIso8601String(),
'is_deleted': row['is_deleted'] ?? 0,
});
} catch (e) {
// 忽略迁移失败的记录
}
}
}
// 创建数据库表 // 创建数据库表
Future<void> _createDB(Database db, int version) async { Future<void> _createDB(Database db, int version) async {
const idType = 'INTEGER PRIMARY KEY AUTOINCREMENT'; const idType = 'INTEGER PRIMARY KEY AUTOINCREMENT';
@@ -35,14 +99,21 @@ class DatabaseHelper {
// 影视表 // 影视表
await db.execute(''' await db.execute('''
CREATE TABLE movies ( CREATE TABLE movies (
id $idType, id TEXT PRIMARY KEY,
title $textType, title TEXT NOT NULL,
poster TEXT, poster_path TEXT,
release_date TEXT,
directors TEXT,
writers TEXT,
actors TEXT,
genres TEXT,
alternate_titles TEXT,
summary TEXT,
rating REAL, rating REAL,
year INTEGER, status TEXT NOT NULL,
status $textType, created_at TEXT NOT NULL,
watch_date TEXT, updated_at TEXT NOT NULL,
note TEXT is_deleted INTEGER DEFAULT 0
) )
'''); ''');

View File

@@ -6,25 +6,17 @@ import 'database_helper.dart';
class MovieDao { class MovieDao {
final DatabaseHelper _dbHelper = DatabaseHelper.instance; final DatabaseHelper _dbHelper = DatabaseHelper.instance;
// 获取所有影视记录 // 获取所有影视记录(未删除的)
Future<List<Movie>> getAllMovies() async { Future<List<Movie>> getAllMovies() async {
final db = await _dbHelper.database; final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query('movies'); final List<Map<String, dynamic>> maps = await db.query(
'movies',
where: 'is_deleted = ?',
whereArgs: [0],
orderBy: 'updated_at DESC',
);
return List.generate(maps.length, (i) { return List.generate(maps.length, (i) => Movie.fromJson(maps[i]));
return Movie(
id: maps[i]['id'].toString(),
title: maps[i]['title'],
poster: maps[i]['poster'],
rating: maps[i]['rating']?.toDouble(),
year: maps[i]['year'],
status: maps[i]['status'],
watchDate: maps[i]['watch_date'] != null
? DateTime.parse(maps[i]['watch_date'])
: null,
note: maps[i]['note'],
);
});
} }
// 根据状态筛选影视记录 // 根据状态筛选影视记录
@@ -32,24 +24,90 @@ class MovieDao {
final db = await _dbHelper.database; final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query( final List<Map<String, dynamic>> maps = await db.query(
'movies', 'movies',
where: 'status = ?', where: 'status = ? AND is_deleted = ?',
whereArgs: [status], whereArgs: [status, 0],
orderBy: 'updated_at DESC',
); );
return List.generate(maps.length, (i) { return List.generate(maps.length, (i) => Movie.fromJson(maps[i]));
return Movie( }
id: maps[i]['id'].toString(),
title: maps[i]['title'], // 根据导演筛选
poster: maps[i]['poster'], Future<List<Movie>> getMoviesByDirector(String director) async {
rating: maps[i]['rating']?.toDouble(), final db = await _dbHelper.database;
year: maps[i]['year'], final List<Map<String, dynamic>> maps = await db.query(
status: maps[i]['status'], 'movies',
watchDate: maps[i]['watch_date'] != null where: 'is_deleted = ?',
? DateTime.parse(maps[i]['watch_date']) whereArgs: [0],
: null, orderBy: 'updated_at DESC',
note: maps[i]['note'], );
);
}); return List.generate(maps.length, (i) => Movie.fromJson(maps[i]))
.where((movie) => movie.directors.contains(director))
.toList();
}
// 根据编剧筛选
Future<List<Movie>> getMoviesByWriter(String writer) async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'movies',
where: 'is_deleted = ?',
whereArgs: [0],
orderBy: 'updated_at DESC',
);
return List.generate(maps.length, (i) => Movie.fromJson(maps[i]))
.where((movie) => movie.writers.contains(writer))
.toList();
}
// 根据演员筛选
Future<List<Movie>> getMoviesByActor(String actor) async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'movies',
where: 'is_deleted = ?',
whereArgs: [0],
orderBy: 'updated_at DESC',
);
return List.generate(maps.length, (i) => Movie.fromJson(maps[i]))
.where((movie) => movie.actors.contains(actor))
.toList();
}
// 根据类型筛选
Future<List<Movie>> getMoviesByGenre(String genre) async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'movies',
where: 'is_deleted = ?',
whereArgs: [0],
orderBy: 'updated_at DESC',
);
return List.generate(maps.length, (i) => Movie.fromJson(maps[i]))
.where((movie) => movie.genres.contains(genre))
.toList();
}
// 搜索影视(标题或别名)
Future<List<Movie>> searchMovies(String keyword) async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'movies',
where: 'is_deleted = ?',
whereArgs: [0],
orderBy: 'updated_at DESC',
);
final lowerKeyword = keyword.toLowerCase();
return List.generate(maps.length, (i) => Movie.fromJson(maps[i]))
.where((movie) =>
movie.title.toLowerCase().contains(lowerKeyword) ||
movie.alternateTitles.any((t) => t.toLowerCase().contains(lowerKeyword)))
.toList();
} }
// 添加影视记录 // 添加影视记录
@@ -69,13 +127,54 @@ class MovieDao {
); );
} }
// 删除影视记录 // 删除影视记录(软删除)
Future<int> deleteMovie(String id) async { Future<int> deleteMovie(String id) async {
final db = await _dbHelper.database; final db = await _dbHelper.database;
return await db.delete( return await db.update(
'movies', 'movies',
{'is_deleted': 1, 'updated_at': DateTime.now().toIso8601String()},
where: 'id = ?', where: 'id = ?',
whereArgs: [id], whereArgs: [id],
); );
} }
// 获取所有导演(去重)
Future<List<String>> getAllDirectors() async {
final movies = await getAllMovies();
final directors = <String>{};
for (final movie in movies) {
directors.addAll(movie.directors);
}
return directors.toList()..sort();
}
// 获取所有编剧(去重)
Future<List<String>> getAllWriters() async {
final movies = await getAllMovies();
final writers = <String>{};
for (final movie in movies) {
writers.addAll(movie.writers);
}
return writers.toList()..sort();
}
// 获取所有演员(去重)
Future<List<String>> getAllActors() async {
final movies = await getAllMovies();
final actors = <String>{};
for (final movie in movies) {
actors.addAll(movie.actors);
}
return actors.toList()..sort();
}
// 获取所有类型(去重)
Future<List<String>> getAllGenres() async {
final movies = await getAllMovies();
final genres = <String>{};
for (final movie in movies) {
genres.addAll(movie.genres);
}
return genres.toList()..sort();
}
} }

View File

@@ -1,3 +1,4 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
@@ -12,11 +13,21 @@ class MovieListItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Card( return Card(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: colorScheme.outline.withOpacity(0.1),
width: 1,
),
),
child: InkWell( child: InkWell(
onTap: () { onTap: () {
// 跳转到详情页
Navigator.pushNamed(context, '/movie-detail', arguments: movie); Navigator.pushNamed(context, '/movie-detail', arguments: movie);
}, },
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -25,103 +36,126 @@ class MovieListItem extends StatelessWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 海报占位图 // 海报
_buildPoster(), _buildPoster(context),
const SizedBox(width: 12), const SizedBox(width: 14),
// 影片信息 // 影片信息
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 标题和年份 // 标题
Text( Text(
movie.title, movie.title,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: TextStyle(
fontWeight: FontWeight.bold, fontSize: 16,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
), ),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
if (movie.year != null) ...[ const SizedBox(height: 6),
const SizedBox(height: 4),
Text(
'${movie.year}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 8), // 上映日期和评分
// 评分和状态
Row( Row(
children: [ children: [
if (movie.rating != null) ...[ if (movie.releaseDate != null) ...[
Icon( Icon(
Icons.star, Icons.calendar_today_outlined,
size: 16, size: 13,
color: Colors.amber[700], color: colorScheme.onSurfaceVariant.withOpacity(0.7),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
movie.rating.toString(), '${movie.releaseDate!.year}',
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 13,
fontWeight: FontWeight.bold, color: colorScheme.onSurfaceVariant.withOpacity(0.7),
color: Colors.amber[700],
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
], ],
// 状态标签 if (movie.rating != null) ...[
_buildStatusTag(context), Icon(
Icons.star_rounded,
size: 14,
color: Colors.amber[700],
),
const SizedBox(width: 2),
Text(
movie.rating!.toStringAsFixed(1),
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.amber[700],
),
),
],
], ],
), ),
if (movie.watchDate != null) ...[ const SizedBox(height: 8),
const SizedBox(height: 6),
Text(
'观看日期:${_formatDate(movie.watchDate!)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
if (movie.note != null && movie.note!.isNotEmpty) ...[ // 导演
const SizedBox(height: 6), if (movie.directors.isNotEmpty)
Text( _buildInfoRow(
movie.note!, context,
style: Theme.of(context).textTheme.bodySmall?.copyWith( prefix: '导演',
color: Theme.of(context).colorScheme.onSurfaceVariant, items: movie.directors.take(2).toList(),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
), ),
],
if (movie.directors.isNotEmpty && movie.genres.isNotEmpty)
const SizedBox(height: 4),
// 类型标签
if (movie.genres.isNotEmpty)
Wrap(
spacing: 6,
runSpacing: 4,
children: movie.genres.take(3).map((genre) => Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.primary.withOpacity(0.08),
borderRadius: BorderRadius.circular(4),
),
child: Text(
genre,
style: TextStyle(
fontSize: 11,
color: colorScheme.primary.withOpacity(0.8),
),
),
)).toList(),
),
const SizedBox(height: 8),
// 状态标签
_buildStatusTag(context),
], ],
), ),
), ),
// 右侧操作按钮 // 右侧操作按钮
Column( Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.edit, size: 20), icon: Icon(Icons.edit_outlined, size: 20, color: colorScheme.primary),
onPressed: () { onPressed: () {
Navigator.pushNamed(context, '/movie-form', arguments: movie); Navigator.pushNamed(context, '/movie-form', arguments: movie);
}, },
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(), constraints: const BoxConstraints(),
), ),
const SizedBox(height: 8),
IconButton( IconButton(
icon: const Icon(Icons.delete_outline, size: 20), icon: Icon(Icons.delete_outline, size: 20, color: colorScheme.error.withOpacity(0.7)),
color: Colors.red,
onPressed: () => _showDeleteDialog(context, movie), onPressed: () => _showDeleteDialog(context, movie),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(), constraints: const BoxConstraints(),
@@ -135,23 +169,65 @@ class MovieListItem extends StatelessWidget {
); );
} }
/// 构建海报占位图 /// 构建海报
Widget _buildPoster() { Widget _buildPoster(BuildContext context) {
return Container( final colorScheme = Theme.of(context).colorScheme;
width: 60,
height: 90, return ClipRRect(
decoration: BoxDecoration( borderRadius: BorderRadius.circular(8),
color: Colors.grey[300], child: Container(
borderRadius: BorderRadius.circular(8), width: 70,
height: 100,
color: colorScheme.surfaceContainerHighest,
child: movie.posterPath != null && movie.posterPath!.isNotEmpty
? Image.file(
File(movie.posterPath!),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => _buildPlaceholder(context),
)
: _buildPlaceholder(context),
), ),
);
}
Widget _buildPlaceholder(BuildContext context) {
return Center(
child: Icon( child: Icon(
Icons.movie, Icons.movie_outlined,
color: Colors.grey[500], color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.3),
size: 32, size: 28,
), ),
); );
} }
/// 构建信息行
Widget _buildInfoRow(BuildContext context, {required String prefix, required List<String> items}) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
children: [
Text(
'$prefix: ',
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant.withOpacity(0.6),
),
),
Expanded(
child: Text(
items.join(' / '),
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant.withOpacity(0.8),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
/// 构建状态标签 /// 构建状态标签
Widget _buildStatusTag(BuildContext context) { Widget _buildStatusTag(BuildContext context) {
Color statusColor; Color statusColor;
@@ -176,12 +252,12 @@ class MovieListItem extends StatelessWidget {
} }
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: statusColor.withOpacity(0.1), color: statusColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
border: Border.all( border: Border.all(
color: statusColor, color: statusColor.withOpacity(0.3),
width: 1, width: 1,
), ),
), ),
@@ -190,28 +266,36 @@ class MovieListItem extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: statusColor, color: statusColor,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w600,
), ),
), ),
); );
} }
/// 格式化日期
String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
/// 显示删除对话框 /// 显示删除对话框
void _showDeleteDialog(BuildContext context, Movie movie) { void _showDeleteDialog(BuildContext context, Movie movie) {
final colorScheme = Theme.of(context).colorScheme;
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('确认删除'), backgroundColor: colorScheme.surface,
content: Text('确定要删除"${movie.title}"吗?此操作不可恢复。'), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Text(
'确认删除',
style: TextStyle(color: colorScheme.onSurface),
),
content: Text(
'确定要删除"${movie.title}"吗?',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.7)),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('取消'), child: Text(
'取消',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
@@ -219,15 +303,16 @@ class MovieListItem extends StatelessWidget {
if (!context.mounted) return; if (!context.mounted) return;
Navigator.pop(context); Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('已删除'), content: const Text('已删除'),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
backgroundColor: colorScheme.primary,
), ),
); );
}, },
child: const Text( child: Text(
'删除', '删除',
style: TextStyle(color: Colors.red), style: TextStyle(color: colorScheme.error),
), ),
), ),
], ],

View File

@@ -33,6 +33,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -41,6 +49,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev"
source: hosted
version: "0.3.5+2"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -65,6 +89,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
url: "https://pub.dev"
source: hosted
version: "0.9.5"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
fl_chart: fl_chart:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -86,11 +158,120 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0" version: "4.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
url: "https://pub.dev"
source: hosted
version: "2.0.33"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
hooks:
dependency: transitive
description:
name: hooks
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156
url: "https://pub.dev"
source: hosted
version: "0.8.13+14"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
url: "https://pub.dev"
source: hosted
version: "0.8.13+6"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.2"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -123,6 +304,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0" version: "4.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -147,6 +336,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.17.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
url: "https://pub.dev"
source: hosted
version: "0.17.4"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@@ -155,6 +360,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
path: path:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -163,6 +376,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
url: "https://pub.dev"
source: hosted
version: "2.2.22"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@@ -187,6 +448,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5+1" version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -288,6 +557,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.9" version: "0.7.9"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -304,6 +581,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.0.2" version: "15.0.2"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks: sdks:
dart: ">=3.9.0 <4.0.0" dart: ">=3.10.3 <4.0.0"
flutter: ">=3.24.0" flutter: ">=3.38.4"

View File

@@ -14,6 +14,8 @@ dependencies:
fl_chart: ^0.69.0 fl_chart: ^0.69.0
sqflite: ^2.3.0 sqflite: ^2.3.0
path: ^1.8.3 path: ^1.8.3
image_picker: ^1.0.4
path_provider: ^2.1.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: