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'; /// 上一次**自动铺**下去的编排清单。用来判断用户有没有自己动过, /// 见 [applyDeviceDefaults]。 static const String _lastAppliedKey = 'ai_tab_device_defaults_last'; 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); // ⚠️ 当前这份编排如果是**设备默认铺下来的**(用户没动过),三个一次性迁移 // 一律跳过。它们是给「用户自己的编排」补新功能用的,而设备编排本来就该 // 只有那几项 —— 尤其 _migrateDefaultLayout 会把 defaultEnabled 里缺的全部 // 追加回来,等于把「连上香薰只显示 EaiCar/现场录音/语音纪要」直接抹掉。 // // 2026-09-14 线上就是这么坏的:香薰编排铺成 3 项 → 重启 App → 迁移追加成 // 11 项 → 而基线 _lastAppliedKey 还停在 3 项 → 之后 applyDeviceDefaults // 一比较发现「对不上」,判成用户动过编排,**从此再也不铺**。表现就是 // 连着香薰却显示全量功能,且怎么重连都恢复不了。 if (_matchesLastApplied()) return; _migrateEmaiAssistant(); _migrateDefaultLayout(); _migrateDefaultOrder(); _rebaseDeviceDefaultsBaseline(); } else { enabledIds.assignAll(AgentModules.defaultEnabled); } } /// 当前编排是否与「上一次自动铺下去的那份」逐项相同 —— 即用户没动过。 bool _matchesLastApplied() { if (!_storage.hasData(_lastAppliedKey)) return false; final last = (_storage.read(_lastAppliedKey) ?? const []) .map((e) => e.toString()) .toList(); return last.isNotEmpty && _sameOrder(enabledIds, last); } /// 一次性纠正:把已经被 [_migrateDefaultLayout] 追加过的设备编排重新认作基线。 /// /// 修上面那条时序 bug 之前装过的机器,storage 里已经是「enabledIds=11 项、 /// 基线=3 项」的坏状态,而迁移带 migratedKey 不会再跑一次,光修 _load 救不回来。 /// /// 判据收得很紧,必须**同时**满足:基线存在、当前编排以基线为前缀、多出来的项 /// 全部来自 defaultEnabled —— 这正是 _migrateDefaultLayout「保持原序、把缺的 /// append 到尾部」留下的形态。 /// /// 纠正的是**基线**不是编排:把基线抬到当前值,界面此刻不变,等下次连上设备时 /// 由 applyDeviceDefaults 正常铺成该设备的编排 —— 那本来就是用户预期的时机。 /// (代价:用户如果**恰好**手动加的也全是 defaultEnabled 里的项、且加在尾部, /// 会被误判成迁移污染,下次连设备时编排被重铺一次。两种误判范围相同, /// 选这个是因为它不会在启动瞬间凭空改变用户看到的界面。) void _rebaseDeviceDefaultsBaseline() { const rebaseKey = 'ai_tab_device_defaults_rebase_v1'; if (_storage.read(rebaseKey) == true) return; _storage.write(rebaseKey, true); if (!_storage.hasData(_lastAppliedKey)) return; final last = (_storage.read(_lastAppliedKey) ?? const []) .map((e) => e.toString()) .toList(); if (!isLayoutMigrationArtifact( enabled: enabledIds, lastApplied: last, defaults: AgentModules.defaultEnabled, )) { return; } _storage.write(_lastAppliedKey, enabledIds.toList()); } /// 当前编排看起来是不是「设备默认编排 + 被一次性迁移追加的全量项」。 /// /// 形态要求([_migrateDefaultLayout] 的行为特征): /// 1. [lastApplied] 非空,且原样是 [enabled] 的**前缀**(顺序都没动); /// 2. [enabled] 确实更长; /// 3. 多出来的项**全部**出自 [defaults] —— 迁移只会补这些。 /// /// 抽成纯函数是为了能脱离 GetStorage 测试,见 test/agent_module_defaults_test.dart。 static bool isLayoutMigrationArtifact({ required List enabled, required List lastApplied, required List defaults, }) { if (lastApplied.isEmpty || enabled.length <= lastApplied.length) { return false; } for (var i = 0; i < lastApplied.length; i++) { if (enabled[i] != lastApplied[i]) return false; } for (final id in enabled.sublist(lastApplied.length)) { if (!defaults.contains(id)) return false; } return true; } /// 一次性迁移:默认排序调整(语音记录助手→现场录音→通话录音→…)。 /// /// 只在用户**没自己排过**时才动——即当前启用集合与默认集合完全一致, /// 只是顺序不同。用户手动拖过或增删过的编排一律不碰。 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); /// 对话助手类模块(2026-09-24):统一由底部中间的「AI」按钮进入,**首页网格不再展示**。 /// 以后新增设备专属助手(像 EaiCar 这种)加到这里,AI 按钮会按设备自动接上。 /// /// ⚠️ 只是不在首页**显示**,编排里的 id 原样保留:设备默认编排、持久化、广场都不受影响, /// 设备按键唤醒找 agent(AgentDefaults.primaryModuleForVendor)也照旧能找到它们。 static const Set assistantIds = { AgentModules.emaiAssistant, AgentModules.eaicarChat, }; /// 首页网格展示的模块:[enabledModules] 去掉对话助手。 List get homeModules => enabledModules .where((m) => !assistantIds.contains(m.id)) .toList(growable: false); /// 首页拖拽排序。[from]/[to] 是 [homeModules] 里的下标,换算成 [enabledIds] 的下标再挪, /// 这样被隐藏的助手在编排里的相对位置不变。越界的语义与 [move] 一致:直接忽略。 void moveOnHome(int from, int to) { final ids = homeModules.map((m) => m.id).toList(growable: false); if (from == to) return; if (from < 0 || from >= ids.length) return; if (to < 0 || to >= ids.length) return; move(enabledIds.indexOf(ids[from]), enabledIds.indexOf(ids[to])); } /// 广场页展示的全集 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; } /// 连上某品类的设备时,把 AI 页铺成这个品类的默认编排。 /// /// 规则只有一条:**只要用户没自己动过编排,AI 页就跟着当前设备走**。 /// 于是「连上车载香薰 → 只剩 EaiCar / 现场录音 / 语音纪要」「换回耳机 → 恢复全量」 /// 都成立,而用户一旦增删或排序过,就再也不会被自动改动。 /// /// 判据是 [_lastAppliedKey]:上一次**自动铺**下去的那份清单。 /// 当前编排和它逐项相同 = 用户没动过;不同 = 动过,直接放手。 /// /// ⚠️ 2026-09-11 之前的规则是「每个品类只铺一次」,切设备时编排不跟着换—— /// 表现是先连耳机(铺 11 个)再连香薰,香薰的三个编排永远铺不上。 /// 别改回去。 /// /// ⚠️ 老装机没有 [_lastAppliedKey] 这条记录,无法判断有没有动过, /// 按「允许铺一次」处理:升级后第一次连设备会按品类重铺一次 AI 页。 /// /// 采用**整套替换**而不是追加:支架/香薰只有录音与开聊,AI 页就该只剩那几个; /// 耳机能跑通话和翻译,就铺全量。追加式做不到「只显示这几个」。 bool applyDeviceDefaults(String? categoryId) { final defaults = AgentModules.defaultsForCategory(categoryId); if (defaults.isEmpty || categoryId == null) return false; final next = defaults .where((id) => AgentModules.byId(id) != null) .toList(growable: false); if (next.isEmpty) return false; final hasRecord = _storage.hasData(_lastAppliedKey); if (hasRecord) { final last = (_storage.read(_lastAppliedKey) ?? const []) .map((e) => e.toString()) .toList(); // 用户动过编排 → 不再自动改动 if (!_sameOrder(enabledIds, last)) return false; } final changed = !_sameOrder(enabledIds, next); if (changed) enabledIds.assignAll(next); _storage.write(_lastAppliedKey, next); final applied = (_storage.read(_appliedCategoriesKey) ?? const []) .map((e) => e.toString()) .toList(); if (!applied.contains(categoryId)) { 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(); } }