基本功能完善

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

View File

@@ -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

View File

@@ -2,7 +2,8 @@
<application
android:label="MookNote"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -26,4 +27,11 @@
android:value="2" />
</application>
<uses-permission android:name="android.permission.INTERNET"/>
<!-- 存储权限 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<!-- Android 13+ 使用新的权限 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
</manifest>

View File

@@ -1,4 +1,4 @@
package com.example.mooknote
package top.iletter.mooknote
import io.flutter.embedding.android.FlutterActivity

View File

@@ -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<String>? directors,
List<String>? writers,
List<String>? actors,
List<String>? genres,
List<String>? 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<String>? authors,
List<String>? alternateTitles,
String? publisher,
List<String>? 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<String> 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<String, dynamic> 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<String>? 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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)}...';
}
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,266 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
import '../models/data_models.dart';
import 'package:uuid/uuid.dart';
/// 添加/编辑书评页面 - 极简设计
class BookReviewFormPage extends StatefulWidget {
final String bookId;
final BookReview? review;
const BookReviewFormPage({
super.key,
required this.bookId,
this.review,
});
@override
State<BookReviewFormPage> createState() => _BookReviewFormPageState();
}
class _BookReviewFormPageState extends State<BookReviewFormPage> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _contentController;
late TextEditingController _reviewerController;
late TextEditingController _sourceController;
late int _reviewType;
@override
void initState() {
super.initState();
final review = widget.review;
_contentController = TextEditingController(text: review?.content ?? '');
_reviewerController = TextEditingController(text: review?.reviewer ?? '');
_sourceController = TextEditingController(text: review?.source ?? '');
_reviewType = review?.reviewType ?? 1;
}
@override
void dispose() {
_contentController.dispose();
_reviewerController.dispose();
_sourceController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isEdit = widget.review != null;
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: Text(isEdit ? '编辑书评' : '写书评'),
actions: [
TextButton(
onPressed: _saveReview,
child: const Text(
'保存',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
],
),
body: Form(
key: _formKey,
child: Column(
children: [
// 顶部信息栏
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: Color(0xFFE5E5E5), width: 0.5),
),
),
child: Row(
children: [
// 类型选择
_buildTypeSelector(),
const SizedBox(width: 16),
// 评论人
Expanded(
child: TextField(
controller: _reviewerController,
style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)),
decoration: const InputDecoration(
hintText: '评论人',
hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)),
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
),
),
const SizedBox(width: 16),
// 来源
SizedBox(
width: 100,
child: TextField(
controller: _sourceController,
style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)),
decoration: const InputDecoration(
hintText: '来源',
hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)),
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
),
),
],
),
),
// 评论内容区域
Expanded(
child: TextFormField(
controller: _contentController,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(
fontSize: 16,
color: Color(0xFF1A1A1A),
height: 1.7,
),
decoration: const InputDecoration(
hintText: '写下你的书评...',
hintStyle: TextStyle(
fontSize: 16,
color: Color(0xFFCCCCCC),
),
border: InputBorder.none,
contentPadding: EdgeInsets.all(16),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '请输入评论内容';
}
return null;
},
),
),
],
),
),
);
}
/// 构建类型选择器
Widget _buildTypeSelector() {
return GestureDetector(
onTap: () => _showTypeSelector(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_reviewType == 1 ? '短评' : '长评',
style: const TextStyle(
fontSize: 13,
color: Color(0xFF666666),
),
),
const SizedBox(width: 4),
const Icon(
Icons.arrow_drop_down,
size: 16,
color: Color(0xFF999999),
),
],
),
),
);
}
/// 显示类型选择
void _showTypeSelector() {
showModalBottomSheet(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: const Text('短评'),
trailing: _reviewType == 1
? const Icon(Icons.check, color: Color(0xFF1A1A1A))
: null,
onTap: () {
setState(() => _reviewType = 1);
Navigator.pop(context);
},
),
const Divider(height: 0.5),
ListTile(
title: const Text('长评'),
trailing: _reviewType == 2
? const Icon(Icons.check, color: Color(0xFF1A1A1A))
: null,
onTap: () {
setState(() => _reviewType = 2);
Navigator.pop(context);
},
),
],
),
),
);
}
Future<void> _saveReview() async {
if (!_formKey.currentState!.validate()) {
return;
}
final now = DateTime.now();
if (widget.review == null) {
final newReview = BookReview(
id: const Uuid().v4(),
bookId: widget.bookId,
content: _contentController.text.trim(),
reviewer: _reviewerController.text.trim(),
source: _sourceController.text.trim(),
reviewType: _reviewType,
isDeleted: false,
createdAt: now,
updatedAt: now,
);
await context.read<AppProvider>().addBookReview(newReview);
} else {
final updatedReview = widget.review!.copyWith(
content: _contentController.text.trim(),
reviewer: _reviewerController.text.trim(),
source: _sourceController.text.trim(),
reviewType: _reviewType,
updatedAt: now,
);
await context.read<AppProvider>().updateBookReview(updatedReview);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(widget.review == null ? '添加成功' : '更新成功'),
behavior: SnackBarBehavior.floating,
),
);
Navigator.pop(context);
}
}

View File

@@ -0,0 +1,270 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
import '../models/data_models.dart';
import 'book_review_form_page.dart';
/// 书籍书评列表页面
class BookReviewsPage extends StatefulWidget {
final Book book;
const BookReviewsPage({super.key, required this.book});
@override
State<BookReviewsPage> createState() => _BookReviewsPageState();
}
class _BookReviewsPageState extends State<BookReviewsPage> {
List<BookReview> _reviews = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadReviews();
}
Future<void> _loadReviews() async {
setState(() => _isLoading = true);
try {
final reviews = await context.read<AppProvider>().getBookReviews(widget.book.id);
setState(() {
_reviews = reviews;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('加载失败: $e')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text('书评'),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => _navigateToAddReview(),
),
const SizedBox(width: 8),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _reviews.isEmpty
? _buildEmptyState()
: _buildReviewList(),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.rate_review_outlined,
size: 64,
color: Color(0xFFCCCCCC),
),
const SizedBox(height: 16),
const Text(
'暂无书评',
style: TextStyle(
fontSize: 16,
color: Color(0xFF999999),
),
),
const SizedBox(height: 24),
OutlinedButton(
onPressed: () => _navigateToAddReview(),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF1A1A1A),
side: const BorderSide(color: Color(0xFF1A1A1A)),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
),
child: const Text('写书评'),
),
],
),
);
}
Widget _buildReviewList() {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _reviews.length,
itemBuilder: (context, index) {
final review = _reviews[index];
return _buildReviewItem(review);
},
);
}
Widget _buildReviewItem(BookReview review) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 头部:类型标签 + 操作按钮
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: review.reviewType == 1
? const Color(0xFFF5F5F5)
: const Color(0xFF1A1A1A),
),
child: Text(
review.typeText,
style: TextStyle(
fontSize: 11,
color: review.reviewType == 1
? const Color(0xFF666666)
: Colors.white,
),
),
),
const Spacer(),
// 编辑按钮
GestureDetector(
onTap: () => _navigateToEditReview(review),
child: const Icon(
Icons.edit_outlined,
size: 18,
color: Color(0xFF999999),
),
),
const SizedBox(width: 16),
// 删除按钮
GestureDetector(
onTap: () => _showDeleteDialog(review),
child: const Icon(
Icons.delete_outline,
size: 18,
color: Colors.red,
),
),
],
),
const SizedBox(height: 12),
// 评论内容
Text(
review.content,
maxLines: review.reviewType == 1 ? 3 : 5,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
height: 1.6,
),
),
const SizedBox(height: 12),
// 底部信息
Row(
children: [
if (review.reviewer.isNotEmpty) ...[
Text(
review.reviewer,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF666666),
),
),
const SizedBox(width: 8),
],
if (review.source.isNotEmpty) ...[
Text(
'来源:${review.source}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
),
const SizedBox(width: 8),
],
const Spacer(),
Text(
_formatDate(review.createdAt),
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
),
],
),
],
),
);
}
String _formatDate(DateTime date) {
return '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}';
}
void _navigateToAddReview() {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BookReviewFormPage(bookId: widget.book.id),
),
).then((_) => _loadReviews());
}
void _navigateToEditReview(BookReview review) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BookReviewFormPage(
bookId: widget.book.id,
review: review,
),
),
).then((_) => _loadReviews());
}
void _showDeleteDialog(BookReview review) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: Colors.white,
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
title: const Text('确认删除'),
content: const Text('确定要删除这条书评吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消', style: TextStyle(color: Color(0xFF666666))),
),
TextButton(
onPressed: () async {
await context.read<AppProvider>().removeBookReview(review.id);
Navigator.pop(context);
_loadReviews();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已删除')),
);
},
child: const Text('删除', style: TextStyle(color: Colors.red)),
),
],
),
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -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<Movie> _movies = [];
@@ -213,4 +217,129 @@ class AppProvider extends ChangeNotifier {
Future<int> getMoviePosterCount(String movieId) async {
return await _posterDao.getPosterCount(movieId);
}
// ========== 书评相关方法 ==========
/// 获取书籍的所有书评
Future<List<BookReview>> getBookReviews(String bookId) async {
return await _bookReviewDao.getReviewsByBookId(bookId);
}
/// 添加书评
Future<void> addBookReview(BookReview review) async {
await _bookReviewDao.insertReview(review);
}
/// 更新书评
Future<void> updateBookReview(BookReview review) async {
await _bookReviewDao.updateReview(review);
}
/// 删除书评
Future<void> removeBookReview(String id) async {
await _bookReviewDao.deleteReview(id);
}
/// 获取书籍的书评数量
Future<int> getBookReviewCount(String bookId) async {
return await _bookReviewDao.getReviewCount(bookId);
}
// ========== 摘抄相关方法 ==========
/// 获取书籍的所有摘抄
Future<List<BookExcerpt>> getBookExcerpts(String bookId) async {
return await _bookExcerptDao.getExcerptsByBookId(bookId);
}
/// 添加摘抄
Future<void> addBookExcerpt(BookExcerpt excerpt) async {
await _bookExcerptDao.insertExcerpt(excerpt);
}
/// 更新摘抄
Future<void> updateBookExcerpt(BookExcerpt excerpt) async {
await _bookExcerptDao.updateExcerpt(excerpt);
}
/// 删除摘抄
Future<void> removeBookExcerpt(String id) async {
await _bookExcerptDao.deleteExcerpt(id);
}
/// 获取书籍的摘抄数量
Future<int> getBookExcerptCount(String bookId) async {
return await _bookExcerptDao.getExcerptCount(bookId);
}
// ========== 回收站相关方法 ==========
/// 获取已删除的影视
Future<List<Movie>> getDeletedMovies() async {
return await _movieDao.getDeletedMovies();
}
/// 恢复影视
Future<void> restoreMovie(String id) async {
await _movieDao.restoreMovie(id);
await loadMovies();
}
/// 彻底删除影视
Future<void> permanentDeleteMovie(String id) async {
await _movieDao.permanentDeleteMovie(id);
}
/// 获取已删除的书籍
Future<List<Book>> getDeletedBooks() async {
return await _bookDao.getDeletedBooks();
}
/// 恢复书籍
Future<void> restoreBook(String id) async {
await _bookDao.restoreBook(id);
await loadBooks();
}
/// 彻底删除书籍
Future<void> permanentDeleteBook(String id) async {
await _bookDao.permanentDeleteBook(id);
}
/// 获取已删除的笔记
Future<List<Note>> getDeletedNotes() async {
return await _noteDao.getDeletedNotes();
}
/// 恢复笔记
Future<void> restoreNote(String id) async {
await _noteDao.restoreNote(id);
await loadNotes();
}
/// 彻底删除笔记
Future<void> permanentDeleteNote(String id) async {
await _noteDao.permanentDeleteNote(id);
}
/// 清空回收站
Future<void> 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();
}
}

View File

@@ -12,15 +12,41 @@ class AppRouter {
static Route<dynamic> 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<String, dynamic>) {
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<String, dynamic>) {
initialStatus = args['initialStatus'] as String?;
}
return MaterialPageRoute(
builder: (_) => BookFormPage(book: book),
builder: (_) => BookFormPage(
book: book,
initialStatus: initialStatus,
),
);
case '/note-form':

View File

@@ -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<ExportResult> 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 = <String>{};
// 收集影视海报
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<void> shareBackup(String filePath) async {
final file = XFile(filePath);
await Share.shareXFiles(
[file],
subject: 'MookNote 数据备份',
text: '这是我的 MookNote 数据备份文件',
);
}
/// 选择并导入备份文件(支持 ZIP 格式)
Future<ImportResult> 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<String, dynamic> backupData;
int imageCount = 0;
// 记录图片文件名到新路径的映射
final imagePathMap = <String, String>{};
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<int>);
backupData = jsonDecode(jsonString) as Map<String, dynamic>;
// 解压图片到应用目录
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<int>);
imagePathMap[fileName] = outputFile.path;
imageCount++;
}
}
} else {
// 处理旧版 JSON 文件
final jsonString = await file.readAsString();
backupData = jsonDecode(jsonString) as Map<String, dynamic>;
}
// 验证备份格式
if (!backupData.containsKey('data')) {
return ImportResult.error('无效的备份文件格式');
}
// 导入数据
final data = backupData['data'] as Map<String, dynamic>;
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<dynamic>;
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<dynamic>;
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<dynamic>;
for (final note in notes) {
await txn.insert('notes', _convertToDbMap(note));
}
}
// 导入影评数据
if (data.containsKey('movie_reviews')) {
final reviews = data['movie_reviews'] as List<dynamic>;
for (final review in reviews) {
await txn.insert('movie_reviews', _convertToDbMap(review));
}
}
// 导入海报墙数据(更新图片路径)
if (data.containsKey('movie_posters')) {
final posters = data['movie_posters'] as List<dynamic>;
for (final poster in posters) {
final posterMap = _convertToDbMap(poster);
final updatedMap = _updateImagePath(posterMap, 'poster_path', imagePathMap);
await txn.insert('movie_posters', updatedMap);
}
}
});
// 统计导入数量
final stats = <String, int>{};
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<String, dynamic> _convertToDbMap(dynamic item) {
if (item is Map<String, dynamic>) {
return item.map((key, value) {
// 处理布尔值
if (value is bool) {
return MapEntry(key, value ? 1 : 0);
}
return MapEntry(key, value);
});
}
return {};
}
/// 更新图片路径为新的路径
Map<String, dynamic> _updateImagePath(
Map<String, dynamic> item,
String pathField,
Map<String, String> imagePathMap,
) {
final newItem = Map<String, dynamic>.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<String, int>? stats;
ImportResult._({
required this.success,
this.cancelled = false,
this.errorMessage,
this.stats,
});
factory ImportResult.success(Map<String, int> 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('');
}
}

View File

@@ -124,4 +124,40 @@ class BookDao {
return List.generate(maps.length, (i) => Book.fromJson(maps[i]));
}
// ========== 回收站相关方法 ==========
// 获取已删除的书籍
Future<List<Book>> getDeletedBooks() async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'books',
where: 'is_deleted = ?',
whereArgs: [1],
orderBy: 'updated_at DESC',
);
return List.generate(maps.length, (i) => Book.fromJson(maps[i]));
}
// 恢复已删除的书籍
Future<int> restoreBook(String id) async {
final db = await _dbHelper.database;
return await db.update(
'books',
{'is_deleted': 0, 'updated_at': DateTime.now().toIso8601String()},
where: 'id = ?',
whereArgs: [id],
);
}
// 彻底删除书籍
Future<int> permanentDeleteBook(String id) async {
final db = await _dbHelper.database;
return await db.delete(
'books',
where: 'id = ?',
whereArgs: [id],
);
}
}

View File

@@ -0,0 +1,109 @@
import 'package:sqflite/sqflite.dart';
import '../models/data_models.dart';
import 'database_helper.dart';
/// 书籍摘抄数据访问对象
class BookExcerptDao {
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
/// 获取书籍的所有摘抄
Future<List<BookExcerpt>> getExcerptsByBookId(String bookId) async {
final db = await _dbHelper.database;
final maps = await db.query(
'book_excerpts',
where: 'book_id = ? AND is_deleted = 0',
whereArgs: [bookId],
orderBy: 'created_at DESC',
);
return maps.map((map) => BookExcerpt.fromJson(map)).toList();
}
/// 根据ID获取摘抄
Future<BookExcerpt?> getExcerptById(String id) async {
final db = await _dbHelper.database;
final maps = await db.query(
'book_excerpts',
where: 'id = ? AND is_deleted = 0',
whereArgs: [id],
);
if (maps.isNotEmpty) {
return BookExcerpt.fromJson(maps.first);
}
return null;
}
/// 插入摘抄
Future<String> insertExcerpt(BookExcerpt excerpt) async {
final db = await _dbHelper.database;
await db.insert(
'book_excerpts',
excerpt.toJson(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
return excerpt.id;
}
/// 更新摘抄
Future<void> updateExcerpt(BookExcerpt excerpt) async {
final db = await _dbHelper.database;
await db.update(
'book_excerpts',
excerpt.toJson(),
where: 'id = ?',
whereArgs: [excerpt.id],
);
}
/// 删除摘抄(软删除)
Future<void> deleteExcerpt(String id) async {
final db = await _dbHelper.database;
await db.update(
'book_excerpts',
{'is_deleted': 1},
where: 'id = ?',
whereArgs: [id],
);
}
/// 彻底删除摘抄
Future<void> permanentDeleteExcerpt(String id) async {
final db = await _dbHelper.database;
await db.delete(
'book_excerpts',
where: 'id = ?',
whereArgs: [id],
);
}
/// 获取摘抄数量
Future<int> getExcerptCount(String bookId) async {
final db = await _dbHelper.database;
final result = await db.rawQuery(
'SELECT COUNT(*) as count FROM book_excerpts WHERE book_id = ? AND is_deleted = 0',
[bookId],
);
return result.first['count'] as int? ?? 0;
}
/// 获取所有已删除的摘抄
Future<List<BookExcerpt>> getDeletedExcerpts() async {
final db = await _dbHelper.database;
final maps = await db.query(
'book_excerpts',
where: 'is_deleted = 1',
orderBy: 'updated_at DESC',
);
return maps.map((map) => BookExcerpt.fromJson(map)).toList();
}
/// 恢复已删除的摘抄
Future<void> restoreExcerpt(String id) async {
final db = await _dbHelper.database;
await db.update(
'book_excerpts',
{'is_deleted': 0},
where: 'id = ?',
whereArgs: [id],
);
}
}

View File

@@ -0,0 +1,109 @@
import 'package:sqflite/sqflite.dart';
import '../models/data_models.dart';
import 'database_helper.dart';
/// 书评数据访问对象
class BookReviewDao {
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
/// 获取书籍的所有书评
Future<List<BookReview>> getReviewsByBookId(String bookId) async {
final db = await _dbHelper.database;
final maps = await db.query(
'book_reviews',
where: 'book_id = ? AND is_deleted = 0',
whereArgs: [bookId],
orderBy: 'created_at DESC',
);
return maps.map((map) => BookReview.fromJson(map)).toList();
}
/// 根据ID获取书评
Future<BookReview?> getReviewById(String id) async {
final db = await _dbHelper.database;
final maps = await db.query(
'book_reviews',
where: 'id = ? AND is_deleted = 0',
whereArgs: [id],
);
if (maps.isNotEmpty) {
return BookReview.fromJson(maps.first);
}
return null;
}
/// 插入书评
Future<String> insertReview(BookReview review) async {
final db = await _dbHelper.database;
await db.insert(
'book_reviews',
review.toJson(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
return review.id;
}
/// 更新书评
Future<void> updateReview(BookReview review) async {
final db = await _dbHelper.database;
await db.update(
'book_reviews',
review.toJson(),
where: 'id = ?',
whereArgs: [review.id],
);
}
/// 删除书评(软删除)
Future<void> deleteReview(String id) async {
final db = await _dbHelper.database;
await db.update(
'book_reviews',
{'is_deleted': 1},
where: 'id = ?',
whereArgs: [id],
);
}
/// 彻底删除书评
Future<void> permanentDeleteReview(String id) async {
final db = await _dbHelper.database;
await db.delete(
'book_reviews',
where: 'id = ?',
whereArgs: [id],
);
}
/// 获取书评数量
Future<int> getReviewCount(String bookId) async {
final db = await _dbHelper.database;
final result = await db.rawQuery(
'SELECT COUNT(*) as count FROM book_reviews WHERE book_id = ? AND is_deleted = 0',
[bookId],
);
return result.first['count'] as int? ?? 0;
}
/// 获取所有已删除的书评
Future<List<BookReview>> getDeletedReviews() async {
final db = await _dbHelper.database;
final maps = await db.query(
'book_reviews',
where: 'is_deleted = 1',
orderBy: 'updated_at DESC',
);
return maps.map((map) => BookReview.fromJson(map)).toList();
}
/// 恢复已删除的书评
Future<void> restoreReview(String id) async {
final db = await _dbHelper.database;
await db.update(
'book_reviews',
{'is_deleted': 0},
where: 'id = ?',
whereArgs: [id],
);
}
}

View File

@@ -20,7 +20,7 @@ class DatabaseHelper {
return await openDatabase(
path,
version: 5,
version: 8,
onCreate: _createDB,
onUpgrade: _onUpgrade,
);
@@ -45,6 +45,31 @@ class DatabaseHelper {
await _createMovieReviewsTable(db);
await _createMoviePostersTable(db);
}
if (oldVersion < 6) {
// 为笔记表添加软删除字段
await _upgradeNotesTableV6(db);
}
if (oldVersion < 7) {
// 创建书评表和摘抄表
await _createBookReviewsTable(db);
await _createBookExcerptsTable(db);
}
if (oldVersion < 8) {
// 确保书评表和摘抄表存在(兼容之前版本未成功创建的情况)
await _createBookReviewsTable(db);
await _createBookExcerptsTable(db);
}
}
/// 升级notes表到V6添加软删除字段
Future<void> _upgradeNotesTableV6(Database db) async {
// 检查是否存在 is_deleted 列
final columns = await db.rawQuery('PRAGMA table_info(notes)');
final hasIsDeleted = columns.any((col) => col['name'] == 'is_deleted');
if (!hasIsDeleted) {
await db.execute('ALTER TABLE notes ADD COLUMN is_deleted INTEGER DEFAULT 0');
}
}
/// 创建影评表
@@ -64,7 +89,7 @@ class DatabaseHelper {
)
''');
}
/// 创建影视海报墙表
Future<void> _createMoviePostersTable(Database db) async {
await db.execute('''
@@ -78,6 +103,41 @@ class DatabaseHelper {
)
''');
}
/// 创建书评表
Future<void> _createBookReviewsTable(Database db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS book_reviews (
id TEXT PRIMARY KEY,
book_id TEXT NOT NULL,
content TEXT NOT NULL,
reviewer TEXT,
source TEXT,
review_type INTEGER DEFAULT 1,
is_deleted INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (book_id) REFERENCES books (id)
)
''');
}
/// 创建书籍摘抄表
Future<void> _createBookExcerptsTable(Database db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS book_excerpts (
id TEXT PRIMARY KEY,
book_id TEXT NOT NULL,
chapter TEXT,
content TEXT NOT NULL,
comment TEXT,
is_deleted INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (book_id) REFERENCES books (id)
)
''');
}
/// 升级notes表到V4
Future<void> _upgradeNotesTableV4(Database db) async {
@@ -285,7 +345,8 @@ class DatabaseHelper {
content_type TEXT DEFAULT 'markdown',
tags TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
updated_at TEXT NOT NULL,
is_deleted INTEGER DEFAULT 0
)
''');
@@ -316,6 +377,37 @@ class DatabaseHelper {
FOREIGN KEY (movie_id) REFERENCES movies (id)
)
''');
// 书评表
await db.execute('''
CREATE TABLE book_reviews (
id TEXT PRIMARY KEY,
book_id TEXT NOT NULL,
content TEXT NOT NULL,
reviewer TEXT,
source TEXT,
review_type INTEGER DEFAULT 1,
is_deleted INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (book_id) REFERENCES books (id)
)
''');
// 书籍摘抄表
await db.execute('''
CREATE TABLE book_excerpts (
id TEXT PRIMARY KEY,
book_id TEXT NOT NULL,
chapter TEXT,
content TEXT NOT NULL,
comment TEXT,
is_deleted INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (book_id) REFERENCES books (id)
)
''');
}
// 关闭数据库

View File

@@ -177,4 +177,40 @@ class MovieDao {
}
return genres.toList()..sort();
}
// ========== 回收站相关方法 ==========
// 获取已删除的影视
Future<List<Movie>> getDeletedMovies() async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'movies',
where: 'is_deleted = ?',
whereArgs: [1],
orderBy: 'updated_at DESC',
);
return List.generate(maps.length, (i) => Movie.fromJson(maps[i]));
}
// 恢复已删除的影视
Future<int> restoreMovie(String id) async {
final db = await _dbHelper.database;
return await db.update(
'movies',
{'is_deleted': 0, 'updated_at': DateTime.now().toIso8601String()},
where: 'id = ?',
whereArgs: [id],
);
}
// 彻底删除影视
Future<int> permanentDeleteMovie(String id) async {
final db = await _dbHelper.database;
return await db.delete(
'movies',
where: 'id = ?',
whereArgs: [id],
);
}
}

View File

@@ -6,11 +6,13 @@ import 'database_helper.dart';
class NoteDao {
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
// 获取所有笔记
// 获取所有未删除的笔记
Future<List<Note>> getAllNotes() async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'notes',
where: 'is_deleted = ?',
whereArgs: [0],
orderBy: 'updated_at DESC',
);
@@ -47,8 +49,45 @@ class NoteDao {
);
}
// 删除笔记
// 删除笔记
Future<int> deleteNote(String id) async {
final db = await _dbHelper.database;
return await db.update(
'notes',
{'is_deleted': 1, 'updated_at': DateTime.now().toIso8601String()},
where: 'id = ?',
whereArgs: [id],
);
}
// ========== 回收站相关方法 ==========
// 获取已删除的笔记
Future<List<Note>> getDeletedNotes() async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'notes',
where: 'is_deleted = ?',
whereArgs: [1],
orderBy: 'updated_at DESC',
);
return List.generate(maps.length, (i) => Note.fromJson(maps[i]));
}
// 恢复已删除的笔记
Future<int> restoreNote(String id) async {
final db = await _dbHelper.database;
return await db.update(
'notes',
{'is_deleted': 0, 'updated_at': DateTime.now().toIso8601String()},
where: 'id = ?',
whereArgs: [id],
);
}
// 彻底删除笔记
Future<int> permanentDeleteNote(String id) async {
final db = await _dbHelper.database;
return await db.delete(
'notes',
@@ -62,8 +101,8 @@ class NoteDao {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'notes',
where: 'content LIKE ? OR tags LIKE ?',
whereArgs: ['%$query%', '%$query%'],
where: '(content LIKE ? OR tags LIKE ?) AND is_deleted = ?',
whereArgs: ['%$query%', '%$query%', 0],
orderBy: 'updated_at DESC',
);
@@ -75,8 +114,8 @@ class NoteDao {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'notes',
where: 'tags LIKE ?',
whereArgs: ['%$tag%'],
where: 'tags LIKE ? AND is_deleted = ?',
whereArgs: ['%$tag%', 0],
orderBy: 'updated_at DESC',
);

49
lib/utils/toast_util.dart Normal file
View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
/// Toast 工具类
class ToastUtil {
static OverlayEntry? _currentToast;
/// 显示 Toast
static void show(BuildContext context, String message) {
// 移除之前的 Toast
_currentToast?.remove();
_currentToast = null;
final overlay = Overlay.of(context);
_currentToast = OverlayEntry(
builder: (context) => Positioned(
top: MediaQuery.of(context).padding.top + 80,
left: 0,
right: 0,
child: Center(
child: Material(
color: Colors.transparent,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A).withOpacity(0.9),
borderRadius: BorderRadius.circular(24),
),
child: Text(
message,
style: const TextStyle(
fontSize: 14,
color: Colors.white,
),
),
),
),
),
),
);
overlay.insert(_currentToast!);
// 2秒后自动消失
Future.delayed(const Duration(seconds: 2), () {
_currentToast?.remove();
_currentToast = null;
});
}
}

View File

@@ -1,134 +1,66 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
import '../models/data_models.dart';
/// 书籍列表项组件 - 极简主义设计
/// 书籍列表项组件 - 网格布局设计
class BookListItem extends StatelessWidget {
final Book book;
const BookListItem({super.key, required this.book});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {
Navigator.pushNamed(context, '/book-detail', arguments: book);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: Color(0xFFE5E5E5), width: 0.5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 封面
Expanded(
child: _buildCover(),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 封面
_buildCover(),
const SizedBox(width: 16),
// 信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 书名
Text(
book.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// 作者
if (book.authors.isNotEmpty)
Text(
book.authors.join(' / '),
style: const TextStyle(
fontSize: 13,
color: Color(0xFF666666),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
// 评分和状态
Row(
children: [
if (book.rating != null) ...[
const Icon(
Icons.star,
size: 14,
color: Color(0xFF1A1A1A),
),
const SizedBox(width: 2),
Text(
book.rating!.toStringAsFixed(1),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(width: 12),
],
_buildStatusTag(),
],
),
// 类型
if (book.genres.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
book.genres.take(3).join(' · '),
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
const SizedBox(height: 8),
// 书名
Text(
book.title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
// 操作按钮
Column(
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// 评分
if (book.rating != null)
Row(
children: [
IconButton(
icon: const Icon(Icons.edit_outlined, size: 20),
color: const Color(0xFF666666),
onPressed: () {
Navigator.pushNamed(context, '/book-form', arguments: book);
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
const Icon(
Icons.star,
size: 12,
color: Color(0xFFFFB800),
),
const SizedBox(height: 8),
IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
color: Colors.red,
onPressed: () => _showDeleteDialog(context),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
const SizedBox(width: 2),
Text(
book.rating!.toStringAsFixed(1),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Color(0xFF666666),
),
),
],
),
],
),
)
else
const SizedBox(height: 16),
],
),
);
}
@@ -136,12 +68,13 @@ class BookListItem extends StatelessWidget {
/// 构建封面
Widget _buildCover() {
return Container(
width: 60,
height: 80,
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5),
),
clipBehavior: Clip.antiAlias,
child: book.coverPath != null && book.coverPath!.isNotEmpty
? Image.file(
File(book.coverPath!),
@@ -151,84 +84,14 @@ class BookListItem extends StatelessWidget {
: _buildCoverPlaceholder(),
);
}
Widget _buildCoverPlaceholder() {
return const Center(
child: Icon(
Icons.menu_book,
size: 24,
Icons.menu_book_outlined,
size: 32,
color: Color(0xFFCCCCCC),
),
);
}
/// 构建状态标签
Widget _buildStatusTag() {
String label;
Color color;
switch (book.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.withValues(alpha: 0.1),
border: Border.all(color: color),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
color: color,
fontWeight: FontWeight.w500,
),
),
);
}
/// 显示删除对话框
void _showDeleteDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: Colors.white,
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
title: const Text('确认删除'),
content: Text('确定要删除"${book.title}"吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消', style: TextStyle(color: Color(0xFF666666))),
),
TextButton(
onPressed: () async {
await context.read<AppProvider>().removeBook(book.id);
if (!context.mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已删除')),
);
},
child: const Text('删除', style: TextStyle(color: Colors.red)),
),
],
),
);
}
}

View File

@@ -58,8 +58,18 @@ class CustomBottomNavBar extends StatelessWidget {
subtitle: 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},
);
},
),
ListTile(
@@ -68,8 +78,18 @@ class CustomBottomNavBar extends StatelessWidget {
subtitle: 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},
);
},
),
ListTile(

View File

@@ -23,27 +23,7 @@ class CustomDrawer extends StatelessWidget {
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
_buildMenuItem(
icon: Icons.delete_outline,
title: '回收站',
onTap: () {
Navigator.pop(context);
_showToast(context, '回收站功能开发中');
},
),
const Divider(height: 0.5, thickness: 0.5, indent: 56, color: Color(0xFFE5E5E5)),
_buildMenuItem(
icon: Icons.settings_outlined,
title: '设置',
onTap: () {
Navigator.pop(context);
_showToast(context, '设置功能开发中');
},
),
],
children: [],
),
),

View File

@@ -1,134 +1,66 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
import '../models/data_models.dart';
/// 观影列表项组件 - 极简主义设计
/// 观影列表项组件 - 网格布局设计
class MovieListItem extends StatelessWidget {
final Movie movie;
const MovieListItem({super.key, required this.movie});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {
Navigator.pushNamed(context, '/movie-detail', arguments: movie);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: Color(0xFFE5E5E5), width: 0.5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 海报
Expanded(
child: _buildPoster(),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 海报
_buildPoster(),
const SizedBox(width: 16),
// 信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 影视名称
Text(
movie.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// 导演
if (movie.directors.isNotEmpty)
Text(
movie.directors.take(2).join(' / '),
style: const TextStyle(
fontSize: 13,
color: Color(0xFF666666),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
// 评分和状态
Row(
children: [
if (movie.rating != null) ...[
const Icon(
Icons.star,
size: 14,
color: Color(0xFF1A1A1A),
),
const SizedBox(width: 2),
Text(
movie.rating!.toStringAsFixed(1),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(width: 12),
],
_buildStatusTag(),
],
),
// 类型
if (movie.genres.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
movie.genres.take(3).join(' · '),
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
const SizedBox(height: 8),
// 影视名称
Text(
movie.title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
// 操作按钮
Column(
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// 评分
if (movie.rating != null)
Row(
children: [
IconButton(
icon: const Icon(Icons.edit_outlined, size: 20),
color: const Color(0xFF666666),
onPressed: () {
Navigator.pushNamed(context, '/movie-form', arguments: movie);
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
const Icon(
Icons.star,
size: 12,
color: Color(0xFFFFB800),
),
const SizedBox(height: 8),
IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
color: Colors.red,
onPressed: () => _showDeleteDialog(context),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
const SizedBox(width: 2),
Text(
movie.rating!.toStringAsFixed(1),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Color(0xFF666666),
),
),
],
),
],
),
)
else
const SizedBox(height: 16),
],
),
);
}
@@ -136,12 +68,13 @@ class MovieListItem extends StatelessWidget {
/// 构建海报
Widget _buildPoster() {
return Container(
width: 60,
height: 80,
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5),
),
clipBehavior: Clip.antiAlias,
child: movie.posterPath != null && movie.posterPath!.isNotEmpty
? Image.file(
File(movie.posterPath!),
@@ -162,73 +95,4 @@ class MovieListItem extends StatelessWidget {
);
}
/// 构建状态标签
Widget _buildStatusTag() {
String label;
Color color;
switch (movie.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.withValues(alpha: 0.1),
border: Border.all(color: color),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
color: color,
fontWeight: FontWeight.w500,
),
),
);
}
/// 显示删除对话框
void _showDeleteDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: Colors.white,
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
title: const Text('确认删除'),
content: Text('确定要删除"${movie.title}"吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消', style: TextStyle(color: Color(0xFF666666))),
),
TextButton(
onPressed: () async {
await context.read<AppProvider>().removeMovie(movie.id);
if (!context.mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已删除')),
);
},
child: const Text('删除', style: TextStyle(color: Colors.red)),
),
],
),
);
}
}

View File

@@ -61,6 +61,7 @@ class MovieStatusBar extends StatelessWidget {
final isSelected = index == currentIndex;
Color color;
// 0:已看(深色), 1:在看(中灰), 2:想看(浅灰)
switch (index) {
case 0:
color = const Color(0xFF1A1A1A);

View File

@@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
archive:
dependency: "direct main"
description:
name: archive
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
url: "https://pub.dev"
source: hosted
version: "3.6.1"
args:
dependency: transitive
description:
@@ -58,7 +66,7 @@ packages:
source: hosted
version: "1.19.1"
cross_file:
dependency: transitive
dependency: "direct main"
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
@@ -113,6 +121,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
url: "https://pub.dev"
source: hosted
version: "8.3.7"
file_selector_linux:
dependency: transitive
description:
@@ -145,6 +161,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
fl_chart:
dependency: "direct main"
description:
@@ -182,6 +206,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.33"
flutter_staggered_grid_view:
dependency: "direct main"
description:
name: flutter_staggered_grid_view
sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395"
url: "https://pub.dev"
source: hosted
version: "0.7.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -448,6 +480,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
url: "https://pub.dev"
source: hosted
version: "11.4.0"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
url: "https://pub.dev"
source: hosted
version: "12.1.0"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
url: "https://pub.dev"
source: hosted
version: "9.4.7"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
url: "https://pub.dev"
source: hosted
version: "0.1.3+5"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
url: "https://pub.dev"
source: hosted
version: "4.3.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
platform:
dependency: transitive
description:
@@ -480,6 +560,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
url: "https://pub.dev"
source: hosted
version: "10.1.4"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
url: "https://pub.dev"
source: hosted
version: "5.0.2"
shared_preferences:
dependency: "direct main"
description:
@@ -645,6 +741,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
url: "https://pub.dev"
source: hosted
version: "2.4.2"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math:
dependency: transitive
description:
@@ -669,6 +805,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories:
dependency: transitive
description:

View File

@@ -18,6 +18,12 @@ dependencies:
path_provider: ^2.1.1
shared_preferences: ^2.2.2
flutter_markdown: ^0.7.4
file_picker: ^8.0.0
share_plus: ^10.0.0
cross_file: ^0.3.4
archive: ^3.6.0
permission_handler: ^11.3.0
flutter_staggered_grid_view: ^0.7.0
dev_dependencies:
flutter_test: