From 34e91fbd7007fd788dd5e67c3d506729ab85efb9 Mon Sep 17 00:00:00 2001 From: ddshi <8811906+ddshi@user.noreply.gitee.com> Date: Fri, 12 Sep 2025 18:17:35 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9F=BA=E7=A1=80=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 5 +- CLAUDE.md | 392 +++++++-- lib/core/constants/app_constants.dart | 36 + lib/core/constants/asset_constants.dart | 22 + lib/core/constants/hive_constants.dart | 25 + lib/core/constants/route_constants.dart | 26 + lib/core/errors/app_error.dart | 90 ++ lib/core/errors/error_handler.dart | 235 ++++++ lib/core/theme/app_colors.dart | 139 ++++ lib/core/theme/app_theme.dart | 246 ++++++ lib/core/utils/file_utils.dart | 454 ++++++++++ lib/core/utils/image_utils.dart | 474 +++++++++++ lib/core/utils/logger.dart | 260 ++++++ lib/main.dart | 166 +--- lib/pages/add_photo_page.dart | 170 ---- lib/pages/categories_page.dart | 191 ----- lib/pages/photo_page.dart | 345 -------- lib/presentation/app_widget.dart | 283 +++++++ lib/presentation/l10n/app_localizations.dart | 264 ++++++ .../providers/theme_provider.dart | 174 ++++ pubspec.lock | 773 +++++++++++++++++- pubspec.yaml | 79 +- snapwish_PRD_v1.1.md | 381 +++++++++ snapwish_PRD_v1.md | 284 +++++++ test/widget_test.dart | 30 - 25 files changed, 4562 insertions(+), 982 deletions(-) create mode 100644 lib/core/constants/app_constants.dart create mode 100644 lib/core/constants/asset_constants.dart create mode 100644 lib/core/constants/hive_constants.dart create mode 100644 lib/core/constants/route_constants.dart create mode 100644 lib/core/errors/app_error.dart create mode 100644 lib/core/errors/error_handler.dart create mode 100644 lib/core/theme/app_colors.dart create mode 100644 lib/core/theme/app_theme.dart create mode 100644 lib/core/utils/file_utils.dart create mode 100644 lib/core/utils/image_utils.dart create mode 100644 lib/core/utils/logger.dart delete mode 100644 lib/pages/add_photo_page.dart delete mode 100644 lib/pages/categories_page.dart delete mode 100644 lib/pages/photo_page.dart create mode 100644 lib/presentation/app_widget.dart create mode 100644 lib/presentation/l10n/app_localizations.dart create mode 100644 lib/presentation/providers/theme_provider.dart create mode 100644 snapwish_PRD_v1.1.md create mode 100644 snapwish_PRD_v1.md delete mode 100644 test/widget_test.dart diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b09ce7c..5afc32b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,10 @@ "permissions": { "allow": [ "Bash(flutter analyze:*)", - "Bash(flutter run:*)" + "Bash(flutter run:*)", + "Bash(tree:*)", + "Bash(flutter pub get:*)", + "Bash(sed:*)" ], "deny": [], "ask": [] diff --git a/CLAUDE.md b/CLAUDE.md index e657894..abe926f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,125 +1,339 @@ # CLAUDE.md -此文件为 Claude Code (claude.ai/code) 提供本仓库代码开发的指导说明。 +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## 项目概览 -**SnapWish** - 一款使用中文界面的 Flutter 照片管理应用。该应用允许用户将照片组织到分类中、添加标签并管理他们的照片收藏。 +**项目名称**:想拍 (InspoSnap) +**项目描述**:Shoot What Inspires You - 拍摄灵感收集与管理应用 +**当前阶段**:MVP v1.1 开发中 +**技术栈**:Flutter + Riverpod + Hive +**参考文档**:[snapwish_PRD_v1.1.md](snapwish_PRD_v1.1.md) - 已确认的产品需求文档 -## 架构结构 +## 技术方案 -该应用采用标准的 Flutter Material Design 架构: -- **主入口**: `lib/main.dart` - 包含根组件和导航设置 -- **导航**: 底部导航栏包含 2 个标签页(照片、分类) -- **悬浮按钮**: 通用添加照片按钮 -- **页面**: 位于 `lib/pages/` 目录 +### 核心技术选型 +- **框架**:Flutter 3.2.5+ (Dart) +- **状态管理**:Riverpod (现代、类型安全) +- **数据库**:Hive (轻量级、高性能) +- **图片处理**:flutter_image_compress + image +- **瀑布流**:flutter_staggered_grid_view +- **国际化**:flutter_localizations +- **分享接收**:receive_sharing_intent +- **图片查看**:photo_view +- **UI组件**:Material Design + 自定义主题 -## 核心组件 - -### 主结构 (`lib/main.dart`) -- **SnapWishApp**: 根 MaterialApp 组件,包含主题配置 -- **MainPage**: 管理底部导航的有状态组件 -- **路由**: `/add-photo` 路由用于添加新照片 - -### 页面 (`lib/pages/`) -- **PhotoPage** (`photo_page.dart`): 照片网格显示和照片详情查看器 -- **CategoriesPage** (`categories_page.dart`): 文件夹/分类管理,包含创建/删除功能 -- **AddPhotoPage** (`add_photo_page.dart`): 照片上传表单,包含分类选择和标签功能 +### 架构设计 +``` +lib/ +├── core/ # 核心功能 +│ ├── constants/ # 常量定义 +│ ├── errors/ # 错误处理 +│ ├── utils/ # 工具类(图片压缩、文件操作) +│ └── theme/ # 主题配置 +├── data/ # 数据层 +│ ├── datasources/ # 数据源 +│ │ ├── local/ # Hive数据库 +│ │ └── share/ # 分享接收 +│ ├── models/ # 数据模型 +│ └── repositories/ # 数据仓库 +├── domain/ # 业务逻辑层 +│ ├── entities/ # 实体类 +│ ├── repositories/ # 仓库接口 +│ └── usecases/ # 用例 +├── presentation/ # 表示层 +│ ├── providers/ # Riverpod状态管理 +│ ├── pages/ # 页面 +│ ├── widgets/ # 组件 +│ └── l10n/ # 国际化 +└── main.dart # 应用入口 +``` ## 开发命令 -### 构建与运行 ```bash # 安装依赖 flutter pub get -# 运行开发服务器 -flutter run - -# 构建 APK -flutter build apk - -# 构建 iOS 应用 -flutter build ios - -# 构建 Web 应用 -flutter build web -``` - -### 测试与代码质量 -```bash -# 运行所有测试 -flutter test - -# 运行特定测试文件 -flutter test test/widget_test.dart +# 运行应用 +flutter run # 默认设备 +flutter run -d android # Android设备 +flutter run -d ios # iOS设备 # 代码分析 flutter analyze -# 代码格式化 -flutter format . +# 运行测试 +flutter test + +# 构建发布 +flutter build apk # Android APK +flutter build ios # iOS应用 +flutter build web # Web版本 + +# 清理缓存 +flutter clean ``` -### 开发工作流 -```bash -# 开发期间热重载 -flutter run --hot +## 技术规范 -# 检查过时包 -flutter pub outdated +### 图片处理规范 +- **缩略图**:长边500px,WebP格式,85%质量,保持原始比例 +- **大GIF处理**:提取前几帧作为缩略图,避免内存溢出 +- **存储路径**:按保存日期分类 `/yyyy/MM/dd/` +- **缓存策略**:30天/500MB自动清理 -# 升级包 -flutter pub upgrade +### 性能指标 +- **冷启动时间**:< 2秒 +- **图片加载空窗期**:< 200ms +- **搜索响应时间**:< 500ms +- **内存占用**:< 200MB(正常使用) +- **用户操作响应**:< 200ms + +### UI/UX规范 +- **响应式断点**:手机≤600dp(2列),平板>600dp(3-4列) +- **图片间距**:8dp标准间距 +- **动画时长**:页面转场300ms +- **加载体验**:骨架屏 + 进度提示 + +## 开发任务分解 + +### 📋 任务清单(模块化开发) + +#### 🔧 Phase 1: 架构搭建(3-4天) +**任务1.1: 项目基础配置** +- [ ] 创建Flutter项目并配置基础依赖 +- [ ] 配置国际化支持(简中/繁中/English) +- [ ] 设置主题系统(浅色/深色/跟随系统) +- [ ] 配置Material Design和自定义色彩方案 + +**任务1.2: 数据层架构** +- [ ] 配置Hive数据库和实体模型 +- [ ] 实现数据访问对象(DAO)模式 +- [ ] 创建基础Repository接口和实现 +- [ ] 设置数据迁移和版本管理 + +**任务1.3: 核心工具类** +- [ ] 图片压缩工具类(长边500px + WebP) +- [ ] 文件存储管理工具 +- [ ] UUID生成和路径管理 +- [ ] 错误处理和日志系统 + +**任务1.4: 基础UI组件** +- [ ] 创建可复用的按钮组件 +- [ ] 实现加载状态组件(骨架屏) +- [ ] 空状态页面组件 +- [ ] 错误提示组件 + +#### 📤 Phase 2: 分享功能(2-3天) +**任务2.1: 分享接收机制** +- [ ] 配置receive_sharing_intent插件 +- [ ] 实现Android分享接收配置 +- [ ] 实现iOS分享接收配置 +- [ ] 处理多张图片接收逻辑 + +**任务2.2: 保存界面UI** +- [ ] 创建半透明模态框界面 +- [ ] 实现图片网格预览组件 +- [ ] 构建文件夹选择器弹窗 +- [ ] 实现标签输入和选择组件 + +**任务2.3: 保存业务逻辑** +- [ ] 实现批量保存模式(默认) +- [ ] 实现单张编辑模式切换 +- [ ] 处理保存进度提示 +- [ ] 异步保存和缩略图生成 + +**任务2.4: 文件夹管理** +- [ ] 创建文件夹弹窗界面 +- [ ] 实现Material Icons选择器 +- [ ] 文件夹排序(最近使用) +- [ ] 文件夹CRUD操作 + +#### 🖼️ Phase 3: 图库展示(2-3天) +**任务3.1: 主图库界面** +- [ ] 实现瀑布流布局(flutter_staggered_grid_view) +- [ ] 响应式列数切换(手机2列/平板3-4列) +- [ ] 图片懒加载和缓存优化 +- [ ] 下拉刷新和上拉加载更多 + +**任务3.2: 搜索功能** +- [ ] 实现常驻搜索框 +- [ ] 模糊搜索算法(文件夹+标签+备注) +- [ ] 搜索历史记录(最近10条) +- [ ] 空搜索结果页面 + +**任务3.3: 状态管理** +- [ ] 图库状态管理(Riverpod) +- [ ] 图片加载状态管理 +- [ ] 搜索状态管理 +- [ ] 分页数据管理 + +#### 📁 Phase 4: 管理功能(3-4天) +**任务4.1: 图片详情页** +- [ ] 图片查看器(photo_view) +- [ ] 左右滑动切换(非循环) +- [ ] 双击放大/缩小功能 +- [ ] 横竖屏适配 + +**任务4.2: 详情信息展示** +- [ ] 显示文件夹、标签、备注信息 +- [ ] 编辑功能入口 +- [ ] 删除功能(二次确认) +- [ ] 导出到系统相册 + +**任务4.3: 标签管理系统** +- [ ] 标签列表页面 +- [ ] 标签编辑功能 +- [ ] Material Icons选择 +- [ ] 标签使用统计 + +**任务4.4: 文件夹页面** +- [ ] 文件夹网格展示 +- [ ] 文件夹封面设置 +- [ ] 文件夹重命名功能 +- [ ] 文件夹删除处理 + +#### ⚙️ Phase 5: 搜索设置(2-3天) +**任务5.1: 设置页面** +- [ ] 设置页面分组布局 +- [ ] 语言切换功能 +- [ ] 主题模式切换 +- [ ] 网格布局切换 + +**任务5.2: 存储管理** +- [ ] 存储使用情况显示 +- [ ] 一键清理缓存功能 +- [ ] 缩略图管理 +- [ ] 存储路径管理 + +**任务5.3: 性能优化** +- [ ] 虚拟滚动实现 +- [ ] 内存管理优化 +- [ ] 大图片处理优化 +- [ ] 启动速度优化 + +**任务5.4: 最终调试** +- [ ] 完整功能测试 +- [ ] 性能测试和调优 +- [ ] 多设备兼容性测试 +- [ ] Bug修复和完善 + +## 数据模型定义 + +### 核心实体类 +```dart +// 图片实体 +class InspirationImage { + final String id; // UUID + final String filePath; // 原图路径 + final String thumbnailPath; // 缩略图路径 + final String? folderId; // 文件夹ID + final List tags; // 标签ID列表 + final String? note; // 备注内容 + final DateTime createdAt; // 创建时间(保存时间) + final DateTime updatedAt; // 更新时间 + final String? originalName; // 原始文件名 + final int fileSize; // 文件大小 + final String mimeType; // MIME类型 + final int? width; // 图片宽度 + final int? height; // 图片高度 + final bool isFavorite; // 是否收藏 +} + +// 文件夹实体 +class ImageFolder { + final String id; // UUID + final String name; // 文件夹名称 + final String? coverImageId; // 封面图片ID + final String icon; // Material Icons名称 + final DateTime createdAt; // 创建时间 + final DateTime updatedAt; // 更新时间 + final DateTime lastUsedAt; // 最近使用时间 +} + +// 标签实体 +class ImageTag { + final String id; // UUID + final String name; // 标签名称 + final String icon; // Material Icons名称 + final String color; // 十六进制颜色 + final int usageCount; // 使用次数 + final DateTime lastUsedAt; // 最近使用时间 +} ``` -## 代码模式 +## 开发注意事项 -### 状态管理 -- 使用 **StatefulWidget** 进行本地状态管理 -- 使用 **setState** 进行 UI 更新 -- 目前使用模拟数据(标有 TODO 注释) +### 代码规范 +- 遵循Dart官方代码风格指南 +- 使用有意义的变量和函数命名 +- 添加必要的代码注释 +- 保持widget的纯净性,业务逻辑分离到provider -### 导航 -- **命名路由** 用于主要导航 (`/add-photo`) -- **Navigator.push** 用于照片详情查看 -- **BottomNavigationBar** 用于主标签页切换 +### 性能要求 +- 冷启动时间 < 2秒 +- 图片加载流畅,无卡顿 +- 用户操作响应 < 200ms +- 内存占用 < 200MB -### UI 组件 -- **Material 3** 设计系统 (`useMaterial3: true`) -- **中文界面**(标签为中文) -- **响应式网格布局** 用于照片显示 -- **对话框式** 交互用于分类管理 +### 错误处理 +- 分享失败:"出了点小问题,请重新分享试试~" +- 保存失败:同上 +- 存储空间不足:同上 +- 所有错误都需要友好提示 -### 数据结构模式 -- **模拟数据** 目前以 Lists/Maps 实现 -- **分类结构**: `{name: String, count: int, isDefault: bool}` -- **标签管理**: 字符串列表支持 # 前缀 +### 兼容性要求 +- Android 7.0+ (API 24+) +- iOS 12.0+ +- 适配各种屏幕尺寸和分辨率 -## 关键 TODO 项目 +## 当前开发任务 -代码库包含多个 TODO 注释,指示计划功能: -- 用真实照片数据替换模拟数据 -- 实现从设备选择照片功能 -- 添加照片保存功能 -- 实现收藏/分享/删除功能 -- 按分类筛选照片 -- 集成真实文件夹/分类数据 +### 🎯 当前阶段:Phase 1.2 - 数据层架构搭建 +**预计时间:1天** +**优先级:高** -## 当前限制 +**具体任务**: +- [ ] 配置Hive数据库和实体模型 +- [ ] 实现数据访问对象(DAO)模式 +- [ ] 创建基础Repository接口和实现 +- [ ] 设置数据迁移和版本管理 -- 无实际照片存储/检索(仅模拟数据) -- 无相机集成 -- 无持久化存储 -- 无图像缓存或优化 -- 仅基础错误处理 +**已完成任务**:Phase 1.1 - 项目基础配置 +**下一个任务**:Phase 1.3 - 核心工具类 -## 开发环境 +**开发顺序**:严格按照任务编号顺序执行,每个任务完成后再进行下一个任务。 -- **Flutter**: 3.2.5+ -- **Dart**: 3.2.5+ -- **目标平台**: iOS、Android(支持 Web) -- **构建工具**: 标准 Flutter 工具链 +**代码提交**:每个任务完成后提交一次,确保版本控制清晰。 -## 特殊说明 -- 代码中使用中文注释,且简介、有效的说明逻辑,并支持flutter复杂、重点的运用和开发技巧,方便后续复盘和学习 \ No newline at end of file +--- + +**最后更新**:2025年9月12日 +**开发状态**:Phase 1.2 进行中 +**下一阶段**:数据层架构搭建... + +**记住:功能优先,保持代码整洁,及时沟通!** 🚀 + +## 开发进度跟踪 + +### 📊 总体进度 +- [x] Phase 1.1: 项目基础配置(5/5)✅ +- [ ] Phase 1.2: 数据层架构搭建(0/4) +- [ ] Phase 1.3: 核心工具类(0/4) +- [ ] Phase 1.4: 基础UI组件(0/4) +- [ ] Phase 2: 分享功能(0/4) + +### 🎯 当前任务详情 +**任务编号**:1.2 +**任务名称**:数据层架构搭建 +**任务状态**:进行中 +**预计完成**:2025年9月12日 +**依赖项**:Phase 1.1 完成 + +**任务验收标准**: +- Hive数据库配置完成 +- 实体模型定义完整 +- DAO模式实现正确 +- Repository接口设计合理 +- 数据迁移机制可用 \ No newline at end of file diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart new file mode 100644 index 0000000..dc5b814 --- /dev/null +++ b/lib/core/constants/app_constants.dart @@ -0,0 +1,36 @@ +/// 应用常量配置 +class AppConstants { + // 应用信息 + static const String appName = '想拍'; + static const String appNameEn = 'InspoSnap'; + static const String appVersion = '1.0.0'; + static const String appDescription = 'Shoot What Inspires You'; + + // 图片处理常量 + static const int maxThumbnailSize = 500; // 缩略图长边最大尺寸 + static const int thumbnailQuality = 85; // 缩略图质量 + static const int maxImageSize = 30 * 1024 * 1024; // 最大图片大小 30MB + static const int maxShareImages = 30; // 最大分享图片数量 + + // 缓存配置 + static const Duration cacheMaxAge = Duration(days: 30); // 缓存最大保存时间 + static const int cacheMaxSize = 500 * 1024 * 1024; // 缓存最大大小 500MB + + // 搜索配置 + static const int maxSearchHistory = 10; // 搜索历史最大数量 + static const Duration searchDebounceDuration = Duration(milliseconds: 300); // 搜索防抖时间 + + // UI配置 + static const double defaultPadding = 16.0; // 默认内边距 + static const double defaultBorderRadius = 8.0; // 默认圆角 + static const double defaultSpacing = 8.0; // 默认间距 + static const int mobileMaxWidth = 600; // 手机最大宽度 + + // 动画配置 + static const Duration pageTransitionDuration = Duration(milliseconds: 300); // 页面转场时间 + static const Duration animationDuration = Duration(milliseconds: 200); // 动画时间 + + // 性能配置 + static const int imageLoadingTimeout = 30; // 图片加载超时时间(秒) + static const int maxMemoryCacheCount = 100; // 内存缓存最大图片数量 +} \ No newline at end of file diff --git a/lib/core/constants/asset_constants.dart b/lib/core/constants/asset_constants.dart new file mode 100644 index 0000000..8be04f4 --- /dev/null +++ b/lib/core/constants/asset_constants.dart @@ -0,0 +1,22 @@ +/// 应用资源文件路径常量 +class AssetConstants { + // 图标资源 + static const String appIcon = 'assets/icons/app_icon.png'; + static const String appLogo = 'assets/icons/app_logo.png'; + + // 图片资源 + static const String emptyFolder = 'assets/images/empty_folder.png'; + static const String emptyGallery = 'assets/images/empty_gallery.png'; + static const String emptySearch = 'assets/images/empty_search.png'; + static const String errorState = 'assets/images/error_state.png'; + static const String loadingPlaceholder = 'assets/images/loading_placeholder.png'; + + // 动画资源 + static const String loadingAnimation = 'assets/animations/loading.json'; + static const String successAnimation = 'assets/animations/success.json'; + static const String errorAnimation = 'assets/animations/error.json'; + + // 字体资源(如果需要自定义字体) + static const String primaryFont = 'NotoSans'; + static const String secondaryFont = 'Roboto'; +} \ No newline at end of file diff --git a/lib/core/constants/hive_constants.dart b/lib/core/constants/hive_constants.dart new file mode 100644 index 0000000..4d530d7 --- /dev/null +++ b/lib/core/constants/hive_constants.dart @@ -0,0 +1,25 @@ +/// Hive数据库常量配置 +class HiveConstants { + // 数据库名称 + static const String databaseName = 'insposnap_db'; + + // Box名称 + static const String imagesBox = 'images'; + static const String foldersBox = 'folders'; + static const String tagsBox = 'tags'; + static const String settingsBox = 'settings'; + static const String searchHistoryBox = 'search_history'; + + // 设置相关Key + static const String themeModeKey = 'theme_mode'; + static const String localeKey = 'locale'; + static const String gridLayoutKey = 'grid_layout'; + static const String firstLaunchKey = 'first_launch'; + + // 数据库版本 + static const int databaseVersion = 1; + + // 分页配置 + static const int defaultPageSize = 20; // 默认分页大小 + static const int maxPageSize = 100; // 最大分页大小 +} \ No newline at end of file diff --git a/lib/core/constants/route_constants.dart b/lib/core/constants/route_constants.dart new file mode 100644 index 0000000..9e41517 --- /dev/null +++ b/lib/core/constants/route_constants.dart @@ -0,0 +1,26 @@ +/// 应用路由名称常量 +class RouteConstants { + // 主页面路由 + static const String main = '/'; + static const String gallery = '/gallery'; + static const String folders = '/folders'; + static const String tags = '/tags'; + static const String settings = '/settings'; + + // 功能页面路由 + static const String saveImage = '/save-image'; + static const String imageDetail = '/image-detail'; + static const String folderDetail = '/folder-detail'; + static const String tagDetail = '/tag-detail'; + + // 编辑页面路由 + static const String editImage = '/edit-image'; + static const String editFolder = '/edit-folder'; + static const String editTag = '/edit-tag'; + + // 设置相关路由 + static const String languageSettings = '/settings/language'; + static const String themeSettings = '/settings/theme'; + static const String storageSettings = '/settings/storage'; + static const String aboutSettings = '/settings/about'; +} \ No newline at end of file diff --git a/lib/core/errors/app_error.dart b/lib/core/errors/app_error.dart new file mode 100644 index 0000000..a923074 --- /dev/null +++ b/lib/core/errors/app_error.dart @@ -0,0 +1,90 @@ +/// 应用错误基类 +/// 所有自定义错误都应该继承此类 +abstract class AppError implements Exception { + final String message; + final String? code; + final StackTrace? stackTrace; + + AppError({ + required this.message, + this.code, + this.stackTrace, + }); + + @override + String toString() { + return 'AppError: $message${code != null ? ' (Code: $code)' : ''}'; + } +} + +/// 图片处理相关错误 +class ImageProcessingError extends AppError { + ImageProcessingError({ + required super.message, + super.code, + super.stackTrace, + }); +} + +/// 文件存储相关错误 +class StorageError extends AppError { + StorageError({ + required super.message, + super.code, + super.stackTrace, + }); +} + +/// 数据库相关错误 +class DatabaseError extends AppError { + DatabaseError({ + required super.message, + super.code, + super.stackTrace, + }); +} + +/// 分享接收相关错误 +class ShareReceiveError extends AppError { + ShareReceiveError({ + required super.message, + super.code, + super.stackTrace, + }); +} + +/// 网络相关错误 +class NetworkError extends AppError { + NetworkError({ + required super.message, + super.code, + super.stackTrace, + }); +} + +/// 权限相关错误 +class PermissionError extends AppError { + PermissionError({ + required super.message, + super.code, + super.stackTrace, + }); +} + +/// 验证错误 +class ValidationError extends AppError { + ValidationError({ + required super.message, + super.code, + super.stackTrace, + }); +} + +/// 业务逻辑错误 +class BusinessError extends AppError { + BusinessError({ + required super.message, + super.code, + super.stackTrace, + }); +} \ No newline at end of file diff --git a/lib/core/errors/error_handler.dart b/lib/core/errors/error_handler.dart new file mode 100644 index 0000000..3146810 --- /dev/null +++ b/lib/core/errors/error_handler.dart @@ -0,0 +1,235 @@ +import 'dart:developer' as developer; +import 'package:flutter/foundation.dart'; +import 'app_error.dart'; + +/// 错误处理工具类 +/// 提供统一的错误处理、日志记录和用户友好的错误消息 +class ErrorHandler { + // 私有构造函数,防止实例化 + ErrorHandler._(); + + /// 记录错误日志 + static void logError( + dynamic error, { + String? message, + StackTrace? stackTrace, + String? source, + }) { + final logMessage = StringBuffer(); + + // 添加错误来源 + if (source != null) { + logMessage.write('[$source] '); + } + + // 添加自定义消息 + if (message != null) { + logMessage.write('$message: '); + } + + // 添加错误信息 + if (error is AppError) { + logMessage.write('${error.message} (${error.code ?? 'UNKNOWN'})'); + } else if (error is Exception) { + logMessage.write(error.toString()); + } else { + logMessage.write(error?.toString() ?? 'Unknown error'); + } + + // 开发模式下打印详细错误信息 + if (kDebugMode) { + developer.log( + logMessage.toString(), + name: 'InspoSnap', + error: error, + stackTrace: stackTrace, + ); + + // 打印堆栈跟踪 + if (stackTrace != null) { + developer.log('StackTrace: $stackTrace', name: 'InspoSnap'); + } + } + } + + /// 获取用户友好的错误消息 + static String getUserFriendlyMessage(dynamic error) { + if (error is AppError) { + return _getAppErrorMessage(error); + } else if (error is Exception) { + return _getExceptionMessage(error); + } else { + return '出了点小问题,请稍后重试'; + } + } + + /// 获取应用错误的用户友好消息 + static String _getAppErrorMessage(AppError error) { + if (error is ImageProcessingError) { + return '图片处理失败,请检查图片格式或重试'; + } else if (error is StorageError) { + return '存储空间不足,请清理空间后重试'; + } else if (error is DatabaseError) { + return '数据保存失败,请重试'; + } else if (error is ShareReceiveError) { + return '出了点小问题,请重新分享试试~'; + } else if (error is NetworkError) { + return '网络连接异常,请检查网络后重试'; + } else if (error is PermissionError) { + return '需要相关权限才能继续使用'; + } else if (error is ValidationError) { + return error.message; // 验证错误直接显示具体消息 + } else if (error is BusinessError) { + return error.message; // 业务错误直接显示具体消息 + } else { + return error.message; // 其他应用错误直接显示消息 + } + } + + /// 获取异常的用户友好消息 + static String _getExceptionMessage(Exception exception) { + final message = exception.toString(); + + // 根据异常类型返回相应的用户友好消息 + if (message.contains('OutOfMemoryError')) { + return '内存不足,请关闭其他应用后重试'; + } else if (message.contains('FileSystemException')) { + return '文件操作失败,请检查存储权限'; + } else if (message.contains('SocketException')) { + return '网络连接失败,请检查网络设置'; + } else if (message.contains('TimeoutException')) { + return '操作超时,请稍后重试'; + } else { + return '出了点小问题,请稍后重试'; + } + } + + /// 包装异步函数,统一处理错误 + static Future wrapAsync( + Future Function() asyncFunction, { + String? source, + T? fallbackValue, + }) async { + try { + return await asyncFunction(); + } catch (error, stackTrace) { + logError(error, source: source, stackTrace: stackTrace); + + if (fallbackValue != null) { + return fallbackValue; + } + + // 如果是AppError,重新抛出 + if (error is AppError) { + rethrow; + } + + // 将其他错误包装为StorageError + throw StorageError( + message: getUserFriendlyMessage(error), + code: ErrorCodes.wrappedError, + stackTrace: stackTrace, + ); + } + } + + /// 包装同步函数,统一处理错误 + static T wrapSync( + T Function() syncFunction, { + String? source, + T? fallbackValue, + }) { + try { + return syncFunction(); + } catch (error, stackTrace) { + logError(error, source: source, stackTrace: stackTrace); + + if (fallbackValue != null) { + return fallbackValue; + } + + // 如果是AppError,重新抛出 + if (error is AppError) { + rethrow; + } + + // 将其他错误包装为StorageError + throw StorageError( + message: getUserFriendlyMessage(error), + code: ErrorCodes.wrappedError, + stackTrace: stackTrace, + ); + } + } + + /// 检查是否为致命错误 + static bool isFatalError(dynamic error) { + if (error is AppError) { + return error.code == 'DATABASE_ERROR' || + error.code == 'STORAGE_ERROR' || + error.code == 'PERMISSION_ERROR'; + } + return false; + } + + /// 获取错误代码 + static String? getErrorCode(dynamic error) { + if (error is AppError) { + return error.code; + } + return null; + } + + /// 获取错误堆栈 + static StackTrace? getStackTrace(dynamic error) { + if (error is AppError) { + return error.stackTrace; + } + return null; + } +} + +/// 错误消息常量 +class ErrorMessages { + static const String unknownError = '出了点小问题,请稍后重试'; + static const String networkError = '网络连接异常,请检查网络后重试'; + static const String serverError = '服务器繁忙,请稍后重试'; + static const String timeoutError = '请求超时,请稍后重试'; + static const String permissionError = '需要相关权限才能继续使用'; + static const String storageError = '存储空间不足,请清理空间后重试'; + static const String invalidInput = '输入内容格式不正确,请检查后重试'; + static const String dataNotFound = '未找到相关数据'; + static const String operationFailed = '操作失败,请重试'; + static const String imageProcessingError = '图片处理失败,请检查图片格式或重试'; + static const String shareReceiveError = '出了点小问题,请重新分享试试~'; + static const String databaseError = '数据保存失败,请重试'; + static const String validationError = '数据验证失败,请检查后重试'; + static const String businessError = '业务处理失败,请稍后重试'; + static const String memoryError = '内存不足,请关闭其他应用后重试'; + static const String fileSystemError = '文件操作失败,请检查存储权限'; + static const String socketError = '网络连接失败,请检查网络设置'; + static const String timeoutException = '操作超时,请稍后重试'; +} + +/// 错误代码常量 +class ErrorCodes { + static const String unknownError = 'UNKNOWN_ERROR'; + static const String networkError = 'NETWORK_ERROR'; + static const String serverError = 'SERVER_ERROR'; + static const String timeoutError = 'TIMEOUT_ERROR'; + static const String permissionError = 'PERMISSION_ERROR'; + static const String storageError = 'STORAGE_ERROR'; + static const String invalidInput = 'INVALID_INPUT'; + static const String dataNotFound = 'DATA_NOT_FOUND'; + static const String operationFailed = 'OPERATION_FAILED'; + static const String imageProcessingError = 'IMAGE_PROCESSING_ERROR'; + static const String shareReceiveError = 'SHARE_RECEIVE_ERROR'; + static const String databaseError = 'DATABASE_ERROR'; + static const String validationError = 'VALIDATION_ERROR'; + static const String businessError = 'BUSINESS_ERROR'; + static const String memoryError = 'MEMORY_ERROR'; + static const String fileSystemError = 'FILE_SYSTEM_ERROR'; + static const String socketError = 'SOCKET_ERROR'; + static const String timeoutException = 'TIMEOUT_EXCEPTION'; + static const String wrappedError = 'WRAPPED_ERROR'; +} \ No newline at end of file diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart new file mode 100644 index 0000000..280afcf --- /dev/null +++ b/lib/core/theme/app_colors.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; + +/// 应用颜色配置类 +/// 提供一致的颜色管理和主题色彩支持 +class AppColors { + // 私有构造函数,防止实例化 + AppColors._(); + + // 主要品牌色彩 + static const MaterialColor primarySwatch = MaterialColor( + 0xFF2196F3, + { + 50: Color(0xFFE3F2FD), + 100: Color(0xFFBBDEFB), + 200: Color(0xFF90CAF9), + 300: Color(0xFF64B5F6), + 400: Color(0xFF42A5F5), + 500: Color(0xFF2196F3), + 600: Color(0xFF1E88E5), + 700: Color(0xFF1976D2), + 800: Color(0xFF1565C0), + 900: Color(0xFF0D47A1), + }, + ); + + // 基础色彩 + static const Color primary = Color(0xFF2196F3); + static const Color primaryLight = Color(0xFF64B5F6); + static const Color primaryDark = Color(0xFF1976D2); + + static const Color secondary = Color(0xFF03DAC6); + static const Color secondaryLight = Color(0xFF5DF2E3); + static const Color secondaryDark = Color(0xFF00BFA5); + + // 状态色彩 + static const Color success = Color(0xFF4CAF50); + static const Color warning = Color(0xFFFFC107); + static const Color error = Color(0xFFF44336); + static const Color info = Color(0xFF2196F3); + + // 中性色彩(浅色模式) + static const Color backgroundLight = Color(0xFFF5F5F5); + static const Color surfaceLight = Colors.white; + static const Color textPrimaryLight = Color(0xFF212121); + static const Color textSecondaryLight = Color(0xFF757575); + static const Color textHintLight = Color(0xFFBDBDBD); + static const Color dividerLight = Color(0xFFE0E0E0); + static const Color borderLight = Color(0xFFCCCCCC); + + // 中性色彩(深色模式)- 保持一定的对比度 + static const Color backgroundDark = Color(0xFF121212); + static const Color surfaceDark = Color(0xFF1E1E1E); + static const Color surfaceVariantDark = Color(0xFF2C2C2C); + static const Color textPrimaryDark = Colors.white; + static const Color textSecondaryDark = Color(0xFFB0B0B0); + static const Color textHintDark = Color(0xFF808080); + static const Color dividerDark = Color(0xFF3C3C3C); + static const Color borderDark = Color(0xFF4C4C4C); + + // 功能色彩 + static const Color favorite = Color(0xFFFF5252); + static const Color bookmark = Color(0xFF448AFF); + static const Color share = Color(0xFF8BC34A); + static const Color download = Color(0xFF00BCD4); + + // 标签色彩(用于标签的颜色选择) + static const List tagColors = [ + Color(0xFFF44336), // 红色 + Color(0xFFE91E63), // 粉色 + Color(0xFF9C27B0), // 紫色 + Color(0xFF673AB7), // 深紫色 + Color(0xFF3F51B5), // 靛蓝色 + Color(0xFF2196F3), // 蓝色 + Color(0xFF03A9F4), // 亮蓝色 + Color(0xFF00BCD4), // 青色 + Color(0xFF009688), // 蓝绿色 + Color(0xFF4CAF50), // 绿色 + Color(0xFF8BC34A), // 浅绿色 + Color(0xFFCDDC39), // 黄绿色 + Color(0xFFFFEB3B), // 黄色 + Color(0xFFFFC107), // 琥珀色 + Color(0xFFFF9800), // 橙色 + Color(0xFFFF5722), // 深橙色 + Color(0xFF795548), // 棕色 + Color(0xFF9E9E9E), // 灰色 + Color(0xFF607D8B), // 蓝灰色 + ]; + + /// 根据背景色获取对比色(确保文字可读性) + static Color getContrastColor(Color backgroundColor) { + // 计算颜色的亮度(0-1) + final brightness = backgroundColor.computeLuminance(); + // 亮度大于0.5使用深色,否则使用浅色 + return brightness > 0.5 ? Colors.black : Colors.white; + } + + /// 从标签色彩列表中随机获取一个颜色 + static Color getRandomTagColor(int index) { + return tagColors[index % tagColors.length]; + } + + /// 根据十六进制字符串获取颜色 + static Color fromHex(String hexString) { + final buffer = StringBuffer(); + if (hexString.length == 6 || hexString.length == 7) buffer.write('ff'); + buffer.write(hexString.replaceFirst('#', '')); + return Color(int.parse(buffer.toString(), radix: 16)); + } + + /// 将颜色转换为十六进制字符串 + static String toHex(Color color) { + return '#${color.value.toRadixString(16).padLeft(8, '0').substring(2)}'; + } + + /// 获取主题对应的颜色 + static Color getBackgroundColor(bool isDark) { + return isDark ? backgroundDark : backgroundLight; + } + + static Color getSurfaceColor(bool isDark) { + return isDark ? surfaceDark : surfaceLight; + } + + static Color getTextPrimaryColor(bool isDark) { + return isDark ? textPrimaryDark : textPrimaryLight; + } + + static Color getTextSecondaryColor(bool isDark) { + return isDark ? textSecondaryDark : textSecondaryLight; + } + + static Color getDividerColor(bool isDark) { + return isDark ? dividerDark : dividerLight; + } + + static Color getBorderColor(bool isDark) { + return isDark ? borderDark : borderLight; + } +} \ No newline at end of file diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..23705dd --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -0,0 +1,246 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// 应用主题配置类 +class AppTheme { + // 私有构造函数,防止实例化 + AppTheme._(); + + /// 主题色彩配置 + static const Color primaryColor = Color(0xFF2196F3); + static const Color secondaryColor = Color(0xFF03DAC6); + static const Color backgroundColor = Color(0xFFF5F5F5); + static const Color surfaceColor = Colors.white; + static const Color errorColor = Color(0xFFB00020); + + /// 文字颜色 + static const Color textPrimary = Color(0xFF212121); + static const Color textSecondary = Color(0xFF757575); + static const Color textHint = Color(0xFFBDBDBD); + + /// 边框和分割线颜色 + static const Color dividerColor = Color(0xFFE0E0E0); + static const Color borderColor = Color(0xFFCCCCCC); + + /// 浅色主题 + static ThemeData lightTheme = ThemeData( + useMaterial3: true, + brightness: Brightness.light, + primaryColor: primaryColor, + colorScheme: const ColorScheme.light( + primary: primaryColor, + secondary: secondaryColor, + surface: surfaceColor, + background: backgroundColor, + error: errorColor, + onPrimary: Colors.white, + onSecondary: Colors.black, + onSurface: textPrimary, + onBackground: textPrimary, + onError: Colors.white, + ), + + // 应用栏主题 + appBarTheme: const AppBarTheme( + backgroundColor: surfaceColor, + foregroundColor: textPrimary, + elevation: 0, + centerTitle: true, + systemOverlayStyle: SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, + ), + ), + + // 卡片主题 + cardTheme: CardTheme( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), + margin: const EdgeInsets.all(8.0), + ), + + // 按钮主题 + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + ), + ), + + // 输入框主题 + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.grey[100], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), + borderSide: const BorderSide(color: borderColor), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), + borderSide: const BorderSide(color: borderColor), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), + borderSide: const BorderSide(color: primaryColor, width: 2.0), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + ), + + // 文字主题 + textTheme: const TextTheme( + displayLarge: TextStyle(fontSize: 57.0, fontWeight: FontWeight.w400, color: textPrimary), + displayMedium: TextStyle(fontSize: 45.0, fontWeight: FontWeight.w400, color: textPrimary), + displaySmall: TextStyle(fontSize: 36.0, fontWeight: FontWeight.w400, color: textPrimary), + headlineLarge: TextStyle(fontSize: 32.0, fontWeight: FontWeight.w400, color: textPrimary), + headlineMedium: TextStyle(fontSize: 28.0, fontWeight: FontWeight.w400, color: textPrimary), + headlineSmall: TextStyle(fontSize: 24.0, fontWeight: FontWeight.w400, color: textPrimary), + titleLarge: TextStyle(fontSize: 22.0, fontWeight: FontWeight.w400, color: textPrimary), + titleMedium: TextStyle(fontSize: 16.0, fontWeight: FontWeight.w500, color: textPrimary), + titleSmall: TextStyle(fontSize: 14.0, fontWeight: FontWeight.w500, color: textPrimary), + bodyLarge: TextStyle(fontSize: 16.0, fontWeight: FontWeight.w400, color: textPrimary), + bodyMedium: TextStyle(fontSize: 14.0, fontWeight: FontWeight.w400, color: textPrimary), + bodySmall: TextStyle(fontSize: 12.0, fontWeight: FontWeight.w400, color: textSecondary), + labelLarge: TextStyle(fontSize: 14.0, fontWeight: FontWeight.w500, color: textPrimary), + labelMedium: TextStyle(fontSize: 12.0, fontWeight: FontWeight.w500, color: textPrimary), + labelSmall: TextStyle(fontSize: 11.0, fontWeight: FontWeight.w500, color: textPrimary), + ), + + // 图标主题 + iconTheme: const IconThemeData( + color: textSecondary, + size: 24.0, + ), + + // 分割线主题 + dividerTheme: const DividerThemeData( + color: dividerColor, + thickness: 1.0, + space: 0, + ), + ); + + /// 深色主题 + static ThemeData darkTheme = ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + primaryColor: primaryColor, + colorScheme: const ColorScheme.dark( + primary: primaryColor, + secondary: secondaryColor, + surface: Color(0xFF121212), + background: Color(0xFF121212), + error: errorColor, + onPrimary: Colors.white, + onSecondary: Colors.black, + onSurface: Colors.white, + onBackground: Colors.white, + onError: Colors.white, + ), + + // 应用栏主题(深色模式保持一定对比度) + appBarTheme: const AppBarTheme( + backgroundColor: Color(0xFF1E1E1E), + foregroundColor: Colors.white, + elevation: 0, + centerTitle: true, + systemOverlayStyle: SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + ), + ), + + // 卡片主题 + cardTheme: CardTheme( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), + margin: const EdgeInsets.all(8.0), + color: const Color(0xFF1E1E1E), + ), + + // 按钮主题 + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + backgroundColor: primaryColor, + foregroundColor: Colors.white, + ), + ), + + // 输入框主题 + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: const Color(0xFF2C2C2C), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), + borderSide: const BorderSide(color: Color(0xFF3C3C3C)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), + borderSide: const BorderSide(color: Color(0xFF3C3C3C)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), + borderSide: const BorderSide(color: primaryColor, width: 2.0), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + ), + + // 文字主题(深色模式保持可读性) + textTheme: const TextTheme( + displayLarge: TextStyle(fontSize: 57.0, fontWeight: FontWeight.w400, color: Colors.white), + displayMedium: TextStyle(fontSize: 45.0, fontWeight: FontWeight.w400, color: Colors.white), + displaySmall: TextStyle(fontSize: 36.0, fontWeight: FontWeight.w400, color: Colors.white), + headlineLarge: TextStyle(fontSize: 32.0, fontWeight: FontWeight.w400, color: Colors.white), + headlineMedium: TextStyle(fontSize: 28.0, fontWeight: FontWeight.w400, color: Colors.white), + headlineSmall: TextStyle(fontSize: 24.0, fontWeight: FontWeight.w400, color: Colors.white), + titleLarge: TextStyle(fontSize: 22.0, fontWeight: FontWeight.w400, color: Colors.white), + titleMedium: TextStyle(fontSize: 16.0, fontWeight: FontWeight.w500, color: Colors.white), + titleSmall: TextStyle(fontSize: 14.0, fontWeight: FontWeight.w500, color: Colors.white), + bodyLarge: TextStyle(fontSize: 16.0, fontWeight: FontWeight.w400, color: Colors.white), + bodyMedium: TextStyle(fontSize: 14.0, fontWeight: FontWeight.w400, color: Colors.white), + bodySmall: TextStyle(fontSize: 12.0, fontWeight: FontWeight.w400, color: Color(0xFFB0B0B0)), + labelLarge: TextStyle(fontSize: 14.0, fontWeight: FontWeight.w500, color: Colors.white), + labelMedium: TextStyle(fontSize: 12.0, fontWeight: FontWeight.w500, color: Colors.white), + labelSmall: TextStyle(fontSize: 11.0, fontWeight: FontWeight.w500, color: Colors.white), + ), + + // 图标主题 + iconTheme: const IconThemeData( + color: Color(0xFFB0B0B0), + size: 24.0, + ), + + // 分割线主题 + dividerTheme: const DividerThemeData( + color: Color(0xFF3C3C3C), + thickness: 1.0, + space: 0, + ), + ); + + /// 获取主题模式对应的主题数据 + static ThemeData getTheme(Brightness brightness) { + return brightness == Brightness.dark ? darkTheme : lightTheme; + } + + /// 获取当前主题的对比色(用于保证可读性) + static Color getContrastColor(Color backgroundColor) { + // 计算颜色的亮度 + final brightness = backgroundColor.computeLuminance(); + // 亮度大于0.5使用深色,否则使用浅色 + return brightness > 0.5 ? Colors.black : Colors.white; + } +} \ No newline at end of file diff --git a/lib/core/utils/file_utils.dart b/lib/core/utils/file_utils.dart new file mode 100644 index 0000000..046c620 --- /dev/null +++ b/lib/core/utils/file_utils.dart @@ -0,0 +1,454 @@ +import 'dart:io'; +import 'package:path/path.dart' as path; +import '../errors/app_error.dart'; +import 'logger.dart'; + +/// 文件工具类 +/// 提供文件存储、路径管理、存储空间检查等功能 +class FileUtils { + // 私有构造函数,防止实例化 + FileUtils._(); + + /// 获取应用文档目录 + static Future getApplicationDocumentsDirectory() async { + try { + final directory = await getApplicationDocumentsDirectory(); + Logger.debug('获取应用文档目录: ${directory.path}'); + return directory; + } catch (error, stackTrace) { + Logger.error('获取应用文档目录失败', error: error, stackTrace: stackTrace); + throw StorageError( + message: '获取应用文档目录失败: ${error.toString()}', + code: 'STORAGE_ERROR', + stackTrace: stackTrace, + ); + } + } + + /// 获取临时目录 + static Future getTemporaryDirectory() async { + try { + final directory = await getTemporaryDirectory(); + Logger.debug('获取临时目录: ${directory.path}'); + return directory; + } catch (error, stackTrace) { + Logger.error('获取临时目录失败', error: error, stackTrace: stackTrace); + throw StorageError( + message: '获取临时目录失败: ${error.toString()}', + code: 'STORAGE_ERROR', + stackTrace: stackTrace, + ); + } + } + + /// 获取应用支持目录(用于存储应用数据) + static Future getApplicationSupportDirectory() async { + try { + final directory = await getApplicationSupportDirectory(); + Logger.debug('获取应用支持目录: ${directory.path}'); + return directory; + } catch (error, stackTrace) { + Logger.error('获取应用支持目录失败', error: error, stackTrace: stackTrace); + throw StorageError( + message: '获取应用支持目录失败: ${error.toString()}', + code: 'STORAGE_ERROR', + stackTrace: stackTrace, + ); + } + } + + /// 创建应用存储目录结构 + /// 按照日期格式创建目录:/yyyy/MM/dd/ + static Future createStorageDirectory(DateTime date) async { + try { + final supportDir = await getApplicationSupportDirectory(); + + // 构建日期路径 + final year = date.year.toString(); + final month = date.month.toString().padLeft(2, '0'); + final day = date.day.toString().padLeft(2, '0'); + + final storagePath = path.join(supportDir.path, 'images', year, month, day); + final storageDir = Directory(storagePath); + + // 如果目录不存在,创建目录 + if (!await storageDir.exists()) { + await storageDir.create(recursive: true); + Logger.debug('创建存储目录: $storagePath'); + } + + return storagePath; + } catch (error, stackTrace) { + Logger.error('创建存储目录失败', error: error, stackTrace: stackTrace); + throw StorageError( + message: '创建存储目录失败: ${error.toString()}', + code: 'STORAGE_ERROR', + stackTrace: stackTrace, + ); + } + } + + /// 创建缩略图存储目录 + static Future createThumbnailDirectory(DateTime date) async { + try { + final supportDir = await getApplicationSupportDirectory(); + + // 构建日期路径 + final year = date.year.toString(); + final month = date.month.toString().padLeft(2, '0'); + final day = date.day.toString().padLeft(2, '0'); + + final thumbnailPath = path.join(supportDir.path, 'thumbnails', year, month, day); + final thumbnailDir = Directory(thumbnailPath); + + // 如果目录不存在,创建目录 + if (!await thumbnailDir.exists()) { + await thumbnailDir.create(recursive: true); + Logger.debug('创建缩略图目录: $thumbnailPath'); + } + + return thumbnailPath; + } catch (error, stackTrace) { + Logger.error('创建缩略图目录失败', error: error, stackTrace: stackTrace); + throw StorageError( + message: '创建缩略图目录失败: ${error.toString()}', + code: 'STORAGE_ERROR', + stackTrace: stackTrace, + ); + } + } + + /// 创建临时文件目录 + static Future createTempDirectory() async { + try { + final tempDir = await getTemporaryDirectory(); + final inspoSnapTempPath = path.join(tempDir.path, 'insposnap_temp'); + final tempDirectory = Directory(inspoSnapTempPath); + + if (!await tempDirectory.exists()) { + await tempDirectory.create(recursive: true); + Logger.debug('创建临时目录: $inspoSnapTempPath'); + } + + return inspoSnapTempPath; + } catch (error, stackTrace) { + Logger.error('创建临时目录失败', error: error, stackTrace: stackTrace); + throw StorageError( + message: '创建临时目录失败: ${error.toString()}', + code: 'STORAGE_ERROR', + stackTrace: stackTrace, + ); + } + } + + /// 检查存储空间是否充足 + static Future hasEnoughStorageSpace({int requiredBytes = 100 * 1024 * 1024}) async { + try { + final directory = await getApplicationSupportDirectory(); + // final stat = await FileStat.stat(directory.path); + + // 获取可用空间(这里简化处理,实际需要更复杂的计算) + // 在实际应用中,可能需要使用 platform-specific 代码来获取准确的可用空间 + + // 模拟检查:如果目录存在且有写入权限,则认为空间充足 + final testFile = File(path.join(directory.path, '.space_test')); + try { + await testFile.writeAsString('test'); + await testFile.delete(); + Logger.debug('存储空间检查通过'); + return true; + } catch (e) { + Logger.warning('存储空间可能不足或没有写入权限'); + return false; + } + } catch (error, stackTrace) { + Logger.error('检查存储空间失败', error: error, stackTrace: stackTrace); + return false; + } + } + + /// 获取目录大小(字节) + static Future getDirectorySize(Directory directory) async { + try { + int totalSize = 0; + + if (!await directory.exists()) { + return 0; + } + + await for (final entity in directory.list(recursive: true, followLinks: false)) { + if (entity is File) { + try { + final stat = await entity.stat(); + totalSize += stat.size; + } catch (e) { + // 忽略单个文件的错误,继续统计其他文件 + Logger.warning('获取文件大小失败: ${entity.path}'); + } + } + } + + Logger.debug('目录大小: ${directory.path} = ${(totalSize / 1024 / 1024).toStringAsFixed(2)}MB'); + return totalSize; + } catch (error, stackTrace) { + Logger.error('获取目录大小失败', error: error, stackTrace: stackTrace); + return 0; + } + } + + /// 删除目录及其所有内容 + static Future deleteDirectory(Directory directory) async { + try { + if (await directory.exists()) { + await directory.delete(recursive: true); + Logger.debug('删除目录: ${directory.path}'); + } + } catch (error, stackTrace) { + Logger.error('删除目录失败', error: error, stackTrace: stackTrace); + throw StorageError( + message: '删除目录失败: ${error.toString()}', + code: 'STORAGE_ERROR', + stackTrace: stackTrace, + ); + } + } + + /// 清空目录内容(保留目录本身) + static Future clearDirectory(Directory directory) async { + try { + if (!await directory.exists()) { + return; + } + + await for (final entity in directory.list(followLinks: false)) { + try { + if (entity is File) { + await entity.delete(); + } else if (entity is Directory) { + await entity.delete(recursive: true); + } + } catch (e) { + // 忽略单个文件/目录的删除错误 + Logger.warning('删除失败: ${entity.path}'); + } + } + + Logger.debug('清空目录: ${directory.path}'); + } catch (error, stackTrace) { + Logger.error('清空目录失败', error: error, stackTrace: stackTrace); + throw StorageError( + message: '清空目录失败: ${error.toString()}', + code: 'STORAGE_ERROR', + stackTrace: stackTrace, + ); + } + } + + /// 复制文件 + static Future copyFile(String sourcePath, String targetPath) async { + try { + final sourceFile = File(sourcePath); + if (!await sourceFile.exists()) { + throw StorageError( + message: '源文件不存在: $sourcePath', + code: 'STORAGE_ERROR', + ); + } + + // 确保目标目录存在 + final targetDir = Directory(path.dirname(targetPath)); + if (!await targetDir.exists()) { + await targetDir.create(recursive: true); + } + + final targetFile = await sourceFile.copy(targetPath); + Logger.debug('复制文件: $sourcePath -> $targetPath'); + return targetFile; + } catch (error, stackTrace) { + Logger.error('复制文件失败', error: error, stackTrace: stackTrace); + throw StorageError( + message: '复制文件失败: ${error.toString()}', + code: 'STORAGE_ERROR', + stackTrace: stackTrace, + ); + } + } + + /// 移动文件 + static Future moveFile(String sourcePath, String targetPath) async { + try { + final sourceFile = File(sourcePath); + if (!await sourceFile.exists()) { + throw StorageError( + message: '源文件不存在: $sourcePath', + code: 'STORAGE_ERROR', + ); + } + + // 确保目标目录存在 + final targetDir = Directory(path.dirname(targetPath)); + if (!await targetDir.exists()) { + await targetDir.create(recursive: true); + } + + final targetFile = await sourceFile.rename(targetPath); + Logger.debug('移动文件: $sourcePath -> $targetPath'); + return targetFile; + } catch (error, stackTrace) { + Logger.error('移动文件失败', error: error, stackTrace: stackTrace); + throw StorageError( + message: '移动文件失败: ${error.toString()}', + code: 'STORAGE_ERROR', + stackTrace: stackTrace, + ); + } + } + + /// 获取文件扩展名 + static String getFileExtension(String filePath) { + return path.extension(filePath).toLowerCase(); + } + + /// 获取文件名(不含路径) + static String getFileName(String filePath) { + return path.basename(filePath); + } + + /// 获取文件名(不含扩展名) + static String getFileNameWithoutExtension(String filePath) { + return path.basenameWithoutExtension(filePath); + } + + /// 生成唯一的文件名 + static String generateUniqueFileName(String originalFileName) { + final timestamp = DateTime.now().millisecondsSinceEpoch; + final extension = getFileExtension(originalFileName); + final nameWithoutExt = getFileNameWithoutExtension(originalFileName); + return '${nameWithoutExt}_$timestamp$extension'; + } + + /// 检查文件是否存在 + static Future fileExists(String filePath) async { + try { + final file = File(filePath); + return await file.exists(); + } catch (error) { + Logger.error('检查文件存在失败', error: error); + return false; + } + } + + /// 获取文件大小(字节) + static Future getFileSize(String filePath) async { + try { + final file = File(filePath); + if (await file.exists()) { + final stat = await file.stat(); + return stat.size; + } + return 0; + } catch (error) { + Logger.error('获取文件大小失败', error: error); + return 0; + } + } + + /// 格式化文件大小 + static String formatFileSize(int bytes) { + if (bytes <= 0) return '0 B'; + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + int unitIndex = 0; + double size = bytes.toDouble(); + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return '${size.toStringAsFixed(2)} ${units[unitIndex]}'; + } + + /// 清理临时文件 + static Future cleanTempFiles() async { + try { + final tempDir = Directory(await createTempDirectory()); + await clearDirectory(tempDir); + Logger.info('临时文件清理完成'); + } catch (error, stackTrace) { + Logger.error('清理临时文件失败', error: error, stackTrace: stackTrace); + } + } + + /// 获取存储使用情况 + static Future> getStorageUsage() async { + try { + final supportDir = await getApplicationSupportDirectory(); + final imagesDir = Directory(path.join(supportDir.path, 'images')); + final thumbnailsDir = Directory(path.join(supportDir.path, 'thumbnails')); + + final imagesSize = await getDirectorySize(imagesDir); + final thumbnailsSize = await getDirectorySize(thumbnailsDir); + final totalSize = imagesSize + thumbnailsSize; + + return { + 'imagesSize': imagesSize, + 'thumbnailsSize': thumbnailsSize, + 'totalSize': totalSize, + 'imagesSizeFormatted': formatFileSize(imagesSize), + 'thumbnailsSizeFormatted': formatFileSize(thumbnailsSize), + 'totalSizeFormatted': formatFileSize(totalSize), + }; + } catch (error, stackTrace) { + Logger.error('获取存储使用情况失败', error: error, stackTrace: stackTrace); + return { + 'imagesSize': 0, + 'thumbnailsSize': 0, + 'totalSize': 0, + 'imagesSizeFormatted': '0 B', + 'thumbnailsSizeFormatted': '0 B', + 'totalSizeFormatted': '0 B', + }; + } + } +} + +/// 文件信息类 +class FileInfo { + final String path; + final String name; + final String extension; + final int size; + final DateTime modifiedTime; + final bool exists; + + FileInfo({ + required this.path, + required this.name, + required this.extension, + required this.size, + required this.modifiedTime, + required this.exists, + }); + + factory FileInfo.fromFile(File file, [String? filePath]) { + final path = filePath ?? file.path; + final stat = file.statSync(); + + return FileInfo( + path: path, + name: file.uri.pathSegments.last, + extension: FileUtils.getFileExtension(path), + size: stat.size, + modifiedTime: stat.modified, + exists: stat.type != FileSystemEntityType.notFound, + ); + } + + String get sizeFormatted => FileUtils.formatFileSize(size); + + @override + String toString() { + return 'FileInfo{path: $path, name: $name, size: $sizeFormatted, modified: $modifiedTime}'; + } +} \ No newline at end of file diff --git a/lib/core/utils/image_utils.dart b/lib/core/utils/image_utils.dart new file mode 100644 index 0000000..8c6ce66 --- /dev/null +++ b/lib/core/utils/image_utils.dart @@ -0,0 +1,474 @@ +import 'dart:io'; +import 'package:flutter_image_compress/flutter_image_compress.dart'; +import 'package:image/image.dart' as img; +import 'package:path/path.dart' as path; +import 'package:uuid/uuid.dart'; +import '../constants/app_constants.dart'; +import '../errors/app_error.dart'; +import 'logger.dart'; + +/// 图片处理工具类 +/// 提供图片压缩、格式转换、缩略图生成等功能 +class ImageUtils { + // 私有构造函数,防止实例化 + ImageUtils._(); + + static const Uuid _uuid = Uuid(); + + /// 生成缩略图 + /// [imagePath] 原图路径 + /// [targetPath] 缩略图保存路径 + /// [maxSize] 缩略图最大尺寸(长边) + /// [quality] 压缩质量(0-100) + static Future generateThumbnail({ + required String imagePath, + required String targetPath, + int maxSize = AppConstants.maxThumbnailSize, + int quality = AppConstants.thumbnailQuality, + }) async { + try { + Logger.debug('开始生成缩略图: $imagePath'); + + // 检查原文件是否存在 + final originalFile = File(imagePath); + if (!await originalFile.exists()) { + throw ImageProcessingError( + message: '原图文件不存在: $imagePath', + code: 'IMAGE_PROCESSING_ERROR', + ); + } + + // 获取文件信息 + final fileSize = await originalFile.length(); + Logger.debug('原图文件大小: ${fileSize / 1024 / 1024}MB'); + + // 检查文件大小限制 + if (fileSize > AppConstants.maxImageSize) { + throw ImageProcessingError( + message: '图片文件过大,最大支持30MB', + code: 'IMAGE_PROCESSING_ERROR', + ); + } + + // 根据文件类型选择处理方式 + final fileExtension = path.extension(imagePath).toLowerCase(); + + if (fileExtension == '.gif') { + return await _generateGifThumbnail( + imagePath: imagePath, + targetPath: targetPath, + maxSize: maxSize, + quality: quality, + ); + } else { + return await _generateStaticImageThumbnail( + imagePath: imagePath, + targetPath: targetPath, + maxSize: maxSize, + quality: quality, + ); + } + } catch (error, stackTrace) { + Logger.error('生成缩略图失败', error: error, stackTrace: stackTrace); + + if (error is ImageProcessingError) { + rethrow; + } + + throw ImageProcessingError( + message: '生成缩略图失败: ${error.toString()}', + code: 'IMAGE_PROCESSING_ERROR', + stackTrace: stackTrace, + ); + } + } + + /// 生成静态图片缩略图 + static Future _generateStaticImageThumbnail({ + required String imagePath, + required String targetPath, + required int maxSize, + required int quality, + }) async { + try { + // 使用 flutter_image_compress 进行压缩 + final result = await FlutterImageCompress.compressAndGetFile( + imagePath, + targetPath, + quality: quality, + minWidth: maxSize, + minHeight: maxSize, + format: CompressFormat.webp, + keepExif: false, + ); + + if (result == null) { + throw ImageProcessingError( + message: '图片压缩失败,返回结果为空', + code: 'IMAGE_PROCESSING_ERROR', + ); + } + + Logger.debug('静态图片缩略图生成成功: ${result.path}'); + return result.path; + } catch (error, stackTrace) { + Logger.error('静态图片压缩失败', error: error, stackTrace: stackTrace); + + // 如果 flutter_image_compress 失败,使用 image 库作为备选方案 + return await _generateThumbnailWithImageLib( + imagePath: imagePath, + targetPath: targetPath, + maxSize: maxSize, + quality: quality, + ); + } + } + + /// 使用 image 库生成缩略图(备选方案) + static Future _generateThumbnailWithImageLib({ + required String imagePath, + required String targetPath, + required int maxSize, + required int quality, + }) async { + try { + // 读取原图 + final originalImage = img.decodeImage(await File(imagePath).readAsBytes()); + if (originalImage == null) { + throw ImageProcessingError( + message: '无法解码图片文件', + code: 'IMAGE_PROCESSING_ERROR', + ); + } + + // 计算缩放比例 + final originalWidth = originalImage.width; + final originalHeight = originalImage.height; + double scale = 1.0; + + if (originalWidth > originalHeight) { + // 横图,以宽度为基准 + if (originalWidth > maxSize) { + scale = maxSize / originalWidth; + } + } else { + // 竖图或方图,以高度为基准 + if (originalHeight > maxSize) { + scale = maxSize / originalHeight; + } + } + + final targetWidth = (originalWidth * scale).round(); + final targetHeight = (originalHeight * scale).round(); + + Logger.debug('图片缩放: ${originalWidth}x$originalHeight -> ${targetWidth}x$targetHeight'); + + // 缩放图片 + final resizedImage = img.copyResize( + originalImage, + width: targetWidth, + height: targetHeight, + ); + + // 保存为JPEG格式(简化处理) + final jpegData = img.encodeJpg(resizedImage, quality: quality); + final thumbnailFile = File(targetPath); + await thumbnailFile.writeAsBytes(jpegData); + + Logger.debug('使用 image 库生成缩略图成功: $targetPath'); + return targetPath; + } catch (error, stackTrace) { + throw ImageProcessingError( + message: '图片库处理失败: ${error.toString()}', + code: 'IMAGE_PROCESSING_ERROR', + stackTrace: stackTrace, + ); + } + } + + /// 生成GIF缩略图 + static Future _generateGifThumbnail({ + required String imagePath, + required String targetPath, + required int maxSize, + required int quality, + }) async { + try { + Logger.debug('处理GIF缩略图: $imagePath'); + + // 读取GIF文件 + final gifFile = File(imagePath); + final gifBytes = await gifFile.readAsBytes(); + + // 解码GIF + final gifImage = img.decodeGif(gifBytes); + if (gifImage == null) { + throw ImageProcessingError( + message: '无法解码GIF文件', + code: 'IMAGE_PROCESSING_ERROR', + ); + } + + // 对于大GIF文件,只提取第一帧作为缩略图 + if (gifImage.length > 1) { + Logger.debug('GIF文件包含 ${gifImage.length} 帧,提取第一帧作为缩略图'); + } + + // 获取第一帧 + final firstFrame = gifImage.frames.isNotEmpty ? gifImage.frames.first : null; + + if (firstFrame == null) { + throw ImageProcessingError( + message: '无法获取GIF的第一帧', + code: 'IMAGE_PROCESSING_ERROR', + ); + } + + // 计算缩放比例 + final originalWidth = firstFrame.width; + final originalHeight = firstFrame.height; + double scale = 1.0; + + if (originalWidth > originalHeight) { + if (originalWidth > maxSize) { + scale = maxSize / originalWidth; + } + } else { + if (originalHeight > maxSize) { + scale = maxSize / originalHeight; + } + } + + final targetWidth = (originalWidth * scale).round(); + final targetHeight = (originalHeight * scale).round(); + + // 缩放第一帧 + final resizedFrame = img.copyResize( + firstFrame, + width: targetWidth, + height: targetHeight, + ); + + // 保存为JPEG格式 + final jpegData = img.encodeJpg(resizedFrame, quality: quality); + final thumbnailFile = File(targetPath); + await thumbnailFile.writeAsBytes(jpegData); + + Logger.debug('GIF缩略图生成成功: $targetPath'); + return targetPath; + } catch (error, stackTrace) { + throw ImageProcessingError( + message: 'GIF缩略图生成失败: ${error.toString()}', + code: 'IMAGE_PROCESSING_ERROR', + stackTrace: stackTrace, + ); + } + } + + /// 生成图片的唯一文件名 + /// [extension] 文件扩展名(包含点号) + static String generateUniqueFileName(String extension) { + final timestamp = DateTime.now().millisecondsSinceEpoch; + final uuid = _uuid.v4().substring(0, 8); + return '${timestamp}_$uuid$extension'; + } + + /// 根据MIME类型获取文件扩展名 + static String getExtensionFromMimeType(String mimeType) { + final mimeTypeMap = { + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/png': '.png', + 'image/gif': '.gif', + 'image/webp': '.webp', + 'image/heic': '.heic', + 'image/heif': '.heif', + }; + + return mimeTypeMap[mimeType.toLowerCase()] ?? '.jpg'; + } + + /// 获取图片的基本信息 + static Future> getImageInfo(String imagePath) async { + try { + final file = File(imagePath); + if (!await file.exists()) { + throw ImageProcessingError( + message: '图片文件不存在: $imagePath', + code: 'IMAGE_PROCESSING_ERROR', + ); + } + + final bytes = await file.readAsBytes(); + final image = img.decodeImage(bytes); + + if (image == null) { + throw ImageProcessingError( + message: '无法解码图片文件', + code: 'IMAGE_PROCESSING_ERROR', + ); + } + + return { + 'width': image.width, + 'height': image.height, + 'size': bytes.length, + 'format': path.extension(imagePath).toLowerCase(), + 'mimeType': _getMimeType(imagePath), + }; + } catch (error, stackTrace) { + Logger.error('获取图片信息失败', error: error, stackTrace: stackTrace); + + if (error is ImageProcessingError) { + rethrow; + } + + throw ImageProcessingError( + message: '获取图片信息失败: ${error.toString()}', + code: 'IMAGE_PROCESSING_ERROR', + stackTrace: stackTrace, + ); + } + } + + /// 根据文件路径获取MIME类型 + static String _getMimeType(String filePath) { + final extension = path.extension(filePath).toLowerCase(); + final mimeTypeMap = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + }; + + return mimeTypeMap[extension] ?? 'image/jpeg'; + } + + /// 检查图片文件是否有效 + static Future isValidImage(String imagePath) async { + try { + final file = File(imagePath); + if (!await file.exists()) { + return false; + } + + final fileSize = await file.length(); + if (fileSize == 0 || fileSize > AppConstants.maxImageSize) { + return false; + } + + final extension = path.extension(imagePath).toLowerCase(); + const validExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.heif']; + + if (!validExtensions.contains(extension)) { + return false; + } + + // 尝试解码图片 + final bytes = await file.readAsBytes(); + final image = img.decodeImage(bytes); + + return image != null; + } catch (error) { + Logger.error('图片验证失败', error: error); + return false; + } + } + + /// 计算图片的宽高比 + static double calculateAspectRatio(int width, int height) { + if (width <= 0 || height <= 0) { + return 1.0; + } + return width / height; + } + + /// 根据目标宽度计算等比例高度 + static int calculateHeightForWidth(int originalWidth, int originalHeight, int targetWidth) { + if (originalWidth <= 0) { + return originalHeight; + } + return (originalHeight * targetWidth / originalWidth).round(); + } + + /// 根据目标高度计算等比例宽度 + static int calculateWidthForHeight(int originalWidth, int originalHeight, int targetHeight) { + if (originalHeight <= 0) { + return originalWidth; + } + return (originalWidth * targetHeight / originalHeight).round(); + } +} + +/// 图片处理结果 +class ImageProcessingResult { + final String originalPath; + final String thumbnailPath; + final Map metadata; + final bool success; + final String? errorMessage; + + ImageProcessingResult({ + required this.originalPath, + required this.thumbnailPath, + required this.metadata, + this.success = true, + this.errorMessage, + }); + + factory ImageProcessingResult.error(String originalPath, String errorMessage) { + return ImageProcessingResult( + originalPath: originalPath, + thumbnailPath: '', + metadata: {}, + success: false, + errorMessage: errorMessage, + ); + } +} + +/// 支持的图片格式 +class SupportedImageFormats { + static const List formats = [ + 'jpg', + 'jpeg', + 'png', + 'gif', + 'webp', + 'heic', + 'heif', + ]; + + static const List extensions = [ + '.jpg', + '.jpeg', + '.png', + '.gif', + '.webp', + '.heic', + '.heif', + ]; + + /// 检查文件扩展名是否支持 + static bool isSupported(String filePath) { + final extension = path.extension(filePath).toLowerCase(); + return extensions.contains(extension); + } + + /// 检查MIME类型是否支持 + static bool isMimeTypeSupported(String mimeType) { + final supportedMimeTypes = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/heic', + 'image/heif', + ]; + return supportedMimeTypes.contains(mimeType.toLowerCase()); + } +} \ No newline at end of file diff --git a/lib/core/utils/logger.dart b/lib/core/utils/logger.dart new file mode 100644 index 0000000..cabda7c --- /dev/null +++ b/lib/core/utils/logger.dart @@ -0,0 +1,260 @@ +import 'dart:developer' as developer; +import 'package:flutter/foundation.dart'; + +/// 日志级别枚举 +enum LogLevel { + debug('DEBUG', 0), + info('INFO', 1), + warning('WARNING', 2), + error('ERROR', 3), + fatal('FATAL', 4); + + final String name; + final int level; + + const LogLevel(this.name, this.level); +} + +/// 日志工具类 +/// 提供统一的日志记录功能,支持不同的日志级别 +class Logger { + // 私有构造函数,防止实例化 + Logger._(); + + /// 当前日志级别(开发模式下为DEBUG,生产模式下为INFO) + static LogLevel _currentLevel = kDebugMode ? LogLevel.debug : LogLevel.info; + + /// 日志标签 + static const String _tag = 'InspoSnap'; + + /// 设置日志级别 + static void setLogLevel(LogLevel level) { + _currentLevel = level; + } + + /// 检查是否应该记录该级别的日志 + static bool _shouldLog(LogLevel level) { + return level.level >= _currentLevel.level; + } + + /// 记录调试日志 + static void debug(String message, {Object? error, StackTrace? stackTrace}) { + if (_shouldLog(LogLevel.debug)) { + _log(LogLevel.debug, message, error: error, stackTrace: stackTrace); + } + } + + /// 记录信息日志 + static void info(String message, {Object? error, StackTrace? stackTrace}) { + if (_shouldLog(LogLevel.info)) { + _log(LogLevel.info, message, error: error, stackTrace: stackTrace); + } + } + + /// 记录警告日志 + static void warning(String message, {Object? error, StackTrace? stackTrace}) { + if (_shouldLog(LogLevel.warning)) { + _log(LogLevel.warning, message, error: error, stackTrace: stackTrace); + } + } + + /// 记录错误日志 + static void error(String message, {Object? error, StackTrace? stackTrace}) { + if (_shouldLog(LogLevel.error)) { + _log(LogLevel.error, message, error: error, stackTrace: stackTrace); + } + } + + /// 记录致命错误日志 + static void fatal(String message, {Object? error, StackTrace? stackTrace}) { + if (_shouldLog(LogLevel.fatal)) { + _log(LogLevel.fatal, message, error: error, stackTrace: stackTrace); + } + } + + /// 内部日志记录方法 + static void _log( + LogLevel level, + String message, { + Object? error, + StackTrace? stackTrace, + }) { + final logMessage = '[$level.name] $message'; + + // 开发环境下使用 developer.log + if (kDebugMode) { + developer.log( + logMessage, + name: _tag, + time: DateTime.now(), + level: level.level, + error: error, + stackTrace: stackTrace, + ); + } + + // 生产环境下可以考虑发送到日志服务器 + if (!kDebugMode && level.level >= LogLevel.error.level) { + // TODO: 在生产环境下实现日志上报功能 + // _reportToServer(level, message, error, stackTrace); + } + } + + /// 记录方法进入(用于调试) + static void enter(String methodName, [Map? parameters]) { + if (_shouldLog(LogLevel.debug)) { + final message = parameters != null + ? 'Entering $methodName with parameters: $parameters' + : 'Entering $methodName'; + debug(message); + } + } + + /// 记录方法退出(用于调试) + static void exit(String methodName, [dynamic result]) { + if (_shouldLog(LogLevel.debug)) { + final message = result != null + ? 'Exiting $methodName with result: $result' + : 'Exiting $methodName'; + debug(message); + } + } + + /// 记录方法执行时间 + static Future measureTime( + String operationName, + Future Function() operation, + ) async { + final stopwatch = Stopwatch()..start(); + + try { + Logger.enter(operationName); + final result = await operation(); + stopwatch.stop(); + Logger.exit(operationName); + Logger.info('$operationName completed in ${stopwatch.elapsedMilliseconds}ms'); + return result; + } catch (error, stackTrace) { + stopwatch.stop(); + Logger.error('$operationName failed after ${stopwatch.elapsedMilliseconds}ms', + error: error, stackTrace: stackTrace); + rethrow; + } + } + + /// 记录同步方法执行时间 + static T measureSyncTime( + String operationName, + T Function() operation, + ) { + final stopwatch = Stopwatch()..start(); + + try { + Logger.enter(operationName); + final result = operation(); + stopwatch.stop(); + Logger.exit(operationName); + Logger.info('$operationName completed in ${stopwatch.elapsedMilliseconds}ms'); + return result; + } catch (error, stackTrace) { + stopwatch.stop(); + Logger.error('$operationName failed after ${stopwatch.elapsedMilliseconds}ms', + error: error, stackTrace: stackTrace); + rethrow; + } + } + + /// 记录对象状态(用于调试) + static void dumpObject(String objectName, Object object) { + if (_shouldLog(LogLevel.debug)) { + debug('$objectName state: ${object.toString()}'); + } + } + + /// 记录异常信息 + static void logException( + String context, + Object error, { + StackTrace? stackTrace, + bool isFatal = false, + }) { + final logLevel = isFatal ? LogLevel.fatal : LogLevel.error; + final message = 'Exception in $context: ${error.toString()}'; + + _log(logLevel, message, error: error, stackTrace: stackTrace); + } + + /// 清理日志(如果有本地日志文件) + static Future clearLogs() async { + // TODO: 如果有本地日志文件,实现清理逻辑 + info('Logs cleared'); + } + + /// 获取日志级别名称 + static String getLevelName(LogLevel level) { + return level.name; + } + + /// 检查是否处于调试模式 + static bool get isDebugMode => kDebugMode; + + /// 检查是否处于发布模式 + static bool get isReleaseMode => kReleaseMode; + + /// 获取当前日志级别 + static LogLevel get currentLevel => _currentLevel; +} + +/// 日志混入类,方便在类中使用日志功能 +mixin LoggerMixin { + /// 记录调试日志 + void logDebug(String message, {Object? error, StackTrace? stackTrace}) { + Logger.debug(message, error: error, stackTrace: stackTrace); + } + + /// 记录信息日志 + void logInfo(String message, {Object? error, StackTrace? stackTrace}) { + Logger.info(message, error: error, stackTrace: stackTrace); + } + + /// 记录警告日志 + void logWarning(String message, {Object? error, StackTrace? stackTrace}) { + Logger.warning(message, error: error, stackTrace: stackTrace); + } + + /// 记录错误日志 + void logError(String message, {Object? error, StackTrace? stackTrace}) { + Logger.error(message, error: error, stackTrace: stackTrace); + } + + /// 记录致命错误日志 + void logFatal(String message, {Object? error, StackTrace? stackTrace}) { + Logger.fatal(message, error: error, stackTrace: stackTrace); + } + + /// 记录方法进入(用于调试) + void logEnter(String methodName, [Map? parameters]) { + Logger.enter('$runtimeType.$methodName', parameters); + } + + /// 记录方法退出(用于调试) + void logExit(String methodName, [dynamic result]) { + Logger.exit('$runtimeType.$methodName', result); + } + + /// 记录方法执行时间 + Future logMeasureTime( + String operationName, + Future Function() operation, + ) async { + return await Logger.measureTime('$runtimeType.$operationName', operation); + } + + /// 记录同步方法执行时间 + T logMeasureSyncTime( + String operationName, + T Function() operation, + ) { + return Logger.measureSyncTime('$runtimeType.$operationName', operation); + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 0f085fe..8524eb0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,139 +1,35 @@ import 'package:flutter/material.dart'; -import 'pages/photo_page.dart'; -import 'pages/categories_page.dart'; -import 'pages/add_photo_page.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'core/constants/app_constants.dart'; +import 'core/utils/logger.dart'; +import 'presentation/app_widget.dart'; -void main() { - runApp(const SnapWishApp()); +/// 应用程序主入口 +/// 负责初始化应用并配置全局设置 +void main() async { + // 确保Flutter绑定已初始化 + WidgetsFlutterBinding.ensureInitialized(); + + // 初始化日志系统 + Logger.setLogLevel(LogLevel.info); + Logger.info('应用启动 - ${AppConstants.appName} v${AppConstants.appVersion}'); + + // 捕获并记录未处理的异常 + FlutterError.onError = (FlutterErrorDetails details) { + Logger.logException('Flutter Error', details.exception, stackTrace: details.stack); + FlutterError.presentError(details); + }; + + // 运行应用 + runApp( + const ProviderScope( + child: AppWidget(), + ), + ); } -class SnapWishApp extends StatelessWidget { - const SnapWishApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'SnapWish', - debugShowCheckedModeBanner: false, - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - useMaterial3: true, - ), - home: const MainPage(), - routes: { - '/add-photo': (context) => const AddPhotoPage(), - }, - ); - } -} - -class MainPage extends StatefulWidget { - const MainPage({super.key}); - - @override - State createState() => _MainPageState(); -} - -class _MainPageState extends State { - int _currentIndex = 0; - - final List _pages = [ - const PhotoPage(), - const CategoriesPage(), - ]; - - @override - Widget build(BuildContext context) { - return Scaffold( - extendBody: true, // 允许内容延伸到导航栏区域 - body: Stack( - children: [ - _pages[_currentIndex], - // 悬浮底部导航组件 - Positioned( - left: 20, - right: 20, - bottom: 80, - child: Container( - height: 70, - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.95), - borderRadius: BorderRadius.circular(35), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.2), - blurRadius: 10, - offset: const Offset(0, 5), - ), - ], - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - // 照片标签按钮 - IconButton( - onPressed: () { - setState(() { - _currentIndex = 0; - }); - }, - icon: Icon( - Icons.photo_library, - color: _currentIndex == 0 - ? Colors.deepPurple - : Colors.grey, - size: 28, - ), - ), - - // 添加按钮 - GestureDetector( - onTap: () { - Navigator.pushNamed(context, '/add-photo'); - }, - child: Container( - width: 50, - height: 50, - decoration: BoxDecoration( - color: Colors.deepPurple, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: Colors.deepPurple.withOpacity(0.3), - blurRadius: 8, - offset: const Offset(0, 3), - ), - ], - ), - child: const Icon( - Icons.add, - color: Colors.white, - size: 30, - ), - ), - ), - - // 分类标签按钮 - IconButton( - onPressed: () { - setState(() { - _currentIndex = 1; - }); - }, - icon: Icon( - Icons.folder, - color: _currentIndex == 1 - ? Colors.deepPurple - : Colors.grey, - size: 28, - ), - ), - ], - ), - ), - ), - ], - ), - ); - } -} +/// 全局错误处理 +/// 捕获并记录未处理的异步异常 +void handleGlobalError(Object error, StackTrace stackTrace) { + Logger.logException('Global Error', error, stackTrace: stackTrace, isFatal: true); +} \ No newline at end of file diff --git a/lib/pages/add_photo_page.dart b/lib/pages/add_photo_page.dart deleted file mode 100644 index d0a950a..0000000 --- a/lib/pages/add_photo_page.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'package:flutter/material.dart'; - -class AddPhotoPage extends StatefulWidget { - const AddPhotoPage({super.key}); - - @override - State createState() => _AddPhotoPageState(); -} - -class _AddPhotoPageState extends State { - String? _selectedCategory; - final TextEditingController _tagsController = TextEditingController(); - final List _tags = []; - - // TODO: 替换为真实文件夹数据 - final List _categories = ['默认分类', '人像', '风景', '建筑']; - - @override - void dispose() { - _tagsController.dispose(); - super.dispose(); - } - - void _selectPhoto() { - // TODO: 实现选择本地照片功能 - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('选择照片功能待实现')), - ); - } - - void _addTag(String text) { - final tag = text.trim(); - if (tag.isNotEmpty && !_tags.contains(tag)) { - setState(() { - _tags.add(tag); - }); - _tagsController.clear(); - } - } - - void _removeTag(String tag) { - setState(() { - _tags.remove(tag); - }); - } - - void _savePhoto() { - if (_selectedCategory == null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('请选择文件夹')), - ); - return; - } - - // TODO: 实现保存照片功能 - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('照片已保存到 $_selectedCategory')), - ); - Navigator.pop(context); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('添加照片'), - actions: [ - TextButton( - onPressed: _savePhoto, - child: const Text('保存', style: TextStyle(color: Colors.white)), - ), - ], - ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 照片预览区域 - GestureDetector( - onTap: _selectPhoto, - child: Container( - height: 200, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey[400]!), - ), - child: const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey), - SizedBox(height: 8), - Text('点击选择照片', style: TextStyle(color: Colors.grey)), - ], - ), - ), - ), - ), - const SizedBox(height: 24), - - // 文件夹选择 - const Text('选择文件夹', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - DropdownButtonFormField( - value: _selectedCategory, - decoration: const InputDecoration( - border: OutlineInputBorder(), - hintText: '请选择文件夹', - ), - items: _categories.map((category) { - return DropdownMenuItem( - value: category, - child: Text(category), - ); - }).toList(), - onChanged: (value) { - setState(() { - _selectedCategory = value; - }); - }, - ), - const SizedBox(height: 24), - - // 标签输入 - const Text('添加标签', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - TextField( - controller: _tagsController, - decoration: InputDecoration( - border: const OutlineInputBorder(), - hintText: '输入标签(支持 #标签 格式)', - suffixIcon: IconButton( - icon: const Icon(Icons.add), - onPressed: () => _addTag(_tagsController.text), - ), - ), - onSubmitted: _addTag, - onChanged: (value) { - // 处理 # 开头的标签输入 - if (value.endsWith(' ') || value.endsWith('#')) { - final parts = value.split('#'); - if (parts.length > 1) { - final tag = parts.last.trim(); - if (tag.isNotEmpty) { - _addTag(tag); - } - } - } - }, - ), - const SizedBox(height: 12), - - // 标签列表 - Wrap( - spacing: 8, - runSpacing: 4, - children: _tags.map((tag) => Chip( - label: Text('#$tag'), - onDeleted: () => _removeTag(tag), - deleteIcon: const Icon(Icons.close, size: 16), - )).toList(), - ), - ], - ), - ), - ); - } -} \ No newline at end of file diff --git a/lib/pages/categories_page.dart b/lib/pages/categories_page.dart deleted file mode 100644 index 1cbeaf7..0000000 --- a/lib/pages/categories_page.dart +++ /dev/null @@ -1,191 +0,0 @@ -import 'package:flutter/material.dart'; - -class CategoriesPage extends StatefulWidget { - const CategoriesPage({super.key}); - - @override - State createState() => _CategoriesPageState(); -} - -class _CategoriesPageState extends State { - // TODO: 替换为真实文件夹数据 - final List> _categories = [ - {'name': '默认分类', 'count': 15, 'isDefault': true}, - {'name': '人像', 'count': 8, 'isDefault': false}, - {'name': '风景', 'count': 12, 'isDefault': false}, - {'name': '建筑', 'count': 5, 'isDefault': false}, - ]; - - void _createCategory() { - final controller = TextEditingController(); - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('创建新文件夹'), - content: TextField( - controller: controller, - decoration: const InputDecoration( - hintText: '输入文件夹名称', - border: OutlineInputBorder(), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('取消'), - ), - TextButton( - onPressed: () { - if (controller.text.isNotEmpty) { - setState(() { - _categories.add({ - 'name': controller.text, - 'count': 0, - 'isDefault': false, - }); - }); - Navigator.pop(context); - } - }, - child: const Text('创建'), - ), - ], - ), - ); - } - - void _deleteCategory(int index) { - final category = _categories[index]; - if (category['isDefault']) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('默认分类不能删除')), - ); - return; - } - - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('删除文件夹'), - content: Text('确定要删除"${category['name']}"吗?\n${category['count']}张照片将被移至默认分类。'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('取消'), - ), - TextButton( - onPressed: () { - setState(() { - // 将照片移至默认分类 - _categories[0]['count'] += category['count']; - _categories.removeAt(index); - }); - Navigator.pop(context); - }, - style: TextButton.styleFrom(foregroundColor: Colors.red), - child: const Text('删除'), - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Stack( - children: [ - ListView.builder( - padding: const EdgeInsets.only(top: 140, bottom: 16), - itemCount: _categories.length + 1, // +1 for header - itemBuilder: (context, index) { - if (index == 0) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - '分类', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.deepPurple, - ), - ), - IconButton( - icon: const Icon(Icons.create_new_folder, color: Colors.deepPurple), - onPressed: _createCategory, - ), - ], - ), - ); - } - - final categoryIndex = index - 1; - final category = _categories[categoryIndex]; - return Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: ListTile( - leading: CircleAvatar( - backgroundColor: category['isDefault'] - ? Colors.blue - : Colors.deepPurple, - child: const Icon(Icons.folder, color: Colors.white), - ), - title: Text( - category['name'], - style: const TextStyle(fontWeight: FontWeight.bold), - ), - subtitle: Text('${category['count']} 张照片'), - trailing: category['isDefault'] - ? null - : IconButton( - icon: const Icon(Icons.delete_outline, color: Colors.red), - onPressed: () => _deleteCategory(categoryIndex), - ), - onTap: () { - // TODO: 打开该分类的照片列表 - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('打开 ${category['name']}')) - ); - }, - ), - ); - }, - ), - // 悬浮标题栏 - Positioned( - top: 0, - left: 0, - right: 0, - child: Container( - height: 80, - padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.95), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: const Center( - child: Text( - 'SnapWish', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.deepPurple, - ), - ), - ), - ), - ), - ], - ), - ); - } -} \ No newline at end of file diff --git a/lib/pages/photo_page.dart b/lib/pages/photo_page.dart deleted file mode 100644 index 7bc446d..0000000 --- a/lib/pages/photo_page.dart +++ /dev/null @@ -1,345 +0,0 @@ -import 'package:flutter/material.dart'; -import 'dart:math'; - -class PhotoPage extends StatelessWidget { - const PhotoPage({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Stack( - children: [ - const PhotoTimeline(), - Positioned( - top: 0, - left: 0, - right: 0, - child: Container( - height: 80, - padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.95), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: const Center( - child: Text( - 'SnapWish', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.deepPurple, - ), - ), - ), - ), - ), - ], - ), - ); - } -} - -class PhotoTimeline extends StatelessWidget { - const PhotoTimeline({super.key}); - - // 生成带日期的模拟照片数据 - Map>> _groupPhotosByDate() { - final random = Random(); - final now = DateTime.now(); - final photos = >[]; - - // 生成过去30天的模拟数据 - for (int i = 0; i < 30; i++) { - final date = now.subtract(Duration(days: i)); - final photoCount = random.nextInt(5) + 1; // 每天1-5张照片 - - for (int j = 0; j < photoCount; j++) { - photos.add({ - 'id': 'photo_${i}_$j', - 'title': '照片 ${i * 5 + j + 1}', - 'date': date, - }); - } - } - - // 按日期分组 - final grouped = >>{}; - for (final photo in photos) { - final date = photo['date'] as DateTime; - final key = _formatDateKey(date); - grouped.putIfAbsent(key, () => []).add(photo); - } - - return grouped; - } - - String _formatDateKey(DateTime date) { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - final yesterday = today.subtract(const Duration(days: 1)); - final dateDay = DateTime(date.year, date.month, date.day); - - if (dateDay == today) { - return '今天'; - } else if (dateDay == yesterday) { - return '昨天'; - } else if (date.year == now.year) { - return '${date.month}月${date.day}日'; - } else { - return '${date.year}年${date.month}月${date.day}日'; - } - } - - @override - Widget build(BuildContext context) { - final groupedPhotos = _groupPhotosByDate(); - final sortedKeys = groupedPhotos.keys.toList() - ..sort((a, b) { - // 将中文日期转换为可排序的格式 - final dateA = _parseDateKey(a); - final dateB = _parseDateKey(b); - return dateB.compareTo(dateA); // 降序排列 - }); - - return ListView.builder( - padding: const EdgeInsets.only(top: 88, bottom: 16), - itemCount: sortedKeys.length, - itemBuilder: (context, index) { - final dateKey = sortedKeys[index]; - final photos = groupedPhotos[dateKey]!; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - dateKey, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.deepPurple, - ), - ), - ), - GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: 16), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 4, - mainAxisSpacing: 4, - childAspectRatio: 1, - ), - itemCount: photos.length, - itemBuilder: (context, photoIndex) { - final photo = photos[photoIndex]; - return GestureDetector( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => PhotoDetailPage( - photos: photos.map((p) => p['title'] as String).toList(), - initialIndex: photoIndex, - ), - ), - ); - }, - child: Container( - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(8), - ), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.photo, size: 40, color: Colors.grey), - const SizedBox(height: 4), - Text( - photo['title'] as String, - style: const TextStyle(fontSize: 12), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ), - ); - }, - ), - ], - ); - }, - ); - } - - DateTime _parseDateKey(String key) { - final now = DateTime.now(); - - if (key == '今天') { - return now; - } else if (key == '昨天') { - return now.subtract(const Duration(days: 1)); - } else if (key.contains('年')) { - // 格式:2024年5月15日 - final match = RegExp(r'(\d+)年(\d+)月(\d+)日').firstMatch(key); - if (match != null) { - return DateTime( - int.parse(match.group(1)!), - int.parse(match.group(2)!), - int.parse(match.group(3)!), - ); - } - } else { - // 格式:5月15日 - final match = RegExp(r'(\d+)月(\d+)日').firstMatch(key); - if (match != null) { - return DateTime( - now.year, - int.parse(match.group(1)!), - int.parse(match.group(2)!), - ); - } - } - - return now; - } -} - -class PhotoDetailPage extends StatefulWidget { - final List photos; - final int initialIndex; - - const PhotoDetailPage({ - super.key, - required this.photos, - required this.initialIndex, - }); - - @override - State createState() => _PhotoDetailPageState(); -} - -class _PhotoDetailPageState extends State { - late int _currentIndex; - late PageController _pageController; - - @override - void initState() { - super.initState(); - _currentIndex = widget.initialIndex; - _pageController = PageController(initialPage: _currentIndex); - } - - @override - void dispose() { - _pageController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - actions: [ - IconButton( - icon: const Icon(Icons.favorite_border), - onPressed: () { - // TODO: 实现收藏功能 - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('收藏功能待实现')), - ); - }, - ), - IconButton( - icon: const Icon(Icons.share), - onPressed: () { - // TODO: 实现分享功能 - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('分享功能待实现')), - ); - }, - ), - IconButton( - icon: const Icon(Icons.delete), - onPressed: () { - // TODO: 实现删除功能 - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('删除功能待实现')), - ); - }, - ), - ], - ), - body: Stack( - children: [ - PageView.builder( - controller: _pageController, - itemCount: widget.photos.length, - onPageChanged: (index) { - setState(() { - _currentIndex = index; - }); - }, - itemBuilder: (context, index) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: MediaQuery.of(context).size.width, - height: MediaQuery.of(context).size.height * 0.7, - color: Colors.grey[800], - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.photo, size: 100, color: Colors.white), - const SizedBox(height: 20), - Text( - widget.photos[index], - style: const TextStyle( - color: Colors.white, - fontSize: 24, - ), - ), - ], - ), - ), - ), - ], - ), - ); - }, - ), - Positioned( - bottom: 20, - left: 0, - right: 0, - child: Center( - child: Text( - '${_currentIndex + 1} / ${widget.photos.length}', - style: const TextStyle( - color: Colors.white, - fontSize: 16, - ), - ), - ), - ), - ], - ), - ); - } -} \ No newline at end of file diff --git a/lib/presentation/app_widget.dart b/lib/presentation/app_widget.dart new file mode 100644 index 0000000..57e8213 --- /dev/null +++ b/lib/presentation/app_widget.dart @@ -0,0 +1,283 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/foundation.dart'; +import '../core/constants/app_constants.dart'; +import '../core/theme/app_theme.dart'; +import '../core/utils/logger.dart'; + +/// 主应用组件 +/// 负责配置MaterialApp和全局设置 +class AppWidget extends ConsumerWidget { + const AppWidget({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + Logger.debug('构建AppWidget'); + + return MaterialApp( + // 应用基础配置 + title: AppConstants.appName, + debugShowCheckedModeBanner: false, + + // 主题配置 + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: ThemeMode.system, + + // 本地化配置 + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [ + Locale('zh', 'CN'), // 简体中文 + Locale('zh', 'TW'), // 繁体中文 + Locale('en', 'US'), // English + ], + + // 主页配置 + home: const AppHomePage(), + + // 导航观察者 + navigatorObservers: [ + _AppNavigatorObserver(), + ], + + // 构建器配置(用于全局错误处理) + builder: (context, child) { + // 配置文字缩放因子,确保文字大小一致性 + final mediaQueryData = MediaQuery.of(context); + final textScaler = MediaQuery.of(context).textScaler; + final scaleFactor = textScaler.scale(1.0).clamp(0.8, 1.2); + + return MediaQuery( + data: mediaQueryData.copyWith( + textScaler: TextScaler.linear(scaleFactor), + ), + child: child ?? const SizedBox.shrink(), + ); + }, + + // 滚动行为配置 + scrollBehavior: const MaterialScrollBehavior().copyWith( + physics: const ClampingScrollPhysics(), + ), + ); + } +} + +/// 应用主页组件 +/// 作为占位符,后续会替换为实际的主页 +class AppHomePage extends StatefulWidget { + const AppHomePage({super.key}); + + @override + State createState() => _AppHomePageState(); +} + +class _AppHomePageState extends State { + @override + void initState() { + super.initState(); + Logger.debug('AppHomePage初始化'); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + Logger.debug('构建AppHomePage'); + + return Scaffold( + appBar: AppBar( + title: const Text('想拍'), + centerTitle: true, + elevation: 0, + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 应用Logo(占位符) + Icon( + Icons.photo_library, + size: 120, + color: theme.colorScheme.primary.withOpacity(0.8), + ), + const SizedBox(height: 32), + + // 欢迎文字 + Text( + '欢迎使用想拍', + style: theme.textTheme.headlineMedium?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + + // 副标题 + Text( + 'Shoot What Inspires You', + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.7), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + + // 功能说明 + Text( + '从其他应用分享图片到这里,\n开始收集你的灵感吧!', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + + // 开始使用按钮 + ElevatedButton.icon( + onPressed: () { + // TODO: 导航到图库页面 + Logger.debug('点击开始使用按钮'); + }, + icon: const Icon(Icons.arrow_forward), + label: const Text('开始使用'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(25), + ), + ), + ), + ], + ), + ), + + // 底部导航栏(占位符) + bottomNavigationBar: NavigationBar( + destinations: const [ + NavigationDestination( + icon: Icon(Icons.photo_library), + label: '图库', + ), + NavigationDestination( + icon: Icon(Icons.folder), + label: '文件夹', + ), + NavigationDestination( + icon: Icon(Icons.label), + label: '标签', + ), + NavigationDestination( + icon: Icon(Icons.settings), + label: '设置', + ), + ], + onDestinationSelected: (index) { + Logger.debug('底部导航选择: $index'); + // TODO: 实现页面切换逻辑 + }, + ), + ); + } +} + +/// 导航观察者 +/// 用于记录页面导航事件 +class _AppNavigatorObserver extends NavigatorObserver { + @override + void didPush(Route route, Route? previousRoute) { + if (route.settings.name != null) { + Logger.debug('页面进入: ${route.settings.name}'); + } + } + + @override + void didPop(Route route, Route? previousRoute) { + if (route.settings.name != null) { + Logger.debug('页面退出: ${route.settings.name}'); + } + } + + @override + void didRemove(Route route, Route? previousRoute) { + if (route.settings.name != null) { + Logger.debug('页面移除: ${route.settings.name}'); + } + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + if (newRoute?.settings.name != null) { + Logger.debug('页面替换: ${newRoute!.settings.name}'); + } + } +} + +/// 应用配置混入 +mixin AppConfigMixin { + /// 获取应用名称 + String get appName => AppConstants.appName; + + /// 获取应用版本 + String get appVersion => AppConstants.appVersion; + + /// 获取应用描述 + String get appDescription => AppConstants.appDescription; + + /// 检查是否为调试模式 + bool get isDebugMode => kDebugMode; + + /// 检查是否为发布模式 + bool get isReleaseMode => kReleaseMode; + + /// 获取当前时间戳 + int get currentTimestamp => DateTime.now().millisecondsSinceEpoch; + + /// 格式化文件大小 + String formatFileSize(int bytes) { + if (bytes <= 0) return '0 B'; + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + int unitIndex = 0; + double size = bytes.toDouble(); + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return '${size.toStringAsFixed(2)} ${units[unitIndex]}'; + } + + /// 格式化日期 + String formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + + /// 计算时间差(友好显示) + String formatTimeDifference(DateTime from, DateTime to) { + final difference = to.difference(from); + + if (difference.inDays > 0) { + return '${difference.inDays}天前'; + } else if (difference.inHours > 0) { + return '${difference.inHours}小时前'; + } else if (difference.inMinutes > 0) { + return '${difference.inMinutes}分钟前'; + } else { + return '刚刚'; + } + } +} + +/// 应用混入 +/// 组合所有应用相关的混入 +mixin AppMixin on AppConfigMixin, LoggerMixin { + // 可以在这里添加更多通用的应用功能 +} \ No newline at end of file diff --git a/lib/presentation/l10n/app_localizations.dart b/lib/presentation/l10n/app_localizations.dart new file mode 100644 index 0000000..4495770 --- /dev/null +++ b/lib/presentation/l10n/app_localizations.dart @@ -0,0 +1,264 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; + +/// 应用本地化支持类 +/// 管理多语言翻译和区域设置 +class AppLocalizations { + final Locale locale; + + AppLocalizations(this.locale); + + /// 获取本地化实例 + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + /// 本地化委托 + static const AppLocalizationsDelegate delegate = AppLocalizationsDelegate(); + + /// 支持的语言列表 + static const supportedLocales = [ + Locale('zh', 'CN'), // 简体中文 + Locale('zh', 'TW'), // 繁体中文 + Locale('en', 'US'), // English + ]; + + /// 本地化代理列表 + static const localizationsDelegates = [ + AppLocalizationsDelegate(), + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ]; + + // 基础本地化字符串 + String get appName { + switch (locale.languageCode) { + case 'zh': + return locale.countryCode == 'TW' ? '想拍' : '想拍'; + case 'en': + default: + return 'InspoSnap'; + } + } + + String get appDescription { + switch (locale.languageCode) { + case 'zh': + return locale.countryCode == 'TW' + ? '拍攝什麼激發你的靈感' + : '拍摄什么激发你的灵感'; + case 'en': + default: + return 'Shoot What Inspires You'; + } + } + + // 导航相关 + String get gallery => _getLocalizedString('图库', '圖庫', 'Gallery'); + String get folders => _getLocalizedString('文件夹', '資料夾', 'Folders'); + String get tags => _getLocalizedString('标签', '標籤', 'Tags'); + String get settings => _getLocalizedString('设置', '設定', 'Settings'); + + // 欢迎页面 + String get welcomeMessage => _getLocalizedString('欢迎使用想拍', '歡迎使用想拍', 'Welcome to InspoSnap'); + String get welcomeSubtitle => _getLocalizedString('拍摄什么激发你的灵感', '拍攝什麼激發你的靈感', 'Shoot What Inspires You'); + String get featureDescription => _getLocalizedString( + '从其他应用分享图片到这里,\n开始收集你的灵感吧!', + '從其他應用分享圖片到這裡,\n開始收集你的靈感吧!', + 'Share images from other apps here,\nand start collecting your inspirations!', + ); + String get startUsing => _getLocalizedString('开始使用', '開始使用', 'Start Using'); + + // 通用操作 + String get save => _getLocalizedString('保存', '儲存', 'Save'); + String get cancel => _getLocalizedString('取消', '取消', 'Cancel'); + String get delete => _getLocalizedString('删除', '刪除', 'Delete'); + String get edit => _getLocalizedString('编辑', '編輯', 'Edit'); + String get search => _getLocalizedString('搜索', '搜尋', 'Search'); + String get share => _getLocalizedString('分享', '分享', 'Share'); + String get export => _getLocalizedString('导出', '匯出', 'Export'); + String get confirm => _getLocalizedString('确认', '確認', 'Confirm'); + String get close => _getLocalizedString('关闭', '關閉', 'Close'); + + // 错误消息 + String get errorTitle => _getLocalizedString('出错了', '出錯了', 'Error'); + String get errorUnknown => _getLocalizedString('出了点小问题,请稍后重试', '出了點小問題,請稍後重試', 'Something went wrong, please try again later'); + String get errorNetwork => _getLocalizedString('网络连接异常,请检查网络后重试', '網路連線異常,請檢查網路後重試', 'Network connection error, please check your connection and try again'); + String get errorStorage => _getLocalizedString('存储空间不足,请清理空间后重试', '儲存空間不足,請清理空間後重試', 'Storage space insufficient, please free up space and try again'); + String get errorPermission => _getLocalizedString('需要相关权限才能继续使用', '需要相關權限才能繼續使用', 'Related permissions are required to continue'); + String get errorImageProcessing => _getLocalizedString('图片处理失败,请检查图片格式或重试', '圖片處理失敗,請檢查圖片格式或重試', 'Image processing failed, please check the image format or try again'); + String get errorShareReceive => _getLocalizedString('出了点小问题,请重新分享试试~', '出了點小問題,請重新分享試試~', 'Something went wrong, please try sharing again~'); + + // 成功消息 + String get successSaved => _getLocalizedString('保存成功', '儲存成功', 'Saved successfully'); + String get successDeleted => _getLocalizedString('删除成功', '刪除成功', 'Deleted successfully'); + String get successUpdated => _getLocalizedString('更新成功', '更新成功', 'Updated successfully'); + + // 空状态 + String get emptyGallery => _getLocalizedString('还没有灵感?去其他App分享图片到这里吧~', '還沒有靈感?去其他App分享圖片到這裡吧~', 'No inspirations yet? Go share images from other apps here~'); + String get emptyFolder => _getLocalizedString('这个文件夹还在等它的第一张灵感图片', '這個文件夾還在等它的第一張靈感圖片', 'This folder is still waiting for its first inspiration image'); + String get emptySearch => _getLocalizedString('换个关键词试试?或者去收集更多灵感吧!', '換個關鍵詞試試?或者去收集更多靈感吧!', 'Try a different keyword? Or go collect more inspirations!'); + + // 主题相关 + String get themeSystem => _getLocalizedString('跟随系统', '跟隨系統', 'Follow System'); + String get themeLight => _getLocalizedString('浅色模式', '淺色模式', 'Light Mode'); + String get themeDark => _getLocalizedString('深色模式', '深色模式', 'Dark Mode'); + String get themeDescriptionSystem => _getLocalizedString('根据系统设置自动切换浅色和深色模式', '根據系統設定自動切換淺色和深色模式', 'Automatically switch between light and dark mode based on system settings'); + String get themeDescriptionLight => _getLocalizedString('始终使用浅色模式', '始終使用淺色模式', 'Always use light mode'); + String get themeDescriptionDark => _getLocalizedString('始终使用深色模式', '始終使用深色模式', 'Always use dark mode'); + + // 语言设置 + String get languageSimplifiedChinese => _getLocalizedString('简体中文', '簡體中文', 'Simplified Chinese'); + String get languageTraditionalChinese => _getLocalizedString('繁体中文', '繁體中文', 'Traditional Chinese'); + String get languageEnglish => _getLocalizedString('English', 'English', 'English'); + + // 设置相关 + String get settingsAppearance => _getLocalizedString('外观设置', '外觀設定', 'Appearance'); + String get settingsStorage => _getLocalizedString('存储管理', '儲存管理', 'Storage Management'); + String get settingsAbout => _getLocalizedString('关于应用', '關於應用', 'About App'); + String get settingsLanguage => _getLocalizedString('语言设置', '語言設定', 'Language'); + String get settingsTheme => _getLocalizedString('主题模式', '主題模式', 'Theme Mode'); + String get settingsGridLayout => _getLocalizedString('网格布局', '網格佈局', 'Grid Layout'); + String get settingsStorageUsage => _getLocalizedString('存储使用情况', '儲存使用情況', 'Storage Usage'); + String get settingsClearCache => _getLocalizedString('清理缓存', '清理緩存', 'Clear Cache'); + String get settingsVersion => _getLocalizedString('版本信息', '版本資訊', 'Version Info'); + String get settingsPrivacyPolicy => _getLocalizedString('隐私政策', '隱私政策', 'Privacy Policy'); + String get settingsTermsOfService => _getLocalizedString('用户协议', '使用者協議', 'Terms of Service'); + + // 分享相关 + String get shareReceiveTitle => _getLocalizedString('保存图片', '儲存圖片', 'Save Image'); + String get shareReceiveMultiple => _getLocalizedString('共 %d 张图片', '共 %d 張圖片', '%d images'); + String get shareReceiveFolder => _getLocalizedString('选择文件夹', '選擇文件夾', 'Select Folder'); + String get shareReceiveTags => _getLocalizedString('添加标签', '新增標籤', 'Add Tags'); + String get shareReceiveNote => _getLocalizedString('添加备注(可选)', '新增備註(可選)', 'Add Note (Optional)'); + String get shareReceiveBatchMode => _getLocalizedString('批量应用', '批次應用', 'Batch Apply'); + String get shareReceiveSingleMode => _getLocalizedString('单张编辑', '單張編輯', 'Single Edit'); + String get shareReceiveSaveProgress => _getLocalizedString('正在保存 %d/%d', '正在儲存 %d/%d', 'Saving %d/%d'); + + // 文件夹相关 + String get folderUncategorized => _getLocalizedString('未分类', '未分類', 'Uncategorized'); + String get folderCreateNew => _getLocalizedString('新建文件夹', '新建文件夾', 'New Folder'); + String get folderNameHint => _getLocalizedString('请输入文件夹名称', '請輸入文件夾名稱', 'Enter folder name'); + String get folderIcon => _getLocalizedString('选择图标', '選擇圖標', 'Select Icon'); + String get folderColor => _getLocalizedString('选择颜色', '選擇顏色', 'Select Color'); + + // 标签相关 + String get tagCreateNew => _getLocalizedString('创建标签', '建立標籤', 'Create Tag'); + String get tagNameHint => _getLocalizedString('请输入标签名称', '請輸入標籤名稱', 'Enter tag name'); + String get tagMaxLength => _getLocalizedString('标签最多20个字符', '標籤最多20個字元', 'Tag max 20 characters'); + String get tagDuplicate => _getLocalizedString('标签已存在', '標籤已存在', 'Tag already exists'); + + // 图片详情 + String get imageDetails => _getLocalizedString('图片详情', '圖片詳情', 'Image Details'); + String get imageInfo => _getLocalizedString('图片信息', '圖片資訊', 'Image Info'); + String get imageFolder => _getLocalizedString('所属文件夹', '所屬文件夾', 'Folder'); + String get imageTags => _getLocalizedString('标签', '標籤', 'Tags'); + String get imageNote => _getLocalizedString('备注', '備註', 'Note'); + String get imageSize => _getLocalizedString('文件大小', '檔案大小', 'File Size'); + String get imageDimensions => _getLocalizedString('图片尺寸', '圖片尺寸', 'Dimensions'); + String get imageFormat => _getLocalizedString('图片格式', '圖片格式', 'Format'); + String get imageCreated => _getLocalizedString('保存时间', '儲存時間', 'Saved Time'); + + // 删除确认 + String get deleteConfirm => _getLocalizedString('确定要删除吗?', '確定要刪除嗎?', 'Are you sure you want to delete?'); + String get deleteConfirmImage => _getLocalizedString('删除后无法恢复', '刪除後無法恢復', 'Cannot be recovered after deletion'); + String get deleteConfirmFolder => _getLocalizedString('文件夹删除后,其中的图片将移至"未分类"', '文件夾刪除後,其中的圖片將移至"未分類"', 'After folder deletion, images will be moved to "Uncategorized"'); + + /// 获取本地化字符串的通用方法 + String _getLocalizedString(String simplified, String traditional, String english) { + switch (locale.languageCode) { + case 'zh': + return locale.countryCode == 'TW' ? traditional : simplified; + case 'en': + default: + return english; + } + } +} + +/// 本地化委托类 +class AppLocalizationsDelegate extends LocalizationsDelegate { + const AppLocalizationsDelegate(); + + @override + bool isSupported(Locale locale) { + // 检查是否支持该locale + for (final supportedLocale in AppLocalizations.supportedLocales) { + if (supportedLocale.languageCode == locale.languageCode) { + return true; + } + } + return false; + } + + @override + Future load(Locale locale) async { + // 返回对应语言的本地化实例 + return AppLocalizations(locale); + } + + @override + bool shouldReload(AppLocalizationsDelegate old) => false; +} + +/// 本地化扩展方法 +extension LocalizationsContextExtension on BuildContext { + /// 获取本地化实例 + AppLocalizations? get loc => AppLocalizations.of(this); + + /// 获取当前语言代码 + String? get languageCode => loc?.locale.languageCode; + + /// 检查是否为中文 + bool get isChinese => languageCode == 'zh'; + + /// 检查是否为英文 + bool get isEnglish => languageCode == 'en'; + + /// 获取文本方向 + TextDirection get textDirection { + return isEnglish ? TextDirection.ltr : TextDirection.ltr; + } +} + +/// 本地化混入 +mixin LocalizationsMixin { + /// 获取本地化实例 + AppLocalizations? getLocalization(BuildContext context) { + return AppLocalizations.of(context); + } + + /// 获取应用名称 + String getAppName(BuildContext context) { + return getLocalization(context)?.appName ?? '想拍'; + } + + /// 获取应用描述 + String getAppDescription(BuildContext context) { + return getLocalization(context)?.appDescription ?? '拍摄什么激发你的灵感'; + } + + /// 获取错误消息 + String getErrorMessage(BuildContext context, String errorKey) { + final localization = getLocalization(context); + if (localization == null) return '出了点小问题'; + + switch (errorKey) { + case 'unknown': + return localization.errorUnknown; + case 'network': + return localization.errorNetwork; + case 'storage': + return localization.errorStorage; + case 'permission': + return localization.errorPermission; + case 'image_processing': + return localization.errorImageProcessing; + case 'share_receive': + return localization.errorShareReceive; + default: + return localization.errorUnknown; + } + } +} \ No newline at end of file diff --git a/lib/presentation/providers/theme_provider.dart b/lib/presentation/providers/theme_provider.dart new file mode 100644 index 0000000..f0fb0a5 --- /dev/null +++ b/lib/presentation/providers/theme_provider.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/utils/logger.dart'; + +/// 主题状态提供者 +final themeProvider = StateNotifierProvider((ref) { + return ThemeNotifier(ref); +}); + +/// 主题通知器 +class ThemeNotifier extends StateNotifier with LoggerMixin { + final Ref ref; + + ThemeNotifier(this.ref) : super(ThemeMode.system) { + _initializeTheme(); + } + + /// 初始化主题设置 + Future _initializeTheme() async { + logEnter('_initializeTheme'); + + try { + // 默认使用系统主题 + state = ThemeMode.system; + logInfo('主题初始化完成: $state'); + } catch (error, stackTrace) { + logError('主题初始化失败', error: error, stackTrace: stackTrace); + state = ThemeMode.system; + } + + logExit('_initializeTheme'); + } + + /// 设置主题模式 + Future setThemeMode(ThemeMode themeMode) async { + logEnter('setThemeMode', {'themeMode': themeMode.toString()}); + + try { + if (state == themeMode) { + logInfo('主题模式未变化,跳过设置: $themeMode'); + return; + } + + state = themeMode; + + logInfo('主题模式已更新: $themeMode'); + } catch (error, stackTrace) { + logError('设置主题模式失败', error: error, stackTrace: stackTrace); + } + + logExit('setThemeMode'); + } + + /// 切换主题模式 + Future toggleThemeMode() async { + logEnter('toggleThemeMode'); + + try { + final currentMode = state; + ThemeMode newMode; + + switch (currentMode) { + case ThemeMode.system: + newMode = ThemeMode.light; + break; + case ThemeMode.light: + newMode = ThemeMode.dark; + break; + case ThemeMode.dark: + newMode = ThemeMode.system; + break; + } + + await setThemeMode(newMode); + } catch (error, stackTrace) { + logError('切换主题模式失败', error: error, stackTrace: stackTrace); + } + + logExit('toggleThemeMode'); + } + + /// 获取当前主题模式显示名称 + String getCurrentThemeModeName() { + switch (state) { + case ThemeMode.system: + return '跟随系统'; + case ThemeMode.light: + return '浅色模式'; + case ThemeMode.dark: + return '深色模式'; + } + } + + /// 检查是否为深色模式 + bool isDarkMode(BuildContext context) { + switch (state) { + case ThemeMode.system: + return MediaQuery.of(context).platformBrightness == Brightness.dark; + case ThemeMode.light: + return false; + case ThemeMode.dark: + return true; + } + } +} + +/// 主题模式扩展 +extension ThemeModeExtension on ThemeMode { + /// 获取主题模式名称 + String get name { + switch (this) { + case ThemeMode.system: + return '跟随系统'; + case ThemeMode.light: + return '浅色模式'; + case ThemeMode.dark: + return '深色模式'; + } + } + + /// 获取主题模式图标 + IconData get icon { + switch (this) { + case ThemeMode.system: + return Icons.brightness_auto; + case ThemeMode.light: + return Icons.brightness_7; + case ThemeMode.dark: + return Icons.brightness_3; + } + } + + /// 获取主题模式描述 + String get description { + switch (this) { + case ThemeMode.system: + return '根据系统设置自动切换浅色和深色模式'; + case ThemeMode.light: + return '始终使用浅色模式'; + case ThemeMode.dark: + return '始终使用深色模式'; + } + } +} + +/// 主题相关工具函数 +class ThemeUtils { + /// 根据系统亮度获取推荐的主题模式 + static ThemeMode getRecommendedThemeMode(Brightness brightness) { + return brightness == Brightness.dark ? ThemeMode.dark : ThemeMode.light; + } + + /// 检查当前是否为深色模式 + static bool isDarkMode(BuildContext context, ThemeMode themeMode) { + switch (themeMode) { + case ThemeMode.system: + return MediaQuery.of(context).platformBrightness == Brightness.dark; + case ThemeMode.light: + return false; + case ThemeMode.dark: + return true; + } + } + + /// 根据索引获取主题模式 + static ThemeMode getThemeModeByIndex(int index) { + return ThemeMode.values[index.clamp(0, ThemeMode.values.length - 1)]; + } + + /// 根据主题模式获取索引 + static int getIndexByThemeMode(ThemeMode themeMode) { + return ThemeMode.values.indexOf(themeMode); + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 58f638c..6e07901 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,46 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: eb376e9acf6938204f90eb3b1f00b578640d3188b4c8a8ec054f9f479af8d051 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "64.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "69f54f967773f6c26c7dcb13e93d7ccee8b17a641689da39e878d5cf13b06893" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "6.2.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.11.3" + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "4.0.7" + args: + dependency: transitive + description: + name: args + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.5.0" async: dependency: transitive description: @@ -17,6 +57,94 @@ packages: url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted version: "2.1.1" + build: + dependency: transitive + description: + name: build + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.4.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.1.1" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "0343061a33da9c5810b2d6cee51945127d8f4c060b7fbdd9d54917f0a3feaaa1" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "4.0.1" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.4.2" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "3ac61a79bfb6f6cc11f693591063a7f19a7af628dc52f141743edac5c16e8c22" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.4.9" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "4ae8ffe5ac758da294ecf1802f2aff01558d8b1b00616aa7538ea9a8a5d50799" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "7.3.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "8.12.0" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "3.3.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "9e90e78ae72caa874a323d78fa6301b3fb8fa7ea76a8f96dc5b5bf79f283bf2f" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "4.0.0" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "205d6a9f1862de34b93184f22b9d2d94586b2f05c581d546695e3d8f6a805cd7" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.2.0" characters: dependency: transitive description: @@ -25,6 +153,22 @@ packages: url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted version: "1.3.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.0.3" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.4.1" clock: dependency: transitive description: @@ -33,6 +177,14 @@ packages: url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted version: "1.1.1" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "4.10.0" collection: dependency: transitive description: @@ -41,6 +193,30 @@ packages: url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted version: "1.18.0" + convert: + dependency: transitive + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "3.1.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: fedaadfa3a6996f75211d835aaeb8fede285dae94262485698afd832371b9a5e + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.3.3+8" + crypto: + dependency: transitive + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "3.0.3" cupertino_icons: dependency: "direct main" description: @@ -49,6 +225,22 @@ packages: url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted version: "1.0.8" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: a85e8f78f4c52f6c63cdaf8c872eb573db0231dcdf3c3a5906d493c1f8bc20e6 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.6.3" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.3.6" fake_async: dependency: transitive description: @@ -57,32 +249,274 @@ packages: url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.1.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "8207f27539deb83732fdda03e259349046a39a4c767269285f449ade355d54ba" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "3.3.1" + flutter_image_compress: + dependency: "direct main" + description: + name: flutter_image_compress + sha256: "45a3071868092a61b11044c70422b04d39d4d9f2ef536f3c5b11fb65a1e7dd90" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.3.0" + flutter_image_compress_common: + dependency: transitive + description: + name: flutter_image_compress_common + sha256: c5c5d50c15e97dd7dc72ff96bd7077b9f791932f2076c5c5b6c43f2c88607bfb + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.0.6" + flutter_image_compress_macos: + dependency: transitive + description: + name: flutter_image_compress_macos + sha256: "20019719b71b743aba0ef874ed29c50747461e5e8438980dfa5c2031898f7337" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.0.3" + flutter_image_compress_ohos: + dependency: transitive + description: + name: flutter_image_compress_ohos + sha256: e76b92bbc830ee08f5b05962fc78a532011fcd2041f620b5400a593e96da3f51 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.0.3" + flutter_image_compress_platform_interface: + dependency: transitive + description: + name: flutter_image_compress_platform_interface + sha256: "579cb3947fd4309103afe6442a01ca01e1e6f93dc53bb4cbd090e8ce34a41889" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.0.5" + flutter_image_compress_web: + dependency: transitive + description: + name: flutter_image_compress_web + sha256: f02fe352b17f82b72f481de45add240db062a2585850bea1667e82cc4cd6c311 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.1.4+1" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.13.1" flutter_lints: dependency: "direct dev" description: name: flutter_lints - sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "2.0.3" + version: "3.0.2" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.6.1" + flutter_staggered_grid_view: + dependency: "direct main" + description: + name: flutter_staggered_grid_view + sha256: "1312314293acceb65b92754298754801b0e1f26a1845833b740b30415bbbcf07" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.6.2" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.4.4" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.1.2" + graphs: + dependency: transitive + description: + name: graphs + sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.3.1" + hive: + dependency: "direct main" + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.2.3" + hive_flutter: + dependency: "direct main" + description: + name: hive_flutter + sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.1.0" + hive_generator: + dependency: "direct dev" + description: + name: hive_generator + sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.0.1" + http: + dependency: transitive + description: + name: http + sha256: a2bbf9d017fcced29139daa8ed2bba4ece450ab222871df93ca9eec6f80c34ba + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.2.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "4.0.2" + image: + dependency: "direct main" + description: + name: image + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "4.5.4" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.18.1" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.0.4" + js: + dependency: transitive + description: + name: js + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.7.1" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "4.9.0" lints: dependency: transitive description: name: lints - sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "2.1.1" + version: "3.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.2.0" matcher: dependency: transitive description: @@ -99,6 +533,14 @@ packages: url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted version: "0.5.0" + material_design_icons_flutter: + dependency: "direct main" + description: + name: material_design_icons_flutter + sha256: "6f986b7a51f3ad4c00e33c5c84e8de1bdd140489bbcdc8b66fc1283dad4dea5a" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "7.0.7296" meta: dependency: transitive description: @@ -107,19 +549,235 @@ packages: url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted version: "1.10.0" - path: + mime: + dependency: "direct main" + description: + name: mime + sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.0.6" + octo_image: dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.1.0" + path: + dependency: "direct main" description: name: path sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted version: "1.8.3" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.1.4" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: a248d8146ee5983446bf03ed5ea8f6533129a12b11f12057ad1b4a67a2b3b41d + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.2.4" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.4.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "6.0.2" + photo_view: + dependency: "direct main" + description: + name: photo_view + sha256: "8036802a00bae2a78fc197af8a158e3e2f7b500561ed23b4c458107685e645bb" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.14.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.5.1" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "6.0.3" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.1.4" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "81876843eb50dc2e1e5b151792c9a985c5ed2536914115ed04e9c8528f6647b0" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.4.0" + receive_sharing_intent: + dependency: "direct main" + description: + name: receive_sharing_intent + sha256: ec76056e4d258ad708e76d85591d933678625318e411564dcb9059048ca3a593 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.8.1" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.6.1" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: "8b71f03fc47ae27d13769496a1746332df4cec43918aeba9aff1e232783a780f" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.5.1" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.6.1" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: d451608bf17a372025fc36058863737636625dfdb7e3cbf6142e0dfeb366ab22 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.4.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.27.7" + shelf: + dependency: transitive + description: + name: shelf + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.4.1" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.0.4" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.99" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.5.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "6adebc0006c37dd63fe05bca0a929b99f06402fc95aa35bf36d67f5c06de01fd" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.3.4" source_span: dependency: transitive description: @@ -128,6 +786,22 @@ packages: url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted version: "1.10.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: a9016f495c927cb90557c909ff26a6d92d9bd54fc42ba92e19d4e79d61e798c6 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.3.2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "28d8c66baee4968519fb8bd6cdbedad982d6e53359091f0b74544a9f32ec72d5" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.5.3" stack_trace: dependency: transitive description: @@ -136,6 +810,14 @@ packages: url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted version: "1.11.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.0.0" stream_channel: dependency: transitive description: @@ -144,6 +826,14 @@ packages: url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted version: "2.1.2" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -152,6 +842,14 @@ packages: url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted version: "1.2.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "3.1.0+1" term_glyph: dependency: transitive description: @@ -168,6 +866,30 @@ packages: url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted version: "0.6.1" + timing: + dependency: transitive + description: + name: timing + sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.0.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.3.2" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "3.0.7" vector_math: dependency: transitive description: @@ -176,6 +898,14 @@ packages: url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted version: "2.1.4" + watcher: + dependency: transitive + description: + name: watcher + sha256: "5bf046f41320ac97a469d506261797f35254fa61c641741ef32dacda98b7d39c" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.1.3" web: dependency: transitive description: @@ -184,5 +914,38 @@ packages: url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted version: "0.3.0" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.4.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.0.4" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "3.1.2" sdks: dart: ">=3.2.5 <4.0.0" + flutter: ">=3.16.6" diff --git a/pubspec.yaml b/pubspec.yaml index 32a0ce8..599405c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ -name: snap_wish -description: "Shoot What You Want" +name: insposnap +description: "Shoot What Inspires You - 拍摄灵感收集与管理应用" # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev @@ -30,22 +30,68 @@ environment: dependencies: flutter: sdk: flutter + + # UI组件和图标 + cupertino_icons: ^1.0.6 + material_design_icons_flutter: ^7.0.7296 + + # 状态管理 + flutter_riverpod: ^2.4.9 + riverpod_annotation: ^2.3.3 + + # 数据存储 + hive: ^2.2.3 + hive_flutter: ^1.1.0 + + # 图片处理 + image: ^4.1.7 + flutter_image_compress: ^2.1.0 + cached_network_image: ^3.3.1 + + # 分享接收 + receive_sharing_intent: ^1.4.5 + + # 国际化 + flutter_localizations: + sdk: flutter + intl: ^0.18.1 + + # 工具类 + path: ^1.8.3 + path_provider: ^2.1.1 + uuid: ^3.0.7 + mime: ^1.0.4 + + # 图片查看 + photo_view: ^0.14.0 + + # 瀑布流布局 + flutter_staggered_grid_view: ^0.6.2 - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.2 +# Flutter启动图标配置 +flutter_launcher_icons: + android: true + ios: true + image_path: "assets/icons/app_icon.png" + min_sdk_android: 24 + remove_alpha_ios: true dev_dependencies: flutter_test: sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^2.0.0 + + # Riverpod代码生成 + riverpod_generator: ^2.3.9 + build_runner: ^2.4.7 + + # Hive代码生成 + hive_generator: ^2.0.1 + + # 代码质量 + flutter_lints: ^3.0.1 + + # 图标生成 + flutter_launcher_icons: ^0.13.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -59,9 +105,10 @@ flutter: uses-material-design: true # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - assets/icons/ + - assets/images/ + - assets/animations/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware diff --git a/snapwish_PRD_v1.1.md b/snapwish_PRD_v1.1.md new file mode 100644 index 0000000..be126e4 --- /dev/null +++ b/snapwish_PRD_v1.1.md @@ -0,0 +1,381 @@ +# "想拍" App 产品需求文档 (PRD) - MVP版本 + +| 文档版本 | V1.1 (MVP) | +| ------------ | ----------------------- | +| **创建日期** | 2025年9月11日 | +| **更新日期** | 2025年9月12日 | +| **创建人** | Claude | +| **项目名称** | 想拍 (snapwish) | +| **项目描述** | Shoot What Inspires You | + +## 1. 引言 + +### 1.1. 项目背景 + +许多摄影爱好者和创意工作者在日常浏览社交媒体(如微博、Twitter、Instagram、Pinterest等)时,会发现很多能激发自己拍摄灵感的图片。然而,这些图片往往散落在各个平台的收藏夹或手机相册中,难以统一管理和快速查找。当用户想要寻找拍摄灵感时,需要在多个应用和文件夹中翻找,效率低下。"想拍"旨在解决这一痛点,提供一个统一的灵感收集和管理工具。 + +### 1.2. 产品愿景 + +成为摄影爱好者和创意人士首选的、最便捷的拍摄灵感收集与管理应用。通过无缝的分享体验和强大的分类功能,帮助用户捕捉、整理并随时回顾每一个创意火花。 + +### 1.3. 目标用户 + +- **摄影爱好者**:希望系统性地收集和整理摄影作品,以供学习和模仿 +- **设计师/艺术家**:需要收集视觉素材,构建自己的灵感库 +- **内容创作者/博主**:为自己的内容创作寻找视觉参考和创意灵感 +- **普通用户**:喜欢美好图片,希望将喜欢的图片收藏并分类管理 + +### 1.4. MVP版本范围 + +本次V1.1 MVP版本专注于实现核心的灵感收集和管理功能,主要目标是让用户能够通过系统分享功能快速保存图片,并提供基础的文件夹和标签管理功能,以及良好的浏览查看体验。 + +## 2. 产品功能详述 + +### 2.1. 核心功能:接收并保存分享图片 + +#### 2.1.1. 从系统分享菜单接收图片 + +**功能描述**:App需要注册为系统分享菜单的目标应用。当用户在任何支持图片分享的应用中选择一张或多张图片并点击"分享"时,可以在分享列表中看到"想拍"App的图标。 + +**用户流程**: +1. 用户在第三方App中看到一张喜欢的图片 +2. 用户长按图片或点击分享按钮,唤起系统分享菜单 +3. 在分享列表中,用户找到并点击"想拍"图标 +4. 系统显示半透明模态框覆盖在当前App上,展示图片保存界面 + +**技术要求**: +- 支持接收单张图片和多张图片(最多30张) +- 能正确处理传入的图片文件流 +- 支持最大30MB的图片文件 +- 支持格式:JPG、PNG、WebP、HEIC、GIF、动态WebP、APNG + +#### 2.1.2. 图片保存界面 + +**功能描述**:从分享菜单跳转过来后,展示待保存的图片预览,并提供文件夹和标签选择器,以及备注输入框。 + +**界面元素**: +- **图片预览区**:清晰展示待保存的图片缩略图。多张图片时显示网格预览,可点击查看大图 +- **文件夹选择器**: + - 点击后弹出文件夹列表供用户选择 + - 默认显示"未分类" + - 列表顶部应有"新建文件夹"的快捷入口 + - 支持文件夹图标选择 + - 按最近使用顺序排序 +- **标签选择器**: + - 输入框支持动态匹配已存在的标签,显示3个建议 + - 用户可以从匹配列表中选择,也可以直接输入新标签 + - 已选择的标签会显示在输入框下方,支持点击删除 + - 单张图片标签数量无限制 + - 单个标签字数限制:20个中文汉字长度 + - 重复标签自动忽略,更新标签使用顺序 +- **备注输入框(可选)**:多行文本框,允许用户为图片添加文字描述 +- **操作模式切换**:批量应用模式(默认) vs 单张编辑模式,切换时保留批量内容到单张编辑 +- **"保存"按钮**:将图片文件保存到本地,并将元数据存入数据库 +- **"取消"按钮**:放弃本次保存,关闭该界面 + +**保存策略**: +- 原图保存至应用专属目录 +- 自动生成缩略图:长边500px,WebP格式,质量85%,保持原始比例 +- 按保存日期分类存储:`/yyyy/MM/dd/` +- 异步处理,避免阻塞UI +- 多张图片保存时显示进度提示 + +### 2.2. 组织与管理功能 + +#### 2.2.1. 文件夹管理 + +**功能描述**:用户可以创建、重命名和删除文件夹。仅支持一级文件夹结构。 + +**具体实现**: +- **创建**:用户可以在文件夹选择器或专门的文件夹页面创建新文件夹,通过弹窗形式,支持图标选择 +- **查看**:在"文件夹"标签页中,以网格形式展示所有文件夹及其封面(默认为该文件夹最新一张图) +- **重命名**:长按文件夹或通过编辑按钮进行重命名 +- **删除**:删除文件夹时,其中的图片自动归类到"未分类"文件夹 +- **排序**:按最近使用顺序排序 + +#### 2.2.2. 标签管理 + +**功能描述**:用户可以创建和删除标签,并查看所有使用特定标签的图片。 + +**具体实现**: +- **创建**:在保存图片时输入新标签即可自动创建 +- **查看**:在"标签"管理页面,以列表形式展示所有标签 +- **管理**:提供标签编辑功能,支持Material Icons图标,颜色跟随主题 +- **去重**:按文本内容自动去重 +- **图标**:支持从Material Icons中选择图标,存储图标名称 +- **颜色**:跟随应用主题色,存储为十六进制格式 + +### 2.3. 浏览与查看功能 + +#### 2.3.1. 主图库视图 + +**功能描述**:App的主界面,以瀑布流的形式展示所有已保存的图片。 + +**具体实现**: +- **布局**:瀑布流网格,支持列数切换 + - 手机(≤600dp):2列 + - 平板(>600dp):3-4列 +- **排序**:默认按保存时间倒序排列 +- **间距**:图片间距8dp +- **加载**:支持上拉加载更多,下拉刷新 +- **空状态**:显示简洁插画和说明文案 +- **搜索框**:常驻顶部 + +#### 2.3.2. 图片详情页 + +**功能描述**:点击图库中的任意一张图片,进入详情页。 + +**界面元素**: +- 高清大图查看,支持手势缩放和拖动 +- 滑动切换:左右滑动切换上一张/下一张,不支持循环 +- 双击放大:双击放大/缩小功能 +- 横竖屏适配:根据设备方向自动调整布局 +- 显示图片的所属文件夹、所有标签和备注信息 +- 提供"编辑"按钮,允许用户修改文件夹、标签和备注 +- 提供"删除"按钮,需要二次确认后删除图片 +- 提供"导出"按钮,可将图片保存到系统相册 + +#### 2.3.3. 搜索功能 + +**功能描述**:在主图库顶部提供搜索功能。 + +**具体实现**: +- **搜索框**:常驻顶部,支持关键词输入 +- **搜索范围**:文件夹名称、标签名称、备注内容 +- **触发方式**:点击搜索按钮触发搜索 +- **搜索历史**:记录最近10条搜索历史 +- **搜索结果**:在图库中实时展示搜索结果,不高亮匹配文字 +- **空搜索**:显示缺省页面 +- **加载优化**:搜索超过1秒时显示loading状态 + +### 2.4. 设置功能 + +**功能描述**:提供应用的基本设置选项。 + +**设置项分组**: +- **外观设置** + - **语言设置**:简体中文、繁体中文、English + - **主题模式**:跟随系统、浅色、深色(保持一定对比度) + - **网格布局**:瀑布流(默认)、等宽网格 +- **存储管理** + - **存储使用情况**:显示当前存储占用 + - **清理缓存**:一键清理缩略图缓存 +- **关于应用** + - **版本信息**:显示当前版本号 + - **用户协议**:链接到用户协议页面 + - **隐私政策**:链接到隐私政策页面 + +### 2.5. 国际化支持 + +**支持语言**: +- 简体中文(默认) +- 繁体中文 +- English + +**文案风格**:活泼友好,符合年轻用户喜好 + +**App名称**: +- 中文:想拍 +- 英文:snapwish + +## 3. 非功能性需求 + +### 3.1. 性能要求 + +- **启动速度**:应用冷启动时间应在2秒以内 +- **加载速度**:图库列表滑动流畅,图片加载不应出现明显卡顿 +- **响应速度**:所有用户操作的响应时间应在200毫秒内 +- **内存管理**:合理管理大图片内存,避免OOM +- **搜索响应**:搜索响应时间应在500毫秒内 + +### 3.2. 易用性要求 + +- **UI/UX设计**:界面设计简洁、直观,符合主流操作习惯 +- **核心操作路径**:分享→选择→保存应尽可能简短 +- **空状态处理**:各种空状态都有友好的提示和引导 +- **首次使用**:暂不增加引导,后续版本优化 + +### 3.3. 平台要求 + +- **技术栈**:使用Flutter框架开发 +- **目标平台**:Android和iOS +- **最低版本**:Android 7.0+,iOS 12.0+ +- **设备适配**:当前版本暂不考虑特殊设备适配 + +### 3.4. 数据存储要求 + +- **图片文件**:保存在应用专属的内部存储目录 +- **缩略图**:单独目录保存,按日期分类存储 +- **元数据**:使用Hive数据库存储图片信息 +- **缓存管理**:智能缓存清理,30天/500MB自动清理 + +### 3.5. 图片处理要求 + +- **原图保存**:保持原始图片质量 +- **缩略图生成**:长边500px,WebP格式,85%质量,保持原始比例 +- **异步处理**:图片处理在后台进行,不阻塞UI +- **内存优化**:大图片分块加载,避免内存溢出 +- **大GIF处理**:提取前几帧作为缩略图,避免内存溢出 + +## 4. 界面设计规范 + +### 4.1. 视觉风格 + +- **设计风格**:Material Design +- **主题色彩**:支持动态主题色,提供5-6种预设主题色 +- **图标风格**:Material Icons +- **圆角规范**:8dp统一圆角 +- **深色模式**:保持一定对比度,不完全反转 + +### 4.2. 交互规范 + +- **手势操作**:单击、长按、滑动、双击、捏合 +- **动画效果**:Material标准动画,页面转场300ms +- **反馈机制**:点击波纹效果、加载状态、操作结果提示 +- **按钮反馈**:Material标准波纹效果 + +### 4.3. 加载状态设计 + +- **图片加载**:使用骨架屏,提供良好的加载体验 +- **保存进度**:多张图片保存时显示进度提示 +- **搜索加载**:超过1秒时显示loading状态 +- **列表加载**:下拉刷新和上拉加载更多 + +### 4.4. 空状态设计 + +- **插画风格**:简洁线性插画 +- **文案风格**:温暖友好,具有引导性 +- **操作引导**:提供明确的下一步操作建议 +- **具体文案**: + - 首次使用:"还没有灵感?去其他App分享图片到这里吧~" + - 搜索结果为空:"换个关键词试试?或者去收集更多灵感吧!" + - 文件夹为空:"这个文件夹还在等它的第一张灵感图片" + +### 4.5. 错误处理 + +- **分享失败**:"出了点小问题,请重新分享试试~" +- **保存失败**:"出了点小问题,请重新分享试试~" +- **存储空间不足**:"出了点小问题,请重新分享试试~" +- **网络错误**:当前版本暂不考虑网络相关功能 + +## 5. 数据模型规范 + +### 5.1. 核心实体 + +```dart +class InspirationImage { + final String id; // UUID + final String filePath; // 原图路径 + final String thumbnailPath; // 缩略图路径 + final String? folderId; // 文件夹ID + final List tags; // 标签ID列表 + final String? note; // 备注内容 + final DateTime createdAt; // 创建时间(保存时间) + final DateTime updatedAt; // 更新时间 + final String? originalName; // 原始文件名 + final int fileSize; // 文件大小 + final String mimeType; // MIME类型 + final int? width; // 图片宽度 + final int? height; // 图片高度 + final bool isFavorite; // 是否收藏 +} + +class ImageFolder { + final String id; // UUID + final String name; // 文件夹名称 + final String? coverImageId; // 封面图片ID + final String icon; // Material Icons名称 + final DateTime createdAt; // 创建时间 + final DateTime updatedAt; // 更新时间 + final DateTime lastUsedAt; // 最近使用时间 +} + +class ImageTag { + final String id; // UUID + final String name; // 标签名称 + final String icon; // Material Icons名称 + final String color; // 十六进制颜色 + final int usageCount; // 使用次数 + final DateTime lastUsedAt; // 最近使用时间 +} +``` + +### 5.2. 存储路径规范 + +``` +应用专属目录/ +├── images/ # 原图存储 +│ └── yyyy/MM/dd/ # 按保存日期分类 +├── thumbnails/ # 缩略图存储 +│ └── yyyy/MM/dd/ # 对应原图日期 +└── cache/ # 缓存目录 + └── temp/ # 临时文件 +``` + +## 6. 开发计划 + +### 6.1. MVP版本开发周期(12-17天) + +**Phase 1: 架构搭建(3-4天)** +- 项目结构搭建和配置 +- 主题和国际化基础配置 +- 数据模型设计和Hive配置 +- 基础组件封装 + +**Phase 2: 分享功能(2-3天)** +- 分享接收功能实现 +- 图片保存界面开发 +- 文件存储和缩略图生成 + +**Phase 3: 图库展示(2-3天)** +- 主图库瀑布流界面 +- 空状态页面设计 +- 图片加载和缓存优化 + +**Phase 4: 管理功能(3-4天)** +- 文件夹管理功能 +- 标签管理系统 +- 图片详情页开发 + +**Phase 5: 搜索设置(2-3天)** +- 搜索功能实现 +- 设置页面开发 +- 性能优化和bug修复 + +### 6.2. 后续版本规划 + +**V1.1版本(短期)** +- 批量管理功能 +- 数据备份与恢复 +- 多种排序方式 +- 视图切换(列表/网格) + +**V2.0版本(中长期)** +- 云同步功能 +- 智能标签推荐 +- 图片来源追溯 +- 主题色提取功能 + +## 7. 风险评估 + +### 7.1. 技术风险 +- **大文件处理**:30MB GIF的内存管理需要特别注意,采用帧提取策略 +- **分享兼容性**:不同Android厂商的分享实现可能存在差异 +- **存储权限**:Android 13+的媒体权限变更需要适配 + +### 7.2. 用户体验风险 +- **首次使用**:没有引导流程,需要空状态设计足够友好 +- **性能感知**:大量图片时的流畅度需要重点优化 + +### 7.3. 缓解措施 +- 实现异步图片处理,避免阻塞主线程 +- 充分测试各种分享场景和设备兼容性 +- 实现智能缓存和内存管理策略 +- 采用虚拟滚动和图片懒加载技术 + +--- + +**文档状态**:已优化,技术细节已确认 +**最后更新**:2025年9月12日 +**确认人员**:产品负责人 + 技术负责人 +**版本说明**:V1.1版本补充了所有UI/UX细节、技术规范和性能指标 \ No newline at end of file diff --git a/snapwish_PRD_v1.md b/snapwish_PRD_v1.md new file mode 100644 index 0000000..5a62ac4 --- /dev/null +++ b/snapwish_PRD_v1.md @@ -0,0 +1,284 @@ +# "想拍" App 产品需求文档 (PRD) - MVP版本 + +| 文档版本 | V1.0 (MVP) | +| ------------ | ----------------------- | +| **创建日期** | 2025年9月11日 | +| **创建人** | Claude | +| **项目名称** | 想拍 (snapwish) | +| **项目描述** | Shoot What Inspires You | + +## 1. 引言 + +### 1.1. 项目背景 + +许多摄影爱好者和创意工作者在日常浏览社交媒体(如微博、Twitter、Instagram、Pinterest等)时,会发现很多能激发自己拍摄灵感的图片。然而,这些图片往往散落在各个平台的收藏夹或手机相册中,难以统一管理和快速查找。当用户想要寻找拍摄灵感时,需要在多个应用和文件夹中翻找,效率低下。"想拍"旨在解决这一痛点,提供一个统一的灵感收集和管理工具。 + +### 1.2. 产品愿景 + +成为摄影爱好者和创意人士首选的、最便捷的拍摄灵感收集与管理应用。通过无缝的分享体验和强大的分类功能,帮助用户捕捉、整理并随时回顾每一个创意火花。 + +### 1.3. 目标用户 + +- **摄影爱好者**:希望系统性地收集和整理摄影作品,以供学习和模仿 +- **设计师/艺术家**:需要收集视觉素材,构建自己的灵感库 +- **内容创作者/博主**:为自己的内容创作寻找视觉参考和创意灵感 +- **普通用户**:喜欢美好图片,希望将喜欢的图片收藏并分类管理 + +### 1.4. MVP版本范围 + +本次V1.0 MVP版本专注于实现核心的灵感收集和管理功能,主要目标是让用户能够通过系统分享功能快速保存图片,并提供基础的文件夹和标签管理功能,以及良好的浏览查看体验。 + +## 2. 产品功能详述 + +### 2.1. 核心功能:接收并保存分享图片 + +#### 2.1.1. 从系统分享菜单接收图片 + +**功能描述**:App需要注册为系统分享菜单的目标应用。当用户在任何支持图片分享的应用中选择一张或多张图片并点击"分享"时,可以在分享列表中看到"想拍"App的图标。 + +**用户流程**: +1. 用户在第三方App中看到一张喜欢的图片 +2. 用户长按图片或点击分享按钮,唤起系统分享菜单 +3. 在分享列表中,用户找到并点击"想拍"图标 +4. 系统自动跳转至"想拍"App的图片保存界面 + +**技术要求**: +- 支持接收单张图片和多张图片(最多30张) +- 能正确处理传入的图片文件流 +- 支持最大30MB的图片文件 +- 支持格式:JPG、PNG、WebP、HEIC、GIF、动态WebP、APNG + +#### 2.1.2. 图片保存界面 + +**功能描述**:从分享菜单跳转过来后,展示待保存的图片预览,并提供文件夹和标签选择器,以及备注输入框。 + +**界面元素**: +- **图片预览区**:清晰展示待保存的图片缩略图。多张图片时显示网格预览,可点击查看大图 +- **文件夹选择器**: + - 点击后弹出文件夹列表供用户选择 + - 默认显示"未分类" + - 列表顶部应有"新建文件夹"的快捷入口 +- **标签选择器**: + - 输入框支持动态匹配已存在的标签 + - 用户可以从匹配列表中选择,也可以直接输入新标签 + - 已选择的标签会显示在输入框下方 + - 单张图片标签数量无限制 + - 单个标签字数限制:20个中文汉字长度 +- **备注输入框(可选)**:多行文本框,允许用户为图片添加文字描述 +- **操作模式切换**:批量应用模式 vs 单张编辑模式 +- **"保存"按钮**:将图片文件保存到本地,并将元数据存入数据库 +- **"取消"按钮**:放弃本次保存,关闭该界面 + +**保存策略**: +- 原图保存至应用专属目录 +- 自动生成500x500缩略图(WebP格式,质量85%) +- 按日期分类存储:`/yyyy/MM/dd/` +- 异步处理,避免阻塞UI + +### 2.2. 组织与管理功能 + +#### 2.2.1. 文件夹管理 + +**功能描述**:用户可以创建、重命名和删除文件夹。仅支持一级文件夹结构。 + +**具体实现**: +- **创建**:用户可以在文件夹选择器或专门的文件夹页面创建新文件夹 +- **查看**:在"文件夹"标签页中,以网格形式展示所有文件夹及其封面(默认为该文件夹最新一张图) +- **重命名**:长按文件夹或通过编辑按钮进行重命名 +- **删除**:删除文件夹时,其中的图片自动归类到"未分类"文件夹 + +#### 2.2.2. 标签管理 + +**功能描述**:用户可以创建和删除标签,并查看所有使用特定标签的图片。 + +**具体实现**: +- **创建**:在保存图片时输入新标签即可自动创建 +- **查看**:在"标签"管理页面,以列表形式展示所有标签 +- **管理**:提供标签编辑功能,支持Material Icons图标,颜色跟随主题 +- **去重**:按文本内容自动去重 +- **图标**:支持从Material Icons中选择图标 +- **颜色**:跟随应用主题色 + +### 2.3. 浏览与查看功能 + +#### 2.3.1. 主图库视图 + +**功能描述**:App的主界面,以瀑布流的形式展示所有已保存的图片。 + +**具体实现**: +- **布局**:瀑布流网格,手机2列,平板3-4列自适应 +- **排序**:默认按保存时间倒序排列 +- **间距**:图片间距8dp +- **加载**:支持上拉加载更多,下拉刷新 +- **空状态**:显示简洁插画和说明文案 + +#### 2.3.2. 图片详情页 + +**功能描述**:点击图库中的任意一张图片,进入详情页。 + +**界面元素**: +- 高清大图查看,支持手势缩放和拖动 +- 滑动切换:左右滑动切换上一张/下一张 +- 双击放大:双击放大/缩小功能 +- 横竖屏适配:根据设备方向自动调整布局 +- 显示图片的所属文件夹、所有标签和备注信息 +- 提供"编辑"按钮,允许用户修改文件夹、标签和备注 +- 提供"删除"按钮,确认后删除图片 +- 提供"导出"按钮,可将图片保存到系统相册 + +#### 2.3.3. 搜索功能 + +**功能描述**:在主图库顶部提供搜索功能。 + +**具体实现**: +- **搜索框**:支持关键词输入 +- **搜索范围**:文件夹名称、标签名称、备注内容 +- **触发方式**:点击搜索按钮触发搜索 +- **搜索历史**:记录最近10条搜索历史 +- **搜索结果**:在图库中实时展示搜索结果 + +### 2.4. 设置功能 + +**功能描述**:提供应用的基本设置选项。 + +**设置项**: +- **语言设置**:简体中文、繁体中文、English +- **主题模式**:跟随系统、浅色、深色 +- **网格布局**:瀑布流(默认)、等宽网格 +- **存储管理**:显示存储使用情况,清理缓存 +- **关于**:版本信息、用户协议、隐私政策 + +### 2.5. 国际化支持 + +**支持语言**: +- 简体中文(默认) +- 繁体中文 +- English + +**文案风格**:活泼友好,符合年轻用户喜好 + +**App名称**: +- 中文:想拍 +- 英文:snapwish + +## 3. 非功能性需求 + +### 3.1. 性能要求 + +- **启动速度**:应用冷启动时间应在3秒以内 +- **加载速度**:图库列表滑动流畅,图片加载不应出现明显卡顿 +- **响应速度**:所有用户操作的响应时间应在200毫秒内 +- **内存管理**:合理管理大图片内存,避免OOM + +### 3.2. 易用性要求 + +- **UI/UX设计**:界面设计简洁、直观,符合主流操作习惯 +- **核心操作路径**:分享→选择→保存应尽可能简短 +- **空状态处理**:各种空状态都有友好的提示和引导 + +### 3.3. 平台要求 + +- **技术栈**:使用Flutter框架开发 +- **目标平台**:Android和iOS +- **最低版本**:Android 7.0+,iOS 12.0+ + +### 3.4. 数据存储要求 + +- **图片文件**:保存在应用专属的内部存储目录 +- **缩略图**:单独目录保存,定期清理策略 +- **元数据**:使用Hive数据库存储图片信息 +- **缓存管理**:智能缓存清理,避免占用过多空间 + +### 3.5. 图片处理要求 + +- **原图保存**:保持原始图片质量 +- **缩略图生成**:500x500像素,WebP格式,85%质量 +- **异步处理**:图片处理在后台进行,不阻塞UI +- **内存优化**:大图片分块加载,避免内存溢出 + +## 4. 界面设计规范 + +### 4.1. 视觉风格 + +- **设计风格**:Material Design +- **主题色彩**:支持动态主题色,跟随系统或用户选择 +- **图标风格**:Material Icons +- **圆角规范**:8dp统一圆角 + +### 4.2. 交互规范 + +- **手势操作**:单击、长按、滑动、双击、捏合 +- **动画效果**:页面转场、元素出现/消失、状态变化 +- **反馈机制**:点击反馈、加载状态、操作结果提示 + +### 4.3. 空状态设计 + +- **插画风格**:简洁线性插画 +- **文案风格**:温暖友好,具有引导性 +- **操作引导**:提供明确的下一步操作建议 + +## 5. 开发计划 + +### 5.1. MVP版本开发周期(12-17天) + +**Phase 1: 架构搭建(3-4天)** +- 项目结构搭建和配置 +- 主题和国际化基础配置 +- 数据模型设计和Hive配置 +- 基础组件封装 + +**Phase 2: 分享功能(2-3天)** +- 分享接收功能实现 +- 图片保存界面开发 +- 文件存储和缩略图生成 + +**Phase 3: 图库展示(2-3天)** +- 主图库瀑布流界面 +- 空状态页面设计 +- 图片加载和缓存优化 + +**Phase 4: 管理功能(3-4天)** +- 文件夹管理功能 +- 标签管理系统 +- 图片详情页开发 + +**Phase 5: 搜索设置(2-3天)** +- 搜索功能实现 +- 设置页面开发 +- 性能优化和bug修复 + +### 5.2. 后续版本规划 + +**V1.1版本(短期)** +- 批量管理功能 +- 数据备份与恢复 +- 多种排序方式 +- 视图切换(列表/网格) + +**V2.0版本(中长期)** +- 云同步功能 +- 智能标签推荐 +- 图片来源追溯 +- 主题色提取功能 + +## 6. 风险评估 + +### 6.1. 技术风险 +- **大文件处理**:30MB GIF的内存管理需要特别注意 +- **分享兼容性**:不同Android厂商的分享实现可能存在差异 +- **存储权限**:Android 13+的媒体权限变更需要适配 + +### 6.2. 用户体验风险 +- **首次使用**:没有引导流程,需要空状态设计足够友好 +- **性能感知**:大量图片时的流畅度需要重点优化 + +### 6.3. 缓解措施 +- 实现异步图片处理,避免阻塞主线程 +- 充分测试各种分享场景和设备兼容性 +- 实现智能缓存和内存管理策略 + +--- + +**文档状态**:已确认,进入开发阶段 +**最后更新**:2025年9月11日 +**确认人员**:产品负责人 + 技术负责人 \ No newline at end of file diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index 83d8723..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// 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:snap_wish/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); - }); -}