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.
241 lines
9.2 KiB
241 lines
9.2 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';
|
|
|
|
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);
|
|
_migrateEmaiAssistant();
|
|
_migrateDefaultLayout();
|
|
_migrateDefaultOrder();
|
|
} else {
|
|
enabledIds.assignAll(AgentModules.defaultEnabled);
|
|
}
|
|
}
|
|
|
|
/// 一次性迁移:默认排序调整(语音记录助手→现场录音→通话录音→…)。
|
|
///
|
|
/// 只在用户**没自己排过**时才动——即当前启用集合与默认集合完全一致,
|
|
/// 只是顺序不同。用户手动拖过或增删过的编排一律不碰。
|
|
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;
|
|
}
|
|
|
|
/// 某个设备品类**第一次连接成功**时铺开它的默认功能。
|
|
///
|
|
/// - 只生效一次:品类 id 记在 [_appliedCategoriesKey] 里,之后再连同类设备
|
|
/// 不会覆盖用户自己的增删和排序;
|
|
/// - 采用「补齐」而不是「替换」:用户已有的模块和顺序原样保留,
|
|
/// 缺的按品类默认顺序追加到后面。
|
|
///
|
|
/// 三条蓝牙链路(蓝讯支架 / 恒玄耳机 / 杰理)连上后各自调用它,
|
|
/// 传自己的 [DeviceCategories] 品类 id。
|
|
bool applyDeviceDefaults(String? categoryId) {
|
|
final defaults = AgentModules.defaultsForCategory(categoryId);
|
|
if (defaults.isEmpty) return false;
|
|
|
|
final applied = (_storage.read<List>(_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<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();
|
|
}
|
|
}
|
|
|