Compare commits

...

2 Commits

Author SHA1 Message Date
ddshi
34e91fbd70 基础架构 2025-09-12 18:17:35 +08:00
ddshi
2b956eb96c 首页调整 2025-08-28 20:04:53 +08:00
25 changed files with 4563 additions and 721 deletions

View File

@ -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": []

391
CLAUDE.md
View File

@ -1,122 +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
### 图片处理规范
- **缩略图**长边500pxWebP格式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<String> 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 工具链
**代码提交**:每个任务完成后提交一次,确保版本控制清晰。
---
**最后更新**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接口设计合理
- 数据迁移机制可用

View File

@ -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; //
}

View File

@ -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';
}

View File

@ -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; //
}

View File

@ -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';
}

View File

@ -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,
});
}

View File

@ -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<T> wrapAsync<T>(
Future<T> 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>(
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';
}

View File

@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
///
///
class AppColors {
//
AppColors._();
//
static const MaterialColor primarySwatch = MaterialColor(
0xFF2196F3,
<int, Color>{
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<Color> 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;
}
}

View File

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

View File

@ -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<Directory> 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<Directory> 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<Directory> 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<String> 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<String> 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<String> 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<bool> 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<int> 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<void> 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<void> 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<File> 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<File> 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<bool> fileExists(String filePath) async {
try {
final file = File(filePath);
return await file.exists();
} catch (error) {
Logger.error('检查文件存在失败', error: error);
return false;
}
}
///
static Future<int> 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<void> cleanTempFiles() async {
try {
final tempDir = Directory(await createTempDirectory());
await clearDirectory(tempDir);
Logger.info('临时文件清理完成');
} catch (error, stackTrace) {
Logger.error('清理临时文件失败', error: error, stackTrace: stackTrace);
}
}
/// 使
static Future<Map<String, dynamic>> 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}';
}
}

View File

@ -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<String> 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<String> _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<String> _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<String> _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<Map<String, dynamic>> 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<bool> 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<String, dynamic> 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<String> formats = [
'jpg',
'jpeg',
'png',
'gif',
'webp',
'heic',
'heif',
];
static const List<String> 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());
}
}

260
lib/core/utils/logger.dart Normal file
View File

@ -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._();
/// DEBUGINFO
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<String, dynamic>? 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<T> measureTime<T>(
String operationName,
Future<T> 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<T>(
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<void> 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<String, dynamic>? parameters]) {
Logger.enter('$runtimeType.$methodName', parameters);
}
/// 退
void logExit(String methodName, [dynamic result]) {
Logger.exit('$runtimeType.$methodName', result);
}
///
Future<T> logMeasureTime<T>(
String operationName,
Future<T> Function() operation,
) async {
return await Logger.measureTime('$runtimeType.$operationName', operation);
}
///
T logMeasureSyncTime<T>(
String operationName,
T Function() operation,
) {
return Logger.measureSyncTime('$runtimeType.$operationName', operation);
}
}

View File

@ -1,74 +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',
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<MainPage> createState() => _MainPageState();
}
class _MainPageState extends State<MainPage> {
int _currentIndex = 0;
final List<Widget> _pages = [
const PhotoPage(),
const CategoriesPage(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: _pages[_currentIndex],
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.pushNamed(context, '/add-photo');
},
child: const Icon(Icons.add),
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (index) {
setState(() {
_currentIndex = index;
});
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.photo_library),
label: '照片',
),
NavigationDestination(
icon: Icon(Icons.folder),
label: '分类',
),
],
),
);
}
}
///
///
void handleGlobalError(Object error, StackTrace stackTrace) {
Logger.logException('Global Error', error, stackTrace: stackTrace, isFatal: true);
}

View File

@ -1,170 +0,0 @@
import 'package:flutter/material.dart';
class AddPhotoPage extends StatefulWidget {
const AddPhotoPage({super.key});
@override
State<AddPhotoPage> createState() => _AddPhotoPageState();
}
class _AddPhotoPageState extends State<AddPhotoPage> {
String? _selectedCategory;
final TextEditingController _tagsController = TextEditingController();
final List<String> _tags = [];
// TODO:
final List<String> _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<String>(
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(),
),
],
),
),
);
}
}

View File

@ -1,142 +0,0 @@
import 'package:flutter/material.dart';
class CategoriesPage extends StatefulWidget {
const CategoriesPage({super.key});
@override
State<CategoriesPage> createState() => _CategoriesPageState();
}
class _CategoriesPageState extends State<CategoriesPage> {
// TODO:
final List<Map<String, dynamic>> _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(
appBar: AppBar(
title: const Text('分类'),
actions: [
IconButton(
icon: const Icon(Icons.create_new_folder),
onPressed: _createCategory,
),
],
),
body: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: _categories.length,
itemBuilder: (context, index) {
final category = _categories[index];
return Card(
margin: const EdgeInsets.symmetric(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(index),
),
onTap: () {
// TODO:
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('打开 ${category['name']}')),
);
},
),
);
},
),
);
}
}

View File

@ -1,200 +0,0 @@
import 'package:flutter/material.dart';
class PhotoPage extends StatelessWidget {
const PhotoPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('照片'),
),
body: const PhotoGrid(),
);
}
}
class PhotoGrid extends StatelessWidget {
const PhotoGrid({super.key});
@override
Widget build(BuildContext context) {
// TODO:
final mockPhotos = List.generate(20, (index) => '照片 ${index + 1}');
return GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 4,
mainAxisSpacing: 4,
childAspectRatio: 1,
),
itemCount: mockPhotos.length,
itemBuilder: (context, index) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PhotoDetailPage(
photos: mockPhotos,
initialIndex: index,
),
),
);
},
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(
mockPhotos[index],
style: const TextStyle(fontSize: 12),
overflow: TextOverflow.ellipsis,
),
],
),
),
),
);
},
);
}
}
class PhotoDetailPage extends StatefulWidget {
final List<String> photos;
final int initialIndex;
const PhotoDetailPage({
super.key,
required this.photos,
required this.initialIndex,
});
@override
State<PhotoDetailPage> createState() => _PhotoDetailPageState();
}
class _PhotoDetailPageState extends State<PhotoDetailPage> {
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,
),
),
),
),
],
),
);
}
}

View File

@ -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<AppHomePage> createState() => _AppHomePageState();
}
class _AppHomePageState extends State<AppHomePage> {
@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<dynamic> route, Route<dynamic>? previousRoute) {
if (route.settings.name != null) {
Logger.debug('页面进入: ${route.settings.name}');
}
}
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
if (route.settings.name != null) {
Logger.debug('页面退出: ${route.settings.name}');
}
}
@override
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
if (route.settings.name != null) {
Logger.debug('页面移除: ${route.settings.name}');
}
}
@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? 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 {
//
}

View File

@ -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<AppLocalizations>(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<AppLocalizations> {
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<AppLocalizations> 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;
}
}
}

View File

@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/utils/logger.dart';
///
final themeProvider = StateNotifierProvider<ThemeNotifier, ThemeMode>((ref) {
return ThemeNotifier(ref);
});
///
class ThemeNotifier extends StateNotifier<ThemeMode> with LoggerMixin {
final Ref ref;
ThemeNotifier(this.ref) : super(ThemeMode.system) {
_initializeTheme();
}
///
Future<void> _initializeTheme() async {
logEnter('_initializeTheme');
try {
// 使
state = ThemeMode.system;
logInfo('主题初始化完成: $state');
} catch (error, stackTrace) {
logError('主题初始化失败', error: error, stackTrace: stackTrace);
state = ThemeMode.system;
}
logExit('_initializeTheme');
}
///
Future<void> 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<void> 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);
}
}

View File

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

View File

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

381
snapwish_PRD_v1.1.md Normal file
View File

@ -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 单张编辑模式,切换时保留批量内容到单张编辑
- **"保存"按钮**:将图片文件保存到本地,并将元数据存入数据库
- **"取消"按钮**:放弃本次保存,关闭该界面
**保存策略**
- 原图保存至应用专属目录
- 自动生成缩略图长边500pxWebP格式质量85%,保持原始比例
- 按保存日期分类存储:`/yyyy/MM/dd/`
- 异步处理避免阻塞UI
- 多张图片保存时显示进度提示
### 2.2. 组织与管理功能
#### 2.2.1. 文件夹管理
**功能描述**:用户可以创建、重命名和删除文件夹。仅支持一级文件夹结构。
**具体实现**
- **创建**:用户可以在文件夹选择器或专门的文件夹页面创建新文件夹,通过弹窗形式,支持图标选择
- **查看**:在"文件夹"标签页中,以网格形式展示所有文件夹及其封面(默认为该文件夹最新一张图)
- **重命名**:长按文件夹或通过编辑按钮进行重命名
- **删除**:删除文件夹时,其中的图片自动归类到"未分类"文件夹
- **排序**:按最近使用顺序排序
#### 2.2.2. 标签管理
**功能描述**:用户可以创建和删除标签,并查看所有使用特定标签的图片。
**具体实现**
- **创建**:在保存图片时输入新标签即可自动创建
- **查看**:在"标签"管理页面,以列表形式展示所有标签
- **管理**提供标签编辑功能支持Material Icons图标颜色跟随主题
- **去重**:按文本内容自动去重
- **图标**支持从Material Icons中选择图标存储图标名称
- **颜色**:跟随应用主题色,存储为十六进制格式
### 2.3. 浏览与查看功能
#### 2.3.1. 主图库视图
**功能描述**App的主界面以瀑布流的形式展示所有已保存的图片。
**具体实现**
- **布局**:瀑布流网格,支持列数切换
- 手机≤600dp2列
- 平板(>600dp3-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. 图片处理要求
- **原图保存**:保持原始图片质量
- **缩略图生成**长边500pxWebP格式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<String> 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细节、技术规范和性能指标

284
snapwish_PRD_v1.md Normal file
View File

@ -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日
**确认人员**:产品负责人 + 技术负责人

View File

@ -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);
});
}