From 44e54180315282e1a468483aaa8952ce2ad14578 Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Thu, 5 Mar 2026 10:40:42 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=BD=B1=E8=A7=86=E7=9A=84?= =?UTF-8?q?=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/models/data_models.dart | 144 ++++- lib/models/data_models_extension.dart | 78 --- lib/pages/movie_detail_page.dart | 620 +++++++++++++------ lib/pages/movie_form_page.dart | 826 ++++++++++++++++++++------ lib/pages/movie_form_page_new.dart | 564 ++++++++++++++++++ lib/utils/database_helper.dart | 87 ++- lib/utils/movie_dao.dart | 167 ++++-- lib/widgets/movie_list_item.dart | 239 +++++--- pubspec.lock | 305 +++++++++- pubspec.yaml | 2 + 10 files changed, 2440 insertions(+), 592 deletions(-) delete mode 100644 lib/models/data_models_extension.dart create mode 100644 lib/pages/movie_form_page_new.dart diff --git a/lib/models/data_models.dart b/lib/models/data_models.dart index 271610f..5fbf20a 100644 --- a/lib/models/data_models.dart +++ b/lib/models/data_models.dart @@ -1,37 +1,65 @@ +import 'dart:io'; +import 'dart:convert'; + /// 影视条目模型 class Movie { final String id; - final String title; - final String? poster; - final double? rating; - final int? year; - final String status; // 'watched', 'want_to_watch', 'watching' - final DateTime? watchDate; - final String? note; + final String title; // 影视名称 + final String? posterPath; // 本地海报路径 + final DateTime? releaseDate; // 上映时间 + final List directors; // 导演列表 + final List writers; // 编剧列表 + final List actors; // 主演列表 + final List genres; // 类型 + final List 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({ required this.id, 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.year, required this.status, - this.watchDate, - this.note, + required this.createdAt, + required this.updatedAt, + this.isDeleted = false, }); factory Movie.fromJson(Map json) { return Movie( id: json['id'] ?? '', title: json['title'] ?? '', - poster: json['poster'], - rating: json['rating']?.toDouble(), - year: json['year'], - status: json['status'] ?? 'want_to_watch', - watchDate: json['watch_date'] != null - ? DateTime.parse(json['watch_date']) + posterPath: json['poster_path'], + releaseDate: json['release_date'] != null + ? DateTime.parse(json['release_date']) : 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 { 'id': id, '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, - 'year': year, 'status': status, - 'watch_date': watchDate?.toIso8601String(), - 'note': note, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + 'is_deleted': isDeleted ? 1 : 0, }; } + + /// 获取封面文件 + File? get posterFile { + if (posterPath == null || posterPath!.isEmpty) return null; + return File(posterPath!); + } + + /// 解析字符串列表 + static List _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? directors, + List? writers, + List? actors, + List? genres, + List? 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 { }; } } + diff --git a/lib/models/data_models_extension.dart b/lib/models/data_models_extension.dart deleted file mode 100644 index 0b131a6..0000000 --- a/lib/models/data_models_extension.dart +++ /dev/null @@ -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? 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, - ); - } -} diff --git a/lib/pages/movie_detail_page.dart b/lib/pages/movie_detail_page.dart index b0eabcd..4428bba 100644 --- a/lib/pages/movie_detail_page.dart +++ b/lib/pages/movie_detail_page.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; @@ -15,39 +16,138 @@ class MovieDetailPage extends StatefulWidget { } class _MovieDetailPageState extends State { + late Movie _movie; + + @override + void initState() { + super.initState(); + _movie = widget.movie; + } + + void _refreshMovie() { + final provider = context.read(); + final updated = provider.movies.firstWhere( + (m) => m.id == _movie.id, + orElse: () => _movie, + ); + setState(() { + _movie = updated; + }); + } @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Scaffold( - appBar: AppBar( - title: Text(widget.movie.title), - actions: [ - IconButton( - icon: const Icon(Icons.edit), - onPressed: () => _navigateToEdit(context), + backgroundColor: colorScheme.surface, + body: CustomScrollView( + slivers: [ + // 海报区域(可折叠) + SliverAppBar( + 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 { Widget _buildPosterSection(BuildContext context) { return Container( width: double.infinity, - height: 300, - decoration: BoxDecoration( - color: Colors.grey[300], - ), - child: Stack( - children: [ - Center( - child: Icon( - Icons.movie, - size: 80, - color: Colors.grey[500], - ), - ), - Positioned( - top: 16, - right: 16, - child: _buildStatusTag(context), - ), - ], - ), + color: Colors.grey[300], + child: _movie.posterPath != null && _movie.posterPath!.isNotEmpty + ? Image.file( + File(_movie.posterPath!), + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => _buildPlaceholder(), + ) + : _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 = '未知'; - } - + Widget _buildPlaceholder() { return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: statusColor.withOpacity(0.9), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - statusText, - style: const TextStyle( - fontSize: 14, - color: Colors.white, - fontWeight: FontWeight.bold, + color: Colors.grey[300], + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.movie, size: 80, color: Colors.grey[500]), + const SizedBox(height: 12), + Text( + '暂无海报', + style: TextStyle(color: Colors.grey[500], fontSize: 14), + ), + ], ), ), ); } - /// 构建信息区域 - Widget _buildInfoSection(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - 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, + /// 构建标题区域 + Widget _buildTitleSection(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, 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( - label, + _movie.title, style: TextStyle( - fontSize: 14, - color: textColor ?? Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 24, + 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( - margin: const EdgeInsets.all(16), padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: colorScheme.surfaceContainerHighest.withOpacity(0.3), borderRadius: BorderRadius.circular(12), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Row( - children: [ - Icon( - Icons.edit_note, - size: 20, - color: Theme.of(context).colorScheme.primary, + // 上映日期 + if (_movie.releaseDate != null) ...[ + Expanded( + child: _buildInfoItem( + context, + 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( - '笔记', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const SizedBox(height: 12), - Text( - widget.movie.note!, - style: Theme.of(context).textTheme.bodyMedium, + ), + Container( + width: 1, + height: 40, + color: colorScheme.outline.withOpacity(0.2), + ), + ], + + // 评分 + Expanded( + child: _buildInfoItem( + context, + icon: Icons.star_rounded, + label: '评分', + value: _movie.rating != null ? '${_movie.rating!.toStringAsFixed(1)}' : '暂无', + valueColor: _movie.rating != null ? Colors.amber[700] : null, + ), ), ], ), ); } - /// 格式化日期 - String _formatDate(DateTime date) { - return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + Widget _buildInfoItem( + BuildContext context, { + 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 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) { - Navigator.pushNamed(context, '/movie-form', arguments: widget.movie).then((_) { - // 返回后刷新数据 + Navigator.pushNamed(context, '/movie-form', arguments: _movie).then((_) { + _refreshMovie(); context.read().loadMovies(); }); } /// 显示删除对话框 void _showDeleteDialog(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('确认删除'), - content: Text('确定要删除"${widget.movie.title}"吗?此操作不可恢复。'), + backgroundColor: colorScheme.surface, + 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: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('取消'), + child: Text( + '取消', + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), ), TextButton( onPressed: () async { - await context.read().removeMovie(widget.movie.id); + await context.read().removeMovie(_movie.id); if (!context.mounted) return; - Navigator.pop(context); // 关闭对话框 - Navigator.pop(context); // 返回上一页 + Navigator.pop(context); + Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('已删除'), + SnackBar( + content: const Text('已删除'), behavior: SnackBarBehavior.floating, + backgroundColor: colorScheme.primary, ), ); }, - child: const Text( + child: Text( '删除', - style: TextStyle(color: Colors.red), + style: TextStyle(color: colorScheme.error), ), ), ], diff --git a/lib/pages/movie_form_page.dart b/lib/pages/movie_form_page.dart index 511cccf..47b50b0 100644 --- a/lib/pages/movie_form_page.dart +++ b/lib/pages/movie_form_page.dart @@ -1,11 +1,15 @@ +import 'dart:io'; 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 '../providers/app_provider.dart'; import '../models/data_models.dart'; /// 添加/编辑影视记录页面 class MovieFormPage extends StatefulWidget { - final Movie? movie; // 如果为 null,则是添加模式;否则是编辑模式 + final Movie? movie; const MovieFormPage({super.key, this.movie}); @@ -15,106 +19,158 @@ class MovieFormPage extends StatefulWidget { class _MovieFormPageState extends State { final _formKey = GlobalKey(); + final ImagePicker _picker = ImagePicker(); + + // 文本控制器 late TextEditingController _titleController; - late TextEditingController _yearController; late TextEditingController _ratingController; - late TextEditingController _noteController; + late TextEditingController _summaryController; + + // 列表控制器(导演、编剧、演员、类型、别名) + final List _directorControllers = []; + final List _writerControllers = []; + final List _actorControllers = []; + final List _genreControllers = []; + final List _alternateTitleControllers = []; + + // 状态 late String _status; - DateTime? _watchDate; + DateTime? _releaseDate; + String? _posterPath; + bool _isLoading = false; @override void initState() { super.initState(); - _titleController = TextEditingController(text: widget.movie?.title ?? ''); - _yearController = TextEditingController(text: widget.movie?.year?.toString() ?? ''); - _ratingController = TextEditingController(text: widget.movie?.rating?.toString() ?? ''); - _noteController = TextEditingController(text: widget.movie?.note ?? ''); - _status = widget.movie?.status ?? 'want_to_watch'; - _watchDate = widget.movie?.watchDate; + final movie = widget.movie; + + _titleController = TextEditingController(text: movie?.title ?? ''); + _ratingController = TextEditingController(text: movie?.rating?.toString() ?? ''); + _summaryController = TextEditingController(text: movie?.summary ?? ''); + + _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 items, List controllers) { + if (items.isEmpty) { + controllers.add(TextEditingController()); + } else { + for (final item in items) { + controllers.add(TextEditingController(text: item)); + } + } } @override void dispose() { _titleController.dispose(); - _yearController.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(); } @override Widget build(BuildContext context) { final isEdit = widget.movie != null; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; return Scaffold( + backgroundColor: colorScheme.surface, 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: [ - IconButton( - icon: const Icon(Icons.save), - onPressed: _saveMovie, + TextButton( + onPressed: _isLoading ? null : _saveMovie, + child: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.primary) + ) + : Text('保存', style: TextStyle(color: colorScheme.primary)), ), ], ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Form( - key: _formKey, + body: Form( + key: _formKey, + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 标题 - TextFormField( + // 海报上传区域 + _buildPosterSection(), + + const SizedBox(height: 24), + + // 基本信息 + _buildSectionTitle('基本信息'), + const SizedBox(height: 12), + + // 影视名称 + _buildTextField( controller: _titleController, - decoration: const InputDecoration( - labelText: '影片名称 *', - hintText: '请输入影片名称', - prefixIcon: Icon(Icons.movie), - border: OutlineInputBorder(), - ), + label: '影视名称 *', + hint: '请输入影视名称', validator: (value) { if (value == null || value.trim().isEmpty) { - return '请输入影片名称'; + return '请输入影视名称'; } return null; }, ), - + const SizedBox(height: 16), - - // 年份和评分 + + // 上映日期和评分 Row( children: [ Expanded( - child: TextFormField( - controller: _yearController, - decoration: const InputDecoration( - labelText: '年份', - hintText: '例如:2024', - prefixIcon: Icon(Icons.calendar_today), - border: OutlineInputBorder(), - ), - keyboardType: TextInputType.number, - ), + child: _buildDatePicker(), ), - const SizedBox(width: 16), - Expanded( - child: TextFormField( + child: _buildTextField( controller: _ratingController, - decoration: const InputDecoration( - labelText: '评分', - hintText: '0-10', - prefixIcon: Icon(Icons.star), - border: OutlineInputBorder(), - ), + label: '评分', + hint: '1-10', keyboardType: const TextInputType.numberWithOptions(decimal: true), validator: (value) { if (value != null && value.isNotEmpty) { final rating = double.tryParse(value); - if (rating == null || rating < 0 || rating > 10) { - return '评分必须在 0-10 之间'; + if (rating == null || rating < 1 || rating > 10) { + return '评分1-10'; } } return null; @@ -123,99 +179,60 @@ class _MovieFormPageState extends State { ), ], ), - + const SizedBox(height: 16), - + // 状态选择 - DropdownButtonFormField( - value: _status, - decoration: const InputDecoration( - labelText: '状态', - prefixIcon: Icon(Icons.check_circle_outline), - border: OutlineInputBorder(), - ), - items: const [ - DropdownMenuItem(value: 'watched', child: Text('已看')), - DropdownMenuItem(value: 'want_to_watch', child: Text('想看')), - DropdownMenuItem(value: 'watching', child: Text('在看')), - ], - onChanged: (value) { - setState(() { - _status = value!; - }); - }, - ), - - const SizedBox(height: 16), - - // 观看日期选择 - InkWell( - onTap: _selectWatchDate, - child: InputDecorator( - decoration: const InputDecoration( - labelText: '观看日期', - prefixIcon: Icon(Icons.event), - border: OutlineInputBorder(), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - _watchDate != null - ? '${_watchDate!.year}-${_watchDate!.month.toString().padLeft(2, '0')}-${_watchDate!.day.toString().padLeft(2, '0')}' - : '选择日期', - style: TextStyle( - color: _watchDate != null - ? Theme.of(context).colorScheme.onSurface - : Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - if (_watchDate != null) - IconButton( - icon: const Icon(Icons.clear, size: 20), - 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, - ), + _buildStatusSelector(), + + const SizedBox(height: 24), + + // 别名 + _buildSectionTitle('别名'), + const SizedBox(height: 8), + _buildTagList(_alternateTitleControllers, '添加别名'), + + const SizedBox(height: 24), + + // 导演 + _buildSectionTitle('导演'), + const SizedBox(height: 8), + _buildTagList(_directorControllers, '添加导演'), + + const SizedBox(height: 24), + + // 编剧 + _buildSectionTitle('编剧'), + const SizedBox(height: 8), + _buildTagList(_writerControllers, '添加编剧'), + + const SizedBox(height: 24), + + // 主演 + _buildSectionTitle('主演'), + const SizedBox(height: 8), + _buildTagList(_actorControllers, '添加主演'), + + const SizedBox(height: 24), + + // 类型 + _buildSectionTitle('类型'), + const SizedBox(height: 8), + _buildTagList(_genreControllers, '添加类型'), + + const SizedBox(height: 24), + + // 剧情简介 + _buildSectionTitle('剧情简介'), + const SizedBox(height: 12), + _buildTextField( + controller: _summaryController, + label: '', + hint: '请输入剧情简介...', maxLines: 5, ), - - const SizedBox(height: 32), - - // 保存按钮 - 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), - ), - ), - ), - ), + + const SizedBox(height: 40), ], ), ), @@ -223,18 +240,408 @@ class _MovieFormPageState extends State { ); } - /// 选择观看日期 - Future _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 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 _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 _selectReleaseDate() async { final picked = await showDatePicker( context: context, - initialDate: _watchDate ?? DateTime.now(), + initialDate: _releaseDate ?? DateTime.now(), 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) { setState(() { - _watchDate = picked; + _releaseDate = picked; }); } } @@ -245,46 +652,99 @@ class _MovieFormPageState extends State { return; } - final year = _yearController.text.isNotEmpty ? int.tryParse(_yearController.text) : null; - final rating = _ratingController.text.isNotEmpty ? double.tryParse(_ratingController.text) : null; + setState(() => _isLoading = true); - if (widget.movie == null) { - // 添加新模式 - final newMovie = Movie( - id: DateTime.now().millisecondsSinceEpoch.toString(), - title: _titleController.text.trim(), - year: year, - rating: rating, - status: _status, - watchDate: _watchDate, - note: _noteController.text.trim(), + try { + final rating = _ratingController.text.isNotEmpty + ? double.tryParse(_ratingController.text) + : null; + + // 收集列表数据 + final directors = _collectNonEmptyTexts(_directorControllers); + final writers = _collectNonEmptyTexts(_writerControllers); + final actors = _collectNonEmptyTexts(_actorControllers); + 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().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().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().addMovie(newMovie); - } else { - // 编辑现有模式 - final updatedMovie = Movie( - id: widget.movie!.id, - title: _titleController.text.trim(), - year: year, - rating: rating, - status: _status, - watchDate: _watchDate, - note: _noteController.text.trim(), - ); - - await context.read().updateMovie(updatedMovie); + Navigator.pop(context); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('保存失败: $e'), + behavior: SnackBarBehavior.floating, + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } } + } - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(widget.movie == null ? '添加成功' : '更新成功'), - behavior: SnackBarBehavior.floating, - ), - ); - - Navigator.pop(context); + /// 收集非空文本 + List _collectNonEmptyTexts(List controllers) { + return controllers + .map((c) => c.text.trim()) + .where((text) => text.isNotEmpty) + .toList(); } } diff --git a/lib/pages/movie_form_page_new.dart b/lib/pages/movie_form_page_new.dart new file mode 100644 index 0000000..844d6e7 --- /dev/null +++ b/lib/pages/movie_form_page_new.dart @@ -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 createState() => _MovieFormPageState(); +} + +class _MovieFormPageState extends State { + final _formKey = GlobalKey(); + 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( + 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 _pickImage() async { + final XFile? image = await _picker.pickImage(source: ImageSource.gallery); + + if (image != null) { + setState(() { + _posterImage = File(image.path); + }); + } + } + + /// 选择日期 + Future _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 _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().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().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 _parseList(String text) { + return text + .split(',') + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toList(); + } +} diff --git a/lib/utils/database_helper.dart b/lib/utils/database_helper.dart index 38cc365..d4f8ae9 100644 --- a/lib/utils/database_helper.dart +++ b/lib/utils/database_helper.dart @@ -20,11 +20,75 @@ class DatabaseHelper { return await openDatabase( path, - version: 1, + version: 2, onCreate: _createDB, + onUpgrade: _onUpgrade, ); } + /// 数据库升级 + Future _onUpgrade(Database db, int oldVersion, int newVersion) async { + if (oldVersion < 2) { + // 升级movies表结构 + await _upgradeMoviesTableV2(db); + } + } + + /// 升级movies表到V2 + Future _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 _createDB(Database db, int version) async { const idType = 'INTEGER PRIMARY KEY AUTOINCREMENT'; @@ -35,14 +99,21 @@ class DatabaseHelper { // 影视表 await db.execute(''' CREATE TABLE movies ( - id $idType, - title $textType, - poster TEXT, + 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, - year INTEGER, - status $textType, - watch_date TEXT, - note TEXT + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + is_deleted INTEGER DEFAULT 0 ) '''); diff --git a/lib/utils/movie_dao.dart b/lib/utils/movie_dao.dart index ddb6eff..8ea2432 100644 --- a/lib/utils/movie_dao.dart +++ b/lib/utils/movie_dao.dart @@ -6,25 +6,17 @@ import 'database_helper.dart'; class MovieDao { final DatabaseHelper _dbHelper = DatabaseHelper.instance; - // 获取所有影视记录 + // 获取所有影视记录(未删除的) Future> getAllMovies() async { final db = await _dbHelper.database; - final List> maps = await db.query('movies'); + final List> maps = await db.query( + 'movies', + where: 'is_deleted = ?', + whereArgs: [0], + orderBy: 'updated_at DESC', + ); - return List.generate(maps.length, (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'], - ); - }); + return List.generate(maps.length, (i) => Movie.fromJson(maps[i])); } // 根据状态筛选影视记录 @@ -32,24 +24,90 @@ class MovieDao { final db = await _dbHelper.database; final List> maps = await db.query( 'movies', - where: 'status = ?', - whereArgs: [status], + where: 'status = ? AND is_deleted = ?', + whereArgs: [status, 0], + orderBy: 'updated_at DESC', ); - return List.generate(maps.length, (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'], - ); - }); + return List.generate(maps.length, (i) => Movie.fromJson(maps[i])); + } + + // 根据导演筛选 + Future> getMoviesByDirector(String director) async { + final db = await _dbHelper.database; + final List> 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.directors.contains(director)) + .toList(); + } + + // 根据编剧筛选 + Future> getMoviesByWriter(String writer) async { + final db = await _dbHelper.database; + final List> 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> getMoviesByActor(String actor) async { + final db = await _dbHelper.database; + final List> 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> getMoviesByGenre(String genre) async { + final db = await _dbHelper.database; + final List> 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> searchMovies(String keyword) async { + final db = await _dbHelper.database; + final List> 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 deleteMovie(String id) async { final db = await _dbHelper.database; - return await db.delete( + return await db.update( 'movies', + {'is_deleted': 1, 'updated_at': DateTime.now().toIso8601String()}, where: 'id = ?', whereArgs: [id], ); } + + // 获取所有导演(去重) + Future> getAllDirectors() async { + final movies = await getAllMovies(); + final directors = {}; + for (final movie in movies) { + directors.addAll(movie.directors); + } + return directors.toList()..sort(); + } + + // 获取所有编剧(去重) + Future> getAllWriters() async { + final movies = await getAllMovies(); + final writers = {}; + for (final movie in movies) { + writers.addAll(movie.writers); + } + return writers.toList()..sort(); + } + + // 获取所有演员(去重) + Future> getAllActors() async { + final movies = await getAllMovies(); + final actors = {}; + for (final movie in movies) { + actors.addAll(movie.actors); + } + return actors.toList()..sort(); + } + + // 获取所有类型(去重) + Future> getAllGenres() async { + final movies = await getAllMovies(); + final genres = {}; + for (final movie in movies) { + genres.addAll(movie.genres); + } + return genres.toList()..sort(); + } } diff --git a/lib/widgets/movie_list_item.dart b/lib/widgets/movie_list_item.dart index 05ef5d9..257eefe 100644 --- a/lib/widgets/movie_list_item.dart +++ b/lib/widgets/movie_list_item.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; @@ -12,11 +13,21 @@ class MovieListItem extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Card( 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( onTap: () { - // 跳转到详情页 Navigator.pushNamed(context, '/movie-detail', arguments: movie); }, borderRadius: BorderRadius.circular(12), @@ -25,103 +36,126 @@ class MovieListItem extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 海报占位图 - _buildPoster(), + // 海报 + _buildPoster(context), - const SizedBox(width: 12), + const SizedBox(width: 14), // 影片信息 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 标题和年份 + // 标题 Text( movie.title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), - if (movie.year != null) ...[ - const SizedBox(height: 4), - Text( - '${movie.year}年', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], + const SizedBox(height: 6), - const SizedBox(height: 8), - - // 评分和状态 + // 上映日期和评分 Row( children: [ - if (movie.rating != null) ...[ + if (movie.releaseDate != null) ...[ Icon( - Icons.star, - size: 16, - color: Colors.amber[700], + Icons.calendar_today_outlined, + size: 13, + color: colorScheme.onSurfaceVariant.withOpacity(0.7), ), const SizedBox(width: 4), Text( - movie.rating.toString(), + '${movie.releaseDate!.year}', style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Colors.amber[700], + fontSize: 13, + color: colorScheme.onSurfaceVariant.withOpacity(0.7), ), ), const SizedBox(width: 12), ], - // 状态标签 - _buildStatusTag(context), + if (movie.rating != null) ...[ + 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: 6), - Text( - '观看日期:${_formatDate(movie.watchDate!)}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], + const SizedBox(height: 8), - if (movie.note != null && movie.note!.isNotEmpty) ...[ - const SizedBox(height: 6), - Text( - movie.note!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, + // 导演 + if (movie.directors.isNotEmpty) + _buildInfoRow( + context, + prefix: '导演', + items: movie.directors.take(2).toList(), ), - ], + + 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( + mainAxisSize: MainAxisSize.min, children: [ IconButton( - icon: const Icon(Icons.edit, size: 20), + icon: Icon(Icons.edit_outlined, size: 20, color: colorScheme.primary), onPressed: () { Navigator.pushNamed(context, '/movie-form', arguments: movie); }, padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), + const SizedBox(height: 8), IconButton( - icon: const Icon(Icons.delete_outline, size: 20), - color: Colors.red, + icon: Icon(Icons.delete_outline, size: 20, color: colorScheme.error.withOpacity(0.7)), onPressed: () => _showDeleteDialog(context, movie), padding: EdgeInsets.zero, constraints: const BoxConstraints(), @@ -135,23 +169,65 @@ class MovieListItem extends StatelessWidget { ); } - /// 构建海报占位图 - Widget _buildPoster() { - return Container( - width: 60, - height: 90, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(8), + /// 构建海报 + Widget _buildPoster(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + 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( - Icons.movie, - color: Colors.grey[500], - size: 32, + Icons.movie_outlined, + color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.3), + size: 28, ), ); } + /// 构建信息行 + Widget _buildInfoRow(BuildContext context, {required String prefix, required List 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) { Color statusColor; @@ -176,12 +252,12 @@ class MovieListItem extends StatelessWidget { } return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: statusColor.withOpacity(0.1), borderRadius: BorderRadius.circular(4), border: Border.all( - color: statusColor, + color: statusColor.withOpacity(0.3), width: 1, ), ), @@ -190,28 +266,36 @@ class MovieListItem extends StatelessWidget { style: TextStyle( fontSize: 11, 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) { + final colorScheme = Theme.of(context).colorScheme; + showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('确认删除'), - content: Text('确定要删除"${movie.title}"吗?此操作不可恢复。'), + backgroundColor: colorScheme.surface, + 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: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('取消'), + child: Text( + '取消', + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), ), TextButton( onPressed: () async { @@ -219,15 +303,16 @@ class MovieListItem extends StatelessWidget { if (!context.mounted) return; Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('已删除'), + SnackBar( + content: const Text('已删除'), behavior: SnackBarBehavior.floating, + backgroundColor: colorScheme.primary, ), ); }, - child: const Text( + child: Text( '删除', - style: TextStyle(color: Colors.red), + style: TextStyle(color: colorScheme.error), ), ), ], diff --git a/pubspec.lock b/pubspec.lock index 4075030..e661ac1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -41,6 +49,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -65,6 +89,54 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -86,11 +158,120 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct dev" description: flutter source: sdk 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: dependency: transitive description: @@ -123,6 +304,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -147,6 +336,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -155,6 +360,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -163,6 +376,54 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -187,6 +448,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: flutter @@ -288,6 +557,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -304,6 +581,30 @@ packages: url: "https://pub.dev" source: hosted 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: - dart: ">=3.9.0 <4.0.0" - flutter: ">=3.24.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 80694b8..0fcf45d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,6 +14,8 @@ dependencies: fl_chart: ^0.69.0 sqflite: ^2.3.0 path: ^1.8.3 + image_picker: ^1.0.4 + path_provider: ^2.1.1 dev_dependencies: flutter_test: