629 lines
18 KiB
Dart
629 lines
18 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — 工具中心状态管理
|
||
/// 创建时间: 2026-04-26
|
||
/// 更新时间: 2026-05-30
|
||
/// 作用: 工具中心面板状态 — 工具列表/搜索/面板开关/持久化/网络监测
|
||
/// 上次更新: 修复隐私问题 — 移除connectivity_plus直接调用,改用全局ConnectivityService避免触发附近设备权限
|
||
/// ============================================================
|
||
|
||
import 'dart:async';
|
||
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import 'package:xianyan/core/storage/kv_storage.dart';
|
||
import 'package:xianyan/core/services/network/connectivity_service.dart';
|
||
import 'package:xianyan/core/utils/logger.dart';
|
||
import '../models/search_type.dart';
|
||
import '../models/tool_item.dart';
|
||
import '../services/tool_api_service.dart';
|
||
|
||
/// 工具中心状态
|
||
class ToolCenterState {
|
||
const ToolCenterState({
|
||
this.tools = defaultTools,
|
||
this.isPanelOpen = false,
|
||
this.searchQuery = '',
|
||
this.searchHistory = const [],
|
||
this.isLoading = true,
|
||
this.healthStatus = const {},
|
||
this.isOnline = true,
|
||
this.needsPermissionCheck = false,
|
||
});
|
||
|
||
/// 全部工具列表
|
||
final List<ToolItem> tools;
|
||
|
||
/// 面板是否展开
|
||
final bool isPanelOpen;
|
||
|
||
/// 搜索关键词
|
||
final String searchQuery;
|
||
|
||
/// 搜索历史
|
||
final List<String> searchHistory;
|
||
|
||
/// 是否正在加载持久化数据
|
||
final bool isLoading;
|
||
|
||
/// 工具健康状态 {toolId: isHealthy}
|
||
final Map<String, bool> healthStatus;
|
||
|
||
/// 是否在线
|
||
final bool isOnline;
|
||
|
||
/// 是否需要权限检查(Android 12+ 附近设备权限未授予时为 true)
|
||
final bool needsPermissionCheck;
|
||
|
||
/// 最近使用(按 lastUsedAt 降序,最多4个,排除未使用的)
|
||
List<ToolItem> get recentTools {
|
||
final used = tools.where((t) => t.lastUsedAt != null).toList();
|
||
used.sort((a, b) {
|
||
if (a.isPinned != b.isPinned) return a.isPinned ? -1 : 1;
|
||
return b.lastUsedAt!.compareTo(a.lastUsedAt!);
|
||
});
|
||
return used.take(4).toList();
|
||
}
|
||
|
||
/// 推荐工具
|
||
List<ToolItem> get recommendedTools {
|
||
return tools.where((t) => t.isRecommended).toList();
|
||
}
|
||
|
||
/// 收藏的工具
|
||
List<ToolItem> get favoritedTools {
|
||
return tools.where((t) => t.isFavorited).toList();
|
||
}
|
||
|
||
/// 已删除的工具
|
||
List<ToolItem> get deletedTools {
|
||
return tools.where((t) => t.isDeleted).toList();
|
||
}
|
||
|
||
/// 可见工具(未删除)
|
||
List<ToolItem> get visibleTools {
|
||
return tools.where((t) => !t.isDeleted).toList();
|
||
}
|
||
|
||
/// 全部工具(按使用次数降序,排除已删除)
|
||
List<ToolItem> get allToolsSorted {
|
||
final sorted = visibleTools.toList();
|
||
sorted.sort((a, b) {
|
||
if (a.isPinned != b.isPinned) return a.isPinned ? -1 : 1;
|
||
return b.useCount.compareTo(a.useCount);
|
||
});
|
||
return sorted;
|
||
}
|
||
|
||
/// 工具排行榜(按使用次数Top10,排除已删除)
|
||
List<ToolItem> get topTools {
|
||
final sorted = visibleTools.toList();
|
||
sorted.sort((a, b) => b.useCount.compareTo(a.useCount));
|
||
return sorted.take(10).toList();
|
||
}
|
||
|
||
/// 搜索过滤结果
|
||
List<ToolItem> get searchResults {
|
||
if (searchQuery.isEmpty) return [];
|
||
final q = searchQuery.toLowerCase();
|
||
return tools
|
||
.where(
|
||
(t) =>
|
||
t.name.toLowerCase().contains(q) ||
|
||
t.emoji.contains(q) ||
|
||
(t.description?.toLowerCase().contains(q) ?? false) ||
|
||
t.category.label.contains(q) ||
|
||
t.tags.any((tag) => tag.toLowerCase().contains(q)),
|
||
)
|
||
.toList();
|
||
}
|
||
|
||
Map<ToolCategory, List<ToolItem>> get toolsGroupedByCategory {
|
||
final map = <ToolCategory, List<ToolItem>>{};
|
||
for (final tool in allToolsSorted) {
|
||
(map[tool.category] ??= []).add(tool);
|
||
}
|
||
return map;
|
||
}
|
||
|
||
ToolCenterState copyWith({
|
||
List<ToolItem>? tools,
|
||
bool? isPanelOpen,
|
||
String? searchQuery,
|
||
List<String>? searchHistory,
|
||
bool? isLoading,
|
||
Map<String, bool>? healthStatus,
|
||
bool? isOnline,
|
||
bool? needsPermissionCheck,
|
||
}) {
|
||
return ToolCenterState(
|
||
tools: tools ?? this.tools,
|
||
isPanelOpen: isPanelOpen ?? this.isPanelOpen,
|
||
searchQuery: searchQuery ?? this.searchQuery,
|
||
searchHistory: searchHistory ?? this.searchHistory,
|
||
isLoading: isLoading ?? this.isLoading,
|
||
healthStatus: healthStatus ?? this.healthStatus,
|
||
isOnline: isOnline ?? this.isOnline,
|
||
needsPermissionCheck: needsPermissionCheck ?? this.needsPermissionCheck,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 工具中心 Notifier
|
||
class ToolCenterNotifier extends Notifier<ToolCenterState> {
|
||
@override
|
||
ToolCenterState build() {
|
||
ref.onDispose(_onDispose);
|
||
Future.microtask(() {
|
||
_loadPersistedData().catchError((Object e) {
|
||
Log.e('ToolCenterNotifier._loadPersistedData 失败', e);
|
||
});
|
||
_initConnectivity();
|
||
_validateSearchTypes();
|
||
}).catchError((_) {});
|
||
return const ToolCenterState();
|
||
}
|
||
|
||
static const _keyPrefix = 'tool_';
|
||
|
||
StreamSubscription<NetworkType>? _connectivitySub;
|
||
|
||
void _initConnectivity() {
|
||
final isOnline = ConnectivityService.isOnline;
|
||
if (!isOnline) {
|
||
final updatedTools = state.tools.map((t) {
|
||
if (t.toolType == ToolType.online) {
|
||
return t.copyWith(status: ToolStatus.offline);
|
||
}
|
||
return t;
|
||
}).toList();
|
||
state = state.copyWith(isOnline: false, tools: updatedTools);
|
||
}
|
||
Log.i('ToolCenter: 初始网络状态 在线=$isOnline');
|
||
|
||
_connectivitySub = ConnectivityService.onTypeChange.listen((type) {
|
||
final nowOnline = type != NetworkType.none;
|
||
if (nowOnline != state.isOnline) {
|
||
final updatedTools = state.tools.map((t) {
|
||
if (t.toolType == ToolType.online) {
|
||
return t.copyWith(
|
||
status: nowOnline ? ToolStatus.available : ToolStatus.offline,
|
||
);
|
||
}
|
||
return t;
|
||
}).toList();
|
||
state = state.copyWith(isOnline: nowOnline, tools: updatedTools);
|
||
Log.i('ToolCenter: 网络状态变更 在线=$nowOnline type=$type');
|
||
if (nowOnline) {
|
||
checkAllHealth();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
void _onDispose() {
|
||
_connectivitySub?.cancel();
|
||
_connectivitySub = null;
|
||
}
|
||
|
||
/// 切换面板开关
|
||
void togglePanel() {
|
||
state = state.copyWith(isPanelOpen: !state.isPanelOpen);
|
||
}
|
||
|
||
/// 打开面板
|
||
void openPanel() {
|
||
if (!state.isPanelOpen) {
|
||
state = state.copyWith(isPanelOpen: true);
|
||
}
|
||
}
|
||
|
||
/// 关闭面板
|
||
void closePanel() {
|
||
if (state.isPanelOpen) {
|
||
state = state.copyWith(isPanelOpen: false, searchQuery: '');
|
||
}
|
||
}
|
||
|
||
/// 清除权限检查标志(用户已处理权限后调用)
|
||
void clearPermissionCheck() {
|
||
if (state.needsPermissionCheck) {
|
||
state = state.copyWith(needsPermissionCheck: false);
|
||
}
|
||
}
|
||
|
||
/// 记录工具使用
|
||
void recordToolUse(String toolId) {
|
||
final updated = state.tools.map((t) {
|
||
if (t.id == toolId) return t.recordUse();
|
||
return t;
|
||
}).toList();
|
||
state = state.copyWith(tools: updated);
|
||
_persistToolUsage(toolId);
|
||
}
|
||
|
||
/// 切换固定状态
|
||
void togglePin(String toolId) {
|
||
final updated = state.tools.map((t) {
|
||
if (t.id == toolId) return t.copyWith(isPinned: !t.isPinned);
|
||
return t;
|
||
}).toList();
|
||
state = state.copyWith(tools: updated);
|
||
_persistToolPin(toolId, updated.firstWhere((t) => t.id == toolId).isPinned);
|
||
}
|
||
|
||
/// 切换收藏状态
|
||
void toggleFavorite(String toolId) {
|
||
final updated = state.tools.map((t) {
|
||
if (t.id == toolId) return t.copyWith(isFavorited: !t.isFavorited);
|
||
return t;
|
||
}).toList();
|
||
state = state.copyWith(tools: updated);
|
||
_persistToolFavorite(
|
||
toolId,
|
||
updated.firstWhere((t) => t.id == toolId).isFavorited,
|
||
);
|
||
}
|
||
|
||
/// 删除工具(标记为已删除)
|
||
void deleteTool(String toolId) {
|
||
final updated = state.tools.map((t) {
|
||
if (t.id == toolId) return t.copyWith(isDeleted: true);
|
||
return t;
|
||
}).toList();
|
||
state = state.copyWith(tools: updated);
|
||
_persistToolDeleted(toolId, true);
|
||
Log.i('工具已删除: $toolId');
|
||
}
|
||
|
||
/// 恢复已删除的工具
|
||
void restoreTool(String toolId) {
|
||
final updated = state.tools.map((t) {
|
||
if (t.id == toolId) return t.copyWith(isDeleted: false);
|
||
return t;
|
||
}).toList();
|
||
state = state.copyWith(tools: updated);
|
||
_persistToolDeleted(toolId, false);
|
||
Log.i('工具已恢复: $toolId');
|
||
}
|
||
|
||
/// 恢复所有已删除的工具
|
||
void restoreAllTools() {
|
||
final deletedIds = state.deletedTools.map((t) => t.id).toList();
|
||
final updated = state.tools.map((t) {
|
||
if (t.isDeleted) return t.copyWith(isDeleted: false);
|
||
return t;
|
||
}).toList();
|
||
state = state.copyWith(tools: updated);
|
||
for (final id in deletedIds) {
|
||
_persistToolDeleted(id, false);
|
||
}
|
||
Log.i('所有已删除工具已恢复');
|
||
}
|
||
|
||
/// 评分
|
||
void rateTool(String toolId, double rating) {
|
||
final updated = state.tools.map((t) {
|
||
if (t.id == toolId) {
|
||
final newCount = t.ratingCount + 1;
|
||
final newRating = ((t.rating * t.ratingCount) + rating) / newCount;
|
||
return t.copyWith(
|
||
rating: (newRating * 10).round() / 10,
|
||
ratingCount: newCount,
|
||
);
|
||
}
|
||
return t;
|
||
}).toList();
|
||
state = state.copyWith(tools: updated);
|
||
_persistToolRating(toolId, updated.firstWhere((t) => t.id == toolId));
|
||
}
|
||
|
||
/// 健康检测
|
||
Future<void> checkToolHealth(String toolId) async {
|
||
final tool = state.tools.firstWhere((t) => t.id == toolId);
|
||
if (tool.apiPath == null) {
|
||
state = state.copyWith(
|
||
healthStatus: {...state.healthStatus, toolId: true},
|
||
);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
final isHealthy = await ToolApiService.healthCheck(tool.apiPath!);
|
||
final updated = state.tools.map((t) {
|
||
if (t.id == toolId) {
|
||
return t.copyWith(
|
||
status: isHealthy ? ToolStatus.available : ToolStatus.offline,
|
||
);
|
||
}
|
||
return t;
|
||
}).toList();
|
||
state = state.copyWith(
|
||
tools: updated,
|
||
healthStatus: {...state.healthStatus, toolId: isHealthy},
|
||
);
|
||
} catch (e) {
|
||
Log.e('工具健康检测失败: $toolId', e);
|
||
state = state.copyWith(
|
||
healthStatus: {...state.healthStatus, toolId: false},
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 批量健康检测
|
||
Future<void> checkAllHealth() async {
|
||
final onlineTools = state.tools.where((t) => t.apiPath != null).toList();
|
||
for (final tool in onlineTools) {
|
||
await checkToolHealth(tool.id);
|
||
}
|
||
}
|
||
|
||
/// 搜索
|
||
void search(String query) {
|
||
state = state.copyWith(searchQuery: query);
|
||
}
|
||
|
||
/// 提交搜索(加入历史)
|
||
void submitSearch(String query) {
|
||
if (query.trim().isEmpty) return;
|
||
final history = List<String>.from(state.searchHistory);
|
||
history.remove(query);
|
||
history.insert(0, query);
|
||
if (history.length > 5) history.removeRange(5, history.length);
|
||
state = state.copyWith(searchHistory: history, searchQuery: query);
|
||
_persistSearchHistory(history);
|
||
}
|
||
|
||
/// 清空搜索
|
||
void clearSearch() {
|
||
state = state.copyWith(searchQuery: '');
|
||
}
|
||
|
||
/// 清空搜索历史
|
||
void clearSearchHistory() {
|
||
state = state.copyWith(searchHistory: []);
|
||
_removeSearchHistory();
|
||
}
|
||
|
||
/// 换一批推荐
|
||
void refreshRecommendations() {
|
||
final nonRecommended = state.tools
|
||
.where((t) => !t.isRecommended && !t.isNew)
|
||
.toList();
|
||
final currentRecommended = state.tools
|
||
.where((t) => t.isRecommended)
|
||
.toList();
|
||
if (nonRecommended.isEmpty) return;
|
||
|
||
nonRecommended.shuffle();
|
||
final newRecId = nonRecommended.first.id;
|
||
final oldRecId = currentRecommended.isNotEmpty
|
||
? currentRecommended.last.id
|
||
: null;
|
||
|
||
final updated = state.tools.map((t) {
|
||
if (t.id == newRecId) return t.copyWith(isRecommended: true);
|
||
if (t.id == oldRecId) return t.copyWith(isRecommended: false);
|
||
return t;
|
||
}).toList();
|
||
state = state.copyWith(tools: updated);
|
||
}
|
||
|
||
/// 从持久化加载工具使用数据
|
||
Future<void> _loadPersistedData() async {
|
||
try {
|
||
final updated = <ToolItem>[];
|
||
for (final tool in defaultTools) {
|
||
final useCount = KvStorage.getInt('${_keyPrefix}use_${tool.id}') ?? 0;
|
||
final lastUsedMs = KvStorage.getInt('${_keyPrefix}last_${tool.id}');
|
||
final isPinned =
|
||
KvStorage.getBool('${_keyPrefix}pin_${tool.id}') ?? false;
|
||
final isFavorited =
|
||
KvStorage.getBool('${_keyPrefix}fav_${tool.id}') ?? false;
|
||
// 代码中 isDeleted=true 表示强制隐藏(如歌词大全/故事大全/作文大全),
|
||
// 持久化数据不应覆盖强制隐藏状态
|
||
final persistedDeleted =
|
||
KvStorage.getBool('${_keyPrefix}del_${tool.id}');
|
||
final isDeleted = tool.isDeleted || (persistedDeleted ?? false);
|
||
final rating =
|
||
KvStorage.getDouble('${_keyPrefix}rating_${tool.id}') ?? 0.0;
|
||
final ratingCount =
|
||
KvStorage.getInt('${_keyPrefix}rating_count_${tool.id}') ?? 0;
|
||
|
||
updated.add(
|
||
tool.copyWith(
|
||
useCount: useCount,
|
||
lastUsedAt: lastUsedMs != null
|
||
? DateTime.fromMillisecondsSinceEpoch(lastUsedMs)
|
||
: null,
|
||
isPinned: isPinned,
|
||
isFavorited: isFavorited,
|
||
isDeleted: isDeleted,
|
||
rating: rating,
|
||
ratingCount: ratingCount,
|
||
),
|
||
);
|
||
}
|
||
|
||
final history = KvStorage.getSearchHistory();
|
||
final sortOrder = KvStorage.getToolSortOrder();
|
||
|
||
state = state.copyWith(
|
||
tools: sortOrder.isNotEmpty
|
||
? _applySortOrder(updated, sortOrder)
|
||
: updated,
|
||
searchHistory: history,
|
||
isLoading: false,
|
||
);
|
||
Log.i('工具中心持久化数据加载完成 (Hive)');
|
||
} catch (e) {
|
||
Log.e('工具中心持久化数据加载失败', e);
|
||
state = state.copyWith(isLoading: false);
|
||
}
|
||
}
|
||
|
||
/// 持久化使用次数和时间
|
||
Future<void> _persistToolUsage(String toolId) async {
|
||
try {
|
||
final tool = state.tools.firstWhere((t) => t.id == toolId);
|
||
await KvStorage.setInt('${_keyPrefix}use_$toolId', tool.useCount);
|
||
if (tool.lastUsedAt != null) {
|
||
await KvStorage.setInt(
|
||
'${_keyPrefix}last_$toolId',
|
||
tool.lastUsedAt!.millisecondsSinceEpoch,
|
||
);
|
||
}
|
||
} catch (e) {
|
||
Log.e('持久化工具使用数据失败', e);
|
||
}
|
||
}
|
||
|
||
Future<void> _persistToolPin(String toolId, bool isPinned) async {
|
||
try {
|
||
await KvStorage.setBool('${_keyPrefix}pin_$toolId', isPinned);
|
||
} catch (e) {
|
||
Log.e('持久化工具固定状态失败', e);
|
||
}
|
||
}
|
||
|
||
Future<void> _persistToolFavorite(String toolId, bool isFavorited) async {
|
||
try {
|
||
await KvStorage.setBool('${_keyPrefix}fav_$toolId', isFavorited);
|
||
} catch (e) {
|
||
Log.e('持久化工具收藏状态失败', e);
|
||
}
|
||
}
|
||
|
||
Future<void> _persistToolDeleted(String toolId, bool isDeleted) async {
|
||
try {
|
||
await KvStorage.setBool('${_keyPrefix}del_$toolId', isDeleted);
|
||
} catch (e) {
|
||
Log.e('持久化工具删除状态失败', e);
|
||
}
|
||
}
|
||
|
||
Future<void> _persistToolRating(String toolId, ToolItem tool) async {
|
||
try {
|
||
await KvStorage.setDouble('${_keyPrefix}rating_$toolId', tool.rating);
|
||
await KvStorage.setInt(
|
||
'${_keyPrefix}rating_count_$toolId',
|
||
tool.ratingCount,
|
||
);
|
||
} catch (e) {
|
||
Log.e('持久化工具评分失败', e);
|
||
}
|
||
}
|
||
|
||
Future<void> _persistSearchHistory(List<String> history) async {
|
||
try {
|
||
await KvStorage.setSearchHistory(history);
|
||
} catch (e) {
|
||
Log.e('持久化搜索历史失败', e);
|
||
}
|
||
}
|
||
|
||
Future<void> _removeSearchHistory() async {
|
||
try {
|
||
await KvStorage.clearSearchHistory();
|
||
} catch (e) {
|
||
Log.e('删除搜索历史失败', e);
|
||
}
|
||
}
|
||
|
||
/// 校验 SearchType 枚举与后端 API 支持的类型是否一致
|
||
///
|
||
/// 列表来源:
|
||
/// - hanziSearchSupported: Hanzi.php $searchMap 的所有 key
|
||
/// - searchAllSupported: Searchall.php $sourceMap 中前端使用的 key + 'all'
|
||
void _validateSearchTypes() {
|
||
SearchType.validateAll(
|
||
hanziSearchSupported: const [
|
||
'poetry',
|
||
'brainteaser',
|
||
'couplet',
|
||
'wisdom',
|
||
'story',
|
||
'saying',
|
||
'riddle',
|
||
'xiehouyu',
|
||
'zuowen',
|
||
'why',
|
||
'drug',
|
||
'food',
|
||
'herbal',
|
||
'pianfang',
|
||
'tisana',
|
||
'changshi',
|
||
'lyric',
|
||
],
|
||
searchAllSupported: const [
|
||
'cs',
|
||
'zgjm',
|
||
'illness',
|
||
'surname',
|
||
'jieqi',
|
||
'nation',
|
||
'joke',
|
||
'all',
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 保存工具排序偏好
|
||
Future<void> saveToolSortOrder(List<String> toolIds) async {
|
||
try {
|
||
await KvStorage.setToolSortOrder(toolIds);
|
||
final sorted = _applySortOrder(state.tools, toolIds);
|
||
state = state.copyWith(tools: sorted);
|
||
Log.i('工具排序偏好已保存');
|
||
} catch (e) {
|
||
Log.e('保存工具排序偏好失败', e);
|
||
}
|
||
}
|
||
|
||
/// 按用户偏好排序工具列表
|
||
List<ToolItem> _applySortOrder(List<ToolItem> tools, List<String> order) {
|
||
if (order.isEmpty) return tools;
|
||
final toolMap = {for (final t in tools) t.id: t};
|
||
final sorted = <ToolItem>[];
|
||
for (final id in order) {
|
||
final tool = toolMap[id];
|
||
if (tool != null) {
|
||
sorted.add(tool);
|
||
toolMap.remove(id);
|
||
}
|
||
}
|
||
sorted.addAll(toolMap.values);
|
||
return sorted;
|
||
}
|
||
|
||
/// 按工具ID记录搜索历史
|
||
Future<void> recordToolSearch(String toolId, String keyword) async {
|
||
if (keyword.trim().isEmpty) return;
|
||
try {
|
||
await KvStorage.addToolSearchHistory(toolId, keyword);
|
||
} catch (e) {
|
||
Log.e('记录工具搜索历史失败', e);
|
||
}
|
||
}
|
||
|
||
/// 获取指定工具的搜索历史
|
||
List<String> getToolSearchHistory(String toolId) {
|
||
return KvStorage.getToolSearchHistory(toolId);
|
||
}
|
||
|
||
/// 清除指定工具的搜索历史
|
||
Future<void> clearToolSearchHistory(String toolId) async {
|
||
try {
|
||
await KvStorage.clearToolSearchHistory(toolId);
|
||
} catch (e) {
|
||
Log.e('清除工具搜索历史失败', e);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 工具中心 Provider
|
||
final toolCenterProvider =
|
||
NotifierProvider<ToolCenterNotifier, ToolCenterState>(
|
||
ToolCenterNotifier.new,
|
||
);
|