From f2ab6a17ba28e3f655f50208c2a2b1823d327686 Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Fri, 6 Mar 2026 16:09:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=BD=B1=E8=AF=84=E6=B5=B7?= =?UTF-8?q?=E6=8A=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/models/data_models.dart | 136 +++++ lib/pages/book_form_page.dart | 615 +++++++++++----------- lib/pages/movie_detail_page.dart | 154 ++++++ lib/pages/movie_form_page.dart | 702 +++++++++++++------------- lib/pages/movie_posters_page.dart | 254 ++++++++++ lib/pages/movie_review_form_page.dart | 263 ++++++++++ lib/pages/movie_reviews_page.dart | 261 ++++++++++ lib/pages/note_detail_page.dart | 115 ++++- lib/pages/note_form_page.dart | 122 ++++- lib/pages/profile_page.dart | 19 +- lib/providers/app_provider.dart | 53 ++ lib/utils/database_helper.dart | 67 ++- lib/utils/movie_poster_dao.dart | 61 +++ lib/utils/movie_review_dao.dart | 98 ++++ lib/utils/user_prefs.dart | 4 +- lib/widgets/custom_drawer.dart | 9 - pubspec.lock | 24 + pubspec.yaml | 1 + 18 files changed, 2260 insertions(+), 698 deletions(-) create mode 100644 lib/pages/movie_posters_page.dart create mode 100644 lib/pages/movie_review_form_page.dart create mode 100644 lib/pages/movie_reviews_page.dart create mode 100644 lib/utils/movie_poster_dao.dart create mode 100644 lib/utils/movie_review_dao.dart diff --git a/lib/models/data_models.dart b/lib/models/data_models.dart index 2542b9d..89cc70b 100644 --- a/lib/models/data_models.dart +++ b/lib/models/data_models.dart @@ -333,3 +333,139 @@ class Note { } } +/// 影评模型 +class MovieReview { + final String id; + final String movieId; + final String content; + final String reviewer; + final String source; + final int reviewType; // 1: 短评, 2: 长评 + final bool isDeleted; + final DateTime createdAt; + final DateTime updatedAt; + + MovieReview({ + required this.id, + required this.movieId, + required this.content, + this.reviewer = '', + this.source = '', + this.reviewType = 1, + this.isDeleted = false, + required this.createdAt, + required this.updatedAt, + }); + + factory MovieReview.fromJson(Map json) { + return MovieReview( + id: json['id']?.toString() ?? '', + movieId: json['movie_id']?.toString() ?? '', + content: json['content'] ?? '', + reviewer: json['reviewer'] ?? '', + source: json['source'] ?? '', + reviewType: json['review_type'] ?? 1, + isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at']) + : DateTime.now(), + updatedAt: json['updated_at'] != null + ? DateTime.parse(json['updated_at']) + : DateTime.now(), + ); + } + + Map toJson() { + return { + 'id': id, + 'movie_id': movieId, + 'content': content, + 'reviewer': reviewer, + 'source': source, + 'review_type': reviewType, + 'is_deleted': isDeleted ? 1 : 0, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + } + + /// 复制并修改 + MovieReview copyWith({ + String? id, + String? movieId, + String? content, + String? reviewer, + String? source, + int? reviewType, + bool? isDeleted, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return MovieReview( + id: id ?? this.id, + movieId: movieId ?? this.movieId, + content: content ?? this.content, + reviewer: reviewer ?? this.reviewer, + source: source ?? this.source, + reviewType: reviewType ?? this.reviewType, + isDeleted: isDeleted ?? this.isDeleted, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + /// 获取评论摘要 + String get summary { + if (content.length <= 50) return content; + return '${content.substring(0, 50)}...'; + } + + /// 评论类型文本 + String get typeText => reviewType == 1 ? '短评' : '长评'; +} + +/// 影视海报墙模型 +class MoviePoster { + final String id; + final String movieId; + final String posterPath; + final bool isDeleted; + final DateTime createdAt; + + MoviePoster({ + required this.id, + required this.movieId, + required this.posterPath, + this.isDeleted = false, + required this.createdAt, + }); + + factory MoviePoster.fromJson(Map json) { + return MoviePoster( + id: json['id']?.toString() ?? '', + movieId: json['movie_id']?.toString() ?? '', + posterPath: json['poster_path'] ?? '', + isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at']) + : DateTime.now(), + ); + } + + Map toJson() { + return { + 'id': id, + 'movie_id': movieId, + 'poster_path': posterPath, + 'is_deleted': isDeleted ? 1 : 0, + 'created_at': createdAt.toIso8601String(), + }; + } + + /// 获取海报文件 + File? get posterFile { + if (posterPath.isEmpty) return null; + return File(posterPath); + } +} + diff --git a/lib/pages/book_form_page.dart b/lib/pages/book_form_page.dart index 22370eb..fc2a920 100644 --- a/lib/pages/book_form_page.dart +++ b/lib/pages/book_form_page.dart @@ -7,7 +7,7 @@ import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; import '../models/data_models.dart'; -/// 添加/编辑书籍页面 - 极简主义设计 +/// 添加/编辑书籍页面 - 紧凑双行布局设计 class BookFormPage extends StatefulWidget { final Book? book; @@ -21,11 +21,16 @@ class _BookFormPageState extends State { final _formKey = GlobalKey(); final ImagePicker _picker = ImagePicker(); + // 输入框控制器 late TextEditingController _titleController; late TextEditingController _publisherController; late TextEditingController _summaryController; late TextEditingController _ratingController; + // 多值字段的临时输入控制器 + final Map _tagControllers = {}; + + // 数据 List _authors = []; List _alternateTitles = []; List _genres = []; @@ -56,9 +61,14 @@ class _BookFormPageState extends State { _publisherController.dispose(); _summaryController.dispose(); _ratingController.dispose(); + _tagControllers.values.forEach((c) => c.dispose()); super.dispose(); } + TextEditingController _getTagController(String key) { + return _tagControllers.putIfAbsent(key, () => TextEditingController()); + } + @override Widget build(BuildContext context) { final isEdit = widget.book != null; @@ -84,123 +94,159 @@ class _BookFormPageState extends State { body: Form( key: _formKey, child: ListView( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), children: [ - // 封面选择 - _buildCoverPicker(), + // 封面选择 - 居中显示 + Center(child: _buildCoverPicker()), const SizedBox(height: 32), - // 基本信息 - _buildSectionTitle('基本信息'), - const SizedBox(height: 16), - - // 书名 - _buildTextField( - controller: _titleController, + // 基本信息区域 + _buildFormItem( label: '书名 *', - hint: '请输入书名', - validator: (value) { - if (value == null || value.trim().isEmpty) { - return '请输入书名'; - } - return null; - }, + child: TextFormField( + controller: _titleController, + style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), + decoration: const InputDecoration( + hintText: '请输入书名', + hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入书名'; + } + return null; + }, + ), ), - const SizedBox(height: 16), + _buildDivider(), // 别名 - _buildTagInput( + _buildMultiValueItem( label: '别名', - hint: '输入别名,按回车添加', - tags: _alternateTitles, - onAdd: (tag) => setState(() => _alternateTitles.add(tag)), - onRemove: (index) => setState(() => _alternateTitles.removeAt(index)), + values: _alternateTitles, + hint: '输入别名', + controllerKey: 'alternateTitles', + onAdd: (v) => setState(() => _alternateTitles.add(v)), + onRemove: (i) => setState(() => _alternateTitles.removeAt(i)), ), - const SizedBox(height: 24), + _buildDivider(), // 作者 - _buildSectionTitle('作者'), - const SizedBox(height: 16), - - _buildTagInput( + _buildMultiValueItem( label: '作者', - hint: '输入作者,按回车添加', - tags: _authors, - onAdd: (tag) => setState(() => _authors.add(tag)), - onRemove: (index) => setState(() => _authors.removeAt(index)), + values: _authors, + hint: '输入作者姓名', + controllerKey: 'authors', + onAdd: (v) => setState(() => _authors.add(v)), + onRemove: (i) => setState(() => _authors.removeAt(i)), ), - const SizedBox(height: 24), + _buildDivider(), // 出版社 - _buildTextField( - controller: _publisherController, + _buildFormItem( label: '出版社', - hint: '请输入出版社', + child: TextFormField( + controller: _publisherController, + style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), + decoration: const InputDecoration( + hintText: '请输入出版社', + hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + ), ), - const SizedBox(height: 24), + _buildDivider(), // 类型 - _buildSectionTitle('类型'), - const SizedBox(height: 16), - - _buildTagInput( + _buildMultiValueItem( label: '类型', - hint: '输入类型,按回车添加', - tags: _genres, - onAdd: (tag) => setState(() => _genres.add(tag)), - onRemove: (index) => setState(() => _genres.removeAt(index)), + values: _genres, + hint: '如:小说、历史', + controllerKey: 'genres', + onAdd: (v) => setState(() => _genres.add(v)), + onRemove: (i) => setState(() => _genres.removeAt(i)), ), - const SizedBox(height: 24), + _buildDivider(), // 书籍简介 - _buildSectionTitle('书籍简介'), - const SizedBox(height: 16), - - _buildTextField( - controller: _summaryController, - label: '', - hint: '写下书籍简介...', - maxLines: 5, + _buildFormItem( + label: '书籍简介', + child: TextFormField( + controller: _summaryController, + maxLines: 4, + style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A), height: 1.5), + decoration: const InputDecoration( + hintText: '写下书籍简介...', + hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + ), ), - const SizedBox(height: 24), + _buildDivider(), - // 评分和状态 - _buildSectionTitle('评分与状态'), - const SizedBox(height: 16), - - Row( - children: [ - Expanded( - flex: 2, - child: _buildTextField( - controller: _ratingController, - 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 < 1 || rating > 10) { - return '评分必须在 1-10 之间'; + // 评分 + _buildFormItem( + label: '评分', + child: Row( + children: [ + Expanded( + child: TextFormField( + controller: _ratingController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), + decoration: const InputDecoration( + hintText: '1-10', + hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + validator: (value) { + if (value != null && value.isNotEmpty) { + final rating = double.tryParse(value); + if (rating == null || rating < 1 || rating > 10) { + return '评分必须在 1-10 之间'; + } } - } - return null; - }, + return null; + }, + ), ), + if (_ratingController.text.isNotEmpty) + const Text( + '分', + style: TextStyle(fontSize: 14, color: Color(0xFF999999)), + ), + ], + ), + ), + + _buildDivider(), + + // 状态 + _buildFormItem( + label: '状态', + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: Wrap( + spacing: 12, + children: [ + _buildStatusChip('想读', 'want_to_read'), + _buildStatusChip('在读', 'reading'), + _buildStatusChip('已读', 'read'), + ], ), - const SizedBox(width: 16), - Expanded( - flex: 3, - child: _buildStatusSelector(), - ), - ], + ), ), const SizedBox(height: 48), @@ -210,15 +256,193 @@ class _BookFormPageState extends State { ); } - /// 构建区块标题 - Widget _buildSectionTitle(String title) { - return Text( - title.toUpperCase(), - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Color(0xFF999999), - letterSpacing: 1, + /// 构建表单条目(标签 + 内容) + Widget _buildFormItem({required String label, required Widget child}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + color: label.contains('*') ? const Color(0xFF1A1A1A) : const Color(0xFF666666), + fontWeight: label.contains('*') ? FontWeight.w500 : FontWeight.normal, + ), + ), + const SizedBox(height: 8), + child, + ], + ); + } + + /// 构建多值条目 + Widget _buildMultiValueItem({ + required String label, + required List values, + required String hint, + required String controllerKey, + required Function(String) onAdd, + required Function(int) onRemove, + }) { + final controller = _getTagController(controllerKey); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 第一行:标签 + 添加按钮 + Row( + children: [ + Text( + label, + style: const TextStyle(fontSize: 13, color: Color(0xFF666666)), + ), + const Spacer(), + // 添加按钮(当输入框有内容时显示) + ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, child) { + final hasText = value.text.trim().isNotEmpty; + return GestureDetector( + onTap: hasText + ? () { + final text = controller.text.trim(); + if (text.isNotEmpty && !values.contains(text)) { + onAdd(text); + controller.clear(); + } + } + : null, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + border: Border.all( + color: hasText ? const Color(0xFF1A1A1A) : const Color(0xFFE5E5E5), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.add, + size: 14, + color: hasText ? const Color(0xFF1A1A1A) : const Color(0xFFCCCCCC), + ), + const SizedBox(width: 2), + Text( + '添加', + style: TextStyle( + fontSize: 12, + color: hasText ? const Color(0xFF1A1A1A) : const Color(0xFFCCCCCC), + ), + ), + ], + ), + ), + ); + }, + ), + ], + ), + const SizedBox(height: 8), + // 第二行:已选标签 + 输入框 + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + ...values.asMap().entries.map((entry) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + entry.value, + style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: () => onRemove(entry.key), + child: const Icon(Icons.close, size: 14, color: Color(0xFF999999)), + ), + ], + ), + ); + }), + // 输入框 + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 100, maxWidth: 150), + child: TextField( + controller: controller, + style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)), + decoration: InputDecoration( + hintText: values.isEmpty ? hint : '', + hintStyle: const TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 5), + ), + onSubmitted: (value) { + final trimmed = value.trim(); + if (trimmed.isNotEmpty && !values.contains(trimmed)) { + onAdd(trimmed); + controller.clear(); + } + }, + ), + ), + ], + ), + ], + ); + } + + /// 构建分隔线 + Widget _buildDivider() { + return Container( + margin: const EdgeInsets.symmetric(vertical: 16), + height: 0.5, + color: const Color(0xFFE5E5E5), + ); + } + + /// 构建状态选择 Chip + Widget _buildStatusChip(String label, String value) { + final isSelected = _status == value; + Color color; + switch (value) { + case 'read': + color = const Color(0xFF1A1A1A); + break; + case 'reading': + color = const Color(0xFF666666); + break; + case 'want_to_read': + color = const Color(0xFF999999); + break; + default: + color = const Color(0xFFCCCCCC); + } + + return GestureDetector( + onTap: () => setState(() => _status = value), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: isSelected ? color : Colors.transparent, + border: Border.all(color: color), + ), + child: Text( + label, + style: TextStyle( + fontSize: 13, + color: isSelected ? Colors.white : color, + ), + ), ), ); } @@ -228,8 +452,8 @@ class _BookFormPageState extends State { return GestureDetector( onTap: _pickCover, child: Container( - width: 120, - height: 160, + width: 140, + height: 200, decoration: BoxDecoration( color: const Color(0xFFF5F5F5), border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5), @@ -251,14 +475,14 @@ class _BookFormPageState extends State { children: [ Icon( Icons.add_photo_alternate_outlined, - size: 32, + size: 40, color: Color(0xFF999999), ), - SizedBox(height: 8), + SizedBox(height: 12), Text( - '添加封面', + '点击添加封面', style: TextStyle( - fontSize: 13, + fontSize: 14, color: Color(0xFF999999), ), ), @@ -266,199 +490,6 @@ class _BookFormPageState extends State { ); } - /// 构建文本输入框 - Widget _buildTextField({ - required TextEditingController controller, - required String label, - String? hint, - int maxLines = 1, - TextInputType? keyboardType, - String? Function(String?)? validator, - }) { - return TextFormField( - controller: controller, - maxLines: maxLines, - keyboardType: keyboardType, - validator: validator, - style: const TextStyle( - fontSize: 15, - color: Color(0xFF1A1A1A), - ), - decoration: InputDecoration( - labelText: label, - hintText: hint, - labelStyle: const TextStyle( - fontSize: 14, - color: Color(0xFF666666), - ), - hintStyle: const TextStyle( - fontSize: 14, - color: Color(0xFFCCCCCC), - ), - border: const UnderlineInputBorder( - borderSide: BorderSide(color: Color(0xFFE5E5E5)), - ), - enabledBorder: const UnderlineInputBorder( - borderSide: BorderSide(color: Color(0xFFE5E5E5)), - ), - focusedBorder: const UnderlineInputBorder( - borderSide: BorderSide(color: Color(0xFF1A1A1A)), - ), - contentPadding: const EdgeInsets.symmetric(vertical: 12), - ), - ); - } - - /// 构建标签输入 - Widget _buildTagInput({ - required String label, - required String hint, - required List tags, - required Function(String) onAdd, - required Function(int) onRemove, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (label.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - label, - style: const TextStyle( - fontSize: 14, - color: Color(0xFF666666), - ), - ), - ), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - ...tags.asMap().entries.map((entry) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - border: Border.all(color: const Color(0xFFE5E5E5)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - entry.value, - style: const TextStyle( - fontSize: 14, - color: Color(0xFF1A1A1A), - ), - ), - const SizedBox(width: 4), - GestureDetector( - onTap: () => onRemove(entry.key), - child: const Icon( - Icons.close, - size: 16, - color: Color(0xFF999999), - ), - ), - ], - ), - ); - }), - Container( - width: 120, - child: TextField( - decoration: InputDecoration( - hintText: hint, - hintStyle: const TextStyle( - fontSize: 13, - color: Color(0xFFCCCCCC), - ), - border: const UnderlineInputBorder( - borderSide: BorderSide(color: Color(0xFFE5E5E5)), - ), - contentPadding: const EdgeInsets.symmetric(vertical: 8), - ), - style: const TextStyle( - fontSize: 14, - color: Color(0xFF1A1A1A), - ), - onSubmitted: (value) { - if (value.trim().isNotEmpty && !tags.contains(value.trim())) { - onAdd(value.trim()); - } - }, - ), - ), - ], - ), - ], - ); - } - - /// 构建状态选择器 - Widget _buildStatusSelector() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '状态', - style: TextStyle( - fontSize: 14, - color: Color(0xFF666666), - ), - ), - const SizedBox(height: 8), - Row( - children: [ - _buildStatusOption('已读', 'read'), - const SizedBox(width: 12), - _buildStatusOption('在读', 'reading'), - const SizedBox(width: 12), - _buildStatusOption('想读', 'want_to_read'), - ], - ), - ], - ); - } - - Widget _buildStatusOption(String label, String value) { - final isSelected = _status == value; - Color color; - switch (value) { - case 'read': - color = const Color(0xFF1A1A1A); - break; - case 'reading': - color = const Color(0xFF666666); - break; - case 'want_to_read': - color = const Color(0xFF999999); - break; - default: - color = const Color(0xFFCCCCCC); - } - - return GestureDetector( - onTap: () => setState(() => _status = value), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: isSelected ? color : Colors.transparent, - border: Border.all(color: color), - ), - child: Text( - label, - style: TextStyle( - fontSize: 13, - color: isSelected ? Colors.white : color, - fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal, - ), - ), - ), - ); - } - /// 选择封面 Future _pickCover() async { try { @@ -505,7 +536,6 @@ class _BookFormPageState extends State { final now = DateTime.now(); if (widget.book == null) { - // 添加新模式 final newBook = Book( id: now.millisecondsSinceEpoch.toString(), title: _titleController.text.trim(), @@ -523,7 +553,6 @@ class _BookFormPageState extends State { await context.read().addBook(newBook); } else { - // 编辑现有模式 final updatedBook = widget.book!.copyWith( title: _titleController.text.trim(), coverPath: _coverPath, diff --git a/lib/pages/movie_detail_page.dart b/lib/pages/movie_detail_page.dart index 9ea531b..5e9d936 100644 --- a/lib/pages/movie_detail_page.dart +++ b/lib/pages/movie_detail_page.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; import '../models/data_models.dart'; +import 'movie_reviews_page.dart'; +import 'movie_posters_page.dart'; /// 影视详情页 - 极简主义设计 class MovieDetailPage extends StatefulWidget { @@ -60,6 +62,11 @@ class _MovieDetailPageState extends State { if (widget.movie.alternateTitles.isNotEmpty) _buildAlternateTitlesSection(), + const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), + + // 影评和海报墙入口 + _buildExtraSections(), + const SizedBox(height: 48), ], ), @@ -470,6 +477,153 @@ class _MovieDetailPageState extends State { ); } + /// 构建额外功能区域(影评、海报墙) + Widget _buildExtraSections() { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '更多', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Color(0xFF999999), + letterSpacing: 1, + ), + ), + const SizedBox(height: 16), + // 影评入口 + GestureDetector( + onTap: () => _navigateToReviews(), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Row( + children: [ + const Icon( + Icons.rate_review_outlined, + size: 24, + color: Color(0xFF666666), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '影评', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: Color(0xFF1A1A1A), + ), + ), + const SizedBox(height: 4), + FutureBuilder( + future: context.read().getMovieReviewCount(widget.movie.id), + builder: (context, snapshot) { + final count = snapshot.data ?? 0; + return Text( + count > 0 ? '$count 条影评' : '暂无影评', + style: const TextStyle( + fontSize: 13, + color: Color(0xFF999999), + ), + ); + }, + ), + ], + ), + ), + const Icon( + Icons.chevron_right, + color: Color(0xFF999999), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + // 海报墙入口 + GestureDetector( + onTap: () => _navigateToPosters(), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Row( + children: [ + const Icon( + Icons.photo_library_outlined, + size: 24, + color: Color(0xFF666666), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '海报墙', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: Color(0xFF1A1A1A), + ), + ), + const SizedBox(height: 4), + FutureBuilder( + future: context.read().getMoviePosterCount(widget.movie.id), + builder: (context, snapshot) { + final count = snapshot.data ?? 0; + return Text( + count > 0 ? '$count 张海报' : '暂无海报', + style: const TextStyle( + fontSize: 13, + color: Color(0xFF999999), + ), + ); + }, + ), + ], + ), + ), + const Icon( + Icons.chevron_right, + color: Color(0xFF999999), + ), + ], + ), + ), + ), + ], + ), + ); + } + + void _navigateToReviews() { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MovieReviewsPage(movie: widget.movie), + ), + ); + } + + void _navigateToPosters() { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MoviePostersPage(movie: widget.movie), + ), + ); + } + /// 构建底部操作栏 Widget _buildBottomBar() { return Container( diff --git a/lib/pages/movie_form_page.dart b/lib/pages/movie_form_page.dart index e46ca55..304c896 100644 --- a/lib/pages/movie_form_page.dart +++ b/lib/pages/movie_form_page.dart @@ -7,7 +7,7 @@ import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; import '../models/data_models.dart'; -/// 添加/编辑影视页面 - 极简主义设计 +/// 添加/编辑影视页面 - 紧凑双行布局设计 class MovieFormPage extends StatefulWidget { final Movie? movie; @@ -21,10 +21,15 @@ class _MovieFormPageState extends State { final _formKey = GlobalKey(); final ImagePicker _picker = ImagePicker(); + // 输入框控制器 late TextEditingController _titleController; late TextEditingController _summaryController; late TextEditingController _ratingController; + // 多值字段的临时输入控制器 + final Map _tagControllers = {}; + + // 数据 List _directors = []; List _writers = []; List _actors = []; @@ -59,9 +64,14 @@ class _MovieFormPageState extends State { _titleController.dispose(); _summaryController.dispose(); _ratingController.dispose(); + _tagControllers.values.forEach((c) => c.dispose()); super.dispose(); } + TextEditingController _getTagController(String key) { + return _tagControllers.putIfAbsent(key, () => TextEditingController()); + } + @override Widget build(BuildContext context) { final isEdit = widget.movie != null; @@ -87,147 +97,198 @@ class _MovieFormPageState extends State { body: Form( key: _formKey, child: ListView( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), children: [ - // 封面选择 - _buildCoverPicker(), + // 封面选择 - 居中显示 + Center(child: _buildCoverPicker()), const SizedBox(height: 32), - // 基本信息 - _buildSectionTitle('基本信息'), - const SizedBox(height: 16), - - // 影视名称 - _buildTextField( - controller: _titleController, + // 基本信息区域 + _buildFormItem( label: '影视名称 *', - hint: '请输入影视名称', - validator: (value) { - if (value == null || value.trim().isEmpty) { - return '请输入影视名称'; - } - return null; - }, + child: TextFormField( + controller: _titleController, + style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), + decoration: const InputDecoration( + hintText: '请输入影视名称', + hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入影视名称'; + } + return null; + }, + ), ), - const SizedBox(height: 16), + _buildDivider(), // 别名 - _buildTagInput( + _buildMultiValueItem( label: '别名', - hint: '输入别名,按回车添加', - tags: _alternateTitles, - onAdd: (tag) => setState(() => _alternateTitles.add(tag)), - onRemove: (index) => setState(() => _alternateTitles.removeAt(index)), + values: _alternateTitles, + hint: '输入别名', + controllerKey: 'alternateTitles', + onAdd: (v) => setState(() => _alternateTitles.add(v)), + onRemove: (i) => setState(() => _alternateTitles.removeAt(i)), ), - const SizedBox(height: 16), + _buildDivider(), // 上映日期 - _buildDatePicker(), + _buildFormItem( + label: '上映日期', + child: GestureDetector( + onTap: _selectReleaseDate, + child: Row( + children: [ + 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 + ? const Color(0xFF1A1A1A) + : const Color(0xFFCCCCCC), + ), + ), + ), + if (_releaseDate != null) + GestureDetector( + onTap: () => setState(() => _releaseDate = null), + child: const Icon(Icons.close, size: 18, color: Color(0xFF999999)), + ), + ], + ), + ), + ), - const SizedBox(height: 24), + _buildDivider(), // 导演 - _buildSectionTitle('导演'), - const SizedBox(height: 16), - - _buildTagInput( + _buildMultiValueItem( label: '导演', - hint: '输入导演,按回车添加', - tags: _directors, - onAdd: (tag) => setState(() => _directors.add(tag)), - onRemove: (index) => setState(() => _directors.removeAt(index)), + values: _directors, + hint: '输入导演姓名', + controllerKey: 'directors', + onAdd: (v) => setState(() => _directors.add(v)), + onRemove: (i) => setState(() => _directors.removeAt(i)), ), - const SizedBox(height: 24), + _buildDivider(), // 编剧 - _buildSectionTitle('编剧'), - const SizedBox(height: 16), - - _buildTagInput( + _buildMultiValueItem( label: '编剧', - hint: '输入编剧,按回车添加', - tags: _writers, - onAdd: (tag) => setState(() => _writers.add(tag)), - onRemove: (index) => setState(() => _writers.removeAt(index)), + values: _writers, + hint: '输入编剧姓名', + controllerKey: 'writers', + onAdd: (v) => setState(() => _writers.add(v)), + onRemove: (i) => setState(() => _writers.removeAt(i)), ), - const SizedBox(height: 24), + _buildDivider(), // 主演 - _buildSectionTitle('主演'), - const SizedBox(height: 16), - - _buildTagInput( + _buildMultiValueItem( label: '主演', - hint: '输入主演,按回车添加', - tags: _actors, - onAdd: (tag) => setState(() => _actors.add(tag)), - onRemove: (index) => setState(() => _actors.removeAt(index)), + values: _actors, + hint: '输入主演姓名', + controllerKey: 'actors', + onAdd: (v) => setState(() => _actors.add(v)), + onRemove: (i) => setState(() => _actors.removeAt(i)), ), - const SizedBox(height: 24), + _buildDivider(), // 类型 - _buildSectionTitle('类型'), - const SizedBox(height: 16), - - _buildTagInput( + _buildMultiValueItem( label: '类型', - hint: '输入类型,按回车添加', - tags: _genres, - onAdd: (tag) => setState(() => _genres.add(tag)), - onRemove: (index) => setState(() => _genres.removeAt(index)), + values: _genres, + hint: '如:剧情、科幻', + controllerKey: 'genres', + onAdd: (v) => setState(() => _genres.add(v)), + onRemove: (i) => setState(() => _genres.removeAt(i)), ), - const SizedBox(height: 24), + _buildDivider(), // 剧情简介 - _buildSectionTitle('剧情简介'), - const SizedBox(height: 16), - - _buildTextField( - controller: _summaryController, - label: '', - hint: '写下剧情简介...', - maxLines: 5, + _buildFormItem( + label: '剧情简介', + child: TextFormField( + controller: _summaryController, + maxLines: 4, + style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A), height: 1.5), + decoration: const InputDecoration( + hintText: '写下剧情简介...', + hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + ), ), - const SizedBox(height: 24), + _buildDivider(), - // 评分和状态 - _buildSectionTitle('评分与状态'), - const SizedBox(height: 16), - - Row( - children: [ - Expanded( - flex: 2, - child: _buildTextField( - controller: _ratingController, - 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 < 1 || rating > 10) { - return '评分必须在 1-10 之间'; + // 评分 + _buildFormItem( + label: '评分', + child: Row( + children: [ + Expanded( + child: TextFormField( + controller: _ratingController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), + decoration: const InputDecoration( + hintText: '1-10', + hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + validator: (value) { + if (value != null && value.isNotEmpty) { + final rating = double.tryParse(value); + if (rating == null || rating < 1 || rating > 10) { + return '评分必须在 1-10 之间'; + } } - } - return null; - }, + return null; + }, + ), ), + if (_ratingController.text.isNotEmpty) + const Text( + '分', + style: TextStyle(fontSize: 14, color: Color(0xFF999999)), + ), + ], + ), + ), + + _buildDivider(), + + // 状态 + _buildFormItem( + label: '状态', + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: Wrap( + spacing: 12, + children: [ + _buildStatusChip('想看', 'want_to_watch'), + _buildStatusChip('在看', 'watching'), + _buildStatusChip('已看', 'watched'), + ], ), - const SizedBox(width: 16), - Expanded( - flex: 3, - child: _buildStatusSelector(), - ), - ], + ), ), const SizedBox(height: 48), @@ -237,15 +298,193 @@ class _MovieFormPageState extends State { ); } - /// 构建区块标题 - Widget _buildSectionTitle(String title) { - return Text( - title.toUpperCase(), - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Color(0xFF999999), - letterSpacing: 1, + /// 构建表单条目(标签 + 内容) + Widget _buildFormItem({required String label, required Widget child}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + color: label.contains('*') ? const Color(0xFF1A1A1A) : const Color(0xFF666666), + fontWeight: label.contains('*') ? FontWeight.w500 : FontWeight.normal, + ), + ), + const SizedBox(height: 8), + child, + ], + ); + } + + /// 构建多值条目 + Widget _buildMultiValueItem({ + required String label, + required List values, + required String hint, + required String controllerKey, + required Function(String) onAdd, + required Function(int) onRemove, + }) { + final controller = _getTagController(controllerKey); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 第一行:标签 + 添加按钮 + Row( + children: [ + Text( + label, + style: const TextStyle(fontSize: 13, color: Color(0xFF666666)), + ), + const Spacer(), + // 添加按钮(当输入框有内容时显示) + ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, child) { + final hasText = value.text.trim().isNotEmpty; + return GestureDetector( + onTap: hasText + ? () { + final text = controller.text.trim(); + if (text.isNotEmpty && !values.contains(text)) { + onAdd(text); + controller.clear(); + } + } + : null, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + border: Border.all( + color: hasText ? const Color(0xFF1A1A1A) : const Color(0xFFE5E5E5), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.add, + size: 14, + color: hasText ? const Color(0xFF1A1A1A) : const Color(0xFFCCCCCC), + ), + const SizedBox(width: 2), + Text( + '添加', + style: TextStyle( + fontSize: 12, + color: hasText ? const Color(0xFF1A1A1A) : const Color(0xFFCCCCCC), + ), + ), + ], + ), + ), + ); + }, + ), + ], + ), + const SizedBox(height: 8), + // 第二行:已选标签 + 输入框 + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + ...values.asMap().entries.map((entry) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + entry.value, + style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: () => onRemove(entry.key), + child: const Icon(Icons.close, size: 14, color: Color(0xFF999999)), + ), + ], + ), + ); + }), + // 输入框 + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 100, maxWidth: 150), + child: TextField( + controller: controller, + style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)), + decoration: InputDecoration( + hintText: values.isEmpty ? hint : '', + hintStyle: const TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 5), + ), + onSubmitted: (value) { + final trimmed = value.trim(); + if (trimmed.isNotEmpty && !values.contains(trimmed)) { + onAdd(trimmed); + controller.clear(); + } + }, + ), + ), + ], + ), + ], + ); + } + + /// 构建分隔线 + Widget _buildDivider() { + return Container( + margin: const EdgeInsets.symmetric(vertical: 16), + height: 0.5, + color: const Color(0xFFE5E5E5), + ); + } + + /// 构建状态选择 Chip + Widget _buildStatusChip(String label, String value) { + final isSelected = _status == value; + Color color; + switch (value) { + case 'watched': + color = const Color(0xFF1A1A1A); + break; + case 'watching': + color = const Color(0xFF666666); + break; + case 'want_to_watch': + color = const Color(0xFF999999); + break; + default: + color = const Color(0xFFCCCCCC); + } + + return GestureDetector( + onTap: () => setState(() => _status = value), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: isSelected ? color : Colors.transparent, + border: Border.all(color: color), + ), + child: Text( + label, + style: TextStyle( + fontSize: 13, + color: isSelected ? Colors.white : color, + ), + ), ), ); } @@ -255,8 +494,8 @@ class _MovieFormPageState extends State { return GestureDetector( onTap: _pickCover, child: Container( - width: 120, - height: 160, + width: 140, + height: 200, decoration: BoxDecoration( color: const Color(0xFFF5F5F5), border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5), @@ -278,14 +517,14 @@ class _MovieFormPageState extends State { children: [ Icon( Icons.add_photo_alternate_outlined, - size: 32, + size: 40, color: Color(0xFF999999), ), - SizedBox(height: 8), + SizedBox(height: 12), Text( - '添加海报', + '点击添加海报', style: TextStyle( - fontSize: 13, + fontSize: 14, color: Color(0xFF999999), ), ), @@ -293,239 +532,6 @@ class _MovieFormPageState extends State { ); } - /// 构建文本输入框 - Widget _buildTextField({ - required TextEditingController controller, - required String label, - String? hint, - int maxLines = 1, - TextInputType? keyboardType, - String? Function(String?)? validator, - }) { - return TextFormField( - controller: controller, - maxLines: maxLines, - keyboardType: keyboardType, - validator: validator, - style: const TextStyle( - fontSize: 15, - color: Color(0xFF1A1A1A), - ), - decoration: InputDecoration( - labelText: label, - hintText: hint, - labelStyle: const TextStyle( - fontSize: 14, - color: Color(0xFF666666), - ), - hintStyle: const TextStyle( - fontSize: 14, - color: Color(0xFFCCCCCC), - ), - border: const UnderlineInputBorder( - borderSide: BorderSide(color: Color(0xFFE5E5E5)), - ), - enabledBorder: const UnderlineInputBorder( - borderSide: BorderSide(color: Color(0xFFE5E5E5)), - ), - focusedBorder: const UnderlineInputBorder( - borderSide: BorderSide(color: Color(0xFF1A1A1A)), - ), - contentPadding: const EdgeInsets.symmetric(vertical: 12), - ), - ); - } - - /// 构建日期选择器 - Widget _buildDatePicker() { - return GestureDetector( - onTap: _selectReleaseDate, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: const BoxDecoration( - border: Border( - bottom: BorderSide(color: Color(0xFFE5E5E5)), - ), - ), - child: Row( - children: [ - Text( - _releaseDate != null - ? '${_releaseDate!.year}.${_releaseDate!.month.toString().padLeft(2, '0')}.${_releaseDate!.day.toString().padLeft(2, '0')}' - : '上映日期', - style: TextStyle( - fontSize: 15, - color: _releaseDate != null - ? const Color(0xFF1A1A1A) - : const Color(0xFFCCCCCC), - ), - ), - const Spacer(), - if (_releaseDate != null) - GestureDetector( - onTap: () => setState(() => _releaseDate = null), - child: const Icon( - Icons.close, - size: 16, - color: Color(0xFF999999), - ), - ), - ], - ), - ), - ); - } - - /// 构建标签输入 - Widget _buildTagInput({ - required String label, - required String hint, - required List tags, - required Function(String) onAdd, - required Function(int) onRemove, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (label.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - label, - style: const TextStyle( - fontSize: 14, - color: Color(0xFF666666), - ), - ), - ), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - ...tags.asMap().entries.map((entry) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - border: Border.all(color: const Color(0xFFE5E5E5)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - entry.value, - style: const TextStyle( - fontSize: 14, - color: Color(0xFF1A1A1A), - ), - ), - const SizedBox(width: 4), - GestureDetector( - onTap: () => onRemove(entry.key), - child: const Icon( - Icons.close, - size: 16, - color: Color(0xFF999999), - ), - ), - ], - ), - ); - }), - SizedBox( - width: 120, - child: TextField( - decoration: InputDecoration( - hintText: hint, - hintStyle: const TextStyle( - fontSize: 13, - color: Color(0xFFCCCCCC), - ), - border: const UnderlineInputBorder( - borderSide: BorderSide(color: Color(0xFFE5E5E5)), - ), - contentPadding: const EdgeInsets.symmetric(vertical: 8), - ), - style: const TextStyle( - fontSize: 14, - color: Color(0xFF1A1A1A), - ), - onSubmitted: (value) { - if (value.trim().isNotEmpty && !tags.contains(value.trim())) { - onAdd(value.trim()); - } - }, - ), - ), - ], - ), - ], - ); - } - - /// 构建状态选择器 - Widget _buildStatusSelector() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '状态', - style: TextStyle( - fontSize: 14, - color: Color(0xFF666666), - ), - ), - const SizedBox(height: 8), - Row( - children: [ - _buildStatusOption('已看', 'watched'), - const SizedBox(width: 12), - _buildStatusOption('在看', 'watching'), - const SizedBox(width: 12), - _buildStatusOption('想看', 'want_to_watch'), - ], - ), - ], - ); - } - - Widget _buildStatusOption(String label, String value) { - final isSelected = _status == value; - Color color; - switch (value) { - case 'watched': - color = const Color(0xFF1A1A1A); - break; - case 'watching': - color = const Color(0xFF666666); - break; - case 'want_to_watch': - color = const Color(0xFF999999); - break; - default: - color = const Color(0xFFCCCCCC); - } - - return GestureDetector( - onTap: () => setState(() => _status = value), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: isSelected ? color : Colors.transparent, - border: Border.all(color: color), - ), - child: Text( - label, - style: TextStyle( - fontSize: 13, - color: isSelected ? Colors.white : color, - fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal, - ), - ), - ), - ); - } - /// 选择封面 Future _pickCover() async { try { @@ -596,7 +602,6 @@ class _MovieFormPageState extends State { final now = DateTime.now(); if (widget.movie == null) { - // 添加新模式 final newMovie = Movie( id: now.millisecondsSinceEpoch.toString(), title: _titleController.text.trim(), @@ -616,7 +621,6 @@ class _MovieFormPageState extends State { await context.read().addMovie(newMovie); } else { - // 编辑现有模式 final updatedMovie = widget.movie!.copyWith( title: _titleController.text.trim(), posterPath: _posterPath, diff --git a/lib/pages/movie_posters_page.dart b/lib/pages/movie_posters_page.dart new file mode 100644 index 0000000..3b6340a --- /dev/null +++ b/lib/pages/movie_posters_page.dart @@ -0,0 +1,254 @@ +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 MoviePostersPage extends StatefulWidget { + final Movie movie; + + const MoviePostersPage({super.key, required this.movie}); + + @override + State createState() => _MoviePostersPageState(); +} + +class _MoviePostersPageState extends State { + final ImagePicker _picker = ImagePicker(); + List _posters = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadPosters(); + } + + Future _loadPosters() async { + setState(() => _isLoading = true); + final posters = await context.read().getMoviePosters(widget.movie.id); + setState(() { + _posters = posters; + _isLoading = false; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text('海报墙'), + actions: [ + IconButton( + icon: const Icon(Icons.add_photo_alternate), + onPressed: _pickPoster, + ), + const SizedBox(width: 8), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _posters.isEmpty + ? _buildEmptyState() + : _buildPosterGrid(), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.photo_library_outlined, + size: 64, + color: Color(0xFFCCCCCC), + ), + const SizedBox(height: 16), + const Text( + '暂无海报', + style: TextStyle( + fontSize: 16, + color: Color(0xFF999999), + ), + ), + const SizedBox(height: 24), + OutlinedButton( + onPressed: _pickPoster, + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF1A1A1A), + side: const BorderSide(color: Color(0xFF1A1A1A)), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + ), + child: const Text('添加海报'), + ), + ], + ), + ); + } + + Widget _buildPosterGrid() { + return GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 0.7, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemCount: _posters.length, + itemBuilder: (context, index) { + final poster = _posters[index]; + return _buildPosterItem(poster); + }, + ); + } + + Widget _buildPosterItem(MoviePoster poster) { + return GestureDetector( + onTap: () => _showPosterDetail(poster), + child: Container( + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Stack( + fit: StackFit.expand, + children: [ + // 海报图片 + Image.file( + File(poster.posterPath), + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Center( + child: Icon( + Icons.broken_image, + color: Color(0xFFCCCCCC), + ), + ), + ), + // 删除按钮 + Positioned( + top: 8, + right: 8, + child: GestureDetector( + onTap: () => _showDeleteDialog(poster), + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Colors.white, + ), + child: const Icon( + Icons.close, + size: 18, + color: Colors.red, + ), + ), + ), + ), + ], + ), + ), + ); + } + + void _showPosterDetail(MoviePoster poster) { + showDialog( + context: context, + builder: (context) => Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.all(16), + child: GestureDetector( + onTap: () => Navigator.pop(context), + child: InteractiveViewer( + minScale: 0.5, + maxScale: 3.0, + child: Image.file( + File(poster.posterPath), + fit: BoxFit.contain, + ), + ), + ), + ), + ); + } + + Future _pickPoster() async { + try { + final XFile? pickedFile = await _picker.pickImage( + source: ImageSource.gallery, + maxWidth: 1200, + maxHeight: 1800, + imageQuality: 85, + ); + + if (pickedFile != null) { + final appDir = await getApplicationDocumentsDirectory(); + final fileName = 'movie_poster_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final savedPath = path.join(appDir.path, 'movie_posters', fileName); + + final posterDir = Directory(path.join(appDir.path, 'movie_posters')); + if (!await posterDir.exists()) { + await posterDir.create(recursive: true); + } + + await File(pickedFile.path).copy(savedPath); + + final newPoster = MoviePoster( + id: DateTime.now().millisecondsSinceEpoch.toString(), + movieId: widget.movie.id, + posterPath: savedPath, + createdAt: DateTime.now(), + ); + + await context.read().addMoviePoster(newPoster); + _loadPosters(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('添加成功')), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('添加海报失败: $e')), + ); + } + } + } + + void _showDeleteDialog(MoviePoster poster) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + title: const Text('确认删除'), + content: const Text('确定要删除这张海报吗?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消', style: TextStyle(color: Color(0xFF666666))), + ), + TextButton( + onPressed: () async { + await context.read().removeMoviePoster(poster.id); + Navigator.pop(context); + _loadPosters(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已删除')), + ); + }, + child: const Text('删除', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } +} diff --git a/lib/pages/movie_review_form_page.dart b/lib/pages/movie_review_form_page.dart new file mode 100644 index 0000000..cff87e2 --- /dev/null +++ b/lib/pages/movie_review_form_page.dart @@ -0,0 +1,263 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; + +/// 添加/编辑影评页面 - 极简设计 +class MovieReviewFormPage extends StatefulWidget { + final String movieId; + final MovieReview? review; + + const MovieReviewFormPage({ + super.key, + required this.movieId, + this.review, + }); + + @override + State createState() => _MovieReviewFormPageState(); +} + +class _MovieReviewFormPageState extends State { + final _formKey = GlobalKey(); + late TextEditingController _contentController; + late TextEditingController _reviewerController; + late TextEditingController _sourceController; + late int _reviewType; + + @override + void initState() { + super.initState(); + final review = widget.review; + _contentController = TextEditingController(text: review?.content ?? ''); + _reviewerController = TextEditingController(text: review?.reviewer ?? ''); + _sourceController = TextEditingController(text: review?.source ?? ''); + _reviewType = review?.reviewType ?? 1; + } + + @override + void dispose() { + _contentController.dispose(); + _reviewerController.dispose(); + _sourceController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isEdit = widget.review != null; + + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: Text(isEdit ? '编辑影评' : '写影评'), + actions: [ + TextButton( + onPressed: _saveReview, + child: const Text( + '保存', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 8), + ], + ), + body: Form( + key: _formKey, + child: Column( + children: [ + // 顶部信息栏 + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: Color(0xFFE5E5E5), width: 0.5), + ), + ), + child: Row( + children: [ + // 类型选择 + _buildTypeSelector(), + const SizedBox(width: 16), + // 评论人 + Expanded( + child: TextField( + controller: _reviewerController, + style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)), + decoration: const InputDecoration( + hintText: '评论人', + hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + const SizedBox(width: 16), + // 来源 + SizedBox( + width: 100, + child: TextField( + controller: _sourceController, + style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)), + decoration: const InputDecoration( + hintText: '来源', + hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + ], + ), + ), + + // 评论内容区域 + Expanded( + child: TextFormField( + controller: _contentController, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + style: const TextStyle( + fontSize: 16, + color: Color(0xFF1A1A1A), + height: 1.7, + ), + decoration: const InputDecoration( + hintText: '写下你的影评...', + hintStyle: TextStyle( + fontSize: 16, + color: Color(0xFFCCCCCC), + ), + border: InputBorder.none, + contentPadding: EdgeInsets.all(16), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入评论内容'; + } + return null; + }, + ), + ), + ], + ), + ), + ); + } + + /// 构建类型选择器 + Widget _buildTypeSelector() { + return GestureDetector( + onTap: () => _showTypeSelector(), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _reviewType == 1 ? '短评' : '长评', + style: const TextStyle( + fontSize: 13, + color: Color(0xFF666666), + ), + ), + const SizedBox(width: 4), + const Icon( + Icons.arrow_drop_down, + size: 16, + color: Color(0xFF999999), + ), + ], + ), + ), + ); + } + + /// 显示类型选择 + void _showTypeSelector() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: const Text('短评'), + trailing: _reviewType == 1 + ? const Icon(Icons.check, color: Color(0xFF1A1A1A)) + : null, + onTap: () { + setState(() => _reviewType = 1); + Navigator.pop(context); + }, + ), + const Divider(height: 0.5), + ListTile( + title: const Text('长评'), + trailing: _reviewType == 2 + ? const Icon(Icons.check, color: Color(0xFF1A1A1A)) + : null, + onTap: () { + setState(() => _reviewType = 2); + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + } + + Future _saveReview() async { + if (!_formKey.currentState!.validate()) { + return; + } + + final now = DateTime.now(); + + if (widget.review == null) { + final newReview = MovieReview( + id: now.millisecondsSinceEpoch.toString(), + movieId: widget.movieId, + content: _contentController.text.trim(), + reviewer: _reviewerController.text.trim(), + source: _sourceController.text.trim(), + reviewType: _reviewType, + createdAt: now, + updatedAt: now, + ); + await context.read().addMovieReview(newReview); + } else { + final updatedReview = widget.review!.copyWith( + content: _contentController.text.trim(), + reviewer: _reviewerController.text.trim(), + source: _sourceController.text.trim(), + reviewType: _reviewType, + updatedAt: now, + ); + await context.read().updateMovieReview(updatedReview); + } + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(widget.review == null ? '添加成功' : '更新成功'), + behavior: SnackBarBehavior.floating, + ), + ); + + Navigator.pop(context); + } +} diff --git a/lib/pages/movie_reviews_page.dart b/lib/pages/movie_reviews_page.dart new file mode 100644 index 0000000..94998e6 --- /dev/null +++ b/lib/pages/movie_reviews_page.dart @@ -0,0 +1,261 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; +import 'movie_review_form_page.dart'; + +/// 影视影评列表页面 +class MovieReviewsPage extends StatefulWidget { + final Movie movie; + + const MovieReviewsPage({super.key, required this.movie}); + + @override + State createState() => _MovieReviewsPageState(); +} + +class _MovieReviewsPageState extends State { + List _reviews = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadReviews(); + } + + Future _loadReviews() async { + setState(() => _isLoading = true); + final reviews = await context.read().getMovieReviews(widget.movie.id); + setState(() { + _reviews = reviews; + _isLoading = false; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text('影评'), + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () => _navigateToAddReview(), + ), + const SizedBox(width: 8), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _reviews.isEmpty + ? _buildEmptyState() + : _buildReviewList(), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.rate_review_outlined, + size: 64, + color: Color(0xFFCCCCCC), + ), + const SizedBox(height: 16), + const Text( + '暂无影评', + style: TextStyle( + fontSize: 16, + color: Color(0xFF999999), + ), + ), + const SizedBox(height: 24), + OutlinedButton( + onPressed: () => _navigateToAddReview(), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF1A1A1A), + side: const BorderSide(color: Color(0xFF1A1A1A)), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + ), + child: const Text('写影评'), + ), + ], + ), + ); + } + + Widget _buildReviewList() { + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _reviews.length, + itemBuilder: (context, index) { + final review = _reviews[index]; + return _buildReviewItem(review); + }, + ); + } + + Widget _buildReviewItem(MovieReview review) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 头部:类型标签 + 操作按钮 + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: review.reviewType == 1 + ? const Color(0xFFF5F5F5) + : const Color(0xFF1A1A1A), + ), + child: Text( + review.typeText, + style: TextStyle( + fontSize: 11, + color: review.reviewType == 1 + ? const Color(0xFF666666) + : Colors.white, + ), + ), + ), + const Spacer(), + // 编辑按钮 + GestureDetector( + onTap: () => _navigateToEditReview(review), + child: const Icon( + Icons.edit_outlined, + size: 18, + color: Color(0xFF999999), + ), + ), + const SizedBox(width: 16), + // 删除按钮 + GestureDetector( + onTap: () => _showDeleteDialog(review), + child: const Icon( + Icons.delete_outline, + size: 18, + color: Colors.red, + ), + ), + ], + ), + + const SizedBox(height: 12), + + // 评论内容 + Text( + review.content, + style: const TextStyle( + fontSize: 15, + color: Color(0xFF1A1A1A), + height: 1.6, + ), + ), + + const SizedBox(height: 12), + + // 底部信息 + Row( + children: [ + if (review.reviewer.isNotEmpty) ...[ + Text( + review.reviewer, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF666666), + ), + ), + const SizedBox(width: 8), + ], + if (review.source.isNotEmpty) ...[ + Text( + '来源:${review.source}', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF999999), + ), + ), + const SizedBox(width: 8), + ], + const Spacer(), + Text( + _formatDate(review.createdAt), + style: const TextStyle( + fontSize: 12, + color: Color(0xFF999999), + ), + ), + ], + ), + ], + ), + ); + } + + String _formatDate(DateTime date) { + return '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}'; + } + + void _navigateToAddReview() { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MovieReviewFormPage(movieId: widget.movie.id), + ), + ).then((_) => _loadReviews()); + } + + void _navigateToEditReview(MovieReview review) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MovieReviewFormPage( + movieId: widget.movie.id, + review: review, + ), + ), + ).then((_) => _loadReviews()); + } + + void _showDeleteDialog(MovieReview review) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + title: const Text('确认删除'), + content: const Text('确定要删除这条影评吗?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消', style: TextStyle(color: Color(0xFF666666))), + ), + TextButton( + onPressed: () async { + await context.read().removeMovieReview(review.id); + Navigator.pop(context); + _loadReviews(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已删除')), + ); + }, + child: const Text('删除', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } +} diff --git a/lib/pages/note_detail_page.dart b/lib/pages/note_detail_page.dart index be3f1e0..21621b7 100644 --- a/lib/pages/note_detail_page.dart +++ b/lib/pages/note_detail_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; import '../models/data_models.dart'; @@ -21,6 +22,30 @@ class _NoteDetailPageState extends State { appBar: AppBar( title: Text(_formatDateTime(widget.note.createdAt)), actions: [ + // 格式指示器 + if (widget.note.contentType == 'markdown') + Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.code, size: 14, color: Color(0xFF666666)), + SizedBox(width: 4), + Text( + 'Markdown', + style: TextStyle( + fontSize: 12, + color: Color(0xFF666666), + ), + ), + ], + ), + ), IconButton( icon: const Icon(Icons.edit_outlined), onPressed: () => _navigateToEdit(context), @@ -67,17 +92,9 @@ class _NoteDetailPageState extends State { // 内容区域 Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Text( - widget.note.content, - style: const TextStyle( - fontSize: 16, - color: Color(0xFF1A1A1A), - height: 1.8, - ), - ), - ), + child: widget.note.contentType == 'markdown' + ? _buildMarkdownContent() + : _buildPlainTextContent(), ), // 底部操作栏 @@ -129,6 +146,82 @@ class _NoteDetailPageState extends State { ); } + /// 构建 Markdown 内容 + Widget _buildMarkdownContent() { + return Markdown( + data: widget.note.content, + styleSheet: MarkdownStyleSheet( + h1: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A), + height: 1.4, + ), + h2: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A), + height: 1.4, + ), + h3: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A), + height: 1.4, + ), + p: const TextStyle( + fontSize: 16, + color: Color(0xFF1A1A1A), + height: 1.8, + ), + code: const TextStyle( + fontSize: 14, + color: Color(0xFF1A1A1A), + backgroundColor: Color(0xFFF5F5F5), + ), + codeblockDecoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + blockquote: const TextStyle( + fontSize: 16, + color: Color(0xFF666666), + fontStyle: FontStyle.italic, + ), + blockquoteDecoration: BoxDecoration( + border: Border( + left: BorderSide(color: const Color(0xFF999999), width: 4), + ), + ), + listBullet: const TextStyle( + fontSize: 16, + color: Color(0xFF1A1A1A), + ), + a: const TextStyle( + fontSize: 16, + color: Color(0xFF1A1A1A), + decoration: TextDecoration.underline, + ), + ), + padding: const EdgeInsets.all(24), + ); + } + + /// 构建纯文本内容 + Widget _buildPlainTextContent() { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Text( + widget.note.content, + style: const TextStyle( + fontSize: 16, + color: Color(0xFF1A1A1A), + height: 1.8, + ), + ), + ); + } + /// 格式化日期时间 String _formatDateTime(DateTime dateTime) { return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; diff --git a/lib/pages/note_form_page.dart b/lib/pages/note_form_page.dart index 2448070..1d91bda 100644 --- a/lib/pages/note_form_page.dart +++ b/lib/pages/note_form_page.dart @@ -17,6 +17,7 @@ class _NoteFormPageState extends State { late TextEditingController _contentController; late DateTime _createdAt; List _tags = []; + String _contentType = 'markdown'; // markdown / rich_text bool _isEditing = false; @override @@ -26,6 +27,7 @@ class _NoteFormPageState extends State { _contentController = TextEditingController(text: note?.content ?? ''); _createdAt = note?.createdAt ?? DateTime.now(); _tags = note != null ? List.from(note.tags) : []; + _contentType = note?.contentType ?? 'markdown'; _isEditing = note != null; } @@ -57,7 +59,7 @@ class _NoteFormPageState extends State { ), body: Column( children: [ - // 顶部信息栏:创建时间 + 标签 + // 顶部信息栏:创建时间 + 格式选择 + 标签 Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: const BoxDecoration( @@ -65,21 +67,27 @@ class _NoteFormPageState extends State { bottom: BorderSide(color: Color(0xFFE5E5E5), width: 0.5), ), ), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 创建时间 - Text( - _formatDateTime(_createdAt), - style: const TextStyle( - fontSize: 12, - color: Color(0xFF999999), - ), + Row( + children: [ + // 创建时间 + Text( + _formatDateTime(_createdAt), + style: const TextStyle( + fontSize: 12, + color: Color(0xFF999999), + ), + ), + const Spacer(), + // 格式选择 + _buildFormatSelector(), + ], ), - const SizedBox(width: 16), + const SizedBox(height: 8), // 标签 - Expanded( - child: _buildTagSelector(), - ), + _buildTagSelector(), ], ), ), @@ -96,14 +104,14 @@ class _NoteFormPageState extends State { color: Color(0xFF1A1A1A), height: 1.6, ), - decoration: const InputDecoration( - hintText: '开始书写...', - hintStyle: TextStyle( + decoration: InputDecoration( + hintText: _contentType == 'markdown' ? '使用 Markdown 格式书写...' : '开始书写...', + hintStyle: const TextStyle( fontSize: 16, color: Color(0xFFCCCCCC), ), border: InputBorder.none, - contentPadding: EdgeInsets.all(16), + contentPadding: const EdgeInsets.all(16), ), ), ), @@ -112,6 +120,84 @@ class _NoteFormPageState extends State { ); } + /// 构建格式选择器 + Widget _buildFormatSelector() { + return GestureDetector( + onTap: () => _showFormatSelector(), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _contentType == 'markdown' ? Icons.code : Icons.text_fields, + size: 14, + color: const Color(0xFF666666), + ), + const SizedBox(width: 4), + Text( + _contentType == 'markdown' ? 'Markdown' : '富文本', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF666666), + ), + ), + const SizedBox(width: 4), + const Icon( + Icons.arrow_drop_down, + size: 16, + color: Color(0xFF999999), + ), + ], + ), + ), + ); + } + + /// 显示格式选择对话框 + void _showFormatSelector() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.code, size: 20), + title: const Text('Markdown'), + subtitle: const Text('支持 Markdown 语法'), + trailing: _contentType == 'markdown' + ? const Icon(Icons.check, color: Color(0xFF1A1A1A)) + : null, + onTap: () { + setState(() => _contentType = 'markdown'); + Navigator.pop(context); + }, + ), + const Divider(height: 0.5, indent: 56), + ListTile( + leading: const Icon(Icons.text_fields, size: 20), + title: const Text('纯文本'), + subtitle: const Text('普通文本格式'), + trailing: _contentType == 'rich_text' + ? const Icon(Icons.check, color: Color(0xFF1A1A1A)) + : null, + onTap: () { + setState(() => _contentType = 'rich_text'); + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + } + /// 构建标签选择器 Widget _buildTagSelector() { return Wrap( @@ -257,6 +343,7 @@ class _NoteFormPageState extends State { // 更新现有笔记 final updatedNote = widget.note!.copyWith( content: content, + contentType: _contentType, tags: _tags, updatedAt: now, ); @@ -266,6 +353,7 @@ class _NoteFormPageState extends State { final newNote = Note( id: now.millisecondsSinceEpoch.toString(), content: content, + contentType: _contentType, tags: _tags, createdAt: _createdAt, updatedAt: now, diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index 036ee17..b0487f4 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -20,8 +20,8 @@ class _ProfilePageState extends State { final UserPrefs _userPrefs = UserPrefs(); // 用户数据 - String _nickname = '记录者'; - String _motto = '记录生活,沉淀思考'; + String _nickname = 'Mook'; + String _motto = '好运不会眷顾一无所有之人。'; String? _avatarPath; bool _isLoading = true; @@ -384,20 +384,7 @@ class _ProfilePageState extends State { ), const Divider(height: 0.5, thickness: 0.5, indent: 56, color: Color(0xFFE5E5E5)), - _buildMenuItem( - icon: Icons.calendar_today_outlined, - title: '记录日历', - onTap: () => _showToast('日历功能开发中'), - ), - const Divider(height: 0.5, thickness: 0.5, indent: 56, color: Color(0xFFE5E5E5)), - - _buildMenuItem( - icon: Icons.favorite_outline, - title: '我的收藏', - onTap: () => _showToast('收藏功能开发中'), - ), - const Divider(height: 0.5, thickness: 0.5, indent: 56, color: Color(0xFFE5E5E5)), - + _buildMenuItem( icon: Icons.delete_outline, title: '回收站', diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 83476bc..1584e4f 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -3,6 +3,8 @@ import '../models/data_models.dart'; import '../utils/movie_dao.dart'; import '../utils/book_dao.dart'; import '../utils/note_dao.dart'; +import '../utils/movie_review_dao.dart'; +import '../utils/movie_poster_dao.dart'; /// 应用全局状态管理 class AppProvider extends ChangeNotifier { @@ -10,6 +12,8 @@ class AppProvider extends ChangeNotifier { final MovieDao _movieDao = MovieDao(); final BookDao _bookDao = BookDao(); final NoteDao _noteDao = NoteDao(); + final MovieReviewDao _reviewDao = MovieReviewDao(); + final MoviePosterDao _posterDao = MoviePosterDao(); // 数据列表 List _movies = []; @@ -160,4 +164,53 @@ class AppProvider extends ChangeNotifier { await _noteDao.deleteNote(id); await loadNotes(); } + + // ========== 影评相关方法 ========== + + /// 获取影视的所有影评 + Future> getMovieReviews(String movieId) async { + return await _reviewDao.getReviewsByMovieId(movieId); + } + + /// 添加影评 + Future addMovieReview(MovieReview review) async { + await _reviewDao.insertReview(review); + } + + /// 更新影评 + Future updateMovieReview(MovieReview review) async { + await _reviewDao.updateReview(review); + } + + /// 删除影评 + Future removeMovieReview(String id) async { + await _reviewDao.deleteReview(id); + } + + /// 获取影视的影评数量 + Future getMovieReviewCount(String movieId) async { + return await _reviewDao.getReviewCount(movieId); + } + + // ========== 海报墙相关方法 ========== + + /// 获取影视的所有海报 + Future> getMoviePosters(String movieId) async { + return await _posterDao.getPostersByMovieId(movieId); + } + + /// 添加海报 + Future addMoviePoster(MoviePoster poster) async { + await _posterDao.insertPoster(poster); + } + + /// 删除海报 + Future removeMoviePoster(String id) async { + await _posterDao.deletePoster(id); + } + + /// 获取影视的海报数量 + Future getMoviePosterCount(String movieId) async { + return await _posterDao.getPosterCount(movieId); + } } diff --git a/lib/utils/database_helper.dart b/lib/utils/database_helper.dart index a7e1b41..ad3b420 100644 --- a/lib/utils/database_helper.dart +++ b/lib/utils/database_helper.dart @@ -20,7 +20,7 @@ class DatabaseHelper { return await openDatabase( path, - version: 4, + version: 5, onCreate: _createDB, onUpgrade: _onUpgrade, ); @@ -40,6 +40,43 @@ class DatabaseHelper { // 升级notes表结构 await _upgradeNotesTableV4(db); } + if (oldVersion < 5) { + // 创建影评表和海报墙表 + await _createMovieReviewsTable(db); + await _createMoviePostersTable(db); + } + } + + /// 创建影评表 + Future _createMovieReviewsTable(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS movie_reviews ( + id TEXT PRIMARY KEY, + movie_id TEXT NOT NULL, + content TEXT NOT NULL, + reviewer TEXT, + source TEXT, + review_type INTEGER DEFAULT 1, + is_deleted INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (movie_id) REFERENCES movies (id) + ) + '''); + } + + /// 创建影视海报墙表 + Future _createMoviePostersTable(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS movie_posters ( + id TEXT PRIMARY KEY, + movie_id TEXT NOT NULL, + poster_path TEXT NOT NULL, + is_deleted INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + FOREIGN KEY (movie_id) REFERENCES movies (id) + ) + '''); } /// 升级notes表到V4 @@ -251,6 +288,34 @@ class DatabaseHelper { updated_at TEXT NOT NULL ) '''); + + // 影评表 + await db.execute(''' + CREATE TABLE movie_reviews ( + id TEXT PRIMARY KEY, + movie_id TEXT NOT NULL, + content TEXT NOT NULL, + reviewer TEXT, + source TEXT, + review_type INTEGER DEFAULT 1, + is_deleted INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (movie_id) REFERENCES movies (id) + ) + '''); + + // 影视海报墙表 + await db.execute(''' + CREATE TABLE movie_posters ( + id TEXT PRIMARY KEY, + movie_id TEXT NOT NULL, + poster_path TEXT NOT NULL, + is_deleted INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + FOREIGN KEY (movie_id) REFERENCES movies (id) + ) + '''); } // 关闭数据库 diff --git a/lib/utils/movie_poster_dao.dart b/lib/utils/movie_poster_dao.dart new file mode 100644 index 0000000..75af9e6 --- /dev/null +++ b/lib/utils/movie_poster_dao.dart @@ -0,0 +1,61 @@ +import 'package:sqflite/sqflite.dart'; +import '../models/data_models.dart'; +import 'database_helper.dart'; + +/// 影视海报墙数据访问对象 +class MoviePosterDao { + final DatabaseHelper _dbHelper = DatabaseHelper.instance; + + /// 获取影视的所有海报 + Future> getPostersByMovieId(String movieId) async { + final db = await _dbHelper.database; + final List> maps = await db.query( + 'movie_posters', + where: 'movie_id = ? AND is_deleted = 0', + whereArgs: [movieId], + orderBy: 'created_at DESC', + ); + + return List.generate(maps.length, (i) => MoviePoster.fromJson(maps[i])); + } + + /// 根据ID获取海报 + Future getPosterById(String id) async { + final db = await _dbHelper.database; + final List> maps = await db.query( + 'movie_posters', + where: 'id = ? AND is_deleted = 0', + whereArgs: [id], + ); + + if (maps.isEmpty) return null; + return MoviePoster.fromJson(maps.first); + } + + /// 添加海报 + Future insertPoster(MoviePoster poster) async { + final db = await _dbHelper.database; + return await db.insert('movie_posters', poster.toJson()); + } + + /// 软删除海报 + Future deletePoster(String id) async { + final db = await _dbHelper.database; + return await db.update( + 'movie_posters', + {'is_deleted': 1}, + where: 'id = ?', + whereArgs: [id], + ); + } + + /// 获取影视的海报数量 + Future getPosterCount(String movieId) async { + final db = await _dbHelper.database; + final result = await db.rawQuery( + 'SELECT COUNT(*) as count FROM movie_posters WHERE movie_id = ? AND is_deleted = 0', + [movieId], + ); + return result.first['count'] as int; + } +} diff --git a/lib/utils/movie_review_dao.dart b/lib/utils/movie_review_dao.dart new file mode 100644 index 0000000..a324a45 --- /dev/null +++ b/lib/utils/movie_review_dao.dart @@ -0,0 +1,98 @@ +import 'package:sqflite/sqflite.dart'; +import '../models/data_models.dart'; +import 'database_helper.dart'; + +/// 影评数据访问对象 +class MovieReviewDao { + final DatabaseHelper _dbHelper = DatabaseHelper.instance; + + /// 获取影视的所有影评 + Future> getReviewsByMovieId(String movieId) async { + final db = await _dbHelper.database; + final List> maps = await db.query( + 'movie_reviews', + where: 'movie_id = ? AND is_deleted = 0', + whereArgs: [movieId], + orderBy: 'created_at DESC', + ); + + return List.generate(maps.length, (i) => MovieReview.fromJson(maps[i])); + } + + /// 根据ID获取影评 + Future getReviewById(String id) async { + final db = await _dbHelper.database; + final List> maps = await db.query( + 'movie_reviews', + where: 'id = ? AND is_deleted = 0', + whereArgs: [id], + ); + + if (maps.isEmpty) return null; + return MovieReview.fromJson(maps.first); + } + + /// 添加影评 + Future insertReview(MovieReview review) async { + final db = await _dbHelper.database; + return await db.insert('movie_reviews', review.toJson()); + } + + /// 更新影评 + Future updateReview(MovieReview review) async { + final db = await _dbHelper.database; + return await db.update( + 'movie_reviews', + review.toJson(), + where: 'id = ?', + whereArgs: [review.id], + ); + } + + /// 软删除影评 + Future deleteReview(String id) async { + final db = await _dbHelper.database; + return await db.update( + 'movie_reviews', + {'is_deleted': 1}, + where: 'id = ?', + whereArgs: [id], + ); + } + + /// 获取影视的影评数量 + Future getReviewCount(String movieId) async { + final db = await _dbHelper.database; + final result = await db.rawQuery( + 'SELECT COUNT(*) as count FROM movie_reviews WHERE movie_id = ? AND is_deleted = 0', + [movieId], + ); + return result.first['count'] as int; + } + + /// 获取短评列表 + Future> getShortReviews(String movieId) async { + final db = await _dbHelper.database; + final List> maps = await db.query( + 'movie_reviews', + where: 'movie_id = ? AND review_type = 1 AND is_deleted = 0', + whereArgs: [movieId], + orderBy: 'created_at DESC', + ); + + return List.generate(maps.length, (i) => MovieReview.fromJson(maps[i])); + } + + /// 获取长评列表 + Future> getLongReviews(String movieId) async { + final db = await _dbHelper.database; + final List> maps = await db.query( + 'movie_reviews', + where: 'movie_id = ? AND review_type = 2 AND is_deleted = 0', + whereArgs: [movieId], + orderBy: 'created_at DESC', + ); + + return List.generate(maps.length, (i) => MovieReview.fromJson(maps[i])); + } +} diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index 3d4488c..9cfbd74 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -24,11 +24,11 @@ class UserPrefs { // ========== 用户信息 ========== /// 昵称 - String get nickname => prefs.getString('nickname') ?? '记录者'; + String get nickname => prefs.getString('nickname') ?? 'Mook'; Future setNickname(String value) => prefs.setString('nickname', value); /// 座右铭 - String get motto => prefs.getString('motto') ?? '记录生活,沉淀思考'; + String get motto => prefs.getString('motto') ?? '好运不会眷顾一无所有之人。'; Future setMotto(String value) => prefs.setString('motto', value); /// 头像路径 diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index aee8cd3..e78d519 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -24,15 +24,6 @@ class CustomDrawer extends StatelessWidget { child: ListView( padding: EdgeInsets.zero, children: [ - _buildMenuItem( - icon: Icons.analytics_outlined, - title: '统计', - onTap: () { - Navigator.pop(context); - _showToast(context, '统计功能开发中'); - }, - ), - const Divider(height: 0.5, thickness: 0.5, indent: 56, color: Color(0xFFE5E5E5)), _buildMenuItem( icon: Icons.delete_outline, diff --git a/pubspec.lock b/pubspec.lock index 704e117..bdc3161 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -158,6 +166,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + flutter_markdown: + dependency: "direct main" + description: + name: flutter_markdown + sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27" + url: "https://pub.dev" + source: hosted + version: "0.7.7+1" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -312,6 +328,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + url: "https://pub.dev" + source: hosted + version: "7.3.0" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ed5469c..0406263 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: image_picker: ^1.0.4 path_provider: ^2.1.1 shared_preferences: ^2.2.2 + flutter_markdown: ^0.7.4 dev_dependencies: flutter_test: