diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 9bfb05c..b906d6f 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -6,7 +6,7 @@ plugins {
}
android {
- namespace = "com.example.mooknote"
+ namespace = "top.iletter.mooknote"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
@@ -21,7 +21,7 @@ android {
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
- applicationId = "com.example.mooknote"
+ applicationId = "top.iletter.mooknote"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 65c2f0b..b48121a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -2,7 +2,8 @@
+ android:icon="@mipmap/ic_launcher"
+ android:requestLegacyExternalStorage="true">
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/com/example/mooknote/MainActivity.kt b/android/app/src/main/kotlin/top/iletter/mooknote/MainActivity.kt
similarity index 76%
rename from android/app/src/main/kotlin/com/example/mooknote/MainActivity.kt
rename to android/app/src/main/kotlin/top/iletter/mooknote/MainActivity.kt
index 73963df..d5438a8 100644
--- a/android/app/src/main/kotlin/com/example/mooknote/MainActivity.kt
+++ b/android/app/src/main/kotlin/top/iletter/mooknote/MainActivity.kt
@@ -1,4 +1,4 @@
-package com.example.mooknote
+package top.iletter.mooknote
import io.flutter.embedding.android.FlutterActivity
diff --git a/lib/models/data_models.dart b/lib/models/data_models.dart
index 89cc70b..8711f1d 100644
--- a/lib/models/data_models.dart
+++ b/lib/models/data_models.dart
@@ -1,6 +1,12 @@
import 'dart:io';
import 'dart:convert';
+/// 用于区分 copyWith 中"未传参数"和"传了 null"的标记
+class _CopyWithNullSentinel {
+ const _CopyWithNullSentinel();
+}
+const _copyWithNull = _CopyWithNullSentinel();
+
/// 影视条目模型
class Movie {
final String id;
@@ -117,14 +123,14 @@ class Movie {
Movie copyWith({
String? id,
String? title,
- String? posterPath,
+ Object? posterPath = _copyWithNull,
DateTime? releaseDate,
List? directors,
List? writers,
List? actors,
List? genres,
List? alternateTitles,
- String? summary,
+ Object? summary = _copyWithNull,
double? rating,
String? status,
DateTime? createdAt,
@@ -134,14 +140,14 @@ class Movie {
return Movie(
id: id ?? this.id,
title: title ?? this.title,
- posterPath: posterPath ?? this.posterPath,
+ posterPath: posterPath is _CopyWithNullSentinel ? this.posterPath : (posterPath as String?),
releaseDate: releaseDate ?? this.releaseDate,
directors: directors ?? this.directors,
writers: writers ?? this.writers,
actors: actors ?? this.actors,
genres: genres ?? this.genres,
alternateTitles: alternateTitles ?? this.alternateTitles,
- summary: summary ?? this.summary,
+ summary: summary is _CopyWithNullSentinel ? this.summary : (summary as String?),
rating: rating ?? this.rating,
status: status ?? this.status,
createdAt: createdAt ?? this.createdAt,
@@ -233,12 +239,12 @@ class Book {
Book copyWith({
String? id,
String? title,
- String? coverPath,
+ Object? coverPath = _copyWithNull,
List? authors,
List? alternateTitles,
String? publisher,
List? genres,
- String? summary,
+ Object? summary = _copyWithNull,
double? rating,
String? status,
DateTime? createdAt,
@@ -248,12 +254,12 @@ class Book {
return Book(
id: id ?? this.id,
title: title ?? this.title,
- coverPath: coverPath ?? this.coverPath,
+ coverPath: coverPath is _CopyWithNullSentinel ? this.coverPath : (coverPath as String?),
authors: authors ?? this.authors,
alternateTitles: alternateTitles ?? this.alternateTitles,
publisher: publisher ?? this.publisher,
genres: genres ?? this.genres,
- summary: summary ?? this.summary,
+ summary: summary is _CopyWithNullSentinel ? this.summary : (summary as String?),
rating: rating ?? this.rating,
status: status ?? this.status,
createdAt: createdAt ?? this.createdAt,
@@ -271,6 +277,7 @@ class Note {
final List tags;
final DateTime createdAt;
final DateTime updatedAt;
+ final bool isDeleted;
Note({
required this.id,
@@ -279,6 +286,7 @@ class Note {
this.tags = const [],
required this.createdAt,
required this.updatedAt,
+ this.isDeleted = false,
});
factory Note.fromJson(Map json) {
@@ -293,6 +301,7 @@ class Note {
updatedAt: json['updated_at'] != null
? DateTime.parse(json['updated_at'])
: DateTime.now(),
+ isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true,
);
}
@@ -304,6 +313,7 @@ class Note {
'tags': jsonEncode(tags),
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
+ 'is_deleted': isDeleted ? 1 : 0,
};
}
@@ -315,6 +325,7 @@ class Note {
List? tags,
DateTime? createdAt,
DateTime? updatedAt,
+ bool? isDeleted,
}) {
return Note(
id: id ?? this.id,
@@ -323,6 +334,7 @@ class Note {
tags: tags ?? this.tags,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
+ isDeleted: isDeleted ?? this.isDeleted,
);
}
@@ -469,3 +481,176 @@ class MoviePoster {
}
}
+/// 书评模型
+class BookReview {
+ final String id;
+ final String bookId;
+ final String content;
+ final String reviewer;
+ final String source;
+ final int reviewType; // 1: 短评, 2: 长评
+ final bool isDeleted;
+ final DateTime createdAt;
+ final DateTime updatedAt;
+
+ BookReview({
+ required this.id,
+ required this.bookId,
+ required this.content,
+ this.reviewer = '',
+ this.source = '',
+ this.reviewType = 1,
+ this.isDeleted = false,
+ required this.createdAt,
+ required this.updatedAt,
+ });
+
+ factory BookReview.fromJson(Map json) {
+ return BookReview(
+ id: json['id']?.toString() ?? '',
+ bookId: json['book_id']?.toString() ?? '',
+ content: json['content'] ?? '',
+ reviewer: json['reviewer'] ?? '',
+ source: json['source'] ?? '',
+ reviewType: json['review_type'] ?? 1,
+ isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true,
+ createdAt: json['created_at'] != null
+ ? DateTime.parse(json['created_at'])
+ : DateTime.now(),
+ updatedAt: json['updated_at'] != null
+ ? DateTime.parse(json['updated_at'])
+ : DateTime.now(),
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'id': id,
+ 'book_id': bookId,
+ 'content': content,
+ 'reviewer': reviewer,
+ 'source': source,
+ 'review_type': reviewType,
+ 'is_deleted': isDeleted ? 1 : 0,
+ 'created_at': createdAt.toIso8601String(),
+ 'updated_at': updatedAt.toIso8601String(),
+ };
+ }
+
+ /// 复制并修改
+ BookReview copyWith({
+ String? id,
+ String? bookId,
+ String? content,
+ String? reviewer,
+ String? source,
+ int? reviewType,
+ bool? isDeleted,
+ DateTime? createdAt,
+ DateTime? updatedAt,
+ }) {
+ return BookReview(
+ id: id ?? this.id,
+ bookId: bookId ?? this.bookId,
+ content: content ?? this.content,
+ reviewer: reviewer ?? this.reviewer,
+ source: source ?? this.source,
+ reviewType: reviewType ?? this.reviewType,
+ isDeleted: isDeleted ?? this.isDeleted,
+ createdAt: createdAt ?? this.createdAt,
+ updatedAt: updatedAt ?? this.updatedAt,
+ );
+ }
+
+ /// 获取评论摘要
+ String get summary {
+ if (content.length <= 50) return content;
+ return '${content.substring(0, 50)}...';
+ }
+
+ /// 评论类型文本
+ String get typeText => reviewType == 1 ? '短评' : '长评';
+}
+
+/// 书籍摘抄模型
+class BookExcerpt {
+ final String id;
+ final String bookId;
+ final String chapter; // 章节
+ final String content; // 摘抄内容
+ final String comment; // 摘抄的评论/感悟
+ final bool isDeleted;
+ final DateTime createdAt;
+ final DateTime updatedAt;
+
+ BookExcerpt({
+ required this.id,
+ required this.bookId,
+ this.chapter = '',
+ required this.content,
+ this.comment = '',
+ this.isDeleted = false,
+ required this.createdAt,
+ required this.updatedAt,
+ });
+
+ factory BookExcerpt.fromJson(Map json) {
+ return BookExcerpt(
+ id: json['id']?.toString() ?? '',
+ bookId: json['book_id']?.toString() ?? '',
+ chapter: json['chapter'] ?? '',
+ content: json['content'] ?? '',
+ comment: json['comment'] ?? '',
+ isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true,
+ createdAt: json['created_at'] != null
+ ? DateTime.parse(json['created_at'])
+ : DateTime.now(),
+ updatedAt: json['updated_at'] != null
+ ? DateTime.parse(json['updated_at'])
+ : DateTime.now(),
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'id': id,
+ 'book_id': bookId,
+ 'chapter': chapter,
+ 'content': content,
+ 'comment': comment,
+ 'is_deleted': isDeleted ? 1 : 0,
+ 'created_at': createdAt.toIso8601String(),
+ 'updated_at': updatedAt.toIso8601String(),
+ };
+ }
+
+ /// 复制并修改
+ BookExcerpt copyWith({
+ String? id,
+ String? bookId,
+ String? chapter,
+ String? content,
+ String? comment,
+ bool? isDeleted,
+ DateTime? createdAt,
+ DateTime? updatedAt,
+ }) {
+ return BookExcerpt(
+ id: id ?? this.id,
+ bookId: bookId ?? this.bookId,
+ chapter: chapter ?? this.chapter,
+ content: content ?? this.content,
+ comment: comment ?? this.comment,
+ isDeleted: isDeleted ?? this.isDeleted,
+ createdAt: createdAt ?? this.createdAt,
+ updatedAt: updatedAt ?? this.updatedAt,
+ );
+ }
+
+ /// 获取摘抄摘要
+ String get summary {
+ if (content.length <= 50) return content;
+ return '${content.substring(0, 50)}...';
+ }
+}
+
diff --git a/lib/pages/backup_page.dart b/lib/pages/backup_page.dart
new file mode 100644
index 0000000..ecb1c41
--- /dev/null
+++ b/lib/pages/backup_page.dart
@@ -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 createState() => _BackupPageState();
+}
+
+class _BackupPageState extends State {
+ 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 _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 _importData() async {
+ // 显示确认对话框
+ final confirmed = await showDialog(
+ 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().loadMovies();
+ await context.read().loadBooks();
+ await context.read().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);
+ }
+ }
+ }
+}
diff --git a/lib/pages/book_detail_page.dart b/lib/pages/book_detail_page.dart
index cf5b7ab..cfc53b7 100644
--- a/lib/pages/book_detail_page.dart
+++ b/lib/pages/book_detail_page.dart
@@ -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 {
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ // 页面获得焦点时刷新数据
+ _refreshBookData();
+ }
+
+ void _refreshBookData() {
+ final provider = context.read();
+ // 强制刷新当前书籍数据
+ provider.loadBooks();
+ }
+
@override
Widget build(BuildContext context) {
+ // 从 Provider 获取最新的 book 数据,实现动态刷新
+ final book = context.watch().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 {
}
/// 构建顶部 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 {
}
/// 构建基本信息
- Widget _buildBasicInfo() {
+ Widget _buildBasicInfo(Book book) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
@@ -133,7 +189,7 @@ class _BookDetailPageState extends State {
children: [
// 书名
Text(
- widget.book.title,
+ book.title,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
@@ -141,13 +197,26 @@ class _BookDetailPageState extends State {
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 {
),
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 {
),
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 {
),
);
}
-
+
/// 构建状态标签
- 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 {
}
/// 构建作者区域
- Widget _buildAuthorsSection() {
+ Widget _buildAuthorsSection(Book book) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
@@ -241,7 +310,7 @@ class _BookDetailPageState extends State {
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 {
}
/// 构建出版社区域
- 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 {
),
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 {
}
/// 构建类型区域
- 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 {
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 {
}
/// 构建简介区域
- Widget _buildSummarySection() {
+ Widget _buildSummarySection(Book book) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
@@ -351,7 +420,7 @@ class _BookDetailPageState extends State {
),
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 {
),
);
}
-
- /// 构建别名区域
- 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 {
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(
+ future: context.read().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(
+ future: context.read().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 {
),
);
}
+
+ /// 显示清空封面对话框
+ 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().updateBook(updatedBook);
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('封面已清空')),
+ );
+ }
+ },
+ child: const Text('清空', style: TextStyle(color: Colors.red)),
+ ),
+ ],
+ ),
+ );
+ }
+
+ /// 下载封面到本地
+ Future _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')),
+ );
+ }
+ }
}
diff --git a/lib/pages/book_excerpt_form_page.dart b/lib/pages/book_excerpt_form_page.dart
new file mode 100644
index 0000000..2dc64a3
--- /dev/null
+++ b/lib/pages/book_excerpt_form_page.dart
@@ -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 createState() => _BookExcerptFormPageState();
+}
+
+class _BookExcerptFormPageState extends State {
+ final _formKey = GlobalKey();
+ 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 _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().updateBookExcerpt(excerpt);
+ } else {
+ await context.read().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);
+ }
+ }
+ }
+}
+
diff --git a/lib/pages/book_excerpts_page.dart b/lib/pages/book_excerpts_page.dart
new file mode 100644
index 0000000..17db5d8
--- /dev/null
+++ b/lib/pages/book_excerpts_page.dart
@@ -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 createState() => _BookExcerptsPageState();
+}
+
+class _BookExcerptsPageState extends State {
+ List _excerpts = [];
+ bool _isLoading = true;
+
+ @override
+ void initState() {
+ super.initState();
+ _loadExcerpts();
+ }
+
+ Future _loadExcerpts() async {
+ setState(() => _isLoading = true);
+ try {
+ final excerpts = await context.read().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> _groupExcerptsByChapter() {
+ final Map> 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 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().removeBookExcerpt(excerpt.id);
+ Navigator.pop(context);
+ _loadExcerpts();
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('已删除')),
+ );
+ },
+ child: const Text('删除', style: TextStyle(color: Colors.red)),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/pages/book_form_page.dart b/lib/pages/book_form_page.dart
index fc2a920..6700e0f 100644
--- a/lib/pages/book_form_page.dart
+++ b/lib/pages/book_form_page.dart
@@ -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 createState() => _BookFormPageState();
@@ -40,18 +41,36 @@ class _BookFormPageState extends State {
@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();
+ 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 {
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 {
},
),
),
-
+
_buildDivider(),
-
+
// 别名
_buildMultiValueItem(
label: '别名',
@@ -133,9 +162,9 @@ class _BookFormPageState extends State {
onAdd: (v) => setState(() => _alternateTitles.add(v)),
onRemove: (i) => setState(() => _alternateTitles.removeAt(i)),
),
-
+
_buildDivider(),
-
+
// 作者
_buildMultiValueItem(
label: '作者',
@@ -145,9 +174,9 @@ class _BookFormPageState extends State {
onAdd: (v) => setState(() => _authors.add(v)),
onRemove: (i) => setState(() => _authors.removeAt(i)),
),
-
+
_buildDivider(),
-
+
// 出版社
_buildFormItem(
label: '出版社',
@@ -162,9 +191,9 @@ class _BookFormPageState extends State {
),
),
),
-
+
_buildDivider(),
-
+
// 类型
_buildMultiValueItem(
label: '类型',
@@ -174,9 +203,9 @@ class _BookFormPageState extends State {
onAdd: (v) => setState(() => _genres.add(v)),
onRemove: (i) => setState(() => _genres.removeAt(i)),
),
-
+
_buildDivider(),
-
+
// 书籍简介
_buildFormItem(
label: '书籍简介',
@@ -192,63 +221,7 @@ class _BookFormPageState extends State {
),
),
),
-
- _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 {
);
}
- /// 构建状态选择 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),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
);
}
diff --git a/lib/pages/book_review_form_page.dart b/lib/pages/book_review_form_page.dart
new file mode 100644
index 0000000..c678170
--- /dev/null
+++ b/lib/pages/book_review_form_page.dart
@@ -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 createState() => _BookReviewFormPageState();
+}
+
+class _BookReviewFormPageState extends State {
+ final _formKey = GlobalKey();
+ late TextEditingController _contentController;
+ late TextEditingController _reviewerController;
+ late TextEditingController _sourceController;
+ late int _reviewType;
+
+ @override
+ void initState() {
+ super.initState();
+ final review = widget.review;
+ _contentController = TextEditingController(text: review?.content ?? '');
+ _reviewerController = TextEditingController(text: review?.reviewer ?? '');
+ _sourceController = TextEditingController(text: review?.source ?? '');
+ _reviewType = review?.reviewType ?? 1;
+ }
+
+ @override
+ void dispose() {
+ _contentController.dispose();
+ _reviewerController.dispose();
+ _sourceController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final isEdit = widget.review != null;
+
+ return Scaffold(
+ backgroundColor: Colors.white,
+ appBar: AppBar(
+ title: Text(isEdit ? '编辑书评' : '写书评'),
+ actions: [
+ TextButton(
+ onPressed: _saveReview,
+ child: const Text(
+ '保存',
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ),
+ const SizedBox(width: 8),
+ ],
+ ),
+ body: Form(
+ key: _formKey,
+ child: Column(
+ children: [
+ // 顶部信息栏
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ decoration: const BoxDecoration(
+ border: Border(
+ bottom: BorderSide(color: Color(0xFFE5E5E5), width: 0.5),
+ ),
+ ),
+ child: Row(
+ children: [
+ // 类型选择
+ _buildTypeSelector(),
+ const SizedBox(width: 16),
+ // 评论人
+ Expanded(
+ child: TextField(
+ controller: _reviewerController,
+ style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)),
+ decoration: const InputDecoration(
+ hintText: '评论人',
+ hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)),
+ border: InputBorder.none,
+ isDense: true,
+ contentPadding: EdgeInsets.zero,
+ ),
+ ),
+ ),
+ const SizedBox(width: 16),
+ // 来源
+ SizedBox(
+ width: 100,
+ child: TextField(
+ controller: _sourceController,
+ style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)),
+ decoration: const InputDecoration(
+ hintText: '来源',
+ hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)),
+ border: InputBorder.none,
+ isDense: true,
+ contentPadding: EdgeInsets.zero,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ // 评论内容区域
+ Expanded(
+ child: TextFormField(
+ controller: _contentController,
+ maxLines: null,
+ expands: true,
+ textAlignVertical: TextAlignVertical.top,
+ style: const TextStyle(
+ fontSize: 16,
+ color: Color(0xFF1A1A1A),
+ height: 1.7,
+ ),
+ decoration: const InputDecoration(
+ hintText: '写下你的书评...',
+ hintStyle: TextStyle(
+ fontSize: 16,
+ color: Color(0xFFCCCCCC),
+ ),
+ border: InputBorder.none,
+ contentPadding: EdgeInsets.all(16),
+ ),
+ validator: (value) {
+ if (value == null || value.trim().isEmpty) {
+ return '请输入评论内容';
+ }
+ return null;
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ /// 构建类型选择器
+ Widget _buildTypeSelector() {
+ return GestureDetector(
+ onTap: () => _showTypeSelector(),
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
+ decoration: BoxDecoration(
+ border: Border.all(color: const Color(0xFFE5E5E5)),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ _reviewType == 1 ? '短评' : '长评',
+ style: const TextStyle(
+ fontSize: 13,
+ color: Color(0xFF666666),
+ ),
+ ),
+ const SizedBox(width: 4),
+ const Icon(
+ Icons.arrow_drop_down,
+ size: 16,
+ color: Color(0xFF999999),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ /// 显示类型选择
+ void _showTypeSelector() {
+ showModalBottomSheet(
+ context: context,
+ backgroundColor: Colors.white,
+ shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
+ builder: (context) => SafeArea(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ ListTile(
+ title: const Text('短评'),
+ trailing: _reviewType == 1
+ ? const Icon(Icons.check, color: Color(0xFF1A1A1A))
+ : null,
+ onTap: () {
+ setState(() => _reviewType = 1);
+ Navigator.pop(context);
+ },
+ ),
+ const Divider(height: 0.5),
+ ListTile(
+ title: const Text('长评'),
+ trailing: _reviewType == 2
+ ? const Icon(Icons.check, color: Color(0xFF1A1A1A))
+ : null,
+ onTap: () {
+ setState(() => _reviewType = 2);
+ Navigator.pop(context);
+ },
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Future _saveReview() async {
+ if (!_formKey.currentState!.validate()) {
+ return;
+ }
+
+ final now = DateTime.now();
+
+ if (widget.review == null) {
+ final newReview = 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().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().updateBookReview(updatedReview);
+ }
+
+ if (!mounted) return;
+
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(widget.review == null ? '添加成功' : '更新成功'),
+ behavior: SnackBarBehavior.floating,
+ ),
+ );
+
+ Navigator.pop(context);
+ }
+}
+
diff --git a/lib/pages/book_reviews_page.dart b/lib/pages/book_reviews_page.dart
new file mode 100644
index 0000000..9f905ff
--- /dev/null
+++ b/lib/pages/book_reviews_page.dart
@@ -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 createState() => _BookReviewsPageState();
+}
+
+class _BookReviewsPageState extends State {
+ List _reviews = [];
+ bool _isLoading = true;
+
+ @override
+ void initState() {
+ super.initState();
+ _loadReviews();
+ }
+
+ Future _loadReviews() async {
+ setState(() => _isLoading = true);
+ try {
+ final reviews = await context.read().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().removeBookReview(review.id);
+ Navigator.pop(context);
+ _loadReviews();
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('已删除')),
+ );
+ },
+ child: const Text('删除', style: TextStyle(color: Colors.red)),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/pages/book_tab_page.dart b/lib/pages/book_tab_page.dart
index 067d2e0..4b53f82 100644
--- a/lib/pages/book_tab_page.dart
+++ b/lib/pages/book_tab_page.dart
@@ -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},
+ );
},
),
],
diff --git a/lib/pages/main_content_page.dart b/lib/pages/main_content_page.dart
index 84e7a42..0cc40a2 100644
--- a/lib/pages/main_content_page.dart
+++ b/lib/pages/main_content_page.dart
@@ -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),
diff --git a/lib/pages/movie_detail_page.dart b/lib/pages/movie_detail_page.dart
index 5e9d936..ca91edc 100644
--- a/lib/pages/movie_detail_page.dart
+++ b/lib/pages/movie_detail_page.dart
@@ -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 {
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ // 页面获得焦点时刷新数据
+ _refreshMovieData();
+ }
+
+ void _refreshMovieData() {
+ final provider = context.read();
+ // 强制刷新当前影视数据
+ provider.loadMovies();
+ }
+
@override
Widget build(BuildContext context) {
+ // 从 Provider 获取最新的 movie 数据,实现动态刷新
+ final movie = context.watch().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 {
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 {
}
/// 构建顶部 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 {
}
/// 构建海报区域
- 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 {
);
}
+ /// 显示清空海报对话框
+ 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().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 {
children: [
// 影视名称
Text(
- widget.movie.title,
+ movie.title,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
@@ -154,12 +242,25 @@ class _MovieDetailPageState extends State {
),
),
+ // 别名(显示在主名称下面,用 / 分隔)
+ 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 {
),
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 {
),
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 {
// 时间信息
Text(
- '添加于 ${_formatDate(widget.movie.createdAt)}',
+ '添加于 ${_formatDate(movie.createdAt)}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
@@ -208,10 +309,10 @@ class _MovieDetailPageState extends State {
}
/// 构建状态标签
- 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 {
}
/// 构建导演区域
- Widget _buildDirectorsSection() {
+ Widget _buildDirectorsSection(Movie movie) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
@@ -265,7 +366,7 @@ class _MovieDetailPageState extends State {
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 {
}
/// 构建编剧区域
- 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 {
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 {
}
/// 构建主演区域
- 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 {
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 {
}
/// 构建类型区域
- 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 {
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 {
}
/// 构建简介区域
- Widget _buildSummarySection() {
+ Widget _buildSummarySection(Movie movie) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
@@ -430,7 +531,7 @@ class _MovieDetailPageState extends State {
),
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 {
);
}
- /// 构建别名区域
- 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 {
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 {
),
const SizedBox(height: 4),
FutureBuilder(
- future: context.read().getMovieReviewCount(widget.movie.id),
+ future: context.read().getMovieReviewCount(movie.id),
builder: (context, snapshot) {
final count = snapshot.data ?? 0;
return Text(
@@ -550,7 +616,7 @@ class _MovieDetailPageState extends State {
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 {
),
const SizedBox(height: 4),
FutureBuilder(
- future: context.read().getMoviePosterCount(widget.movie.id),
+ future: context.read().getMoviePosterCount(movie.id),
builder: (context, snapshot) {
final count = snapshot.data ?? 0;
return Text(
@@ -606,20 +672,20 @@ class _MovieDetailPageState extends State {
);
}
- 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 {
),
);
}
+
+ /// 下载海报到本地
+ Future _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 _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 _getAndroidSdkInt() async {
+ // 简化处理,实际可以通过 platform channel 获取
+ // 这里默认返回较低版本,使用传统存储权限
+ return 30;
+ }
}
diff --git a/lib/pages/movie_form_page.dart b/lib/pages/movie_form_page.dart
index 304c896..8e90ffb 100644
--- a/lib/pages/movie_form_page.dart
+++ b/lib/pages/movie_form_page.dart
@@ -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 createState() => _MovieFormPageState();
@@ -42,7 +43,22 @@ class _MovieFormPageState extends State {
@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();
+ 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 {
_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 {
const SizedBox(height: 32),
+ // 状态选择(靠左显示)
+ _buildStatusSelector(),
+
+ const SizedBox(height: 20),
+
+ // 评分 - 星星选择(靠左显示)
+ _buildStarRating(),
+
+ const SizedBox(height: 32),
+
// 基本信息区域
_buildFormItem(
label: '影视名称 *',
@@ -235,62 +264,6 @@ class _MovieFormPageState extends State {
),
),
- _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 {
);
}
- /// 构建状态选择 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 {
/// 构建封面选择器
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),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
);
}
diff --git a/lib/pages/movie_posters_page.dart b/lib/pages/movie_posters_page.dart
index 3b6340a..0a8df62 100644
--- a/lib/pages/movie_posters_page.dart
+++ b/lib/pages/movie_posters_page.dart
@@ -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 {
}
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 {
_loadPosters();
if (mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('添加成功')),
- );
+ ToastUtil.show(context, '添加成功');
}
}
} catch (e) {
@@ -241,9 +274,7 @@ class _MoviePostersPageState extends State {
await context.read().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)),
),
diff --git a/lib/pages/movie_reviews_page.dart b/lib/pages/movie_reviews_page.dart
index 94998e6..647109c 100644
--- a/lib/pages/movie_reviews_page.dart
+++ b/lib/pages/movie_reviews_page.dart
@@ -157,6 +157,8 @@ class _MovieReviewsPageState extends State {
// 评论内容
Text(
review.content,
+ maxLines: review.reviewType == 1 ? 3 : 5,
+ overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
diff --git a/lib/pages/movie_tab_page.dart b/lib/pages/movie_tab_page.dart
index eeb275b..9e370ed 100644
--- a/lib/pages/movie_tab_page.dart
+++ b/lib/pages/movie_tab_page.dart
@@ -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('添加记录'),
),
],
diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart
index b0487f4..8d645af 100644
--- a/lib/pages/profile_page.dart
+++ b/lib/pages/profile_page.dart
@@ -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 {
_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 {
_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()),
+ );
+ },
),
],
);
diff --git a/lib/pages/recycle_bin_page.dart b/lib/pages/recycle_bin_page.dart
new file mode 100644
index 0000000..7f540da
--- /dev/null
+++ b/lib/pages/recycle_bin_page.dart
@@ -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 createState() => _RecycleBinPageState();
+}
+
+class _RecycleBinPageState extends State with SingleTickerProviderStateMixin {
+ late TabController _tabController;
+ List _deletedMovies = [];
+ List _deletedBooks = [];
+ List _deletedNotes = [];
+ bool _isLoading = true;
+
+ @override
+ void initState() {
+ super.initState();
+ _tabController = TabController(length: 3, vsync: this);
+ _loadDeletedItems();
+ }
+
+ @override
+ void dispose() {
+ _tabController.dispose();
+ super.dispose();
+ }
+
+ Future _loadDeletedItems() async {
+ setState(() => _isLoading = true);
+ final provider = context.read();
+ 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 _restoreMovie(Movie movie) async {
+ await context.read().restoreMovie(movie.id);
+ _loadDeletedItems();
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('影视已恢复')),
+ );
+ }
+ }
+
+ /// 彻底删除影视
+ Future _permanentDeleteMovie(Movie movie) async {
+ final confirmed = await _showConfirmDialog('确定要彻底删除这部影视吗?此操作不可恢复。');
+ if (confirmed) {
+ await context.read().permanentDeleteMovie(movie.id);
+ _loadDeletedItems();
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('已彻底删除')),
+ );
+ }
+ }
+ }
+
+ /// 恢复书籍
+ Future _restoreBook(Book book) async {
+ await context.read().restoreBook(book.id);
+ _loadDeletedItems();
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('书籍已恢复')),
+ );
+ }
+ }
+
+ /// 彻底删除书籍
+ Future _permanentDeleteBook(Book book) async {
+ final confirmed = await _showConfirmDialog('确定要彻底删除这本书籍吗?此操作不可恢复。');
+ if (confirmed) {
+ await context.read().permanentDeleteBook(book.id);
+ _loadDeletedItems();
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('已彻底删除')),
+ );
+ }
+ }
+ }
+
+ /// 恢复笔记
+ Future _restoreNote(Note note) async {
+ await context.read().restoreNote(note.id);
+ _loadDeletedItems();
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('笔记已恢复')),
+ );
+ }
+ }
+
+ /// 彻底删除笔记
+ Future _permanentDeleteNote(Note note) async {
+ final confirmed = await _showConfirmDialog('确定要彻底删除这条笔记吗?此操作不可恢复。');
+ if (confirmed) {
+ await context.read().permanentDeleteNote(note.id);
+ _loadDeletedItems();
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('已彻底删除')),
+ );
+ }
+ }
+ }
+
+ /// 显示确认对话框
+ Future _showConfirmDialog(String message) async {
+ final result = await showDialog(
+ 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().clearRecycleBin();
+ _loadDeletedItems();
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('回收站已清空')),
+ );
+ }
+ },
+ child: const Text('清空', style: TextStyle(color: Colors.red)),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/pages/search_page.dart b/lib/pages/search_page.dart
new file mode 100644
index 0000000..8de44ed
--- /dev/null
+++ b/lib/pages/search_page.dart
@@ -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 createState() => _SearchPageState();
+}
+
+class _SearchPageState extends State {
+ final _searchController = TextEditingController();
+ int _selectedType = 0; // 0: 影视, 1: 书籍, 2: 笔记
+ List _results = [];
+ bool _isSearching = false;
+
+ final List _typeLabels = ['影视', '书籍', '笔记'];
+
+ @override
+ void dispose() {
+ _searchController.dispose();
+ super.dispose();
+ }
+
+ Future _performSearch() async {
+ final keyword = _searchController.text.trim();
+ if (keyword.isEmpty) return;
+
+ setState(() => _isSearching = true);
+
+ try {
+ List results;
+ final provider = context.read();
+
+ 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),
+ ),
+ );
+ }
+}
+
+
diff --git a/lib/pages/statistics_page.dart b/lib/pages/statistics_page.dart
new file mode 100644
index 0000000..23f8147
--- /dev/null
+++ b/lib/pages/statistics_page.dart
@@ -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 createState() => _StatisticsPageState();
+}
+
+class _StatisticsPageState extends State 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(
+ 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 movies, List books, List 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 movies, List books, List notes) {
+ // 合并所有数据按日期
+ final Map 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 movies, List books, List 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 movies, List books, List 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 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);
+}
diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart
index 1584e4f..99d36bc 100644
--- a/lib/providers/app_provider.dart
+++ b/lib/providers/app_provider.dart
@@ -5,6 +5,8 @@ import '../utils/book_dao.dart';
import '../utils/note_dao.dart';
import '../utils/movie_review_dao.dart';
import '../utils/movie_poster_dao.dart';
+import '../utils/book_review_dao.dart';
+import '../utils/book_excerpt_dao.dart';
/// 应用全局状态管理
class AppProvider extends ChangeNotifier {
@@ -14,6 +16,8 @@ class AppProvider extends ChangeNotifier {
final NoteDao _noteDao = NoteDao();
final MovieReviewDao _reviewDao = MovieReviewDao();
final MoviePosterDao _posterDao = MoviePosterDao();
+ final BookReviewDao _bookReviewDao = BookReviewDao();
+ final BookExcerptDao _bookExcerptDao = BookExcerptDao();
// 数据列表
List _movies = [];
@@ -213,4 +217,129 @@ class AppProvider extends ChangeNotifier {
Future getMoviePosterCount(String movieId) async {
return await _posterDao.getPosterCount(movieId);
}
+
+ // ========== 书评相关方法 ==========
+
+ /// 获取书籍的所有书评
+ Future> getBookReviews(String bookId) async {
+ return await _bookReviewDao.getReviewsByBookId(bookId);
+ }
+
+ /// 添加书评
+ Future addBookReview(BookReview review) async {
+ await _bookReviewDao.insertReview(review);
+ }
+
+ /// 更新书评
+ Future updateBookReview(BookReview review) async {
+ await _bookReviewDao.updateReview(review);
+ }
+
+ /// 删除书评
+ Future removeBookReview(String id) async {
+ await _bookReviewDao.deleteReview(id);
+ }
+
+ /// 获取书籍的书评数量
+ Future getBookReviewCount(String bookId) async {
+ return await _bookReviewDao.getReviewCount(bookId);
+ }
+
+ // ========== 摘抄相关方法 ==========
+
+ /// 获取书籍的所有摘抄
+ Future> getBookExcerpts(String bookId) async {
+ return await _bookExcerptDao.getExcerptsByBookId(bookId);
+ }
+
+ /// 添加摘抄
+ Future addBookExcerpt(BookExcerpt excerpt) async {
+ await _bookExcerptDao.insertExcerpt(excerpt);
+ }
+
+ /// 更新摘抄
+ Future updateBookExcerpt(BookExcerpt excerpt) async {
+ await _bookExcerptDao.updateExcerpt(excerpt);
+ }
+
+ /// 删除摘抄
+ Future removeBookExcerpt(String id) async {
+ await _bookExcerptDao.deleteExcerpt(id);
+ }
+
+ /// 获取书籍的摘抄数量
+ Future getBookExcerptCount(String bookId) async {
+ return await _bookExcerptDao.getExcerptCount(bookId);
+ }
+
+ // ========== 回收站相关方法 ==========
+
+ /// 获取已删除的影视
+ Future> getDeletedMovies() async {
+ return await _movieDao.getDeletedMovies();
+ }
+
+ /// 恢复影视
+ Future restoreMovie(String id) async {
+ await _movieDao.restoreMovie(id);
+ await loadMovies();
+ }
+
+ /// 彻底删除影视
+ Future permanentDeleteMovie(String id) async {
+ await _movieDao.permanentDeleteMovie(id);
+ }
+
+ /// 获取已删除的书籍
+ Future> getDeletedBooks() async {
+ return await _bookDao.getDeletedBooks();
+ }
+
+ /// 恢复书籍
+ Future restoreBook(String id) async {
+ await _bookDao.restoreBook(id);
+ await loadBooks();
+ }
+
+ /// 彻底删除书籍
+ Future permanentDeleteBook(String id) async {
+ await _bookDao.permanentDeleteBook(id);
+ }
+
+ /// 获取已删除的笔记
+ Future> getDeletedNotes() async {
+ return await _noteDao.getDeletedNotes();
+ }
+
+ /// 恢复笔记
+ Future restoreNote(String id) async {
+ await _noteDao.restoreNote(id);
+ await loadNotes();
+ }
+
+ /// 彻底删除笔记
+ Future permanentDeleteNote(String id) async {
+ await _noteDao.permanentDeleteNote(id);
+ }
+
+ /// 清空回收站
+ Future clearRecycleBin() async {
+ final deletedMovies = await _movieDao.getDeletedMovies();
+ final deletedBooks = await _bookDao.getDeletedBooks();
+ final deletedNotes = await _noteDao.getDeletedNotes();
+
+ for (final movie in deletedMovies) {
+ await _movieDao.permanentDeleteMovie(movie.id);
+ }
+ for (final book in deletedBooks) {
+ await _bookDao.permanentDeleteBook(book.id);
+ }
+ for (final note in deletedNotes) {
+ await _noteDao.permanentDeleteNote(note.id);
+ }
+
+ await loadMovies();
+ await loadBooks();
+ await loadNotes();
+ }
}
diff --git a/lib/utils/app_router.dart b/lib/utils/app_router.dart
index 40bf44c..eec4be9 100644
--- a/lib/utils/app_router.dart
+++ b/lib/utils/app_router.dart
@@ -12,15 +12,41 @@ class AppRouter {
static Route generateRoute(RouteSettings settings) {
switch (settings.name) {
case '/movie-form':
- final movie = settings.arguments as Movie?;
+ // 处理不同参数类型:Movie 对象或 Map(包含 initialStatus)
+ final args = settings.arguments;
+ Movie? movie;
+ String? initialStatus;
+
+ if (args is Movie) {
+ movie = args;
+ } else if (args is Map) {
+ initialStatus = args['initialStatus'] as String?;
+ }
+
return MaterialPageRoute(
- builder: (_) => MovieFormPage(movie: movie),
+ builder: (_) => MovieFormPage(
+ movie: movie,
+ initialStatus: initialStatus,
+ ),
);
case '/book-form':
- final book = settings.arguments as Book?;
+ // 处理不同参数类型:Book 对象或 Map(包含 initialStatus)
+ final args = settings.arguments;
+ Book? book;
+ String? initialStatus;
+
+ if (args is Book) {
+ book = args;
+ } else if (args is Map) {
+ initialStatus = args['initialStatus'] as String?;
+ }
+
return MaterialPageRoute(
- builder: (_) => BookFormPage(book: book),
+ builder: (_) => BookFormPage(
+ book: book,
+ initialStatus: initialStatus,
+ ),
);
case '/note-form':
diff --git a/lib/utils/backup_service.dart b/lib/utils/backup_service.dart
new file mode 100644
index 0000000..0f9b4a2
--- /dev/null
+++ b/lib/utils/backup_service.dart
@@ -0,0 +1,440 @@
+import 'dart:convert';
+import 'dart:io';
+import 'dart:typed_data';
+import 'package:archive/archive.dart';
+import 'package:archive/archive_io.dart';
+import 'package:file_picker/file_picker.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 'database_helper.dart';
+
+/// 数据备份服务 - 支持导出和导入数据(包含图片)
+class BackupService {
+ static final BackupService instance = BackupService._init();
+
+ BackupService._init();
+
+ /// 导出所有数据和图片为 ZIP 文件,并选择保存路径
+ Future exportDataWithImages() async {
+ try {
+ final db = await DatabaseHelper.instance.database;
+
+ // 导出所有表的数据
+ final movies = await db.query('movies');
+ final books = await db.query('books');
+ final notes = await db.query('notes');
+ final movieReviews = await db.query('movie_reviews');
+ final moviePosters = await db.query('movie_posters');
+
+ // 收集所有图片路径
+ final imagePaths = {};
+
+ // 收集影视海报
+ for (final movie in movies) {
+ final posterPath = movie['poster_path'] as String?;
+ if (posterPath != null && posterPath.isNotEmpty) {
+ imagePaths.add(posterPath);
+ }
+ }
+
+ // 收集书籍封面
+ for (final book in books) {
+ final coverPath = book['cover_path'] as String?;
+ if (coverPath != null && coverPath.isNotEmpty) {
+ imagePaths.add(coverPath);
+ }
+ }
+
+ // 收集海报墙图片
+ for (final poster in moviePosters) {
+ final posterPath = poster['poster_path'] as String?;
+ if (posterPath != null && posterPath.isNotEmpty) {
+ imagePaths.add(posterPath);
+ }
+ }
+
+ // 构建备份数据
+ final backupData = {
+ 'version': 2,
+ 'exportTime': DateTime.now().toIso8601String(),
+ 'appName': 'MookNote',
+ 'hasImages': true,
+ 'data': {
+ 'movies': movies,
+ 'books': books,
+ 'notes': notes,
+ 'movie_reviews': movieReviews,
+ 'movie_posters': moviePosters,
+ },
+ };
+
+ // 创建 ZIP 文件
+ final archive = Archive();
+
+ // 添加 JSON 数据
+ final jsonString = const JsonEncoder.withIndent(' ').convert(backupData);
+ final jsonBytes = Uint8List.fromList(utf8.encode(jsonString));
+ archive.addFile(ArchiveFile('data.json', jsonBytes.length, jsonBytes));
+
+ // 添加图片文件
+ int imageCount = 0;
+ for (final imagePath in imagePaths) {
+ final file = File(imagePath);
+ if (await file.exists()) {
+ final bytes = await file.readAsBytes();
+ final fileName = path.basename(imagePath);
+ // 使用相对路径存储图片
+ archive.addFile(ArchiveFile('images/$fileName', bytes.length, bytes));
+ imageCount++;
+ }
+ }
+
+ // 压缩 ZIP
+ final zipEncoder = ZipEncoder();
+ final zipBytes = zipEncoder.encode(archive);
+ if (zipBytes == null) {
+ return ExportResult.error('压缩备份文件失败');
+ }
+
+ // 保存到临时目录
+ final tempDir = await getTemporaryDirectory();
+ final fileName = 'mooknote_backup_${_formatDateTime(DateTime.now())}.zip';
+ final tempFilePath = path.join(tempDir.path, fileName);
+ final tempFile = File(tempFilePath);
+ await tempFile.writeAsBytes(zipBytes);
+
+ // 在移动端使用分享功能,让用户选择保存位置
+ // 在桌面端可以尝试使用 saveFile
+ String? finalPath;
+
+ try {
+ // 尝试使用系统保存对话框(桌面端支持)
+ final outputPath = await FilePicker.platform.saveFile(
+ dialogTitle: '保存备份文件',
+ fileName: fileName,
+ type: FileType.custom,
+ allowedExtensions: ['zip'],
+ bytes: Uint8List.fromList(zipBytes), // 在移动端需要提供 bytes
+ );
+
+ if (outputPath == null) {
+ // 用户取消,返回临时文件路径
+ finalPath = tempFilePath;
+ } else {
+ finalPath = outputPath;
+ // 如果保存路径不是临时文件路径,需要复制过去
+ if (finalPath != tempFilePath) {
+ final outputFile = File(finalPath);
+ await outputFile.writeAsBytes(zipBytes);
+ }
+ }
+ } catch (e) {
+ // 如果 saveFile 失败,使用临时文件路径
+ finalPath = tempFilePath;
+ }
+
+ return ExportResult.success(
+ filePath: finalPath,
+ movieCount: movies.length,
+ bookCount: books.length,
+ noteCount: notes.length,
+ imageCount: imageCount,
+ );
+ } catch (e) {
+ return ExportResult.error('导出失败: $e');
+ }
+ }
+
+ /// 分享备份文件
+ Future shareBackup(String filePath) async {
+ final file = XFile(filePath);
+ await Share.shareXFiles(
+ [file],
+ subject: 'MookNote 数据备份',
+ text: '这是我的 MookNote 数据备份文件',
+ );
+ }
+
+ /// 选择并导入备份文件(支持 ZIP 格式)
+ Future importData() async {
+ try {
+ // 选择文件
+ final result = await FilePicker.platform.pickFiles(
+ type: FileType.custom,
+ allowedExtensions: ['zip', 'json'],
+ allowMultiple: false,
+ );
+
+ if (result == null || result.files.isEmpty) {
+ return ImportResult.cancelled();
+ }
+
+ final filePath = result.files.first.path;
+ if (filePath == null) {
+ return ImportResult.error('无法读取文件路径');
+ }
+
+ final file = File(filePath);
+ final extension = path.extension(filePath).toLowerCase();
+
+ Map backupData;
+ int imageCount = 0;
+ // 记录图片文件名到新路径的映射
+ final imagePathMap = {};
+
+ if (extension == '.zip') {
+ // 处理 ZIP 文件
+ final bytes = await file.readAsBytes();
+ final archive = ZipDecoder().decodeBytes(bytes);
+
+ // 查找 data.json
+ final dataFile = archive.findFile('data.json');
+ if (dataFile == null) {
+ return ImportResult.error('备份文件中没有找到数据文件');
+ }
+
+ final jsonString = utf8.decode(dataFile.content as List);
+ backupData = jsonDecode(jsonString) as Map;
+
+ // 解压图片到应用目录
+ final appDir = await getApplicationDocumentsDirectory();
+ final imagesDir = Directory(path.join(appDir.path, 'images'));
+ if (!await imagesDir.exists()) {
+ await imagesDir.create(recursive: true);
+ }
+
+ for (final archiveFile in archive) {
+ if (archiveFile.name.startsWith('images/')) {
+ final fileName = path.basename(archiveFile.name);
+ final outputFile = File(path.join(imagesDir.path, fileName));
+ await outputFile.writeAsBytes(archiveFile.content as List);
+ imagePathMap[fileName] = outputFile.path;
+ imageCount++;
+ }
+ }
+ } else {
+ // 处理旧版 JSON 文件
+ final jsonString = await file.readAsString();
+ backupData = jsonDecode(jsonString) as Map;
+ }
+
+ // 验证备份格式
+ if (!backupData.containsKey('data')) {
+ return ImportResult.error('无效的备份文件格式');
+ }
+
+ // 导入数据
+ final data = backupData['data'] as Map;
+ final db = await DatabaseHelper.instance.database;
+
+ // 开始事务
+ await db.transaction((txn) async {
+ // 清空现有数据
+ await txn.delete('movie_reviews');
+ await txn.delete('movie_posters');
+ await txn.delete('movies');
+ await txn.delete('books');
+ await txn.delete('notes');
+
+ // 导入影视数据(更新图片路径)
+ if (data.containsKey('movies')) {
+ final movies = data['movies'] as List;
+ for (final movie in movies) {
+ final movieMap = _convertToDbMap(movie);
+ final updatedMap = _updateImagePath(movieMap, 'poster_path', imagePathMap);
+ await txn.insert('movies', updatedMap);
+ }
+ }
+
+ // 导入书籍数据(更新图片路径)
+ if (data.containsKey('books')) {
+ final books = data['books'] as List;
+ for (final book in books) {
+ final bookMap = _convertToDbMap(book);
+ final updatedMap = _updateImagePath(bookMap, 'cover_path', imagePathMap);
+ await txn.insert('books', updatedMap);
+ }
+ }
+
+ // 导入笔记数据
+ if (data.containsKey('notes')) {
+ final notes = data['notes'] as List;
+ for (final note in notes) {
+ await txn.insert('notes', _convertToDbMap(note));
+ }
+ }
+
+ // 导入影评数据
+ if (data.containsKey('movie_reviews')) {
+ final reviews = data['movie_reviews'] as List;
+ for (final review in reviews) {
+ await txn.insert('movie_reviews', _convertToDbMap(review));
+ }
+ }
+
+ // 导入海报墙数据(更新图片路径)
+ if (data.containsKey('movie_posters')) {
+ final posters = data['movie_posters'] as List;
+ for (final poster in posters) {
+ final posterMap = _convertToDbMap(poster);
+ final updatedMap = _updateImagePath(posterMap, 'poster_path', imagePathMap);
+ await txn.insert('movie_posters', updatedMap);
+ }
+ }
+ });
+
+ // 统计导入数量
+ final stats = {};
+ if (data.containsKey('movies')) {
+ stats['影视'] = (data['movies'] as List).length;
+ }
+ if (data.containsKey('books')) {
+ stats['书籍'] = (data['books'] as List).length;
+ }
+ if (data.containsKey('notes')) {
+ stats['笔记'] = (data['notes'] as List).length;
+ }
+ if (data.containsKey('movie_reviews')) {
+ stats['影评'] = (data['movie_reviews'] as List).length;
+ }
+ if (data.containsKey('movie_posters')) {
+ stats['海报'] = (data['movie_posters'] as List).length;
+ }
+ if (imageCount > 0) {
+ stats['图片'] = imageCount;
+ }
+
+ return ImportResult.success(stats);
+ } catch (e) {
+ return ImportResult.error('导入失败: $e');
+ }
+ }
+
+ /// 将动态类型转换为数据库可用的 Map
+ Map _convertToDbMap(dynamic item) {
+ if (item is Map) {
+ return item.map((key, value) {
+ // 处理布尔值
+ if (value is bool) {
+ return MapEntry(key, value ? 1 : 0);
+ }
+ return MapEntry(key, value);
+ });
+ }
+ return {};
+ }
+
+ /// 更新图片路径为新的路径
+ Map _updateImagePath(
+ Map item,
+ String pathField,
+ Map imagePathMap,
+ ) {
+ final newItem = Map.from(item);
+ final oldPath = item[pathField] as String?;
+
+ if (oldPath != null && oldPath.isNotEmpty) {
+ final fileName = path.basename(oldPath);
+ // 如果图片在映射中,更新路径
+ if (imagePathMap.containsKey(fileName)) {
+ newItem[pathField] = imagePathMap[fileName];
+ }
+ }
+
+ return newItem;
+ }
+
+ /// 格式化日期时间用于文件名
+ String _formatDateTime(DateTime dateTime) {
+ return '${dateTime.year}${_pad(dateTime.month)}${_pad(dateTime.day)}_${_pad(dateTime.hour)}${_pad(dateTime.minute)}${_pad(dateTime.second)}';
+ }
+
+ String _pad(int number) {
+ return number.toString().padLeft(2, '0');
+ }
+}
+
+/// 导出结果
+class ExportResult {
+ final bool success;
+ final bool cancelled;
+ final String? errorMessage;
+ final String? filePath;
+ final int movieCount;
+ final int bookCount;
+ final int noteCount;
+ final int imageCount;
+
+ ExportResult._({
+ required this.success,
+ this.cancelled = false,
+ this.errorMessage,
+ this.filePath,
+ this.movieCount = 0,
+ this.bookCount = 0,
+ this.noteCount = 0,
+ this.imageCount = 0,
+ });
+
+ factory ExportResult.success({
+ required String filePath,
+ required int movieCount,
+ required int bookCount,
+ required int noteCount,
+ required int imageCount,
+ }) {
+ return ExportResult._(
+ success: true,
+ filePath: filePath,
+ movieCount: movieCount,
+ bookCount: bookCount,
+ noteCount: noteCount,
+ imageCount: imageCount,
+ );
+ }
+
+ factory ExportResult.cancelled() {
+ return ExportResult._(success: false, cancelled: true);
+ }
+
+ factory ExportResult.error(String message) {
+ return ExportResult._(success: false, errorMessage: message);
+ }
+}
+
+/// 导入结果
+class ImportResult {
+ final bool success;
+ final bool cancelled;
+ final String? errorMessage;
+ final Map? stats;
+
+ ImportResult._({
+ required this.success,
+ this.cancelled = false,
+ this.errorMessage,
+ this.stats,
+ });
+
+ factory ImportResult.success(Map stats) {
+ return ImportResult._(success: true, stats: stats);
+ }
+
+ factory ImportResult.cancelled() {
+ return ImportResult._(success: false, cancelled: true);
+ }
+
+ factory ImportResult.error(String message) {
+ return ImportResult._(success: false, errorMessage: message);
+ }
+
+ /// 获取统计信息文本
+ String get statsText {
+ if (stats == null || stats!.isEmpty) {
+ return '没有导入任何数据';
+ }
+ return stats!.entries.map((e) => '${e.key}: ${e.value}').join(',');
+ }
+}
diff --git a/lib/utils/book_dao.dart b/lib/utils/book_dao.dart
index be02818..731c0fc 100644
--- a/lib/utils/book_dao.dart
+++ b/lib/utils/book_dao.dart
@@ -124,4 +124,40 @@ class BookDao {
return List.generate(maps.length, (i) => Book.fromJson(maps[i]));
}
+
+ // ========== 回收站相关方法 ==========
+
+ // 获取已删除的书籍
+ Future> getDeletedBooks() async {
+ final db = await _dbHelper.database;
+ final List