import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import '../models/agent_module.dart'; /// AI 页 / 广场页共享的模块编排状态。 /// /// 只保存"AI 页启用了哪些模块、按什么顺序",广场页展示的是全集, /// 通过 [isEnabled] 反查某个模块是否已添加。用户的编排结果持久化在 /// GetStorage,卸载重装才会回到默认值。 class AgentModuleService extends GetxService { static AgentModuleService get to => Get.find(); static const String _storageKey = 'ai_tab_module_ids'; /// 已经应用过默认编排的设备品类 id,保证「第一次连接」只铺一次 static const String _appliedCategoriesKey = 'ai_tab_device_defaults_applied'; final GetStorage _storage = GetStorage(); /// AI 页当前展示的模块 id,顺序即展示顺序 final RxList enabledIds = [].obs; @override void onInit() { super.onInit(); _load(); } void _load() { final dynamic raw = _storage.read(_storageKey); if (raw is List && raw.isNotEmpty) { // 过滤掉注册表里已不存在的 id(版本升级删过模块的情况) final list = raw .map((e) => e.toString()) .where((id) => AgentModules.byId(id) != null) .toList(); enabledIds.assignAll(list); _migrateEmaiAssistant(); _migrateDefaultLayout(); _migrateDefaultOrder(); } else { enabledIds.assignAll(AgentModules.defaultEnabled); } } /// 一次性迁移:默认排序调整(语音记录助手→现场录音→通话录音→…)。 /// /// 只在用户**没自己排过**时才动——即当前启用集合与默认集合完全一致, /// 只是顺序不同。用户手动拖过或增删过的编排一律不碰。 void _migrateDefaultOrder() { const migratedKey = 'ai_tab_order_v6_migrated'; if (_storage.read(migratedKey) == true) return; _storage.write(migratedKey, true); final current = enabledIds.toSet(); final wanted = AgentModules.defaultEnabled .where((id) => AgentModules.byId(id) != null) .toList(growable: false); if (current.length != wanted.length) return; if (!current.containsAll(wanted)) return; if (_sameOrder(enabledIds, wanted)) return; enabledIds.assignAll(wanted); _persist(); } /// 一次性迁移:默认编排从 3 个扩到 10 个(补上通话录音/通话翻译/翻译类)。 /// 老用户存的还是旧的三件套,不补的话新功能他们看不到;只补一次, /// 之后用户自己删掉就不再塞回来。 void _migrateDefaultLayout() { const migratedKey = 'ai_tab_layout_v2_migrated'; if (_storage.read(migratedKey) == true) return; _storage.write(migratedKey, true); var changed = false; for (final id in AgentModules.defaultEnabled) { if (AgentModules.byId(id) == null) continue; if (enabledIds.contains(id)) continue; enabledIds.add(id); changed = true; } if (changed) _persist(); } /// 一次性迁移:AI 页底部那颗 AI 球下线了,入口改成「EMAI助手」模块。 /// 老用户的编排里没有这个 id,不补的话他们就彻底进不去 AI 对话了。 /// 只补一次,用户之后自己删掉就不再塞回来。 void _migrateEmaiAssistant() { const migratedKey = 'ai_tab_emai_migrated'; if (_storage.read(migratedKey) == true) return; _storage.write(migratedKey, true); if (enabledIds.contains(AgentModules.emaiAssistant)) return; // 放在「语音记录助手」后面,与默认编排的位置一致 final at = enabledIds.indexOf(AgentModules.meeting); enabledIds.insert(at >= 0 ? at + 1 : 0, AgentModules.emaiAssistant); _persist(); } void _persist() => _storage.write(_storageKey, enabledIds.toList()); /// AI 页要渲染的模块(已按用户顺序展开) List get enabledModules => enabledIds .map(AgentModules.byId) .whereType() .toList(growable: false); /// 广场页展示的全集 List get allModules => AgentModules.all; /// 广场搜索:标题/副标题/id 命中即可,支持模糊匹配。 /// /// 三档依次放宽: /// 1) 子串命中("翻译" → 同声翻译、面对面翻译…) /// 2) 按空格拆词后全部命中("英文 学习" → 英文学习) /// 3) 子序列命中("英学" → 英文学习;"tran" → translation 类 id) /// 空关键词返回全集。 static List search(List source, String rawQuery) { final q = rawQuery.trim().toLowerCase(); if (q.isEmpty) return source; bool matches(AgentModule m) { final haystack = '${m.title} ${m.subtitle ?? ''} ${m.id}'.toLowerCase(); if (haystack.contains(q)) return true; final terms = q.split(RegExp(r'\s+')).where((t) => t.isNotEmpty); if (terms.isNotEmpty && terms.every(haystack.contains)) return true; return _isSubsequence(q.replaceAll(RegExp(r'\s+'), ''), haystack); } return source.where(matches).toList(growable: false); } /// query 的字符能否按顺序(可跳过)在 text 里找到 static bool _isSubsequence(String query, String text) { if (query.isEmpty) return true; var i = 0; for (var j = 0; j < text.length && i < query.length; j++) { if (text[j] == query[i]) i++; } return i == query.length; } /// 某个设备品类**第一次连接成功**时铺开它的默认功能。 /// /// - 只生效一次:品类 id 记在 [_appliedCategoriesKey] 里,之后再连同类设备 /// 不会覆盖用户自己的增删和排序; /// - 采用「补齐」而不是「替换」:用户已有的模块和顺序原样保留, /// 缺的按品类默认顺序追加到后面。 /// /// 三条蓝牙链路(蓝讯支架 / 恒玄耳机 / 杰理)连上后各自调用它, /// 传自己的 [DeviceCategories] 品类 id。 bool applyDeviceDefaults(String? categoryId) { final defaults = AgentModules.defaultsForCategory(categoryId); if (defaults.isEmpty) return false; final applied = (_storage.read(_appliedCategoriesKey) ?? const []) .map((e) => e.toString()) .toList(); if (applied.contains(categoryId)) return false; // 按该设备的能力**整套铺开**,而不是往用户现有编排上追加: // 支架/录音卡片只有录音能力,AI 页就该只剩三个;耳机能跑通话和翻译, // 就铺全量。追加式做不到「只显示」这个要求。 final next = defaults .where((id) => AgentModules.byId(id) != null) .toList(growable: false); final changed = !_sameOrder(enabledIds, next); if (changed) enabledIds.assignAll(next); applied.add(categoryId!); _storage.write(_appliedCategoriesKey, applied); if (changed) _persist(); return changed; } static bool _sameOrder(List a, List b) { if (a.length != b.length) return false; for (var i = 0; i < a.length; i++) { if (a[i] != b[i]) return false; } return true; } /// 该品类的默认编排是否已经铺过(调试/重置用) bool hasAppliedDefaults(String categoryId) => (_storage.read(_appliedCategoriesKey) ?? const []) .map((e) => e.toString()) .contains(categoryId); bool isEnabled(String id) => enabledIds.contains(id); /// 从广场添加到 AI 页:新模块一律追加到最后,符合"新添加的放在最下面" void add(String id) { if (AgentModules.byId(id) == null || enabledIds.contains(id)) return; enabledIds.add(id); _persist(); } /// 从 AI 页移除,模块回到广场(广场是全集,移除后 isEnabled 变 false 即可) void remove(String id) { if (enabledIds.remove(id)) _persist(); } void toggle(String id) => isEnabled(id) ? remove(id) : add(id); /// 拖拽排序。newIndex 采用 ReorderableListView 的语义: /// 往下拖时它给的是"插入前"的下标,需要先减一。 void reorder(int oldIndex, int newIndex) { if (oldIndex < 0 || oldIndex >= enabledIds.length) return; var target = newIndex; if (target > oldIndex) target -= 1; if (target < 0) target = 0; if (target >= enabledIds.length) target = enabledIds.length - 1; if (target == oldIndex) return; final id = enabledIds.removeAt(oldIndex); enabledIds.insert(target, id); _persist(); } /// 把 from 位置的模块直接挪到 to 位置(拖到哪个槽就占哪个槽)。 /// /// 与 [reorder] 的区别:这里的 to 是「目标槽位的真实下标」,不需要做 /// ReorderableListView 那种减一修正。AI 页混排布局(前三个是网格、 /// 后面是横条)用的是这个。 void move(int from, int to) { if (from == to) return; if (from < 0 || from >= enabledIds.length) return; if (to < 0 || to >= enabledIds.length) return; final id = enabledIds.removeAt(from); enabledIds.insert(to, id); _persist(); } /// 恢复默认编排(连同「已铺过哪些品类」的记录一起清掉, /// 这样下次连上设备会重新按该设备的默认功能铺开) void resetToDefault() { enabledIds.assignAll(AgentModules.defaultEnabled); _storage.remove(_appliedCategoriesKey); _persist(); } }