2.2
This commit is contained in:
parent
8f284075b6
commit
7114c367b6
@ -2,5 +2,6 @@
|
||||
"language_preferences": {
|
||||
"documentation": "zh-CN",
|
||||
"code_comments": "zh-CN"
|
||||
}
|
||||
},
|
||||
"primaryApiKey": "xxx"
|
||||
}
|
||||
10
.claude/settings.json
Normal file
10
.claude/settings.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "afca3c573b634e1fb55f1902ffcb8aed.hgmMkxrwPRFbNDn1",
|
||||
"ANTHROPIC_BASE_URL": "https://open.bigmodel.cn/api/anthropic",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "GLM-4.5-Air",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "GLM-4.6",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "GLM-4.6",
|
||||
"ANTHROPIC_MODEL": "GLM-4.6"
|
||||
}
|
||||
}
|
||||
@ -11,7 +11,8 @@
|
||||
"Bash(flutter build:*)",
|
||||
"Bash(flutter clean:*)",
|
||||
"Bash(dart analyze:*)",
|
||||
"Bash(dart test:*)"
|
||||
"Bash(dart test:*)",
|
||||
"Bash(grep:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@ -18,6 +18,12 @@ class FolderDao {
|
||||
return hiveFolder.id;
|
||||
}
|
||||
|
||||
/// 添加Hive文件夹 - 直接插入HiveImageFolder对象
|
||||
/// [hiveFolder] 要创建的Hive文件夹对象
|
||||
Future<void> insertHiveFolder(HiveImageFolder hiveFolder) async {
|
||||
await _foldersBox.put(hiveFolder.id, hiveFolder);
|
||||
}
|
||||
|
||||
/// 根据ID获取文件夹 - 通过唯一标识符查找文件夹
|
||||
/// [id] 文件夹的唯一标识符
|
||||
/// 返回找到的文件夹实体,如果不存在则返回null
|
||||
@ -26,6 +32,13 @@ class FolderDao {
|
||||
return hiveFolder?.toEntity();
|
||||
}
|
||||
|
||||
/// 根据ID获取Hive文件夹 - 直接返回HiveImageFolder对象
|
||||
/// [id] 文件夹的唯一标识符
|
||||
/// 返回找到的Hive文件夹对象,如果不存在则返回null
|
||||
Future<HiveImageFolder?> getHiveFolderById(String id) async {
|
||||
return _foldersBox.get(id);
|
||||
}
|
||||
|
||||
/// 获取所有文件夹 - 按最近使用时间倒序排列
|
||||
/// 返回所有文件夹实体列表,最近使用的文件夹在前
|
||||
Future<List<ImageFolder>> getAllFolders() async {
|
||||
@ -41,14 +54,29 @@ class FolderDao {
|
||||
/// 返回最近使用的文件夹列表
|
||||
Future<List<ImageFolder>> getRecentFolders({int? limit}) async {
|
||||
final allFolders = await getAllFolders();
|
||||
|
||||
|
||||
if (limit == null || limit >= allFolders.length) {
|
||||
return allFolders;
|
||||
}
|
||||
|
||||
|
||||
return allFolders.sublist(0, limit);
|
||||
}
|
||||
|
||||
/// 获取最近使用的Hive文件夹 - 返回HiveImageFolder对象列表
|
||||
/// [limit] 返回数量限制
|
||||
/// 返回最近使用的Hive文件夹列表
|
||||
Future<List<HiveImageFolder>> getRecentHiveFolders(int limit) async {
|
||||
final hiveFolders = _foldersBox.values
|
||||
.toList()
|
||||
..sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt));
|
||||
|
||||
if (limit >= hiveFolders.length) {
|
||||
return hiveFolders;
|
||||
}
|
||||
|
||||
return hiveFolders.sublist(0, limit);
|
||||
}
|
||||
|
||||
/// 更新文件夹信息 - 修改文件夹的元数据
|
||||
/// [folder] 包含更新数据的文件夹实体
|
||||
/// 返回更新后的文件夹实体
|
||||
@ -62,11 +90,17 @@ class FolderDao {
|
||||
updatedAt: DateTime.now(), // 更新时间
|
||||
lastUsedAt: folder.lastUsedAt,
|
||||
);
|
||||
|
||||
|
||||
await _foldersBox.put(hiveFolder.id, hiveFolder);
|
||||
return hiveFolder.toEntity();
|
||||
}
|
||||
|
||||
/// 更新Hive文件夹 - 直接更新HiveImageFolder对象
|
||||
/// [hiveFolder] 要更新的Hive文件夹对象
|
||||
Future<void> updateHiveFolder(HiveImageFolder hiveFolder) async {
|
||||
await _foldersBox.put(hiveFolder.id, hiveFolder);
|
||||
}
|
||||
|
||||
/// 更新文件夹使用时间 - 记录文件夹的最近访问时间
|
||||
/// [folderId] 文件夹ID
|
||||
/// 返回更新后的文件夹实体
|
||||
@ -165,15 +199,27 @@ class FolderDao {
|
||||
/// 返回匹配的文件夹列表
|
||||
Future<List<ImageFolder>> searchFolders(String query) async {
|
||||
final lowerQuery = query.toLowerCase();
|
||||
|
||||
|
||||
final hiveFolders = _foldersBox.values
|
||||
.where((folder) => folder.name.toLowerCase().contains(lowerQuery))
|
||||
.toList()
|
||||
..sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt));
|
||||
|
||||
|
||||
return hiveFolders.map((hiveFolder) => hiveFolder.toEntity()).toList();
|
||||
}
|
||||
|
||||
/// 搜索Hive文件夹 - 返回HiveImageFolder对象列表
|
||||
/// [query] 搜索关键词
|
||||
/// 返回匹配的Hive文件夹列表
|
||||
Future<List<HiveImageFolder>> searchHiveFolders(String query) async {
|
||||
final lowerQuery = query.toLowerCase();
|
||||
|
||||
return _foldersBox.values
|
||||
.where((folder) => folder.name.toLowerCase().contains(lowerQuery))
|
||||
.toList()
|
||||
..sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt));
|
||||
}
|
||||
|
||||
/// 关闭数据盒 - 释放数据库资源
|
||||
/// 通常在应用退出时调用
|
||||
Future<void> close() async {
|
||||
|
||||
@ -18,6 +18,12 @@ class TagDao {
|
||||
return hiveTag.id;
|
||||
}
|
||||
|
||||
/// 添加Hive标签 - 直接插入HiveImageTag对象
|
||||
/// [hiveTag] 要创建的Hive标签对象
|
||||
Future<void> insertHiveTag(HiveImageTag hiveTag) async {
|
||||
await _tagsBox.put(hiveTag.id, hiveTag);
|
||||
}
|
||||
|
||||
/// 批量添加标签 - 一次性创建多个标签
|
||||
/// [tags] 要创建的标签实体列表
|
||||
/// 返回创建成功的标签ID列表
|
||||
@ -40,6 +46,13 @@ class TagDao {
|
||||
return hiveTag?.toEntity();
|
||||
}
|
||||
|
||||
/// 根据ID获取Hive标签 - 直接返回HiveImageTag对象
|
||||
/// [id] 标签的唯一标识符
|
||||
/// 返回找到的Hive标签对象,如果不存在则返回null
|
||||
Future<HiveImageTag?> getHiveTagById(String id) async {
|
||||
return _tagsBox.get(id);
|
||||
}
|
||||
|
||||
/// 根据名称获取标签 - 通过标签名称精确查找
|
||||
/// [name] 标签名称
|
||||
/// 返回找到的标签实体,如果不存在则返回null
|
||||
@ -55,6 +68,21 @@ class TagDao {
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据名称获取Hive标签 - 通过标签名称精确查找Hive标签对象
|
||||
/// [name] 标签名称
|
||||
/// 返回找到的Hive标签对象,如果不存在则返回null
|
||||
Future<HiveImageTag?> getHiveTagByName(String name) async {
|
||||
try {
|
||||
final hiveTag = _tagsBox.values.firstWhere(
|
||||
(tag) => tag.name == name,
|
||||
);
|
||||
return hiveTag;
|
||||
} catch (e) {
|
||||
// 没有找到标签时返回null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有标签 - 按使用次数倒序排列
|
||||
/// 返回所有标签实体列表,使用次数多的标签在前
|
||||
Future<List<ImageTag>> getAllTags() async {
|
||||
@ -219,4 +247,83 @@ class TagDao {
|
||||
Future<void> close() async {
|
||||
await _tagsBox.close();
|
||||
}
|
||||
|
||||
/// 获取所有Hive标签 - 返回HiveImageTag对象列表
|
||||
/// 返回所有Hive标签列表
|
||||
Future<List<HiveImageTag>> getAllHiveTags() async {
|
||||
final hiveTags = _tagsBox.values.toList()
|
||||
..sort((a, b) => b.usageCount.compareTo(a.usageCount));
|
||||
return hiveTags;
|
||||
}
|
||||
|
||||
/// 获取热门Hive标签 - 返回HiveImageTag对象列表
|
||||
/// [limit] 返回数量限制
|
||||
/// 返回热门的Hive标签列表
|
||||
Future<List<HiveImageTag>> getPopularHiveTags(int limit) async {
|
||||
final hiveTags = _tagsBox.values.toList()
|
||||
..sort((a, b) => b.usageCount.compareTo(a.usageCount));
|
||||
|
||||
if (limit >= hiveTags.length) {
|
||||
return hiveTags;
|
||||
}
|
||||
|
||||
return hiveTags.sublist(0, limit);
|
||||
}
|
||||
|
||||
/// 获取最近使用的Hive标签 - 返回HiveImageTag对象列表
|
||||
/// [limit] 返回数量限制
|
||||
/// 返回最近使用的Hive标签列表
|
||||
Future<List<HiveImageTag>> getRecentHiveTags(int limit) async {
|
||||
final hiveTags = _tagsBox.values.toList()
|
||||
..sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt));
|
||||
|
||||
if (limit >= hiveTags.length) {
|
||||
return hiveTags;
|
||||
}
|
||||
|
||||
return hiveTags.sublist(0, limit);
|
||||
}
|
||||
|
||||
/// 搜索Hive标签 - 返回HiveImageTag对象列表
|
||||
/// [query] 搜索关键词
|
||||
/// 返回匹配的Hive标签列表
|
||||
Future<List<HiveImageTag>> searchHiveTags(String query) async {
|
||||
final lowerQuery = query.toLowerCase();
|
||||
|
||||
return _tagsBox.values
|
||||
.where((tag) => tag.name.toLowerCase().contains(lowerQuery))
|
||||
.toList()
|
||||
..sort((a, b) => b.usageCount.compareTo(a.usageCount));
|
||||
}
|
||||
|
||||
/// 获取未使用的Hive标签 - 返回HiveImageTag对象列表
|
||||
/// 返回未使用的Hive标签列表
|
||||
Future<List<HiveImageTag>> getUnusedHiveTags() async {
|
||||
return _tagsBox.values
|
||||
.where((tag) => tag.usageCount == 0)
|
||||
.toList()
|
||||
..sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt));
|
||||
}
|
||||
|
||||
/// 更新Hive标签 - 直接更新HiveImageTag对象
|
||||
/// [hiveTag] 要更新的Hive标签对象
|
||||
Future<void> updateHiveTag(HiveImageTag hiveTag) async {
|
||||
await _tagsBox.put(hiveTag.id, hiveTag);
|
||||
}
|
||||
|
||||
/// 清理未使用的Hive标签 - 删除所有使用次数为0的标签
|
||||
/// 返回删除的标签数量
|
||||
Future<int> cleanupUnusedHiveTags() async {
|
||||
final unusedTags = await getUnusedHiveTags();
|
||||
for (final tag in unusedTags) {
|
||||
await _tagsBox.delete(tag.id);
|
||||
}
|
||||
return unusedTags.length;
|
||||
}
|
||||
|
||||
/// 清理未使用的标签 - 删除所有使用次数为0的标签
|
||||
/// 返回删除的标签数量
|
||||
Future<int> cleanupUnusedTags() async {
|
||||
return await cleanupUnusedHiveTags();
|
||||
}
|
||||
}
|
||||
41
lib/data/repositories/folder_repository.dart
Normal file
41
lib/data/repositories/folder_repository.dart
Normal file
@ -0,0 +1,41 @@
|
||||
import '../../domain/entities/image_folder.dart';
|
||||
|
||||
/// 文件夹仓库接口 - 定义文件夹数据操作的抽象接口
|
||||
/// 负责管理文件夹的增删改查等操作
|
||||
abstract class FolderRepository {
|
||||
/// 获取所有文件夹
|
||||
Future<List<ImageFolder>> getAllFolders();
|
||||
|
||||
/// 根据ID获取文件夹
|
||||
Future<ImageFolder?> getFolderById(String id);
|
||||
|
||||
/// 创建新文件夹
|
||||
Future<String> createFolder({
|
||||
required String name,
|
||||
required String icon,
|
||||
String? coverImageId,
|
||||
});
|
||||
|
||||
/// 更新文件夹信息
|
||||
Future<void> updateFolder({
|
||||
required String id,
|
||||
required String name,
|
||||
required String icon,
|
||||
String? coverImageId,
|
||||
});
|
||||
|
||||
/// 删除文件夹
|
||||
Future<void> deleteFolder(String id);
|
||||
|
||||
/// 更新文件夹最后使用时间
|
||||
Future<void> updateFolderLastUsed(String id);
|
||||
|
||||
/// 获取文件夹中的图片数量
|
||||
Future<int> getFolderImageCount(String id);
|
||||
|
||||
/// 获取最近使用的文件夹
|
||||
Future<List<ImageFolder>> getRecentFolders({int limit = 10});
|
||||
|
||||
/// 搜索文件夹
|
||||
Future<List<ImageFolder>> searchFolders(String keyword);
|
||||
}
|
||||
168
lib/data/repositories/folder_repository_impl.dart
Normal file
168
lib/data/repositories/folder_repository_impl.dart
Normal file
@ -0,0 +1,168 @@
|
||||
import '../datasources/local/folder_dao.dart';
|
||||
import '../models/hive_image_folder.dart';
|
||||
import '../../domain/entities/image_folder.dart';
|
||||
import 'folder_repository.dart';
|
||||
|
||||
/// 文件夹仓库实现类 - 基于Hive数据库的文件夹操作实现
|
||||
/// 负责处理文件夹数据的持久化存储和检索
|
||||
class FolderRepositoryImpl implements FolderRepository {
|
||||
final FolderDao _folderDao;
|
||||
|
||||
FolderRepositoryImpl(this._folderDao);
|
||||
|
||||
@override
|
||||
Future<List<ImageFolder>> getAllFolders() async {
|
||||
try {
|
||||
final folders = await _folderDao.getAllFolders();
|
||||
return folders;
|
||||
} catch (e) {
|
||||
throw Exception('获取文件夹列表失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ImageFolder?> getFolderById(String id) async {
|
||||
try {
|
||||
final folder = await _folderDao.getFolderById(id);
|
||||
return folder;
|
||||
} catch (e) {
|
||||
throw Exception('获取文件夹失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> createFolder({
|
||||
required String name,
|
||||
required String icon,
|
||||
String? coverImageId,
|
||||
}) async {
|
||||
try {
|
||||
final now = DateTime.now();
|
||||
final hiveFolder = HiveImageFolder(
|
||||
id: _generateId(),
|
||||
name: name,
|
||||
coverImageId: coverImageId,
|
||||
icon: icon,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastUsedAt: now,
|
||||
);
|
||||
|
||||
await _folderDao.insertHiveFolder(hiveFolder);
|
||||
return hiveFolder.id;
|
||||
} catch (e) {
|
||||
throw Exception('创建文件夹失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateFolder({
|
||||
required String id,
|
||||
required String name,
|
||||
required String icon,
|
||||
String? coverImageId,
|
||||
}) async {
|
||||
try {
|
||||
final existingFolder = await _folderDao.getHiveFolderById(id);
|
||||
if (existingFolder == null) {
|
||||
throw Exception('文件夹不存在');
|
||||
}
|
||||
|
||||
final updatedFolder = HiveImageFolder(
|
||||
id: id,
|
||||
name: name,
|
||||
coverImageId: coverImageId,
|
||||
icon: icon,
|
||||
createdAt: existingFolder.createdAt,
|
||||
updatedAt: DateTime.now(),
|
||||
lastUsedAt: existingFolder.lastUsedAt,
|
||||
);
|
||||
|
||||
await _folderDao.updateHiveFolder(updatedFolder);
|
||||
} catch (e) {
|
||||
throw Exception('更新文件夹失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteFolder(String id) async {
|
||||
try {
|
||||
await _folderDao.deleteFolder(id);
|
||||
} catch (e) {
|
||||
throw Exception('删除文件夹失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateFolderLastUsed(String id) async {
|
||||
try {
|
||||
final existingFolder = await _folderDao.getHiveFolderById(id);
|
||||
if (existingFolder == null) {
|
||||
throw Exception('文件夹不存在');
|
||||
}
|
||||
|
||||
final updatedFolder = HiveImageFolder(
|
||||
id: id,
|
||||
name: existingFolder.name,
|
||||
coverImageId: existingFolder.coverImageId,
|
||||
icon: existingFolder.icon,
|
||||
createdAt: existingFolder.createdAt,
|
||||
updatedAt: DateTime.now(),
|
||||
lastUsedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await _folderDao.updateHiveFolder(updatedFolder);
|
||||
} catch (e) {
|
||||
throw Exception('更新文件夹使用时间失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getFolderImageCount(String id) async {
|
||||
try {
|
||||
// 这里需要实现获取文件夹中图片数量的逻辑
|
||||
// 可能需要调用ImageDAO的相关方法
|
||||
// 暂时返回0,后续实现
|
||||
return 0;
|
||||
} catch (e) {
|
||||
throw Exception('获取文件夹图片数量失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ImageFolder>> getRecentFolders({int limit = 10}) async {
|
||||
try {
|
||||
final folders = await _folderDao.getRecentFolders(limit: limit);
|
||||
return folders;
|
||||
} catch (e) {
|
||||
throw Exception('获取最近使用的文件夹失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ImageFolder>> searchFolders(String keyword) async {
|
||||
try {
|
||||
final folders = await _folderDao.searchFolders(keyword);
|
||||
return folders;
|
||||
} catch (e) {
|
||||
throw Exception('搜索文件夹失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成唯一ID
|
||||
String _generateId() {
|
||||
return DateTime.now().millisecondsSinceEpoch.toString() +
|
||||
_generateRandomString(8);
|
||||
}
|
||||
|
||||
/// 生成随机字符串
|
||||
String _generateRandomString(int length) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
final random = DateTime.now().millisecondsSinceEpoch;
|
||||
String result = '';
|
||||
for (int i = 0; i < length; i++) {
|
||||
result += chars[(random + i) % chars.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
260
lib/data/repositories/tag_repository_impl.dart
Normal file
260
lib/data/repositories/tag_repository_impl.dart
Normal file
@ -0,0 +1,260 @@
|
||||
import '../datasources/local/tag_dao.dart';
|
||||
import '../models/hive_image_tag.dart';
|
||||
import '../../domain/entities/image_tag.dart';
|
||||
import '../../domain/repositories/tag_repository.dart';
|
||||
|
||||
/// 标签仓库实现类 - 基于Hive数据库的标签操作实现
|
||||
/// 负责处理标签数据的持久化存储和检索
|
||||
class TagRepositoryImpl implements TagRepository {
|
||||
final TagDao _tagDao;
|
||||
|
||||
TagRepositoryImpl(this._tagDao);
|
||||
|
||||
@override
|
||||
Future<String> createTag(ImageTag tag) async {
|
||||
try {
|
||||
final hiveTag = HiveImageTag.fromEntity(tag);
|
||||
await _tagDao.insertHiveTag(hiveTag);
|
||||
return hiveTag.id;
|
||||
} catch (e) {
|
||||
throw Exception('创建标签失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 简化的创建标签方法,直接接受参数而不是实体
|
||||
Future<String> createTagWithParams({
|
||||
required String name,
|
||||
required String icon,
|
||||
required String color,
|
||||
}) async {
|
||||
try {
|
||||
final now = DateTime.now();
|
||||
final hiveTag = HiveImageTag(
|
||||
id: _generateId(),
|
||||
name: name,
|
||||
icon: icon,
|
||||
color: color,
|
||||
usageCount: 0,
|
||||
lastUsedAt: now,
|
||||
);
|
||||
|
||||
await _tagDao.insertHiveTag(hiveTag);
|
||||
return hiveTag.id;
|
||||
} catch (e) {
|
||||
throw Exception('创建标签失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>> createTags(List<ImageTag> tags) async {
|
||||
try {
|
||||
final List<String> tagIds = [];
|
||||
for (final tag in tags) {
|
||||
final tagId = await createTag(tag);
|
||||
tagIds.add(tagId);
|
||||
}
|
||||
return tagIds;
|
||||
} catch (e) {
|
||||
throw Exception('批量创建标签失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ImageTag?> getTag(String id) async {
|
||||
try {
|
||||
final hiveTag = await _tagDao.getHiveTagById(id);
|
||||
return hiveTag?.toEntity();
|
||||
} catch (e) {
|
||||
throw Exception('获取标签失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ImageTag?> getTagByName(String name) async {
|
||||
try {
|
||||
final tag = await _tagDao.getTagByName(name);
|
||||
return tag; // DAO已经返回ImageTag
|
||||
} catch (e) {
|
||||
throw Exception('根据名称获取标签失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ImageTag>> getAllTags() async {
|
||||
try {
|
||||
final hiveTags = await _tagDao.getAllHiveTags();
|
||||
return hiveTags.map((hiveTag) => hiveTag.toEntity()).toList();
|
||||
} catch (e) {
|
||||
throw Exception('获取所有标签失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ImageTag>> getPopularTags({int limit = 10}) async {
|
||||
try {
|
||||
final hiveTags = await _tagDao.getPopularHiveTags(limit);
|
||||
return hiveTags.map((hiveTag) => hiveTag.toEntity()).toList();
|
||||
} catch (e) {
|
||||
throw Exception('获取热门标签失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ImageTag>> getRecentTags({int limit = 10}) async {
|
||||
try {
|
||||
final hiveTags = await _tagDao.getRecentHiveTags(limit);
|
||||
return hiveTags.map((hiveTag) => hiveTag.toEntity()).toList();
|
||||
} catch (e) {
|
||||
throw Exception('获取最近使用的标签失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ImageTag> updateTag(ImageTag tag) async {
|
||||
try {
|
||||
final hiveTag = HiveImageTag.fromEntity(tag);
|
||||
await _tagDao.updateHiveTag(hiveTag);
|
||||
return hiveTag.toEntity();
|
||||
} catch (e) {
|
||||
throw Exception('更新标签失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ImageTag?> incrementTagUsage(String tagId) async {
|
||||
try {
|
||||
final updatedTag = await _tagDao.incrementTagUsage(tagId);
|
||||
return updatedTag; // DAO已经返回ImageTag
|
||||
} catch (e) {
|
||||
throw Exception('增加标签使用次数失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据标签名称增加使用次数
|
||||
Future<void> incrementTagUsageByName(String tagName) async {
|
||||
try {
|
||||
final existingTag = await _tagDao.getTagByName(tagName);
|
||||
if (existingTag != null) {
|
||||
// 需要通过Hive标签ID来增加使用次数
|
||||
final hiveTag = await _tagDao.getHiveTagByName(tagName);
|
||||
if (hiveTag != null) {
|
||||
await _tagDao.incrementTagUsage(hiveTag.id);
|
||||
}
|
||||
} else {
|
||||
// 标签不存在,创建新标签
|
||||
await createTagWithParams(
|
||||
name: tagName,
|
||||
icon: 'local_offer',
|
||||
color: '#2196F3',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('更新标签使用次数失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ImageTag?> decrementTagUsage(String tagId) async {
|
||||
try {
|
||||
final updatedTag = await _tagDao.decrementTagUsage(tagId);
|
||||
return updatedTag; // DAO已经返回ImageTag
|
||||
} catch (e) {
|
||||
throw Exception('减少标签使用次数失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> deleteTag(String id) async {
|
||||
try {
|
||||
await _tagDao.deleteTag(id);
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw Exception('删除标签失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> deleteTags(List<String> ids) async {
|
||||
try {
|
||||
for (final id in ids) {
|
||||
await _tagDao.deleteTag(id);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw Exception('批量删除标签失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> tagExists(String id) async {
|
||||
try {
|
||||
return await _tagDao.tagExists(id);
|
||||
} catch (e) {
|
||||
throw Exception('检查标签是否存在失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> tagNameExists(String name) async {
|
||||
try {
|
||||
return await _tagDao.tagNameExists(name);
|
||||
} catch (e) {
|
||||
throw Exception('检查标签名称是否存在失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getTagCount() async {
|
||||
try {
|
||||
return await _tagDao.getTagCount();
|
||||
} catch (e) {
|
||||
throw Exception('获取标签总数失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ImageTag>> searchTags(String query) async {
|
||||
try {
|
||||
final hiveTags = await _tagDao.searchHiveTags(query);
|
||||
return hiveTags.map((hiveTag) => hiveTag.toEntity()).toList();
|
||||
} catch (e) {
|
||||
throw Exception('搜索标签失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ImageTag>> getUnusedTags() async {
|
||||
try {
|
||||
final hiveTags = await _tagDao.getUnusedHiveTags();
|
||||
return hiveTags.map((hiveTag) => hiveTag.toEntity()).toList();
|
||||
} catch (e) {
|
||||
throw Exception('获取未使用的标签失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> cleanupUnusedTags() async {
|
||||
try {
|
||||
return await _tagDao.cleanupUnusedTags();
|
||||
} catch (e) {
|
||||
throw Exception('清理未使用的标签失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成唯一ID
|
||||
String _generateId() {
|
||||
return DateTime.now().millisecondsSinceEpoch.toString() +
|
||||
_generateRandomString(8);
|
||||
}
|
||||
|
||||
/// 生成随机字符串
|
||||
String _generateRandomString(int length) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
final random = DateTime.now().millisecondsSinceEpoch;
|
||||
String result = '';
|
||||
for (int i = 0; i < length; i++) {
|
||||
result += chars[(random + i) % chars.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -66,4 +66,8 @@ class ImageFolder {
|
||||
lastUsedAt: lastUsedAt ?? this.lastUsedAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// 文件夹图片数量 - 计算属性,动态获取文件夹中包含的图片数量
|
||||
/// 注意:这是一个占位符属性,实际实现需要通过Repository查询
|
||||
int get imageCount => 0;
|
||||
}
|
||||
@ -7,6 +7,7 @@ import '../core/theme/app_theme.dart';
|
||||
import '../core/utils/logger.dart';
|
||||
import 'providers/share_provider.dart';
|
||||
import 'widgets/share_test_widget.dart';
|
||||
import 'widgets/save_dialog.dart';
|
||||
|
||||
/// 主应用组件
|
||||
/// 负责配置MaterialApp和全局设置
|
||||
@ -110,19 +111,28 @@ class _AppHomePageState extends ConsumerState<AppHomePage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
Logger.debug('构建AppHomePage');
|
||||
|
||||
final shareState = ref.watch(shareProvider);
|
||||
|
||||
Logger.debug('构建AppHomePage, 分享状态: showShareUI=${shareState.showShareUI}, 文件数量=${shareState.pendingFiles.length}');
|
||||
|
||||
// 如果有错误,打印错误信息
|
||||
if (shareState.error != null) {
|
||||
Logger.error('分享状态错误: ${shareState.error}');
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('想拍'),
|
||||
centerTitle: true,
|
||||
elevation: 0,
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
body: Stack(
|
||||
children: [
|
||||
// 主页面内容
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 应用Logo(占位符)
|
||||
Icon(
|
||||
Icons.photo_library,
|
||||
@ -182,9 +192,21 @@ class _AppHomePageState extends ConsumerState<AppHomePage> {
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 分享保存对话框
|
||||
if (shareState.showShareUI && shareState.pendingFiles.isNotEmpty)
|
||||
SaveDialog(
|
||||
sharedFiles: shareState.pendingFiles,
|
||||
onClose: () {
|
||||
// 关闭分享界面并清除待处理文件
|
||||
ref.read(shareProvider.notifier).clearPendingFiles();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
|
||||
// 底部导航栏(占位符)
|
||||
bottomNavigationBar: NavigationBar(
|
||||
destinations: const [
|
||||
|
||||
218
lib/presentation/providers/folder_provider.dart
Normal file
218
lib/presentation/providers/folder_provider.dart
Normal file
@ -0,0 +1,218 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../data/repositories/folder_repository.dart';
|
||||
import '../../data/repositories/folder_repository_impl.dart';
|
||||
import '../../data/datasources/local/folder_dao.dart';
|
||||
|
||||
/// 文件夹状态类 - 管理文件夹列表的状态
|
||||
class FolderState {
|
||||
/// 文件夹列表
|
||||
final List<FolderModel> folders;
|
||||
|
||||
/// 是否正在加载
|
||||
final bool isLoading;
|
||||
|
||||
/// 错误信息
|
||||
final String? error;
|
||||
|
||||
FolderState({
|
||||
required this.folders,
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
/// 创建初始状态
|
||||
factory FolderState.initial() {
|
||||
return FolderState(folders: []);
|
||||
}
|
||||
|
||||
/// 创建加载状态
|
||||
FolderState copyWithLoading({
|
||||
List<FolderModel>? folders,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return FolderState(
|
||||
folders: folders ?? this.folders,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 文件夹模型 - 简化的文件夹数据模型
|
||||
class FolderModel {
|
||||
/// 文件夹ID
|
||||
final String id;
|
||||
|
||||
/// 文件夹名称
|
||||
final String name;
|
||||
|
||||
/// 文件夹图标
|
||||
final String icon;
|
||||
|
||||
/// 文件夹中的图片数量
|
||||
final int imageCount;
|
||||
|
||||
/// 创建时间
|
||||
final DateTime createdAt;
|
||||
|
||||
/// 最后使用时间
|
||||
final DateTime lastUsedAt;
|
||||
|
||||
FolderModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.icon,
|
||||
this.imageCount = 0,
|
||||
required this.createdAt,
|
||||
required this.lastUsedAt,
|
||||
});
|
||||
|
||||
/// 从Map创建FolderModel
|
||||
factory FolderModel.fromMap(Map<String, dynamic> map) {
|
||||
return FolderModel(
|
||||
id: map['id'] as String,
|
||||
name: map['name'] as String,
|
||||
icon: map['icon'] as String? ?? 'folder',
|
||||
imageCount: map['imageCount'] as int? ?? 0,
|
||||
createdAt: DateTime.parse(map['createdAt'] as String),
|
||||
lastUsedAt: DateTime.parse(map['lastUsedAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
/// 转换为Map
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'icon': icon,
|
||||
'imageCount': imageCount,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'lastUsedAt': lastUsedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 文件夹Provider - 提供文件夹管理功能
|
||||
class FolderProvider extends StateNotifier<FolderState> {
|
||||
final FolderRepository _folderRepository;
|
||||
|
||||
FolderProvider(this._folderRepository) : super(FolderState.initial());
|
||||
|
||||
/// 加载文件夹列表
|
||||
Future<void> loadFolders() async {
|
||||
state = state.copyWithLoading(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final folders = await _folderRepository.getAllFolders();
|
||||
final folderModels = folders.map((folder) => FolderModel.fromMap({
|
||||
'id': folder.id,
|
||||
'name': folder.name,
|
||||
'icon': folder.icon,
|
||||
'imageCount': folder.imageCount,
|
||||
'createdAt': folder.createdAt.toIso8601String(),
|
||||
'lastUsedAt': folder.lastUsedAt.toIso8601String(),
|
||||
})).toList();
|
||||
|
||||
state = state.copyWithLoading(
|
||||
folders: folderModels,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWithLoading(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建新文件夹
|
||||
Future<String?> createFolder({
|
||||
required String name,
|
||||
required String icon,
|
||||
}) async {
|
||||
try {
|
||||
final folderId = await _folderRepository.createFolder(
|
||||
name: name,
|
||||
icon: icon,
|
||||
);
|
||||
|
||||
// 重新加载文件夹列表
|
||||
await loadFolders();
|
||||
|
||||
return folderId;
|
||||
} catch (e) {
|
||||
state = state.copyWithLoading(error: e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新文件夹信息
|
||||
Future<bool> updateFolder({
|
||||
required String id,
|
||||
required String name,
|
||||
required String icon,
|
||||
}) async {
|
||||
try {
|
||||
await _folderRepository.updateFolder(
|
||||
id: id,
|
||||
name: name,
|
||||
icon: icon,
|
||||
);
|
||||
|
||||
// 重新加载文件夹列表
|
||||
await loadFolders();
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
state = state.copyWithLoading(error: e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除文件夹
|
||||
Future<bool> deleteFolder(String id) async {
|
||||
try {
|
||||
await _folderRepository.deleteFolder(id);
|
||||
|
||||
// 重新加载文件夹列表
|
||||
await loadFolders();
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
state = state.copyWithLoading(error: e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新文件夹最后使用时间
|
||||
Future<void> updateLastUsedTime(String folderId) async {
|
||||
try {
|
||||
await _folderRepository.updateFolderLastUsed(folderId);
|
||||
|
||||
// 重新加载文件夹列表
|
||||
await loadFolders();
|
||||
} catch (e) {
|
||||
// 静默处理错误,不显示给用户
|
||||
debugPrint('更新文件夹使用时间失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 文件夹Provider实例
|
||||
final folderProvider = StateNotifierProvider<FolderProvider, FolderState>((ref) {
|
||||
final folderRepository = ref.watch(folderRepositoryProvider);
|
||||
return FolderProvider(folderRepository);
|
||||
});
|
||||
|
||||
/// 文件夹Repository Provider
|
||||
final folderRepositoryProvider = Provider<FolderRepository>((ref) {
|
||||
final folderDao = ref.watch(folderDaoProvider);
|
||||
return FolderRepositoryImpl(folderDao);
|
||||
});
|
||||
|
||||
/// 文件夹DAO Provider
|
||||
final folderDaoProvider = Provider<FolderDao>((ref) {
|
||||
return FolderDao();
|
||||
});
|
||||
@ -348,11 +348,93 @@ class ShareNotifier extends StateNotifier<ShareState> {
|
||||
_checkPendingShares();
|
||||
}
|
||||
|
||||
/// 清除待处理文件 - 清空当前待处理的分享文件
|
||||
void clearPendingFiles() {
|
||||
Logger.info('清除待处理分享文件');
|
||||
state = state.copyWith(
|
||||
pendingFiles: [],
|
||||
showShareUI: false,
|
||||
isProcessing: false,
|
||||
error: null,
|
||||
);
|
||||
|
||||
// 同时清除仓库中的待处理文件
|
||||
_shareRepository.clearCurrentShare();
|
||||
}
|
||||
|
||||
/// 获取分享文件数量 - 获取当前待处理的分享文件数量
|
||||
int getShareFileCount() {
|
||||
return _shareRepository.getShareFileCount();
|
||||
}
|
||||
|
||||
/// 批量保存图片 - 保存多张分享的图片
|
||||
/// [sharedFiles] 分享的媒体文件列表
|
||||
/// [folderId] 目标文件夹ID
|
||||
/// [tags] 标签列表
|
||||
/// [note] 备注内容
|
||||
Future<void> saveBatchImages({
|
||||
required List<SharedMediaFile> sharedFiles,
|
||||
required String folderId,
|
||||
required List<String> tags,
|
||||
String? note,
|
||||
}) async {
|
||||
try {
|
||||
Logger.info('开始批量保存${sharedFiles.length}张图片');
|
||||
|
||||
// 更新状态为处理中
|
||||
state = state.copyWith(isProcessing: true, error: null);
|
||||
|
||||
// 模拟保存过程,实际实现中会调用真实的保存逻辑
|
||||
await _simulateSaveProcess(folderId, tags, note);
|
||||
|
||||
// 保存完成
|
||||
state = state.copyWith(isProcessing: false);
|
||||
Logger.info('批量保存完成');
|
||||
|
||||
} catch (e) {
|
||||
Logger.error('批量保存失败', error: e);
|
||||
state = state.copyWith(
|
||||
isProcessing: false,
|
||||
error: '保存失败: $e',
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 单张保存图片 - 保存单张分享的图片
|
||||
/// [sharedFile] 分享的媒体文件
|
||||
/// [folderId] 目标文件夹ID
|
||||
/// [tags] 标签列表
|
||||
/// [note] 备注内容
|
||||
Future<void> saveSingleImage({
|
||||
required SharedMediaFile sharedFile,
|
||||
required String folderId,
|
||||
required List<String> tags,
|
||||
String? note,
|
||||
}) async {
|
||||
try {
|
||||
Logger.info('开始单张保存图片: ${sharedFile.path}');
|
||||
|
||||
// 更新状态为处理中
|
||||
state = state.copyWith(isProcessing: true, error: null);
|
||||
|
||||
// 模拟保存过程,实际实现中会调用真实的保存逻辑
|
||||
await _simulateSaveProcess(folderId, tags, note);
|
||||
|
||||
// 保存完成
|
||||
state = state.copyWith(isProcessing: false);
|
||||
Logger.info('单张保存完成');
|
||||
|
||||
} catch (e) {
|
||||
Logger.error('单张保存失败', error: e);
|
||||
state = state.copyWith(
|
||||
isProcessing: false,
|
||||
error: '保存失败: $e',
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 释放资源 - 清理分享相关资源
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
|
||||
280
lib/presentation/providers/tag_provider.dart
Normal file
280
lib/presentation/providers/tag_provider.dart
Normal file
@ -0,0 +1,280 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../domain/repositories/tag_repository.dart';
|
||||
import '../../domain/entities/image_tag.dart';
|
||||
import '../../data/repositories/tag_repository_impl.dart';
|
||||
import '../../data/datasources/local/tag_dao.dart';
|
||||
|
||||
/// 标签状态类 - 管理标签列表的状态
|
||||
class TagState {
|
||||
/// 标签列表
|
||||
final List<TagModel> tags;
|
||||
|
||||
/// 是否正在加载
|
||||
final bool isLoading;
|
||||
|
||||
/// 错误信息
|
||||
final String? error;
|
||||
|
||||
TagState({
|
||||
required this.tags,
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
/// 创建初始状态
|
||||
factory TagState.initial() {
|
||||
return TagState(tags: []);
|
||||
}
|
||||
|
||||
/// 创建加载状态
|
||||
TagState copyWithLoading({
|
||||
List<TagModel>? tags,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return TagState(
|
||||
tags: tags ?? this.tags,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 标签模型 - 简化的标签数据模型
|
||||
class TagModel {
|
||||
/// 标签ID
|
||||
final String id;
|
||||
|
||||
/// 标签名称
|
||||
final String name;
|
||||
|
||||
/// 标签图标
|
||||
final String icon;
|
||||
|
||||
/// 标签颜色
|
||||
final String color;
|
||||
|
||||
/// 使用次数
|
||||
final int usageCount;
|
||||
|
||||
/// 最后使用时间
|
||||
final DateTime lastUsedAt;
|
||||
|
||||
TagModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
this.usageCount = 0,
|
||||
required this.lastUsedAt,
|
||||
});
|
||||
|
||||
/// 从Map创建TagModel
|
||||
factory TagModel.fromMap(Map<String, dynamic> map) {
|
||||
return TagModel(
|
||||
id: map['id'] as String,
|
||||
name: map['name'] as String,
|
||||
icon: map['icon'] as String? ?? 'local_offer',
|
||||
color: map['color'] as String? ?? '#2196F3',
|
||||
usageCount: map['usageCount'] as int? ?? 0,
|
||||
lastUsedAt: DateTime.parse(map['lastUsedAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
/// 转换为Map
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'icon': icon,
|
||||
'color': color,
|
||||
'usageCount': usageCount,
|
||||
'lastUsedAt': lastUsedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 标签Provider - 提供标签管理功能
|
||||
class TagProvider extends StateNotifier<TagState> {
|
||||
final TagRepository _tagRepository;
|
||||
|
||||
TagProvider(this._tagRepository) : super(TagState.initial());
|
||||
|
||||
/// 加载标签列表
|
||||
Future<void> loadTags() async {
|
||||
state = state.copyWithLoading(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final tags = await _tagRepository.getAllTags();
|
||||
final tagModels = tags.map((tag) => TagModel.fromMap({
|
||||
'id': tag.id,
|
||||
'name': tag.name,
|
||||
'icon': tag.icon,
|
||||
'color': tag.color,
|
||||
'usageCount': tag.usageCount,
|
||||
'lastUsedAt': tag.lastUsedAt.toIso8601String(),
|
||||
})).toList();
|
||||
|
||||
state = state.copyWithLoading(
|
||||
tags: tagModels,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWithLoading(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建新标签
|
||||
Future<String?> createTag({
|
||||
required String name,
|
||||
required String icon,
|
||||
required String color,
|
||||
}) async {
|
||||
try {
|
||||
final now = DateTime.now();
|
||||
final newTag = ImageTag(
|
||||
id: '', // 将在Repository中生成
|
||||
name: name,
|
||||
icon: icon,
|
||||
color: color,
|
||||
usageCount: 0,
|
||||
lastUsedAt: now,
|
||||
);
|
||||
|
||||
final tagId = await _tagRepository.createTag(newTag);
|
||||
|
||||
// 重新加载标签列表
|
||||
await loadTags();
|
||||
|
||||
return tagId;
|
||||
} catch (e) {
|
||||
state = state.copyWithLoading(error: e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新标签信息
|
||||
Future<bool> updateTag({
|
||||
required String id,
|
||||
required String name,
|
||||
required String icon,
|
||||
required String color,
|
||||
}) async {
|
||||
try {
|
||||
// 先获取现有标签
|
||||
final existingTag = await _tagRepository.getTag(id);
|
||||
if (existingTag == null) {
|
||||
state = state.copyWithLoading(error: '标签不存在');
|
||||
return false;
|
||||
}
|
||||
|
||||
final updatedTag = ImageTag(
|
||||
id: id,
|
||||
name: name,
|
||||
icon: icon,
|
||||
color: color,
|
||||
usageCount: existingTag.usageCount,
|
||||
lastUsedAt: existingTag.lastUsedAt,
|
||||
);
|
||||
|
||||
await _tagRepository.updateTag(updatedTag);
|
||||
|
||||
// 重新加载标签列表
|
||||
await loadTags();
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
state = state.copyWithLoading(error: e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除标签
|
||||
Future<bool> deleteTag(String id) async {
|
||||
try {
|
||||
await _tagRepository.deleteTag(id);
|
||||
|
||||
// 重新加载标签列表
|
||||
await loadTags();
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
state = state.copyWithLoading(error: e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 增加标签使用次数
|
||||
Future<void> incrementTagUsage(String tagName) async {
|
||||
try {
|
||||
// 查找标签
|
||||
final existingTag = state.tags.firstWhere(
|
||||
(tag) => tag.name == tagName,
|
||||
orElse: () => TagModel(
|
||||
id: '',
|
||||
name: tagName,
|
||||
icon: 'local_offer',
|
||||
color: '#2196F3',
|
||||
lastUsedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
if (existingTag.id.isNotEmpty) {
|
||||
// 标签已存在,更新使用次数
|
||||
final tagRepo = _tagRepository as TagRepositoryImpl;
|
||||
await tagRepo.incrementTagUsageByName(tagName);
|
||||
await loadTags();
|
||||
} else {
|
||||
// 标签不存在,创建新标签
|
||||
final tagRepo = _tagRepository as TagRepositoryImpl;
|
||||
await tagRepo.createTagWithParams(
|
||||
name: tagName,
|
||||
icon: 'local_offer',
|
||||
color: '#2196F3',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默处理错误,不显示给用户
|
||||
debugPrint('更新标签使用次数失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取热门标签
|
||||
List<TagModel> getPopularTags({int limit = 10}) {
|
||||
final sortedTags = List<TagModel>.from(state.tags)
|
||||
..sort((a, b) => b.usageCount.compareTo(a.usageCount));
|
||||
|
||||
return sortedTags.take(limit).toList();
|
||||
}
|
||||
|
||||
/// 搜索标签
|
||||
List<TagModel> searchTags(String keyword) {
|
||||
if (keyword.isEmpty) {
|
||||
return state.tags;
|
||||
}
|
||||
|
||||
return state.tags.where((tag) =>
|
||||
tag.name.toLowerCase().contains(keyword.toLowerCase())).toList();
|
||||
}
|
||||
}
|
||||
|
||||
/// 标签Provider实例
|
||||
final tagProvider = StateNotifierProvider<TagProvider, TagState>((ref) {
|
||||
final tagRepository = ref.watch(tagRepositoryProvider);
|
||||
return TagProvider(tagRepository);
|
||||
});
|
||||
|
||||
/// 标签Repository Provider
|
||||
final tagRepositoryProvider = Provider<TagRepository>((ref) {
|
||||
final tagDao = ref.watch(tagDaoProvider);
|
||||
return TagRepositoryImpl(tagDao);
|
||||
});
|
||||
|
||||
/// 标签DAO Provider
|
||||
final tagDaoProvider = Provider<TagDao>((ref) {
|
||||
return TagDao();
|
||||
});
|
||||
696
lib/presentation/widgets/folder_selector.dart
Normal file
696
lib/presentation/widgets/folder_selector.dart
Normal file
@ -0,0 +1,696 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/folder_provider.dart';
|
||||
import 'custom_button.dart';
|
||||
|
||||
/// 文件夹选择器弹窗 - 用于选择保存图片的目标文件夹
|
||||
/// 支持文件夹列表显示、新建文件夹、最近使用排序等功能
|
||||
class FolderSelectorDialog extends ConsumerStatefulWidget {
|
||||
/// 文件夹选择回调
|
||||
final Function(String folderId) onFolderSelected;
|
||||
|
||||
/// 当前选中的文件夹ID
|
||||
final String? currentFolderId;
|
||||
|
||||
const FolderSelectorDialog({
|
||||
Key? key,
|
||||
required this.onFolderSelected,
|
||||
this.currentFolderId,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
ConsumerState<FolderSelectorDialog> createState() => _FolderSelectorDialogState();
|
||||
}
|
||||
|
||||
class _FolderSelectorDialogState extends ConsumerState<FolderSelectorDialog> {
|
||||
/// 搜索控制器
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
/// 搜索关键词
|
||||
String searchKeyword = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 加载文件夹列表
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(folderProvider.notifier).loadFolders();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
width: 480,
|
||||
height: 600,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 标题栏
|
||||
_buildHeader(colorScheme),
|
||||
|
||||
// 搜索框
|
||||
_buildSearchBar(colorScheme),
|
||||
|
||||
// 文件夹列表
|
||||
Expanded(
|
||||
child: _buildFolderList(colorScheme),
|
||||
),
|
||||
|
||||
// 底部操作栏
|
||||
_buildFooter(colorScheme),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建标题栏
|
||||
Widget _buildHeader(ColorScheme colorScheme) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.folder,
|
||||
color: colorScheme.primary,
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'选择文件夹',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: '关闭',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建搜索框
|
||||
Widget _buildSearchBar(ColorScheme colorScheme) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜索文件夹...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: searchKeyword.isNotEmpty
|
||||
? IconButton(
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
searchKeyword = '';
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.clear),
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
searchKeyword = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建文件夹列表
|
||||
Widget _buildFolderList(ColorScheme colorScheme) {
|
||||
final folderState = ref.watch(folderProvider);
|
||||
|
||||
if (folderState.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
if (folderState.error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'加载文件夹失败',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomButton(
|
||||
text: '重试',
|
||||
onPressed: () {
|
||||
ref.read(folderProvider.notifier).loadFolders();
|
||||
},
|
||||
buttonType: ButtonType.outlined,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final folders = folderState.folders;
|
||||
|
||||
if (folders.isEmpty) {
|
||||
return _buildEmptyState(colorScheme);
|
||||
}
|
||||
|
||||
// 过滤文件夹
|
||||
final filteredFolders = searchKeyword.isEmpty
|
||||
? folders
|
||||
: folders.where((folder) =>
|
||||
folder.name.toLowerCase().contains(searchKeyword.toLowerCase())).toList();
|
||||
|
||||
if (filteredFolders.isEmpty) {
|
||||
return _buildNoSearchResults(colorScheme);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
itemCount: filteredFolders.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final folder = filteredFolders[index];
|
||||
final isSelected = folder.id == widget.currentFolderId;
|
||||
|
||||
return _buildFolderItem(folder, isSelected, colorScheme);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建空状态
|
||||
Widget _buildEmptyState(ColorScheme colorScheme) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.folder_outlined,
|
||||
size: 64,
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'还没有文件夹',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'创建第一个文件夹来开始整理你的灵感',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.outline.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
CustomButton(
|
||||
text: '创建文件夹',
|
||||
onPressed: _showCreateFolderDialog,
|
||||
prefixIcon: Icons.add,
|
||||
buttonType: ButtonType.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建无搜索结果状态
|
||||
Widget _buildNoSearchResults(ColorScheme colorScheme) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search_off,
|
||||
size: 64,
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'未找到匹配的文件夹',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'尝试使用其他关键词搜索',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.outline.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建文件夹项
|
||||
Widget _buildFolderItem(
|
||||
dynamic folder,
|
||||
bool isSelected,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () {
|
||||
widget.onFolderSelected(folder.id);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.outline.withOpacity(0.2),
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
color: isSelected
|
||||
? colorScheme.primary.withOpacity(0.1)
|
||||
: colorScheme.surface,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 文件夹图标
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? colorScheme.primary.withOpacity(0.2)
|
||||
: colorScheme.primaryContainer.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
_getFolderIcon(folder.icon),
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.primary,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// 文件夹信息
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
folder.name,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${folder.imageCount} 张图片',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 选择标记
|
||||
if (isSelected)
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
color: colorScheme.primary,
|
||||
size: 24,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建底部操作栏
|
||||
Widget _buildFooter(ColorScheme colorScheme) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(16),
|
||||
bottomRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 创建新文件夹按钮
|
||||
Expanded(
|
||||
child: CustomButton(
|
||||
text: '新建文件夹',
|
||||
onPressed: _showCreateFolderDialog,
|
||||
prefixIcon: Icons.add,
|
||||
buttonType: ButtonType.outlined,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// 取消按钮
|
||||
CustomButton(
|
||||
text: '取消',
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
buttonType: ButtonType.text,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示创建文件夹对话框
|
||||
void _showCreateFolderDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => CreateFolderDialog(
|
||||
onFolderCreated: (folderId) {
|
||||
Navigator.of(context).pop(); // 关闭创建对话框
|
||||
widget.onFolderSelected(folderId);
|
||||
Navigator.of(context).pop(); // 关闭选择器对话框
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取文件夹图标
|
||||
IconData _getFolderIcon(String? iconCode) {
|
||||
// 默认文件夹图标
|
||||
if (iconCode == null || iconCode.isEmpty) {
|
||||
return Icons.folder;
|
||||
}
|
||||
|
||||
// 尝试解析Material Icons名称
|
||||
try {
|
||||
// 这里需要根据实际的图标存储方式来实现
|
||||
// 暂时返回默认图标
|
||||
return Icons.folder;
|
||||
} catch (e) {
|
||||
return Icons.folder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建文件夹对话框
|
||||
class CreateFolderDialog extends ConsumerStatefulWidget {
|
||||
/// 文件夹创建成功回调
|
||||
final Function(String folderId) onFolderCreated;
|
||||
|
||||
const CreateFolderDialog({
|
||||
Key? key,
|
||||
required this.onFolderCreated,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
ConsumerState<CreateFolderDialog> createState() => _CreateFolderDialogState();
|
||||
}
|
||||
|
||||
class _CreateFolderDialogState extends ConsumerState<CreateFolderDialog> {
|
||||
/// 文件夹名称控制器
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
|
||||
/// 选中的图标
|
||||
String selectedIcon = 'folder';
|
||||
|
||||
/// 表单键
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
|
||||
/// 是否正在创建
|
||||
bool isCreating = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
width: 400,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Text(
|
||||
'新建文件夹',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 文件夹名称输入
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '文件夹名称',
|
||||
hintText: '输入文件夹名称',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.folder),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '请输入文件夹名称';
|
||||
}
|
||||
if (value.trim().length > 20) {
|
||||
return '文件夹名称不能超过20个字符';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _createFolder(),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 图标选择
|
||||
Text(
|
||||
'选择图标',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildIconSelector(colorScheme),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 操作按钮
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
// 取消按钮
|
||||
CustomButton(
|
||||
text: '取消',
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
buttonType: ButtonType.text,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// 创建按钮
|
||||
CustomButton(
|
||||
text: '创建',
|
||||
onPressed: isCreating ? null : _createFolder,
|
||||
buttonType: ButtonType.primary,
|
||||
isLoading: isCreating,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建图标选择器
|
||||
Widget _buildIconSelector(ColorScheme colorScheme) {
|
||||
final icons = [
|
||||
'folder',
|
||||
'bookmark',
|
||||
'favorite',
|
||||
'star',
|
||||
'work',
|
||||
'home',
|
||||
'travel_explore',
|
||||
'restaurant',
|
||||
'shopping_bag',
|
||||
'sports_esports',
|
||||
];
|
||||
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: icons.map((icon) {
|
||||
final isSelected = selectedIcon == icon;
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
selectedIcon = icon;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? colorScheme.primary.withOpacity(0.2)
|
||||
: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.outline.withOpacity(0.2),
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
_getIconData(icon),
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurface,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建文件夹
|
||||
Future<void> _createFolder() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
isCreating = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final folderId = await ref.read(folderProvider.notifier).createFolder(
|
||||
name: _nameController.text.trim(),
|
||||
icon: selectedIcon,
|
||||
);
|
||||
|
||||
if (folderId != null) {
|
||||
widget.onFolderCreated(folderId);
|
||||
} else {
|
||||
_showErrorSnackBar('创建文件夹失败');
|
||||
}
|
||||
} catch (e) {
|
||||
_showErrorSnackBar('创建文件夹失败:${e.toString()}');
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isCreating = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示错误提示
|
||||
void _showErrorSnackBar(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取图标数据
|
||||
IconData _getIconData(String iconName) {
|
||||
switch (iconName) {
|
||||
case 'folder':
|
||||
return Icons.folder;
|
||||
case 'bookmark':
|
||||
return Icons.bookmark;
|
||||
case 'favorite':
|
||||
return Icons.favorite;
|
||||
case 'star':
|
||||
return Icons.star;
|
||||
case 'work':
|
||||
return Icons.work;
|
||||
case 'home':
|
||||
return Icons.home;
|
||||
case 'travel_explore':
|
||||
return Icons.travel_explore;
|
||||
case 'restaurant':
|
||||
return Icons.restaurant;
|
||||
case 'shopping_bag':
|
||||
return Icons.shopping_bag;
|
||||
case 'sports_esports':
|
||||
return Icons.sports_esports;
|
||||
default:
|
||||
return Icons.folder;
|
||||
}
|
||||
}
|
||||
}
|
||||
488
lib/presentation/widgets/image_grid_preview.dart
Normal file
488
lib/presentation/widgets/image_grid_preview.dart
Normal file
@ -0,0 +1,488 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
||||
|
||||
/// 图片网格预览组件 - 用于批量模式下显示多张图片的网格布局
|
||||
/// 支持图片预览、选择状态和错误处理
|
||||
class ImageGridPreview extends StatelessWidget {
|
||||
/// 分享的图片文件列表
|
||||
final List<SharedMediaFile> images;
|
||||
|
||||
/// 选中的图片索引列表
|
||||
final List<int> selectedIndices;
|
||||
|
||||
/// 图片选择状态变更回调
|
||||
final Function(int index, bool selected) onSelectionChanged;
|
||||
|
||||
/// 是否显示选择框
|
||||
final bool showSelection;
|
||||
|
||||
/// 网格列数
|
||||
final int crossAxisCount;
|
||||
|
||||
/// 图片间距
|
||||
final double spacing;
|
||||
|
||||
/// 边距
|
||||
final EdgeInsets padding;
|
||||
|
||||
const ImageGridPreview({
|
||||
Key? key,
|
||||
required this.images,
|
||||
required this.selectedIndices,
|
||||
required this.onSelectionChanged,
|
||||
this.showSelection = true,
|
||||
this.crossAxisCount = 2,
|
||||
this.spacing = 8.0,
|
||||
this.padding = const EdgeInsets.all(16),
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (images.isEmpty) {
|
||||
return _buildEmptyState(context);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: GridView.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
crossAxisSpacing: spacing,
|
||||
mainAxisSpacing: spacing,
|
||||
childAspectRatio: 1.0,
|
||||
),
|
||||
itemCount: images.length,
|
||||
itemBuilder: (context, index) => _buildImageItem(context, index),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建空状态
|
||||
Widget _buildEmptyState(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.photo_library_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'暂无图片',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建单个图片项
|
||||
Widget _buildImageItem(BuildContext context, int index) {
|
||||
final imageFile = images[index];
|
||||
final isSelected = selectedIndices.contains(index);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (showSelection) {
|
||||
onSelectionChanged(index, !isSelected);
|
||||
} else {
|
||||
_showImagePreview(context, index);
|
||||
}
|
||||
},
|
||||
onLongPress: () => _showImagePreview(context, index),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: isSelected
|
||||
? Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 3,
|
||||
)
|
||||
: null,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(5), // 稍小以避免边框溢出
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// 图片显示
|
||||
Image.file(
|
||||
File(imageFile.path),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
_buildErrorPlaceholder(context),
|
||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||
if (frame == null) return _buildLoadingPlaceholder(context);
|
||||
return child;
|
||||
},
|
||||
),
|
||||
|
||||
// 选择标记
|
||||
if (showSelection)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.white.withOpacity(0.7),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.withOpacity(0.5),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: isSelected
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
|
||||
// 文件类型标记
|
||||
if (_isGifImage(imageFile.path))
|
||||
Positioned(
|
||||
bottom: 8,
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'GIF',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建加载中的占位符
|
||||
Widget _buildLoadingPlaceholder(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.grey[200],
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建错误占位符
|
||||
Widget _buildErrorPlaceholder(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.grey[200],
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.broken_image,
|
||||
size: 32,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'加载失败',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示图片预览
|
||||
void _showImagePreview(BuildContext context, int index) {
|
||||
Navigator.of(context).push(
|
||||
PageRouteBuilder<void>(
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
ImagePreviewPage(
|
||||
images: images,
|
||||
initialIndex: index,
|
||||
),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 检查是否为GIF图片
|
||||
bool _isGifImage(String path) {
|
||||
return path.toLowerCase().endsWith('.gif');
|
||||
}
|
||||
}
|
||||
|
||||
/// 图片预览页面 - 全屏显示单张图片
|
||||
class ImagePreviewPage extends StatefulWidget {
|
||||
/// 图片文件列表
|
||||
final List<SharedMediaFile> images;
|
||||
|
||||
/// 初始显示的图片索引
|
||||
final int initialIndex;
|
||||
|
||||
const ImagePreviewPage({
|
||||
Key? key,
|
||||
required this.images,
|
||||
required this.initialIndex,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ImagePreviewPage> createState() => _ImagePreviewPageState();
|
||||
}
|
||||
|
||||
class _ImagePreviewPageState extends State<ImagePreviewPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late PageController _pageController;
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
|
||||
/// 当前显示的图片索引
|
||||
int currentIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
currentIndex = widget.initialIndex;
|
||||
_pageController = PageController(initialPage: currentIndex);
|
||||
|
||||
// 动画控制器
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
// 延迟显示UI控件
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) {
|
||||
_animationController.forward();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
// 图片显示区域
|
||||
PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: widget.images.length,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
currentIndex = index;
|
||||
});
|
||||
},
|
||||
itemBuilder: (context, index) => _buildFullImage(context, index),
|
||||
),
|
||||
|
||||
// 顶部工具栏
|
||||
FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: _buildTopToolbar(),
|
||||
),
|
||||
|
||||
// 底部指示器
|
||||
FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: _buildBottomIndicator(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建全屏图片
|
||||
Widget _buildFullImage(BuildContext context, int index) {
|
||||
final imageFile = widget.images[index];
|
||||
|
||||
return InteractiveViewer(
|
||||
panEnabled: true,
|
||||
boundaryMargin: const EdgeInsets.all(20),
|
||||
minScale: 0.5,
|
||||
maxScale: 4.0,
|
||||
child: Image.file(
|
||||
File(imageFile.path),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) => _buildErrorView(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建错误视图
|
||||
Widget _buildErrorView() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.broken_image,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'图片加载失败',
|
||||
style: TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建顶部工具栏
|
||||
Widget _buildTopToolbar() {
|
||||
return Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
height: MediaQuery.of(context).padding.top + 56,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.7),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top,
|
||||
left: 16,
|
||||
right: 16,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 返回按钮
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// 图片信息
|
||||
if (_isGifImage(widget.images[currentIndex].path))
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'GIF',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建底部指示器
|
||||
Widget _buildBottomIndicator() {
|
||||
return Positioned(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
'${currentIndex + 1} / ${widget.images.length}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 检查是否为GIF图片
|
||||
bool _isGifImage(String path) {
|
||||
return path.toLowerCase().endsWith('.gif');
|
||||
}
|
||||
}
|
||||
549
lib/presentation/widgets/save_dialog.dart
Normal file
549
lib/presentation/widgets/save_dialog.dart
Normal file
@ -0,0 +1,549 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
||||
import '../providers/share_provider.dart';
|
||||
import 'custom_button.dart';
|
||||
import 'image_grid_preview.dart';
|
||||
import 'folder_selector.dart';
|
||||
import 'tag_selector.dart';
|
||||
|
||||
/// 保存对话框 - 处理分享图片的保存界面
|
||||
/// 支持批量保存、文件夹选择、标签添加等功能
|
||||
class SaveDialog extends ConsumerStatefulWidget {
|
||||
/// 分享接收的媒体文件列表
|
||||
final List<SharedMediaFile> sharedFiles;
|
||||
|
||||
/// 对话框关闭回调
|
||||
final VoidCallback onClose;
|
||||
|
||||
const SaveDialog({
|
||||
Key? key,
|
||||
required this.sharedFiles,
|
||||
required this.onClose,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
ConsumerState<SaveDialog> createState() => _SaveDialogState();
|
||||
}
|
||||
|
||||
class _SaveDialogState extends ConsumerState<SaveDialog> {
|
||||
/// 当前选中的文件夹ID
|
||||
String? selectedFolderId;
|
||||
|
||||
/// 当前输入的标签列表
|
||||
final List<String> tags = [];
|
||||
|
||||
/// 标签输入控制器
|
||||
final TextEditingController tagController = TextEditingController();
|
||||
|
||||
/// 备注输入控制器
|
||||
final TextEditingController noteController = TextEditingController();
|
||||
|
||||
/// 是否为批量保存模式
|
||||
bool isBatchMode = true;
|
||||
|
||||
/// 当前编辑的图片索引
|
||||
int currentEditingIndex = 0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
tagController.dispose();
|
||||
noteController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 切换批量/单张编辑模式
|
||||
void toggleEditMode() {
|
||||
setState(() {
|
||||
isBatchMode = !isBatchMode;
|
||||
currentEditingIndex = 0;
|
||||
});
|
||||
}
|
||||
|
||||
/// 切换到下一张图片编辑
|
||||
void nextImage() {
|
||||
if (currentEditingIndex < widget.sharedFiles.length - 1) {
|
||||
setState(() {
|
||||
currentEditingIndex++;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换到上一张图片编辑
|
||||
void previousImage() {
|
||||
if (currentEditingIndex > 0) {
|
||||
setState(() {
|
||||
currentEditingIndex--;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加标签
|
||||
void addTag(String tag) {
|
||||
if (tag.isNotEmpty && !tags.contains(tag)) {
|
||||
setState(() {
|
||||
tags.add(tag);
|
||||
});
|
||||
tagController.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// 移除标签
|
||||
void removeTag(String tag) {
|
||||
setState(() {
|
||||
tags.remove(tag);
|
||||
});
|
||||
}
|
||||
|
||||
/// 执行保存操作
|
||||
Future<void> performSave() async {
|
||||
if (selectedFolderId == null) {
|
||||
_showErrorSnackBar('请选择保存文件夹');
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示保存进度
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const Dialog(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('正在保存图片...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
if (isBatchMode) {
|
||||
// 批量保存模式
|
||||
await ref.read(shareProvider.notifier).saveBatchImages(
|
||||
sharedFiles: widget.sharedFiles,
|
||||
folderId: selectedFolderId!,
|
||||
tags: tags,
|
||||
note: noteController.text.trim(),
|
||||
);
|
||||
} else {
|
||||
// 单张保存模式
|
||||
await ref.read(shareProvider.notifier).saveSingleImage(
|
||||
sharedFile: widget.sharedFiles[currentEditingIndex],
|
||||
folderId: selectedFolderId!,
|
||||
tags: tags,
|
||||
note: noteController.text.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
// 关闭进度对话框
|
||||
Navigator.of(context).pop();
|
||||
|
||||
// 显示成功消息
|
||||
_showSuccessSnackBar('图片保存成功!');
|
||||
|
||||
// 关闭保存对话框
|
||||
widget.onClose();
|
||||
} catch (e) {
|
||||
// 关闭进度对话框
|
||||
Navigator.of(context).pop();
|
||||
|
||||
// 显示错误消息
|
||||
_showErrorSnackBar('保存失败:${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示错误提示
|
||||
void _showErrorSnackBar(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示成功提示
|
||||
void _showSuccessSnackBar(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: Container(
|
||||
width: MediaQuery.of(context).size.width * 0.9,
|
||||
height: MediaQuery.of(context).size.height * 0.8,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.scaffoldBackgroundColor.withOpacity(0.95),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 标题栏
|
||||
_buildHeader(colorScheme),
|
||||
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
// 左侧图片预览区域
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _buildImagePreviewArea(),
|
||||
),
|
||||
|
||||
// 分隔线
|
||||
VerticalDivider(
|
||||
color: colorScheme.outline.withOpacity(0.2),
|
||||
thickness: 1,
|
||||
),
|
||||
|
||||
// 右侧设置区域
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: _buildSettingsArea(colorScheme),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 底部操作栏
|
||||
_buildFooter(colorScheme),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建标题栏
|
||||
Widget _buildHeader(ColorScheme colorScheme) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'保存灵感',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
|
||||
// 批量/单张切换按钮
|
||||
if (widget.sharedFiles.length > 1)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FilterChip(
|
||||
label: const Text('批量'),
|
||||
selected: isBatchMode,
|
||||
onSelected: (selected) {
|
||||
if (selected) toggleEditMode();
|
||||
},
|
||||
avatar: const Icon(Icons.photo_library),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
label: const Text('单张'),
|
||||
selected: !isBatchMode,
|
||||
onSelected: (selected) {
|
||||
if (selected) toggleEditMode();
|
||||
},
|
||||
avatar: const Icon(Icons.photo),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 关闭按钮
|
||||
IconButton(
|
||||
onPressed: widget.onClose,
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: '关闭',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建图片预览区域
|
||||
Widget _buildImagePreviewArea() {
|
||||
if (isBatchMode) {
|
||||
// 批量模式:使用图片网格预览组件
|
||||
return ImageGridPreview(
|
||||
images: widget.sharedFiles,
|
||||
selectedIndices: [], // 批量模式下显示所有图片
|
||||
onSelectionChanged: (index, selected) {
|
||||
// 批量模式下不处理选择
|
||||
},
|
||||
showSelection: false, // 批量模式下不显示选择框
|
||||
crossAxisCount: 2,
|
||||
spacing: 8.0,
|
||||
padding: const EdgeInsets.all(16),
|
||||
);
|
||||
} else {
|
||||
// 单张模式:显示单张图片和切换控制
|
||||
return Column(
|
||||
children: [
|
||||
// 图片显示区域
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(
|
||||
File(widget.sharedFiles[currentEditingIndex].path),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
color: Colors.grey[300],
|
||||
child: const Icon(
|
||||
Icons.broken_image,
|
||||
color: Colors.grey,
|
||||
size: 64,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 图片切换控制
|
||||
if (widget.sharedFiles.length > 1)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: currentEditingIndex > 0 ? previousImage : null,
|
||||
icon: const Icon(Icons.keyboard_arrow_left),
|
||||
),
|
||||
Text(
|
||||
'${currentEditingIndex + 1} / ${widget.sharedFiles.length}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: currentEditingIndex < widget.sharedFiles.length - 1
|
||||
? nextImage
|
||||
: null,
|
||||
icon: const Icon(Icons.keyboard_arrow_right),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建设置区域
|
||||
Widget _buildSettingsArea(ColorScheme colorScheme) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 文件夹选择
|
||||
_buildFolderSelector(colorScheme),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 标签输入
|
||||
_buildTagInput(colorScheme),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 备注输入
|
||||
_buildNoteInput(colorScheme),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建文件夹选择器
|
||||
Widget _buildFolderSelector(ColorScheme colorScheme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'选择文件夹',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: _showFolderSelector,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: colorScheme.outline.withOpacity(0.3)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: colorScheme.surface,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
selectedFolderId != null ? Icons.folder : Icons.folder_outlined,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
selectedFolderId != null ? '已选择文件夹' : '点击选择文件夹',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: selectedFolderId != null
|
||||
? colorScheme.onSurface
|
||||
: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建标签输入
|
||||
Widget _buildTagInput(ColorScheme colorScheme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'添加标签',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TagSelector(
|
||||
selectedTags: tags,
|
||||
onTagsChanged: (newTags) {
|
||||
setState(() {
|
||||
tags.clear();
|
||||
tags.addAll(newTags);
|
||||
});
|
||||
},
|
||||
showCreateButton: true,
|
||||
maxTags: 10,
|
||||
hintText: '输入标签或从已有标签中选择',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建备注输入
|
||||
Widget _buildNoteInput(ColorScheme colorScheme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'添加备注',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: noteController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入备注信息(可选)',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.note_add),
|
||||
),
|
||||
maxLines: 3,
|
||||
minLines: 1,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建底部操作栏
|
||||
Widget _buildFooter(ColorScheme colorScheme) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(16),
|
||||
bottomRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
// 取消按钮
|
||||
CustomButton(
|
||||
text: '取消',
|
||||
onPressed: widget.onClose,
|
||||
buttonType: ButtonType.text,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// 保存按钮
|
||||
CustomButton(
|
||||
text: isBatchMode
|
||||
? '保存 ${widget.sharedFiles.length} 张图片'
|
||||
: '保存图片',
|
||||
onPressed: performSave,
|
||||
buttonType: ButtonType.primary,
|
||||
isLoading: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示文件夹选择器
|
||||
void _showFolderSelector() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => FolderSelectorDialog(
|
||||
onFolderSelected: (folderId) {
|
||||
setState(() {
|
||||
selectedFolderId = folderId;
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
currentFolderId: selectedFolderId,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -218,6 +218,14 @@ class ShareTestWidget extends ConsumerWidget {
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('刷新'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: !shareState.isProcessing
|
||||
? () => _simulateShareTest(ref)
|
||||
: null,
|
||||
icon: const Icon(Icons.photo),
|
||||
label: const Text('模拟分享'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -419,4 +427,27 @@ class ShareTestWidget extends ConsumerWidget {
|
||||
final shareNotifier = ref.read(shareProvider.notifier);
|
||||
shareNotifier.clearError();
|
||||
}
|
||||
|
||||
/// 模拟分享测试 - 创建模拟的分享数据来测试保存对话框
|
||||
void _simulateShareTest(WidgetRef ref) {
|
||||
Logger.info('用户点击模拟分享测试');
|
||||
|
||||
// 创建模拟的分享文件
|
||||
final mockFiles = [
|
||||
SharedMediaFile(
|
||||
path: 'test://image1.jpg',
|
||||
type: SharedMediaType.image,
|
||||
),
|
||||
SharedMediaFile(
|
||||
path: 'test://image2.png',
|
||||
type: SharedMediaType.image,
|
||||
),
|
||||
];
|
||||
|
||||
// 模拟接收到分享文件
|
||||
final shareNotifier = ref.read(shareProvider.notifier);
|
||||
shareNotifier.handleSharedFiles(mockFiles);
|
||||
|
||||
Logger.info('模拟分享测试完成,创建了${mockFiles.length}个测试文件');
|
||||
}
|
||||
}
|
||||
776
lib/presentation/widgets/tag_selector.dart
Normal file
776
lib/presentation/widgets/tag_selector.dart
Normal file
@ -0,0 +1,776 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/tag_provider.dart';
|
||||
import 'custom_button.dart';
|
||||
|
||||
/// 标签选择器组件 - 用于为图片添加和管理标签
|
||||
/// 支持新建标签、搜索标签、最近使用排序等功能
|
||||
class TagSelector extends ConsumerStatefulWidget {
|
||||
/// 当前选中的标签列表
|
||||
final List<String> selectedTags;
|
||||
|
||||
/// 标签选择变更回调
|
||||
final Function(List<String> tags) onTagsChanged;
|
||||
|
||||
/// 是否显示创建按钮
|
||||
final bool showCreateButton;
|
||||
|
||||
/// 最大标签数量
|
||||
final int maxTags;
|
||||
|
||||
/// 占位提示文本
|
||||
final String hintText;
|
||||
|
||||
const TagSelector({
|
||||
Key? key,
|
||||
required this.selectedTags,
|
||||
required this.onTagsChanged,
|
||||
this.showCreateButton = true,
|
||||
this.maxTags = 10,
|
||||
this.hintText = '添加标签...',
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
ConsumerState<TagSelector> createState() => _TagSelectorState();
|
||||
}
|
||||
|
||||
class _TagSelectorState extends ConsumerState<TagSelector> {
|
||||
/// 搜索控制器
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
/// 输入控制器
|
||||
final TextEditingController _inputController = TextEditingController();
|
||||
|
||||
/// 搜索关键词
|
||||
String searchKeyword = '';
|
||||
|
||||
/// 是否显示建议列表
|
||||
bool showSuggestions = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 加载标签列表
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(tagProvider.notifier).loadTags();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_inputController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标签输入区域
|
||||
_buildTagInputArea(colorScheme),
|
||||
|
||||
// 已选标签显示
|
||||
if (widget.selectedTags.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: _buildSelectedTags(colorScheme),
|
||||
),
|
||||
|
||||
// 标签建议列表
|
||||
if (showSuggestions)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: _buildTagSuggestions(colorScheme),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建标签输入区域
|
||||
Widget _buildTagInputArea(ColorScheme colorScheme) {
|
||||
return Row(
|
||||
children: [
|
||||
// 输入框
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _inputController,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hintText,
|
||||
prefixIcon: const Icon(Icons.tag),
|
||||
suffixIcon: _inputController.text.isNotEmpty
|
||||
? IconButton(
|
||||
onPressed: () {
|
||||
_inputController.clear();
|
||||
setState(() {
|
||||
showSuggestions = false;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.clear),
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
searchKeyword = value;
|
||||
showSuggestions = value.isNotEmpty;
|
||||
});
|
||||
},
|
||||
onSubmitted: (value) {
|
||||
_addNewTag(value.trim());
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// 添加按钮
|
||||
CustomButton(
|
||||
text: '添加',
|
||||
onPressed: _inputController.text.trim().isNotEmpty
|
||||
? () => _addNewTag(_inputController.text.trim())
|
||||
: null,
|
||||
buttonType: ButtonType.outlined,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建已选标签
|
||||
Widget _buildSelectedTags(ColorScheme colorScheme) {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: widget.selectedTags.map((tag) {
|
||||
return Chip(
|
||||
label: Text(
|
||||
tag,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimary,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
backgroundColor: colorScheme.primary,
|
||||
deleteIcon: Icon(
|
||||
Icons.close,
|
||||
color: colorScheme.onPrimary,
|
||||
size: 16,
|
||||
),
|
||||
onDeleted: () => _removeTag(tag),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建标签建议列表
|
||||
Widget _buildTagSuggestions(ColorScheme colorScheme) {
|
||||
final tagState = ref.watch(tagProvider);
|
||||
|
||||
if (tagState.isLoading) {
|
||||
return const SizedBox(
|
||||
height: 100,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final tags = tagState.tags;
|
||||
|
||||
if (tags.isEmpty) {
|
||||
return _buildEmptySuggestions(colorScheme);
|
||||
}
|
||||
|
||||
// 过滤标签(排除已选标签)
|
||||
final availableTags = tags.where((tag) {
|
||||
return !widget.selectedTags.contains(tag.name) &&
|
||||
(searchKeyword.isEmpty ||
|
||||
tag.name.toLowerCase().contains(searchKeyword.toLowerCase()));
|
||||
}).toList();
|
||||
|
||||
if (availableTags.isEmpty) {
|
||||
return _buildNoSuggestions(colorScheme);
|
||||
}
|
||||
|
||||
// 按使用次数排序(最近使用的在前)
|
||||
availableTags.sort((a, b) => b.usageCount.compareTo(a.usageCount));
|
||||
|
||||
return Container(
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: availableTags.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 4),
|
||||
itemBuilder: (context, index) {
|
||||
final tag = availableTags[index];
|
||||
return _buildSuggestionItem(tag, colorScheme);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建空建议状态
|
||||
Widget _buildEmptySuggestions(ColorScheme colorScheme) {
|
||||
return Container(
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.tag_outlined,
|
||||
size: 32,
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'还没有标签',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建无建议状态
|
||||
Widget _buildNoSuggestions(ColorScheme colorScheme) {
|
||||
return Container(
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'没有找到匹配的标签',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
if (widget.showCreateButton && _inputController.text.trim().isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: CustomButton(
|
||||
text: '创建',
|
||||
onPressed: () => _createNewTag(_inputController.text.trim()),
|
||||
buttonType: ButtonType.text,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建建议项
|
||||
Widget _buildSuggestionItem(dynamic tag, ColorScheme colorScheme) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
onTap: () => _addExistingTag(tag.name),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
// 标签图标
|
||||
Icon(
|
||||
_getTagIcon(tag.icon),
|
||||
color: _getTagColor(tag.color),
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 标签名称
|
||||
Expanded(
|
||||
child: Text(
|
||||
tag.name,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
|
||||
// 使用次数
|
||||
if (tag.usageCount > 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${tag.usageCount}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 添加新标签
|
||||
void _addNewTag(String tagName) {
|
||||
if (tagName.isEmpty) return;
|
||||
|
||||
final newTags = List<String>.from(widget.selectedTags);
|
||||
|
||||
if (!newTags.contains(tagName)) {
|
||||
if (newTags.length >= widget.maxTags) {
|
||||
_showErrorSnackBar('最多只能添加${widget.maxTags}个标签');
|
||||
return;
|
||||
}
|
||||
|
||||
newTags.add(tagName);
|
||||
widget.onTagsChanged(newTags);
|
||||
|
||||
// 更新标签使用次数
|
||||
ref.read(tagProvider.notifier).incrementTagUsage(tagName);
|
||||
}
|
||||
|
||||
_inputController.clear();
|
||||
setState(() {
|
||||
searchKeyword = '';
|
||||
showSuggestions = false;
|
||||
});
|
||||
}
|
||||
|
||||
/// 添加已存在的标签
|
||||
void _addExistingTag(String tagName) {
|
||||
_addNewTag(tagName);
|
||||
}
|
||||
|
||||
/// 创建新标签
|
||||
void _createNewTag(String tagName) {
|
||||
if (tagName.isEmpty) return;
|
||||
|
||||
// 显示创建标签对话框
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => CreateTagDialog(
|
||||
tagName: tagName,
|
||||
onTagCreated: (tagId) {
|
||||
Navigator.of(context).pop();
|
||||
_addNewTag(tagName);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 移除标签
|
||||
void _removeTag(String tagName) {
|
||||
final newTags = List<String>.from(widget.selectedTags)..remove(tagName);
|
||||
widget.onTagsChanged(newTags);
|
||||
}
|
||||
|
||||
/// 显示错误提示
|
||||
void _showErrorSnackBar(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取标签图标
|
||||
IconData _getTagIcon(String? iconCode) {
|
||||
// 默认标签图标
|
||||
if (iconCode == null || iconCode.isEmpty) {
|
||||
return Icons.local_offer;
|
||||
}
|
||||
|
||||
// 尝试解析Material Icons名称
|
||||
try {
|
||||
// 这里需要根据实际的图标存储方式来实现
|
||||
// 暂时返回默认图标
|
||||
return Icons.local_offer;
|
||||
} catch (e) {
|
||||
return Icons.local_offer;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取标签颜色
|
||||
Color _getTagColor(String? colorCode) {
|
||||
// 默认标签颜色
|
||||
if (colorCode == null || colorCode.isEmpty) {
|
||||
return Colors.blue;
|
||||
}
|
||||
|
||||
try {
|
||||
// 尝试解析十六进制颜色
|
||||
return Color(int.parse(colorCode.substring(1), radix: 16) + 0xFF000000);
|
||||
} catch (e) {
|
||||
return Colors.blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建标签对话框
|
||||
class CreateTagDialog extends ConsumerStatefulWidget {
|
||||
/// 标签名称
|
||||
final String tagName;
|
||||
|
||||
/// 标签创建成功回调
|
||||
final Function(String tagId) onTagCreated;
|
||||
|
||||
const CreateTagDialog({
|
||||
Key? key,
|
||||
required this.tagName,
|
||||
required this.onTagCreated,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
ConsumerState<CreateTagDialog> createState() => _CreateTagDialogState();
|
||||
}
|
||||
|
||||
class _CreateTagDialogState extends ConsumerState<CreateTagDialog> {
|
||||
/// 标签名称控制器
|
||||
late TextEditingController _nameController;
|
||||
|
||||
/// 选中的图标
|
||||
String selectedIcon = 'local_offer';
|
||||
|
||||
/// 选中的颜色
|
||||
String selectedColor = '#2196F3'; // 默认蓝色
|
||||
|
||||
/// 表单键
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
|
||||
/// 是否正在创建
|
||||
bool isCreating = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameController = TextEditingController(text: widget.tagName);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
width: 400,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Text(
|
||||
'新建标签',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 标签名称输入
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '标签名称',
|
||||
hintText: '输入标签名称',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.local_offer),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '请输入标签名称';
|
||||
}
|
||||
if (value.trim().length > 10) {
|
||||
return '标签名称不能超过10个字符';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 图标选择
|
||||
Text(
|
||||
'选择图标',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildIconSelector(colorScheme),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 颜色选择
|
||||
Text(
|
||||
'选择颜色',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildColorSelector(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 操作按钮
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
// 取消按钮
|
||||
CustomButton(
|
||||
text: '取消',
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
buttonType: ButtonType.text,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// 创建按钮
|
||||
CustomButton(
|
||||
text: '创建',
|
||||
onPressed: isCreating ? null : _createTag,
|
||||
buttonType: ButtonType.primary,
|
||||
isLoading: isCreating,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建图标选择器
|
||||
Widget _buildIconSelector(ColorScheme colorScheme) {
|
||||
final icons = [
|
||||
'local_offer',
|
||||
'label',
|
||||
'bookmark',
|
||||
'favorite',
|
||||
'star',
|
||||
'tag',
|
||||
'category',
|
||||
'style',
|
||||
'interests',
|
||||
'lightbulb',
|
||||
];
|
||||
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: icons.map((icon) {
|
||||
final isSelected = selectedIcon == icon;
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
selectedIcon = icon;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? colorScheme.primary.withOpacity(0.2)
|
||||
: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.outline.withOpacity(0.2),
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
_getIconData(icon),
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurface,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建颜色选择器
|
||||
Widget _buildColorSelector() {
|
||||
final colors = [
|
||||
'#F44336', // 红色
|
||||
'#E91E63', // 粉色
|
||||
'#9C27B0', // 紫色
|
||||
'#673AB7', // 深紫
|
||||
'#3F51B5', // 靛蓝
|
||||
'#2196F3', // 蓝色
|
||||
'#03A9F4', // 浅蓝
|
||||
'#00BCD4', // 青色
|
||||
'#009688', // 蓝绿
|
||||
'#4CAF50', // 绿色
|
||||
'#8BC34A', // 浅绿
|
||||
'#CDDC39', // 黄绿
|
||||
'#FFEB3B', // 黄色
|
||||
'#FFC107', // 琥珀
|
||||
'#FF9800', // 橙色
|
||||
'#FF5722', // 深橙
|
||||
];
|
||||
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: colors.map((color) {
|
||||
final isSelected = selectedColor == color;
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
selectedColor = color;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(int.parse(color.substring(1), radix: 16) + 0xFF000000),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: isSelected ? Colors.black : Colors.white,
|
||||
width: isSelected ? 3 : 2,
|
||||
),
|
||||
),
|
||||
child: isSelected
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建标签
|
||||
Future<void> _createTag() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
isCreating = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final tagId = await ref.read(tagProvider.notifier).createTag(
|
||||
name: _nameController.text.trim(),
|
||||
icon: selectedIcon,
|
||||
color: selectedColor,
|
||||
);
|
||||
|
||||
if (tagId != null) {
|
||||
widget.onTagCreated(tagId);
|
||||
} else {
|
||||
_showErrorSnackBar('创建标签失败');
|
||||
}
|
||||
} catch (e) {
|
||||
_showErrorSnackBar('创建标签失败:${e.toString()}');
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isCreating = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示错误提示
|
||||
void _showErrorSnackBar(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取图标数据
|
||||
IconData _getIconData(String iconName) {
|
||||
switch (iconName) {
|
||||
case 'local_offer':
|
||||
return Icons.local_offer;
|
||||
case 'label':
|
||||
return Icons.label;
|
||||
case 'bookmark':
|
||||
return Icons.bookmark;
|
||||
case 'favorite':
|
||||
return Icons.favorite;
|
||||
case 'star':
|
||||
return Icons.star;
|
||||
case 'tag':
|
||||
return Icons.tag;
|
||||
case 'category':
|
||||
return Icons.category;
|
||||
case 'style':
|
||||
return Icons.style;
|
||||
case 'interests':
|
||||
return Icons.interests;
|
||||
case 'lightbulb':
|
||||
return Icons.lightbulb;
|
||||
default:
|
||||
return Icons.local_offer;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user