commit adad6372a3d1972923b0d32dff77a1fcec21cb8e Author: DelLevin-Home Date: Thu Mar 5 10:06:32 2026 +0800 基础功能 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eace3e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# VS Code +.vscode/ + +# Flutter/Dart/Pub +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Android +android/.gradle/ +android/captures/ +android/gradlew +android/gradlew.bat +android/local.properties +android/**/GeneratedPluginRegistrant.java +android/app/src/main/jniSrc/ + +# iOS/XCode +ios/Flutter/*.framework +ios/Flutter/Flutter.framework +ios/Flutter/Flutter.podspec +ios/Flutter/Generated.xcconfig +ios/Flutter/ephemeral +ios/Flutter/app.flx +ios/Flutter/app.zip +ios/Flutter/flutter_assets/ +ios/Flutter/flutter_export_environment.sh +ios/ServiceDefinitions.json +ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions +!android/gradle/wrapper/gradle-wrapper.jar +!ios/Flutter/Debug.xcconfig +!ios/Flutter/Release.xcconfig + +# Coverage +coverage/ + +# Temporary files +*.tmp +*.temp diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..56546de --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "48c32af0345e9ad5747f78ddce828c7f795f7159" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 + base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 + - platform: android + create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 + base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..22bc3a5 --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,251 @@ +# MookNote 快速入门指南 + +## 第一步:安装 Flutter 开发环境 + +### Windows 系统详细步骤 + +#### 1. 下载 Flutter SDK +访问:https://docs.flutter.dev/get-started/install/windows +- 点击 "Download Flutter SDK" +- 下载完成后解压到 `C:\src\flutter` + +#### 2. 添加环境变量 +``` +1. 右键"此电脑" → "属性" → "高级系统设置" +2. 点击"环境变量" +3. 在"系统变量"中找到 "Path",点击"编辑" +4. 点击"新建",添加:C:\src\flutter\bin +5. 确定保存 +``` + +#### 3. 验证安装 + +打开命令提示符(cmd)或 PowerShell: +```bash +flutter --version +flutter doctor +``` + +#### 4. 安装 Android Studio + +- 下载地址:https://developer.android.com/studio +- 安装完成后打开 Android Studio +- 配置 Android SDK: + - File → Settings → Appearance & Behavior → System Settings → Android SDK + - 选择 "SDK Platforms" 标签 + - 勾选 "Android 13.0 (API 33)" 或更高版本 + - 选择 "SDK Tools" 标签 + - 勾选 "Android SDK Build-Tools" 和 "Android Emulator" + - 点击 Apply 开始安装 + +#### 5. 接受 Android 许可证 +```bash +flutter doctor --android-licenses +``` +按提示输入 `y` 接受所有许可证 + +#### 6. 再次运行 flutter doctor +```bash +flutter doctor +``` +确保看到类似以下输出: +``` +[✓] Flutter (Channel stable, 3.x.x, on Microsoft Windows) +[✓] Android toolchain - develop for Android devices +[✓] Android Studio +[✓] Connected device +``` + +## 第二步:运行 MookNote 项目 + +### 1. 进入项目目录 +```bash +cd d:\UserData\Desktop\my_proj\mooknote +``` + +### 2. 安装依赖包 +```bash +flutter pub get +``` + +### 3. 准备设备 +**选项 A:使用真机** +``` +1. 手机开启"开发者选项" + - 设置 → 关于手机 → 连续点击"版本号"7 次 +2. 开启"USB 调试" + - 设置 → 开发者选项 → USB 调试 +3. 用 USB 连接电脑 +4. 手机上允许 USB 调试授权 +``` + +**选项 B:使用模拟器** +``` +1. Android Studio → Tools → Device Manager +2. Click "Create device" +3. 选择设备型号(如 Pixel 6) +4. 下载并选择系统镜像(推荐 API 33) +5. 完成创建后点击启动按钮 +``` + +### 4. 运行应用 +```bash +flutter run +``` + +或者在 Android Studio 中: +- File → Open → 选择 mooknote 文件夹 +- 点击顶部工具栏的运行按钮(绿色三角形) + +## 第三步:理解代码结构 + +### 核心文件说明 + +#### 1. 入口文件 - `lib/main.dart` +```dart +void main() { + runApp(const MyApp()); +} +``` +应用的起点,配置主题和路由 + +#### 2. 主页面 - `lib/pages/home_page.dart` +包含: +- 顶部 AppBar(菜单按钮 + 标题) +- 三个标签页切换(观影/阅读/笔记) +- 主体内容区域 + +#### 3. 状态管理 - `lib/providers/app_provider.dart` +管理全局状态: +- 当前选中的标签页 +- 当前选中的状态筛选 +- 侧边菜单开关 + +#### 4. 数据模型 - `lib/models/data_models.dart` +定义数据结构: +- `Movie` - 电影数据 +- `Book` - 书籍数据 +- `Note` - 笔记数据 + +### Widget 组件层次 +``` +MyApp +└── HomePage (主页面) + ├── CustomDrawer (侧边菜单) + │ └── 热力图组件 + ├── AppBar (顶部栏) + ├── TabBar (标签栏) + │ ├── 观影 + │ ├── 阅读 + │ └── 笔记 + ├── TabContent (标签内容) + │ ├── MovieTabPage + │ │ ├── MovieStatusBar (状态栏) + │ │ └── MovieListItem (列表项) + │ ├── BookTabPage + │ │ ├── BookStatusBar (状态栏) + │ │ └── BookListItem (列表项) + │ └── NoteTabPage + │ └── NoteListItem (列表项) + └── CustomBottomNavBar (底部导航) +``` + +## 第四步:修改和定制 + +### 修改应用名称 +编辑 `pubspec.yaml`: +```yaml +name: mooknote # 包名 +``` + +编辑 `android/app/src/main/AndroidManifest.xml`: +```xml +android:label="MookNote" +``` + +### 修改主题颜色 +编辑 `lib/utils/app_theme.dart`: +```dart +static const Color primaryColor = Color(0xFF6200EE); // 主色调 +``` + +### 添加示例数据 +编辑对应的 page 文件,例如 `lib/pages/movie_tab_page.dart`: +```dart +List _getSampleMovies(int statusIndex) { + // 在这里添加你的电影数据 +} +``` + +## 第五步:构建 APK + +### 开发版本 +```bash +flutter build apk --debug +``` + +### 发布版本 +```bash +flutter build apk --release +``` + +生成的 APK 位置: +``` +build/app/outputs/flutter-apk/app-release.apk +``` + +## 常见问题解决 + +### Q1: flutter pub get 失败 +```bash +# 清除缓存 +flutter clean +flutter pub cache repair +flutter pub get +``` + +### Q2: 无法识别设备 +```bash +# 查看设备列表 +flutter devices + +# 如果没有设备,检查: +# 1. USB 线是否连接好 +# 2. 手机 USB 调试是否开启 +# 3. 是否安装了手机驱动 +``` + +### Q3: 热重载不工作 +按 `R` 进行热重载,按 `r` 完全重启 +如果都不行,停止应用重新运行 + +### Q4: 中文乱码 +确保所有文件使用 UTF-8 编码 + +## 下一步学习建议 + +### 1. 学习 Dart 基础语法(1-2 天) +- 变量和类型 +- 函数 +- 类和对象 +- 异步编程(async/await) + +### 2. 学习 Flutter 基础(3-5 天) +- Widget 概念 +- StatelessWidget vs StatefulWidget +- 布局组件(Row, Column, Container 等) +- 列表(ListView) + +### 3. 实践项目功能 +- 实现添加功能表单 +- 集成 SQLite 数据库 +- 实现搜索功能 +- 完善详情页 + +### 4. 参考资源 +- [Dart 语言中文文档](https://dart.cn/) +- [Flutter 实战电子书](https://book.flutterchina.club/) +- [B 站 Flutter 教程](https://search.bilibili.com/all?keyword=flutter 教程) + +--- +祝你开发顺利!有问题随时询问。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f29529 --- /dev/null +++ b/README.md @@ -0,0 +1,219 @@ +# MookNote - Flutter 观影阅读笔记应用 + +## 项目简介 +MookNote 是一款用于记录观影、阅读和笔记的 Android 应用,使用 Flutter 框架开发。 + +## 功能特性 + +### 主界面 +- **顶部导航栏**: 左侧菜单按钮 + 标题 + 搜索按钮 +- **三个标签页**: 观影、阅读、笔记 +- **底部导航栏**: 主页、新增、我的 + +### 观影模块 +- 三个状态筛选:已看、想看、在看 +- 显示影片标题、年份、评分、观看日期、笔记 +- 支持添加和管理观影记录 + +### 阅读模块 +- 三个状态筛选:读完、在读、准备读 +- 显示书籍标题、作者、评分、阅读日期、笔记 +- 支持添加和管理书籍记录 + +### 笔记模块 +- 显示所有笔记列表 +- 支持标签分类 +- 显示笔记内容和更新时间 + +### 侧边菜单 +- GitHub 风格热力图(年度记录可视化) +- 统计功能入口 +- 回收站入口 +- 设置入口 + +## 环境要求 + +### 必需软件 +1. **Flutter SDK** (建议 3.5.0+) + - 下载地址:https://docs.flutter.dev/get-started/install + +2. **Android Studio** + - 下载地址:https://developer.android.com/studio + +3. **Android SDK** (通过 Android Studio 安装) + +### 环境配置步骤 + +#### Windows 系统 + +1. **下载并安装 Flutter SDK** + ``` + - 下载 Flutter SDK zip 文件 + - 解压到 C:\src\flutter(或其他非系统目录) + - 将 C:\src\flutter\bin 添加到系统环境变量 Path + ``` + +2. **验证 Flutter 安装** + ```bash + flutter --version + flutter doctor + ``` + +3. **安装 Android Studio** + ``` + - 下载并安装 Android Studio + - 打开 Android Studio → Settings → Appearance & Behavior → System Settings → Android SDK + - 安装 Android SDK Platform(建议 API 33+) + - 安装 Android SDK Build-Tools + - 安装 Android Emulator(可选,用于模拟器测试) + ``` + +4. **接受 Android 许可证** + + ```bash + flutter doctor --android-licenses + ``` + + + +```bash +$env:HTTP_PROXY="http://127.0.0.1:10808" +$env:HTTPS_PROXY="http://127.0.0.1:10808" +flutter create --platforms android . +``` + + + + + +## 运行项目 + +### 1. 克隆/复制项目后,进入项目目录 +```bash +cd mooknote +``` + +### 2. 安装依赖 + +```bash +flutter pub get +``` + +### 3. 连接设备或启动模拟器 +- 连接 Android 真机(需开启 USB 调试) +- 或启动 Android 模拟器 + +### 4. 运行应用 +```bash +flutter run +``` + +或使用 Android Studio: +- 打开项目 +- 点击运行按钮 (Run) + +## 项目结构 + +``` +mooknote/ +├── android/ # Android 平台配置 +│ └── app/ +│ └── src/main/ +│ ├── AndroidManifest.xml +│ └── ... +├── lib/ # Dart 源代码 +│ ├── main.dart # 应用入口 +│ ├── pages/ # 页面组件 +│ │ ├── home_page.dart # 主页面 +│ │ ├── movie_tab_page.dart # 观影标签页 +│ │ ├── book_tab_page.dart # 阅读标签页 +│ │ └── note_tab_page.dart # 笔记标签页 +│ ├── widgets/ # 可复用组件 +│ │ ├── custom_drawer.dart # 侧边菜单 +│ │ ├── bottom_nav_bar.dart # 底部导航 +│ │ ├── movie_status_bar.dart # 观影状态栏 +│ │ ├── movie_list_item.dart # 观影列表项 +│ │ ├── book_status_bar.dart # 书籍状态栏 +│ │ ├── book_list_item.dart # 书籍列表项 +│ │ └── note_list_item.dart # 笔记列表项 +│ ├── models/ # 数据模型 +│ │ └── data_models.dart # Movie, Book, Note 模型 +│ ├── providers/ # 状态管理 +│ │ └── app_provider.dart # 全局状态管理 +│ └── utils/ # 工具类 +│ └── app_theme.dart # 主题配置 +├── assets/ # 资源文件 +│ └── images/ # 图片资源 +├── fonts/ # 字体文件 +├── pubspec.yaml # 项目配置文件 +└── README.md # 本文件 +``` + +## 技术栈 + +- **Flutter**: 跨平台移动应用框架 +- **Dart**: 编程语言 +- **Provider**: 状态管理 +- **Material Design 3**: UI 设计规范 + +## 后续开发计划 + +### 数据存储 + +- [x] 集成 SQLite 数据库(sqflite 包) +- [x] 实现本地数据持久化 +- [ ] 数据备份与恢复 + +### 功能增强 + +- [x] 添加/编辑表单页面 +- [ ] 详情页完整实现 +- [ ] 搜索功能 +- [ ] 统计图表页面 +- [ ] 回收站功能 +- [ ] 设置页面 + +### 用户体验 +- [ ] 加载动画 +- [ ] 下拉刷新 +- [ ] 无限滚动加载 +- [ ] 深色模式优化 + +### 高级功能 + +- [ ] 导出功能(JSON/CSV) +- [ ] 云同步 +- [ ] 桌面小组件 + +## 常见问题 + +### Q: flutter doctor 显示 Android license status unknown +A: 运行 `flutter doctor --android-licenses` 接受许可证 + +### Q: 模拟器无法启动 +A: 确保在 BIOS 中启用了虚拟化技术(VT-x/AMD-V) + +### Q: 热重载不生效 +A: 某些代码更改需要完全重启应用(如 main() 函数修改) + +## 开发建议 + +### 对于有 Vue 经验的你 +Flutter 的很多概念与 Vue 相似: +- **Widget** ≈ Vue 组件 +- **StatefulWidget** ≈ 带数据的 Vue 组件 +- **setState()** ≈ this.$forceUpdate() + 数据更新 +- **Provider** ≈ Vuex/Pinia +- **pubspec.yaml** ≈ package.json + +### 学习资源 +- [Flutter 官方文档](https://docs.flutter.dev/) +- [Dart 语言指南](https://dart.dev/guides) +- [Flutter 中文社区](https://flutterchina.club/) + +## 许可证 +MIT License + +--- +**作者**: 你的昵称 +**创建时间**: 2024-03-04 diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..9bfb05c --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.mooknote" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.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 + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..65c2f0b --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/mooknote/MainActivity.kt b/android/app/src/main/kotlin/com/example/mooknote/MainActivity.kt new file mode 100644 index 0000000..73963df --- /dev/null +++ b/android/app/src/main/kotlin/com/example/mooknote/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.mooknote + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..214e866 --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..90c683f --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +systemProp.http.proxyHost=127.0.0.1 +systemProp.http.proxyPort=10808 +systemProp.https.proxyHost=127.0.0.1 +systemProp.https.proxyPort=10808 \ No newline at end of file diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..3012406 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'pages/home_page.dart'; +import 'utils/app_theme.dart'; +import 'utils/app_router.dart'; +import 'providers/app_provider.dart'; + +void main() async { + // 确保 Flutter 绑定初始化完成 + WidgetsFlutterBinding.ensureInitialized(); + + // 初始化数据库 + final appProvider = AppProvider(); + await appProvider.initDatabase(); + + runApp(MyApp(appProvider: appProvider)); +} + +class MyApp extends StatelessWidget { + final AppProvider appProvider; + + const MyApp({super.key, required this.appProvider}); + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: appProvider), + ], + child: MaterialApp( + title: 'MookNote', + debugShowCheckedModeBanner: false, + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: ThemeMode.system, + home: const HomePage(), + onGenerateRoute: AppRouter.generateRoute, + ), + ); + } +} diff --git a/lib/models/data_models.dart b/lib/models/data_models.dart new file mode 100644 index 0000000..271610f --- /dev/null +++ b/lib/models/data_models.dart @@ -0,0 +1,148 @@ +/// 影视条目模型 +class Movie { + final String id; + final String title; + final String? poster; + final double? rating; + final int? year; + final String status; // 'watched', 'want_to_watch', 'watching' + final DateTime? watchDate; + final String? note; + + Movie({ + required this.id, + required this.title, + this.poster, + this.rating, + this.year, + required this.status, + this.watchDate, + this.note, + }); + + factory Movie.fromJson(Map json) { + return Movie( + id: json['id'] ?? '', + title: json['title'] ?? '', + poster: json['poster'], + rating: json['rating']?.toDouble(), + year: json['year'], + status: json['status'] ?? 'want_to_watch', + watchDate: json['watch_date'] != null + ? DateTime.parse(json['watch_date']) + : null, + note: json['note'], + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'poster': poster, + 'rating': rating, + 'year': year, + 'status': status, + 'watch_date': watchDate?.toIso8601String(), + 'note': note, + }; + } +} + +/// 书籍条目模型 +class Book { + final String id; + final String title; + final String? author; + final String? cover; + final double? rating; + final String status; // 'read', 'reading', 'want_to_read' + final DateTime? readDate; + final String? note; + + Book({ + required this.id, + required this.title, + this.author, + this.cover, + this.rating, + required this.status, + this.readDate, + this.note, + }); + + factory Book.fromJson(Map json) { + return Book( + id: json['id'] ?? '', + title: json['title'] ?? '', + author: json['author'], + cover: json['cover'], + rating: json['rating']?.toDouble(), + status: json['status'] ?? 'want_to_read', + readDate: json['read_date'] != null + ? DateTime.parse(json['read_date']) + : null, + note: json['note'], + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'author': author, + 'cover': cover, + 'rating': rating, + 'status': status, + 'read_date': readDate?.toIso8601String(), + 'note': note, + }; + } +} + +/// 笔记模型 +class Note { + final String id; + final String title; + final String content; + final List tags; + final DateTime createdAt; + final DateTime updatedAt; + + Note({ + required this.id, + required this.title, + required this.content, + this.tags = const [], + required this.createdAt, + required this.updatedAt, + }); + + factory Note.fromJson(Map json) { + return Note( + id: json['id'] ?? '', + title: json['title'] ?? '', + content: json['content'] ?? '', + tags: json['tags'] != null + ? List.from(json['tags']) + : [], + 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, + 'title': title, + 'content': content, + 'tags': tags, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + } +} diff --git a/lib/models/data_models_extension.dart b/lib/models/data_models_extension.dart new file mode 100644 index 0000000..0b131a6 --- /dev/null +++ b/lib/models/data_models_extension.dart @@ -0,0 +1,78 @@ +/// 数据模型扩展 - 添加 copyWith 方法以便更新数据 +library; + +import 'data_models.dart'; + +/// Movie 扩展 - 添加 copyWith 方法 +extension MovieExtension on Movie { + /// 创建副本并允许修改部分属性 + Movie copyWith({ + String? id, + String? title, + String? poster, + double? rating, + int? year, + String? status, + DateTime? watchDate, + String? note, + }) { + return Movie( + id: id ?? this.id, + title: title ?? this.title, + poster: poster ?? this.poster, + rating: rating ?? this.rating, + year: year ?? this.year, + status: status ?? this.status, + watchDate: watchDate ?? this.watchDate, + note: note ?? this.note, + ); + } +} + +/// Book 扩展 - 添加 copyWith 方法 +extension BookExtension on Book { + /// 创建副本并允许修改部分属性 + Book copyWith({ + String? id, + String? title, + String? author, + String? cover, + double? rating, + String? status, + DateTime? readDate, + String? note, + }) { + return Book( + id: id ?? this.id, + title: title ?? this.title, + author: author ?? this.author, + cover: cover ?? this.cover, + rating: rating ?? this.rating, + status: status ?? this.status, + readDate: readDate ?? this.readDate, + note: note ?? this.note, + ); + } +} + +/// Note 扩展 - 添加 copyWith 方法 +extension NoteExtension on Note { + /// 创建副本并允许修改部分属性 + Note copyWith({ + String? id, + String? title, + String? content, + List? tags, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return Note( + id: id ?? this.id, + title: title ?? this.title, + content: content ?? this.content, + tags: tags ?? this.tags, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} diff --git a/lib/pages/book_detail_page.dart b/lib/pages/book_detail_page.dart new file mode 100644 index 0000000..3c21af4 --- /dev/null +++ b/lib/pages/book_detail_page.dart @@ -0,0 +1,293 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; +import '../utils/app_theme.dart'; + +/// 书籍详情页 +class BookDetailPage extends StatefulWidget { + final Book book; + + const BookDetailPage({super.key, required this.book}); + + @override + State createState() => _BookDetailPageState(); +} + +class _BookDetailPageState extends State { + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.book.title), + actions: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () => _navigateToEdit(context), + ), + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () => _showDeleteDialog(context), + ), + ], + ), + body: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 封面区域 + _buildCoverSection(context), + + // 基本信息 + _buildInfoSection(context), + + // 笔记区域 + if (widget.book.note != null && widget.book.note!.isNotEmpty) + _buildNoteSection(context), + ], + ), + ), + ); + } + + /// 构建封面区域 + Widget _buildCoverSection(BuildContext context) { + return Container( + width: double.infinity, + height: 250, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Theme.of(context).colorScheme.primaryContainer, + Theme.of(context).colorScheme.surface, + ], + ), + ), + child: Stack( + children: [ + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.menu_book, + size: 80, + color: Theme.of(context).colorScheme.primary.withOpacity(0.6), + ), + const SizedBox(height: 16), + Text( + widget.book.title, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Positioned( + top: 16, + right: 16, + child: _buildStatusTag(context), + ), + ], + ), + ); + } + + /// 构建状态标签 + Widget _buildStatusTag(BuildContext context) { + Color statusColor; + String statusText; + + switch (widget.book.status) { + case 'read': + statusColor = AppTheme.readColor; + statusText = '读完'; + break; + case 'reading': + statusColor = AppTheme.readingColor; + statusText = '在读'; + break; + case 'want_to_read': + statusColor = AppTheme.wantToReadColor; + statusText = '准备读'; + break; + default: + statusColor = Colors.grey; + statusText = '未知'; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.9), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + statusText, + style: const TextStyle( + fontSize: 14, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + /// 构建信息区域 + Widget _buildInfoSection(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 作者 + if (widget.book.author != null && widget.book.author!.isNotEmpty) ...[ + _buildInfoItem( + context, + icon: Icons.person, + label: widget.book.author!, + ), + const SizedBox(height: 12), + ], + + // 评分 + if (widget.book.rating != null) ...[ + _buildInfoItem( + context, + icon: Icons.star, + label: widget.book.rating.toString(), + iconColor: Colors.amber[700], + textColor: Colors.amber[700], + ), + const SizedBox(height: 12), + ], + + // 阅读日期 + if (widget.book.readDate != null) + _buildInfoItem( + context, + icon: Icons.event, + label: '阅读日期:${_formatDate(widget.book.readDate!)}', + ), + ], + ), + ); + } + + /// 构建信息项 + Widget _buildInfoItem( + BuildContext context, { + required IconData icon, + required String label, + Color? iconColor, + Color? textColor, + }) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 18, color: iconColor), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: 14, + color: textColor ?? Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ); + } + + /// 构建笔记区域 + Widget _buildNoteSection(BuildContext context) { + return Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.edit_note, + size: 20, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + '笔记', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + widget.book.note!, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ); + } + + /// 格式化日期 + String _formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + + /// 跳转到编辑页面 + void _navigateToEdit(BuildContext context) { + Navigator.pushNamed(context, '/book-form', arguments: widget.book).then((_) { + context.read().loadBooks(); + }); + } + + /// 显示删除对话框 + void _showDeleteDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('确认删除'), + content: Text('确定要删除"${widget.book.title}"吗?此操作不可恢复。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + TextButton( + onPressed: () async { + await context.read().removeBook(widget.book.id); + if (!mounted) return; + Navigator.pop(context); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('已删除'), + behavior: SnackBarBehavior.floating, + ), + ); + }, + child: const Text( + '删除', + style: TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/book_form_page.dart b/lib/pages/book_form_page.dart new file mode 100644 index 0000000..4169605 --- /dev/null +++ b/lib/pages/book_form_page.dart @@ -0,0 +1,287 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; + +/// 添加/编辑书籍记录页面 +class BookFormPage extends StatefulWidget { + final Book? book; // 如果为 null,则是添加模式;否则是编辑模式 + + const BookFormPage({super.key, this.book}); + + @override + State createState() => _BookFormPageState(); +} + +class _BookFormPageState extends State { + final _formKey = GlobalKey(); + late TextEditingController _titleController; + late TextEditingController _authorController; + late TextEditingController _ratingController; + late TextEditingController _noteController; + late String _status; + DateTime? _readDate; + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(text: widget.book?.title ?? ''); + _authorController = TextEditingController(text: widget.book?.author ?? ''); + _ratingController = TextEditingController(text: widget.book?.rating?.toString() ?? ''); + _noteController = TextEditingController(text: widget.book?.note ?? ''); + _status = widget.book?.status ?? 'want_to_read'; + _readDate = widget.book?.readDate; + } + + @override + void dispose() { + _titleController.dispose(); + _authorController.dispose(); + _ratingController.dispose(); + _noteController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isEdit = widget.book != null; + + return Scaffold( + appBar: AppBar( + title: Text(isEdit ? '编辑书籍' : '添加书籍'), + actions: [ + IconButton( + icon: const Icon(Icons.save), + onPressed: _saveBook, + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题 + TextFormField( + controller: _titleController, + decoration: const InputDecoration( + labelText: '书名 *', + hintText: '请输入书名', + prefixIcon: Icon(Icons.menu_book), + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入书名'; + } + return null; + }, + ), + + const SizedBox(height: 16), + + // 作者 + TextFormField( + controller: _authorController, + decoration: const InputDecoration( + labelText: '作者', + hintText: '请输入作者', + prefixIcon: Icon(Icons.person), + border: OutlineInputBorder(), + ), + ), + + const SizedBox(height: 16), + + // 年份和评分 + Row( + children: [ + Expanded( + child: TextFormField( + controller: _ratingController, + decoration: const InputDecoration( + labelText: '评分', + hintText: '0-10', + prefixIcon: Icon(Icons.star), + border: OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + validator: (value) { + if (value != null && value.isNotEmpty) { + final rating = double.tryParse(value); + if (rating == null || rating < 0 || rating > 10) { + return '评分必须在 0-10 之间'; + } + } + return null; + }, + ), + ), + ], + ), + + const SizedBox(height: 16), + + // 状态选择 + DropdownButtonFormField( + value: _status, + decoration: const InputDecoration( + labelText: '状态', + prefixIcon: Icon(Icons.check_circle_outline), + border: OutlineInputBorder(), + ), + items: const [ + DropdownMenuItem(value: 'read', child: Text('读完')), + DropdownMenuItem(value: 'reading', child: Text('在读')), + DropdownMenuItem(value: 'want_to_read', child: Text('准备读')), + ], + onChanged: (value) { + setState(() { + _status = value!; + }); + }, + ), + + const SizedBox(height: 16), + + // 阅读日期选择 + InkWell( + onTap: _selectReadDate, + child: InputDecorator( + decoration: const InputDecoration( + labelText: '阅读日期', + prefixIcon: Icon(Icons.event), + border: OutlineInputBorder(), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _readDate != null + ? '${_readDate!.year}-${_readDate!.month.toString().padLeft(2, '0')}-${_readDate!.day.toString().padLeft(2, '0')}' + : '选择日期', + style: TextStyle( + color: _readDate != null + ? Theme.of(context).colorScheme.onSurface + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + if (_readDate != null) + IconButton( + icon: const Icon(Icons.clear, size: 20), + onPressed: () { + setState(() { + _readDate = null; + }); + }, + ), + ], + ), + ), + ), + + const SizedBox(height: 16), + + // 笔记 + TextFormField( + controller: _noteController, + decoration: const InputDecoration( + labelText: '笔记', + hintText: '写下你的读后感...', + prefixIcon: Icon(Icons.edit_note), + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + maxLines: 5, + ), + + const SizedBox(height: 32), + + // 保存按钮 + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton.icon( + onPressed: _saveBook, + icon: const Icon(Icons.save), + label: Text(isEdit ? '保存修改' : '添加记录'), + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + /// 选择阅读日期 + Future _selectReadDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _readDate ?? DateTime.now(), + firstDate: DateTime(1900), + lastDate: DateTime.now(), + ); + + if (picked != null) { + setState(() { + _readDate = picked; + }); + } + } + + /// 保存书籍记录 + Future _saveBook() async { + if (!_formKey.currentState!.validate()) { + return; + } + + final rating = _ratingController.text.isNotEmpty ? double.tryParse(_ratingController.text) : null; + + if (widget.book == null) { + // 添加新模式 + final newBook = Book( + id: DateTime.now().millisecondsSinceEpoch.toString(), + title: _titleController.text.trim(), + author: _authorController.text.trim(), + rating: rating, + status: _status, + readDate: _readDate, + note: _noteController.text.trim(), + ); + + await context.read().addBook(newBook); + } else { + // 编辑现有模式 + final updatedBook = Book( + id: widget.book!.id, + title: _titleController.text.trim(), + author: _authorController.text.trim(), + rating: rating, + status: _status, + readDate: _readDate, + note: _noteController.text.trim(), + ); + + await context.read().updateBook(updatedBook); + } + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(widget.book == null ? '添加成功' : '更新成功'), + behavior: SnackBarBehavior.floating, + ), + ); + + Navigator.pop(context); + } +} diff --git a/lib/pages/book_tab_page.dart b/lib/pages/book_tab_page.dart new file mode 100644 index 0000000..c160b11 --- /dev/null +++ b/lib/pages/book_tab_page.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; +import '../widgets/book_status_bar.dart'; +import '../widgets/book_list_item.dart'; + +/// 阅读标签页 +class BookTabPage extends StatelessWidget { + const BookTabPage({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // 状态选择栏(读完、在读、准备读) + const BookStatusBar(), + + // 书籍列表 + Expanded( + child: _buildBookList(context), + ), + ], + ); + } + + /// 构建书籍列表 + Widget _buildBookList(BuildContext context) { + return Consumer( + builder: (context, provider, child) { + // 根据状态筛选书籍 + final statusMap = { + 0: 'read', + 1: 'reading', + 2: 'want_to_read', + }; + final currentStatus = statusMap[provider.bookStatusIndex]!; + final books = provider.getBooksByStatus(currentStatus); + + if (books.isEmpty) { + return _buildEmptyState(context, provider.bookStatusIndex); + } + + return RefreshIndicator( + onRefresh: () async => await provider.loadBooks(), + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: books.length, + itemBuilder: (context, index) { + return BookListItem(book: books[index]); + }, + ), + ); + }, + ); + } + + /// 构建空状态提示 + Widget _buildEmptyState(BuildContext context, int statusIndex) { + final statusText = ['读完', '在读', '准备读'][statusIndex]; + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.menu_book_outlined, + size: 80, + color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.3), + ), + const SizedBox(height: 16), + Text( + '暂无$statusText的书籍', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + icon: const Icon(Icons.add), + label: const Text('添加记录'), + onPressed: () { + Navigator.pushNamed(context, '/book-form'); + }, + ), + ], + ), + ); + } + + /// 获取示例书籍数据 + List _getSampleBooks(int statusIndex) { + final statusMap = ['read', 'reading', 'want_to_read']; + final currentStatus = statusMap[statusIndex]; + + // 示例数据(实际应从数据库获取) + final allBooks = [ + Book( + id: '1', + title: '活着', + author: '余华', + rating: 9.2, + status: 'read', + readDate: DateTime(2024, 1, 20), + note: '非常感人的故事,让人思考生命的意义', + ), + Book( + id: '2', + title: '百年孤独', + author: '加西亚·马尔克斯', + rating: 9.3, + status: 'read', + readDate: DateTime(2024, 2, 15), + ), + Book( + id: '3', + title: '人类简史', + author: '尤瓦尔·赫拉利', + rating: 9.0, + status: 'reading', + ), + Book( + id: '4', + title: '三体', + author: '刘慈欣', + rating: 9.5, + status: 'want_to_read', + ), + Book( + id: '5', + title: '追风筝的人', + author: '卡勒德·胡赛尼', + rating: 8.9, + status: 'read', + readDate: DateTime(2024, 3, 5), + note: '关于救赎与成长的故事', + ), + ]; + + return allBooks.where((b) => b.status == currentStatus).toList(); + } +} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart new file mode 100644 index 0000000..3e5dca2 --- /dev/null +++ b/lib/pages/home_page.dart @@ -0,0 +1,247 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../widgets/custom_drawer.dart'; +import '../widgets/bottom_nav_bar.dart'; +import 'movie_tab_page.dart'; +import 'book_tab_page.dart'; +import 'note_tab_page.dart'; + +/// 主页 - 包含顶部菜单、三个标签页、底部导航 +class HomePage extends StatefulWidget { + const HomePage({super.key}); + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + // 左侧弹出菜单 + drawer: const CustomDrawer(), + + // 主体内容 + body: Column( + children: [ + // 顶部 AppBar + _buildAppBar(), + + // 三个标签页的标题栏 + _buildTabBar(), + + // 标签页内容 + Expanded( + child: _buildTabContent(), + ), + ], + ), + + // 底部导航栏 + bottomNavigationBar: const CustomBottomNavBar(), + ); + } + + /// 构建顶部 AppBar + Widget _buildAppBar() { + return Consumer( + builder: (context, provider, child) { + return AppBar( + title: Text(_getAppBarTitle(provider)), + actions: [ + // 添加按钮 + IconButton( + icon: const Icon(Icons.add), + onPressed: () => _showAddDialog(context, provider), + ), + IconButton( + icon: const Icon(Icons.search), + onPressed: () { + // TODO: 搜索功能 + }, + ), + ], + ); + }, + ); + } + + /// 获取 AppBar 标题 + String _getAppBarTitle(AppProvider provider) { + switch (provider.mainTabIndex) { + case 0: + return '观影'; + case 1: + return '阅读'; + case 2: + return '笔记'; + default: + return 'MookNote'; + } + } + + /// 构建标签栏 + Widget _buildTabBar() { + return Consumer( + builder: (context, provider, child) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + // 观影 + _buildTabItem( + context, + '观影', + Icons.movie, + 0, + provider.mainTabIndex, + () => provider.setMainTabIndex(0), + ), + + // 阅读 + _buildTabItem( + context, + '阅读', + Icons.menu_book, + 1, + provider.mainTabIndex, + () => provider.setMainTabIndex(1), + ), + + // 笔记 + _buildTabItem( + context, + '笔记', + Icons.note, + 2, + provider.mainTabIndex, + () => provider.setMainTabIndex(2), + ), + ], + ), + ); + }, + ); + } + + /// 构建单个标签项 + Widget _buildTabItem( + BuildContext context, + String label, + IconData icon, + int index, + int currentIndex, + VoidCallback onTap, + ) { + final isSelected = index == currentIndex; + final colorScheme = Theme.of(context).colorScheme; + + return Expanded( + child: InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + color: isSelected ? colorScheme.primary : colorScheme.onSurfaceVariant, + size: 24, + ), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected ? colorScheme.primary : colorScheme.onSurfaceVariant, + ), + ), + if (isSelected) + Container( + margin: const EdgeInsets.only(top: 8), + width: 32, + height: 3, + decoration: BoxDecoration( + color: colorScheme.primary, + borderRadius: BorderRadius.circular(2), + ), + ), + ], + ), + ), + ), + ); + } + + /// 构建标签页内容 + Widget _buildTabContent() { + return Consumer( + builder: (context, provider, child) { + switch (provider.mainTabIndex) { + case 0: + return const MovieTabPage(); + case 1: + return const BookTabPage(); + case 2: + return const NoteTabPage(); + default: + return const MovieTabPage(); + } + }, + ); + } + + /// 显示添加对话框 + void _showAddDialog(BuildContext context, AppProvider provider) { + showModalBottomSheet( + context: context, + builder: (BuildContext context) { + return SafeArea( + child: Wrap( + children: [ + ListTile( + leading: const Icon(Icons.movie, color: Colors.green), + title: const Text('添加观影'), + subtitle: const Text('记录你看过的电影'), + onTap: () { + Navigator.pop(context); + Navigator.pushNamed(context, '/movie-form'); + }, + ), + ListTile( + leading: const Icon(Icons.menu_book, color: Colors.orange), + title: const Text('添加阅读'), + subtitle: const Text('记录你读过的书'), + onTap: () { + Navigator.pop(context); + Navigator.pushNamed(context, '/book-form'); + }, + ), + ListTile( + leading: const Icon(Icons.note, color: Colors.blue), + title: const Text('添加笔记'), + subtitle: const Text('记录你的想法和笔记'), + onTap: () { + Navigator.pop(context); + Navigator.pushNamed(context, '/note-form'); + }, + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/pages/movie_detail_page.dart b/lib/pages/movie_detail_page.dart new file mode 100644 index 0000000..b0eabcd --- /dev/null +++ b/lib/pages/movie_detail_page.dart @@ -0,0 +1,284 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; +import '../utils/app_theme.dart'; + +/// 影视详情页 +class MovieDetailPage extends StatefulWidget { + final Movie movie; + + const MovieDetailPage({super.key, required this.movie}); + + @override + State createState() => _MovieDetailPageState(); +} + +class _MovieDetailPageState extends State { + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.movie.title), + actions: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () => _navigateToEdit(context), + ), + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () => _showDeleteDialog(context), + ), + ], + ), + body: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 海报区域 + _buildPosterSection(context), + + // 基本信息 + _buildInfoSection(context), + + // 笔记区域 + if (widget.movie.note != null && widget.movie.note!.isNotEmpty) + _buildNoteSection(context), + ], + ), + ), + ); + } + + /// 构建海报区域 + Widget _buildPosterSection(BuildContext context) { + return Container( + width: double.infinity, + height: 300, + decoration: BoxDecoration( + color: Colors.grey[300], + ), + child: Stack( + children: [ + Center( + child: Icon( + Icons.movie, + size: 80, + color: Colors.grey[500], + ), + ), + Positioned( + top: 16, + right: 16, + child: _buildStatusTag(context), + ), + ], + ), + ); + } + + /// 构建状态标签 + Widget _buildStatusTag(BuildContext context) { + Color statusColor; + String statusText; + + switch (widget.movie.status) { + case 'watched': + statusColor = AppTheme.watchedColor; + statusText = '已看'; + break; + case 'want_to_watch': + statusColor = AppTheme.wantToWatchColor; + statusText = '想看'; + break; + case 'watching': + statusColor = AppTheme.watchingColor; + statusText = '在看'; + break; + default: + statusColor = Colors.grey; + statusText = '未知'; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.9), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + statusText, + style: const TextStyle( + fontSize: 14, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + /// 构建信息区域 + Widget _buildInfoSection(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题 + Text( + widget.movie.title, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + + const SizedBox(height: 16), + + // 年份和评分 + Row( + children: [ + if (widget.movie.year != null) ...[ + _buildInfoItem( + context, + icon: Icons.calendar_today, + label: '${widget.movie.year}年', + ), + const SizedBox(width: 16), + ], + if (widget.movie.rating != null) ...[ + _buildInfoItem( + context, + icon: Icons.star, + label: widget.movie.rating.toString(), + iconColor: Colors.amber[700], + textColor: Colors.amber[700], + ), + ], + ], + ), + + const SizedBox(height: 16), + + // 观看日期 + if (widget.movie.watchDate != null) + _buildInfoItem( + context, + icon: Icons.event, + label: '观看日期:${_formatDate(widget.movie.watchDate!)}', + ), + ], + ), + ); + } + + /// 构建信息项 + Widget _buildInfoItem( + BuildContext context, { + required IconData icon, + required String label, + Color? iconColor, + Color? textColor, + }) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 18, color: iconColor), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: 14, + color: textColor ?? Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ); + } + + /// 构建笔记区域 + Widget _buildNoteSection(BuildContext context) { + return Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.edit_note, + size: 20, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + '笔记', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + widget.movie.note!, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ); + } + + /// 格式化日期 + String _formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + + /// 跳转到编辑页面 + void _navigateToEdit(BuildContext context) { + Navigator.pushNamed(context, '/movie-form', arguments: widget.movie).then((_) { + // 返回后刷新数据 + context.read().loadMovies(); + }); + } + + /// 显示删除对话框 + void _showDeleteDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('确认删除'), + content: Text('确定要删除"${widget.movie.title}"吗?此操作不可恢复。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + TextButton( + onPressed: () async { + await context.read().removeMovie(widget.movie.id); + if (!context.mounted) return; + Navigator.pop(context); // 关闭对话框 + Navigator.pop(context); // 返回上一页 + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('已删除'), + behavior: SnackBarBehavior.floating, + ), + ); + }, + child: const Text( + '删除', + style: TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/movie_form_page.dart b/lib/pages/movie_form_page.dart new file mode 100644 index 0000000..511cccf --- /dev/null +++ b/lib/pages/movie_form_page.dart @@ -0,0 +1,290 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; + +/// 添加/编辑影视记录页面 +class MovieFormPage extends StatefulWidget { + final Movie? movie; // 如果为 null,则是添加模式;否则是编辑模式 + + const MovieFormPage({super.key, this.movie}); + + @override + State createState() => _MovieFormPageState(); +} + +class _MovieFormPageState extends State { + final _formKey = GlobalKey(); + late TextEditingController _titleController; + late TextEditingController _yearController; + late TextEditingController _ratingController; + late TextEditingController _noteController; + late String _status; + DateTime? _watchDate; + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(text: widget.movie?.title ?? ''); + _yearController = TextEditingController(text: widget.movie?.year?.toString() ?? ''); + _ratingController = TextEditingController(text: widget.movie?.rating?.toString() ?? ''); + _noteController = TextEditingController(text: widget.movie?.note ?? ''); + _status = widget.movie?.status ?? 'want_to_watch'; + _watchDate = widget.movie?.watchDate; + } + + @override + void dispose() { + _titleController.dispose(); + _yearController.dispose(); + _ratingController.dispose(); + _noteController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isEdit = widget.movie != null; + + return Scaffold( + appBar: AppBar( + title: Text(isEdit ? '编辑影片' : '添加影片'), + actions: [ + IconButton( + icon: const Icon(Icons.save), + onPressed: _saveMovie, + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题 + TextFormField( + controller: _titleController, + decoration: const InputDecoration( + labelText: '影片名称 *', + hintText: '请输入影片名称', + prefixIcon: Icon(Icons.movie), + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入影片名称'; + } + return null; + }, + ), + + const SizedBox(height: 16), + + // 年份和评分 + Row( + children: [ + Expanded( + child: TextFormField( + controller: _yearController, + decoration: const InputDecoration( + labelText: '年份', + hintText: '例如:2024', + prefixIcon: Icon(Icons.calendar_today), + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + ), + ), + + const SizedBox(width: 16), + + Expanded( + child: TextFormField( + controller: _ratingController, + decoration: const InputDecoration( + labelText: '评分', + hintText: '0-10', + prefixIcon: Icon(Icons.star), + border: OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + validator: (value) { + if (value != null && value.isNotEmpty) { + final rating = double.tryParse(value); + if (rating == null || rating < 0 || rating > 10) { + return '评分必须在 0-10 之间'; + } + } + return null; + }, + ), + ), + ], + ), + + const SizedBox(height: 16), + + // 状态选择 + DropdownButtonFormField( + value: _status, + decoration: const InputDecoration( + labelText: '状态', + prefixIcon: Icon(Icons.check_circle_outline), + border: OutlineInputBorder(), + ), + items: const [ + DropdownMenuItem(value: 'watched', child: Text('已看')), + DropdownMenuItem(value: 'want_to_watch', child: Text('想看')), + DropdownMenuItem(value: 'watching', child: Text('在看')), + ], + onChanged: (value) { + setState(() { + _status = value!; + }); + }, + ), + + const SizedBox(height: 16), + + // 观看日期选择 + InkWell( + onTap: _selectWatchDate, + child: InputDecorator( + decoration: const InputDecoration( + labelText: '观看日期', + prefixIcon: Icon(Icons.event), + border: OutlineInputBorder(), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _watchDate != null + ? '${_watchDate!.year}-${_watchDate!.month.toString().padLeft(2, '0')}-${_watchDate!.day.toString().padLeft(2, '0')}' + : '选择日期', + style: TextStyle( + color: _watchDate != null + ? Theme.of(context).colorScheme.onSurface + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + if (_watchDate != null) + IconButton( + icon: const Icon(Icons.clear, size: 20), + onPressed: () { + setState(() { + _watchDate = null; + }); + }, + ), + ], + ), + ), + ), + + const SizedBox(height: 16), + + // 笔记 + TextFormField( + controller: _noteController, + decoration: const InputDecoration( + labelText: '笔记', + hintText: '写下你的观后感...', + prefixIcon: Icon(Icons.edit_note), + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + maxLines: 5, + ), + + const SizedBox(height: 32), + + // 保存按钮 + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton.icon( + onPressed: _saveMovie, + icon: const Icon(Icons.save), + label: Text(isEdit ? '保存修改' : '添加记录'), + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + /// 选择观看日期 + Future _selectWatchDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _watchDate ?? DateTime.now(), + firstDate: DateTime(1900), + lastDate: DateTime.now(), + ); + + if (picked != null) { + setState(() { + _watchDate = picked; + }); + } + } + + /// 保存影视记录 + Future _saveMovie() async { + if (!_formKey.currentState!.validate()) { + return; + } + + final year = _yearController.text.isNotEmpty ? int.tryParse(_yearController.text) : null; + final rating = _ratingController.text.isNotEmpty ? double.tryParse(_ratingController.text) : null; + + if (widget.movie == null) { + // 添加新模式 + final newMovie = Movie( + id: DateTime.now().millisecondsSinceEpoch.toString(), + title: _titleController.text.trim(), + year: year, + rating: rating, + status: _status, + watchDate: _watchDate, + note: _noteController.text.trim(), + ); + + await context.read().addMovie(newMovie); + } else { + // 编辑现有模式 + final updatedMovie = Movie( + id: widget.movie!.id, + title: _titleController.text.trim(), + year: year, + rating: rating, + status: _status, + watchDate: _watchDate, + note: _noteController.text.trim(), + ); + + await context.read().updateMovie(updatedMovie); + } + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(widget.movie == null ? '添加成功' : '更新成功'), + behavior: SnackBarBehavior.floating, + ), + ); + + Navigator.pop(context); + } +} diff --git a/lib/pages/movie_tab_page.dart b/lib/pages/movie_tab_page.dart new file mode 100644 index 0000000..d82ec5a --- /dev/null +++ b/lib/pages/movie_tab_page.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; +import '../widgets/movie_status_bar.dart'; +import '../widgets/movie_list_item.dart'; + +/// 观影标签页 +class MovieTabPage extends StatelessWidget { + const MovieTabPage({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // 状态选择栏(已看、想看、在看) + const MovieStatusBar(), + + // 影片列表 + Expanded( + child: _buildMovieList(context), + ), + ], + ); + } + + /// 构建影片列表 + Widget _buildMovieList(BuildContext context) { + return Consumer( + builder: (context, provider, child) { + // 根据状态筛选影片 + final statusMap = { + 0: 'watched', + 1: 'want_to_watch', + 2: 'watching', + }; + final currentStatus = statusMap[provider.movieStatusIndex]!; + final movies = provider.getMoviesByStatus(currentStatus); + + if (movies.isEmpty) { + return _buildEmptyState(context, provider.movieStatusIndex); + } + + return RefreshIndicator( + onRefresh: () async => await provider.loadMovies(), + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: movies.length, + itemBuilder: (context, index) { + return MovieListItem(movie: movies[index]); + }, + ), + ); + }, + ); + } + + /// 构建空状态提示 + Widget _buildEmptyState(BuildContext context, int statusIndex) { + final statusText = ['已看', '想看', '在看'][statusIndex]; + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.movie_creation_outlined, + size: 80, + color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.3), + ), + const SizedBox(height: 16), + Text( + '暂无$statusText的影片', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + icon: const Icon(Icons.add), + label: const Text('添加记录'), + onPressed: () { + Navigator.pushNamed(context, '/movie-form'); + }, + ), + ], + ), + ); + } +} diff --git a/lib/pages/note_detail_page.dart b/lib/pages/note_detail_page.dart new file mode 100644 index 0000000..a2fe02b --- /dev/null +++ b/lib/pages/note_detail_page.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; + +/// 笔记详情页 +class NoteDetailPage extends StatefulWidget { + final Note note; + + const NoteDetailPage({super.key, required this.note}); + + @override + State createState() => _NoteDetailPageState(); +} + +class _NoteDetailPageState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.note.title), + actions: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () => _navigateToEdit(context), + ), + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () => _showDeleteDialog(context), + ), + ], + ), + body: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标签区域 + if (widget.note.tags.isNotEmpty) _buildTagsSection(context), + + // 内容区域 + _buildContentSection(context), + + // 时间信息 + _buildTimeSection(context), + ], + ), + ), + ); + } + + /// 构建标签区域 + Widget _buildTagsSection(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: widget.note.tags.map((tag) { + return Chip( + label: Text(tag), + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + labelStyle: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ); + }).toList(), + ), + ); + } + + /// 构建内容区域 + Widget _buildContentSection(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.note.content, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + height: 1.8, + ), + ), + ], + ), + ); + } + + /// 构建时间信息区域 + Widget _buildTimeSection(BuildContext context) { + return Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.access_time, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + '创建时间', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + _formatDateTime(widget.note.createdAt), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Icon( + Icons.edit, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + '更新时间', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + _formatDateTime(widget.note.updatedAt), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + /// 格式化日期时间 + String _formatDateTime(DateTime dateTime) { + return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; + } + + /// 跳转到编辑页面 + void _navigateToEdit(BuildContext context) { + Navigator.pushNamed(context, '/note-form', arguments: widget.note).then((_) { + context.read().loadNotes(); + }); + } + + /// 显示删除对话框 + void _showDeleteDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('确认删除'), + content: Text('确定要删除"${widget.note.title}"吗?此操作不可恢复。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + TextButton( + onPressed: () async { + await context.read().removeNote(widget.note.id); + if (!mounted) return; + Navigator.pop(context); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('已删除'), + behavior: SnackBarBehavior.floating, + ), + ); + }, + child: const Text( + '删除', + style: TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/note_form_page.dart b/lib/pages/note_form_page.dart new file mode 100644 index 0000000..c3cac78 --- /dev/null +++ b/lib/pages/note_form_page.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; + +/// 添加/编辑笔记页面 +class NoteFormPage extends StatefulWidget { + final Note? note; // 如果为 null,则是添加模式;否则是编辑模式 + + const NoteFormPage({super.key, this.note}); + + @override + State createState() => _NoteFormPageState(); +} + +class _NoteFormPageState extends State { + final _formKey = GlobalKey(); + late TextEditingController _titleController; + late TextEditingController _contentController; + late TextEditingController _tagsController; + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(text: widget.note?.title ?? ''); + _contentController = TextEditingController(text: widget.note?.content ?? ''); + _tagsController = TextEditingController( + text: widget.note?.tags.join(', ') ?? '', + ); + } + + @override + void dispose() { + _titleController.dispose(); + _contentController.dispose(); + _tagsController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isEdit = widget.note != null; + + return Scaffold( + appBar: AppBar( + title: Text(isEdit ? '编辑笔记' : '添加笔记'), + actions: [ + IconButton( + icon: const Icon(Icons.save), + onPressed: _saveNote, + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题 + TextFormField( + controller: _titleController, + decoration: const InputDecoration( + labelText: '标题 *', + hintText: '请输入笔记标题', + prefixIcon: Icon(Icons.title), + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入标题'; + } + return null; + }, + ), + + const SizedBox(height: 16), + + // 标签 + TextFormField( + controller: _tagsController, + decoration: const InputDecoration( + labelText: '标签', + hintText: '多个标签用逗号分隔', + prefixIcon: Icon(Icons.local_offer), + border: OutlineInputBorder(), + ), + ), + + const SizedBox(height: 16), + + // 内容 + TextFormField( + controller: _contentController, + decoration: const InputDecoration( + labelText: '内容 *', + hintText: '请输入笔记内容...', + prefixIcon: Icon(Icons.edit_note), + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + maxLines: 15, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入内容'; + } + return null; + }, + ), + + const SizedBox(height: 32), + + // 保存按钮 + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton.icon( + onPressed: _saveNote, + icon: const Icon(Icons.save), + label: Text(isEdit ? '保存修改' : '添加笔记'), + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + /// 保存笔记 + Future _saveNote() async { + if (!_formKey.currentState!.validate()) { + return; + } + + // 解析标签 + final tags = _tagsController.text + .split(',') + .map((tag) => tag.trim()) + .where((tag) => tag.isNotEmpty) + .toList(); + + if (widget.note == null) { + // 添加新模式 + final now = DateTime.now(); + final newNote = Note( + id: now.millisecondsSinceEpoch.toString(), + title: _titleController.text.trim(), + content: _contentController.text.trim(), + tags: tags, + createdAt: now, + updatedAt: now, + ); + + await context.read().addNote(newNote); + } else { + // 编辑现有模式 + final updatedNote = Note( + id: widget.note!.id, + title: _titleController.text.trim(), + content: _contentController.text.trim(), + tags: tags, + createdAt: widget.note!.createdAt, + updatedAt: DateTime.now(), + ); + + await context.read().updateNote(updatedNote); + } + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(widget.note == null ? '添加成功' : '更新成功'), + behavior: SnackBarBehavior.floating, + ), + ); + + Navigator.pop(context); + } +} diff --git a/lib/pages/note_tab_page.dart b/lib/pages/note_tab_page.dart new file mode 100644 index 0000000..c8bb3a9 --- /dev/null +++ b/lib/pages/note_tab_page.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; +import '../widgets/note_list_item.dart'; + +/// 笔记标签页 +class NoteTabPage extends StatelessWidget { + const NoteTabPage({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // 笔记列表(无状态筛选,显示所有笔记) + Expanded( + child: _buildNoteList(context), + ), + ], + ); + } + + /// 构建笔记列表 + Widget _buildNoteList(BuildContext context) { + return Consumer( + builder: (context, provider, child) { + final notes = provider.notes; + + if (notes.isEmpty) { + return _buildEmptyState(context); + } + + return RefreshIndicator( + onRefresh: () async => await provider.loadNotes(), + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: notes.length, + itemBuilder: (context, index) { + return NoteListItem(note: notes[index]); + }, + ), + ); + }, + ); + } + + /// 构建空状态提示 + Widget _buildEmptyState(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.note_outlined, + size: 80, + color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.3), + ), + const SizedBox(height: 16), + Text( + '暂无笔记', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + icon: const Icon(Icons.add), + label: const Text('添加笔记'), + onPressed: () { + Navigator.pushNamed(context, '/note-form'); + }, + ), + ], + ), + ); + } + + /// 获取示例笔记数据 + List _getSampleNotes() { + // 示例数据(实际应从数据库获取) + return [ + Note( + id: '1', + title: 'Flutter 学习心得', + content: '今天开始学习 Flutter 框架,感觉和 Vue 有很多相似之处,都是声明式 UI,组件化开发。Widget 的概念很有趣,一切皆 Widget。', + tags: ['学习', 'Flutter', '编程'], + createdAt: DateTime(2024, 3, 1, 10, 30), + updatedAt: DateTime(2024, 3, 1, 10, 30), + ), + Note( + id: '2', + title: '《活着》读后感', + content: '余华的《活着》真的是一部让人深思的作品。福贵的一生经历了太多的苦难,但他依然坚强地活着。生命的意义或许就在于活着本身。', + tags: ['阅读', '感悟', '书籍'], + createdAt: DateTime(2024, 2, 20, 15, 20), + updatedAt: DateTime(2024, 2, 20, 16, 0), + ), + Note( + id: '3', + title: '电影《星际穿越》观后感', + content: '诺兰的电影总是充满想象力。《星际穿越》将科幻与亲情完美结合,五维空间的呈现方式令人震撼。配乐也是一绝。', + tags: ['观影', '科幻', '电影'], + createdAt: DateTime(2024, 2, 15, 20, 0), + updatedAt: DateTime(2024, 2, 15, 20, 30), + ), + Note( + id: '4', + title: 'Python 数据分析笔记', + content: 'Pandas 库的 DataFrame 操作非常强大,可以方便地进行数据清洗和分析。需要多练习熟练掌握常用操作。', + tags: ['Python', '数据分析', '技术'], + createdAt: DateTime(2024, 1, 10, 9, 0), + updatedAt: DateTime(2024, 1, 10, 9, 30), + ), + Note( + id: '5', + title: '生活随笔', + content: '春天来了,天气渐暖。周末去公园散步,看到花开得很好。生活中的小确幸值得记录。', + tags: ['生活', '随笔'], + createdAt: DateTime(2024, 3, 3, 18, 0), + updatedAt: DateTime(2024, 3, 3, 18, 0), + ), + ]; + } +} diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart new file mode 100644 index 0000000..83476bc --- /dev/null +++ b/lib/providers/app_provider.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import '../models/data_models.dart'; +import '../utils/movie_dao.dart'; +import '../utils/book_dao.dart'; +import '../utils/note_dao.dart'; + +/// 应用全局状态管理 +class AppProvider extends ChangeNotifier { + // 数据库访问对象 + final MovieDao _movieDao = MovieDao(); + final BookDao _bookDao = BookDao(); + final NoteDao _noteDao = NoteDao(); + + // 数据列表 + List _movies = []; + List _books = []; + List _notes = []; + + // 当前主界面选中的标签 (0: 观影,1: 阅读,2: 笔记) + int _mainTabIndex = 0; + + // 当前底部导航选中的索引 (0: 主页,1: 新增,2: 我的) + int _bottomNavIndex = 0; + + // 观影选中的状态 (0: 已看,1: 想看,2: 在看) + int _movieStatusIndex = 0; + + // 阅读选中的状态 (0: 读完,1: 在读,2: 准备读) + int _bookStatusIndex = 0; + + // 侧边菜单是否打开 + bool _drawerOpen = false; + + // 初始化数据库 + Future initDatabase() async { + await loadMovies(); + await loadBooks(); + await loadNotes(); + } + + // 加载影视数据 + Future loadMovies() async { + _movies = await _movieDao.getAllMovies(); + notifyListeners(); + } + + // 加载书籍数据 + Future loadBooks() async { + _books = await _bookDao.getAllBooks(); + notifyListeners(); + } + + // 加载笔记数据 + Future loadNotes() async { + _notes = await _noteDao.getAllNotes(); + notifyListeners(); + } + + // Getters + int get mainTabIndex => _mainTabIndex; + int get bottomNavIndex => _bottomNavIndex; + int get movieStatusIndex => _movieStatusIndex; + int get bookStatusIndex => _bookStatusIndex; + bool get drawerOpen => _drawerOpen; + List get movies => _movies; + List get books => _books; + List get notes => _notes; + + // 根据状态获取影视列表 + List getMoviesByStatus(String status) { + return _movies.where((movie) => movie.status == status).toList(); + } + + // 根据状态获取书籍列表 + List getBooksByStatus(String status) { + return _books.where((book) => book.status == status).toList(); + } + + // Setters + void setMainTabIndex(int index) { + _mainTabIndex = index; + notifyListeners(); + } + + void setBottomNavIndex(int index) { + _bottomNavIndex = index; + notifyListeners(); + } + + void setMovieStatusIndex(int index) { + _movieStatusIndex = index; + notifyListeners(); + } + + void setBookStatusIndex(int index) { + _bookStatusIndex = index; + notifyListeners(); + } + + void toggleDrawer() { + _drawerOpen = !_drawerOpen; + notifyListeners(); + } + + void closeDrawer() { + _drawerOpen = false; + notifyListeners(); + } + + // 添加影视记录 + Future addMovie(Movie movie) async { + await _movieDao.insertMovie(movie); + await loadMovies(); + } + + // 更新影视记录 + Future updateMovie(Movie movie) async { + await _movieDao.updateMovie(movie); + await loadMovies(); + } + + // 删除影视记录 + Future removeMovie(String id) async { + await _movieDao.deleteMovie(id); + await loadMovies(); + } + + // 添加书籍记录 + Future addBook(Book book) async { + await _bookDao.insertBook(book); + await loadBooks(); + } + + // 更新书籍记录 + Future updateBook(Book book) async { + await _bookDao.updateBook(book); + await loadBooks(); + } + + // 删除书籍记录 + Future removeBook(String id) async { + await _bookDao.deleteBook(id); + await loadBooks(); + } + + // 添加笔记 + Future addNote(Note note) async { + await _noteDao.insertNote(note); + await loadNotes(); + } + + // 更新笔记 + Future updateNote(Note note) async { + await _noteDao.updateNote(note); + await loadNotes(); + } + + // 删除笔记 + Future removeNote(String id) async { + await _noteDao.deleteNote(id); + await loadNotes(); + } +} diff --git a/lib/utils/app_router.dart b/lib/utils/app_router.dart new file mode 100644 index 0000000..40bf44c --- /dev/null +++ b/lib/utils/app_router.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import '../models/data_models.dart'; +import '../pages/movie_form_page.dart'; +import '../pages/book_form_page.dart'; +import '../pages/note_form_page.dart'; +import '../pages/movie_detail_page.dart'; +import '../pages/book_detail_page.dart'; +import '../pages/note_detail_page.dart'; + +/// 路由生成器 +class AppRouter { + static Route generateRoute(RouteSettings settings) { + switch (settings.name) { + case '/movie-form': + final movie = settings.arguments as Movie?; + return MaterialPageRoute( + builder: (_) => MovieFormPage(movie: movie), + ); + + case '/book-form': + final book = settings.arguments as Book?; + return MaterialPageRoute( + builder: (_) => BookFormPage(book: book), + ); + + case '/note-form': + final note = settings.arguments as Note?; + return MaterialPageRoute( + builder: (_) => NoteFormPage(note: note), + ); + + case '/movie-detail': + final movie = settings.arguments as Movie; + return MaterialPageRoute( + builder: (_) => MovieDetailPage(movie: movie), + ); + + case '/book-detail': + final book = settings.arguments as Book; + return MaterialPageRoute( + builder: (_) => BookDetailPage(book: book), + ); + + case '/note-detail': + final note = settings.arguments as Note; + return MaterialPageRoute( + builder: (_) => NoteDetailPage(note: note), + ); + + default: + return MaterialPageRoute( + builder: (_) => Scaffold( + body: Center( + child: Text('未找到页面:${settings.name}'), + ), + ), + ); + } + } +} diff --git a/lib/utils/app_theme.dart b/lib/utils/app_theme.dart new file mode 100644 index 0000000..70eeba5 --- /dev/null +++ b/lib/utils/app_theme.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + // 主色调 + static const Color primaryColor = Color(0xFF6200EE); + static const Color secondaryColor = Color(0xFF03DAC6); + + // 状态颜色 + static const Color watchedColor = Color(0xFF4CAF50); // 已看 - 绿色 + static const Color wantToWatchColor = Color(0xFFFF9800); // 想看 - 橙色 + static const Color watchingColor = Color(0xFF2196F3); // 在看 - 蓝色 + + static const Color readColor = Color(0xFF4CAF50); // 读完 - 绿色 + static const Color wantToReadColor = Color(0xFFFF9800); // 准备读 - 橙色 + static const Color readingColor = Color(0xFF2196F3); // 在读 - 蓝色 + + // 亮色主题 + static final ThemeData lightTheme = ThemeData( + useMaterial3: true, + brightness: Brightness.light, + colorScheme: ColorScheme.fromSeed( + seedColor: primaryColor, + brightness: Brightness.light, + ), + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + ), + cardTheme: CardThemeData( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + elevation: 8, + selectedItemColor: primaryColor, + unselectedItemColor: Colors.grey, + ), + ); + + // 暗色主题 + static final ThemeData darkTheme = ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + colorScheme: ColorScheme.fromSeed( + seedColor: primaryColor, + brightness: Brightness.dark, + ), + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + ), + cardTheme: CardThemeData( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + elevation: 8, + selectedItemColor: secondaryColor, + unselectedItemColor: Colors.grey, + ), + ); +} diff --git a/lib/utils/book_dao.dart b/lib/utils/book_dao.dart new file mode 100644 index 0000000..ec6d3ae --- /dev/null +++ b/lib/utils/book_dao.dart @@ -0,0 +1,81 @@ +import 'package:sqflite/sqflite.dart'; +import '../models/data_models.dart'; +import 'database_helper.dart'; + +/// 书籍数据访问对象 +class BookDao { + final DatabaseHelper _dbHelper = DatabaseHelper.instance; + + // 获取所有书籍记录 + Future> getAllBooks() async { + final db = await _dbHelper.database; + final List> maps = await db.query('books'); + + return List.generate(maps.length, (i) { + return Book( + id: maps[i]['id'].toString(), + title: maps[i]['title'], + author: maps[i]['author'], + cover: maps[i]['cover'], + rating: maps[i]['rating']?.toDouble(), + status: maps[i]['status'], + readDate: maps[i]['read_date'] != null + ? DateTime.parse(maps[i]['read_date']) + : null, + note: maps[i]['note'], + ); + }); + } + + // 根据状态筛选书籍记录 + Future> getBooksByStatus(String status) async { + final db = await _dbHelper.database; + final List> maps = await db.query( + 'books', + where: 'status = ?', + whereArgs: [status], + ); + + return List.generate(maps.length, (i) { + return Book( + id: maps[i]['id'].toString(), + title: maps[i]['title'], + author: maps[i]['author'], + cover: maps[i]['cover'], + rating: maps[i]['rating']?.toDouble(), + status: maps[i]['status'], + readDate: maps[i]['read_date'] != null + ? DateTime.parse(maps[i]['read_date']) + : null, + note: maps[i]['note'], + ); + }); + } + + // 添加书籍记录 + Future insertBook(Book book) async { + final db = await _dbHelper.database; + return await db.insert('books', book.toJson()); + } + + // 更新书籍记录 + Future updateBook(Book book) async { + final db = await _dbHelper.database; + return await db.update( + 'books', + book.toJson(), + where: 'id = ?', + whereArgs: [book.id], + ); + } + + // 删除书籍记录 + Future deleteBook(String id) async { + final db = await _dbHelper.database; + return await db.delete( + 'books', + where: 'id = ?', + whereArgs: [id], + ); + } +} diff --git a/lib/utils/database_helper.dart b/lib/utils/database_helper.dart new file mode 100644 index 0000000..38cc365 --- /dev/null +++ b/lib/utils/database_helper.dart @@ -0,0 +1,81 @@ +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart'; + +/// 数据库帮助类 - 管理数据库的创建和版本控制 +class DatabaseHelper { + static final DatabaseHelper instance = DatabaseHelper._init(); + static Database? _database; + + DatabaseHelper._init(); + + Future get database async { + if (_database != null) return _database!; + _database = await _initDB('mooknote.db'); + return _database!; + } + + Future _initDB(String filePath) async { + final dbPath = await getDatabasesPath(); + final path = join(dbPath, filePath); + + return await openDatabase( + path, + version: 1, + onCreate: _createDB, + ); + } + + // 创建数据库表 + Future _createDB(Database db, int version) async { + const idType = 'INTEGER PRIMARY KEY AUTOINCREMENT'; + const textType = 'TEXT NOT NULL'; + const integerType = 'INTEGER NOT NULL'; + const booleanType = 'INTEGER NOT NULL'; + + // 影视表 + await db.execute(''' + CREATE TABLE movies ( + id $idType, + title $textType, + poster TEXT, + rating REAL, + year INTEGER, + status $textType, + watch_date TEXT, + note TEXT + ) + '''); + + // 书籍表 + await db.execute(''' + CREATE TABLE books ( + id $idType, + title $textType, + author TEXT, + cover TEXT, + rating REAL, + status $textType, + read_date TEXT, + note TEXT + ) + '''); + + // 笔记表 + await db.execute(''' + CREATE TABLE notes ( + id $idType, + title $textType, + content $textType, + tags TEXT, + created_at $textType, + updated_at $textType + ) + '''); + } + + // 关闭数据库 + Future close() async { + final db = await instance.database; + db.close(); + } +} diff --git a/lib/utils/movie_dao.dart b/lib/utils/movie_dao.dart new file mode 100644 index 0000000..ddb6eff --- /dev/null +++ b/lib/utils/movie_dao.dart @@ -0,0 +1,81 @@ +import 'package:sqflite/sqflite.dart'; +import '../models/data_models.dart'; +import 'database_helper.dart'; + +/// 影视数据访问对象 +class MovieDao { + final DatabaseHelper _dbHelper = DatabaseHelper.instance; + + // 获取所有影视记录 + Future> getAllMovies() async { + final db = await _dbHelper.database; + final List> maps = await db.query('movies'); + + return List.generate(maps.length, (i) { + return Movie( + id: maps[i]['id'].toString(), + title: maps[i]['title'], + poster: maps[i]['poster'], + rating: maps[i]['rating']?.toDouble(), + year: maps[i]['year'], + status: maps[i]['status'], + watchDate: maps[i]['watch_date'] != null + ? DateTime.parse(maps[i]['watch_date']) + : null, + note: maps[i]['note'], + ); + }); + } + + // 根据状态筛选影视记录 + Future> getMoviesByStatus(String status) async { + final db = await _dbHelper.database; + final List> maps = await db.query( + 'movies', + where: 'status = ?', + whereArgs: [status], + ); + + return List.generate(maps.length, (i) { + return Movie( + id: maps[i]['id'].toString(), + title: maps[i]['title'], + poster: maps[i]['poster'], + rating: maps[i]['rating']?.toDouble(), + year: maps[i]['year'], + status: maps[i]['status'], + watchDate: maps[i]['watch_date'] != null + ? DateTime.parse(maps[i]['watch_date']) + : null, + note: maps[i]['note'], + ); + }); + } + + // 添加影视记录 + Future insertMovie(Movie movie) async { + final db = await _dbHelper.database; + return await db.insert('movies', movie.toJson()); + } + + // 更新影视记录 + Future updateMovie(Movie movie) async { + final db = await _dbHelper.database; + return await db.update( + 'movies', + movie.toJson(), + where: 'id = ?', + whereArgs: [movie.id], + ); + } + + // 删除影视记录 + Future deleteMovie(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 new file mode 100644 index 0000000..41a8466 --- /dev/null +++ b/lib/utils/note_dao.dart @@ -0,0 +1,69 @@ +import 'package:sqflite/sqflite.dart'; +import '../models/data_models.dart'; +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', + orderBy: 'updated_at DESC', + ); + + return List.generate(maps.length, (i) { + return Note( + id: maps[i]['id'].toString(), + title: maps[i]['title'], + content: maps[i]['content'], + tags: maps[i]['tags'] != null + ? List.from(maps[i]['tags'].split(',')) + : [], + createdAt: DateTime.parse(maps[i]['created_at']), + updatedAt: DateTime.parse(maps[i]['updated_at']), + ); + }); + } + + // 添加笔记 + Future insertNote(Note note) async { + final db = await _dbHelper.database; + return await db.insert('notes', { + 'id': note.id, + 'title': note.title, + 'content': note.content, + 'tags': note.tags.join(','), + 'created_at': note.createdAt.toIso8601String(), + 'updated_at': note.updatedAt.toIso8601String(), + }); + } + + // 更新笔记 + Future updateNote(Note note) async { + final db = await _dbHelper.database; + return await db.update( + 'notes', + { + 'title': note.title, + 'content': note.content, + 'tags': note.tags.join(','), + 'updated_at': note.updatedAt.toIso8601String(), + }, + where: 'id = ?', + whereArgs: [note.id], + ); + } + + // 删除笔记 + Future deleteNote(String id) async { + final db = await _dbHelper.database; + return await db.delete( + 'notes', + where: 'id = ?', + whereArgs: [id], + ); + } +} diff --git a/lib/widgets/book_list_item.dart b/lib/widgets/book_list_item.dart new file mode 100644 index 0000000..df46021 --- /dev/null +++ b/lib/widgets/book_list_item.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; +import '../utils/app_theme.dart'; + +/// 书籍列表项组件 +class BookListItem extends StatelessWidget { + final Book book; + + const BookListItem({super.key, required this.book}); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: InkWell( + onTap: () { + // 跳转到详情页 + Navigator.pushNamed(context, '/book-detail', arguments: book); + }, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 封面占位图 + _buildCover(), + + const SizedBox(width: 12), + + // 书籍信息 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题 + Text( + book.title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + + if (book.author != null) ...[ + const SizedBox(height: 4), + Text( + book.author!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + + const SizedBox(height: 8), + + // 评分和状态 + Row( + children: [ + if (book.rating != null) ...[ + Icon( + Icons.star, + size: 16, + color: Colors.amber[700], + ), + const SizedBox(width: 4), + Text( + book.rating.toString(), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.amber[700], + ), + ), + const SizedBox(width: 12), + ], + + // 状态标签 + _buildStatusTag(context), + ], + ), + + if (book.readDate != null) ...[ + const SizedBox(height: 6), + Text( + '阅读日期:${_formatDate(book.readDate!)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + + if (book.note != null && book.note!.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + book.note!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + + // 右侧操作按钮 + Column( + children: [ + IconButton( + icon: const Icon(Icons.edit, size: 20), + onPressed: () { + Navigator.pushNamed(context, '/book-form', arguments: book); + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 20), + color: Colors.red, + onPressed: () => _showDeleteDialog(context, book), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ], + ), + ), + ), + ); + } + + /// 构建封面占位图 + Widget _buildCover() { + return Container( + width: 60, + height: 90, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.menu_book, + color: Colors.grey[500], + size: 32, + ), + ); + } + + /// 构建状态标签 + Widget _buildStatusTag(BuildContext context) { + Color statusColor; + String statusText; + + switch (book.status) { + case 'read': + statusColor = AppTheme.readColor; + statusText = '读完'; + break; + case 'reading': + statusColor = AppTheme.readingColor; + statusText = '在读'; + break; + case 'want_to_read': + statusColor = AppTheme.wantToReadColor; + statusText = '准备读'; + break; + default: + statusColor = Colors.grey; + statusText = '未知'; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: statusColor, + width: 1, + ), + ), + child: Text( + statusText, + style: TextStyle( + fontSize: 11, + color: statusColor, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + /// 格式化日期 + String _formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + + /// 显示删除对话框 + void _showDeleteDialog(BuildContext context, Book book) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('确认删除'), + content: Text('确定要删除"${book.title}"吗?此操作不可恢复。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + TextButton( + onPressed: () async { + await context.read().removeBook(book.id); + if (!context.mounted) return; + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('已删除'), + behavior: SnackBarBehavior.floating, + ), + ); + }, + child: const Text( + '删除', + style: TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/book_status_bar.dart b/lib/widgets/book_status_bar.dart new file mode 100644 index 0000000..260467e --- /dev/null +++ b/lib/widgets/book_status_bar.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../utils/app_theme.dart'; + +/// 阅读状态选择栏 +class BookStatusBar extends StatelessWidget { + const BookStatusBar({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, provider, child) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + _buildStatusItem( + context, + '读完', + AppTheme.readColor, + Icons.check_circle, + 0, + provider.bookStatusIndex, + () => provider.setBookStatusIndex(0), + ), + const SizedBox(width: 12), + _buildStatusItem( + context, + '在读', + AppTheme.readingColor, + Icons.auto_stories, + 1, + provider.bookStatusIndex, + () => provider.setBookStatusIndex(1), + ), + const SizedBox(width: 12), + _buildStatusItem( + context, + '准备读', + AppTheme.wantToReadColor, + Icons.bookmark_border, + 2, + provider.bookStatusIndex, + () => provider.setBookStatusIndex(2), + ), + ], + ), + ); + }, + ); + } + + /// 构建状态项 + Widget _buildStatusItem( + BuildContext context, + String label, + Color color, + IconData icon, + int index, + int currentIndex, + VoidCallback onTap, + ) { + final isSelected = index == currentIndex; + + return Expanded( + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: isSelected ? color.withOpacity(0.1) : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected ? color : Colors.transparent, + width: 2, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + color: isSelected ? color : Theme.of(context).colorScheme.onSurfaceVariant, + size: 20, + ), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected ? color : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/bottom_nav_bar.dart b/lib/widgets/bottom_nav_bar.dart new file mode 100644 index 0000000..7f9dc38 --- /dev/null +++ b/lib/widgets/bottom_nav_bar.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; + +/// 自定义底部导航栏 +class CustomBottomNavBar extends StatelessWidget { + const CustomBottomNavBar({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, provider, child) { + return BottomNavigationBar( + currentIndex: provider.bottomNavIndex, + onTap: (index) { + if (index == 1) { + // 新增按钮 - 显示选择对话框 + _showAddDialog(context, provider); + } else { + provider.setBottomNavIndex(index); + if (index == 0) { + // 主页 - 重置到首页 + provider.setMainTabIndex(0); + } + // index == 2 是我的页面(待实现) + } + }, + type: BottomNavigationBarType.fixed, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.home_outlined), + activeIcon: Icon(Icons.home), + label: '主页', + ), + BottomNavigationBarItem( + icon: Icon(Icons.add_circle_outline), + activeIcon: Icon(Icons.add_circle), + label: '新增', + ), + BottomNavigationBarItem( + icon: Icon(Icons.person_outline), + activeIcon: Icon(Icons.person), + label: '我的', + ), + ], + ); + }, + ); + } + + /// 显示新增对话框 + void _showAddDialog(BuildContext context, AppProvider provider) { + showModalBottomSheet( + context: context, + builder: (BuildContext context) { + return SafeArea( + child: Wrap( + children: [ + ListTile( + leading: const Icon(Icons.movie, color: Colors.green), + title: const Text('添加观影'), + subtitle: const Text('记录你看过的电影'), + onTap: () { + Navigator.pop(context); + // 直接打开添加观影表单 + Navigator.pushNamed(context, '/movie-form'); + }, + ), + ListTile( + leading: const Icon(Icons.menu_book, color: Colors.orange), + title: const Text('添加阅读'), + subtitle: const Text('记录你读过的书'), + onTap: () { + Navigator.pop(context); + // 直接打开添加阅读表单 + Navigator.pushNamed(context, '/book-form'); + }, + ), + ListTile( + leading: const Icon(Icons.note, color: Colors.blue), + title: const Text('添加笔记'), + subtitle: const Text('记录你的想法和笔记'), + onTap: () { + Navigator.pop(context); + // 直接打开添加笔记表单 + Navigator.pushNamed(context, '/note-form'); + }, + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart new file mode 100644 index 0000000..cc0e4b7 --- /dev/null +++ b/lib/widgets/custom_drawer.dart @@ -0,0 +1,213 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; + +/// 自定义左侧弹出菜单 +class CustomDrawer extends StatelessWidget { + const CustomDrawer({super.key}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Drawer( + child: Column( + children: [ + // 顶部用户信息区域(含热力图) + _buildHeader(context), + + // 分割线 + Divider(height: 1, color: colorScheme.outlineVariant), + + // 菜单项列表 + Expanded( + child: ListView( + padding: EdgeInsets.zero, + children: [ + ListTile( + leading: const Icon(Icons.analytics), + title: const Text('统计'), + onTap: () { + Navigator.pop(context); + // TODO: 跳转到统计页面 + }, + ), + ListTile( + leading: const Icon(Icons.delete_outline), + title: const Text('回收站'), + onTap: () { + Navigator.pop(context); + // TODO: 跳转到回收站页面 + }, + ), + ListTile( + leading: const Icon(Icons.settings), + title: const Text('设置'), + onTap: () { + Navigator.pop(context); + // TODO: 跳转到设置页面 + }, + ), + ], + ), + ), + + // 底部版本信息 + Container( + padding: const EdgeInsets.all(16), + child: Text( + 'MookNote v1.0.0', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ); + } + + /// 构建头部(含热力图) + Widget _buildHeader(BuildContext context) { + return Consumer( + builder: (context, provider, child) { + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 48, 16, 16), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Theme.of(context).colorScheme.primaryContainer, + Theme.of(context).colorScheme.surface, + ], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 用户头像和名称 + Row( + children: [ + CircleAvatar( + radius: 32, + backgroundColor: Theme.of(context).colorScheme.primary, + child: const Icon( + Icons.person, + color: Colors.white, + size: 32, + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '用户', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + '记录生活点滴', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ), + + const SizedBox(height: 20), + + // GitHub 风格热力图 + _buildHeatmap(context), + ], + ), + ); + }, + ); + } + + /// 构建热力图 + Widget _buildHeatmap(BuildContext context) { + const int weeks = 52; // 一年 52 周 + const int daysPerWeek = 7; + + // 生成随机数据(实际应从数据库获取) + final random = DateTime.now().millisecondsSinceEpoch; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '年度记录', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + SizedBox( + height: daysPerWeek * 14, // 每个格子 14x14 + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate(weeks, (weekIndex) { + return Padding( + padding: const EdgeInsets.only(right: 2), + child: Column( + children: List.generate(daysPerWeek, (dayIndex) { + // 根据随机值决定颜色深度 + final intensity = (random + weekIndex * 7 + dayIndex) % 100 / 100; + final color = _getHeatmapColor(intensity, context); + + return Container( + margin: const EdgeInsets.only(bottom: 2), + width: 12, + height: 12, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ); + }), + ), + ); + }), + ), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Less', + style: Theme.of(context).textTheme.labelSmall, + ), + Text( + 'More', + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ], + ); + } + + /// 根据强度获取热力图颜色 + Color _getHeatmapColor(double intensity, BuildContext context) { + if (intensity == 0) { + return Theme.of(context).colorScheme.surfaceContainerHighest; + } else if (intensity < 0.25) { + return Theme.of(context).colorScheme.primaryContainer.withOpacity(0.4); + } else if (intensity < 0.5) { + return Theme.of(context).colorScheme.primaryContainer.withOpacity(0.6); + } else if (intensity < 0.75) { + return Theme.of(context).colorScheme.primaryContainer.withOpacity(0.8); + } else { + return Theme.of(context).colorScheme.primary; + } + } +} diff --git a/lib/widgets/movie_list_item.dart b/lib/widgets/movie_list_item.dart new file mode 100644 index 0000000..05ef5d9 --- /dev/null +++ b/lib/widgets/movie_list_item.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; +import '../utils/app_theme.dart'; + +/// 观影列表项组件 +class MovieListItem extends StatelessWidget { + final Movie movie; + + const MovieListItem({super.key, required this.movie}); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: InkWell( + onTap: () { + // 跳转到详情页 + Navigator.pushNamed(context, '/movie-detail', arguments: movie); + }, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 海报占位图 + _buildPoster(), + + const SizedBox(width: 12), + + // 影片信息 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题和年份 + Text( + movie.title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + + if (movie.year != null) ...[ + const SizedBox(height: 4), + Text( + '${movie.year}年', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + + const SizedBox(height: 8), + + // 评分和状态 + Row( + children: [ + if (movie.rating != null) ...[ + Icon( + Icons.star, + size: 16, + color: Colors.amber[700], + ), + const SizedBox(width: 4), + Text( + movie.rating.toString(), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.amber[700], + ), + ), + const SizedBox(width: 12), + ], + + // 状态标签 + _buildStatusTag(context), + ], + ), + + if (movie.watchDate != null) ...[ + const SizedBox(height: 6), + Text( + '观看日期:${_formatDate(movie.watchDate!)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + + if (movie.note != null && movie.note!.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + movie.note!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + + // 右侧操作按钮 + Column( + children: [ + IconButton( + icon: const Icon(Icons.edit, size: 20), + onPressed: () { + Navigator.pushNamed(context, '/movie-form', arguments: movie); + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 20), + color: Colors.red, + onPressed: () => _showDeleteDialog(context, movie), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ], + ), + ), + ), + ); + } + + /// 构建海报占位图 + Widget _buildPoster() { + return Container( + width: 60, + height: 90, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.movie, + color: Colors.grey[500], + size: 32, + ), + ); + } + + /// 构建状态标签 + Widget _buildStatusTag(BuildContext context) { + Color statusColor; + String statusText; + + switch (movie.status) { + case 'watched': + statusColor = AppTheme.watchedColor; + statusText = '已看'; + break; + case 'want_to_watch': + statusColor = AppTheme.wantToWatchColor; + statusText = '想看'; + break; + case 'watching': + statusColor = AppTheme.watchingColor; + statusText = '在看'; + break; + default: + statusColor = Colors.grey; + statusText = '未知'; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: statusColor, + width: 1, + ), + ), + child: Text( + statusText, + style: TextStyle( + fontSize: 11, + color: statusColor, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + /// 格式化日期 + String _formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + + /// 显示删除对话框 + void _showDeleteDialog(BuildContext context, Movie movie) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('确认删除'), + content: Text('确定要删除"${movie.title}"吗?此操作不可恢复。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + TextButton( + onPressed: () async { + await context.read().removeMovie(movie.id); + if (!context.mounted) return; + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('已删除'), + behavior: SnackBarBehavior.floating, + ), + ); + }, + child: const Text( + '删除', + style: TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/movie_status_bar.dart b/lib/widgets/movie_status_bar.dart new file mode 100644 index 0000000..499bec7 --- /dev/null +++ b/lib/widgets/movie_status_bar.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../utils/app_theme.dart'; + +/// 观影状态选择栏 +class MovieStatusBar extends StatelessWidget { + const MovieStatusBar({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, provider, child) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + _buildStatusItem( + context, + '已看', + AppTheme.watchedColor, + Icons.check_circle, + 0, + provider.movieStatusIndex, + () => provider.setMovieStatusIndex(0), + ), + const SizedBox(width: 12), + _buildStatusItem( + context, + '想看', + AppTheme.wantToWatchColor, + Icons.bookmark_border, + 1, + provider.movieStatusIndex, + () => provider.setMovieStatusIndex(1), + ), + const SizedBox(width: 12), + _buildStatusItem( + context, + '在看', + AppTheme.watchingColor, + Icons.play_circle_outline, + 2, + provider.movieStatusIndex, + () => provider.setMovieStatusIndex(2), + ), + ], + ), + ); + }, + ); + } + + /// 构建状态项 + Widget _buildStatusItem( + BuildContext context, + String label, + Color color, + IconData icon, + int index, + int currentIndex, + VoidCallback onTap, + ) { + final isSelected = index == currentIndex; + + return Expanded( + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: isSelected ? color.withOpacity(0.1) : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected ? color : Colors.transparent, + width: 2, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + color: isSelected ? color : Theme.of(context).colorScheme.onSurfaceVariant, + size: 20, + ), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected ? color : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/note_list_item.dart b/lib/widgets/note_list_item.dart new file mode 100644 index 0000000..d054ab6 --- /dev/null +++ b/lib/widgets/note_list_item.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; + +/// 笔记列表项组件 +class NoteListItem extends StatelessWidget { + final Note note; + + const NoteListItem({super.key, required this.note}); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: InkWell( + onTap: () { + // 跳转到详情页 + Navigator.pushNamed(context, '/note-detail', arguments: note); + }, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题 + Text( + note.title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + + const SizedBox(height: 8), + + // 内容摘要 + Text( + note.content, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + + const SizedBox(height: 12), + + // 标签 + if (note.tags.isNotEmpty) ...[ + Wrap( + spacing: 6, + runSpacing: 6, + children: note.tags.map((tag) => _buildTag(context, tag)).toList(), + ), + ], + + const SizedBox(height: 12), + + // 时间信息和操作按钮 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _formatDate(note.updatedAt), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit, size: 20), + onPressed: () { + Navigator.pushNamed(context, '/note-form', arguments: note); + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 20), + color: Colors.red, + onPressed: () => _showDeleteDialog(context, note), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ], + ), + ], + ), + ), + ), + ); + } + + /// 构建标签 + Widget _buildTag(BuildContext context, String tag) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '#$tag', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ); + } + + /// 格式化日期 + String _formatDate(DateTime date) { + final now = DateTime.now(); + final difference = now.difference(date); + + if (difference.inDays == 0) { + if (difference.inHours == 0) { + if (difference.inMinutes == 0) { + return '刚刚'; + } + return '${difference.inMinutes}分钟前'; + } + return '${difference.inHours}小时前'; + } else if (difference.inDays < 7) { + return '${difference.inDays}天前'; + } else { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + } + + /// 显示删除对话框 + void _showDeleteDialog(BuildContext context, Note note) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('确认删除'), + content: Text('确定要删除"${note.title}"吗?此操作不可恢复。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + TextButton( + onPressed: () async { + await context.read().removeNote(note.id); + if (!context.mounted) return; + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('已删除'), + behavior: SnackBarBehavior.floating, + ), + ); + }, + child: const Text( + '删除', + style: TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..4075030 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,309 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "74959b99b92b9eebeed1a4049426fd67c4abc3c5a0f4d12e2877097d6a11ae08" + url: "https://pub.dev" + source: hosted + version: "0.69.2" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + url: "https://pub.dev" + source: hosted + version: "0.12.18" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + url: "https://pub.dev" + source: hosted + version: "2.4.2+2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + url: "https://pub.dev" + source: hosted + version: "0.7.9" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" +sdks: + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.24.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..80694b8 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,27 @@ +name: mooknote +description: "A Flutter application for tracking movies, books, and notes" +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ^3.5.0 + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.8 + provider: ^6.1.2 + fl_chart: ^0.69.0 + sqflite: ^2.3.0 + path: ^1.8.3 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + +flutter: + uses-material-design: true + + assets: + - assets/images/ diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..9532999 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mooknote/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +}