2.2
This commit is contained in:
parent
8f284075b6
commit
7114c367b6
@ -2,5 +2,6 @@
|
|||||||
"language_preferences": {
|
"language_preferences": {
|
||||||
"documentation": "zh-CN",
|
"documentation": "zh-CN",
|
||||||
"code_comments": "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 build:*)",
|
||||||
"Bash(flutter clean:*)",
|
"Bash(flutter clean:*)",
|
||||||
"Bash(dart analyze:*)",
|
"Bash(dart analyze:*)",
|
||||||
"Bash(dart test:*)"
|
"Bash(dart test:*)",
|
||||||
|
"Bash(grep:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
@ -18,6 +18,12 @@ class FolderDao {
|
|||||||
return hiveFolder.id;
|
return hiveFolder.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 添加Hive文件夹 - 直接插入HiveImageFolder对象
|
||||||
|
/// [hiveFolder] 要创建的Hive文件夹对象
|
||||||
|
Future<void> insertHiveFolder(HiveImageFolder hiveFolder) async {
|
||||||
|
await _foldersBox.put(hiveFolder.id, hiveFolder);
|
||||||
|
}
|
||||||
|
|
||||||
/// 根据ID获取文件夹 - 通过唯一标识符查找文件夹
|
/// 根据ID获取文件夹 - 通过唯一标识符查找文件夹
|
||||||
/// [id] 文件夹的唯一标识符
|
/// [id] 文件夹的唯一标识符
|
||||||
/// 返回找到的文件夹实体,如果不存在则返回null
|
/// 返回找到的文件夹实体,如果不存在则返回null
|
||||||
@ -26,6 +32,13 @@ class FolderDao {
|
|||||||
return hiveFolder?.toEntity();
|
return hiveFolder?.toEntity();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 根据ID获取Hive文件夹 - 直接返回HiveImageFolder对象
|
||||||
|
/// [id] 文件夹的唯一标识符
|
||||||
|
/// 返回找到的Hive文件夹对象,如果不存在则返回null
|
||||||
|
Future<HiveImageFolder?> getHiveFolderById(String id) async {
|
||||||
|
return _foldersBox.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取所有文件夹 - 按最近使用时间倒序排列
|
/// 获取所有文件夹 - 按最近使用时间倒序排列
|
||||||
/// 返回所有文件夹实体列表,最近使用的文件夹在前
|
/// 返回所有文件夹实体列表,最近使用的文件夹在前
|
||||||
Future<List<ImageFolder>> getAllFolders() async {
|
Future<List<ImageFolder>> getAllFolders() async {
|
||||||
@ -49,6 +62,21 @@ class FolderDao {
|
|||||||
return allFolders.sublist(0, limit);
|
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] 包含更新数据的文件夹实体
|
/// [folder] 包含更新数据的文件夹实体
|
||||||
/// 返回更新后的文件夹实体
|
/// 返回更新后的文件夹实体
|
||||||
@ -67,6 +95,12 @@ class FolderDao {
|
|||||||
return hiveFolder.toEntity();
|
return hiveFolder.toEntity();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 更新Hive文件夹 - 直接更新HiveImageFolder对象
|
||||||
|
/// [hiveFolder] 要更新的Hive文件夹对象
|
||||||
|
Future<void> updateHiveFolder(HiveImageFolder hiveFolder) async {
|
||||||
|
await _foldersBox.put(hiveFolder.id, hiveFolder);
|
||||||
|
}
|
||||||
|
|
||||||
/// 更新文件夹使用时间 - 记录文件夹的最近访问时间
|
/// 更新文件夹使用时间 - 记录文件夹的最近访问时间
|
||||||
/// [folderId] 文件夹ID
|
/// [folderId] 文件夹ID
|
||||||
/// 返回更新后的文件夹实体
|
/// 返回更新后的文件夹实体
|
||||||
@ -174,6 +208,18 @@ class FolderDao {
|
|||||||
return hiveFolders.map((hiveFolder) => hiveFolder.toEntity()).toList();
|
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 {
|
Future<void> close() async {
|
||||||
|
|||||||
@ -18,6 +18,12 @@ class TagDao {
|
|||||||
return hiveTag.id;
|
return hiveTag.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 添加Hive标签 - 直接插入HiveImageTag对象
|
||||||
|
/// [hiveTag] 要创建的Hive标签对象
|
||||||
|
Future<void> insertHiveTag(HiveImageTag hiveTag) async {
|
||||||
|
await _tagsBox.put(hiveTag.id, hiveTag);
|
||||||
|
}
|
||||||
|
|
||||||
/// 批量添加标签 - 一次性创建多个标签
|
/// 批量添加标签 - 一次性创建多个标签
|
||||||
/// [tags] 要创建的标签实体列表
|
/// [tags] 要创建的标签实体列表
|
||||||
/// 返回创建成功的标签ID列表
|
/// 返回创建成功的标签ID列表
|
||||||
@ -40,6 +46,13 @@ class TagDao {
|
|||||||
return hiveTag?.toEntity();
|
return hiveTag?.toEntity();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 根据ID获取Hive标签 - 直接返回HiveImageTag对象
|
||||||
|
/// [id] 标签的唯一标识符
|
||||||
|
/// 返回找到的Hive标签对象,如果不存在则返回null
|
||||||
|
Future<HiveImageTag?> getHiveTagById(String id) async {
|
||||||
|
return _tagsBox.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
/// 根据名称获取标签 - 通过标签名称精确查找
|
/// 根据名称获取标签 - 通过标签名称精确查找
|
||||||
/// [name] 标签名称
|
/// [name] 标签名称
|
||||||
/// 返回找到的标签实体,如果不存在则返回null
|
/// 返回找到的标签实体,如果不存在则返回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 {
|
Future<List<ImageTag>> getAllTags() async {
|
||||||
@ -219,4 +247,83 @@ class TagDao {
|
|||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
await _tagsBox.close();
|
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,
|
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 '../core/utils/logger.dart';
|
||||||
import 'providers/share_provider.dart';
|
import 'providers/share_provider.dart';
|
||||||
import 'widgets/share_test_widget.dart';
|
import 'widgets/share_test_widget.dart';
|
||||||
|
import 'widgets/save_dialog.dart';
|
||||||
|
|
||||||
/// 主应用组件
|
/// 主应用组件
|
||||||
/// 负责配置MaterialApp和全局设置
|
/// 负责配置MaterialApp和全局设置
|
||||||
@ -110,8 +111,14 @@ class _AppHomePageState extends ConsumerState<AppHomePage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final shareState = ref.watch(shareProvider);
|
||||||
|
|
||||||
Logger.debug('构建AppHomePage');
|
Logger.debug('构建AppHomePage, 分享状态: showShareUI=${shareState.showShareUI}, 文件数量=${shareState.pendingFiles.length}');
|
||||||
|
|
||||||
|
// 如果有错误,打印错误信息
|
||||||
|
if (shareState.error != null) {
|
||||||
|
Logger.error('分享状态错误: ${shareState.error}');
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@ -119,10 +126,13 @@ class _AppHomePageState extends ConsumerState<AppHomePage> {
|
|||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
),
|
),
|
||||||
body: Center(
|
body: Stack(
|
||||||
child: Column(
|
children: [
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
// 主页面内容
|
||||||
children: [
|
Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
// 应用Logo(占位符)
|
// 应用Logo(占位符)
|
||||||
Icon(
|
Icon(
|
||||||
Icons.photo_library,
|
Icons.photo_library,
|
||||||
@ -182,7 +192,19 @@ class _AppHomePageState extends ConsumerState<AppHomePage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 分享保存对话框
|
||||||
|
if (shareState.showShareUI && shareState.pendingFiles.isNotEmpty)
|
||||||
|
SaveDialog(
|
||||||
|
sharedFiles: shareState.pendingFiles,
|
||||||
|
onClose: () {
|
||||||
|
// 关闭分享界面并清除待处理文件
|
||||||
|
ref.read(shareProvider.notifier).clearPendingFiles();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// 底部导航栏(占位符)
|
// 底部导航栏(占位符)
|
||||||
|
|||||||
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();
|
_checkPendingShares();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 清除待处理文件 - 清空当前待处理的分享文件
|
||||||
|
void clearPendingFiles() {
|
||||||
|
Logger.info('清除待处理分享文件');
|
||||||
|
state = state.copyWith(
|
||||||
|
pendingFiles: [],
|
||||||
|
showShareUI: false,
|
||||||
|
isProcessing: false,
|
||||||
|
error: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 同时清除仓库中的待处理文件
|
||||||
|
_shareRepository.clearCurrentShare();
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取分享文件数量 - 获取当前待处理的分享文件数量
|
/// 获取分享文件数量 - 获取当前待处理的分享文件数量
|
||||||
int getShareFileCount() {
|
int getShareFileCount() {
|
||||||
return _shareRepository.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
|
@override
|
||||||
Future<void> dispose() async {
|
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),
|
icon: const Icon(Icons.refresh),
|
||||||
label: const Text('刷新'),
|
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);
|
final shareNotifier = ref.read(shareProvider.notifier);
|
||||||
shareNotifier.clearError();
|
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