From 082145ff79184844e8a5880dd3ae17a02eac2164 Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Sat, 7 Mar 2026 00:40:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9F=BA=E6=9C=AC=E5=8A=9F=E8=83=BD=E5=AE=8C?= =?UTF-8?q?=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle.kts | 4 +- android/app/src/main/AndroidManifest.xml | 10 +- .../iletter}/mooknote/MainActivity.kt | 2 +- lib/models/data_models.dart | 201 ++++- lib/pages/backup_page.dart | 312 ++++++++ lib/pages/book_detail_page.dart | 401 ++++++++-- lib/pages/book_excerpt_form_page.dart | 191 +++++ lib/pages/book_excerpts_page.dart | 300 ++++++++ lib/pages/book_form_page.dart | 373 ++++++--- lib/pages/book_review_form_page.dart | 266 +++++++ lib/pages/book_reviews_page.dart | 270 +++++++ lib/pages/book_tab_page.dart | 22 +- lib/pages/main_content_page.dart | 34 +- lib/pages/movie_detail_page.dart | 325 +++++--- lib/pages/movie_form_page.dart | 339 +++++--- lib/pages/movie_posters_page.dart | 129 ++-- lib/pages/movie_reviews_page.dart | 2 + lib/pages/movie_tab_page.dart | 30 +- lib/pages/profile_page.dart | 31 +- lib/pages/recycle_bin_page.dart | 376 +++++++++ lib/pages/search_page.dart | 486 ++++++++++++ lib/pages/statistics_page.dart | 728 ++++++++++++++++++ lib/providers/app_provider.dart | 129 ++++ lib/utils/app_router.dart | 34 +- lib/utils/backup_service.dart | 440 +++++++++++ lib/utils/book_dao.dart | 36 + lib/utils/book_excerpt_dao.dart | 109 +++ lib/utils/book_review_dao.dart | 109 +++ lib/utils/database_helper.dart | 98 ++- lib/utils/movie_dao.dart | 36 + lib/utils/note_dao.dart | 51 +- lib/utils/toast_util.dart | 49 ++ lib/widgets/book_list_item.dart | 237 ++---- lib/widgets/bottom_nav_bar.dart | 28 +- lib/widgets/custom_drawer.dart | 22 +- lib/widgets/movie_list_item.dart | 230 ++---- lib/widgets/movie_status_bar.dart | 1 + pubspec.lock | 146 +++- pubspec.yaml | 6 + 39 files changed, 5726 insertions(+), 867 deletions(-) rename android/app/src/main/kotlin/{com/example => top/iletter}/mooknote/MainActivity.kt (76%) create mode 100644 lib/pages/backup_page.dart create mode 100644 lib/pages/book_excerpt_form_page.dart create mode 100644 lib/pages/book_excerpts_page.dart create mode 100644 lib/pages/book_review_form_page.dart create mode 100644 lib/pages/book_reviews_page.dart create mode 100644 lib/pages/recycle_bin_page.dart create mode 100644 lib/pages/search_page.dart create mode 100644 lib/pages/statistics_page.dart create mode 100644 lib/utils/backup_service.dart create mode 100644 lib/utils/book_excerpt_dao.dart create mode 100644 lib/utils/book_review_dao.dart create mode 100644 lib/utils/toast_util.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9bfb05c..b906d6f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } android { - namespace = "com.example.mooknote" + namespace = "top.iletter.mooknote" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -21,7 +21,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.mooknote" + applicationId = "top.iletter.mooknote" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 65c2f0b..b48121a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,8 @@ + android:icon="@mipmap/ic_launcher" + android:requestLegacyExternalStorage="true"> + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/mooknote/MainActivity.kt b/android/app/src/main/kotlin/top/iletter/mooknote/MainActivity.kt similarity index 76% rename from android/app/src/main/kotlin/com/example/mooknote/MainActivity.kt rename to android/app/src/main/kotlin/top/iletter/mooknote/MainActivity.kt index 73963df..d5438a8 100644 --- a/android/app/src/main/kotlin/com/example/mooknote/MainActivity.kt +++ b/android/app/src/main/kotlin/top/iletter/mooknote/MainActivity.kt @@ -1,4 +1,4 @@ -package com.example.mooknote +package top.iletter.mooknote import io.flutter.embedding.android.FlutterActivity diff --git a/lib/models/data_models.dart b/lib/models/data_models.dart index 89cc70b..8711f1d 100644 --- a/lib/models/data_models.dart +++ b/lib/models/data_models.dart @@ -1,6 +1,12 @@ import 'dart:io'; import 'dart:convert'; +/// 用于区分 copyWith 中"未传参数"和"传了 null"的标记 +class _CopyWithNullSentinel { + const _CopyWithNullSentinel(); +} +const _copyWithNull = _CopyWithNullSentinel(); + /// 影视条目模型 class Movie { final String id; @@ -117,14 +123,14 @@ class Movie { Movie copyWith({ String? id, String? title, - String? posterPath, + Object? posterPath = _copyWithNull, DateTime? releaseDate, List? directors, List? writers, List? actors, List? genres, List? alternateTitles, - String? summary, + Object? summary = _copyWithNull, double? rating, String? status, DateTime? createdAt, @@ -134,14 +140,14 @@ class Movie { return Movie( id: id ?? this.id, title: title ?? this.title, - posterPath: posterPath ?? this.posterPath, + posterPath: posterPath is _CopyWithNullSentinel ? this.posterPath : (posterPath as String?), releaseDate: releaseDate ?? this.releaseDate, directors: directors ?? this.directors, writers: writers ?? this.writers, actors: actors ?? this.actors, genres: genres ?? this.genres, alternateTitles: alternateTitles ?? this.alternateTitles, - summary: summary ?? this.summary, + summary: summary is _CopyWithNullSentinel ? this.summary : (summary as String?), rating: rating ?? this.rating, status: status ?? this.status, createdAt: createdAt ?? this.createdAt, @@ -233,12 +239,12 @@ class Book { Book copyWith({ String? id, String? title, - String? coverPath, + Object? coverPath = _copyWithNull, List? authors, List? alternateTitles, String? publisher, List? genres, - String? summary, + Object? summary = _copyWithNull, double? rating, String? status, DateTime? createdAt, @@ -248,12 +254,12 @@ class Book { return Book( id: id ?? this.id, title: title ?? this.title, - coverPath: coverPath ?? this.coverPath, + coverPath: coverPath is _CopyWithNullSentinel ? this.coverPath : (coverPath as String?), authors: authors ?? this.authors, alternateTitles: alternateTitles ?? this.alternateTitles, publisher: publisher ?? this.publisher, genres: genres ?? this.genres, - summary: summary ?? this.summary, + summary: summary is _CopyWithNullSentinel ? this.summary : (summary as String?), rating: rating ?? this.rating, status: status ?? this.status, createdAt: createdAt ?? this.createdAt, @@ -271,6 +277,7 @@ class Note { final List tags; final DateTime createdAt; final DateTime updatedAt; + final bool isDeleted; Note({ required this.id, @@ -279,6 +286,7 @@ class Note { this.tags = const [], required this.createdAt, required this.updatedAt, + this.isDeleted = false, }); factory Note.fromJson(Map json) { @@ -293,6 +301,7 @@ class Note { updatedAt: json['updated_at'] != null ? DateTime.parse(json['updated_at']) : DateTime.now(), + isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, ); } @@ -304,6 +313,7 @@ class Note { 'tags': jsonEncode(tags), 'created_at': createdAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(), + 'is_deleted': isDeleted ? 1 : 0, }; } @@ -315,6 +325,7 @@ class Note { List? tags, DateTime? createdAt, DateTime? updatedAt, + bool? isDeleted, }) { return Note( id: id ?? this.id, @@ -323,6 +334,7 @@ class Note { tags: tags ?? this.tags, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, + isDeleted: isDeleted ?? this.isDeleted, ); } @@ -469,3 +481,176 @@ class MoviePoster { } } +/// 书评模型 +class BookReview { + final String id; + final String bookId; + final String content; + final String reviewer; + final String source; + final int reviewType; // 1: 短评, 2: 长评 + final bool isDeleted; + final DateTime createdAt; + final DateTime updatedAt; + + BookReview({ + required this.id, + required this.bookId, + required this.content, + this.reviewer = '', + this.source = '', + this.reviewType = 1, + this.isDeleted = false, + required this.createdAt, + required this.updatedAt, + }); + + factory BookReview.fromJson(Map json) { + return BookReview( + id: json['id']?.toString() ?? '', + bookId: json['book_id']?.toString() ?? '', + content: json['content'] ?? '', + reviewer: json['reviewer'] ?? '', + source: json['source'] ?? '', + reviewType: json['review_type'] ?? 1, + isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at']) + : DateTime.now(), + updatedAt: json['updated_at'] != null + ? DateTime.parse(json['updated_at']) + : DateTime.now(), + ); + } + + Map toJson() { + return { + 'id': id, + 'book_id': bookId, + 'content': content, + 'reviewer': reviewer, + 'source': source, + 'review_type': reviewType, + 'is_deleted': isDeleted ? 1 : 0, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + } + + /// 复制并修改 + BookReview copyWith({ + String? id, + String? bookId, + String? content, + String? reviewer, + String? source, + int? reviewType, + bool? isDeleted, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return BookReview( + id: id ?? this.id, + bookId: bookId ?? this.bookId, + content: content ?? this.content, + reviewer: reviewer ?? this.reviewer, + source: source ?? this.source, + reviewType: reviewType ?? this.reviewType, + isDeleted: isDeleted ?? this.isDeleted, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + /// 获取评论摘要 + String get summary { + if (content.length <= 50) return content; + return '${content.substring(0, 50)}...'; + } + + /// 评论类型文本 + String get typeText => reviewType == 1 ? '短评' : '长评'; +} + +/// 书籍摘抄模型 +class BookExcerpt { + final String id; + final String bookId; + final String chapter; // 章节 + final String content; // 摘抄内容 + final String comment; // 摘抄的评论/感悟 + final bool isDeleted; + final DateTime createdAt; + final DateTime updatedAt; + + BookExcerpt({ + required this.id, + required this.bookId, + this.chapter = '', + required this.content, + this.comment = '', + this.isDeleted = false, + required this.createdAt, + required this.updatedAt, + }); + + factory BookExcerpt.fromJson(Map json) { + return BookExcerpt( + id: json['id']?.toString() ?? '', + bookId: json['book_id']?.toString() ?? '', + chapter: json['chapter'] ?? '', + content: json['content'] ?? '', + comment: json['comment'] ?? '', + isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at']) + : DateTime.now(), + updatedAt: json['updated_at'] != null + ? DateTime.parse(json['updated_at']) + : DateTime.now(), + ); + } + + Map toJson() { + return { + 'id': id, + 'book_id': bookId, + 'chapter': chapter, + 'content': content, + 'comment': comment, + 'is_deleted': isDeleted ? 1 : 0, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + } + + /// 复制并修改 + BookExcerpt copyWith({ + String? id, + String? bookId, + String? chapter, + String? content, + String? comment, + bool? isDeleted, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return BookExcerpt( + id: id ?? this.id, + bookId: bookId ?? this.bookId, + chapter: chapter ?? this.chapter, + content: content ?? this.content, + comment: comment ?? this.comment, + isDeleted: isDeleted ?? this.isDeleted, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + /// 获取摘抄摘要 + String get summary { + if (content.length <= 50) return content; + return '${content.substring(0, 50)}...'; + } +} + diff --git a/lib/pages/backup_page.dart b/lib/pages/backup_page.dart new file mode 100644 index 0000000..ecb1c41 --- /dev/null +++ b/lib/pages/backup_page.dart @@ -0,0 +1,312 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../utils/backup_service.dart'; + +/// 数据备份页面 +class BackupPage extends StatefulWidget { + const BackupPage({super.key}); + + @override + State createState() => _BackupPageState(); +} + +class _BackupPageState extends State { + bool _isExporting = false; + bool _isImporting = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text('数据备份'), + ), + body: ListView( + padding: const EdgeInsets.all(24), + children: [ + // 导出数据 + _buildSection( + title: '导出数据', + description: '将所有数据导出为 JSON 文件,可用于备份或迁移到其他设备', + icon: Icons.upload_outlined, + buttonText: '导出', + isLoading: _isExporting, + onTap: _exportData, + ), + + const SizedBox(height: 32), + + // 导入数据 + _buildSection( + title: '导入数据', + description: '从备份文件导入数据,将覆盖当前所有数据', + icon: Icons.download_outlined, + buttonText: '导入', + isLoading: _isImporting, + onTap: _importData, + isDestructive: true, + ), + + const SizedBox(height: 48), + + // 说明 + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + size: 16, + color: const Color(0xFF666666), + ), + const SizedBox(width: 8), + Text( + '使用说明', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: const Color(0xFF666666), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + '1. 导出数据会生成一个 .zip 文件,包含所有数据和图片\n' + '2. 选择保存路径后,可以通过微信、邮件等方式发送备份文件\n' + '3. 在新设备上选择导入数据,选择备份文件即可恢复\n' + '4. 导入数据会完全覆盖当前设备的数据,请谨慎操作', + style: TextStyle( + fontSize: 13, + color: const Color(0xFF999999), + height: 1.6, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildSection({ + required String title, + required String description, + required IconData icon, + required String buttonText, + required bool isLoading, + required VoidCallback onTap, + bool isDestructive = false, + }) { + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + icon, + size: 24, + color: isDestructive ? Colors.red : const Color(0xFF1A1A1A), + ), + const SizedBox(width: 12), + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Color(0xFF1A1A1A), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + description, + style: const TextStyle( + fontSize: 14, + color: Color(0xFF666666), + height: 1.5, + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: isLoading ? null : onTap, + style: OutlinedButton.styleFrom( + foregroundColor: isDestructive ? Colors.red : const Color(0xFF1A1A1A), + side: BorderSide( + color: isDestructive ? Colors.red : const Color(0xFF1A1A1A), + ), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + isDestructive ? Colors.red : const Color(0xFF1A1A1A), + ), + ), + ) + : Text(buttonText), + ), + ), + ], + ), + ); + } + + /// 导出数据 + Future _exportData() async { + setState(() => _isExporting = true); + + try { + final result = await BackupService.instance.exportDataWithImages(); + + if (!mounted) return; + + if (result.cancelled) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已取消导出')), + ); + } else if (result.success) { + // 显示导出成功信息 + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + title: const Text('导出成功'), + content: Text( + '备份文件已保存到:\n${result.filePath}\n\n' + '包含数据:\n' + '• 影视: ${result.movieCount}\n' + '• 书籍: ${result.bookCount}\n' + '• 笔记: ${result.noteCount}\n' + '• 图片: ${result.imageCount}', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('确定'), + ), + ], + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(result.errorMessage ?? '导出失败')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('导出失败: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _isExporting = false); + } + } + } + + /// 导入数据 + Future _importData() async { + // 显示确认对话框 + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + title: const Text('确认导入'), + content: const Text( + '导入数据将覆盖当前所有数据,此操作不可恢复。\n\n是否继续?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消', style: TextStyle(color: Color(0xFF666666))), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('确认导入', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + + if (confirmed != true) return; + + setState(() => _isImporting = true); + + try { + final result = await BackupService.instance.importData(); + + if (!mounted) return; + + if (result.cancelled) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已取消导入')), + ); + } else if (result.success) { + // 刷新数据 + await context.read().loadMovies(); + await context.read().loadBooks(); + await context.read().loadNotes(); + + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + title: const Text('导入成功'), + content: Text('成功导入数据:\n${result.statsText}'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('确定'), + ), + ], + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(result.errorMessage ?? '导入失败')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('导入失败: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _isImporting = false); + } + } + } +} diff --git a/lib/pages/book_detail_page.dart b/lib/pages/book_detail_page.dart index cf5b7ab..cfc53b7 100644 --- a/lib/pages/book_detail_page.dart +++ b/lib/pages/book_detail_page.dart @@ -1,8 +1,12 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:cross_file/cross_file.dart'; import '../providers/app_provider.dart'; import '../models/data_models.dart'; +import 'book_reviews_page.dart'; +import 'book_excerpts_page.dart'; /// 书籍详情页 - 极简主义设计 class BookDetailPage extends StatefulWidget { @@ -15,46 +19,65 @@ class BookDetailPage extends StatefulWidget { } class _BookDetailPageState extends State { + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // 页面获得焦点时刷新数据 + _refreshBookData(); + } + + void _refreshBookData() { + final provider = context.read(); + // 强制刷新当前书籍数据 + provider.loadBooks(); + } + @override Widget build(BuildContext context) { + // 从 Provider 获取最新的 book 数据,实现动态刷新 + final book = context.watch().books + .where((b) => b.id == widget.book.id) + .firstOrNull ?? widget.book; + return Scaffold( backgroundColor: Colors.white, body: CustomScrollView( slivers: [ // 顶部封面区域 - _buildSliverAppBar(), - + _buildSliverAppBar(book), + // 内容区域 SliverToBoxAdapter( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 基本信息 - _buildBasicInfo(), + _buildBasicInfo(book), const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), // 作者信息 - _buildAuthorsSection(), - + _buildAuthorsSection(book), + // 出版社 - if (widget.book.publisher != null && widget.book.publisher!.isNotEmpty) - _buildPublisherSection(), - + if (book.publisher != null && book.publisher!.isNotEmpty) + _buildPublisherSection(book), + // 类型 - if (widget.book.genres.isNotEmpty) - _buildGenresSection(), + if (book.genres.isNotEmpty) + _buildGenresSection(book), const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), // 简介 - if (widget.book.summary != null && widget.book.summary!.isNotEmpty) - _buildSummarySection(), - - // 别名 - if (widget.book.alternateTitles.isNotEmpty) - _buildAlternateTitlesSection(), - + if (book.summary != null && book.summary!.isNotEmpty) + _buildSummarySection(book), + + const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), + + // 书评和摘抄入口 + _buildExtraSections(book), + const SizedBox(height: 48), ], ), @@ -68,33 +91,66 @@ class _BookDetailPageState extends State { } /// 构建顶部 AppBar - Widget _buildSliverAppBar() { + Widget _buildSliverAppBar(Book book) { + final hasCover = book.coverPath != null && book.coverPath!.isNotEmpty; + return SliverAppBar( - expandedHeight: 280, + expandedHeight: 320, pinned: true, - backgroundColor: Colors.white, + backgroundColor: const Color(0xFFF5F5F5), flexibleSpace: FlexibleSpaceBar( - background: _buildCoverSection(), + background: _buildCoverSection(book), ), actions: [ - IconButton( - icon: const Icon(Icons.edit_outlined), - onPressed: () => _navigateToEdit(context), + // 下载封面按钮(仅当有封面时显示) + if (hasCover) + Container( + margin: const EdgeInsets.all(8), + decoration: const BoxDecoration( + color: Colors.white, + ), + child: IconButton( + icon: const Icon(Icons.download_outlined, color: Color(0xFF666666)), + onPressed: () => _downloadCover(book), + tooltip: '下载封面', + ), + ), + // 清空封面按钮(仅当有封面时显示) + if (hasCover) + Container( + margin: const EdgeInsets.all(8), + decoration: const BoxDecoration( + color: Colors.white, + ), + child: IconButton( + icon: const Icon(Icons.hide_image_outlined, color: Color(0xFF666666)), + onPressed: () => _showClearCoverDialog(book), + tooltip: '清空封面', + ), + ), + // 编辑按钮 + Container( + margin: const EdgeInsets.all(8), + decoration: const BoxDecoration( + color: Colors.white, + ), + child: IconButton( + icon: const Icon(Icons.edit_outlined, color: Color(0xFF1A1A1A)), + onPressed: () => _navigateToEdit(context), + ), ), const SizedBox(width: 8), ], ); } - + /// 构建封面区域 - Widget _buildCoverSection() { - return Container( - width: double.infinity, - color: const Color(0xFFF5F5F5), - child: widget.book.coverPath != null && widget.book.coverPath!.isNotEmpty + Widget _buildCoverSection(Book book) { + return SizedBox.expand( + child: book.coverPath != null && book.coverPath!.isNotEmpty ? Image.file( - File(widget.book.coverPath!), - fit: BoxFit.contain, + File(book.coverPath!), + fit: BoxFit.cover, errorBuilder: (_, __, ___) => _buildCoverPlaceholder(), ) : _buildCoverPlaceholder(), @@ -125,7 +181,7 @@ class _BookDetailPageState extends State { } /// 构建基本信息 - Widget _buildBasicInfo() { + Widget _buildBasicInfo(Book book) { return Padding( padding: const EdgeInsets.all(24), child: Column( @@ -133,7 +189,7 @@ class _BookDetailPageState extends State { children: [ // 书名 Text( - widget.book.title, + book.title, style: const TextStyle( fontSize: 24, fontWeight: FontWeight.w600, @@ -141,13 +197,26 @@ class _BookDetailPageState extends State { height: 1.3, ), ), - + + // 别名(显示在主名称下面,用 / 分隔) + if (book.alternateTitles.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + book.alternateTitles.join(' / '), + style: const TextStyle( + fontSize: 14, + color: Color(0xFF999999), + height: 1.4, + ), + ), + ], + const SizedBox(height: 16), - + // 评分和状态 Row( children: [ - if (widget.book.rating != null) ...[ + if (book.rating != null) ...[ const Icon( Icons.star, size: 20, @@ -155,7 +224,7 @@ class _BookDetailPageState extends State { ), const SizedBox(width: 4), Text( - widget.book.rating!.toStringAsFixed(1), + book.rating!.toStringAsFixed(1), style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w600, @@ -164,15 +233,15 @@ class _BookDetailPageState extends State { ), const SizedBox(width: 16), ], - _buildStatusTag(), + _buildStatusTag(book), ], ), - + const SizedBox(height: 8), - + // 时间信息 Text( - '添加于 ${_formatDate(widget.book.createdAt)}', + '添加于 ${_formatDate(book.createdAt)}', style: const TextStyle( fontSize: 12, color: Color(0xFF999999), @@ -182,12 +251,12 @@ class _BookDetailPageState extends State { ), ); } - + /// 构建状态标签 - Widget _buildStatusTag() { + Widget _buildStatusTag(Book book) { String label; Color color; - switch (widget.book.status) { + switch (book.status) { case 'read': label = '已读'; color = const Color(0xFF1A1A1A); @@ -222,7 +291,7 @@ class _BookDetailPageState extends State { } /// 构建作者区域 - Widget _buildAuthorsSection() { + Widget _buildAuthorsSection(Book book) { return Padding( padding: const EdgeInsets.all(24), child: Column( @@ -241,7 +310,7 @@ class _BookDetailPageState extends State { Wrap( spacing: 8, runSpacing: 8, - children: widget.book.authors.map((author) { + children: book.authors.map((author) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( @@ -264,7 +333,7 @@ class _BookDetailPageState extends State { } /// 构建出版社区域 - Widget _buildPublisherSection() { + Widget _buildPublisherSection(Book book) { return Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), child: Column( @@ -281,7 +350,7 @@ class _BookDetailPageState extends State { ), const SizedBox(height: 8), Text( - widget.book.publisher!, + book.publisher!, style: const TextStyle( fontSize: 15, color: Color(0xFF1A1A1A), @@ -293,7 +362,7 @@ class _BookDetailPageState extends State { } /// 构建类型区域 - Widget _buildGenresSection() { + Widget _buildGenresSection(Book book) { return Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), child: Column( @@ -312,7 +381,7 @@ class _BookDetailPageState extends State { Wrap( spacing: 8, runSpacing: 8, - children: widget.book.genres.map((genre) { + children: book.genres.map((genre) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( @@ -334,7 +403,7 @@ class _BookDetailPageState extends State { } /// 构建简介区域 - Widget _buildSummarySection() { + Widget _buildSummarySection(Book book) { return Padding( padding: const EdgeInsets.all(24), child: Column( @@ -351,7 +420,7 @@ class _BookDetailPageState extends State { ), const SizedBox(height: 12), Text( - widget.book.summary!, + book.summary!, style: const TextStyle( fontSize: 15, color: Color(0xFF1A1A1A), @@ -362,16 +431,16 @@ class _BookDetailPageState extends State { ), ); } - - /// 构建别名区域 - Widget _buildAlternateTitlesSection() { + + /// 构建额外功能区域(书评、摘抄) + Widget _buildExtraSections(Book book) { return Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( - '别名', + '更多', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, @@ -379,25 +448,137 @@ class _BookDetailPageState extends State { letterSpacing: 1, ), ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: widget.book.alternateTitles.map((title) { - return Text( - title, - style: const TextStyle( - fontSize: 14, - color: Color(0xFF666666), - ), - ); - }).toList(), + const SizedBox(height: 16), + // 书评入口 + GestureDetector( + onTap: () => _navigateToReviews(book), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Row( + children: [ + const Icon( + Icons.rate_review_outlined, + size: 24, + color: Color(0xFF666666), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '书评', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: Color(0xFF1A1A1A), + ), + ), + const SizedBox(height: 4), + FutureBuilder( + future: context.read().getBookReviewCount(book.id), + builder: (context, snapshot) { + final count = snapshot.data ?? 0; + return Text( + count > 0 ? '$count 条书评' : '暂无书评', + style: const TextStyle( + fontSize: 13, + color: Color(0xFF999999), + ), + ); + }, + ), + ], + ), + ), + const Icon( + Icons.chevron_right, + color: Color(0xFF999999), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + // 摘抄入口 + GestureDetector( + onTap: () => _navigateToExcerpts(book), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Row( + children: [ + const Icon( + Icons.format_quote_outlined, + size: 24, + color: Color(0xFF666666), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '摘抄', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: Color(0xFF1A1A1A), + ), + ), + const SizedBox(height: 4), + FutureBuilder( + future: context.read().getBookExcerptCount(book.id), + builder: (context, snapshot) { + final count = snapshot.data ?? 0; + return Text( + count > 0 ? '$count 条摘抄' : '暂无摘抄', + style: const TextStyle( + fontSize: 13, + color: Color(0xFF999999), + ), + ); + }, + ), + ], + ), + ), + const Icon( + Icons.chevron_right, + color: Color(0xFF999999), + ), + ], + ), + ), ), ], ), ); } - + + void _navigateToReviews(Book book) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BookReviewsPage(book: book), + ), + ); + } + + void _navigateToExcerpts(Book book) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BookExcerptsPage(book: book), + ), + ); + } + /// 构建底部操作栏 Widget _buildBottomBar() { return Container( @@ -486,4 +667,80 @@ class _BookDetailPageState extends State { ), ); } + + /// 显示清空封面对话框 + void _showClearCoverDialog(Book book) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + title: const Text('清空封面'), + content: const Text('确定要清空封面吗?清空后将使用默认占位图。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消', style: TextStyle(color: Color(0xFF666666))), + ), + TextButton( + onPressed: () async { + Navigator.pop(context); + final updatedBook = book.copyWith( + coverPath: null, + updatedAt: DateTime.now(), + ); + await context.read().updateBook(updatedBook); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('封面已清空')), + ); + } + }, + child: const Text('清空', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } + + /// 下载封面到本地 + Future _downloadCover(Book book) async { + if (book.coverPath == null || book.coverPath!.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('没有可下载的封面')), + ); + return; + } + + try { + final sourceFile = File(book.coverPath!); + if (!await sourceFile.exists()) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('封面文件不存在')), + ); + return; + } + + // 生成文件名:书籍名称_时间戳_封面.jpg + final timestamp = DateTime.now().millisecondsSinceEpoch; + final fileName = '${book.title}_${timestamp}_封面.jpg'; + + // 获取临时目录路径 + final tempDir = await Directory.systemTemp.createTemp(); + final tempFile = File('${tempDir.path}/$fileName'); + await sourceFile.copy(tempFile.path); + + // 使用分享功能让用户选择保存位置 + await Share.shareXFiles( + [XFile(tempFile.path)], + subject: '${book.title} 封面', + text: '下载自 MookNote', + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('下载失败: $e')), + ); + } + } } diff --git a/lib/pages/book_excerpt_form_page.dart b/lib/pages/book_excerpt_form_page.dart new file mode 100644 index 0000000..2dc64a3 --- /dev/null +++ b/lib/pages/book_excerpt_form_page.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; +import 'package:uuid/uuid.dart'; + +/// 摘抄表单页面 - 新增/编辑摘抄 +class BookExcerptFormPage extends StatefulWidget { + final String bookId; + final BookExcerpt? excerpt; + + const BookExcerptFormPage({ + super.key, + required this.bookId, + this.excerpt, + }); + + @override + State createState() => _BookExcerptFormPageState(); +} + +class _BookExcerptFormPageState extends State { + final _formKey = GlobalKey(); + final _chapterController = TextEditingController(); + final _contentController = TextEditingController(); + final _commentController = TextEditingController(); + + bool _isLoading = false; + + bool get _isEditing => widget.excerpt != null; + + @override + void initState() { + super.initState(); + if (_isEditing) { + _chapterController.text = widget.excerpt!.chapter; + _contentController.text = widget.excerpt!.content; + _commentController.text = widget.excerpt!.comment; + } + } + + @override + void dispose() { + _chapterController.dispose(); + _contentController.dispose(); + _commentController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: Text(_isEditing ? '编辑摘抄' : '添加摘抄'), + actions: [ + TextButton( + onPressed: _isLoading ? null : _saveExcerpt, + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text( + '保存', + style: TextStyle( + color: Color(0xFF1A1A1A), + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + ], + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(24), + children: [ + // 章节 + TextFormField( + controller: _chapterController, + decoration: const InputDecoration( + labelText: '章节(可选)', + hintText: '例如:第一章、第3节等', + border: OutlineInputBorder( + borderRadius: BorderRadius.zero, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.zero, + borderSide: BorderSide(color: Color(0xFF1A1A1A)), + ), + ), + ), + + const SizedBox(height: 24), + + // 摘抄内容 + TextFormField( + controller: _contentController, + maxLines: 8, + decoration: const InputDecoration( + labelText: '摘抄内容', + hintText: '输入你想要摘抄的内容...', + alignLabelWithHint: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.zero, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.zero, + borderSide: BorderSide(color: Color(0xFF1A1A1A)), + ), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入摘抄内容'; + } + return null; + }, + ), + + const SizedBox(height: 24), + + // 评论/感悟 + TextFormField( + controller: _commentController, + maxLines: 5, + decoration: const InputDecoration( + labelText: '我的感悟(可选)', + hintText: '记录你对这段内容的思考和感悟...', + alignLabelWithHint: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.zero, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.zero, + borderSide: BorderSide(color: Color(0xFF1A1A1A)), + ), + ), + ), + ], + ), + ), + ); + } + + Future _saveExcerpt() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + + try { + final now = DateTime.now(); + final excerpt = BookExcerpt( + id: _isEditing ? widget.excerpt!.id : const Uuid().v4(), + bookId: widget.bookId, + chapter: _chapterController.text.trim(), + content: _contentController.text.trim(), + comment: _commentController.text.trim(), + isDeleted: false, + createdAt: _isEditing ? widget.excerpt!.createdAt : now, + updatedAt: now, + ); + + if (_isEditing) { + await context.read().updateBookExcerpt(excerpt); + } else { + await context.read().addBookExcerpt(excerpt); + } + + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(_isEditing ? '摘抄已更新' : '摘抄已添加')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('保存失败: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } +} + diff --git a/lib/pages/book_excerpts_page.dart b/lib/pages/book_excerpts_page.dart new file mode 100644 index 0000000..17db5d8 --- /dev/null +++ b/lib/pages/book_excerpts_page.dart @@ -0,0 +1,300 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; +import 'book_excerpt_form_page.dart'; + +/// 书籍摘抄列表页面 +class BookExcerptsPage extends StatefulWidget { + final Book book; + + const BookExcerptsPage({super.key, required this.book}); + + @override + State createState() => _BookExcerptsPageState(); +} + +class _BookExcerptsPageState extends State { + List _excerpts = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadExcerpts(); + } + + Future _loadExcerpts() async { + setState(() => _isLoading = true); + try { + final excerpts = await context.read().getBookExcerpts(widget.book.id); + setState(() { + _excerpts = excerpts; + _isLoading = false; + }); + } catch (e) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('加载失败: $e')), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text('摘抄'), + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () => _navigateToAddExcerpt(), + ), + const SizedBox(width: 8), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _excerpts.isEmpty + ? _buildEmptyState() + : _buildExcerptList(), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.format_quote_outlined, + size: 64, + color: Color(0xFFCCCCCC), + ), + const SizedBox(height: 16), + const Text( + '暂无摘抄', + style: TextStyle( + fontSize: 16, + color: Color(0xFF999999), + ), + ), + const SizedBox(height: 24), + OutlinedButton( + onPressed: () => _navigateToAddExcerpt(), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF1A1A1A), + side: const BorderSide(color: Color(0xFF1A1A1A)), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + ), + child: const Text('添加摘抄'), + ), + ], + ), + ); + } + + /// 按章节分组摘抄数据 + Map> _groupExcerptsByChapter() { + final Map> groups = {}; + + for (final excerpt in _excerpts) { + final chapter = excerpt.chapter.isEmpty ? '未分类' : excerpt.chapter; + if (!groups.containsKey(chapter)) { + groups[chapter] = []; + } + groups[chapter]!.add(excerpt); + } + + // 每个章节内的摘抄按时间排序(新的在前) + for (final chapter in groups.keys) { + groups[chapter]!.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + } + + return groups; + } + + Widget _buildExcerptList() { + final groups = _groupExcerptsByChapter(); + final chapters = groups.keys.toList(); + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: chapters.length, + itemBuilder: (context, index) { + final chapter = chapters[index]; + final excerpts = groups[chapter]!; + return _buildChapterSection(chapter, excerpts); + }, + ); + } + + Widget _buildChapterSection(String chapter, List excerpts) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 章节标题 + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: const BoxDecoration( + color: Color(0xFFF5F5F5), + border: Border( + left: BorderSide(color: Color(0xFF1A1A1A), width: 4), + ), + ), + child: Text( + chapter, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A), + ), + ), + ), + const SizedBox(height: 12), + // 该章节下的摘抄列表 + ...excerpts.map((excerpt) => _buildExcerptItem(excerpt)), + const SizedBox(height: 24), + ], + ); + } + + Widget _buildExcerptItem(BookExcerpt excerpt) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 摘抄内容 + Text( + excerpt.content, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + color: Color(0xFF1A1A1A), + height: 1.5, + ), + ), + + // 评论/感悟 + if (excerpt.comment.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: const BoxDecoration( + color: Color(0xFFF5F5F5), + ), + child: Text( + excerpt.comment, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF666666), + ), + ), + ), + ], + + const SizedBox(height: 8), + + // 底部:时间 + 操作按钮 + Row( + children: [ + Text( + _formatDate(excerpt.createdAt), + style: const TextStyle( + fontSize: 11, + color: Color(0xFF999999), + ), + ), + const Spacer(), + // 编辑按钮 + GestureDetector( + onTap: () => _navigateToEditExcerpt(excerpt), + child: const Icon( + Icons.edit_outlined, + size: 16, + color: Color(0xFF999999), + ), + ), + const SizedBox(width: 12), + // 删除按钮 + GestureDetector( + onTap: () => _showDeleteDialog(excerpt), + child: const Icon( + Icons.delete_outline, + size: 16, + color: Colors.red, + ), + ), + ], + ), + ], + ), + ); + } + + String _formatDate(DateTime date) { + return '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}'; + } + + void _navigateToAddExcerpt() { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BookExcerptFormPage(bookId: widget.book.id), + ), + ).then((_) => _loadExcerpts()); + } + + void _navigateToEditExcerpt(BookExcerpt excerpt) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BookExcerptFormPage( + bookId: widget.book.id, + excerpt: excerpt, + ), + ), + ).then((_) => _loadExcerpts()); + } + + void _showDeleteDialog(BookExcerpt excerpt) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + title: const Text('确认删除'), + content: const Text('确定要删除这条摘抄吗?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消', style: TextStyle(color: Color(0xFF666666))), + ), + TextButton( + onPressed: () async { + await context.read().removeBookExcerpt(excerpt.id); + Navigator.pop(context); + _loadExcerpts(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已删除')), + ); + }, + child: const Text('删除', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } +} diff --git a/lib/pages/book_form_page.dart b/lib/pages/book_form_page.dart index fc2a920..6700e0f 100644 --- a/lib/pages/book_form_page.dart +++ b/lib/pages/book_form_page.dart @@ -10,8 +10,9 @@ import '../models/data_models.dart'; /// 添加/编辑书籍页面 - 紧凑双行布局设计 class BookFormPage extends StatefulWidget { final Book? book; - - const BookFormPage({super.key, this.book}); + final String? initialStatus; // 添加时的默认状态 + + const BookFormPage({super.key, this.book, this.initialStatus}); @override State createState() => _BookFormPageState(); @@ -40,18 +41,36 @@ class _BookFormPageState extends State { @override void initState() { super.initState(); - final book = widget.book; + _initializeData(); + } + + void _initializeData() { + // 如果有传入book,尝试从Provider获取最新数据 + Book? book = widget.book; + if (book != null) { + final appProvider = context.read(); + final latestBook = appProvider.books + .where((b) => b.id == book!.id) + .firstOrNull; + if (latestBook != null) { + book = latestBook; + } + } + _titleController = TextEditingController(text: book?.title ?? ''); _publisherController = TextEditingController(text: book?.publisher ?? ''); _summaryController = TextEditingController(text: book?.summary ?? ''); _ratingController = TextEditingController(text: book?.rating?.toString() ?? ''); - + if (book != null) { _authors = List.from(book.authors); _alternateTitles = List.from(book.alternateTitles); _genres = List.from(book.genres); _coverPath = book.coverPath; _status = book.status; + } else if (widget.initialStatus != null) { + // 添加模式:使用传入的默认状态 + _status = widget.initialStatus!; } } @@ -98,9 +117,19 @@ class _BookFormPageState extends State { children: [ // 封面选择 - 居中显示 Center(child: _buildCoverPicker()), - + const SizedBox(height: 32), - + + // 状态选择(靠左显示) + _buildStatusSelector(), + + const SizedBox(height: 20), + + // 评分 - 星星选择(靠左显示) + _buildStarRating(), + + const SizedBox(height: 32), + // 基本信息区域 _buildFormItem( label: '书名 *', @@ -121,9 +150,9 @@ class _BookFormPageState extends State { }, ), ), - + _buildDivider(), - + // 别名 _buildMultiValueItem( label: '别名', @@ -133,9 +162,9 @@ class _BookFormPageState extends State { onAdd: (v) => setState(() => _alternateTitles.add(v)), onRemove: (i) => setState(() => _alternateTitles.removeAt(i)), ), - + _buildDivider(), - + // 作者 _buildMultiValueItem( label: '作者', @@ -145,9 +174,9 @@ class _BookFormPageState extends State { onAdd: (v) => setState(() => _authors.add(v)), onRemove: (i) => setState(() => _authors.removeAt(i)), ), - + _buildDivider(), - + // 出版社 _buildFormItem( label: '出版社', @@ -162,9 +191,9 @@ class _BookFormPageState extends State { ), ), ), - + _buildDivider(), - + // 类型 _buildMultiValueItem( label: '类型', @@ -174,9 +203,9 @@ class _BookFormPageState extends State { onAdd: (v) => setState(() => _genres.add(v)), onRemove: (i) => setState(() => _genres.removeAt(i)), ), - + _buildDivider(), - + // 书籍简介 _buildFormItem( label: '书籍简介', @@ -192,63 +221,7 @@ class _BookFormPageState extends State { ), ), ), - - _buildDivider(), - - // 评分 - _buildFormItem( - label: '评分', - child: Row( - children: [ - Expanded( - child: TextFormField( - controller: _ratingController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), - decoration: const InputDecoration( - hintText: '1-10', - hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), - border: InputBorder.none, - contentPadding: EdgeInsets.zero, - ), - validator: (value) { - if (value != null && value.isNotEmpty) { - final rating = double.tryParse(value); - if (rating == null || rating < 1 || rating > 10) { - return '评分必须在 1-10 之间'; - } - } - return null; - }, - ), - ), - if (_ratingController.text.isNotEmpty) - const Text( - '分', - style: TextStyle(fontSize: 14, color: Color(0xFF999999)), - ), - ], - ), - ), - - _buildDivider(), - - // 状态 - _buildFormItem( - label: '状态', - child: Padding( - padding: const EdgeInsets.only(top: 8), - child: Wrap( - spacing: 12, - children: [ - _buildStatusChip('想读', 'want_to_read'), - _buildStatusChip('在读', 'reading'), - _buildStatusChip('已读', 'read'), - ], - ), - ), - ), - + const SizedBox(height: 48), ], ), @@ -410,62 +383,236 @@ class _BookFormPageState extends State { ); } - /// 构建状态选择 Chip - Widget _buildStatusChip(String label, String value) { + /// 构建状态选择器(靠左显示,带标签) + Widget _buildStatusSelector() { + return Row( + children: [ + const Text( + '状态', + style: TextStyle(fontSize: 14, color: Color(0xFF666666)), + ), + const SizedBox(width: 16), + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildStatusOption('想读', 'want_to_read'), + _buildStatusOption('在读', 'reading'), + _buildStatusOption('已读', 'read'), + ], + ), + ), + ], + ); + } + + /// 构建星星评分(5星制,每星2分,支持手动输入) + Widget _buildStarRating() { + return Row( + children: [ + const Text( + '评分', + style: TextStyle(fontSize: 14, color: Color(0xFF666666)), + ), + const SizedBox(width: 16), + // 星星选择 + _buildStarSelector(), + const SizedBox(width: 12), + // 手动输入框 + _buildRatingInputField(), + ], + ); + } + + /// 构建星星选择器 + Widget _buildStarSelector() { + final currentRating = double.tryParse(_ratingController.text) ?? 0; + final starRating = currentRating / 2; + + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(5, (index) { + final starValue = index + 1; + final scoreValue = starValue * 2; + final isFilled = starValue <= starRating; + final isHalf = starValue == starRating.ceil() && starRating % 1 != 0; + + return InkWell( + onTap: () { + setState(() { + _ratingController.text = scoreValue.toString(); + }); + }, + borderRadius: BorderRadius.circular(4), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4), + child: Icon( + isHalf + ? Icons.star_half + : isFilled + ? Icons.star + : Icons.star_border, + size: 24, + color: isFilled || isHalf + ? const Color(0xFFFFB800) + : const Color(0xFFE5E5E5), + ), + ), + ); + }), + ), + ); + } + + /// 构建评分输入框 + Widget _buildRatingInputField() { + return Container( + width: 56, + height: 36, + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(8), + ), + child: TextFormField( + controller: _ratingController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: Color(0xFF1A1A1A), + ), + decoration: const InputDecoration( + hintText: '-', + hintStyle: TextStyle(fontSize: 15, color: Color(0xFFCCCCCC)), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), + ), + validator: (value) { + if (value != null && value.isNotEmpty) { + final rating = double.tryParse(value); + if (rating == null || rating < 0 || rating > 10) { + return '0-10'; + } + } + return null; + }, + onChanged: (value) { + // 限制输入范围 + if (value.isNotEmpty) { + final rating = double.tryParse(value); + if (rating != null) { + if (rating > 10) { + _ratingController.text = '10'; + } else if (rating < 0) { + _ratingController.text = '0'; + } + } + } + setState(() {}); // 更新星星显示 + }, + ), + ); + } + + /// 构建状态选项 + Widget _buildStatusOption(String label, String value) { final isSelected = _status == value; - Color color; - switch (value) { - case 'read': - color = const Color(0xFF1A1A1A); - break; - case 'reading': - color = const Color(0xFF666666); - break; - case 'want_to_read': - color = const Color(0xFF999999); - break; - default: - color = const Color(0xFFCCCCCC); - } - + return GestureDetector( onTap: () => setState(() => _status = value), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), decoration: BoxDecoration( - color: isSelected ? color : Colors.transparent, - border: Border.all(color: color), + color: isSelected ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(6), + boxShadow: isSelected + ? [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ] + : null, ), child: Text( label, style: TextStyle( - fontSize: 13, - color: isSelected ? Colors.white : color, + fontSize: 14, + fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal, + color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFF999999), ), ), ), ); } - + /// 构建封面选择器 Widget _buildCoverPicker() { - return GestureDetector( - onTap: _pickCover, - child: Container( - width: 140, - height: 200, - decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5), + final hasCover = _coverPath != null && _coverPath!.isNotEmpty; + + return Column( + children: [ + GestureDetector( + onTap: _pickCover, + child: Container( + width: 140, + height: 200, + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5), + ), + child: hasCover + ? Image.file( + File(_coverPath!), + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _buildCoverPlaceholder(), + ) + : _buildCoverPlaceholder(), + ), ), - child: _coverPath != null && _coverPath!.isNotEmpty - ? Image.file( - File(_coverPath!), - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _buildCoverPlaceholder(), - ) - : _buildCoverPlaceholder(), - ), + // 清空封面按钮(仅当有封面时显示) + if (hasCover) + Padding( + padding: const EdgeInsets.only(top: 12), + child: GestureDetector( + onTap: () => setState(() => _coverPath = null), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.hide_image_outlined, + size: 16, + color: Color(0xFF666666), + ), + SizedBox(width: 4), + Text( + '清空封面', + style: TextStyle( + fontSize: 13, + color: Color(0xFF666666), + ), + ), + ], + ), + ), + ), + ), + ], ); } diff --git a/lib/pages/book_review_form_page.dart b/lib/pages/book_review_form_page.dart new file mode 100644 index 0000000..c678170 --- /dev/null +++ b/lib/pages/book_review_form_page.dart @@ -0,0 +1,266 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; +import 'package:uuid/uuid.dart'; + +/// 添加/编辑书评页面 - 极简设计 +class BookReviewFormPage extends StatefulWidget { + final String bookId; + final BookReview? review; + + const BookReviewFormPage({ + super.key, + required this.bookId, + this.review, + }); + + @override + State createState() => _BookReviewFormPageState(); +} + +class _BookReviewFormPageState extends State { + final _formKey = GlobalKey(); + late TextEditingController _contentController; + late TextEditingController _reviewerController; + late TextEditingController _sourceController; + late int _reviewType; + + @override + void initState() { + super.initState(); + final review = widget.review; + _contentController = TextEditingController(text: review?.content ?? ''); + _reviewerController = TextEditingController(text: review?.reviewer ?? ''); + _sourceController = TextEditingController(text: review?.source ?? ''); + _reviewType = review?.reviewType ?? 1; + } + + @override + void dispose() { + _contentController.dispose(); + _reviewerController.dispose(); + _sourceController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isEdit = widget.review != null; + + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: Text(isEdit ? '编辑书评' : '写书评'), + actions: [ + TextButton( + onPressed: _saveReview, + child: const Text( + '保存', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 8), + ], + ), + body: Form( + key: _formKey, + child: Column( + children: [ + // 顶部信息栏 + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: Color(0xFFE5E5E5), width: 0.5), + ), + ), + child: Row( + children: [ + // 类型选择 + _buildTypeSelector(), + const SizedBox(width: 16), + // 评论人 + Expanded( + child: TextField( + controller: _reviewerController, + style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)), + decoration: const InputDecoration( + hintText: '评论人', + hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + const SizedBox(width: 16), + // 来源 + SizedBox( + width: 100, + child: TextField( + controller: _sourceController, + style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)), + decoration: const InputDecoration( + hintText: '来源', + hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + ], + ), + ), + + // 评论内容区域 + Expanded( + child: TextFormField( + controller: _contentController, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + style: const TextStyle( + fontSize: 16, + color: Color(0xFF1A1A1A), + height: 1.7, + ), + decoration: const InputDecoration( + hintText: '写下你的书评...', + hintStyle: TextStyle( + fontSize: 16, + color: Color(0xFFCCCCCC), + ), + border: InputBorder.none, + contentPadding: EdgeInsets.all(16), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入评论内容'; + } + return null; + }, + ), + ), + ], + ), + ), + ); + } + + /// 构建类型选择器 + Widget _buildTypeSelector() { + return GestureDetector( + onTap: () => _showTypeSelector(), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _reviewType == 1 ? '短评' : '长评', + style: const TextStyle( + fontSize: 13, + color: Color(0xFF666666), + ), + ), + const SizedBox(width: 4), + const Icon( + Icons.arrow_drop_down, + size: 16, + color: Color(0xFF999999), + ), + ], + ), + ), + ); + } + + /// 显示类型选择 + void _showTypeSelector() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: const Text('短评'), + trailing: _reviewType == 1 + ? const Icon(Icons.check, color: Color(0xFF1A1A1A)) + : null, + onTap: () { + setState(() => _reviewType = 1); + Navigator.pop(context); + }, + ), + const Divider(height: 0.5), + ListTile( + title: const Text('长评'), + trailing: _reviewType == 2 + ? const Icon(Icons.check, color: Color(0xFF1A1A1A)) + : null, + onTap: () { + setState(() => _reviewType = 2); + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + } + + Future _saveReview() async { + if (!_formKey.currentState!.validate()) { + return; + } + + final now = DateTime.now(); + + if (widget.review == null) { + final newReview = BookReview( + id: const Uuid().v4(), + bookId: widget.bookId, + content: _contentController.text.trim(), + reviewer: _reviewerController.text.trim(), + source: _sourceController.text.trim(), + reviewType: _reviewType, + isDeleted: false, + createdAt: now, + updatedAt: now, + ); + await context.read().addBookReview(newReview); + } else { + final updatedReview = widget.review!.copyWith( + content: _contentController.text.trim(), + reviewer: _reviewerController.text.trim(), + source: _sourceController.text.trim(), + reviewType: _reviewType, + updatedAt: now, + ); + await context.read().updateBookReview(updatedReview); + } + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(widget.review == null ? '添加成功' : '更新成功'), + behavior: SnackBarBehavior.floating, + ), + ); + + Navigator.pop(context); + } +} + diff --git a/lib/pages/book_reviews_page.dart b/lib/pages/book_reviews_page.dart new file mode 100644 index 0000000..9f905ff --- /dev/null +++ b/lib/pages/book_reviews_page.dart @@ -0,0 +1,270 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; +import 'book_review_form_page.dart'; + +/// 书籍书评列表页面 +class BookReviewsPage extends StatefulWidget { + final Book book; + + const BookReviewsPage({super.key, required this.book}); + + @override + State createState() => _BookReviewsPageState(); +} + +class _BookReviewsPageState extends State { + List _reviews = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadReviews(); + } + + Future _loadReviews() async { + setState(() => _isLoading = true); + try { + final reviews = await context.read().getBookReviews(widget.book.id); + setState(() { + _reviews = reviews; + _isLoading = false; + }); + } catch (e) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('加载失败: $e')), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text('书评'), + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () => _navigateToAddReview(), + ), + const SizedBox(width: 8), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _reviews.isEmpty + ? _buildEmptyState() + : _buildReviewList(), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.rate_review_outlined, + size: 64, + color: Color(0xFFCCCCCC), + ), + const SizedBox(height: 16), + const Text( + '暂无书评', + style: TextStyle( + fontSize: 16, + color: Color(0xFF999999), + ), + ), + const SizedBox(height: 24), + OutlinedButton( + onPressed: () => _navigateToAddReview(), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF1A1A1A), + side: const BorderSide(color: Color(0xFF1A1A1A)), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + ), + child: const Text('写书评'), + ), + ], + ), + ); + } + + Widget _buildReviewList() { + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _reviews.length, + itemBuilder: (context, index) { + final review = _reviews[index]; + return _buildReviewItem(review); + }, + ); + } + + Widget _buildReviewItem(BookReview review) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 头部:类型标签 + 操作按钮 + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: review.reviewType == 1 + ? const Color(0xFFF5F5F5) + : const Color(0xFF1A1A1A), + ), + child: Text( + review.typeText, + style: TextStyle( + fontSize: 11, + color: review.reviewType == 1 + ? const Color(0xFF666666) + : Colors.white, + ), + ), + ), + const Spacer(), + // 编辑按钮 + GestureDetector( + onTap: () => _navigateToEditReview(review), + child: const Icon( + Icons.edit_outlined, + size: 18, + color: Color(0xFF999999), + ), + ), + const SizedBox(width: 16), + // 删除按钮 + GestureDetector( + onTap: () => _showDeleteDialog(review), + child: const Icon( + Icons.delete_outline, + size: 18, + color: Colors.red, + ), + ), + ], + ), + + const SizedBox(height: 12), + + // 评论内容 + Text( + review.content, + maxLines: review.reviewType == 1 ? 3 : 5, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + color: Color(0xFF1A1A1A), + height: 1.6, + ), + ), + + const SizedBox(height: 12), + + // 底部信息 + Row( + children: [ + if (review.reviewer.isNotEmpty) ...[ + Text( + review.reviewer, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF666666), + ), + ), + const SizedBox(width: 8), + ], + if (review.source.isNotEmpty) ...[ + Text( + '来源:${review.source}', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF999999), + ), + ), + const SizedBox(width: 8), + ], + const Spacer(), + Text( + _formatDate(review.createdAt), + style: const TextStyle( + fontSize: 12, + color: Color(0xFF999999), + ), + ), + ], + ), + ], + ), + ); + } + + String _formatDate(DateTime date) { + return '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}'; + } + + void _navigateToAddReview() { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BookReviewFormPage(bookId: widget.book.id), + ), + ).then((_) => _loadReviews()); + } + + void _navigateToEditReview(BookReview review) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BookReviewFormPage( + bookId: widget.book.id, + review: review, + ), + ), + ).then((_) => _loadReviews()); + } + + void _showDeleteDialog(BookReview review) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + title: const Text('确认删除'), + content: const Text('确定要删除这条书评吗?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消', style: TextStyle(color: Color(0xFF666666))), + ), + TextButton( + onPressed: () async { + await context.read().removeBookReview(review.id); + Navigator.pop(context); + _loadReviews(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已删除')), + ); + }, + child: const Text('删除', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } +} diff --git a/lib/pages/book_tab_page.dart b/lib/pages/book_tab_page.dart index 067d2e0..4b53f82 100644 --- a/lib/pages/book_tab_page.dart +++ b/lib/pages/book_tab_page.dart @@ -43,8 +43,16 @@ class BookTabPage extends StatelessWidget { return RefreshIndicator( onRefresh: () async => await provider.loadBooks(), - child: ListView.builder( + color: const Color(0xFF1A1A1A), + backgroundColor: Colors.white, + child: GridView.builder( padding: const EdgeInsets.all(16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 0.55, + crossAxisSpacing: 12, + mainAxisSpacing: 16, + ), itemCount: books.length, itemBuilder: (context, index) { return BookListItem(book: books[index]); @@ -80,7 +88,17 @@ class BookTabPage extends StatelessWidget { icon: const Icon(Icons.add), label: const Text('添加记录'), onPressed: () { - Navigator.pushNamed(context, '/book-form'); + final statusMap = { + 0: 'read', + 1: 'reading', + 2: 'want_to_read', + }; + final currentStatus = statusMap[statusIndex]!; + Navigator.pushNamed( + context, + '/book-form', + arguments: {'initialStatus': currentStatus}, + ); }, ), ], diff --git a/lib/pages/main_content_page.dart b/lib/pages/main_content_page.dart index 84e7a42..0cc40a2 100644 --- a/lib/pages/main_content_page.dart +++ b/lib/pages/main_content_page.dart @@ -4,6 +4,7 @@ import '../providers/app_provider.dart'; import 'movie_tab_page.dart'; import 'book_tab_page.dart'; import 'note_tab_page.dart'; +import 'search_page.dart'; /// 主内容页 - 观影/阅读/笔记标签页 class MainContentPage extends StatelessWidget { @@ -37,7 +38,12 @@ class MainContentPage extends StatelessWidget { IconButton( icon: const Icon(Icons.search), onPressed: () { - // TODO: 搜索功能 + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SearchPage(), + ), + ); }, ), ], @@ -176,7 +182,18 @@ class MainContentPage extends StatelessWidget { title: const Text('添加观影'), onTap: () { Navigator.pop(context); - Navigator.pushNamed(context, '/movie-form'); + // 根据当前影视标签页的选中状态设置默认值 + final statusMap = { + 0: 'watched', + 1: 'watching', + 2: 'want_to_watch', + }; + final currentStatus = statusMap[provider.movieStatusIndex] ?? 'want_to_watch'; + Navigator.pushNamed( + context, + '/movie-form', + arguments: {'initialStatus': currentStatus}, + ); }, ), const Divider(height: 0.5, indent: 56), @@ -185,7 +202,18 @@ class MainContentPage extends StatelessWidget { title: const Text('添加阅读'), onTap: () { Navigator.pop(context); - Navigator.pushNamed(context, '/book-form'); + // 根据当前阅读标签页的选中状态设置默认值 + final statusMap = { + 0: 'read', + 1: 'reading', + 2: 'want_to_read', + }; + final currentStatus = statusMap[provider.bookStatusIndex] ?? 'want_to_read'; + Navigator.pushNamed( + context, + '/book-form', + arguments: {'initialStatus': currentStatus}, + ); }, ), const Divider(height: 0.5, indent: 56), diff --git a/lib/pages/movie_detail_page.dart b/lib/pages/movie_detail_page.dart index 5e9d936..ca91edc 100644 --- a/lib/pages/movie_detail_page.dart +++ b/lib/pages/movie_detail_page.dart @@ -1,6 +1,11 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; +import 'package:share_plus/share_plus.dart'; +import 'package:cross_file/cross_file.dart'; +import 'package:permission_handler/permission_handler.dart'; import '../providers/app_provider.dart'; import '../models/data_models.dart'; import 'movie_reviews_page.dart'; @@ -17,14 +22,32 @@ class MovieDetailPage extends StatefulWidget { } class _MovieDetailPageState extends State { + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // 页面获得焦点时刷新数据 + _refreshMovieData(); + } + + void _refreshMovieData() { + final provider = context.read(); + // 强制刷新当前影视数据 + provider.loadMovies(); + } + @override Widget build(BuildContext context) { + // 从 Provider 获取最新的 movie 数据,实现动态刷新 + final movie = context.watch().movies + .where((m) => m.id == widget.movie.id) + .firstOrNull ?? widget.movie; + return Scaffold( backgroundColor: Colors.white, body: CustomScrollView( slivers: [ // 顶部海报区域 - _buildSliverAppBar(), + _buildSliverAppBar(movie), // 内容区域 SliverToBoxAdapter( @@ -32,40 +55,36 @@ class _MovieDetailPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ // 基本信息 - _buildBasicInfo(), + _buildBasicInfo(movie), const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), // 导演 - if (widget.movie.directors.isNotEmpty) - _buildDirectorsSection(), + if (movie.directors.isNotEmpty) + _buildDirectorsSection(movie), // 编剧 - if (widget.movie.writers.isNotEmpty) - _buildWritersSection(), + if (movie.writers.isNotEmpty) + _buildWritersSection(movie), // 主演 - if (widget.movie.actors.isNotEmpty) - _buildActorsSection(), + if (movie.actors.isNotEmpty) + _buildActorsSection(movie), // 类型 - if (widget.movie.genres.isNotEmpty) - _buildGenresSection(), + if (movie.genres.isNotEmpty) + _buildGenresSection(movie), const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), // 简介 - if (widget.movie.summary != null && widget.movie.summary!.isNotEmpty) - _buildSummarySection(), - - // 别名 - if (widget.movie.alternateTitles.isNotEmpty) - _buildAlternateTitlesSection(), + if (movie.summary != null && movie.summary!.isNotEmpty) + _buildSummarySection(movie), const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), // 影评和海报墙入口 - _buildExtraSections(), + _buildExtraSections(movie), const SizedBox(height: 48), ], @@ -80,18 +99,53 @@ class _MovieDetailPageState extends State { } /// 构建顶部 AppBar - Widget _buildSliverAppBar() { + Widget _buildSliverAppBar(Movie movie) { + final hasPoster = movie.posterPath != null && movie.posterPath!.isNotEmpty; + return SliverAppBar( - expandedHeight: 280, + expandedHeight: 320, pinned: true, - backgroundColor: Colors.white, + backgroundColor: const Color(0xFFF5F5F5), flexibleSpace: FlexibleSpaceBar( - background: _buildPosterSection(), + background: _buildPosterSection(movie), ), actions: [ - IconButton( - icon: const Icon(Icons.edit_outlined), - onPressed: () => _navigateToEdit(context), + // 下载海报按钮(仅当有海报时显示) + if (hasPoster) + Container( + margin: const EdgeInsets.all(8), + decoration: const BoxDecoration( + color: Colors.white, + ), + child: IconButton( + icon: const Icon(Icons.download_outlined, color: Color(0xFF666666)), + onPressed: () => _downloadPoster(movie), + tooltip: '下载海报', + ), + ), + // 清空海报按钮(仅当有海报时显示) + if (hasPoster) + Container( + margin: const EdgeInsets.all(8), + decoration: const BoxDecoration( + color: Colors.white, + ), + child: IconButton( + icon: const Icon(Icons.hide_image_outlined, color: Color(0xFF666666)), + onPressed: () => _showClearPosterDialog(movie), + tooltip: '清空海报', + ), + ), + // 编辑按钮 + Container( + margin: const EdgeInsets.all(8), + decoration: const BoxDecoration( + color: Colors.white, + ), + child: IconButton( + icon: const Icon(Icons.edit_outlined, color: Color(0xFF1A1A1A)), + onPressed: () => _navigateToEdit(context), + ), ), const SizedBox(width: 8), ], @@ -99,14 +153,12 @@ class _MovieDetailPageState extends State { } /// 构建海报区域 - Widget _buildPosterSection() { - return Container( - width: double.infinity, - color: const Color(0xFFF5F5F5), - child: widget.movie.posterPath != null && widget.movie.posterPath!.isNotEmpty + Widget _buildPosterSection(Movie movie) { + return SizedBox.expand( + child: movie.posterPath != null && movie.posterPath!.isNotEmpty ? Image.file( - File(widget.movie.posterPath!), - fit: BoxFit.contain, + File(movie.posterPath!), + fit: BoxFit.cover, errorBuilder: (_, __, ___) => _buildPosterPlaceholder(), ) : _buildPosterPlaceholder(), @@ -136,8 +188,44 @@ class _MovieDetailPageState extends State { ); } + /// 显示清空海报对话框 + void _showClearPosterDialog(Movie movie) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + title: const Text('清空海报'), + content: const Text('确定要清空海报吗?清空后将使用默认占位图。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消', style: TextStyle(color: Color(0xFF666666))), + ), + TextButton( + onPressed: () async { + Navigator.pop(context); + final updatedMovie = movie.copyWith( + posterPath: null, + updatedAt: DateTime.now(), + ); + await context.read().updateMovie(updatedMovie); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('海报已清空')), + ); + } + }, + child: const Text('清空', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } + /// 构建基本信息 - Widget _buildBasicInfo() { + Widget _buildBasicInfo(Movie movie) { return Padding( padding: const EdgeInsets.all(24), child: Column( @@ -145,7 +233,7 @@ class _MovieDetailPageState extends State { children: [ // 影视名称 Text( - widget.movie.title, + movie.title, style: const TextStyle( fontSize: 24, fontWeight: FontWeight.w600, @@ -154,12 +242,25 @@ class _MovieDetailPageState extends State { ), ), + // 别名(显示在主名称下面,用 / 分隔) + if (movie.alternateTitles.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + movie.alternateTitles.join(' / '), + style: const TextStyle( + fontSize: 14, + color: Color(0xFF999999), + height: 1.4, + ), + ), + ], + const SizedBox(height: 16), // 评分和状态 Row( children: [ - if (widget.movie.rating != null) ...[ + if (movie.rating != null) ...[ const Icon( Icons.star, size: 20, @@ -167,7 +268,7 @@ class _MovieDetailPageState extends State { ), const SizedBox(width: 4), Text( - widget.movie.rating!.toStringAsFixed(1), + movie.rating!.toStringAsFixed(1), style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w600, @@ -176,16 +277,16 @@ class _MovieDetailPageState extends State { ), const SizedBox(width: 16), ], - _buildStatusTag(), + _buildStatusTag(movie), ], ), const SizedBox(height: 8), // 上映日期 - if (widget.movie.releaseDate != null) + if (movie.releaseDate != null) Text( - '${widget.movie.releaseDate!.year}年上映', + '${movie.releaseDate!.year}年上映', style: const TextStyle( fontSize: 14, color: Color(0xFF999999), @@ -196,7 +297,7 @@ class _MovieDetailPageState extends State { // 时间信息 Text( - '添加于 ${_formatDate(widget.movie.createdAt)}', + '添加于 ${_formatDate(movie.createdAt)}', style: const TextStyle( fontSize: 12, color: Color(0xFF999999), @@ -208,10 +309,10 @@ class _MovieDetailPageState extends State { } /// 构建状态标签 - Widget _buildStatusTag() { + Widget _buildStatusTag(Movie movie) { String label; Color color; - switch (widget.movie.status) { + switch (movie.status) { case 'watched': label = '已看'; color = const Color(0xFF1A1A1A); @@ -246,7 +347,7 @@ class _MovieDetailPageState extends State { } /// 构建导演区域 - Widget _buildDirectorsSection() { + Widget _buildDirectorsSection(Movie movie) { return Padding( padding: const EdgeInsets.all(24), child: Column( @@ -265,7 +366,7 @@ class _MovieDetailPageState extends State { Wrap( spacing: 8, runSpacing: 8, - children: widget.movie.directors.map((director) { + children: movie.directors.map((director) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( @@ -288,7 +389,7 @@ class _MovieDetailPageState extends State { } /// 构建编剧区域 - Widget _buildWritersSection() { + Widget _buildWritersSection(Movie movie) { return Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), child: Column( @@ -307,7 +408,7 @@ class _MovieDetailPageState extends State { Wrap( spacing: 8, runSpacing: 8, - children: widget.movie.writers.map((writer) { + children: movie.writers.map((writer) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( @@ -330,7 +431,7 @@ class _MovieDetailPageState extends State { } /// 构建主演区域 - Widget _buildActorsSection() { + Widget _buildActorsSection(Movie movie) { return Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), child: Column( @@ -349,7 +450,7 @@ class _MovieDetailPageState extends State { Wrap( spacing: 8, runSpacing: 8, - children: widget.movie.actors.map((actor) { + children: movie.actors.map((actor) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( @@ -372,7 +473,7 @@ class _MovieDetailPageState extends State { } /// 构建类型区域 - Widget _buildGenresSection() { + Widget _buildGenresSection(Movie movie) { return Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), child: Column( @@ -391,7 +492,7 @@ class _MovieDetailPageState extends State { Wrap( spacing: 8, runSpacing: 8, - children: widget.movie.genres.map((genre) { + children: movie.genres.map((genre) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( @@ -413,7 +514,7 @@ class _MovieDetailPageState extends State { } /// 构建简介区域 - Widget _buildSummarySection() { + Widget _buildSummarySection(Movie movie) { return Padding( padding: const EdgeInsets.all(24), child: Column( @@ -430,7 +531,7 @@ class _MovieDetailPageState extends State { ), const SizedBox(height: 12), Text( - widget.movie.summary!, + movie.summary!, style: const TextStyle( fontSize: 15, color: Color(0xFF1A1A1A), @@ -442,43 +543,8 @@ class _MovieDetailPageState extends State { ); } - /// 构建别名区域 - Widget _buildAlternateTitlesSection() { - return Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '别名', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Color(0xFF999999), - letterSpacing: 1, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: widget.movie.alternateTitles.map((title) { - return Text( - title, - style: const TextStyle( - fontSize: 14, - color: Color(0xFF666666), - ), - ); - }).toList(), - ), - ], - ), - ); - } - /// 构建额外功能区域(影评、海报墙) - Widget _buildExtraSections() { + Widget _buildExtraSections(Movie movie) { return Padding( padding: const EdgeInsets.all(24), child: Column( @@ -496,7 +562,7 @@ class _MovieDetailPageState extends State { const SizedBox(height: 16), // 影评入口 GestureDetector( - onTap: () => _navigateToReviews(), + onTap: () => _navigateToReviews(movie), child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -524,7 +590,7 @@ class _MovieDetailPageState extends State { ), const SizedBox(height: 4), FutureBuilder( - future: context.read().getMovieReviewCount(widget.movie.id), + future: context.read().getMovieReviewCount(movie.id), builder: (context, snapshot) { final count = snapshot.data ?? 0; return Text( @@ -550,7 +616,7 @@ class _MovieDetailPageState extends State { const SizedBox(height: 12), // 海报墙入口 GestureDetector( - onTap: () => _navigateToPosters(), + onTap: () => _navigateToPosters(movie), child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -578,7 +644,7 @@ class _MovieDetailPageState extends State { ), const SizedBox(height: 4), FutureBuilder( - future: context.read().getMoviePosterCount(widget.movie.id), + future: context.read().getMoviePosterCount(movie.id), builder: (context, snapshot) { final count = snapshot.data ?? 0; return Text( @@ -606,20 +672,20 @@ class _MovieDetailPageState extends State { ); } - void _navigateToReviews() { + void _navigateToReviews(Movie movie) { Navigator.push( context, MaterialPageRoute( - builder: (context) => MovieReviewsPage(movie: widget.movie), + builder: (context) => MovieReviewsPage(movie: movie), ), ); } - void _navigateToPosters() { + void _navigateToPosters(Movie movie) { Navigator.push( context, MaterialPageRoute( - builder: (context) => MoviePostersPage(movie: widget.movie), + builder: (context) => MoviePostersPage(movie: movie), ), ); } @@ -712,4 +778,73 @@ class _MovieDetailPageState extends State { ), ); } + + /// 下载海报到本地 + Future _downloadPoster(Movie movie) async { + if (movie.posterPath == null || movie.posterPath!.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('没有可下载的海报')), + ); + return; + } + + try { + final sourceFile = File(movie.posterPath!); + if (!await sourceFile.exists()) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('海报文件不存在')), + ); + return; + } + + // 生成文件名:影视名称_时间戳_海报.扩展名 + final timestamp = DateTime.now().millisecondsSinceEpoch; + final fileName = '${movie.title}_${timestamp}_海报${path.extension(movie.posterPath!)}'; + + // 复制到临时目录 + final tempDir = await getTemporaryDirectory(); + final tempFile = File(path.join(tempDir.path, fileName)); + await sourceFile.copy(tempFile.path); + + // 使用分享功能让用户选择保存位置 + await Share.shareXFiles( + [XFile(tempFile.path)], + subject: '${movie.title} 海报', + text: '下载自 MookNote', + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('下载失败: $e')), + ); + } + } + + /// 请求存储权限 + Future _requestStoragePermission() async { + // Android 13+ 使用新的权限 + if (Platform.isAndroid) { + final sdkInt = await _getAndroidSdkInt(); + if (sdkInt >= 33) { + // Android 13+ 使用 READ_MEDIA_IMAGES + final status = await Permission.photos.request(); + return status.isGranted; + } else { + // Android 12 及以下使用存储权限 + var status = await Permission.storage.request(); + if (status.isDenied) { + status = await Permission.storage.request(); + } + return status.isGranted; + } + } + // iOS 不需要额外权限来保存到应用沙盒 + return true; + } + + /// 获取 Android SDK 版本 + Future _getAndroidSdkInt() async { + // 简化处理,实际可以通过 platform channel 获取 + // 这里默认返回较低版本,使用传统存储权限 + return 30; + } } diff --git a/lib/pages/movie_form_page.dart b/lib/pages/movie_form_page.dart index 304c896..8e90ffb 100644 --- a/lib/pages/movie_form_page.dart +++ b/lib/pages/movie_form_page.dart @@ -10,8 +10,9 @@ import '../models/data_models.dart'; /// 添加/编辑影视页面 - 紧凑双行布局设计 class MovieFormPage extends StatefulWidget { final Movie? movie; + final String? initialStatus; // 添加时的默认状态 - const MovieFormPage({super.key, this.movie}); + const MovieFormPage({super.key, this.movie, this.initialStatus}); @override State createState() => _MovieFormPageState(); @@ -42,7 +43,22 @@ class _MovieFormPageState extends State { @override void initState() { super.initState(); - final movie = widget.movie; + _initializeData(); + } + + void _initializeData() { + // 如果有传入movie,尝试从Provider获取最新数据 + Movie? movie = widget.movie; + if (movie != null) { + final appProvider = context.read(); + final latestMovie = appProvider.movies + .where((m) => m.id == movie!.id) + .firstOrNull; + if (latestMovie != null) { + movie = latestMovie; + } + } + _titleController = TextEditingController(text: movie?.title ?? ''); _summaryController = TextEditingController(text: movie?.summary ?? ''); _ratingController = TextEditingController(text: movie?.rating?.toString() ?? ''); @@ -56,6 +72,9 @@ class _MovieFormPageState extends State { _posterPath = movie.posterPath; _status = movie.status; _releaseDate = movie.releaseDate; + } else if (widget.initialStatus != null) { + // 添加模式:使用传入的默认状态 + _status = widget.initialStatus!; } } @@ -104,6 +123,16 @@ class _MovieFormPageState extends State { const SizedBox(height: 32), + // 状态选择(靠左显示) + _buildStatusSelector(), + + const SizedBox(height: 20), + + // 评分 - 星星选择(靠左显示) + _buildStarRating(), + + const SizedBox(height: 32), + // 基本信息区域 _buildFormItem( label: '影视名称 *', @@ -235,62 +264,6 @@ class _MovieFormPageState extends State { ), ), - _buildDivider(), - - // 评分 - _buildFormItem( - label: '评分', - child: Row( - children: [ - Expanded( - child: TextFormField( - controller: _ratingController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), - decoration: const InputDecoration( - hintText: '1-10', - hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), - border: InputBorder.none, - contentPadding: EdgeInsets.zero, - ), - validator: (value) { - if (value != null && value.isNotEmpty) { - final rating = double.tryParse(value); - if (rating == null || rating < 1 || rating > 10) { - return '评分必须在 1-10 之间'; - } - } - return null; - }, - ), - ), - if (_ratingController.text.isNotEmpty) - const Text( - '分', - style: TextStyle(fontSize: 14, color: Color(0xFF999999)), - ), - ], - ), - ), - - _buildDivider(), - - // 状态 - _buildFormItem( - label: '状态', - child: Padding( - padding: const EdgeInsets.only(top: 8), - child: Wrap( - spacing: 12, - children: [ - _buildStatusChip('想看', 'want_to_watch'), - _buildStatusChip('在看', 'watching'), - _buildStatusChip('已看', 'watched'), - ], - ), - ), - ), - const SizedBox(height: 48), ], ), @@ -452,37 +425,173 @@ class _MovieFormPageState extends State { ); } - /// 构建状态选择 Chip - Widget _buildStatusChip(String label, String value) { + /// 构建状态选择器(靠左显示,带标签) + Widget _buildStatusSelector() { + return Row( + children: [ + const Text( + '状态', + style: TextStyle(fontSize: 14, color: Color(0xFF666666)), + ), + const SizedBox(width: 16), + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildStatusOption('想看', 'want_to_watch'), + _buildStatusOption('在看', 'watching'), + _buildStatusOption('已看', 'watched'), + ], + ), + ), + ], + ); + } + + /// 构建星星评分(5星制,每星2分,支持手动输入) + Widget _buildStarRating() { + return Row( + children: [ + const Text( + '评分', + style: TextStyle(fontSize: 14, color: Color(0xFF666666)), + ), + const SizedBox(width: 16), + // 星星选择 + _buildStarSelector(), + const SizedBox(width: 12), + // 手动输入框 + _buildRatingInputField(), + ], + ); + } + + /// 构建星星选择器 + Widget _buildStarSelector() { + final currentRating = double.tryParse(_ratingController.text) ?? 0; + final starRating = currentRating / 2; + + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(5, (index) { + final starValue = index + 1; + final scoreValue = starValue * 2; + final isFilled = starValue <= starRating; + final isHalf = starValue == starRating.ceil() && starRating % 1 != 0; + + return InkWell( + onTap: () { + setState(() { + _ratingController.text = scoreValue.toString(); + }); + }, + borderRadius: BorderRadius.circular(4), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4), + child: Icon( + isHalf + ? Icons.star_half + : isFilled + ? Icons.star + : Icons.star_border, + size: 24, + color: isFilled || isHalf + ? const Color(0xFFFFB800) + : const Color(0xFFE5E5E5), + ), + ), + ); + }), + ), + ); + } + + /// 构建评分输入框 + Widget _buildRatingInputField() { + return Container( + width: 56, + height: 36, + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(8), + ), + child: TextFormField( + controller: _ratingController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: Color(0xFF1A1A1A), + ), + decoration: const InputDecoration( + hintText: '-', + hintStyle: TextStyle(fontSize: 15, color: Color(0xFFCCCCCC)), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), + ), + validator: (value) { + if (value != null && value.isNotEmpty) { + final rating = double.tryParse(value); + if (rating == null || rating < 0 || rating > 10) { + return '0-10'; + } + } + return null; + }, + onChanged: (value) { + // 限制输入范围 + if (value.isNotEmpty) { + final rating = double.tryParse(value); + if (rating != null) { + if (rating > 10) { + _ratingController.text = '10'; + } else if (rating < 0) { + _ratingController.text = '0'; + } + } + } + setState(() {}); // 更新星星显示 + }, + ), + ); + } + + /// 构建状态选项 + Widget _buildStatusOption(String label, String value) { final isSelected = _status == value; - Color color; - switch (value) { - case 'watched': - color = const Color(0xFF1A1A1A); - break; - case 'watching': - color = const Color(0xFF666666); - break; - case 'want_to_watch': - color = const Color(0xFF999999); - break; - default: - color = const Color(0xFFCCCCCC); - } return GestureDetector( onTap: () => setState(() => _status = value), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), decoration: BoxDecoration( - color: isSelected ? color : Colors.transparent, - border: Border.all(color: color), + color: isSelected ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(6), + boxShadow: isSelected + ? [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ] + : null, ), child: Text( label, style: TextStyle( - fontSize: 13, - color: isSelected ? Colors.white : color, + fontSize: 14, + fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal, + color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFF999999), ), ), ), @@ -491,23 +600,61 @@ class _MovieFormPageState extends State { /// 构建封面选择器 Widget _buildCoverPicker() { - return GestureDetector( - onTap: _pickCover, - child: Container( - width: 140, - height: 200, - decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5), + final hasPoster = _posterPath != null && _posterPath!.isNotEmpty; + + return Column( + children: [ + GestureDetector( + onTap: _pickCover, + child: Container( + width: 140, + height: 200, + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5), + ), + child: hasPoster + ? Image.file( + File(_posterPath!), + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _buildCoverPlaceholder(), + ) + : _buildCoverPlaceholder(), + ), ), - child: _posterPath != null && _posterPath!.isNotEmpty - ? Image.file( - File(_posterPath!), - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _buildCoverPlaceholder(), - ) - : _buildCoverPlaceholder(), - ), + // 清空海报按钮(仅当有海报时显示) + if (hasPoster) + Padding( + padding: const EdgeInsets.only(top: 12), + child: GestureDetector( + onTap: () => setState(() => _posterPath = null), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.hide_image_outlined, + size: 16, + color: Color(0xFF666666), + ), + SizedBox(width: 4), + Text( + '清空海报', + style: TextStyle( + fontSize: 13, + color: Color(0xFF666666), + ), + ), + ], + ), + ), + ), + ), + ], ); } diff --git a/lib/pages/movie_posters_page.dart b/lib/pages/movie_posters_page.dart index 3b6340a..0a8df62 100644 --- a/lib/pages/movie_posters_page.dart +++ b/lib/pages/movie_posters_page.dart @@ -1,11 +1,14 @@ import 'dart:io'; +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import 'package:provider/provider.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import '../providers/app_provider.dart'; import '../models/data_models.dart'; +import '../utils/toast_util.dart'; /// 影视海报墙页面 class MoviePostersPage extends StatefulWidget { @@ -93,64 +96,96 @@ class _MoviePostersPageState extends State { } Widget _buildPosterGrid() { - return GridView.builder( + return MasonryGridView.count( padding: const EdgeInsets.all(16), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 0.7, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - ), + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, itemCount: _posters.length, itemBuilder: (context, index) { final poster = _posters[index]; - return _buildPosterItem(poster); + return _buildPosterItem(poster, index); }, ); } - Widget _buildPosterItem(MoviePoster poster) { + Widget _buildPosterItem(MoviePoster poster, int index) { + // 根据索引生成不同的高度,实现瀑布流效果 + final heights = [180.0, 220.0, 160.0, 200.0, 240.0, 190.0]; + final height = heights[index % heights.length]; + return GestureDetector( onTap: () => _showPosterDetail(poster), child: Container( + height: height, decoration: BoxDecoration( - border: Border.all(color: const Color(0xFFE5E5E5)), - ), - child: Stack( - fit: StackFit.expand, - children: [ - // 海报图片 - Image.file( - File(poster.posterPath), - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const Center( - child: Icon( - Icons.broken_image, - color: Color(0xFFCCCCCC), - ), - ), - ), - // 删除按钮 - Positioned( - top: 8, - right: 8, - child: GestureDetector( - onTap: () => _showDeleteDialog(poster), - child: Container( - padding: const EdgeInsets.all(4), - decoration: const BoxDecoration( - color: Colors.white, - ), - child: const Icon( - Icons.close, - size: 18, - color: Colors.red, - ), - ), - ), + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 8, + offset: const Offset(0, 2), ), ], ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Stack( + fit: StackFit.expand, + children: [ + // 海报图片 + Image.file( + File(poster.posterPath), + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Center( + child: Icon( + Icons.broken_image, + color: Color(0xFFCCCCCC), + ), + ), + ), + // 渐变遮罩(底部) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + height: 40, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withOpacity(0.3), + ], + ), + ), + ), + ), + // 删除按钮 + Positioned( + top: 8, + right: 8, + child: GestureDetector( + onTap: () => _showDeleteDialog(poster), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.9), + borderRadius: BorderRadius.circular(4), + ), + child: const Icon( + Icons.close, + size: 16, + color: Colors.red, + ), + ), + ), + ), + ], + ), + ), ), ); } @@ -208,9 +243,7 @@ class _MoviePostersPageState extends State { _loadPosters(); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('添加成功')), - ); + ToastUtil.show(context, '添加成功'); } } } catch (e) { @@ -241,9 +274,7 @@ class _MoviePostersPageState extends State { await context.read().removeMoviePoster(poster.id); Navigator.pop(context); _loadPosters(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('已删除')), - ); + ToastUtil.show(context, '已删除'); }, child: const Text('删除', style: TextStyle(color: Colors.red)), ), diff --git a/lib/pages/movie_reviews_page.dart b/lib/pages/movie_reviews_page.dart index 94998e6..647109c 100644 --- a/lib/pages/movie_reviews_page.dart +++ b/lib/pages/movie_reviews_page.dart @@ -157,6 +157,8 @@ class _MovieReviewsPageState extends State { // 评论内容 Text( review.content, + maxLines: review.reviewType == 1 ? 3 : 5, + overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 15, color: Color(0xFF1A1A1A), diff --git a/lib/pages/movie_tab_page.dart b/lib/pages/movie_tab_page.dart index eeb275b..9e370ed 100644 --- a/lib/pages/movie_tab_page.dart +++ b/lib/pages/movie_tab_page.dart @@ -31,8 +31,8 @@ class MovieTabPage extends StatelessWidget { builder: (context, provider, child) { final statusMap = { 0: 'watched', - 1: 'want_to_watch', - 2: 'watching', + 1: 'watching', + 2: 'want_to_watch', }; final currentStatus = statusMap[provider.movieStatusIndex]!; final movies = provider.getMoviesByStatus(currentStatus); @@ -45,8 +45,14 @@ class MovieTabPage extends StatelessWidget { onRefresh: () async => await provider.loadMovies(), color: const Color(0xFF1A1A1A), backgroundColor: Colors.white, - child: ListView.builder( - padding: EdgeInsets.zero, + child: GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 0.55, + crossAxisSpacing: 12, + mainAxisSpacing: 16, + ), itemCount: movies.length, itemBuilder: (context, index) { return MovieListItem(movie: movies[index]); @@ -59,7 +65,7 @@ class MovieTabPage extends StatelessWidget { /// 构建空状态 Widget _buildEmptyState(BuildContext context, int statusIndex) { - final statusText = ['已看', '想看', '在看'][statusIndex]; + final statusText = ['已看', '在看', '想看'][statusIndex]; return Center( child: Column( @@ -80,7 +86,19 @@ class MovieTabPage extends StatelessWidget { ), const SizedBox(height: 24), TextButton( - onPressed: () => Navigator.pushNamed(context, '/movie-form'), + onPressed: () { + final statusMap = { + 0: 'watched', + 1: 'watching', + 2: 'want_to_watch', + }; + final currentStatus = statusMap[statusIndex]!; + Navigator.pushNamed( + context, + '/movie-form', + arguments: {'initialStatus': currentStatus}, + ); + }, child: const Text('添加记录'), ), ], diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index b0487f4..8d645af 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -6,6 +6,9 @@ import 'package:path/path.dart' as path; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; import '../utils/user_prefs.dart'; +import 'recycle_bin_page.dart'; +import 'backup_page.dart'; +import 'statistics_page.dart'; /// 个人中心页面 - 极简主义设计 class ProfilePage extends StatefulWidget { @@ -380,7 +383,12 @@ class _ProfilePageState extends State { _buildMenuItem( icon: Icons.analytics_outlined, title: '数据统计', - onTap: () => _showToast('详细统计功能开发中'), + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const StatisticsPage()), + ); + }, ), const Divider(height: 0.5, thickness: 0.5, indent: 56, color: Color(0xFFE5E5E5)), @@ -388,21 +396,24 @@ class _ProfilePageState extends State { _buildMenuItem( icon: Icons.delete_outline, title: '回收站', - onTap: () => _showToast('回收站功能开发中'), + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const RecycleBinPage()), + ); + }, ), const Divider(height: 0.5, thickness: 0.5, indent: 56, color: Color(0xFFE5E5E5)), _buildMenuItem( icon: Icons.backup_outlined, title: '数据备份', - onTap: () => _showToast('备份功能开发中'), - ), - const Divider(height: 0.5, thickness: 0.5, indent: 56, color: Color(0xFFE5E5E5)), - - _buildMenuItem( - icon: Icons.settings_outlined, - title: '设置', - onTap: () => _showSettings(context), + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const BackupPage()), + ); + }, ), ], ); diff --git a/lib/pages/recycle_bin_page.dart b/lib/pages/recycle_bin_page.dart new file mode 100644 index 0000000..7f540da --- /dev/null +++ b/lib/pages/recycle_bin_page.dart @@ -0,0 +1,376 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; + +/// 回收站页面 +class RecycleBinPage extends StatefulWidget { + const RecycleBinPage({super.key}); + + @override + State createState() => _RecycleBinPageState(); +} + +class _RecycleBinPageState extends State with SingleTickerProviderStateMixin { + late TabController _tabController; + List _deletedMovies = []; + List _deletedBooks = []; + List _deletedNotes = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + _loadDeletedItems(); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + Future _loadDeletedItems() async { + setState(() => _isLoading = true); + final provider = context.read(); + final movies = await provider.getDeletedMovies(); + final books = await provider.getDeletedBooks(); + final notes = await provider.getDeletedNotes(); + setState(() { + _deletedMovies = movies; + _deletedBooks = books; + _deletedNotes = notes; + _isLoading = false; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text('回收站'), + bottom: TabBar( + controller: _tabController, + labelColor: const Color(0xFF1A1A1A), + unselectedLabelColor: const Color(0xFF999999), + indicatorColor: const Color(0xFF1A1A1A), + tabs: [ + Tab(text: '影视 (${_deletedMovies.length})'), + Tab(text: '书籍 (${_deletedBooks.length})'), + Tab(text: '笔记 (${_deletedNotes.length})'), + ], + ), + actions: [ + // 清空全部 + TextButton( + onPressed: _showClearAllDialog, + child: const Text( + '清空', + style: TextStyle(color: Colors.red), + ), + ), + const SizedBox(width: 8), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : TabBarView( + controller: _tabController, + children: [ + _buildMovieList(), + _buildBookList(), + _buildNoteList(), + ], + ), + ); + } + + /// 影视列表 + Widget _buildMovieList() { + if (_deletedMovies.isEmpty) { + return _buildEmptyState('暂无删除的影视'); + } + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _deletedMovies.length, + itemBuilder: (context, index) { + final movie = _deletedMovies[index]; + return _buildDeletedItemCard( + title: movie.title, + subtitle: '删除于 ${_formatDate(movie.updatedAt)}', + onRestore: () => _restoreMovie(movie), + onDelete: () => _permanentDeleteMovie(movie), + ); + }, + ); + } + + /// 书籍列表 + Widget _buildBookList() { + if (_deletedBooks.isEmpty) { + return _buildEmptyState('暂无删除的书籍'); + } + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _deletedBooks.length, + itemBuilder: (context, index) { + final book = _deletedBooks[index]; + return _buildDeletedItemCard( + title: book.title, + subtitle: '删除于 ${_formatDate(book.updatedAt)}', + onRestore: () => _restoreBook(book), + onDelete: () => _permanentDeleteBook(book), + ); + }, + ); + } + + /// 笔记列表 + Widget _buildNoteList() { + if (_deletedNotes.isEmpty) { + return _buildEmptyState('暂无删除的笔记'); + } + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _deletedNotes.length, + itemBuilder: (context, index) { + final note = _deletedNotes[index]; + return _buildDeletedItemCard( + title: note.summary, + subtitle: '删除于 ${_formatDate(note.updatedAt)}', + onRestore: () => _restoreNote(note), + onDelete: () => _permanentDeleteNote(note), + ); + }, + ); + } + + /// 构建已删除项卡片 + Widget _buildDeletedItemCard({ + required String title, + required String subtitle, + required VoidCallback onRestore, + required VoidCallback onDelete, + }) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 15, + color: Color(0xFF1A1A1A), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + Text( + subtitle, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF999999), + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // 恢复按钮 + OutlinedButton( + onPressed: onRestore, + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF1A1A1A), + side: const BorderSide(color: Color(0xFF1A1A1A)), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('恢复'), + ), + const SizedBox(width: 12), + // 彻底删除按钮 + OutlinedButton( + onPressed: onDelete, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + side: const BorderSide(color: Colors.red), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('彻底删除'), + ), + ], + ), + ], + ), + ); + } + + Widget _buildEmptyState(String message) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.delete_outline, + size: 64, + color: Color(0xFFCCCCCC), + ), + const SizedBox(height: 16), + Text( + message, + style: const TextStyle( + fontSize: 16, + color: Color(0xFF999999), + ), + ), + ], + ), + ); + } + + String _formatDate(DateTime date) { + return '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}'; + } + + /// 恢复影视 + Future _restoreMovie(Movie movie) async { + await context.read().restoreMovie(movie.id); + _loadDeletedItems(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('影视已恢复')), + ); + } + } + + /// 彻底删除影视 + Future _permanentDeleteMovie(Movie movie) async { + final confirmed = await _showConfirmDialog('确定要彻底删除这部影视吗?此操作不可恢复。'); + if (confirmed) { + await context.read().permanentDeleteMovie(movie.id); + _loadDeletedItems(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已彻底删除')), + ); + } + } + } + + /// 恢复书籍 + Future _restoreBook(Book book) async { + await context.read().restoreBook(book.id); + _loadDeletedItems(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('书籍已恢复')), + ); + } + } + + /// 彻底删除书籍 + Future _permanentDeleteBook(Book book) async { + final confirmed = await _showConfirmDialog('确定要彻底删除这本书籍吗?此操作不可恢复。'); + if (confirmed) { + await context.read().permanentDeleteBook(book.id); + _loadDeletedItems(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已彻底删除')), + ); + } + } + } + + /// 恢复笔记 + Future _restoreNote(Note note) async { + await context.read().restoreNote(note.id); + _loadDeletedItems(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('笔记已恢复')), + ); + } + } + + /// 彻底删除笔记 + Future _permanentDeleteNote(Note note) async { + final confirmed = await _showConfirmDialog('确定要彻底删除这条笔记吗?此操作不可恢复。'); + if (confirmed) { + await context.read().permanentDeleteNote(note.id); + _loadDeletedItems(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已彻底删除')), + ); + } + } + } + + /// 显示确认对话框 + Future _showConfirmDialog(String message) async { + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + title: const Text('确认删除'), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消', style: TextStyle(color: Color(0xFF666666))), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('删除', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + return result ?? false; + } + + /// 显示清空全部对话框 + void _showClearAllDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + title: const Text('清空回收站'), + content: const Text('确定要清空回收站吗?所有项目将被彻底删除,此操作不可恢复。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消', style: TextStyle(color: Color(0xFF666666))), + ), + TextButton( + onPressed: () async { + Navigator.pop(context); + await context.read().clearRecycleBin(); + _loadDeletedItems(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('回收站已清空')), + ); + } + }, + child: const Text('清空', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } +} diff --git a/lib/pages/search_page.dart b/lib/pages/search_page.dart new file mode 100644 index 0000000..8de44ed --- /dev/null +++ b/lib/pages/search_page.dart @@ -0,0 +1,486 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; +import 'movie_detail_page.dart'; +import 'book_detail_page.dart'; +import 'note_detail_page.dart'; + +/// 搜索页面 +class SearchPage extends StatefulWidget { + const SearchPage({super.key}); + + @override + State createState() => _SearchPageState(); +} + +class _SearchPageState extends State { + final _searchController = TextEditingController(); + int _selectedType = 0; // 0: 影视, 1: 书籍, 2: 笔记 + List _results = []; + bool _isSearching = false; + + final List _typeLabels = ['影视', '书籍', '笔记']; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _performSearch() async { + final keyword = _searchController.text.trim(); + if (keyword.isEmpty) return; + + setState(() => _isSearching = true); + + try { + List results; + final provider = context.read(); + + switch (_selectedType) { + case 0: // 影视 + results = provider.movies.where((movie) { + return _matchMovie(movie, keyword); + }).toList(); + break; + case 1: // 书籍 + results = provider.books.where((book) { + return _matchBook(book, keyword); + }).toList(); + break; + case 2: // 笔记 + results = provider.notes.where((note) { + return _matchNote(note, keyword); + }).toList(); + break; + default: + results = []; + } + + setState(() { + _results = results; + _isSearching = false; + }); + } catch (e) { + setState(() => _isSearching = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('搜索失败: $e')), + ); + } + } + + bool _matchMovie(Movie movie, String keyword) { + final lowerKeyword = keyword.toLowerCase(); + return movie.title.toLowerCase().contains(lowerKeyword) || + movie.alternateTitles.any((t) => t.toLowerCase().contains(lowerKeyword)) || + (movie.summary?.toLowerCase().contains(lowerKeyword) ?? false); + } + + bool _matchBook(Book book, String keyword) { + final lowerKeyword = keyword.toLowerCase(); + return book.title.toLowerCase().contains(lowerKeyword) || + book.alternateTitles.any((t) => t.toLowerCase().contains(lowerKeyword)) || + (book.summary?.toLowerCase().contains(lowerKeyword) ?? false); + } + + bool _matchNote(Note note, String keyword) { + return note.content.toLowerCase().contains(keyword.toLowerCase()); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text('搜索'), + ), + body: Column( + children: [ + // 搜索类型选择 + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: Color(0xFFE5E5E5), width: 0.5), + ), + ), + child: Row( + children: List.generate(_typeLabels.length, (index) { + final isSelected = _selectedType == index; + return GestureDetector( + onTap: () { + setState(() { + _selectedType = index; + _results = []; + }); + if (_searchController.text.isNotEmpty) { + _performSearch(); + } + }, + child: Container( + margin: const EdgeInsets.only(right: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFF5F5F5), + border: Border.all( + color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFE5E5E5), + ), + ), + child: Text( + _typeLabels[index], + style: TextStyle( + fontSize: 14, + color: isSelected ? Colors.white : const Color(0xFF666666), + ), + ), + ), + ); + }), + ), + ), + + // 搜索输入框 + Container( + padding: const EdgeInsets.all(16), + child: TextField( + controller: _searchController, + autofocus: true, + decoration: InputDecoration( + hintText: _getSearchHint(), + hintStyle: const TextStyle(color: Color(0xFF999999)), + prefixIcon: const Icon(Icons.search, color: Color(0xFF999999)), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, color: Color(0xFF999999)), + onPressed: () { + _searchController.clear(); + setState(() => _results = []); + }, + ) + : null, + border: const OutlineInputBorder( + borderRadius: BorderRadius.zero, + borderSide: BorderSide(color: Color(0xFFE5E5E5)), + ), + focusedBorder: const OutlineInputBorder( + borderRadius: BorderRadius.zero, + borderSide: BorderSide(color: Color(0xFF1A1A1A)), + ), + ), + onSubmitted: (_) => _performSearch(), + onChanged: (_) => setState(() {}), + ), + ), + + // 搜索结果 + Expanded( + child: _isSearching + ? const Center(child: CircularProgressIndicator()) + : _results.isEmpty + ? _buildEmptyState() + : _buildResultList(), + ), + ], + ), + ); + } + + String _getSearchHint() { + switch (_selectedType) { + case 0: + return '搜索影视名称、别名、简介...'; + case 1: + return '搜索书籍名称、别名、简介...'; + case 2: + return '搜索笔记内容...'; + default: + return '请输入搜索关键词'; + } + } + + Widget _buildEmptyState() { + if (_searchController.text.isEmpty) { + return const Center( + child: Text( + '输入关键词开始搜索', + style: TextStyle(color: Color(0xFF999999)), + ), + ); + } + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.search_off, size: 64, color: Color(0xFFCCCCCC)), + SizedBox(height: 16), + Text( + '未找到相关内容', + style: TextStyle(color: Color(0xFF999999)), + ), + ], + ), + ); + } + + Widget _buildResultList() { + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: _results.length, + itemBuilder: (context, index) { + final item = _results[index]; + if (item is Movie) { + return _buildMovieItem(item); + } else if (item is Book) { + return _buildBookItem(item); + } else if (item is Note) { + return _buildNoteItem(item); + } + return const SizedBox.shrink(); + }, + ); + } + + Widget _buildMovieItem(Movie movie) { + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MovieDetailPage(movie: movie), + ), + ); + }, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Row( + children: [ + // 海报 + Container( + width: 60, + height: 80, + color: const Color(0xFFF5F5F5), + child: movie.posterPath != null + ? Image.file( + File(movie.posterPath!), + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Icon(Icons.movie, color: Color(0xFFCCCCCC)), + ) + : const Icon(Icons.movie, color: Color(0xFFCCCCCC)), + ), + const SizedBox(width: 12), + // 信息 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + movie.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + if (movie.alternateTitles.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + movie.alternateTitles.join(' / '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF999999), + ), + ), + ], + const SizedBox(height: 4), + _buildStatusTag(movie.status), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildBookItem(Book book) { + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BookDetailPage(book: book), + ), + ); + }, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Row( + children: [ + // 封面 + Container( + width: 60, + height: 80, + color: const Color(0xFFF5F5F5), + child: book.coverPath != null + ? Image.file( + File(book.coverPath!), + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Icon(Icons.book, color: Color(0xFFCCCCCC)), + ) + : const Icon(Icons.book, color: Color(0xFFCCCCCC)), + ), + const SizedBox(width: 12), + // 信息 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + book.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + if (book.alternateTitles.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + book.alternateTitles.join(' / '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF999999), + ), + ), + ], + const SizedBox(height: 4), + _buildBookStatusTag(book.status), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildNoteItem(Note note) { + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => NoteDetailPage(note: note), + ), + ); + }, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + note.content, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + height: 1.5, + ), + ), + const SizedBox(height: 8), + Text( + '${note.createdAt.year}.${note.createdAt.month.toString().padLeft(2, '0')}.${note.createdAt.day.toString().padLeft(2, '0')}', + style: const TextStyle( + fontSize: 11, + color: Color(0xFF999999), + ), + ), + ], + ), + ), + ); + } + + Widget _buildStatusTag(String status) { + String label; + Color color; + switch (status) { + case 'watched': + label = '已看'; + color = const Color(0xFF1A1A1A); + break; + case 'watching': + label = '在看'; + color = const Color(0xFF666666); + break; + case 'want_to_watch': + label = '想看'; + color = const Color(0xFF999999); + break; + default: + label = '未知'; + color = const Color(0xFFCCCCCC); + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration(color: color), + child: Text( + label, + style: const TextStyle(fontSize: 11, color: Colors.white), + ), + ); + } + + Widget _buildBookStatusTag(String status) { + String label; + Color color; + switch (status) { + case 'read': + label = '已读'; + color = const Color(0xFF1A1A1A); + break; + case 'reading': + label = '在读'; + color = const Color(0xFF666666); + break; + case 'want_to_read': + label = '想读'; + color = const Color(0xFF999999); + break; + default: + label = '未知'; + color = const Color(0xFFCCCCCC); + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration(color: color), + child: Text( + label, + style: const TextStyle(fontSize: 11, color: Colors.white), + ), + ); + } +} + + diff --git a/lib/pages/statistics_page.dart b/lib/pages/statistics_page.dart new file mode 100644 index 0000000..23f8147 --- /dev/null +++ b/lib/pages/statistics_page.dart @@ -0,0 +1,728 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:fl_chart/fl_chart.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; + +/// 数据统计页面 - 多维度数据分析 +class StatisticsPage extends StatefulWidget { + const StatisticsPage({super.key}); + + @override + State createState() => _StatisticsPageState(); +} + +class _StatisticsPageState extends State with SingleTickerProviderStateMixin { + late TabController _tabController; + DateTime _selectedMonth = DateTime.now(); + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 3, + child: Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text('数据统计'), + bottom: TabBar( + controller: _tabController, + tabs: const [ + Tab(text: '概览'), + Tab(text: '日历'), + Tab(text: '趋势'), + ], + labelColor: const Color(0xFF1A1A1A), + unselectedLabelColor: const Color(0xFF999999), + indicatorColor: const Color(0xFF1A1A1A), + ), + ), + body: Consumer( + builder: (context, provider, child) { + final movies = provider.movies; + final books = provider.books; + final notes = provider.notes; + + return TabBarView( + controller: _tabController, + children: [ + _buildOverviewTab(movies, books, notes), + _buildCalendarTab(movies, books, notes), + _buildTrendTab(movies, books, notes), + ], + ); + }, + ), + ), + ); + } + + /// 概览标签页 + Widget _buildOverviewTab(List movies, List books, List notes) { + final totalMovies = movies.length; + final totalBooks = books.length; + final totalNotes = notes.length; + + final watchedMovies = movies.where((m) => m.status == 'watched').length; + final watchingMovies = movies.where((m) => m.status == 'watching').length; + final wantToWatchMovies = movies.where((m) => m.status == 'want_to_watch').length; + + final readBooks = books.where((b) => b.status == 'read').length; + final readingBooks = books.where((b) => b.status == 'reading').length; + final wantToReadBooks = books.where((b) => b.status == 'want_to_read').length; + + return ListView( + padding: const EdgeInsets.all(24), + children: [ + // 总数据卡片 + _buildSectionTitle('数据总览'), + const SizedBox(height: 16), + Row( + children: [ + Expanded(child: _buildStatCard('影视', totalMovies, Icons.movie_outlined, const Color(0xFF1A1A1A))), + const SizedBox(width: 12), + Expanded(child: _buildStatCard('书籍', totalBooks, Icons.menu_book_outlined, const Color(0xFF666666))), + const SizedBox(width: 12), + Expanded(child: _buildStatCard('笔记', totalNotes, Icons.note_outlined, const Color(0xFF999999))), + ], + ), + const SizedBox(height: 32), + + // 影视状态分布 + _buildSectionTitle('影视状态分布'), + const SizedBox(height: 16), + _buildStatusDistribution([ + _StatusData('已看', watchedMovies, const Color(0xFF1A1A1A)), + _StatusData('在看', watchingMovies, const Color(0xFF666666)), + _StatusData('想看', wantToWatchMovies, const Color(0xFF999999)), + ]), + const SizedBox(height: 32), + + // 书籍状态分布 + _buildSectionTitle('书籍状态分布'), + const SizedBox(height: 16), + _buildStatusDistribution([ + _StatusData('已读', readBooks, const Color(0xFF1A1A1A)), + _StatusData('在读', readingBooks, const Color(0xFF666666)), + _StatusData('想读', wantToReadBooks, const Color(0xFF999999)), + ]), + const SizedBox(height: 32), + + // 最近活动 + _buildSectionTitle('最近7天活动'), + const SizedBox(height: 16), + _buildRecentActivity(movies, books, notes), + ], + ); + } + + /// 日历标签页 + Widget _buildCalendarTab(List movies, List books, List notes) { + // 合并所有数据按日期 + final Map dailyData = {}; + + for (final movie in movies) { + final date = DateTime(movie.createdAt.year, movie.createdAt.month, movie.createdAt.day); + dailyData.putIfAbsent(date, () => _DailyData()).movies++; + } + + for (final book in books) { + final date = DateTime(book.createdAt.year, book.createdAt.month, book.createdAt.day); + dailyData.putIfAbsent(date, () => _DailyData()).books++; + } + + for (final note in notes) { + final date = DateTime(note.createdAt.year, note.createdAt.month, note.createdAt.day); + dailyData.putIfAbsent(date, () => _DailyData()).notes++; + } + + return Column( + children: [ + // 月份选择器 + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: () { + setState(() { + _selectedMonth = DateTime(_selectedMonth.year, _selectedMonth.month - 1); + }); + }, + ), + Text( + '${_selectedMonth.year}年${_selectedMonth.month}月', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: () { + setState(() { + _selectedMonth = DateTime(_selectedMonth.year, _selectedMonth.month + 1); + }); + }, + ), + ], + ), + ), + + // 热力图图例 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const Text('少', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), + const SizedBox(width: 8), + ...List.generate(4, (index) { + final opacity = 0.2 + (index * 0.2); + return Container( + width: 12, + height: 12, + margin: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A).withOpacity(opacity), + borderRadius: BorderRadius.circular(2), + ), + ); + }), + const SizedBox(width: 8), + const Text('多', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), + ], + ), + ), + const SizedBox(height: 16), + + // 日历热力图 + Expanded( + child: _buildHeatMapCalendar(dailyData), + ), + ], + ); + } + + /// 趋势标签页 + Widget _buildTrendTab(List movies, List books, List notes) { + // 近30天每日新增数据 + final now = DateTime.now(); + final thirtyDaysAgo = now.subtract(const Duration(days: 29)); + + final List<_DailyTrend> movieTrend = []; + final List<_DailyTrend> bookTrend = []; + final List<_DailyTrend> noteTrend = []; + + for (int i = 0; i < 30; i++) { + final date = DateTime(thirtyDaysAgo.year, thirtyDaysAgo.month, thirtyDaysAgo.day + i); + + final movieCount = movies.where((m) { + final mDate = DateTime(m.createdAt.year, m.createdAt.month, m.createdAt.day); + return mDate.isAtSameMomentAs(date); + }).length; + + final bookCount = books.where((b) { + final bDate = DateTime(b.createdAt.year, b.createdAt.month, b.createdAt.day); + return bDate.isAtSameMomentAs(date); + }).length; + + final noteCount = notes.where((n) { + final nDate = DateTime(n.createdAt.year, n.createdAt.month, n.createdAt.day); + return nDate.isAtSameMomentAs(date); + }).length; + + movieTrend.add(_DailyTrend(date, movieCount)); + bookTrend.add(_DailyTrend(date, bookCount)); + noteTrend.add(_DailyTrend(date, noteCount)); + } + + return ListView( + padding: const EdgeInsets.all(24), + children: [ + _buildSectionTitle('近30天新增趋势'), + const SizedBox(height: 8), + const Text( + '每日新增数据统计', + style: TextStyle(fontSize: 13, color: Color(0xFF999999)), + ), + const SizedBox(height: 24), + + // 折线图 + SizedBox( + height: 250, + child: _buildLineChart(movieTrend, bookTrend, noteTrend), + ), + const SizedBox(height: 32), + + // 图例 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildLegendItem('影视', const Color(0xFF1A1A1A)), + const SizedBox(width: 24), + _buildLegendItem('书籍', const Color(0xFF666666)), + const SizedBox(width: 24), + _buildLegendItem('笔记', const Color(0xFF999999)), + ], + ), + const SizedBox(height: 32), + + // 近7天统计 + _buildSectionTitle('近7天新增统计'), + const SizedBox(height: 16), + _buildWeeklyStats(movieTrend.sublist(23), bookTrend.sublist(23), noteTrend.sublist(23)), + ], + ); + } + + /// 构建统计卡片 + Widget _buildStatCard(String title, int count, IconData icon, Color color) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Column( + children: [ + Icon(icon, color: color, size: 24), + const SizedBox(height: 8), + Text( + count.toString(), + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w600, + color: color, + ), + ), + const SizedBox(height: 4), + Text( + title, + style: const TextStyle(fontSize: 12, color: Color(0xFF666666)), + ), + ], + ), + ); + } + + /// 构建状态分布 + Widget _buildStatusDistribution(List<_StatusData> data) { + final total = data.fold(0, (sum, item) => sum + item.count); + + return Column( + children: data.map((item) { + final percentage = total > 0 ? (item.count / total * 100).toStringAsFixed(1) : '0'; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration(color: item.color), + ), + const SizedBox(width: 12), + Text(item.label, style: const TextStyle(fontSize: 14)), + const Spacer(), + Text( + '${item.count} ($percentage%)', + style: const TextStyle(fontSize: 14, color: Color(0xFF666666)), + ), + ], + ), + ); + }).toList(), + ); + } + + /// 构建最近活动 + Widget _buildRecentActivity(List movies, List books, List notes) { + final now = DateTime.now(); + final sevenDaysAgo = now.subtract(const Duration(days: 7)); + + final recentMovies = movies.where((m) => m.createdAt.isAfter(sevenDaysAgo)).length; + final recentBooks = books.where((b) => b.createdAt.isAfter(sevenDaysAgo)).length; + final recentNotes = notes.where((n) => n.createdAt.isAfter(sevenDaysAgo)).length; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Column( + children: [ + _buildActivityRow('新增影视', recentMovies, Icons.movie_outlined), + const Divider(height: 24), + _buildActivityRow('新增书籍', recentBooks, Icons.menu_book_outlined), + const Divider(height: 24), + _buildActivityRow('新增笔记', recentNotes, Icons.note_outlined), + ], + ), + ); + } + + Widget _buildActivityRow(String label, int count, IconData icon) { + return Row( + children: [ + Icon(icon, size: 20, color: const Color(0xFF666666)), + const SizedBox(width: 12), + Text(label, style: const TextStyle(fontSize: 14)), + const Spacer(), + Text( + '$count 个', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + ], + ); + } + + /// 构建热力图日历 + Widget _buildHeatMapCalendar(Map dailyData) { + final year = _selectedMonth.year; + final month = _selectedMonth.month; + final daysInMonth = DateTime(year, month + 1, 0).day; + final firstWeekday = DateTime(year, month, 1).weekday % 7; + + // 计算最大数量用于颜色强度 + int maxCount = 0; + for (final data in dailyData.values) { + final count = data.total; + if (count > maxCount) maxCount = count; + } + if (maxCount == 0) maxCount = 1; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + // 星期标题 + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: const ['日', '一', '二', '三', '四', '五', '六'] + .map((d) => SizedBox( + width: 36, + child: Text( + d, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: Color(0xFF999999)), + ), + )) + .toList(), + ), + const SizedBox(height: 8), + + // 日历网格 + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + // 空白填充 + ...List.generate(firstWeekday, (_) => const SizedBox(width: 36, height: 36)), + + // 日期 + ...List.generate(daysInMonth, (index) { + final day = index + 1; + final date = DateTime(year, month, day); + final data = dailyData[date]; + final count = data?.total ?? 0; + + // 计算颜色强度 + double opacity = 0.1; + if (count > 0) { + opacity = 0.3 + (count / maxCount * 0.7); + opacity = opacity.clamp(0.3, 1.0); + } + + return GestureDetector( + onTap: () { + if (count > 0) { + _showDayDetail(context, date, data!); + } + }, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: count > 0 + ? const Color(0xFF1A1A1A).withOpacity(opacity) + : const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(4), + ), + child: Center( + child: Text( + day.toString(), + style: TextStyle( + fontSize: 12, + color: count > 0 ? Colors.white : const Color(0xFF666666), + ), + ), + ), + ), + ); + }), + ], + ), + ], + ), + ); + } + + /// 显示某天详情 + void _showDayDetail(BuildContext context, DateTime date, _DailyData data) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + builder: (context) => Container( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${date.year}年${date.month}月${date.day}日', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 16), + if (data.movies > 0) + _buildDetailRow(Icons.movie_outlined, '影视', data.movies), + if (data.books > 0) + _buildDetailRow(Icons.menu_book_outlined, '书籍', data.books), + if (data.notes > 0) + _buildDetailRow(Icons.note_outlined, '笔记', data.notes), + const SizedBox(height: 16), + Text( + '总计: ${data.total} 项', + style: const TextStyle(fontSize: 14, color: Color(0xFF666666)), + ), + ], + ), + ), + ); + } + + Widget _buildDetailRow(IconData icon, String label, int count) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Icon(icon, size: 18, color: const Color(0xFF666666)), + const SizedBox(width: 8), + Text(label, style: const TextStyle(fontSize: 14)), + const Spacer(), + Text('$count 个', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + ], + ), + ); + } + + /// 构建折线图 + Widget _buildLineChart(List<_DailyTrend> movieTrend, List<_DailyTrend> bookTrend, List<_DailyTrend> noteTrend) { + final spotsMovie = movieTrend.asMap().entries.map((e) { + return FlSpot(e.key.toDouble(), e.value.count.toDouble()); + }).toList(); + + final spotsBook = bookTrend.asMap().entries.map((e) { + return FlSpot(e.key.toDouble(), e.value.count.toDouble()); + }).toList(); + + final spotsNote = noteTrend.asMap().entries.map((e) { + return FlSpot(e.key.toDouble(), e.value.count.toDouble()); + }).toList(); + + return LineChart( + LineChartData( + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: 1, + getDrawingHorizontalLine: (value) { + return FlLine( + color: const Color(0xFFE5E5E5), + strokeWidth: 0.5, + ); + }, + ), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 30, + getTitlesWidget: (value, meta) { + return Text( + value.toInt().toString(), + style: const TextStyle(fontSize: 10, color: Color(0xFF999999)), + ); + }, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 30, + interval: 5, + getTitlesWidget: (value, meta) { + if (value.toInt() >= 0 && value.toInt() < movieTrend.length) { + final date = movieTrend[value.toInt()].date; + return Text( + '${date.month}/${date.day}', + style: const TextStyle(fontSize: 10, color: Color(0xFF999999)), + ); + } + return const Text(''); + }, + ), + ), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + borderData: FlBorderData(show: false), + lineBarsData: [ + // 影视折线 + LineChartBarData( + spots: spotsMovie, + isCurved: true, + color: const Color(0xFF1A1A1A), + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: false), + ), + // 书籍折线 + LineChartBarData( + spots: spotsBook, + isCurved: true, + color: const Color(0xFF666666), + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: false), + ), + // 笔记折线 + LineChartBarData( + spots: spotsNote, + isCurved: true, + color: const Color(0xFF999999), + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: false), + ), + ], + ), + ); + } + + /// 构建图例项 + Widget _buildLegendItem(String label, Color color) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 12, + height: 3, + decoration: BoxDecoration(color: color), + ), + const SizedBox(width: 8), + Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF666666))), + ], + ); + } + + /// 构建近7天统计 + Widget _buildWeeklyStats(List<_DailyTrend> movieTrend, List<_DailyTrend> bookTrend, List<_DailyTrend> noteTrend) { + final totalMovies = movieTrend.fold(0, (sum, item) => sum + item.count); + final totalBooks = bookTrend.fold(0, (sum, item) => sum + item.count); + final totalNotes = noteTrend.fold(0, (sum, item) => sum + item.count); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Column( + children: [ + _buildWeeklyRow('影视新增', totalMovies, const Color(0xFF1A1A1A)), + const Divider(height: 24), + _buildWeeklyRow('书籍新增', totalBooks, const Color(0xFF666666)), + const Divider(height: 24), + _buildWeeklyRow('笔记新增', totalNotes, const Color(0xFF999999)), + const Divider(height: 24), + _buildWeeklyRow('总计', totalMovies + totalBooks + totalNotes, const Color(0xFF1A1A1A), isTotal: true), + ], + ), + ); + } + + Widget _buildWeeklyRow(String label, int count, Color color, {bool isTotal = false}) { + return Row( + children: [ + Text( + label, + style: TextStyle( + fontSize: isTotal ? 14 : 13, + fontWeight: isTotal ? FontWeight.w600 : FontWeight.normal, + ), + ), + const Spacer(), + Text( + '$count 个', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ], + ); + } + + Widget _buildSectionTitle(String title) { + return Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A), + ), + ); + } +} + +/// 状态数据 +class _StatusData { + final String label; + final int count; + final Color color; + + _StatusData(this.label, this.count, this.color); +} + +/// 每日数据 +class _DailyData { + int movies = 0; + int books = 0; + int notes = 0; + + int get total => movies + books + notes; +} + +/// 每日趋势 +class _DailyTrend { + final DateTime date; + final int count; + + _DailyTrend(this.date, this.count); +} diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 1584e4f..99d36bc 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -5,6 +5,8 @@ import '../utils/book_dao.dart'; import '../utils/note_dao.dart'; import '../utils/movie_review_dao.dart'; import '../utils/movie_poster_dao.dart'; +import '../utils/book_review_dao.dart'; +import '../utils/book_excerpt_dao.dart'; /// 应用全局状态管理 class AppProvider extends ChangeNotifier { @@ -14,6 +16,8 @@ class AppProvider extends ChangeNotifier { final NoteDao _noteDao = NoteDao(); final MovieReviewDao _reviewDao = MovieReviewDao(); final MoviePosterDao _posterDao = MoviePosterDao(); + final BookReviewDao _bookReviewDao = BookReviewDao(); + final BookExcerptDao _bookExcerptDao = BookExcerptDao(); // 数据列表 List _movies = []; @@ -213,4 +217,129 @@ class AppProvider extends ChangeNotifier { Future getMoviePosterCount(String movieId) async { return await _posterDao.getPosterCount(movieId); } + + // ========== 书评相关方法 ========== + + /// 获取书籍的所有书评 + Future> getBookReviews(String bookId) async { + return await _bookReviewDao.getReviewsByBookId(bookId); + } + + /// 添加书评 + Future addBookReview(BookReview review) async { + await _bookReviewDao.insertReview(review); + } + + /// 更新书评 + Future updateBookReview(BookReview review) async { + await _bookReviewDao.updateReview(review); + } + + /// 删除书评 + Future removeBookReview(String id) async { + await _bookReviewDao.deleteReview(id); + } + + /// 获取书籍的书评数量 + Future getBookReviewCount(String bookId) async { + return await _bookReviewDao.getReviewCount(bookId); + } + + // ========== 摘抄相关方法 ========== + + /// 获取书籍的所有摘抄 + Future> getBookExcerpts(String bookId) async { + return await _bookExcerptDao.getExcerptsByBookId(bookId); + } + + /// 添加摘抄 + Future addBookExcerpt(BookExcerpt excerpt) async { + await _bookExcerptDao.insertExcerpt(excerpt); + } + + /// 更新摘抄 + Future updateBookExcerpt(BookExcerpt excerpt) async { + await _bookExcerptDao.updateExcerpt(excerpt); + } + + /// 删除摘抄 + Future removeBookExcerpt(String id) async { + await _bookExcerptDao.deleteExcerpt(id); + } + + /// 获取书籍的摘抄数量 + Future getBookExcerptCount(String bookId) async { + return await _bookExcerptDao.getExcerptCount(bookId); + } + + // ========== 回收站相关方法 ========== + + /// 获取已删除的影视 + Future> getDeletedMovies() async { + return await _movieDao.getDeletedMovies(); + } + + /// 恢复影视 + Future restoreMovie(String id) async { + await _movieDao.restoreMovie(id); + await loadMovies(); + } + + /// 彻底删除影视 + Future permanentDeleteMovie(String id) async { + await _movieDao.permanentDeleteMovie(id); + } + + /// 获取已删除的书籍 + Future> getDeletedBooks() async { + return await _bookDao.getDeletedBooks(); + } + + /// 恢复书籍 + Future restoreBook(String id) async { + await _bookDao.restoreBook(id); + await loadBooks(); + } + + /// 彻底删除书籍 + Future permanentDeleteBook(String id) async { + await _bookDao.permanentDeleteBook(id); + } + + /// 获取已删除的笔记 + Future> getDeletedNotes() async { + return await _noteDao.getDeletedNotes(); + } + + /// 恢复笔记 + Future restoreNote(String id) async { + await _noteDao.restoreNote(id); + await loadNotes(); + } + + /// 彻底删除笔记 + Future permanentDeleteNote(String id) async { + await _noteDao.permanentDeleteNote(id); + } + + /// 清空回收站 + Future clearRecycleBin() async { + final deletedMovies = await _movieDao.getDeletedMovies(); + final deletedBooks = await _bookDao.getDeletedBooks(); + final deletedNotes = await _noteDao.getDeletedNotes(); + + for (final movie in deletedMovies) { + await _movieDao.permanentDeleteMovie(movie.id); + } + for (final book in deletedBooks) { + await _bookDao.permanentDeleteBook(book.id); + } + for (final note in deletedNotes) { + await _noteDao.permanentDeleteNote(note.id); + } + + await loadMovies(); + await loadBooks(); + await loadNotes(); + } } diff --git a/lib/utils/app_router.dart b/lib/utils/app_router.dart index 40bf44c..eec4be9 100644 --- a/lib/utils/app_router.dart +++ b/lib/utils/app_router.dart @@ -12,15 +12,41 @@ class AppRouter { static Route generateRoute(RouteSettings settings) { switch (settings.name) { case '/movie-form': - final movie = settings.arguments as Movie?; + // 处理不同参数类型:Movie 对象或 Map(包含 initialStatus) + final args = settings.arguments; + Movie? movie; + String? initialStatus; + + if (args is Movie) { + movie = args; + } else if (args is Map) { + initialStatus = args['initialStatus'] as String?; + } + return MaterialPageRoute( - builder: (_) => MovieFormPage(movie: movie), + builder: (_) => MovieFormPage( + movie: movie, + initialStatus: initialStatus, + ), ); case '/book-form': - final book = settings.arguments as Book?; + // 处理不同参数类型:Book 对象或 Map(包含 initialStatus) + final args = settings.arguments; + Book? book; + String? initialStatus; + + if (args is Book) { + book = args; + } else if (args is Map) { + initialStatus = args['initialStatus'] as String?; + } + return MaterialPageRoute( - builder: (_) => BookFormPage(book: book), + builder: (_) => BookFormPage( + book: book, + initialStatus: initialStatus, + ), ); case '/note-form': diff --git a/lib/utils/backup_service.dart b/lib/utils/backup_service.dart new file mode 100644 index 0000000..0f9b4a2 --- /dev/null +++ b/lib/utils/backup_service.dart @@ -0,0 +1,440 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:archive/archive.dart'; +import 'package:archive/archive_io.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; +import 'package:share_plus/share_plus.dart'; +import 'package:cross_file/cross_file.dart'; +import 'database_helper.dart'; + +/// 数据备份服务 - 支持导出和导入数据(包含图片) +class BackupService { + static final BackupService instance = BackupService._init(); + + BackupService._init(); + + /// 导出所有数据和图片为 ZIP 文件,并选择保存路径 + Future exportDataWithImages() async { + try { + final db = await DatabaseHelper.instance.database; + + // 导出所有表的数据 + final movies = await db.query('movies'); + final books = await db.query('books'); + final notes = await db.query('notes'); + final movieReviews = await db.query('movie_reviews'); + final moviePosters = await db.query('movie_posters'); + + // 收集所有图片路径 + final imagePaths = {}; + + // 收集影视海报 + for (final movie in movies) { + final posterPath = movie['poster_path'] as String?; + if (posterPath != null && posterPath.isNotEmpty) { + imagePaths.add(posterPath); + } + } + + // 收集书籍封面 + for (final book in books) { + final coverPath = book['cover_path'] as String?; + if (coverPath != null && coverPath.isNotEmpty) { + imagePaths.add(coverPath); + } + } + + // 收集海报墙图片 + for (final poster in moviePosters) { + final posterPath = poster['poster_path'] as String?; + if (posterPath != null && posterPath.isNotEmpty) { + imagePaths.add(posterPath); + } + } + + // 构建备份数据 + final backupData = { + 'version': 2, + 'exportTime': DateTime.now().toIso8601String(), + 'appName': 'MookNote', + 'hasImages': true, + 'data': { + 'movies': movies, + 'books': books, + 'notes': notes, + 'movie_reviews': movieReviews, + 'movie_posters': moviePosters, + }, + }; + + // 创建 ZIP 文件 + final archive = Archive(); + + // 添加 JSON 数据 + final jsonString = const JsonEncoder.withIndent(' ').convert(backupData); + final jsonBytes = Uint8List.fromList(utf8.encode(jsonString)); + archive.addFile(ArchiveFile('data.json', jsonBytes.length, jsonBytes)); + + // 添加图片文件 + int imageCount = 0; + for (final imagePath in imagePaths) { + final file = File(imagePath); + if (await file.exists()) { + final bytes = await file.readAsBytes(); + final fileName = path.basename(imagePath); + // 使用相对路径存储图片 + archive.addFile(ArchiveFile('images/$fileName', bytes.length, bytes)); + imageCount++; + } + } + + // 压缩 ZIP + final zipEncoder = ZipEncoder(); + final zipBytes = zipEncoder.encode(archive); + if (zipBytes == null) { + return ExportResult.error('压缩备份文件失败'); + } + + // 保存到临时目录 + final tempDir = await getTemporaryDirectory(); + final fileName = 'mooknote_backup_${_formatDateTime(DateTime.now())}.zip'; + final tempFilePath = path.join(tempDir.path, fileName); + final tempFile = File(tempFilePath); + await tempFile.writeAsBytes(zipBytes); + + // 在移动端使用分享功能,让用户选择保存位置 + // 在桌面端可以尝试使用 saveFile + String? finalPath; + + try { + // 尝试使用系统保存对话框(桌面端支持) + final outputPath = await FilePicker.platform.saveFile( + dialogTitle: '保存备份文件', + fileName: fileName, + type: FileType.custom, + allowedExtensions: ['zip'], + bytes: Uint8List.fromList(zipBytes), // 在移动端需要提供 bytes + ); + + if (outputPath == null) { + // 用户取消,返回临时文件路径 + finalPath = tempFilePath; + } else { + finalPath = outputPath; + // 如果保存路径不是临时文件路径,需要复制过去 + if (finalPath != tempFilePath) { + final outputFile = File(finalPath); + await outputFile.writeAsBytes(zipBytes); + } + } + } catch (e) { + // 如果 saveFile 失败,使用临时文件路径 + finalPath = tempFilePath; + } + + return ExportResult.success( + filePath: finalPath, + movieCount: movies.length, + bookCount: books.length, + noteCount: notes.length, + imageCount: imageCount, + ); + } catch (e) { + return ExportResult.error('导出失败: $e'); + } + } + + /// 分享备份文件 + Future shareBackup(String filePath) async { + final file = XFile(filePath); + await Share.shareXFiles( + [file], + subject: 'MookNote 数据备份', + text: '这是我的 MookNote 数据备份文件', + ); + } + + /// 选择并导入备份文件(支持 ZIP 格式) + Future importData() async { + try { + // 选择文件 + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['zip', 'json'], + allowMultiple: false, + ); + + if (result == null || result.files.isEmpty) { + return ImportResult.cancelled(); + } + + final filePath = result.files.first.path; + if (filePath == null) { + return ImportResult.error('无法读取文件路径'); + } + + final file = File(filePath); + final extension = path.extension(filePath).toLowerCase(); + + Map backupData; + int imageCount = 0; + // 记录图片文件名到新路径的映射 + final imagePathMap = {}; + + if (extension == '.zip') { + // 处理 ZIP 文件 + final bytes = await file.readAsBytes(); + final archive = ZipDecoder().decodeBytes(bytes); + + // 查找 data.json + final dataFile = archive.findFile('data.json'); + if (dataFile == null) { + return ImportResult.error('备份文件中没有找到数据文件'); + } + + final jsonString = utf8.decode(dataFile.content as List); + backupData = jsonDecode(jsonString) as Map; + + // 解压图片到应用目录 + final appDir = await getApplicationDocumentsDirectory(); + final imagesDir = Directory(path.join(appDir.path, 'images')); + if (!await imagesDir.exists()) { + await imagesDir.create(recursive: true); + } + + for (final archiveFile in archive) { + if (archiveFile.name.startsWith('images/')) { + final fileName = path.basename(archiveFile.name); + final outputFile = File(path.join(imagesDir.path, fileName)); + await outputFile.writeAsBytes(archiveFile.content as List); + imagePathMap[fileName] = outputFile.path; + imageCount++; + } + } + } else { + // 处理旧版 JSON 文件 + final jsonString = await file.readAsString(); + backupData = jsonDecode(jsonString) as Map; + } + + // 验证备份格式 + if (!backupData.containsKey('data')) { + return ImportResult.error('无效的备份文件格式'); + } + + // 导入数据 + final data = backupData['data'] as Map; + final db = await DatabaseHelper.instance.database; + + // 开始事务 + await db.transaction((txn) async { + // 清空现有数据 + await txn.delete('movie_reviews'); + await txn.delete('movie_posters'); + await txn.delete('movies'); + await txn.delete('books'); + await txn.delete('notes'); + + // 导入影视数据(更新图片路径) + if (data.containsKey('movies')) { + final movies = data['movies'] as List; + for (final movie in movies) { + final movieMap = _convertToDbMap(movie); + final updatedMap = _updateImagePath(movieMap, 'poster_path', imagePathMap); + await txn.insert('movies', updatedMap); + } + } + + // 导入书籍数据(更新图片路径) + if (data.containsKey('books')) { + final books = data['books'] as List; + for (final book in books) { + final bookMap = _convertToDbMap(book); + final updatedMap = _updateImagePath(bookMap, 'cover_path', imagePathMap); + await txn.insert('books', updatedMap); + } + } + + // 导入笔记数据 + if (data.containsKey('notes')) { + final notes = data['notes'] as List; + for (final note in notes) { + await txn.insert('notes', _convertToDbMap(note)); + } + } + + // 导入影评数据 + if (data.containsKey('movie_reviews')) { + final reviews = data['movie_reviews'] as List; + for (final review in reviews) { + await txn.insert('movie_reviews', _convertToDbMap(review)); + } + } + + // 导入海报墙数据(更新图片路径) + if (data.containsKey('movie_posters')) { + final posters = data['movie_posters'] as List; + for (final poster in posters) { + final posterMap = _convertToDbMap(poster); + final updatedMap = _updateImagePath(posterMap, 'poster_path', imagePathMap); + await txn.insert('movie_posters', updatedMap); + } + } + }); + + // 统计导入数量 + final stats = {}; + if (data.containsKey('movies')) { + stats['影视'] = (data['movies'] as List).length; + } + if (data.containsKey('books')) { + stats['书籍'] = (data['books'] as List).length; + } + if (data.containsKey('notes')) { + stats['笔记'] = (data['notes'] as List).length; + } + if (data.containsKey('movie_reviews')) { + stats['影评'] = (data['movie_reviews'] as List).length; + } + if (data.containsKey('movie_posters')) { + stats['海报'] = (data['movie_posters'] as List).length; + } + if (imageCount > 0) { + stats['图片'] = imageCount; + } + + return ImportResult.success(stats); + } catch (e) { + return ImportResult.error('导入失败: $e'); + } + } + + /// 将动态类型转换为数据库可用的 Map + Map _convertToDbMap(dynamic item) { + if (item is Map) { + return item.map((key, value) { + // 处理布尔值 + if (value is bool) { + return MapEntry(key, value ? 1 : 0); + } + return MapEntry(key, value); + }); + } + return {}; + } + + /// 更新图片路径为新的路径 + Map _updateImagePath( + Map item, + String pathField, + Map imagePathMap, + ) { + final newItem = Map.from(item); + final oldPath = item[pathField] as String?; + + if (oldPath != null && oldPath.isNotEmpty) { + final fileName = path.basename(oldPath); + // 如果图片在映射中,更新路径 + if (imagePathMap.containsKey(fileName)) { + newItem[pathField] = imagePathMap[fileName]; + } + } + + return newItem; + } + + /// 格式化日期时间用于文件名 + String _formatDateTime(DateTime dateTime) { + return '${dateTime.year}${_pad(dateTime.month)}${_pad(dateTime.day)}_${_pad(dateTime.hour)}${_pad(dateTime.minute)}${_pad(dateTime.second)}'; + } + + String _pad(int number) { + return number.toString().padLeft(2, '0'); + } +} + +/// 导出结果 +class ExportResult { + final bool success; + final bool cancelled; + final String? errorMessage; + final String? filePath; + final int movieCount; + final int bookCount; + final int noteCount; + final int imageCount; + + ExportResult._({ + required this.success, + this.cancelled = false, + this.errorMessage, + this.filePath, + this.movieCount = 0, + this.bookCount = 0, + this.noteCount = 0, + this.imageCount = 0, + }); + + factory ExportResult.success({ + required String filePath, + required int movieCount, + required int bookCount, + required int noteCount, + required int imageCount, + }) { + return ExportResult._( + success: true, + filePath: filePath, + movieCount: movieCount, + bookCount: bookCount, + noteCount: noteCount, + imageCount: imageCount, + ); + } + + factory ExportResult.cancelled() { + return ExportResult._(success: false, cancelled: true); + } + + factory ExportResult.error(String message) { + return ExportResult._(success: false, errorMessage: message); + } +} + +/// 导入结果 +class ImportResult { + final bool success; + final bool cancelled; + final String? errorMessage; + final Map? stats; + + ImportResult._({ + required this.success, + this.cancelled = false, + this.errorMessage, + this.stats, + }); + + factory ImportResult.success(Map stats) { + return ImportResult._(success: true, stats: stats); + } + + factory ImportResult.cancelled() { + return ImportResult._(success: false, cancelled: true); + } + + factory ImportResult.error(String message) { + return ImportResult._(success: false, errorMessage: message); + } + + /// 获取统计信息文本 + String get statsText { + if (stats == null || stats!.isEmpty) { + return '没有导入任何数据'; + } + return stats!.entries.map((e) => '${e.key}: ${e.value}').join(','); + } +} diff --git a/lib/utils/book_dao.dart b/lib/utils/book_dao.dart index be02818..731c0fc 100644 --- a/lib/utils/book_dao.dart +++ b/lib/utils/book_dao.dart @@ -124,4 +124,40 @@ class BookDao { return List.generate(maps.length, (i) => Book.fromJson(maps[i])); } + + // ========== 回收站相关方法 ========== + + // 获取已删除的书籍 + Future> getDeletedBooks() async { + final db = await _dbHelper.database; + final List> 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 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 permanentDeleteBook(String id) async { + final db = await _dbHelper.database; + return await db.delete( + 'books', + where: 'id = ?', + whereArgs: [id], + ); + } } diff --git a/lib/utils/book_excerpt_dao.dart b/lib/utils/book_excerpt_dao.dart new file mode 100644 index 0000000..670cf62 --- /dev/null +++ b/lib/utils/book_excerpt_dao.dart @@ -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> 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 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 insertExcerpt(BookExcerpt excerpt) async { + final db = await _dbHelper.database; + await db.insert( + 'book_excerpts', + excerpt.toJson(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + return excerpt.id; + } + + /// 更新摘抄 + Future updateExcerpt(BookExcerpt excerpt) async { + final db = await _dbHelper.database; + await db.update( + 'book_excerpts', + excerpt.toJson(), + where: 'id = ?', + whereArgs: [excerpt.id], + ); + } + + /// 删除摘抄(软删除) + Future deleteExcerpt(String id) async { + final db = await _dbHelper.database; + await db.update( + 'book_excerpts', + {'is_deleted': 1}, + where: 'id = ?', + whereArgs: [id], + ); + } + + /// 彻底删除摘抄 + Future permanentDeleteExcerpt(String id) async { + final db = await _dbHelper.database; + await db.delete( + 'book_excerpts', + where: 'id = ?', + whereArgs: [id], + ); + } + + /// 获取摘抄数量 + Future 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> 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 restoreExcerpt(String id) async { + final db = await _dbHelper.database; + await db.update( + 'book_excerpts', + {'is_deleted': 0}, + where: 'id = ?', + whereArgs: [id], + ); + } +} diff --git a/lib/utils/book_review_dao.dart b/lib/utils/book_review_dao.dart new file mode 100644 index 0000000..1e4f269 --- /dev/null +++ b/lib/utils/book_review_dao.dart @@ -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> 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 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 insertReview(BookReview review) async { + final db = await _dbHelper.database; + await db.insert( + 'book_reviews', + review.toJson(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + return review.id; + } + + /// 更新书评 + Future updateReview(BookReview review) async { + final db = await _dbHelper.database; + await db.update( + 'book_reviews', + review.toJson(), + where: 'id = ?', + whereArgs: [review.id], + ); + } + + /// 删除书评(软删除) + Future deleteReview(String id) async { + final db = await _dbHelper.database; + await db.update( + 'book_reviews', + {'is_deleted': 1}, + where: 'id = ?', + whereArgs: [id], + ); + } + + /// 彻底删除书评 + Future permanentDeleteReview(String id) async { + final db = await _dbHelper.database; + await db.delete( + 'book_reviews', + where: 'id = ?', + whereArgs: [id], + ); + } + + /// 获取书评数量 + Future 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> 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 restoreReview(String id) async { + final db = await _dbHelper.database; + await db.update( + 'book_reviews', + {'is_deleted': 0}, + where: 'id = ?', + whereArgs: [id], + ); + } +} diff --git a/lib/utils/database_helper.dart b/lib/utils/database_helper.dart index ad3b420..9ac9805 100644 --- a/lib/utils/database_helper.dart +++ b/lib/utils/database_helper.dart @@ -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 _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 _createMoviePostersTable(Database db) async { await db.execute(''' @@ -78,6 +103,41 @@ class DatabaseHelper { ) '''); } + + /// 创建书评表 + Future _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 _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 _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) + ) + '''); } // 关闭数据库 diff --git a/lib/utils/movie_dao.dart b/lib/utils/movie_dao.dart index 8ea2432..d6e323c 100644 --- a/lib/utils/movie_dao.dart +++ b/lib/utils/movie_dao.dart @@ -177,4 +177,40 @@ class MovieDao { } return genres.toList()..sort(); } + + // ========== 回收站相关方法 ========== + + // 获取已删除的影视 + Future> getDeletedMovies() async { + final db = await _dbHelper.database; + final List> 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 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 permanentDeleteMovie(String id) async { + final db = await _dbHelper.database; + return await db.delete( + 'movies', + where: 'id = ?', + whereArgs: [id], + ); + } } diff --git a/lib/utils/note_dao.dart b/lib/utils/note_dao.dart index d5480ecd..89ff32b 100644 --- a/lib/utils/note_dao.dart +++ b/lib/utils/note_dao.dart @@ -6,11 +6,13 @@ import 'database_helper.dart'; class NoteDao { final DatabaseHelper _dbHelper = DatabaseHelper.instance; - // 获取所有笔记 + // 获取所有未删除的笔记 Future> getAllNotes() async { final db = await _dbHelper.database; final List> maps = await db.query( 'notes', + where: 'is_deleted = ?', + whereArgs: [0], orderBy: 'updated_at DESC', ); @@ -47,8 +49,45 @@ class NoteDao { ); } - // 删除笔记 + // 软删除笔记 Future 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> getDeletedNotes() async { + final db = await _dbHelper.database; + final List> 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 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 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> 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> maps = await db.query( 'notes', - where: 'tags LIKE ?', - whereArgs: ['%$tag%'], + where: 'tags LIKE ? AND is_deleted = ?', + whereArgs: ['%$tag%', 0], orderBy: 'updated_at DESC', ); diff --git a/lib/utils/toast_util.dart b/lib/utils/toast_util.dart new file mode 100644 index 0000000..4f22a17 --- /dev/null +++ b/lib/utils/toast_util.dart @@ -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; + }); + } +} diff --git a/lib/widgets/book_list_item.dart b/lib/widgets/book_list_item.dart index 741a8ba..9f382be 100644 --- a/lib/widgets/book_list_item.dart +++ b/lib/widgets/book_list_item.dart @@ -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().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)), - ), - ], - ), - ); - } } diff --git a/lib/widgets/bottom_nav_bar.dart b/lib/widgets/bottom_nav_bar.dart index 2e056fc..ac93d8c 100644 --- a/lib/widgets/bottom_nav_bar.dart +++ b/lib/widgets/bottom_nav_bar.dart @@ -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( diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index e78d519..60c3be8 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -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: [], ), ), diff --git a/lib/widgets/movie_list_item.dart b/lib/widgets/movie_list_item.dart index e670d97..0f36309 100644 --- a/lib/widgets/movie_list_item.dart +++ b/lib/widgets/movie_list_item.dart @@ -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().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)), - ), - ], - ), - ); - } } diff --git a/lib/widgets/movie_status_bar.dart b/lib/widgets/movie_status_bar.dart index 31ebbfc..e565e7a 100644 --- a/lib/widgets/movie_status_bar.dart +++ b/lib/widgets/movie_status_bar.dart @@ -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); diff --git a/pubspec.lock b/pubspec.lock index bdc3161..952c9a3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 0406263..832c934 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: