You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

342 lines
14 KiB

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<String> enabledIds = <String>[].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 里缺的全部
// 追加回来,等于把「连上香薰只显示 Smartcar/现场录音/语音纪要」直接抹掉。
//
// 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<List>(_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<bool>(rebaseKey) == true) return;
_storage.write(rebaseKey, true);
if (!_storage.hasData(_lastAppliedKey)) return;
final last = (_storage.read<List>(_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<String> enabled,
required List<String> lastApplied,
required List<String> 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<bool>(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<bool>(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<bool>(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<AgentModule> get enabledModules => enabledIds
.map(AgentModules.byId)
.whereType<AgentModule>()
.toList(growable: false);
/// 广场页展示的全集
List<AgentModule> get allModules => AgentModules.all;
/// 广场搜索:标题/副标题/id 命中即可,支持模糊匹配。
///
/// 三档依次放宽:
/// 1) 子串命中("翻译" → 同声翻译、面对面翻译…)
/// 2) 按空格拆词后全部命中("英文 学习" → 英文学习)
/// 3) 子序列命中("英学" → 英文学习;"tran" → translation 类 id)
/// 空关键词返回全集。
static List<AgentModule> search(List<AgentModule> 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 页就跟着当前设备走**。
/// 于是「连上车载香薰 → 只剩 Smartcar / 现场录音 / 语音纪要」「换回耳机 → 恢复全量」
/// 都成立,而用户一旦增删或排序过,就再也不会被自动改动。
///
/// 判据是 [_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<List>(_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<List>(_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<String> a, List<String> 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<List>(_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();
}
}