新增影评海报功能

This commit is contained in:
DelLevin-Home
2026-03-06 16:09:56 +08:00
parent 17de976d4b
commit f2ab6a17ba
18 changed files with 2260 additions and 698 deletions

View File

@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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);
}
}

View File

@@ -7,7 +7,7 @@ import 'package:provider/provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../models/data_models.dart'; import '../models/data_models.dart';
/// 添加/编辑书籍页面 - 极简主义设计 /// 添加/编辑书籍页面 - 紧凑双行布局设计
class BookFormPage extends StatefulWidget { class BookFormPage extends StatefulWidget {
final Book? book; final Book? book;
@@ -21,11 +21,16 @@ class _BookFormPageState extends State<BookFormPage> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
// 输入框控制器
late TextEditingController _titleController; late TextEditingController _titleController;
late TextEditingController _publisherController; late TextEditingController _publisherController;
late TextEditingController _summaryController; late TextEditingController _summaryController;
late TextEditingController _ratingController; late TextEditingController _ratingController;
// 多值字段的临时输入控制器
final Map<String, TextEditingController> _tagControllers = {};
// 数据
List<String> _authors = []; List<String> _authors = [];
List<String> _alternateTitles = []; List<String> _alternateTitles = [];
List<String> _genres = []; List<String> _genres = [];
@@ -56,9 +61,14 @@ class _BookFormPageState extends State<BookFormPage> {
_publisherController.dispose(); _publisherController.dispose();
_summaryController.dispose(); _summaryController.dispose();
_ratingController.dispose(); _ratingController.dispose();
_tagControllers.values.forEach((c) => c.dispose());
super.dispose(); super.dispose();
} }
TextEditingController _getTagController(String key) {
return _tagControllers.putIfAbsent(key, () => TextEditingController());
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isEdit = widget.book != null; final isEdit = widget.book != null;
@@ -84,123 +94,159 @@ class _BookFormPageState extends State<BookFormPage> {
body: Form( body: Form(
key: _formKey, key: _formKey,
child: ListView( child: ListView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
children: [ children: [
// 封面选择 // 封面选择 - 居中显示
_buildCoverPicker(), Center(child: _buildCoverPicker()),
const SizedBox(height: 32), const SizedBox(height: 32),
// 基本信息 // 基本信息区域
_buildSectionTitle('基本信息'), _buildFormItem(
const SizedBox(height: 16),
// 书名
_buildTextField(
controller: _titleController,
label: '书名 *', label: '书名 *',
hint: '请输入书名', child: TextFormField(
validator: (value) { controller: _titleController,
if (value == null || value.trim().isEmpty) { style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)),
return '请输入书名'; decoration: const InputDecoration(
} hintText: '请输入书名',
return null; 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: '别名', label: '别名',
hint: '输入别名,按回车添加', values: _alternateTitles,
tags: _alternateTitles, hint: '输入别名',
onAdd: (tag) => setState(() => _alternateTitles.add(tag)), controllerKey: 'alternateTitles',
onRemove: (index) => setState(() => _alternateTitles.removeAt(index)), onAdd: (v) => setState(() => _alternateTitles.add(v)),
onRemove: (i) => setState(() => _alternateTitles.removeAt(i)),
), ),
const SizedBox(height: 24), _buildDivider(),
// 作者 // 作者
_buildSectionTitle('作者'), _buildMultiValueItem(
const SizedBox(height: 16),
_buildTagInput(
label: '作者', label: '作者',
hint: '输入作者,按回车添加', values: _authors,
tags: _authors, hint: '输入作者姓名',
onAdd: (tag) => setState(() => _authors.add(tag)), controllerKey: 'authors',
onRemove: (index) => setState(() => _authors.removeAt(index)), onAdd: (v) => setState(() => _authors.add(v)),
onRemove: (i) => setState(() => _authors.removeAt(i)),
), ),
const SizedBox(height: 24), _buildDivider(),
// 出版社 // 出版社
_buildTextField( _buildFormItem(
controller: _publisherController,
label: '出版社', 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('类型'), _buildMultiValueItem(
const SizedBox(height: 16),
_buildTagInput(
label: '类型', label: '类型',
hint: '输入类型,按回车添加', values: _genres,
tags: _genres, hint: '如:小说、历史',
onAdd: (tag) => setState(() => _genres.add(tag)), controllerKey: 'genres',
onRemove: (index) => setState(() => _genres.removeAt(index)), onAdd: (v) => setState(() => _genres.add(v)),
onRemove: (i) => setState(() => _genres.removeAt(i)),
), ),
const SizedBox(height: 24), _buildDivider(),
// 书籍简介 // 书籍简介
_buildSectionTitle('书籍简介'), _buildFormItem(
const SizedBox(height: 16), label: '书籍简介',
child: TextFormField(
_buildTextField( controller: _summaryController,
controller: _summaryController, maxLines: 4,
label: '', style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A), height: 1.5),
hint: '写下书籍简介...', decoration: const InputDecoration(
maxLines: 5, hintText: '写下书籍简介...',
hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
),
), ),
const SizedBox(height: 24), _buildDivider(),
// 评分和状态 // 评分
_buildSectionTitle('评分与状态'), _buildFormItem(
const SizedBox(height: 16), label: '评分',
child: Row(
Row( children: [
children: [ Expanded(
Expanded( child: TextFormField(
flex: 2, controller: _ratingController,
child: _buildTextField( keyboardType: const TextInputType.numberWithOptions(decimal: true),
controller: _ratingController, style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)),
label: '评分', decoration: const InputDecoration(
hint: '1-10', hintText: '1-10',
keyboardType: const TextInputType.numberWithOptions(decimal: true), hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)),
validator: (value) { border: InputBorder.none,
if (value != null && value.isNotEmpty) { contentPadding: EdgeInsets.zero,
final rating = double.tryParse(value); ),
if (rating == null || rating < 1 || rating > 10) { validator: (value) {
return '评分必须在 1-10 之间'; 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), const SizedBox(height: 48),
@@ -210,15 +256,193 @@ class _BookFormPageState extends State<BookFormPage> {
); );
} }
/// 构建区块标题 /// 构建表单条目(标签 + 内容)
Widget _buildSectionTitle(String title) { Widget _buildFormItem({required String label, required Widget child}) {
return Text( return Column(
title.toUpperCase(), crossAxisAlignment: CrossAxisAlignment.start,
style: const TextStyle( children: [
fontSize: 11, Text(
fontWeight: FontWeight.w600, label,
color: Color(0xFF999999), style: TextStyle(
letterSpacing: 1, 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<String> 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<TextEditingValue>(
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<BookFormPage> {
return GestureDetector( return GestureDetector(
onTap: _pickCover, onTap: _pickCover,
child: Container( child: Container(
width: 120, width: 140,
height: 160, height: 200,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF5F5F5), color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5), border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5),
@@ -251,14 +475,14 @@ class _BookFormPageState extends State<BookFormPage> {
children: [ children: [
Icon( Icon(
Icons.add_photo_alternate_outlined, Icons.add_photo_alternate_outlined,
size: 32, size: 40,
color: Color(0xFF999999), color: Color(0xFF999999),
), ),
SizedBox(height: 8), SizedBox(height: 12),
Text( Text(
'添加封面', '点击添加封面',
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 14,
color: Color(0xFF999999), color: Color(0xFF999999),
), ),
), ),
@@ -266,199 +490,6 @@ class _BookFormPageState extends State<BookFormPage> {
); );
} }
/// 构建文本输入框
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<String> 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<void> _pickCover() async { Future<void> _pickCover() async {
try { try {
@@ -505,7 +536,6 @@ class _BookFormPageState extends State<BookFormPage> {
final now = DateTime.now(); final now = DateTime.now();
if (widget.book == null) { if (widget.book == null) {
// 添加新模式
final newBook = Book( final newBook = Book(
id: now.millisecondsSinceEpoch.toString(), id: now.millisecondsSinceEpoch.toString(),
title: _titleController.text.trim(), title: _titleController.text.trim(),
@@ -523,7 +553,6 @@ class _BookFormPageState extends State<BookFormPage> {
await context.read<AppProvider>().addBook(newBook); await context.read<AppProvider>().addBook(newBook);
} else { } else {
// 编辑现有模式
final updatedBook = widget.book!.copyWith( final updatedBook = widget.book!.copyWith(
title: _titleController.text.trim(), title: _titleController.text.trim(),
coverPath: _coverPath, coverPath: _coverPath,

View File

@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../models/data_models.dart'; import '../models/data_models.dart';
import 'movie_reviews_page.dart';
import 'movie_posters_page.dart';
/// 影视详情页 - 极简主义设计 /// 影视详情页 - 极简主义设计
class MovieDetailPage extends StatefulWidget { class MovieDetailPage extends StatefulWidget {
@@ -60,6 +62,11 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
if (widget.movie.alternateTitles.isNotEmpty) if (widget.movie.alternateTitles.isNotEmpty)
_buildAlternateTitlesSection(), _buildAlternateTitlesSection(),
const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
// 影评和海报墙入口
_buildExtraSections(),
const SizedBox(height: 48), const SizedBox(height: 48),
], ],
), ),
@@ -470,6 +477,153 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
); );
} }
/// 构建额外功能区域(影评、海报墙)
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<int>(
future: context.read<AppProvider>().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<int>(
future: context.read<AppProvider>().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() { Widget _buildBottomBar() {
return Container( return Container(

View File

@@ -7,7 +7,7 @@ import 'package:provider/provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../models/data_models.dart'; import '../models/data_models.dart';
/// 添加/编辑影视页面 - 极简主义设计 /// 添加/编辑影视页面 - 紧凑双行布局设计
class MovieFormPage extends StatefulWidget { class MovieFormPage extends StatefulWidget {
final Movie? movie; final Movie? movie;
@@ -21,10 +21,15 @@ class _MovieFormPageState extends State<MovieFormPage> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
// 输入框控制器
late TextEditingController _titleController; late TextEditingController _titleController;
late TextEditingController _summaryController; late TextEditingController _summaryController;
late TextEditingController _ratingController; late TextEditingController _ratingController;
// 多值字段的临时输入控制器
final Map<String, TextEditingController> _tagControllers = {};
// 数据
List<String> _directors = []; List<String> _directors = [];
List<String> _writers = []; List<String> _writers = [];
List<String> _actors = []; List<String> _actors = [];
@@ -59,9 +64,14 @@ class _MovieFormPageState extends State<MovieFormPage> {
_titleController.dispose(); _titleController.dispose();
_summaryController.dispose(); _summaryController.dispose();
_ratingController.dispose(); _ratingController.dispose();
_tagControllers.values.forEach((c) => c.dispose());
super.dispose(); super.dispose();
} }
TextEditingController _getTagController(String key) {
return _tagControllers.putIfAbsent(key, () => TextEditingController());
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isEdit = widget.movie != null; final isEdit = widget.movie != null;
@@ -87,147 +97,198 @@ class _MovieFormPageState extends State<MovieFormPage> {
body: Form( body: Form(
key: _formKey, key: _formKey,
child: ListView( child: ListView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
children: [ children: [
// 封面选择 // 封面选择 - 居中显示
_buildCoverPicker(), Center(child: _buildCoverPicker()),
const SizedBox(height: 32), const SizedBox(height: 32),
// 基本信息 // 基本信息区域
_buildSectionTitle('基本信息'), _buildFormItem(
const SizedBox(height: 16),
// 影视名称
_buildTextField(
controller: _titleController,
label: '影视名称 *', label: '影视名称 *',
hint: '请输入影视名称', child: TextFormField(
validator: (value) { controller: _titleController,
if (value == null || value.trim().isEmpty) { style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)),
return '请输入影视名称'; decoration: const InputDecoration(
} hintText: '请输入影视名称',
return null; 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: '别名', label: '别名',
hint: '输入别名,按回车添加', values: _alternateTitles,
tags: _alternateTitles, hint: '输入别名',
onAdd: (tag) => setState(() => _alternateTitles.add(tag)), controllerKey: 'alternateTitles',
onRemove: (index) => setState(() => _alternateTitles.removeAt(index)), 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('导演'), _buildMultiValueItem(
const SizedBox(height: 16),
_buildTagInput(
label: '导演', label: '导演',
hint: '输入导演,按回车添加', values: _directors,
tags: _directors, hint: '输入导演姓名',
onAdd: (tag) => setState(() => _directors.add(tag)), controllerKey: 'directors',
onRemove: (index) => setState(() => _directors.removeAt(index)), onAdd: (v) => setState(() => _directors.add(v)),
onRemove: (i) => setState(() => _directors.removeAt(i)),
), ),
const SizedBox(height: 24), _buildDivider(),
// 编剧 // 编剧
_buildSectionTitle('编剧'), _buildMultiValueItem(
const SizedBox(height: 16),
_buildTagInput(
label: '编剧', label: '编剧',
hint: '输入编剧,按回车添加', values: _writers,
tags: _writers, hint: '输入编剧姓名',
onAdd: (tag) => setState(() => _writers.add(tag)), controllerKey: 'writers',
onRemove: (index) => setState(() => _writers.removeAt(index)), onAdd: (v) => setState(() => _writers.add(v)),
onRemove: (i) => setState(() => _writers.removeAt(i)),
), ),
const SizedBox(height: 24), _buildDivider(),
// 主演 // 主演
_buildSectionTitle('主演'), _buildMultiValueItem(
const SizedBox(height: 16),
_buildTagInput(
label: '主演', label: '主演',
hint: '输入主演,按回车添加', values: _actors,
tags: _actors, hint: '输入主演姓名',
onAdd: (tag) => setState(() => _actors.add(tag)), controllerKey: 'actors',
onRemove: (index) => setState(() => _actors.removeAt(index)), onAdd: (v) => setState(() => _actors.add(v)),
onRemove: (i) => setState(() => _actors.removeAt(i)),
), ),
const SizedBox(height: 24), _buildDivider(),
// 类型 // 类型
_buildSectionTitle('类型'), _buildMultiValueItem(
const SizedBox(height: 16),
_buildTagInput(
label: '类型', label: '类型',
hint: '输入类型,按回车添加', values: _genres,
tags: _genres, hint: '如:剧情、科幻',
onAdd: (tag) => setState(() => _genres.add(tag)), controllerKey: 'genres',
onRemove: (index) => setState(() => _genres.removeAt(index)), onAdd: (v) => setState(() => _genres.add(v)),
onRemove: (i) => setState(() => _genres.removeAt(i)),
), ),
const SizedBox(height: 24), _buildDivider(),
// 剧情简介 // 剧情简介
_buildSectionTitle('剧情简介'), _buildFormItem(
const SizedBox(height: 16), label: '剧情简介',
child: TextFormField(
_buildTextField( controller: _summaryController,
controller: _summaryController, maxLines: 4,
label: '', style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A), height: 1.5),
hint: '写下剧情简介...', decoration: const InputDecoration(
maxLines: 5, hintText: '写下剧情简介...',
hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
),
), ),
const SizedBox(height: 24), _buildDivider(),
// 评分和状态 // 评分
_buildSectionTitle('评分与状态'), _buildFormItem(
const SizedBox(height: 16), label: '评分',
child: Row(
Row( children: [
children: [ Expanded(
Expanded( child: TextFormField(
flex: 2, controller: _ratingController,
child: _buildTextField( keyboardType: const TextInputType.numberWithOptions(decimal: true),
controller: _ratingController, style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)),
label: '评分', decoration: const InputDecoration(
hint: '1-10', hintText: '1-10',
keyboardType: const TextInputType.numberWithOptions(decimal: true), hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)),
validator: (value) { border: InputBorder.none,
if (value != null && value.isNotEmpty) { contentPadding: EdgeInsets.zero,
final rating = double.tryParse(value); ),
if (rating == null || rating < 1 || rating > 10) { validator: (value) {
return '评分必须在 1-10 之间'; 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), const SizedBox(height: 48),
@@ -237,15 +298,193 @@ class _MovieFormPageState extends State<MovieFormPage> {
); );
} }
/// 构建区块标题 /// 构建表单条目(标签 + 内容)
Widget _buildSectionTitle(String title) { Widget _buildFormItem({required String label, required Widget child}) {
return Text( return Column(
title.toUpperCase(), crossAxisAlignment: CrossAxisAlignment.start,
style: const TextStyle( children: [
fontSize: 11, Text(
fontWeight: FontWeight.w600, label,
color: Color(0xFF999999), style: TextStyle(
letterSpacing: 1, 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<String> 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<TextEditingValue>(
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<MovieFormPage> {
return GestureDetector( return GestureDetector(
onTap: _pickCover, onTap: _pickCover,
child: Container( child: Container(
width: 120, width: 140,
height: 160, height: 200,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF5F5F5), color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5), border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5),
@@ -278,14 +517,14 @@ class _MovieFormPageState extends State<MovieFormPage> {
children: [ children: [
Icon( Icon(
Icons.add_photo_alternate_outlined, Icons.add_photo_alternate_outlined,
size: 32, size: 40,
color: Color(0xFF999999), color: Color(0xFF999999),
), ),
SizedBox(height: 8), SizedBox(height: 12),
Text( Text(
'添加海报', '点击添加海报',
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 14,
color: Color(0xFF999999), color: Color(0xFF999999),
), ),
), ),
@@ -293,239 +532,6 @@ class _MovieFormPageState extends State<MovieFormPage> {
); );
} }
/// 构建文本输入框
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<String> 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<void> _pickCover() async { Future<void> _pickCover() async {
try { try {
@@ -596,7 +602,6 @@ class _MovieFormPageState extends State<MovieFormPage> {
final now = DateTime.now(); final now = DateTime.now();
if (widget.movie == null) { if (widget.movie == null) {
// 添加新模式
final newMovie = Movie( final newMovie = Movie(
id: now.millisecondsSinceEpoch.toString(), id: now.millisecondsSinceEpoch.toString(),
title: _titleController.text.trim(), title: _titleController.text.trim(),
@@ -616,7 +621,6 @@ class _MovieFormPageState extends State<MovieFormPage> {
await context.read<AppProvider>().addMovie(newMovie); await context.read<AppProvider>().addMovie(newMovie);
} else { } else {
// 编辑现有模式
final updatedMovie = widget.movie!.copyWith( final updatedMovie = widget.movie!.copyWith(
title: _titleController.text.trim(), title: _titleController.text.trim(),
posterPath: _posterPath, posterPath: _posterPath,

View File

@@ -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<MoviePostersPage> createState() => _MoviePostersPageState();
}
class _MoviePostersPageState extends State<MoviePostersPage> {
final ImagePicker _picker = ImagePicker();
List<MoviePoster> _posters = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadPosters();
}
Future<void> _loadPosters() async {
setState(() => _isLoading = true);
final posters = await context.read<AppProvider>().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<void> _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<AppProvider>().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<AppProvider>().removeMoviePoster(poster.id);
Navigator.pop(context);
_loadPosters();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已删除')),
);
},
child: const Text('删除', style: TextStyle(color: Colors.red)),
),
],
),
);
}
}

View File

@@ -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<MovieReviewFormPage> createState() => _MovieReviewFormPageState();
}
class _MovieReviewFormPageState extends State<MovieReviewFormPage> {
final _formKey = GlobalKey<FormState>();
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<void> _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<AppProvider>().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<AppProvider>().updateMovieReview(updatedReview);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(widget.review == null ? '添加成功' : '更新成功'),
behavior: SnackBarBehavior.floating,
),
);
Navigator.pop(context);
}
}

View File

@@ -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<MovieReviewsPage> createState() => _MovieReviewsPageState();
}
class _MovieReviewsPageState extends State<MovieReviewsPage> {
List<MovieReview> _reviews = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadReviews();
}
Future<void> _loadReviews() async {
setState(() => _isLoading = true);
final reviews = await context.read<AppProvider>().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<AppProvider>().removeMovieReview(review.id);
Navigator.pop(context);
_loadReviews();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已删除')),
);
},
child: const Text('删除', style: TextStyle(color: Colors.red)),
),
],
),
);
}
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../models/data_models.dart'; import '../models/data_models.dart';
@@ -21,6 +22,30 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
appBar: AppBar( appBar: AppBar(
title: Text(_formatDateTime(widget.note.createdAt)), title: Text(_formatDateTime(widget.note.createdAt)),
actions: [ 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( IconButton(
icon: const Icon(Icons.edit_outlined), icon: const Icon(Icons.edit_outlined),
onPressed: () => _navigateToEdit(context), onPressed: () => _navigateToEdit(context),
@@ -67,17 +92,9 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
// 内容区域 // 内容区域
Expanded( Expanded(
child: SingleChildScrollView( child: widget.note.contentType == 'markdown'
padding: const EdgeInsets.all(24), ? _buildMarkdownContent()
child: Text( : _buildPlainTextContent(),
widget.note.content,
style: const TextStyle(
fontSize: 16,
color: Color(0xFF1A1A1A),
height: 1.8,
),
),
),
), ),
// 底部操作栏 // 底部操作栏
@@ -129,6 +146,82 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
); );
} }
/// 构建 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) { 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')}'; 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')}';

View File

@@ -17,6 +17,7 @@ class _NoteFormPageState extends State<NoteFormPage> {
late TextEditingController _contentController; late TextEditingController _contentController;
late DateTime _createdAt; late DateTime _createdAt;
List<String> _tags = []; List<String> _tags = [];
String _contentType = 'markdown'; // markdown / rich_text
bool _isEditing = false; bool _isEditing = false;
@override @override
@@ -26,6 +27,7 @@ class _NoteFormPageState extends State<NoteFormPage> {
_contentController = TextEditingController(text: note?.content ?? ''); _contentController = TextEditingController(text: note?.content ?? '');
_createdAt = note?.createdAt ?? DateTime.now(); _createdAt = note?.createdAt ?? DateTime.now();
_tags = note != null ? List.from(note.tags) : []; _tags = note != null ? List.from(note.tags) : [];
_contentType = note?.contentType ?? 'markdown';
_isEditing = note != null; _isEditing = note != null;
} }
@@ -57,7 +59,7 @@ class _NoteFormPageState extends State<NoteFormPage> {
), ),
body: Column( body: Column(
children: [ children: [
// 顶部信息栏:创建时间 + 标签 // 顶部信息栏:创建时间 + 格式选择 + 标签
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: const BoxDecoration( decoration: const BoxDecoration(
@@ -65,21 +67,27 @@ class _NoteFormPageState extends State<NoteFormPage> {
bottom: BorderSide(color: Color(0xFFE5E5E5), width: 0.5), bottom: BorderSide(color: Color(0xFFE5E5E5), width: 0.5),
), ),
), ),
child: Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 创建时间 Row(
Text( children: [
_formatDateTime(_createdAt), // 创建时间
style: const TextStyle( Text(
fontSize: 12, _formatDateTime(_createdAt),
color: Color(0xFF999999), style: const TextStyle(
), fontSize: 12,
color: Color(0xFF999999),
),
),
const Spacer(),
// 格式选择
_buildFormatSelector(),
],
), ),
const SizedBox(width: 16), const SizedBox(height: 8),
// 标签 // 标签
Expanded( _buildTagSelector(),
child: _buildTagSelector(),
),
], ],
), ),
), ),
@@ -96,14 +104,14 @@ class _NoteFormPageState extends State<NoteFormPage> {
color: Color(0xFF1A1A1A), color: Color(0xFF1A1A1A),
height: 1.6, height: 1.6,
), ),
decoration: const InputDecoration( decoration: InputDecoration(
hintText: '开始书写...', hintText: _contentType == 'markdown' ? '使用 Markdown 格式书写...' : '开始书写...',
hintStyle: TextStyle( hintStyle: const TextStyle(
fontSize: 16, fontSize: 16,
color: Color(0xFFCCCCCC), color: Color(0xFFCCCCCC),
), ),
border: InputBorder.none, border: InputBorder.none,
contentPadding: EdgeInsets.all(16), contentPadding: const EdgeInsets.all(16),
), ),
), ),
), ),
@@ -112,6 +120,84 @@ class _NoteFormPageState extends State<NoteFormPage> {
); );
} }
/// 构建格式选择器
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() { Widget _buildTagSelector() {
return Wrap( return Wrap(
@@ -257,6 +343,7 @@ class _NoteFormPageState extends State<NoteFormPage> {
// 更新现有笔记 // 更新现有笔记
final updatedNote = widget.note!.copyWith( final updatedNote = widget.note!.copyWith(
content: content, content: content,
contentType: _contentType,
tags: _tags, tags: _tags,
updatedAt: now, updatedAt: now,
); );
@@ -266,6 +353,7 @@ class _NoteFormPageState extends State<NoteFormPage> {
final newNote = Note( final newNote = Note(
id: now.millisecondsSinceEpoch.toString(), id: now.millisecondsSinceEpoch.toString(),
content: content, content: content,
contentType: _contentType,
tags: _tags, tags: _tags,
createdAt: _createdAt, createdAt: _createdAt,
updatedAt: now, updatedAt: now,

View File

@@ -20,8 +20,8 @@ class _ProfilePageState extends State<ProfilePage> {
final UserPrefs _userPrefs = UserPrefs(); final UserPrefs _userPrefs = UserPrefs();
// 用户数据 // 用户数据
String _nickname = '记录者'; String _nickname = 'Mook';
String _motto = '记录生活,沉淀思考'; String _motto = '好运不会眷顾一无所有之人。';
String? _avatarPath; String? _avatarPath;
bool _isLoading = true; bool _isLoading = true;
@@ -384,20 +384,7 @@ class _ProfilePageState extends State<ProfilePage> {
), ),
const Divider(height: 0.5, thickness: 0.5, indent: 56, color: Color(0xFFE5E5E5)), 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( _buildMenuItem(
icon: Icons.delete_outline, icon: Icons.delete_outline,
title: '回收站', title: '回收站',

View File

@@ -3,6 +3,8 @@ import '../models/data_models.dart';
import '../utils/movie_dao.dart'; import '../utils/movie_dao.dart';
import '../utils/book_dao.dart'; import '../utils/book_dao.dart';
import '../utils/note_dao.dart'; import '../utils/note_dao.dart';
import '../utils/movie_review_dao.dart';
import '../utils/movie_poster_dao.dart';
/// 应用全局状态管理 /// 应用全局状态管理
class AppProvider extends ChangeNotifier { class AppProvider extends ChangeNotifier {
@@ -10,6 +12,8 @@ class AppProvider extends ChangeNotifier {
final MovieDao _movieDao = MovieDao(); final MovieDao _movieDao = MovieDao();
final BookDao _bookDao = BookDao(); final BookDao _bookDao = BookDao();
final NoteDao _noteDao = NoteDao(); final NoteDao _noteDao = NoteDao();
final MovieReviewDao _reviewDao = MovieReviewDao();
final MoviePosterDao _posterDao = MoviePosterDao();
// 数据列表 // 数据列表
List<Movie> _movies = []; List<Movie> _movies = [];
@@ -160,4 +164,53 @@ class AppProvider extends ChangeNotifier {
await _noteDao.deleteNote(id); await _noteDao.deleteNote(id);
await loadNotes(); await loadNotes();
} }
// ========== 影评相关方法 ==========
/// 获取影视的所有影评
Future<List<MovieReview>> getMovieReviews(String movieId) async {
return await _reviewDao.getReviewsByMovieId(movieId);
}
/// 添加影评
Future<void> addMovieReview(MovieReview review) async {
await _reviewDao.insertReview(review);
}
/// 更新影评
Future<void> updateMovieReview(MovieReview review) async {
await _reviewDao.updateReview(review);
}
/// 删除影评
Future<void> removeMovieReview(String id) async {
await _reviewDao.deleteReview(id);
}
/// 获取影视的影评数量
Future<int> getMovieReviewCount(String movieId) async {
return await _reviewDao.getReviewCount(movieId);
}
// ========== 海报墙相关方法 ==========
/// 获取影视的所有海报
Future<List<MoviePoster>> getMoviePosters(String movieId) async {
return await _posterDao.getPostersByMovieId(movieId);
}
/// 添加海报
Future<void> addMoviePoster(MoviePoster poster) async {
await _posterDao.insertPoster(poster);
}
/// 删除海报
Future<void> removeMoviePoster(String id) async {
await _posterDao.deletePoster(id);
}
/// 获取影视的海报数量
Future<int> getMoviePosterCount(String movieId) async {
return await _posterDao.getPosterCount(movieId);
}
} }

View File

@@ -20,7 +20,7 @@ class DatabaseHelper {
return await openDatabase( return await openDatabase(
path, path,
version: 4, version: 5,
onCreate: _createDB, onCreate: _createDB,
onUpgrade: _onUpgrade, onUpgrade: _onUpgrade,
); );
@@ -40,6 +40,43 @@ class DatabaseHelper {
// 升级notes表结构 // 升级notes表结构
await _upgradeNotesTableV4(db); await _upgradeNotesTableV4(db);
} }
if (oldVersion < 5) {
// 创建影评表和海报墙表
await _createMovieReviewsTable(db);
await _createMoviePostersTable(db);
}
}
/// 创建影评表
Future<void> _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<void> _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 /// 升级notes表到V4
@@ -251,6 +288,34 @@ class DatabaseHelper {
updated_at TEXT NOT NULL 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)
)
''');
} }
// 关闭数据库 // 关闭数据库

View File

@@ -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<List<MoviePoster>> getPostersByMovieId(String movieId) async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> 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<MoviePoster?> getPosterById(String id) async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> 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<int> insertPoster(MoviePoster poster) async {
final db = await _dbHelper.database;
return await db.insert('movie_posters', poster.toJson());
}
/// 软删除海报
Future<int> deletePoster(String id) async {
final db = await _dbHelper.database;
return await db.update(
'movie_posters',
{'is_deleted': 1},
where: 'id = ?',
whereArgs: [id],
);
}
/// 获取影视的海报数量
Future<int> 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;
}
}

View File

@@ -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<List<MovieReview>> getReviewsByMovieId(String movieId) async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> 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<MovieReview?> getReviewById(String id) async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> 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<int> insertReview(MovieReview review) async {
final db = await _dbHelper.database;
return await db.insert('movie_reviews', review.toJson());
}
/// 更新影评
Future<int> updateReview(MovieReview review) async {
final db = await _dbHelper.database;
return await db.update(
'movie_reviews',
review.toJson(),
where: 'id = ?',
whereArgs: [review.id],
);
}
/// 软删除影评
Future<int> deleteReview(String id) async {
final db = await _dbHelper.database;
return await db.update(
'movie_reviews',
{'is_deleted': 1},
where: 'id = ?',
whereArgs: [id],
);
}
/// 获取影视的影评数量
Future<int> 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<List<MovieReview>> getShortReviews(String movieId) async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> 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<List<MovieReview>> getLongReviews(String movieId) async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> 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]));
}
}

View File

@@ -24,11 +24,11 @@ class UserPrefs {
// ========== 用户信息 ========== // ========== 用户信息 ==========
/// 昵称 /// 昵称
String get nickname => prefs.getString('nickname') ?? '记录者'; String get nickname => prefs.getString('nickname') ?? 'Mook';
Future<bool> setNickname(String value) => prefs.setString('nickname', value); Future<bool> setNickname(String value) => prefs.setString('nickname', value);
/// 座右铭 /// 座右铭
String get motto => prefs.getString('motto') ?? '记录生活,沉淀思考'; String get motto => prefs.getString('motto') ?? '好运不会眷顾一无所有之人。';
Future<bool> setMotto(String value) => prefs.setString('motto', value); Future<bool> setMotto(String value) => prefs.setString('motto', value);
/// 头像路径 /// 头像路径

View File

@@ -24,15 +24,6 @@ class CustomDrawer extends StatelessWidget {
child: ListView( child: ListView(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
children: [ 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( _buildMenuItem(
icon: Icons.delete_outline, icon: Icons.delete_outline,

View File

@@ -1,6 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -158,6 +166,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0" 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: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@@ -312,6 +328,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "1.3.0"
markdown:
dependency: transitive
description:
name: markdown
sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1"
url: "https://pub.dev"
source: hosted
version: "7.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:

View File

@@ -17,6 +17,7 @@ dependencies:
image_picker: ^1.0.4 image_picker: ^1.0.4
path_provider: ^2.1.1 path_provider: ^2.1.1
shared_preferences: ^2.2.2 shared_preferences: ^2.2.2
flutter_markdown: ^0.7.4
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: