基本功能完善

This commit is contained in:
DelLevin-Home
2026-03-07 00:40:01 +08:00
parent f2ab6a17ba
commit 082145ff79
39 changed files with 5726 additions and 867 deletions

312
lib/pages/backup_page.dart Normal file
View File

@@ -0,0 +1,312 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
import '../utils/backup_service.dart';
/// 数据备份页面
class BackupPage extends StatefulWidget {
const BackupPage({super.key});
@override
State<BackupPage> createState() => _BackupPageState();
}
class _BackupPageState extends State<BackupPage> {
bool _isExporting = false;
bool _isImporting = false;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text('数据备份'),
),
body: ListView(
padding: const EdgeInsets.all(24),
children: [
// 导出数据
_buildSection(
title: '导出数据',
description: '将所有数据导出为 JSON 文件,可用于备份或迁移到其他设备',
icon: Icons.upload_outlined,
buttonText: '导出',
isLoading: _isExporting,
onTap: _exportData,
),
const SizedBox(height: 32),
// 导入数据
_buildSection(
title: '导入数据',
description: '从备份文件导入数据,将覆盖当前所有数据',
icon: Icons.download_outlined,
buttonText: '导入',
isLoading: _isImporting,
onTap: _importData,
isDestructive: true,
),
const SizedBox(height: 48),
// 说明
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
size: 16,
color: const Color(0xFF666666),
),
const SizedBox(width: 8),
Text(
'使用说明',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: const Color(0xFF666666),
),
),
],
),
const SizedBox(height: 12),
Text(
'1. 导出数据会生成一个 .zip 文件,包含所有数据和图片\n'
'2. 选择保存路径后,可以通过微信、邮件等方式发送备份文件\n'
'3. 在新设备上选择导入数据,选择备份文件即可恢复\n'
'4. 导入数据会完全覆盖当前设备的数据,请谨慎操作',
style: TextStyle(
fontSize: 13,
color: const Color(0xFF999999),
height: 1.6,
),
),
],
),
),
],
),
);
}
Widget _buildSection({
required String title,
required String description,
required IconData icon,
required String buttonText,
required bool isLoading,
required VoidCallback onTap,
bool isDestructive = false,
}) {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
icon,
size: 24,
color: isDestructive ? Colors.red : const Color(0xFF1A1A1A),
),
const SizedBox(width: 12),
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
),
],
),
const SizedBox(height: 12),
Text(
description,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF666666),
height: 1.5,
),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: isLoading ? null : onTap,
style: OutlinedButton.styleFrom(
foregroundColor: isDestructive ? Colors.red : const Color(0xFF1A1A1A),
side: BorderSide(
color: isDestructive ? Colors.red : const Color(0xFF1A1A1A),
),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(
isDestructive ? Colors.red : const Color(0xFF1A1A1A),
),
),
)
: Text(buttonText),
),
),
],
),
);
}
/// 导出数据
Future<void> _exportData() async {
setState(() => _isExporting = true);
try {
final result = await BackupService.instance.exportDataWithImages();
if (!mounted) return;
if (result.cancelled) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已取消导出')),
);
} else if (result.success) {
// 显示导出成功信息
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: Colors.white,
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
title: const Text('导出成功'),
content: Text(
'备份文件已保存到:\n${result.filePath}\n\n'
'包含数据:\n'
'• 影视: ${result.movieCount}\n'
'• 书籍: ${result.bookCount}\n'
'• 笔记: ${result.noteCount}\n'
'• 图片: ${result.imageCount}',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('确定'),
),
],
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result.errorMessage ?? '导出失败')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('导出失败: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isExporting = false);
}
}
}
/// 导入数据
Future<void> _importData() async {
// 显示确认对话框
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: Colors.white,
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
title: const Text('确认导入'),
content: const Text(
'导入数据将覆盖当前所有数据,此操作不可恢复。\n\n是否继续?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消', style: TextStyle(color: Color(0xFF666666))),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('确认导入', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed != true) return;
setState(() => _isImporting = true);
try {
final result = await BackupService.instance.importData();
if (!mounted) return;
if (result.cancelled) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已取消导入')),
);
} else if (result.success) {
// 刷新数据
await context.read<AppProvider>().loadMovies();
await context.read<AppProvider>().loadBooks();
await context.read<AppProvider>().loadNotes();
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: Colors.white,
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
title: const Text('导入成功'),
content: Text('成功导入数据:\n${result.statsText}'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('确定'),
),
],
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result.errorMessage ?? '导入失败')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('导入失败: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isImporting = false);
}
}
}
}

View File

@@ -1,8 +1,12 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:cross_file/cross_file.dart';
import '../providers/app_provider.dart';
import '../models/data_models.dart';
import 'book_reviews_page.dart';
import 'book_excerpts_page.dart';
/// 书籍详情页 - 极简主义设计
class BookDetailPage extends StatefulWidget {
@@ -15,46 +19,65 @@ class BookDetailPage extends StatefulWidget {
}
class _BookDetailPageState extends State<BookDetailPage> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
// 页面获得焦点时刷新数据
_refreshBookData();
}
void _refreshBookData() {
final provider = context.read<AppProvider>();
// 强制刷新当前书籍数据
provider.loadBooks();
}
@override
Widget build(BuildContext context) {
// 从 Provider 获取最新的 book 数据,实现动态刷新
final book = context.watch<AppProvider>().books
.where((b) => b.id == widget.book.id)
.firstOrNull ?? widget.book;
return Scaffold(
backgroundColor: Colors.white,
body: CustomScrollView(
slivers: [
// 顶部封面区域
_buildSliverAppBar(),
_buildSliverAppBar(book),
// 内容区域
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 基本信息
_buildBasicInfo(),
_buildBasicInfo(book),
const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
// 作者信息
_buildAuthorsSection(),
_buildAuthorsSection(book),
// 出版社
if (widget.book.publisher != null && widget.book.publisher!.isNotEmpty)
_buildPublisherSection(),
if (book.publisher != null && book.publisher!.isNotEmpty)
_buildPublisherSection(book),
// 类型
if (widget.book.genres.isNotEmpty)
_buildGenresSection(),
if (book.genres.isNotEmpty)
_buildGenresSection(book),
const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
// 简介
if (widget.book.summary != null && widget.book.summary!.isNotEmpty)
_buildSummarySection(),
// 别名
if (widget.book.alternateTitles.isNotEmpty)
_buildAlternateTitlesSection(),
if (book.summary != null && book.summary!.isNotEmpty)
_buildSummarySection(book),
const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
// 书评和摘抄入口
_buildExtraSections(book),
const SizedBox(height: 48),
],
),
@@ -68,33 +91,66 @@ class _BookDetailPageState extends State<BookDetailPage> {
}
/// 构建顶部 AppBar
Widget _buildSliverAppBar() {
Widget _buildSliverAppBar(Book book) {
final hasCover = book.coverPath != null && book.coverPath!.isNotEmpty;
return SliverAppBar(
expandedHeight: 280,
expandedHeight: 320,
pinned: true,
backgroundColor: Colors.white,
backgroundColor: const Color(0xFFF5F5F5),
flexibleSpace: FlexibleSpaceBar(
background: _buildCoverSection(),
background: _buildCoverSection(book),
),
actions: [
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => _navigateToEdit(context),
// 下载封面按钮(仅当有封面时显示)
if (hasCover)
Container(
margin: const EdgeInsets.all(8),
decoration: const BoxDecoration(
color: Colors.white,
),
child: IconButton(
icon: const Icon(Icons.download_outlined, color: Color(0xFF666666)),
onPressed: () => _downloadCover(book),
tooltip: '下载封面',
),
),
// 清空封面按钮(仅当有封面时显示)
if (hasCover)
Container(
margin: const EdgeInsets.all(8),
decoration: const BoxDecoration(
color: Colors.white,
),
child: IconButton(
icon: const Icon(Icons.hide_image_outlined, color: Color(0xFF666666)),
onPressed: () => _showClearCoverDialog(book),
tooltip: '清空封面',
),
),
// 编辑按钮
Container(
margin: const EdgeInsets.all(8),
decoration: const BoxDecoration(
color: Colors.white,
),
child: IconButton(
icon: const Icon(Icons.edit_outlined, color: Color(0xFF1A1A1A)),
onPressed: () => _navigateToEdit(context),
),
),
const SizedBox(width: 8),
],
);
}
/// 构建封面区域
Widget _buildCoverSection() {
return Container(
width: double.infinity,
color: const Color(0xFFF5F5F5),
child: widget.book.coverPath != null && widget.book.coverPath!.isNotEmpty
Widget _buildCoverSection(Book book) {
return SizedBox.expand(
child: book.coverPath != null && book.coverPath!.isNotEmpty
? Image.file(
File(widget.book.coverPath!),
fit: BoxFit.contain,
File(book.coverPath!),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildCoverPlaceholder(),
)
: _buildCoverPlaceholder(),
@@ -125,7 +181,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
}
/// 构建基本信息
Widget _buildBasicInfo() {
Widget _buildBasicInfo(Book book) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
@@ -133,7 +189,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
children: [
// 书名
Text(
widget.book.title,
book.title,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
@@ -141,13 +197,26 @@ class _BookDetailPageState extends State<BookDetailPage> {
height: 1.3,
),
),
// 别名(显示在主名称下面,用 / 分隔)
if (book.alternateTitles.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
book.alternateTitles.join(' / '),
style: const TextStyle(
fontSize: 14,
color: Color(0xFF999999),
height: 1.4,
),
),
],
const SizedBox(height: 16),
// 评分和状态
Row(
children: [
if (widget.book.rating != null) ...[
if (book.rating != null) ...[
const Icon(
Icons.star,
size: 20,
@@ -155,7 +224,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
),
const SizedBox(width: 4),
Text(
widget.book.rating!.toStringAsFixed(1),
book.rating!.toStringAsFixed(1),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
@@ -164,15 +233,15 @@ class _BookDetailPageState extends State<BookDetailPage> {
),
const SizedBox(width: 16),
],
_buildStatusTag(),
_buildStatusTag(book),
],
),
const SizedBox(height: 8),
// 时间信息
Text(
'添加于 ${_formatDate(widget.book.createdAt)}',
'添加于 ${_formatDate(book.createdAt)}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
@@ -182,12 +251,12 @@ class _BookDetailPageState extends State<BookDetailPage> {
),
);
}
/// 构建状态标签
Widget _buildStatusTag() {
Widget _buildStatusTag(Book book) {
String label;
Color color;
switch (widget.book.status) {
switch (book.status) {
case 'read':
label = '已读';
color = const Color(0xFF1A1A1A);
@@ -222,7 +291,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
}
/// 构建作者区域
Widget _buildAuthorsSection() {
Widget _buildAuthorsSection(Book book) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
@@ -241,7 +310,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
Wrap(
spacing: 8,
runSpacing: 8,
children: widget.book.authors.map((author) {
children: book.authors.map((author) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
@@ -264,7 +333,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
}
/// 构建出版社区域
Widget _buildPublisherSection() {
Widget _buildPublisherSection(Book book) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: Column(
@@ -281,7 +350,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
),
const SizedBox(height: 8),
Text(
widget.book.publisher!,
book.publisher!,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
@@ -293,7 +362,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
}
/// 构建类型区域
Widget _buildGenresSection() {
Widget _buildGenresSection(Book book) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: Column(
@@ -312,7 +381,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
Wrap(
spacing: 8,
runSpacing: 8,
children: widget.book.genres.map((genre) {
children: book.genres.map((genre) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
@@ -334,7 +403,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
}
/// 构建简介区域
Widget _buildSummarySection() {
Widget _buildSummarySection(Book book) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
@@ -351,7 +420,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
),
const SizedBox(height: 12),
Text(
widget.book.summary!,
book.summary!,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
@@ -362,16 +431,16 @@ class _BookDetailPageState extends State<BookDetailPage> {
),
);
}
/// 构建别名区域
Widget _buildAlternateTitlesSection() {
/// 构建额外功能区域(书评、摘抄)
Widget _buildExtraSections(Book book) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'别名',
'更多',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
@@ -379,25 +448,137 @@ class _BookDetailPageState extends State<BookDetailPage> {
letterSpacing: 1,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: widget.book.alternateTitles.map((title) {
return Text(
title,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF666666),
),
);
}).toList(),
const SizedBox(height: 16),
// 书评入口
GestureDetector(
onTap: () => _navigateToReviews(book),
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>().getBookReviewCount(book.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: () => _navigateToExcerpts(book),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Row(
children: [
const Icon(
Icons.format_quote_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>().getBookExcerptCount(book.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(Book book) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BookReviewsPage(book: book),
),
);
}
void _navigateToExcerpts(Book book) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BookExcerptsPage(book: book),
),
);
}
/// 构建底部操作栏
Widget _buildBottomBar() {
return Container(
@@ -486,4 +667,80 @@ class _BookDetailPageState extends State<BookDetailPage> {
),
);
}
/// 显示清空封面对话框
void _showClearCoverDialog(Book book) {
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 {
Navigator.pop(context);
final updatedBook = book.copyWith(
coverPath: null,
updatedAt: DateTime.now(),
);
await context.read<AppProvider>().updateBook(updatedBook);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('封面已清空')),
);
}
},
child: const Text('清空', style: TextStyle(color: Colors.red)),
),
],
),
);
}
/// 下载封面到本地
Future<void> _downloadCover(Book book) async {
if (book.coverPath == null || book.coverPath!.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('没有可下载的封面')),
);
return;
}
try {
final sourceFile = File(book.coverPath!);
if (!await sourceFile.exists()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('封面文件不存在')),
);
return;
}
// 生成文件名书籍名称_时间戳_封面.jpg
final timestamp = DateTime.now().millisecondsSinceEpoch;
final fileName = '${book.title}_${timestamp}_封面.jpg';
// 获取临时目录路径
final tempDir = await Directory.systemTemp.createTemp();
final tempFile = File('${tempDir.path}/$fileName');
await sourceFile.copy(tempFile.path);
// 使用分享功能让用户选择保存位置
await Share.shareXFiles(
[XFile(tempFile.path)],
subject: '${book.title} 封面',
text: '下载自 MookNote',
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('下载失败: $e')),
);
}
}
}

View File

@@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
import '../models/data_models.dart';
import 'package:uuid/uuid.dart';
/// 摘抄表单页面 - 新增/编辑摘抄
class BookExcerptFormPage extends StatefulWidget {
final String bookId;
final BookExcerpt? excerpt;
const BookExcerptFormPage({
super.key,
required this.bookId,
this.excerpt,
});
@override
State<BookExcerptFormPage> createState() => _BookExcerptFormPageState();
}
class _BookExcerptFormPageState extends State<BookExcerptFormPage> {
final _formKey = GlobalKey<FormState>();
final _chapterController = TextEditingController();
final _contentController = TextEditingController();
final _commentController = TextEditingController();
bool _isLoading = false;
bool get _isEditing => widget.excerpt != null;
@override
void initState() {
super.initState();
if (_isEditing) {
_chapterController.text = widget.excerpt!.chapter;
_contentController.text = widget.excerpt!.content;
_commentController.text = widget.excerpt!.comment;
}
}
@override
void dispose() {
_chapterController.dispose();
_contentController.dispose();
_commentController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: Text(_isEditing ? '编辑摘抄' : '添加摘抄'),
actions: [
TextButton(
onPressed: _isLoading ? null : _saveExcerpt,
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text(
'保存',
style: TextStyle(
color: Color(0xFF1A1A1A),
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
],
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(24),
children: [
// 章节
TextFormField(
controller: _chapterController,
decoration: const InputDecoration(
labelText: '章节(可选)',
hintText: '例如第一章、第3节等',
border: OutlineInputBorder(
borderRadius: BorderRadius.zero,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: Color(0xFF1A1A1A)),
),
),
),
const SizedBox(height: 24),
// 摘抄内容
TextFormField(
controller: _contentController,
maxLines: 8,
decoration: const InputDecoration(
labelText: '摘抄内容',
hintText: '输入你想要摘抄的内容...',
alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.zero,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: Color(0xFF1A1A1A)),
),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '请输入摘抄内容';
}
return null;
},
),
const SizedBox(height: 24),
// 评论/感悟
TextFormField(
controller: _commentController,
maxLines: 5,
decoration: const InputDecoration(
labelText: '我的感悟(可选)',
hintText: '记录你对这段内容的思考和感悟...',
alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.zero,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: Color(0xFF1A1A1A)),
),
),
),
],
),
),
);
}
Future<void> _saveExcerpt() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final now = DateTime.now();
final excerpt = BookExcerpt(
id: _isEditing ? widget.excerpt!.id : const Uuid().v4(),
bookId: widget.bookId,
chapter: _chapterController.text.trim(),
content: _contentController.text.trim(),
comment: _commentController.text.trim(),
isDeleted: false,
createdAt: _isEditing ? widget.excerpt!.createdAt : now,
updatedAt: now,
);
if (_isEditing) {
await context.read<AppProvider>().updateBookExcerpt(excerpt);
} else {
await context.read<AppProvider>().addBookExcerpt(excerpt);
}
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(_isEditing ? '摘抄已更新' : '摘抄已添加')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存失败: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
}

View File

@@ -0,0 +1,300 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
import '../models/data_models.dart';
import 'book_excerpt_form_page.dart';
/// 书籍摘抄列表页面
class BookExcerptsPage extends StatefulWidget {
final Book book;
const BookExcerptsPage({super.key, required this.book});
@override
State<BookExcerptsPage> createState() => _BookExcerptsPageState();
}
class _BookExcerptsPageState extends State<BookExcerptsPage> {
List<BookExcerpt> _excerpts = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadExcerpts();
}
Future<void> _loadExcerpts() async {
setState(() => _isLoading = true);
try {
final excerpts = await context.read<AppProvider>().getBookExcerpts(widget.book.id);
setState(() {
_excerpts = excerpts;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('加载失败: $e')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text('摘抄'),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => _navigateToAddExcerpt(),
),
const SizedBox(width: 8),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _excerpts.isEmpty
? _buildEmptyState()
: _buildExcerptList(),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.format_quote_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: () => _navigateToAddExcerpt(),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF1A1A1A),
side: const BorderSide(color: Color(0xFF1A1A1A)),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
),
child: const Text('添加摘抄'),
),
],
),
);
}
/// 按章节分组摘抄数据
Map<String, List<BookExcerpt>> _groupExcerptsByChapter() {
final Map<String, List<BookExcerpt>> groups = {};
for (final excerpt in _excerpts) {
final chapter = excerpt.chapter.isEmpty ? '未分类' : excerpt.chapter;
if (!groups.containsKey(chapter)) {
groups[chapter] = [];
}
groups[chapter]!.add(excerpt);
}
// 每个章节内的摘抄按时间排序(新的在前)
for (final chapter in groups.keys) {
groups[chapter]!.sort((a, b) => b.createdAt.compareTo(a.createdAt));
}
return groups;
}
Widget _buildExcerptList() {
final groups = _groupExcerptsByChapter();
final chapters = groups.keys.toList();
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: chapters.length,
itemBuilder: (context, index) {
final chapter = chapters[index];
final excerpts = groups[chapter]!;
return _buildChapterSection(chapter, excerpts);
},
);
}
Widget _buildChapterSection(String chapter, List<BookExcerpt> excerpts) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 章节标题
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: const BoxDecoration(
color: Color(0xFFF5F5F5),
border: Border(
left: BorderSide(color: Color(0xFF1A1A1A), width: 4),
),
),
child: Text(
chapter,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
),
const SizedBox(height: 12),
// 该章节下的摘抄列表
...excerpts.map((excerpt) => _buildExcerptItem(excerpt)),
const SizedBox(height: 24),
],
);
}
Widget _buildExcerptItem(BookExcerpt excerpt) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 摘抄内容
Text(
excerpt.content,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF1A1A1A),
height: 1.5,
),
),
// 评论/感悟
if (excerpt.comment.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: const BoxDecoration(
color: Color(0xFFF5F5F5),
),
child: Text(
excerpt.comment,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF666666),
),
),
),
],
const SizedBox(height: 8),
// 底部:时间 + 操作按钮
Row(
children: [
Text(
_formatDate(excerpt.createdAt),
style: const TextStyle(
fontSize: 11,
color: Color(0xFF999999),
),
),
const Spacer(),
// 编辑按钮
GestureDetector(
onTap: () => _navigateToEditExcerpt(excerpt),
child: const Icon(
Icons.edit_outlined,
size: 16,
color: Color(0xFF999999),
),
),
const SizedBox(width: 12),
// 删除按钮
GestureDetector(
onTap: () => _showDeleteDialog(excerpt),
child: const Icon(
Icons.delete_outline,
size: 16,
color: Colors.red,
),
),
],
),
],
),
);
}
String _formatDate(DateTime date) {
return '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}';
}
void _navigateToAddExcerpt() {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BookExcerptFormPage(bookId: widget.book.id),
),
).then((_) => _loadExcerpts());
}
void _navigateToEditExcerpt(BookExcerpt excerpt) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BookExcerptFormPage(
bookId: widget.book.id,
excerpt: excerpt,
),
),
).then((_) => _loadExcerpts());
}
void _showDeleteDialog(BookExcerpt excerpt) {
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>().removeBookExcerpt(excerpt.id);
Navigator.pop(context);
_loadExcerpts();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已删除')),
);
},
child: const Text('删除', style: TextStyle(color: Colors.red)),
),
],
),
);
}
}

View File

@@ -10,8 +10,9 @@ import '../models/data_models.dart';
/// 添加/编辑书籍页面 - 紧凑双行布局设计
class BookFormPage extends StatefulWidget {
final Book? book;
const BookFormPage({super.key, this.book});
final String? initialStatus; // 添加时的默认状态
const BookFormPage({super.key, this.book, this.initialStatus});
@override
State<BookFormPage> createState() => _BookFormPageState();
@@ -40,18 +41,36 @@ class _BookFormPageState extends State<BookFormPage> {
@override
void initState() {
super.initState();
final book = widget.book;
_initializeData();
}
void _initializeData() {
// 如果有传入book尝试从Provider获取最新数据
Book? book = widget.book;
if (book != null) {
final appProvider = context.read<AppProvider>();
final latestBook = appProvider.books
.where((b) => b.id == book!.id)
.firstOrNull;
if (latestBook != null) {
book = latestBook;
}
}
_titleController = TextEditingController(text: book?.title ?? '');
_publisherController = TextEditingController(text: book?.publisher ?? '');
_summaryController = TextEditingController(text: book?.summary ?? '');
_ratingController = TextEditingController(text: book?.rating?.toString() ?? '');
if (book != null) {
_authors = List.from(book.authors);
_alternateTitles = List.from(book.alternateTitles);
_genres = List.from(book.genres);
_coverPath = book.coverPath;
_status = book.status;
} else if (widget.initialStatus != null) {
// 添加模式:使用传入的默认状态
_status = widget.initialStatus!;
}
}
@@ -98,9 +117,19 @@ class _BookFormPageState extends State<BookFormPage> {
children: [
// 封面选择 - 居中显示
Center(child: _buildCoverPicker()),
const SizedBox(height: 32),
// 状态选择(靠左显示)
_buildStatusSelector(),
const SizedBox(height: 20),
// 评分 - 星星选择(靠左显示)
_buildStarRating(),
const SizedBox(height: 32),
// 基本信息区域
_buildFormItem(
label: '书名 *',
@@ -121,9 +150,9 @@ class _BookFormPageState extends State<BookFormPage> {
},
),
),
_buildDivider(),
// 别名
_buildMultiValueItem(
label: '别名',
@@ -133,9 +162,9 @@ class _BookFormPageState extends State<BookFormPage> {
onAdd: (v) => setState(() => _alternateTitles.add(v)),
onRemove: (i) => setState(() => _alternateTitles.removeAt(i)),
),
_buildDivider(),
// 作者
_buildMultiValueItem(
label: '作者',
@@ -145,9 +174,9 @@ class _BookFormPageState extends State<BookFormPage> {
onAdd: (v) => setState(() => _authors.add(v)),
onRemove: (i) => setState(() => _authors.removeAt(i)),
),
_buildDivider(),
// 出版社
_buildFormItem(
label: '出版社',
@@ -162,9 +191,9 @@ class _BookFormPageState extends State<BookFormPage> {
),
),
),
_buildDivider(),
// 类型
_buildMultiValueItem(
label: '类型',
@@ -174,9 +203,9 @@ class _BookFormPageState extends State<BookFormPage> {
onAdd: (v) => setState(() => _genres.add(v)),
onRemove: (i) => setState(() => _genres.removeAt(i)),
),
_buildDivider(),
// 书籍简介
_buildFormItem(
label: '书籍简介',
@@ -192,63 +221,7 @@ class _BookFormPageState extends State<BookFormPage> {
),
),
),
_buildDivider(),
// 评分
_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;
},
),
),
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(height: 48),
],
),
@@ -410,62 +383,236 @@ class _BookFormPageState extends State<BookFormPage> {
);
}
/// 构建状态选择 Chip
Widget _buildStatusChip(String label, String value) {
/// 构建状态选择器(靠左显示,带标签)
Widget _buildStatusSelector() {
return Row(
children: [
const Text(
'状态',
style: TextStyle(fontSize: 14, color: Color(0xFF666666)),
),
const SizedBox(width: 16),
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildStatusOption('想读', 'want_to_read'),
_buildStatusOption('在读', 'reading'),
_buildStatusOption('已读', 'read'),
],
),
),
],
);
}
/// 构建星星评分5星制每星2分支持手动输入
Widget _buildStarRating() {
return Row(
children: [
const Text(
'评分',
style: TextStyle(fontSize: 14, color: Color(0xFF666666)),
),
const SizedBox(width: 16),
// 星星选择
_buildStarSelector(),
const SizedBox(width: 12),
// 手动输入框
_buildRatingInputField(),
],
);
}
/// 构建星星选择器
Widget _buildStarSelector() {
final currentRating = double.tryParse(_ratingController.text) ?? 0;
final starRating = currentRating / 2;
return Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(5, (index) {
final starValue = index + 1;
final scoreValue = starValue * 2;
final isFilled = starValue <= starRating;
final isHalf = starValue == starRating.ceil() && starRating % 1 != 0;
return InkWell(
onTap: () {
setState(() {
_ratingController.text = scoreValue.toString();
});
},
borderRadius: BorderRadius.circular(4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4),
child: Icon(
isHalf
? Icons.star_half
: isFilled
? Icons.star
: Icons.star_border,
size: 24,
color: isFilled || isHalf
? const Color(0xFFFFB800)
: const Color(0xFFE5E5E5),
),
),
);
}),
),
);
}
/// 构建评分输入框
Widget _buildRatingInputField() {
return Container(
width: 56,
height: 36,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(8),
),
child: TextFormField(
controller: _ratingController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
decoration: const InputDecoration(
hintText: '-',
hintStyle: TextStyle(fontSize: 15, color: Color(0xFFCCCCCC)),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
validator: (value) {
if (value != null && value.isNotEmpty) {
final rating = double.tryParse(value);
if (rating == null || rating < 0 || rating > 10) {
return '0-10';
}
}
return null;
},
onChanged: (value) {
// 限制输入范围
if (value.isNotEmpty) {
final rating = double.tryParse(value);
if (rating != null) {
if (rating > 10) {
_ratingController.text = '10';
} else if (rating < 0) {
_ratingController.text = '0';
}
}
}
setState(() {}); // 更新星星显示
},
),
);
}
/// 构建状态选项
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: 6),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? color : Colors.transparent,
border: Border.all(color: color),
color: isSelected ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(6),
boxShadow: isSelected
? [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
]
: null,
),
child: Text(
label,
style: TextStyle(
fontSize: 13,
color: isSelected ? Colors.white : color,
fontSize: 14,
fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal,
color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFF999999),
),
),
),
);
}
/// 构建封面选择器
Widget _buildCoverPicker() {
return GestureDetector(
onTap: _pickCover,
child: Container(
width: 140,
height: 200,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5),
final hasCover = _coverPath != null && _coverPath!.isNotEmpty;
return Column(
children: [
GestureDetector(
onTap: _pickCover,
child: Container(
width: 140,
height: 200,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5),
),
child: hasCover
? Image.file(
File(_coverPath!),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildCoverPlaceholder(),
)
: _buildCoverPlaceholder(),
),
),
child: _coverPath != null && _coverPath!.isNotEmpty
? Image.file(
File(_coverPath!),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildCoverPlaceholder(),
)
: _buildCoverPlaceholder(),
),
// 清空封面按钮(仅当有封面时显示)
if (hasCover)
Padding(
padding: const EdgeInsets.only(top: 12),
child: GestureDetector(
onTap: () => setState(() => _coverPath = null),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.hide_image_outlined,
size: 16,
color: Color(0xFF666666),
),
SizedBox(width: 4),
Text(
'清空封面',
style: TextStyle(
fontSize: 13,
color: Color(0xFF666666),
),
),
],
),
),
),
),
],
);
}

View File

@@ -0,0 +1,266 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
import '../models/data_models.dart';
import 'package:uuid/uuid.dart';
/// 添加/编辑书评页面 - 极简设计
class BookReviewFormPage extends StatefulWidget {
final String bookId;
final BookReview? review;
const BookReviewFormPage({
super.key,
required this.bookId,
this.review,
});
@override
State<BookReviewFormPage> createState() => _BookReviewFormPageState();
}
class _BookReviewFormPageState extends State<BookReviewFormPage> {
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 = BookReview(
id: const Uuid().v4(),
bookId: widget.bookId,
content: _contentController.text.trim(),
reviewer: _reviewerController.text.trim(),
source: _sourceController.text.trim(),
reviewType: _reviewType,
isDeleted: false,
createdAt: now,
updatedAt: now,
);
await context.read<AppProvider>().addBookReview(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>().updateBookReview(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,270 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
import '../models/data_models.dart';
import 'book_review_form_page.dart';
/// 书籍书评列表页面
class BookReviewsPage extends StatefulWidget {
final Book book;
const BookReviewsPage({super.key, required this.book});
@override
State<BookReviewsPage> createState() => _BookReviewsPageState();
}
class _BookReviewsPageState extends State<BookReviewsPage> {
List<BookReview> _reviews = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadReviews();
}
Future<void> _loadReviews() async {
setState(() => _isLoading = true);
try {
final reviews = await context.read<AppProvider>().getBookReviews(widget.book.id);
setState(() {
_reviews = reviews;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('加载失败: $e')),
);
}
}
@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(BookReview 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,
maxLines: review.reviewType == 1 ? 3 : 5,
overflow: TextOverflow.ellipsis,
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) => BookReviewFormPage(bookId: widget.book.id),
),
).then((_) => _loadReviews());
}
void _navigateToEditReview(BookReview review) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BookReviewFormPage(
bookId: widget.book.id,
review: review,
),
),
).then((_) => _loadReviews());
}
void _showDeleteDialog(BookReview 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>().removeBookReview(review.id);
Navigator.pop(context);
_loadReviews();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已删除')),
);
},
child: const Text('删除', style: TextStyle(color: Colors.red)),
),
],
),
);
}
}

View File

@@ -43,8 +43,16 @@ class BookTabPage extends StatelessWidget {
return RefreshIndicator(
onRefresh: () async => await provider.loadBooks(),
child: ListView.builder(
color: const Color(0xFF1A1A1A),
backgroundColor: Colors.white,
child: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
childAspectRatio: 0.55,
crossAxisSpacing: 12,
mainAxisSpacing: 16,
),
itemCount: books.length,
itemBuilder: (context, index) {
return BookListItem(book: books[index]);
@@ -80,7 +88,17 @@ class BookTabPage extends StatelessWidget {
icon: const Icon(Icons.add),
label: const Text('添加记录'),
onPressed: () {
Navigator.pushNamed(context, '/book-form');
final statusMap = {
0: 'read',
1: 'reading',
2: 'want_to_read',
};
final currentStatus = statusMap[statusIndex]!;
Navigator.pushNamed(
context,
'/book-form',
arguments: {'initialStatus': currentStatus},
);
},
),
],

View File

@@ -4,6 +4,7 @@ import '../providers/app_provider.dart';
import 'movie_tab_page.dart';
import 'book_tab_page.dart';
import 'note_tab_page.dart';
import 'search_page.dart';
/// 主内容页 - 观影/阅读/笔记标签页
class MainContentPage extends StatelessWidget {
@@ -37,7 +38,12 @@ class MainContentPage extends StatelessWidget {
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
// TODO: 搜索功能
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SearchPage(),
),
);
},
),
],
@@ -176,7 +182,18 @@ class MainContentPage extends StatelessWidget {
title: const Text('添加观影'),
onTap: () {
Navigator.pop(context);
Navigator.pushNamed(context, '/movie-form');
// 根据当前影视标签页的选中状态设置默认值
final statusMap = {
0: 'watched',
1: 'watching',
2: 'want_to_watch',
};
final currentStatus = statusMap[provider.movieStatusIndex] ?? 'want_to_watch';
Navigator.pushNamed(
context,
'/movie-form',
arguments: {'initialStatus': currentStatus},
);
},
),
const Divider(height: 0.5, indent: 56),
@@ -185,7 +202,18 @@ class MainContentPage extends StatelessWidget {
title: const Text('添加阅读'),
onTap: () {
Navigator.pop(context);
Navigator.pushNamed(context, '/book-form');
// 根据当前阅读标签页的选中状态设置默认值
final statusMap = {
0: 'read',
1: 'reading',
2: 'want_to_read',
};
final currentStatus = statusMap[provider.bookStatusIndex] ?? 'want_to_read';
Navigator.pushNamed(
context,
'/book-form',
arguments: {'initialStatus': currentStatus},
);
},
),
const Divider(height: 0.5, indent: 56),

View File

@@ -1,6 +1,11 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import 'package:share_plus/share_plus.dart';
import 'package:cross_file/cross_file.dart';
import 'package:permission_handler/permission_handler.dart';
import '../providers/app_provider.dart';
import '../models/data_models.dart';
import 'movie_reviews_page.dart';
@@ -17,14 +22,32 @@ class MovieDetailPage extends StatefulWidget {
}
class _MovieDetailPageState extends State<MovieDetailPage> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
// 页面获得焦点时刷新数据
_refreshMovieData();
}
void _refreshMovieData() {
final provider = context.read<AppProvider>();
// 强制刷新当前影视数据
provider.loadMovies();
}
@override
Widget build(BuildContext context) {
// 从 Provider 获取最新的 movie 数据,实现动态刷新
final movie = context.watch<AppProvider>().movies
.where((m) => m.id == widget.movie.id)
.firstOrNull ?? widget.movie;
return Scaffold(
backgroundColor: Colors.white,
body: CustomScrollView(
slivers: [
// 顶部海报区域
_buildSliverAppBar(),
_buildSliverAppBar(movie),
// 内容区域
SliverToBoxAdapter(
@@ -32,40 +55,36 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 基本信息
_buildBasicInfo(),
_buildBasicInfo(movie),
const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
// 导演
if (widget.movie.directors.isNotEmpty)
_buildDirectorsSection(),
if (movie.directors.isNotEmpty)
_buildDirectorsSection(movie),
// 编剧
if (widget.movie.writers.isNotEmpty)
_buildWritersSection(),
if (movie.writers.isNotEmpty)
_buildWritersSection(movie),
// 主演
if (widget.movie.actors.isNotEmpty)
_buildActorsSection(),
if (movie.actors.isNotEmpty)
_buildActorsSection(movie),
// 类型
if (widget.movie.genres.isNotEmpty)
_buildGenresSection(),
if (movie.genres.isNotEmpty)
_buildGenresSection(movie),
const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
// 简介
if (widget.movie.summary != null && widget.movie.summary!.isNotEmpty)
_buildSummarySection(),
// 别名
if (widget.movie.alternateTitles.isNotEmpty)
_buildAlternateTitlesSection(),
if (movie.summary != null && movie.summary!.isNotEmpty)
_buildSummarySection(movie),
const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
// 影评和海报墙入口
_buildExtraSections(),
_buildExtraSections(movie),
const SizedBox(height: 48),
],
@@ -80,18 +99,53 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
}
/// 构建顶部 AppBar
Widget _buildSliverAppBar() {
Widget _buildSliverAppBar(Movie movie) {
final hasPoster = movie.posterPath != null && movie.posterPath!.isNotEmpty;
return SliverAppBar(
expandedHeight: 280,
expandedHeight: 320,
pinned: true,
backgroundColor: Colors.white,
backgroundColor: const Color(0xFFF5F5F5),
flexibleSpace: FlexibleSpaceBar(
background: _buildPosterSection(),
background: _buildPosterSection(movie),
),
actions: [
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => _navigateToEdit(context),
// 下载海报按钮(仅当有海报时显示)
if (hasPoster)
Container(
margin: const EdgeInsets.all(8),
decoration: const BoxDecoration(
color: Colors.white,
),
child: IconButton(
icon: const Icon(Icons.download_outlined, color: Color(0xFF666666)),
onPressed: () => _downloadPoster(movie),
tooltip: '下载海报',
),
),
// 清空海报按钮(仅当有海报时显示)
if (hasPoster)
Container(
margin: const EdgeInsets.all(8),
decoration: const BoxDecoration(
color: Colors.white,
),
child: IconButton(
icon: const Icon(Icons.hide_image_outlined, color: Color(0xFF666666)),
onPressed: () => _showClearPosterDialog(movie),
tooltip: '清空海报',
),
),
// 编辑按钮
Container(
margin: const EdgeInsets.all(8),
decoration: const BoxDecoration(
color: Colors.white,
),
child: IconButton(
icon: const Icon(Icons.edit_outlined, color: Color(0xFF1A1A1A)),
onPressed: () => _navigateToEdit(context),
),
),
const SizedBox(width: 8),
],
@@ -99,14 +153,12 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
}
/// 构建海报区域
Widget _buildPosterSection() {
return Container(
width: double.infinity,
color: const Color(0xFFF5F5F5),
child: widget.movie.posterPath != null && widget.movie.posterPath!.isNotEmpty
Widget _buildPosterSection(Movie movie) {
return SizedBox.expand(
child: movie.posterPath != null && movie.posterPath!.isNotEmpty
? Image.file(
File(widget.movie.posterPath!),
fit: BoxFit.contain,
File(movie.posterPath!),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildPosterPlaceholder(),
)
: _buildPosterPlaceholder(),
@@ -136,8 +188,44 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
);
}
/// 显示清空海报对话框
void _showClearPosterDialog(Movie movie) {
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 {
Navigator.pop(context);
final updatedMovie = movie.copyWith(
posterPath: null,
updatedAt: DateTime.now(),
);
await context.read<AppProvider>().updateMovie(updatedMovie);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('海报已清空')),
);
}
},
child: const Text('清空', style: TextStyle(color: Colors.red)),
),
],
),
);
}
/// 构建基本信息
Widget _buildBasicInfo() {
Widget _buildBasicInfo(Movie movie) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
@@ -145,7 +233,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
children: [
// 影视名称
Text(
widget.movie.title,
movie.title,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
@@ -154,12 +242,25 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
),
),
// 别名(显示在主名称下面,用 / 分隔)
if (movie.alternateTitles.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
movie.alternateTitles.join(' / '),
style: const TextStyle(
fontSize: 14,
color: Color(0xFF999999),
height: 1.4,
),
),
],
const SizedBox(height: 16),
// 评分和状态
Row(
children: [
if (widget.movie.rating != null) ...[
if (movie.rating != null) ...[
const Icon(
Icons.star,
size: 20,
@@ -167,7 +268,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
),
const SizedBox(width: 4),
Text(
widget.movie.rating!.toStringAsFixed(1),
movie.rating!.toStringAsFixed(1),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
@@ -176,16 +277,16 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
),
const SizedBox(width: 16),
],
_buildStatusTag(),
_buildStatusTag(movie),
],
),
const SizedBox(height: 8),
// 上映日期
if (widget.movie.releaseDate != null)
if (movie.releaseDate != null)
Text(
'${widget.movie.releaseDate!.year}年上映',
'${movie.releaseDate!.year}年上映',
style: const TextStyle(
fontSize: 14,
color: Color(0xFF999999),
@@ -196,7 +297,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
// 时间信息
Text(
'添加于 ${_formatDate(widget.movie.createdAt)}',
'添加于 ${_formatDate(movie.createdAt)}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
@@ -208,10 +309,10 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
}
/// 构建状态标签
Widget _buildStatusTag() {
Widget _buildStatusTag(Movie movie) {
String label;
Color color;
switch (widget.movie.status) {
switch (movie.status) {
case 'watched':
label = '已看';
color = const Color(0xFF1A1A1A);
@@ -246,7 +347,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
}
/// 构建导演区域
Widget _buildDirectorsSection() {
Widget _buildDirectorsSection(Movie movie) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
@@ -265,7 +366,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
Wrap(
spacing: 8,
runSpacing: 8,
children: widget.movie.directors.map((director) {
children: movie.directors.map((director) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
@@ -288,7 +389,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
}
/// 构建编剧区域
Widget _buildWritersSection() {
Widget _buildWritersSection(Movie movie) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: Column(
@@ -307,7 +408,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
Wrap(
spacing: 8,
runSpacing: 8,
children: widget.movie.writers.map((writer) {
children: movie.writers.map((writer) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
@@ -330,7 +431,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
}
/// 构建主演区域
Widget _buildActorsSection() {
Widget _buildActorsSection(Movie movie) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: Column(
@@ -349,7 +450,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
Wrap(
spacing: 8,
runSpacing: 8,
children: widget.movie.actors.map((actor) {
children: movie.actors.map((actor) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
@@ -372,7 +473,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
}
/// 构建类型区域
Widget _buildGenresSection() {
Widget _buildGenresSection(Movie movie) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: Column(
@@ -391,7 +492,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
Wrap(
spacing: 8,
runSpacing: 8,
children: widget.movie.genres.map((genre) {
children: movie.genres.map((genre) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
@@ -413,7 +514,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
}
/// 构建简介区域
Widget _buildSummarySection() {
Widget _buildSummarySection(Movie movie) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
@@ -430,7 +531,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
),
const SizedBox(height: 12),
Text(
widget.movie.summary!,
movie.summary!,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
@@ -442,43 +543,8 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
);
}
/// 构建别名区域
Widget _buildAlternateTitlesSection() {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'别名',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF999999),
letterSpacing: 1,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: widget.movie.alternateTitles.map((title) {
return Text(
title,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF666666),
),
);
}).toList(),
),
],
),
);
}
/// 构建额外功能区域(影评、海报墙)
Widget _buildExtraSections() {
Widget _buildExtraSections(Movie movie) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
@@ -496,7 +562,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
const SizedBox(height: 16),
// 影评入口
GestureDetector(
onTap: () => _navigateToReviews(),
onTap: () => _navigateToReviews(movie),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@@ -524,7 +590,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
),
const SizedBox(height: 4),
FutureBuilder<int>(
future: context.read<AppProvider>().getMovieReviewCount(widget.movie.id),
future: context.read<AppProvider>().getMovieReviewCount(movie.id),
builder: (context, snapshot) {
final count = snapshot.data ?? 0;
return Text(
@@ -550,7 +616,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
const SizedBox(height: 12),
// 海报墙入口
GestureDetector(
onTap: () => _navigateToPosters(),
onTap: () => _navigateToPosters(movie),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@@ -578,7 +644,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
),
const SizedBox(height: 4),
FutureBuilder<int>(
future: context.read<AppProvider>().getMoviePosterCount(widget.movie.id),
future: context.read<AppProvider>().getMoviePosterCount(movie.id),
builder: (context, snapshot) {
final count = snapshot.data ?? 0;
return Text(
@@ -606,20 +672,20 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
);
}
void _navigateToReviews() {
void _navigateToReviews(Movie movie) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MovieReviewsPage(movie: widget.movie),
builder: (context) => MovieReviewsPage(movie: movie),
),
);
}
void _navigateToPosters() {
void _navigateToPosters(Movie movie) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MoviePostersPage(movie: widget.movie),
builder: (context) => MoviePostersPage(movie: movie),
),
);
}
@@ -712,4 +778,73 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
),
);
}
/// 下载海报到本地
Future<void> _downloadPoster(Movie movie) async {
if (movie.posterPath == null || movie.posterPath!.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('没有可下载的海报')),
);
return;
}
try {
final sourceFile = File(movie.posterPath!);
if (!await sourceFile.exists()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('海报文件不存在')),
);
return;
}
// 生成文件名影视名称_时间戳_海报.扩展名
final timestamp = DateTime.now().millisecondsSinceEpoch;
final fileName = '${movie.title}_${timestamp}_海报${path.extension(movie.posterPath!)}';
// 复制到临时目录
final tempDir = await getTemporaryDirectory();
final tempFile = File(path.join(tempDir.path, fileName));
await sourceFile.copy(tempFile.path);
// 使用分享功能让用户选择保存位置
await Share.shareXFiles(
[XFile(tempFile.path)],
subject: '${movie.title} 海报',
text: '下载自 MookNote',
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('下载失败: $e')),
);
}
}
/// 请求存储权限
Future<bool> _requestStoragePermission() async {
// Android 13+ 使用新的权限
if (Platform.isAndroid) {
final sdkInt = await _getAndroidSdkInt();
if (sdkInt >= 33) {
// Android 13+ 使用 READ_MEDIA_IMAGES
final status = await Permission.photos.request();
return status.isGranted;
} else {
// Android 12 及以下使用存储权限
var status = await Permission.storage.request();
if (status.isDenied) {
status = await Permission.storage.request();
}
return status.isGranted;
}
}
// iOS 不需要额外权限来保存到应用沙盒
return true;
}
/// 获取 Android SDK 版本
Future<int> _getAndroidSdkInt() async {
// 简化处理,实际可以通过 platform channel 获取
// 这里默认返回较低版本,使用传统存储权限
return 30;
}
}

View File

@@ -10,8 +10,9 @@ import '../models/data_models.dart';
/// 添加/编辑影视页面 - 紧凑双行布局设计
class MovieFormPage extends StatefulWidget {
final Movie? movie;
final String? initialStatus; // 添加时的默认状态
const MovieFormPage({super.key, this.movie});
const MovieFormPage({super.key, this.movie, this.initialStatus});
@override
State<MovieFormPage> createState() => _MovieFormPageState();
@@ -42,7 +43,22 @@ class _MovieFormPageState extends State<MovieFormPage> {
@override
void initState() {
super.initState();
final movie = widget.movie;
_initializeData();
}
void _initializeData() {
// 如果有传入movie尝试从Provider获取最新数据
Movie? movie = widget.movie;
if (movie != null) {
final appProvider = context.read<AppProvider>();
final latestMovie = appProvider.movies
.where((m) => m.id == movie!.id)
.firstOrNull;
if (latestMovie != null) {
movie = latestMovie;
}
}
_titleController = TextEditingController(text: movie?.title ?? '');
_summaryController = TextEditingController(text: movie?.summary ?? '');
_ratingController = TextEditingController(text: movie?.rating?.toString() ?? '');
@@ -56,6 +72,9 @@ class _MovieFormPageState extends State<MovieFormPage> {
_posterPath = movie.posterPath;
_status = movie.status;
_releaseDate = movie.releaseDate;
} else if (widget.initialStatus != null) {
// 添加模式:使用传入的默认状态
_status = widget.initialStatus!;
}
}
@@ -104,6 +123,16 @@ class _MovieFormPageState extends State<MovieFormPage> {
const SizedBox(height: 32),
// 状态选择(靠左显示)
_buildStatusSelector(),
const SizedBox(height: 20),
// 评分 - 星星选择(靠左显示)
_buildStarRating(),
const SizedBox(height: 32),
// 基本信息区域
_buildFormItem(
label: '影视名称 *',
@@ -235,62 +264,6 @@ class _MovieFormPageState extends State<MovieFormPage> {
),
),
_buildDivider(),
// 评分
_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;
},
),
),
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(height: 48),
],
),
@@ -452,37 +425,173 @@ class _MovieFormPageState extends State<MovieFormPage> {
);
}
/// 构建状态选择 Chip
Widget _buildStatusChip(String label, String value) {
/// 构建状态选择器(靠左显示,带标签)
Widget _buildStatusSelector() {
return Row(
children: [
const Text(
'状态',
style: TextStyle(fontSize: 14, color: Color(0xFF666666)),
),
const SizedBox(width: 16),
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildStatusOption('想看', 'want_to_watch'),
_buildStatusOption('在看', 'watching'),
_buildStatusOption('已看', 'watched'),
],
),
),
],
);
}
/// 构建星星评分5星制每星2分支持手动输入
Widget _buildStarRating() {
return Row(
children: [
const Text(
'评分',
style: TextStyle(fontSize: 14, color: Color(0xFF666666)),
),
const SizedBox(width: 16),
// 星星选择
_buildStarSelector(),
const SizedBox(width: 12),
// 手动输入框
_buildRatingInputField(),
],
);
}
/// 构建星星选择器
Widget _buildStarSelector() {
final currentRating = double.tryParse(_ratingController.text) ?? 0;
final starRating = currentRating / 2;
return Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(5, (index) {
final starValue = index + 1;
final scoreValue = starValue * 2;
final isFilled = starValue <= starRating;
final isHalf = starValue == starRating.ceil() && starRating % 1 != 0;
return InkWell(
onTap: () {
setState(() {
_ratingController.text = scoreValue.toString();
});
},
borderRadius: BorderRadius.circular(4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4),
child: Icon(
isHalf
? Icons.star_half
: isFilled
? Icons.star
: Icons.star_border,
size: 24,
color: isFilled || isHalf
? const Color(0xFFFFB800)
: const Color(0xFFE5E5E5),
),
),
);
}),
),
);
}
/// 构建评分输入框
Widget _buildRatingInputField() {
return Container(
width: 56,
height: 36,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(8),
),
child: TextFormField(
controller: _ratingController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
decoration: const InputDecoration(
hintText: '-',
hintStyle: TextStyle(fontSize: 15, color: Color(0xFFCCCCCC)),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
validator: (value) {
if (value != null && value.isNotEmpty) {
final rating = double.tryParse(value);
if (rating == null || rating < 0 || rating > 10) {
return '0-10';
}
}
return null;
},
onChanged: (value) {
// 限制输入范围
if (value.isNotEmpty) {
final rating = double.tryParse(value);
if (rating != null) {
if (rating > 10) {
_ratingController.text = '10';
} else if (rating < 0) {
_ratingController.text = '0';
}
}
}
setState(() {}); // 更新星星显示
},
),
);
}
/// 构建状态选项
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: 6),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? color : Colors.transparent,
border: Border.all(color: color),
color: isSelected ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(6),
boxShadow: isSelected
? [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
]
: null,
),
child: Text(
label,
style: TextStyle(
fontSize: 13,
color: isSelected ? Colors.white : color,
fontSize: 14,
fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal,
color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFF999999),
),
),
),
@@ -491,23 +600,61 @@ class _MovieFormPageState extends State<MovieFormPage> {
/// 构建封面选择器
Widget _buildCoverPicker() {
return GestureDetector(
onTap: _pickCover,
child: Container(
width: 140,
height: 200,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5),
final hasPoster = _posterPath != null && _posterPath!.isNotEmpty;
return Column(
children: [
GestureDetector(
onTap: _pickCover,
child: Container(
width: 140,
height: 200,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5),
),
child: hasPoster
? Image.file(
File(_posterPath!),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildCoverPlaceholder(),
)
: _buildCoverPlaceholder(),
),
),
child: _posterPath != null && _posterPath!.isNotEmpty
? Image.file(
File(_posterPath!),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildCoverPlaceholder(),
)
: _buildCoverPlaceholder(),
),
// 清空海报按钮(仅当有海报时显示)
if (hasPoster)
Padding(
padding: const EdgeInsets.only(top: 12),
child: GestureDetector(
onTap: () => setState(() => _posterPath = null),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.hide_image_outlined,
size: 16,
color: Color(0xFF666666),
),
SizedBox(width: 4),
Text(
'清空海报',
style: TextStyle(
fontSize: 13,
color: Color(0xFF666666),
),
),
],
),
),
),
),
],
);
}

View File

@@ -1,11 +1,14 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import 'package:provider/provider.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import '../providers/app_provider.dart';
import '../models/data_models.dart';
import '../utils/toast_util.dart';
/// 影视海报墙页面
class MoviePostersPage extends StatefulWidget {
@@ -93,64 +96,96 @@ class _MoviePostersPageState extends State<MoviePostersPage> {
}
Widget _buildPosterGrid() {
return GridView.builder(
return MasonryGridView.count(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.7,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
itemCount: _posters.length,
itemBuilder: (context, index) {
final poster = _posters[index];
return _buildPosterItem(poster);
return _buildPosterItem(poster, index);
},
);
}
Widget _buildPosterItem(MoviePoster poster) {
Widget _buildPosterItem(MoviePoster poster, int index) {
// 根据索引生成不同的高度,实现瀑布流效果
final heights = [180.0, 220.0, 160.0, 200.0, 240.0, 190.0];
final height = heights[index % heights.length];
return GestureDetector(
onTap: () => _showPosterDetail(poster),
child: Container(
height: height,
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,
),
),
),
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
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(
bottom: 0,
left: 0,
right: 0,
child: Container(
height: 40,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.3),
],
),
),
),
),
// 删除按钮
Positioned(
top: 8,
right: 8,
child: GestureDetector(
onTap: () => _showDeleteDialog(poster),
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(4),
),
child: const Icon(
Icons.close,
size: 16,
color: Colors.red,
),
),
),
),
],
),
),
),
);
}
@@ -208,9 +243,7 @@ class _MoviePostersPageState extends State<MoviePostersPage> {
_loadPosters();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('添加成功')),
);
ToastUtil.show(context, '添加成功');
}
}
} catch (e) {
@@ -241,9 +274,7 @@ class _MoviePostersPageState extends State<MoviePostersPage> {
await context.read<AppProvider>().removeMoviePoster(poster.id);
Navigator.pop(context);
_loadPosters();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已删除')),
);
ToastUtil.show(context, '已删除');
},
child: const Text('删除', style: TextStyle(color: Colors.red)),
),

View File

@@ -157,6 +157,8 @@ class _MovieReviewsPageState extends State<MovieReviewsPage> {
// 评论内容
Text(
review.content,
maxLines: review.reviewType == 1 ? 3 : 5,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),

View File

@@ -31,8 +31,8 @@ class MovieTabPage extends StatelessWidget {
builder: (context, provider, child) {
final statusMap = {
0: 'watched',
1: 'want_to_watch',
2: 'watching',
1: 'watching',
2: 'want_to_watch',
};
final currentStatus = statusMap[provider.movieStatusIndex]!;
final movies = provider.getMoviesByStatus(currentStatus);
@@ -45,8 +45,14 @@ class MovieTabPage extends StatelessWidget {
onRefresh: () async => await provider.loadMovies(),
color: const Color(0xFF1A1A1A),
backgroundColor: Colors.white,
child: ListView.builder(
padding: EdgeInsets.zero,
child: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
childAspectRatio: 0.55,
crossAxisSpacing: 12,
mainAxisSpacing: 16,
),
itemCount: movies.length,
itemBuilder: (context, index) {
return MovieListItem(movie: movies[index]);
@@ -59,7 +65,7 @@ class MovieTabPage extends StatelessWidget {
/// 构建空状态
Widget _buildEmptyState(BuildContext context, int statusIndex) {
final statusText = ['已看', '', ''][statusIndex];
final statusText = ['已看', '', ''][statusIndex];
return Center(
child: Column(
@@ -80,7 +86,19 @@ class MovieTabPage extends StatelessWidget {
),
const SizedBox(height: 24),
TextButton(
onPressed: () => Navigator.pushNamed(context, '/movie-form'),
onPressed: () {
final statusMap = {
0: 'watched',
1: 'watching',
2: 'want_to_watch',
};
final currentStatus = statusMap[statusIndex]!;
Navigator.pushNamed(
context,
'/movie-form',
arguments: {'initialStatus': currentStatus},
);
},
child: const Text('添加记录'),
),
],

View File

@@ -6,6 +6,9 @@ import 'package:path/path.dart' as path;
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
import '../utils/user_prefs.dart';
import 'recycle_bin_page.dart';
import 'backup_page.dart';
import 'statistics_page.dart';
/// 个人中心页面 - 极简主义设计
class ProfilePage extends StatefulWidget {
@@ -380,7 +383,12 @@ class _ProfilePageState extends State<ProfilePage> {
_buildMenuItem(
icon: Icons.analytics_outlined,
title: '数据统计',
onTap: () => _showToast('详细统计功能开发中'),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const StatisticsPage()),
);
},
),
const Divider(height: 0.5, thickness: 0.5, indent: 56, color: Color(0xFFE5E5E5)),
@@ -388,21 +396,24 @@ class _ProfilePageState extends State<ProfilePage> {
_buildMenuItem(
icon: Icons.delete_outline,
title: '回收站',
onTap: () => _showToast('回收站功能开发中'),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const RecycleBinPage()),
);
},
),
const Divider(height: 0.5, thickness: 0.5, indent: 56, color: Color(0xFFE5E5E5)),
_buildMenuItem(
icon: Icons.backup_outlined,
title: '数据备份',
onTap: () => _showToast('备份功能开发中'),
),
const Divider(height: 0.5, thickness: 0.5, indent: 56, color: Color(0xFFE5E5E5)),
_buildMenuItem(
icon: Icons.settings_outlined,
title: '设置',
onTap: () => _showSettings(context),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const BackupPage()),
);
},
),
],
);

View File

@@ -0,0 +1,376 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
import '../models/data_models.dart';
/// 回收站页面
class RecycleBinPage extends StatefulWidget {
const RecycleBinPage({super.key});
@override
State<RecycleBinPage> createState() => _RecycleBinPageState();
}
class _RecycleBinPageState extends State<RecycleBinPage> with SingleTickerProviderStateMixin {
late TabController _tabController;
List<Movie> _deletedMovies = [];
List<Book> _deletedBooks = [];
List<Note> _deletedNotes = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_loadDeletedItems();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> _loadDeletedItems() async {
setState(() => _isLoading = true);
final provider = context.read<AppProvider>();
final movies = await provider.getDeletedMovies();
final books = await provider.getDeletedBooks();
final notes = await provider.getDeletedNotes();
setState(() {
_deletedMovies = movies;
_deletedBooks = books;
_deletedNotes = notes;
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text('回收站'),
bottom: TabBar(
controller: _tabController,
labelColor: const Color(0xFF1A1A1A),
unselectedLabelColor: const Color(0xFF999999),
indicatorColor: const Color(0xFF1A1A1A),
tabs: [
Tab(text: '影视 (${_deletedMovies.length})'),
Tab(text: '书籍 (${_deletedBooks.length})'),
Tab(text: '笔记 (${_deletedNotes.length})'),
],
),
actions: [
// 清空全部
TextButton(
onPressed: _showClearAllDialog,
child: const Text(
'清空',
style: TextStyle(color: Colors.red),
),
),
const SizedBox(width: 8),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: TabBarView(
controller: _tabController,
children: [
_buildMovieList(),
_buildBookList(),
_buildNoteList(),
],
),
);
}
/// 影视列表
Widget _buildMovieList() {
if (_deletedMovies.isEmpty) {
return _buildEmptyState('暂无删除的影视');
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _deletedMovies.length,
itemBuilder: (context, index) {
final movie = _deletedMovies[index];
return _buildDeletedItemCard(
title: movie.title,
subtitle: '删除于 ${_formatDate(movie.updatedAt)}',
onRestore: () => _restoreMovie(movie),
onDelete: () => _permanentDeleteMovie(movie),
);
},
);
}
/// 书籍列表
Widget _buildBookList() {
if (_deletedBooks.isEmpty) {
return _buildEmptyState('暂无删除的书籍');
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _deletedBooks.length,
itemBuilder: (context, index) {
final book = _deletedBooks[index];
return _buildDeletedItemCard(
title: book.title,
subtitle: '删除于 ${_formatDate(book.updatedAt)}',
onRestore: () => _restoreBook(book),
onDelete: () => _permanentDeleteBook(book),
);
},
);
}
/// 笔记列表
Widget _buildNoteList() {
if (_deletedNotes.isEmpty) {
return _buildEmptyState('暂无删除的笔记');
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _deletedNotes.length,
itemBuilder: (context, index) {
final note = _deletedNotes[index];
return _buildDeletedItemCard(
title: note.summary,
subtitle: '删除于 ${_formatDate(note.updatedAt)}',
onRestore: () => _restoreNote(note),
onDelete: () => _permanentDeleteNote(note),
);
},
);
}
/// 构建已删除项卡片
Widget _buildDeletedItemCard({
required String title,
required String subtitle,
required VoidCallback onRestore,
required VoidCallback onDelete,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// 恢复按钮
OutlinedButton(
onPressed: onRestore,
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF1A1A1A),
side: const BorderSide(color: Color(0xFF1A1A1A)),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('恢复'),
),
const SizedBox(width: 12),
// 彻底删除按钮
OutlinedButton(
onPressed: onDelete,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
side: const BorderSide(color: Colors.red),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('彻底删除'),
),
],
),
],
),
);
}
Widget _buildEmptyState(String message) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.delete_outline,
size: 64,
color: Color(0xFFCCCCCC),
),
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(
fontSize: 16,
color: Color(0xFF999999),
),
),
],
),
);
}
String _formatDate(DateTime date) {
return '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}';
}
/// 恢复影视
Future<void> _restoreMovie(Movie movie) async {
await context.read<AppProvider>().restoreMovie(movie.id);
_loadDeletedItems();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('影视已恢复')),
);
}
}
/// 彻底删除影视
Future<void> _permanentDeleteMovie(Movie movie) async {
final confirmed = await _showConfirmDialog('确定要彻底删除这部影视吗?此操作不可恢复。');
if (confirmed) {
await context.read<AppProvider>().permanentDeleteMovie(movie.id);
_loadDeletedItems();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已彻底删除')),
);
}
}
}
/// 恢复书籍
Future<void> _restoreBook(Book book) async {
await context.read<AppProvider>().restoreBook(book.id);
_loadDeletedItems();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('书籍已恢复')),
);
}
}
/// 彻底删除书籍
Future<void> _permanentDeleteBook(Book book) async {
final confirmed = await _showConfirmDialog('确定要彻底删除这本书籍吗?此操作不可恢复。');
if (confirmed) {
await context.read<AppProvider>().permanentDeleteBook(book.id);
_loadDeletedItems();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已彻底删除')),
);
}
}
}
/// 恢复笔记
Future<void> _restoreNote(Note note) async {
await context.read<AppProvider>().restoreNote(note.id);
_loadDeletedItems();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('笔记已恢复')),
);
}
}
/// 彻底删除笔记
Future<void> _permanentDeleteNote(Note note) async {
final confirmed = await _showConfirmDialog('确定要彻底删除这条笔记吗?此操作不可恢复。');
if (confirmed) {
await context.read<AppProvider>().permanentDeleteNote(note.id);
_loadDeletedItems();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已彻底删除')),
);
}
}
}
/// 显示确认对话框
Future<bool> _showConfirmDialog(String message) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: Colors.white,
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
title: const Text('确认删除'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消', style: TextStyle(color: Color(0xFF666666))),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('删除', style: TextStyle(color: Colors.red)),
),
],
),
);
return result ?? false;
}
/// 显示清空全部对话框
void _showClearAllDialog() {
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 {
Navigator.pop(context);
await context.read<AppProvider>().clearRecycleBin();
_loadDeletedItems();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('回收站已清空')),
);
}
},
child: const Text('清空', style: TextStyle(color: Colors.red)),
),
],
),
);
}
}

486
lib/pages/search_page.dart Normal file
View File

@@ -0,0 +1,486 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
import '../models/data_models.dart';
import 'movie_detail_page.dart';
import 'book_detail_page.dart';
import 'note_detail_page.dart';
/// 搜索页面
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
final _searchController = TextEditingController();
int _selectedType = 0; // 0: 影视, 1: 书籍, 2: 笔记
List<dynamic> _results = [];
bool _isSearching = false;
final List<String> _typeLabels = ['影视', '书籍', '笔记'];
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _performSearch() async {
final keyword = _searchController.text.trim();
if (keyword.isEmpty) return;
setState(() => _isSearching = true);
try {
List<dynamic> results;
final provider = context.read<AppProvider>();
switch (_selectedType) {
case 0: // 影视
results = provider.movies.where((movie) {
return _matchMovie(movie, keyword);
}).toList();
break;
case 1: // 书籍
results = provider.books.where((book) {
return _matchBook(book, keyword);
}).toList();
break;
case 2: // 笔记
results = provider.notes.where((note) {
return _matchNote(note, keyword);
}).toList();
break;
default:
results = [];
}
setState(() {
_results = results;
_isSearching = false;
});
} catch (e) {
setState(() => _isSearching = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('搜索失败: $e')),
);
}
}
bool _matchMovie(Movie movie, String keyword) {
final lowerKeyword = keyword.toLowerCase();
return movie.title.toLowerCase().contains(lowerKeyword) ||
movie.alternateTitles.any((t) => t.toLowerCase().contains(lowerKeyword)) ||
(movie.summary?.toLowerCase().contains(lowerKeyword) ?? false);
}
bool _matchBook(Book book, String keyword) {
final lowerKeyword = keyword.toLowerCase();
return book.title.toLowerCase().contains(lowerKeyword) ||
book.alternateTitles.any((t) => t.toLowerCase().contains(lowerKeyword)) ||
(book.summary?.toLowerCase().contains(lowerKeyword) ?? false);
}
bool _matchNote(Note note, String keyword) {
return note.content.toLowerCase().contains(keyword.toLowerCase());
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text('搜索'),
),
body: 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: List.generate(_typeLabels.length, (index) {
final isSelected = _selectedType == index;
return GestureDetector(
onTap: () {
setState(() {
_selectedType = index;
_results = [];
});
if (_searchController.text.isNotEmpty) {
_performSearch();
}
},
child: Container(
margin: const EdgeInsets.only(right: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFF5F5F5),
border: Border.all(
color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFE5E5E5),
),
),
child: Text(
_typeLabels[index],
style: TextStyle(
fontSize: 14,
color: isSelected ? Colors.white : const Color(0xFF666666),
),
),
),
);
}),
),
),
// 搜索输入框
Container(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchController,
autofocus: true,
decoration: InputDecoration(
hintText: _getSearchHint(),
hintStyle: const TextStyle(color: Color(0xFF999999)),
prefixIcon: const Icon(Icons.search, color: Color(0xFF999999)),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, color: Color(0xFF999999)),
onPressed: () {
_searchController.clear();
setState(() => _results = []);
},
)
: null,
border: const OutlineInputBorder(
borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: Color(0xFFE5E5E5)),
),
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: Color(0xFF1A1A1A)),
),
),
onSubmitted: (_) => _performSearch(),
onChanged: (_) => setState(() {}),
),
),
// 搜索结果
Expanded(
child: _isSearching
? const Center(child: CircularProgressIndicator())
: _results.isEmpty
? _buildEmptyState()
: _buildResultList(),
),
],
),
);
}
String _getSearchHint() {
switch (_selectedType) {
case 0:
return '搜索影视名称、别名、简介...';
case 1:
return '搜索书籍名称、别名、简介...';
case 2:
return '搜索笔记内容...';
default:
return '请输入搜索关键词';
}
}
Widget _buildEmptyState() {
if (_searchController.text.isEmpty) {
return const Center(
child: Text(
'输入关键词开始搜索',
style: TextStyle(color: Color(0xFF999999)),
),
);
}
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64, color: Color(0xFFCCCCCC)),
SizedBox(height: 16),
Text(
'未找到相关内容',
style: TextStyle(color: Color(0xFF999999)),
),
],
),
);
}
Widget _buildResultList() {
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _results.length,
itemBuilder: (context, index) {
final item = _results[index];
if (item is Movie) {
return _buildMovieItem(item);
} else if (item is Book) {
return _buildBookItem(item);
} else if (item is Note) {
return _buildNoteItem(item);
}
return const SizedBox.shrink();
},
);
}
Widget _buildMovieItem(Movie movie) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MovieDetailPage(movie: movie),
),
);
},
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Row(
children: [
// 海报
Container(
width: 60,
height: 80,
color: const Color(0xFFF5F5F5),
child: movie.posterPath != null
? Image.file(
File(movie.posterPath!),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Icon(Icons.movie, color: Color(0xFFCCCCCC)),
)
: const Icon(Icons.movie, color: Color(0xFFCCCCCC)),
),
const SizedBox(width: 12),
// 信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
movie.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
if (movie.alternateTitles.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
movie.alternateTitles.join(' / '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
),
],
const SizedBox(height: 4),
_buildStatusTag(movie.status),
],
),
),
],
),
),
);
}
Widget _buildBookItem(Book book) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BookDetailPage(book: book),
),
);
},
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Row(
children: [
// 封面
Container(
width: 60,
height: 80,
color: const Color(0xFFF5F5F5),
child: book.coverPath != null
? Image.file(
File(book.coverPath!),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Icon(Icons.book, color: Color(0xFFCCCCCC)),
)
: const Icon(Icons.book, color: Color(0xFFCCCCCC)),
),
const SizedBox(width: 12),
// 信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
book.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
if (book.alternateTitles.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
book.alternateTitles.join(' / '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
),
],
const SizedBox(height: 4),
_buildBookStatusTag(book.status),
],
),
),
],
),
),
);
}
Widget _buildNoteItem(Note note) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NoteDetailPage(note: note),
),
);
},
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
note.content,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
height: 1.5,
),
),
const SizedBox(height: 8),
Text(
'${note.createdAt.year}.${note.createdAt.month.toString().padLeft(2, '0')}.${note.createdAt.day.toString().padLeft(2, '0')}',
style: const TextStyle(
fontSize: 11,
color: Color(0xFF999999),
),
),
],
),
),
);
}
Widget _buildStatusTag(String status) {
String label;
Color color;
switch (status) {
case 'watched':
label = '已看';
color = const Color(0xFF1A1A1A);
break;
case 'watching':
label = '在看';
color = const Color(0xFF666666);
break;
case 'want_to_watch':
label = '想看';
color = const Color(0xFF999999);
break;
default:
label = '未知';
color = const Color(0xFFCCCCCC);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(color: color),
child: Text(
label,
style: const TextStyle(fontSize: 11, color: Colors.white),
),
);
}
Widget _buildBookStatusTag(String status) {
String label;
Color color;
switch (status) {
case 'read':
label = '已读';
color = const Color(0xFF1A1A1A);
break;
case 'reading':
label = '在读';
color = const Color(0xFF666666);
break;
case 'want_to_read':
label = '想读';
color = const Color(0xFF999999);
break;
default:
label = '未知';
color = const Color(0xFFCCCCCC);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(color: color),
child: Text(
label,
style: const TextStyle(fontSize: 11, color: Colors.white),
),
);
}
}

View File

@@ -0,0 +1,728 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:fl_chart/fl_chart.dart';
import '../providers/app_provider.dart';
import '../models/data_models.dart';
/// 数据统计页面 - 多维度数据分析
class StatisticsPage extends StatefulWidget {
const StatisticsPage({super.key});
@override
State<StatisticsPage> createState() => _StatisticsPageState();
}
class _StatisticsPageState extends State<StatisticsPage> with SingleTickerProviderStateMixin {
late TabController _tabController;
DateTime _selectedMonth = DateTime.now();
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text('数据统计'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: '概览'),
Tab(text: '日历'),
Tab(text: '趋势'),
],
labelColor: const Color(0xFF1A1A1A),
unselectedLabelColor: const Color(0xFF999999),
indicatorColor: const Color(0xFF1A1A1A),
),
),
body: Consumer<AppProvider>(
builder: (context, provider, child) {
final movies = provider.movies;
final books = provider.books;
final notes = provider.notes;
return TabBarView(
controller: _tabController,
children: [
_buildOverviewTab(movies, books, notes),
_buildCalendarTab(movies, books, notes),
_buildTrendTab(movies, books, notes),
],
);
},
),
),
);
}
/// 概览标签页
Widget _buildOverviewTab(List<Movie> movies, List<Book> books, List<Note> notes) {
final totalMovies = movies.length;
final totalBooks = books.length;
final totalNotes = notes.length;
final watchedMovies = movies.where((m) => m.status == 'watched').length;
final watchingMovies = movies.where((m) => m.status == 'watching').length;
final wantToWatchMovies = movies.where((m) => m.status == 'want_to_watch').length;
final readBooks = books.where((b) => b.status == 'read').length;
final readingBooks = books.where((b) => b.status == 'reading').length;
final wantToReadBooks = books.where((b) => b.status == 'want_to_read').length;
return ListView(
padding: const EdgeInsets.all(24),
children: [
// 总数据卡片
_buildSectionTitle('数据总览'),
const SizedBox(height: 16),
Row(
children: [
Expanded(child: _buildStatCard('影视', totalMovies, Icons.movie_outlined, const Color(0xFF1A1A1A))),
const SizedBox(width: 12),
Expanded(child: _buildStatCard('书籍', totalBooks, Icons.menu_book_outlined, const Color(0xFF666666))),
const SizedBox(width: 12),
Expanded(child: _buildStatCard('笔记', totalNotes, Icons.note_outlined, const Color(0xFF999999))),
],
),
const SizedBox(height: 32),
// 影视状态分布
_buildSectionTitle('影视状态分布'),
const SizedBox(height: 16),
_buildStatusDistribution([
_StatusData('已看', watchedMovies, const Color(0xFF1A1A1A)),
_StatusData('在看', watchingMovies, const Color(0xFF666666)),
_StatusData('想看', wantToWatchMovies, const Color(0xFF999999)),
]),
const SizedBox(height: 32),
// 书籍状态分布
_buildSectionTitle('书籍状态分布'),
const SizedBox(height: 16),
_buildStatusDistribution([
_StatusData('已读', readBooks, const Color(0xFF1A1A1A)),
_StatusData('在读', readingBooks, const Color(0xFF666666)),
_StatusData('想读', wantToReadBooks, const Color(0xFF999999)),
]),
const SizedBox(height: 32),
// 最近活动
_buildSectionTitle('最近7天活动'),
const SizedBox(height: 16),
_buildRecentActivity(movies, books, notes),
],
);
}
/// 日历标签页
Widget _buildCalendarTab(List<Movie> movies, List<Book> books, List<Note> notes) {
// 合并所有数据按日期
final Map<DateTime, _DailyData> dailyData = {};
for (final movie in movies) {
final date = DateTime(movie.createdAt.year, movie.createdAt.month, movie.createdAt.day);
dailyData.putIfAbsent(date, () => _DailyData()).movies++;
}
for (final book in books) {
final date = DateTime(book.createdAt.year, book.createdAt.month, book.createdAt.day);
dailyData.putIfAbsent(date, () => _DailyData()).books++;
}
for (final note in notes) {
final date = DateTime(note.createdAt.year, note.createdAt.month, note.createdAt.day);
dailyData.putIfAbsent(date, () => _DailyData()).notes++;
}
return Column(
children: [
// 月份选择器
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: () {
setState(() {
_selectedMonth = DateTime(_selectedMonth.year, _selectedMonth.month - 1);
});
},
),
Text(
'${_selectedMonth.year}${_selectedMonth.month}',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: () {
setState(() {
_selectedMonth = DateTime(_selectedMonth.year, _selectedMonth.month + 1);
});
},
),
],
),
),
// 热力图图例
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Text('', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
const SizedBox(width: 8),
...List.generate(4, (index) {
final opacity = 0.2 + (index * 0.2);
return Container(
width: 12,
height: 12,
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A).withOpacity(opacity),
borderRadius: BorderRadius.circular(2),
),
);
}),
const SizedBox(width: 8),
const Text('', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
],
),
),
const SizedBox(height: 16),
// 日历热力图
Expanded(
child: _buildHeatMapCalendar(dailyData),
),
],
);
}
/// 趋势标签页
Widget _buildTrendTab(List<Movie> movies, List<Book> books, List<Note> notes) {
// 近30天每日新增数据
final now = DateTime.now();
final thirtyDaysAgo = now.subtract(const Duration(days: 29));
final List<_DailyTrend> movieTrend = [];
final List<_DailyTrend> bookTrend = [];
final List<_DailyTrend> noteTrend = [];
for (int i = 0; i < 30; i++) {
final date = DateTime(thirtyDaysAgo.year, thirtyDaysAgo.month, thirtyDaysAgo.day + i);
final movieCount = movies.where((m) {
final mDate = DateTime(m.createdAt.year, m.createdAt.month, m.createdAt.day);
return mDate.isAtSameMomentAs(date);
}).length;
final bookCount = books.where((b) {
final bDate = DateTime(b.createdAt.year, b.createdAt.month, b.createdAt.day);
return bDate.isAtSameMomentAs(date);
}).length;
final noteCount = notes.where((n) {
final nDate = DateTime(n.createdAt.year, n.createdAt.month, n.createdAt.day);
return nDate.isAtSameMomentAs(date);
}).length;
movieTrend.add(_DailyTrend(date, movieCount));
bookTrend.add(_DailyTrend(date, bookCount));
noteTrend.add(_DailyTrend(date, noteCount));
}
return ListView(
padding: const EdgeInsets.all(24),
children: [
_buildSectionTitle('近30天新增趋势'),
const SizedBox(height: 8),
const Text(
'每日新增数据统计',
style: TextStyle(fontSize: 13, color: Color(0xFF999999)),
),
const SizedBox(height: 24),
// 折线图
SizedBox(
height: 250,
child: _buildLineChart(movieTrend, bookTrend, noteTrend),
),
const SizedBox(height: 32),
// 图例
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLegendItem('影视', const Color(0xFF1A1A1A)),
const SizedBox(width: 24),
_buildLegendItem('书籍', const Color(0xFF666666)),
const SizedBox(width: 24),
_buildLegendItem('笔记', const Color(0xFF999999)),
],
),
const SizedBox(height: 32),
// 近7天统计
_buildSectionTitle('近7天新增统计'),
const SizedBox(height: 16),
_buildWeeklyStats(movieTrend.sublist(23), bookTrend.sublist(23), noteTrend.sublist(23)),
],
);
}
/// 构建统计卡片
Widget _buildStatCard(String title, int count, IconData icon, Color color) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Column(
children: [
Icon(icon, color: color, size: 24),
const SizedBox(height: 8),
Text(
count.toString(),
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
color: color,
),
),
const SizedBox(height: 4),
Text(
title,
style: const TextStyle(fontSize: 12, color: Color(0xFF666666)),
),
],
),
);
}
/// 构建状态分布
Widget _buildStatusDistribution(List<_StatusData> data) {
final total = data.fold(0, (sum, item) => sum + item.count);
return Column(
children: data.map((item) {
final percentage = total > 0 ? (item.count / total * 100).toStringAsFixed(1) : '0';
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(color: item.color),
),
const SizedBox(width: 12),
Text(item.label, style: const TextStyle(fontSize: 14)),
const Spacer(),
Text(
'${item.count} ($percentage%)',
style: const TextStyle(fontSize: 14, color: Color(0xFF666666)),
),
],
),
);
}).toList(),
);
}
/// 构建最近活动
Widget _buildRecentActivity(List<Movie> movies, List<Book> books, List<Note> notes) {
final now = DateTime.now();
final sevenDaysAgo = now.subtract(const Duration(days: 7));
final recentMovies = movies.where((m) => m.createdAt.isAfter(sevenDaysAgo)).length;
final recentBooks = books.where((b) => b.createdAt.isAfter(sevenDaysAgo)).length;
final recentNotes = notes.where((n) => n.createdAt.isAfter(sevenDaysAgo)).length;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Column(
children: [
_buildActivityRow('新增影视', recentMovies, Icons.movie_outlined),
const Divider(height: 24),
_buildActivityRow('新增书籍', recentBooks, Icons.menu_book_outlined),
const Divider(height: 24),
_buildActivityRow('新增笔记', recentNotes, Icons.note_outlined),
],
),
);
}
Widget _buildActivityRow(String label, int count, IconData icon) {
return Row(
children: [
Icon(icon, size: 20, color: const Color(0xFF666666)),
const SizedBox(width: 12),
Text(label, style: const TextStyle(fontSize: 14)),
const Spacer(),
Text(
'$count',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
],
);
}
/// 构建热力图日历
Widget _buildHeatMapCalendar(Map<DateTime, _DailyData> dailyData) {
final year = _selectedMonth.year;
final month = _selectedMonth.month;
final daysInMonth = DateTime(year, month + 1, 0).day;
final firstWeekday = DateTime(year, month, 1).weekday % 7;
// 计算最大数量用于颜色强度
int maxCount = 0;
for (final data in dailyData.values) {
final count = data.total;
if (count > maxCount) maxCount = count;
}
if (maxCount == 0) maxCount = 1;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
// 星期标题
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: const ['', '', '', '', '', '', '']
.map((d) => SizedBox(
width: 36,
child: Text(
d,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: Color(0xFF999999)),
),
))
.toList(),
),
const SizedBox(height: 8),
// 日历网格
Wrap(
spacing: 8,
runSpacing: 8,
children: [
// 空白填充
...List.generate(firstWeekday, (_) => const SizedBox(width: 36, height: 36)),
// 日期
...List.generate(daysInMonth, (index) {
final day = index + 1;
final date = DateTime(year, month, day);
final data = dailyData[date];
final count = data?.total ?? 0;
// 计算颜色强度
double opacity = 0.1;
if (count > 0) {
opacity = 0.3 + (count / maxCount * 0.7);
opacity = opacity.clamp(0.3, 1.0);
}
return GestureDetector(
onTap: () {
if (count > 0) {
_showDayDetail(context, date, data!);
}
},
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: count > 0
? const Color(0xFF1A1A1A).withOpacity(opacity)
: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(4),
),
child: Center(
child: Text(
day.toString(),
style: TextStyle(
fontSize: 12,
color: count > 0 ? Colors.white : const Color(0xFF666666),
),
),
),
),
);
}),
],
),
],
),
);
}
/// 显示某天详情
void _showDayDetail(BuildContext context, DateTime date, _DailyData data) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
builder: (context) => Container(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${date.year}${date.month}${date.day}',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
const SizedBox(height: 16),
if (data.movies > 0)
_buildDetailRow(Icons.movie_outlined, '影视', data.movies),
if (data.books > 0)
_buildDetailRow(Icons.menu_book_outlined, '书籍', data.books),
if (data.notes > 0)
_buildDetailRow(Icons.note_outlined, '笔记', data.notes),
const SizedBox(height: 16),
Text(
'总计: ${data.total}',
style: const TextStyle(fontSize: 14, color: Color(0xFF666666)),
),
],
),
),
);
}
Widget _buildDetailRow(IconData icon, String label, int count) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Icon(icon, size: 18, color: const Color(0xFF666666)),
const SizedBox(width: 8),
Text(label, style: const TextStyle(fontSize: 14)),
const Spacer(),
Text('$count', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
],
),
);
}
/// 构建折线图
Widget _buildLineChart(List<_DailyTrend> movieTrend, List<_DailyTrend> bookTrend, List<_DailyTrend> noteTrend) {
final spotsMovie = movieTrend.asMap().entries.map((e) {
return FlSpot(e.key.toDouble(), e.value.count.toDouble());
}).toList();
final spotsBook = bookTrend.asMap().entries.map((e) {
return FlSpot(e.key.toDouble(), e.value.count.toDouble());
}).toList();
final spotsNote = noteTrend.asMap().entries.map((e) {
return FlSpot(e.key.toDouble(), e.value.count.toDouble());
}).toList();
return LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 1,
getDrawingHorizontalLine: (value) {
return FlLine(
color: const Color(0xFFE5E5E5),
strokeWidth: 0.5,
);
},
),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
getTitlesWidget: (value, meta) {
return Text(
value.toInt().toString(),
style: const TextStyle(fontSize: 10, color: Color(0xFF999999)),
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: 5,
getTitlesWidget: (value, meta) {
if (value.toInt() >= 0 && value.toInt() < movieTrend.length) {
final date = movieTrend[value.toInt()].date;
return Text(
'${date.month}/${date.day}',
style: const TextStyle(fontSize: 10, color: Color(0xFF999999)),
);
}
return const Text('');
},
),
),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
borderData: FlBorderData(show: false),
lineBarsData: [
// 影视折线
LineChartBarData(
spots: spotsMovie,
isCurved: true,
color: const Color(0xFF1A1A1A),
barWidth: 2,
isStrokeCapRound: true,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(show: false),
),
// 书籍折线
LineChartBarData(
spots: spotsBook,
isCurved: true,
color: const Color(0xFF666666),
barWidth: 2,
isStrokeCapRound: true,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(show: false),
),
// 笔记折线
LineChartBarData(
spots: spotsNote,
isCurved: true,
color: const Color(0xFF999999),
barWidth: 2,
isStrokeCapRound: true,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(show: false),
),
],
),
);
}
/// 构建图例项
Widget _buildLegendItem(String label, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 12,
height: 3,
decoration: BoxDecoration(color: color),
),
const SizedBox(width: 8),
Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF666666))),
],
);
}
/// 构建近7天统计
Widget _buildWeeklyStats(List<_DailyTrend> movieTrend, List<_DailyTrend> bookTrend, List<_DailyTrend> noteTrend) {
final totalMovies = movieTrend.fold(0, (sum, item) => sum + item.count);
final totalBooks = bookTrend.fold(0, (sum, item) => sum + item.count);
final totalNotes = noteTrend.fold(0, (sum, item) => sum + item.count);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Column(
children: [
_buildWeeklyRow('影视新增', totalMovies, const Color(0xFF1A1A1A)),
const Divider(height: 24),
_buildWeeklyRow('书籍新增', totalBooks, const Color(0xFF666666)),
const Divider(height: 24),
_buildWeeklyRow('笔记新增', totalNotes, const Color(0xFF999999)),
const Divider(height: 24),
_buildWeeklyRow('总计', totalMovies + totalBooks + totalNotes, const Color(0xFF1A1A1A), isTotal: true),
],
),
);
}
Widget _buildWeeklyRow(String label, int count, Color color, {bool isTotal = false}) {
return Row(
children: [
Text(
label,
style: TextStyle(
fontSize: isTotal ? 14 : 13,
fontWeight: isTotal ? FontWeight.w600 : FontWeight.normal,
),
),
const Spacer(),
Text(
'$count',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: color,
),
),
],
);
}
Widget _buildSectionTitle(String title) {
return Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
);
}
}
/// 状态数据
class _StatusData {
final String label;
final int count;
final Color color;
_StatusData(this.label, this.count, this.color);
}
/// 每日数据
class _DailyData {
int movies = 0;
int books = 0;
int notes = 0;
int get total => movies + books + notes;
}
/// 每日趋势
class _DailyTrend {
final DateTime date;
final int count;
_DailyTrend(this.date, this.count);
}