generated from dellevin/template
基本功能完善
This commit is contained in:
312
lib/pages/backup_page.dart
Normal file
312
lib/pages/backup_page.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
191
lib/pages/book_excerpt_form_page.dart
Normal file
191
lib/pages/book_excerpt_form_page.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
300
lib/pages/book_excerpts_page.dart
Normal file
300
lib/pages/book_excerpts_page.dart
Normal 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
266
lib/pages/book_review_form_page.dart
Normal file
266
lib/pages/book_review_form_page.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
270
lib/pages/book_reviews_page.dart
Normal file
270
lib/pages/book_reviews_page.dart
Normal 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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('添加记录'),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
376
lib/pages/recycle_bin_page.dart
Normal file
376
lib/pages/recycle_bin_page.dart
Normal 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
486
lib/pages/search_page.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
728
lib/pages/statistics_page.dart
Normal file
728
lib/pages/statistics_page.dart
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user