43 Commits

Author SHA1 Message Date
208147cdf2 启动动画和登录页面的动画 2026-09-23 16:52:18 +08:00
b3a700491a 启动动画和登录页面的动画 2026-09-23 16:50:48 +08:00
d230279f4f 启动动画和登录页面的动画 2026-09-23 16:48:48 +08:00
32397f8899 AIR版的功能添加 2026-09-20 16:31:15 +08:00
c8b597f683 蓝牙和上位机和非上位机的功能的适配 2026-09-16 15:38:41 +08:00
a1e01da241 多角色的配置适配 2026-09-14 12:35:20 +08:00
3547f80383 修改UI 2026-09-11 16:12:44 +08:00
4b88a291a5 ios蓝牙修复写配置 2026-09-11 15:37:37 +08:00
877ff932d4 修复iOS蓝牙扫描问题和权限问题
- iOS 跳过 Android 专属权限检查(bluetoothConnect/bluetoothScan)
- iOS 跳过定位服务检查(iOS 蓝牙扫描不需要定位)
- Info.plist 添加 NSBluetoothAlwaysUsageDescription 蓝牙权限
- Podfile 修复被污染内容并添加 CDN 源
- project.pbxproj 临时修改 Bundle ID 和 Team(适配当前开发环境)
2026-09-11 15:22:53 +08:00
43c0b68e1a 遗漏的文件 2026-09-11 09:07:18 +08:00
356c2a1925 蓝牙增强+个更换绑定逻辑接口 2026-09-11 09:06:20 +08:00
3ba480f63f config文件 2026-09-10 09:43:27 +08:00
85e9632326 退出登陆的重新设计到我的页面;优化高清地图的加载和显示(冷启动稍慢,再次秒开);声网通用组件遗漏文件; 2026-09-10 09:02:23 +08:00
b4bedd9737 修复路径规划任务的取消接口的入参的结构去掉deviceid,测试通过。 2026-09-10 08:58:04 +08:00
d76bcdcdf2 声网通用组件开发测试通过 2026-09-10 08:55:56 +08:00
b468e577a6 远程遥控视频的显示bug修复;恢复远程遥控中的前置推送检查和动态实时信息的展示。 2026-09-04 15:02:43 +08:00
db81816b78 修复高清地图(贴图)不支持手动滑动地理位置显示问题;修复地图显示上次设备位置的异常情况;修复打点保存少携带mode +orgid参数问题。 2026-09-04 08:43:43 +08:00
5141d6e83f 蓝牙绑定设备+场站切换支持高清图层的一对一显示+设备实时状态的部分显示 2026-09-02 16:58:45 +08:00
61bce2c1cf 绑定设备+覆盖模式+控制显示bug+在线更新 2026-09-01 08:59:20 +08:00
0fa67a127b Merge remote-tracking branch 'origin/feature/next-arch' into feature/next-arch 2026-08-26 14:25:53 +08:00
7c01229b9e 1:修复远程遥控控制状态显示不正确的问题
2:更换端口映射为测试服的8081-59003,9001-59004
2026-08-26 14:23:20 +08:00
a48778bc8a 删除 _fix_impl.py 2026-08-25 03:44:22 +00:00
e8070b5183 删除 _fix_json_data.py 2026-08-25 03:44:13 +00:00
fc3cf4de0f 删除 _fix_overflow.py 2026-08-25 03:44:01 +00:00
db323a26ce 删除 _fix_pause_stop.py 2026-08-25 03:43:51 +00:00
11509d1aa6 删除 _fix_startwork.py 2026-08-25 03:43:41 +00:00
97a2126fcf 删除 _fix_taskid2.py 2026-08-25 03:43:33 +00:00
0978f387fd 删除 _fix_taskid3.py 2026-08-25 03:43:23 +00:00
e9532ddafa 删除 _fix_taskid.py 2026-08-25 03:43:14 +00:00
43fbd0af19 删除 _patch_save.py 2026-08-25 03:43:06 +00:00
4b03da8142 删除 flutter_01.log 2026-08-25 03:42:53 +00:00
d0fcc067ca 删除 fix_dup.py 2026-08-25 03:42:42 +00:00
ca805ee957 删除 flutter_02.log 2026-08-25 03:42:33 +00:00
d119407866 删除 flutter_04.log 2026-08-25 03:42:21 +00:00
5a02522bb4 删除 flutter_03.log 2026-08-25 03:42:12 +00:00
54dac30915 删除 flutter_05.log 2026-08-25 03:42:03 +00:00
875766cc28 删除 flutter_06.log 2026-08-25 03:41:54 +00:00
e8a79168e8 删除 flutter_07.log 2026-08-25 03:41:45 +00:00
7773fe7a60 删除 flutter_08.log 2026-08-25 03:41:37 +00:00
b6e2a40aa7 删除 _fix_flow.py 2026-08-25 03:41:25 +00:00
bb2becdf7b 删除 _fix_dup.py 2026-08-25 03:41:16 +00:00
ca616faf11 迁移提交 2026-08-25 11:34:32 +08:00
83220869da 高清地图+无人机实时信息显示异常问题修复 2026-08-19 14:57:24 +08:00
144 changed files with 10266 additions and 4190 deletions

21
.gitignore vendored
View File

@@ -20,7 +20,9 @@ build/
gen/
local.properties
.gradle/
.kotlin/
captures/
hs_err_pid*.log
.idea/modules.xml
.idea/workspace.xml
@@ -79,6 +81,25 @@ Debug/
# ----------------------------------------------------------------------------
lib/generated_plugin_registrant.dart
# ----------------------------------------------------------------------------
# Node.js 依赖目录(前端/工具链产物,不需要提交)
# ----------------------------------------------------------------------------
node_modules/
**/node_modules/
# ----------------------------------------------------------------------------
# 日志与崩溃转储(本地调试产物,不需要提交)
# ----------------------------------------------------------------------------
flutter_*.log
test_run.log
*.log
.sentry-native/
# ----------------------------------------------------------------------------
# Office 临时锁文件(Word/Excel 打开时生成,不需要提交)
# ----------------------------------------------------------------------------
~$*
# ----------------------------------------------------------------------------
# 编辑器与系统文件
# ----------------------------------------------------------------------------

View File

@@ -1,61 +0,0 @@
import sys
path = r'C:\Users\jsmbz\Documents\flutter-app-02\lib\features\home\presentation\widgets\map\testmap_pages.dart'
with open(path, 'r', encoding='utf-8') as f:
lines = f.readlines()
# 删除重复的 failMsg 声明和重复的检查逻辑 (行2402-2411,0索引为2401-2410)
# 但行号可能因之前的编辑有偏移,用内容查找
del_start = None
del_end = None
for i, line in enumerate(lines):
if 'debugPrint' in line and '创建设备任务失败' in line and 'failure' in line:
# 检查下一行是否是重复的 final failMsg
if i+1 < len(lines) and 'final failMsg = failure.toString();' in lines[i+1]:
# 找到了重复的起点
del_start = i # debugPrint 行
# 找到这个重复块的结束:下一个 return; 之后的 },
for j in range(i+2, len(lines)):
if '// 其他失败' in lines[j] or (lines[j].strip() == 'return;' and 'taskCubit.clearCurrentTask();' in lines[j-1]):
# 更精确:找到 " return;" 且前一行是 taskCubit.clearCurrentTask
if lines[j].strip() == 'return;':
del_end = j + 1 # 包含 return; 行
break
if del_end is None:
# 备选:找到下一个 ' },' (fold 成功回调的开始)
for j in range(i+2, len(lines)):
if lines[j].strip().startswith('},') and 'taskId' in lines[j+1]:
del_end = j
break
break
if del_start is not None and del_end is not None:
print(f'Deleting lines {del_start+1} to {del_end}')
for k in range(del_start, del_end):
print(f' DEL: L{k+1}: {lines[k].rstrip()}')
del lines[del_start:del_end]
else:
print('Could not find duplicate block, checking for alternate pattern...')
# 备选:直接搜索两个 final failMsg 行
failmsg_lines = []
for i, line in enumerate(lines):
if 'final failMsg = failure.toString();' in line:
failmsg_lines.append(i)
print(f'failMsg lines: {[l+1 for l in failmsg_lines]}')
if len(failmsg_lines) >= 2:
# 删除第二个 failMsg 及它所属的整个重复检查块
second = failmsg_lines[1]
# 从第二个 failMsg 前一行 (debugPrint) 开始删
start = second - 1
# 找到这个 block 的结束
end = second + 8 # 大约8行后是这个重复块的结束
print(f'Alt: deleting lines {start+1} to {end}')
for k in range(start, min(end, len(lines))):
print(f' DEL: L{k+1}: {lines[k].rstrip()}')
del lines[start:end]
with open(path, 'w', encoding='utf-8', newline='') as f:
f.writelines(lines)
print('DONE')

View File

@@ -1,92 +0,0 @@
import re
path = r'C:\Users\jsmbz\Documents\flutter-app-02\lib\features\home\presentation\widgets\map\testmap_pages.dart'
with open(path, 'r', encoding='utf-8') as f:
content = f.read()
# Fix 1: Add var failed = false and if(failed) return, plus else branch for needRequery
old_block = """ var needRequery = false;
result.fold(
(failure) {
final failMsg = failure.toString();
if (failMsg.contains('存在任务') || failMsg.contains('already exists')) {
needRequery = true;
return;
}
_showPageToast(message: '作业启动失败', type: ToastType.error);
taskCubit.clearCurrentTask();
return;
},
(taskId) {
taskCubit.updateCurrentTaskId(taskId);
},
);
if (needRequery) {
await taskCubit.fetchAndFilterTask(deviceId);
await Future.delayed(const Duration(milliseconds: 300));
final existingTasks = taskCubit.state.activeTasks;
if (existingTasks.isNotEmpty) {
taskCubit.selectTask(existingTasks.first);
}
return;
}"""
new_block = """ var needRequery = false;
var failed = false;
result.fold(
(failure) {
final failMsg = failure.toString();
if (failMsg.contains('存在任务') || failMsg.contains('already exists')) {
needRequery = true;
return;
}
failed = true;
debugPrint('[开始作业] 创建失败: $failMsg');
_showPageToast(message: '作业启动失败', type: ToastType.error);
taskCubit.clearCurrentTask();
return;
},
(taskId) {
taskCubit.updateCurrentTaskId(taskId);
},
);
if (failed) return;
if (needRequery) {
await taskCubit.fetchAndFilterTask(deviceId);
await Future.delayed(const Duration(milliseconds: 300));
final existingTasks = taskCubit.state.activeTasks;
if (existingTasks.isNotEmpty) {
taskCubit.selectTask(existingTasks.first);
} else {
debugPrint('[开始作业] needRequery 后仍无活跃任务');
_showPageToast(message: '未找到活跃任务', type: ToastType.warn);
}
return;
}"""
if old_block in content:
content = content.replace(old_block, new_block)
print("Block replaced successfully")
else:
print("Block NOT FOUND in file!")
# Try tab indentation
old_block_tabs = old_block.replace(' ', '\t\t\t\t')
if old_block_tabs in content:
content = content.replace(old_block_tabs, new_block.replace(' ', '\t\t\t\t'))
print("Block replaced with TABS")
else:
# Find the unique line
for i, line in enumerate(content.split('\n')):
if 'var needRequery = false;' in line and 'result.fold(' in content.split('\n')[i+1]:
print(f"Found at line {i+1}: {repr(line)}")
break
else:
print("Could not find needRequery line")
with open(path, 'w', encoding='utf-8', newline='') as f:
f.write(content)
print("Done")

View File

@@ -1,51 +0,0 @@
import sys
filepath = r'C:\Users\jsmbz\Documents\flutter-app-02\lib\features\devices\data\repositories\generate_path_repository_Impl.dart'
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
old = ''' if (response.statusCode != 200) {
throw Exception('创建设备任务失败: HTTP ${response.statusCode}');
}
} catch (e) {
print('❌ [创建设备任务] 错误: $e');
throw Exception('创建设备任务失败: $e');
}'''
new = ''' if (response.statusCode != 200) {
throw Exception('创建设备任务失败: HTTP ${response.statusCode}');
}
// 解析响应,提取 taskId
final data = jsonDecode(response.body) as Map<String, dynamic>;
if (data['code'] == 200) {
final taskData = data['data'];
if (taskData is int) {
print('✅ [创建设备任务] 任务ID: $taskData');
return taskData;
} else if (taskData is Map<String, dynamic>) {
final taskId = taskData['id'] ?? taskData['taskId'];
if (taskId is int) {
print('✅ [创建设备任务] 任务ID: $taskId');
return taskId;
}
}
throw Exception('创建设备任务成功但无法解析taskId: ${response.body}');
} else {
throw Exception('创建设备任务失败: ${data['msg'] ?? response.body}');
}
} catch (e) {
print('❌ [创建设备任务] 错误: $e');
throw Exception('创建设备任务失败: $e');
}'''
if old in content:
content = content.replace(old, new)
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
print('SUCCESS')
else:
print('NOT FOUND')
# Debug: find the line
for i, line in enumerate(content.split('\n'), 1):
if '创建设备任务失败: HTTP' in line:
print(f'Line {i}: {repr(line)}')

View File

@@ -1,43 +0,0 @@
import codecs
path = r'C:\Users\jsmbz\Documents\flutter-app-02\lib\features\home\presentation\widgets\map\testmap_pages.dart'
with codecs.open(path, 'r', 'utf-8') as f:
content = f.read()
old = ''' return PlotData(
id:
record['id']?.toString() ??
DateTime.now().microsecondsSinceEpoch.toString(), // \u552f\u4e00ID
plotName: record['workName'] ?? '\u672a\u547d\u540d\u5730\u5757', // \u5730\u5757\u540d\u79f0\uff08\u4ece\u63a5\u53e3\u5b57\u6bb5\u53d6\uff09
imageUrl: record['imgUrl'] ?? '', // \u56fe\u7247URL\uff08\u4ece\u63a5\u53e3\u5b57\u6bb5\u53d6\uff0c\u65e0\u5219\u4e3a\u7a7a\uff09
jsonData: record['jsonData'], // \u539f\u59cb\u6570\u636e\u7684JSON\u5b57\u7b26\u4e32\uff08\u53ef\u9009\uff0c\u4fbf\u4e8e\u8c03\u8bd5\u6216\u540e\u7eed\u4f7f\u7528\uff09
);'''
new = ''' // \u517c\u5bb9 jsonData \u4e3a Map \u6216 String\uff1a\u7edf\u4e00\u8f6c\u4e3a JSON \u5b57\u7b26\u4e32
String? jsonDataStr;
final rawJsonData = record['jsonData'];
if (rawJsonData is String) {
jsonDataStr = rawJsonData;
} else if (rawJsonData is Map) {
jsonDataStr = jsonEncode(rawJsonData);
}
return PlotData(
id:
record['id']?.toString() ??
DateTime.now().microsecondsSinceEpoch.toString(), // \u552f\u4e00ID
plotName: record['workName'] ?? '\u672a\u547d\u540d\u5730\u5757', // \u5730\u5757\u540d\u79f0\uff08\u4ece\u63a5\u53e3\u5b57\u6bb5\u53d6\uff09
imageUrl: record['imgUrl'] ?? '', // \u56fe\u7247URL\uff08\u4ece\u63a5\u53e3\u5b57\u6bb5\u53d6\uff0c\u65e0\u5219\u4e3a\u7a7a\uff09
jsonData: jsonDataStr, // \u539f\u59cb\u6570\u636e\u7684JSON\u5b57\u7b26\u4e32\uff08\u53ef\u9009\uff0c\u4fbf\u4e8e\u8c03\u8bd5\u6216\u540e\u7eed\u4f7f\u7528\uff09
);'''
if old in content:
content = content.replace(old, new)
with codecs.open(path, 'w', 'utf-8') as f:
f.write(content)
print('SUCCESS: replaced')
else:
print('FAILED: old text not found')
lines = content.split('\n')
for i, line in enumerate(lines[3198:3208], start=3199):
print(f'{i}: {repr(line)}')

View File

@@ -1,43 +0,0 @@
filepath = r'C:\Users\jsmbz\Documents\flutter-app-02\lib\features\home\presentation\widgets\map\testmap_pages.dart'
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
old = ''' const SizedBox(height: 4),
Row(
children: [
Text(
'设备: ${currentTask.deviceId}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF4E5969),
),
),
const SizedBox(width: 12),
Container('''
new = ''' const SizedBox(height: 4),
Text(
'设备: ${currentTask.deviceId}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF4E5969),
),
softWrap: true,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Container('''
if old in content:
content = content.replace(old, new)
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
print('SUCCESS - replaced device ID text with wrapping support')
else:
print('NOT FOUND')
# Debug: find the line
for i, line in enumerate(content.split('\n'), 1):
if "设备: ${currentTask.deviceId}" in line:
print(f'Line {i}: {repr(line)}')

View File

@@ -1,74 +0,0 @@
import sys
path = r'C:\Users\jsmbz\Documents\flutter-app-02\lib\features\home\presentation\widgets\map\testmap_pages.dart'
with open(path, 'r', encoding='utf-8') as f:
lines = f.readlines()
print(f'Total lines: {len(lines)}')
# Find lines with "无可用任务"
target_lines = []
for i, line in enumerate(lines):
if '无可用任务' in line:
target_lines.append(i)
# Print surrounding context
start = max(0, i-5)
end = min(len(lines), i+3)
for j in range(start, end):
print(f' L{j+1}: {lines[j].rstrip()}')
print(f'Found at lines: {[l+1 for l in target_lines]}')
# For each occurrence, replace the block:
# lines[i-3]: " final taskId = taskCubit.state.currentTaskId;"
# lines[i-2]: " if (taskId == null) {"
# lines[i-1]: " _showPageToast(message: "无可用任务", type: ToastType.warn);"
# lines[i]: " return;"
# lines[i+1]: " }"
for line_idx in target_lines:
# Find the start of the block
block_start = line_idx - 1
block_end = line_idx + 2
indent = ''
for ch in lines[block_start]:
if ch in (' ', '\t'):
indent += ch
else:
break
new_lines = [
f'{indent}if (taskId == null) {{\n',
f'{indent} await taskCubit.fetchAndFilterTask(deviceId);\n',
f'{indent} final tasks = taskCubit.state.activeTasks;\n',
f'{indent} if (tasks.isNotEmpty) {{\n',
f'{indent} taskCubit.selectTask(tasks.first);\n',
f'{indent} taskId = tasks.first.id;\n',
f'{indent} }} else {{\n',
f'{indent} _showPageToast(message: "无可用任务", type: ToastType.warn);\n',
f'{indent} return;\n',
f'{indent} }}\n',
f'{indent}}}\n',
]
# Also change "final taskId" to "var taskId"
var_line_idx = line_idx - 2
old_var_line = lines[var_line_idx]
lines[var_line_idx] = old_var_line.replace('final taskId', 'var taskId')
# Replace the block
old_block = lines[block_start:block_end+1]
print(f'Replacing lines {block_start+1}-{block_end+1}:')
for l in old_block:
print(f' OLD: {l.rstrip()}')
for l in new_lines:
print(f' NEW: {l.rstrip()}')
lines[block_start:block_end+1] = new_lines
with open(path, 'w', encoding='utf-8', newline='') as f:
f.writelines(lines)
print('DONE')

View File

@@ -1,77 +0,0 @@
import sys
path = r'C:\Users\jsmbz\Documents\flutter-app-02\lib\features\home\presentation\widgets\map\testmap_pages.dart'
with open(path, 'r', encoding='utf-8') as f:
content = f.read()
# Find the block to replace
old_block_start = content.find('result.fold(')
if old_block_start < 0:
print('ERROR: result.fold( not found')
sys.exit(1)
# Find the end of the fold block: ');' after the fold call
# The fold ends with ');' on a line
old_block_end = content.find(' );', old_block_start)
if old_block_end < 0:
print('ERROR: fold end not found')
sys.exit(1)
old_block_end += len(' );')
old_block = content[old_block_start:old_block_end]
print('Old block found:')
print(repr(old_block[:100]))
print('...')
new_block = ''' final taskIdOrFlag = result.fold<int?>(
(failure) {
debugPrint('❌ [开始作业] 创建设备任务失败: $failure');
final failMsg = failure.toString();
if (failMsg.contains('存在任务') || failMsg.contains('already exists')) {
return null;
}
_showPageToast(message: "作业启动失败", type: ToastType.error);
return -1;
},
(taskId) => taskId,
);
if (taskIdOrFlag == null) {
debugPrint('⚠️ [开始作业] 设备已有任务,重新查询任务池...');
await taskCubit.fetchAndFilterTask(deviceId);
await Future.delayed(const Duration(milliseconds: 300));
final existingTasks = taskCubit.state.activeTasks;
debugPrint('🔍 [开始作业] 重新查询到 ${existingTasks.length} 个活跃任务');
if (existingTasks.isNotEmpty) {
final existingTask = existingTasks.first;
taskCubit.selectTask(existingTask);
debugPrint('✅ [开始作业] 复用已有任务 #${existingTask.id},taskId已保存');
_showPageToast(message: "设备正在作业中", type: ToastType.success);
_saveDataToLocal();
return;
}
debugPrint('⚠️ [开始作业] 重新查询后仍未找到活跃任务');
_showPageToast(message: "设备正在作业中", type: ToastType.warn);
return;
}
if (taskIdOrFlag == -1) {
taskCubit.clearCurrentTask();
setState(() {
isStartWork = false;
_workStatus = WorkStatus.idle;
});
return;
}
final taskId = taskIdOrFlag as int;
debugPrint('✅ [开始作业] 创建设备任务成功,taskId: $taskId');
taskCubit.updateCurrentTaskId(taskId);'''
new_content = content[:old_block_start] + new_block + content[old_block_end:]
with open(path, 'w', encoding='utf-8', newline='') as f:
f.write(new_content)
print('SUCCESS: File updated')

View File

@@ -1,75 +0,0 @@
# -*- coding: utf-8 -*-
import sys
path = r'C:\Users\jsmbz\Documents\flutter-app-02\lib\features\home\presentation\widgets\map\testmap_pages.dart'
with open(path, 'r', encoding='utf-8') as f:
content = f.read()
# Find the result.fold block
old_start = ' result.fold('
pos = content.find(old_start)
if pos < 0:
print('ERROR: result.fold not found')
sys.exit(1)
print(f'Found result.fold at char {pos}')
# Find the end of fold: " );" followed by " } catch"
# Search for the exact pattern
end_pattern = '\n );\n } catch'
end_pos = content.find(end_pattern, pos)
if end_pos < 0:
print(f'ERROR: fold end not found')
sys.exit(1)
end_pos += len('\n );')
print(f'Fold ends at char {end_pos}')
old_block = content[pos:end_pos]
print('Old block length:', len(old_block))
print('Old block preview:', repr(old_block[:80]))
new_block = ''' var needRequery = false;
result.fold(
(failure) {
final failMsg = failure.toString();
if (failMsg.contains('存在任务') || failMsg.contains('already exists')) {
needRequery = true;
return;
}
_showPageToast(message: "作业启动失败", type: ToastType.error);
taskCubit.clearCurrentTask();
setState(() {
isStartWork = false;
_workStatus = WorkStatus.idle;
});
return;
},
(taskId) {
taskCubit.updateCurrentTaskId(taskId);
},
);
if (needRequery) {
debugPrint('⚠️ [开始作业] 设备已有任务,重新查询任务池...');
await taskCubit.fetchAndFilterTask(deviceId);
await Future.delayed(const Duration(milliseconds: 300));
final existingTasks = taskCubit.state.activeTasks;
if (existingTasks.isNotEmpty) {
final existingTask = existingTasks.first;
taskCubit.selectTask(existingTask);
debugPrint('✅ [开始作业] 复用已有任务 #${existingTask.id},taskId已保存');
_showPageToast(message: "设备正在作业中", type: ToastType.success);
_saveDataToLocal();
return;
}
debugPrint('⚠️ [开始作业] 重新查询后仍未找到活跃任务');
_showPageToast(message: "设备正在作业中", type: ToastType.warn);
return;
}'''
new_content = content[:pos] + new_block + content[end_pos:]
with open(path, 'w', encoding='utf-8', newline='') as f:
f.write(new_content)
print('SUCCESS - File updated')

View File

@@ -1,99 +0,0 @@
# -*- coding: utf-8 -*-
import sys
path = r'C:\Users\jsmbz\Documents\flutter-app-02\lib\features\home\presentation\widgets\map\testmap_pages.dart'
with open(path, 'r', encoding='utf-8') as f:
content = f.read()
# Find the "存在任务" branch that just returns without saving taskId
# We'll replace the "return;" in that branch with code that does async requery
# But fold callbacks can't be async...
# Instead, let's find the fold end and add async handler after it
# Find: "\n );\n } catch (e) {"
old_pat = '\n );\n } catch (e) {'
idx = content.find(old_pat)
if idx < 0:
print('Pattern not found, trying variants...')
# Try with different whitespace
old_pat = ' );\n } catch (e) {'
idx = content.find(old_pat)
if idx < 0:
print('Still not found')
sys.exit(1)
print(f'Found at index {idx}')
# Insert code between the fold close and the catch
# We'll restructure to handle needRequery
insert_code = '''
if (needRequery) {
debugPrint('设备已有任务,重新查询任务池...');
await taskCubit.fetchAndFilterTask(deviceId);
await Future.delayed(const Duration(milliseconds: 300));
final existingTasks = taskCubit.state.activeTasks;
if (existingTasks.isNotEmpty) {
final existingTask = existingTasks.first;
taskCubit.selectTask(existingTask);
debugPrint('复用已有任务 #${existingTask.id},taskId已保存');
_showPageToast(message: "设备正在作业中", type: ToastType.success);
_saveDataToLocal();
return;
}
_showPageToast(message: "设备正在作业中", type: ToastType.warn);
return;
}
} catch (e) {'''
# Now find and fix the fold callback to set needRequery flag
# Replace the "存在任务" branch
old_branch = '''if (failMsg.contains('存在任务') || failMsg.contains('already exists')) {
debugPrint('⚠️ [开始作业] 设备已有任务,保留作业状态');
_showPageToast(message: "设备正在作业中", type: ToastType.warn);
// 🔥 不重置 _workStatus,按钮保持可见
return;'''
new_branch = '''if (failMsg.contains('存在任务') || failMsg.contains('already exists')) {
needRequery = true;
return;'''
branch_idx = content.find(old_branch)
if branch_idx < 0:
# Try without the debug print variation
old_branch = '''if (failMsg.contains('存在任务') || failMsg.contains('already exists')) {
debugPrint'''
branch_idx = content.find(old_branch)
if branch_idx < 0:
print('Branch pattern not found')
else:
print(f'Branch found at {branch_idx} (partial match)')
else:
print(f'Branch found at {branch_idx}')
# Do the branch replacement
content = content[:branch_idx] + new_branch + content[branch_idx + len(old_branch):]
# Also add needRequery var declaration before fold
# Find " result.fold(" and add var before it
fold_marker = ' result.fold('
fold_idx = content.find(fold_marker)
if fold_idx >= 0:
# Check if needRequery is already declared
check = content.find('needRequery', fold_idx - 50, fold_idx)
if check < 0:
content = content[:fold_idx] + ' var needRequery = false;\n' + content[fold_idx:]
print('Added needRequery declaration')
# Now find and replace the fold end pattern again (after content modifications)
old_pat = '\n );\n } catch (e) {'
idx = content.find(old_pat)
if idx < 0:
print('Fold end pattern not found after modifications')
sys.exit(1)
content = content[:idx + len('\n );')] + insert_code + content[idx + len(old_pat):]
with open(path, 'w', encoding='utf-8', newline='') as f:
f.write(content)
print('DONE')

View File

@@ -1,83 +0,0 @@
import sys
path = r'C:\Users\jsmbz\Documents\flutter-app-02\lib\features\home\presentation\widgets\map\testmap_pages.dart'
with open(path, 'r', encoding='utf-8') as f:
content = f.read()
start_marker = ' try {\n final result = await sl<CreateDeviceTaskUseCase>().execute('
start = content.find(start_marker)
if start < 0:
print('start not found')
sys.exit(1)
print(f'start={start}')
end_marker = 'return;\n }\n }'
end = content.find(end_marker, start)
if end < 0:
print('end not found')
sys.exit(1)
end += len('return;\n }\n }')
print(f'end={end}')
new_code = """ try {
final result = await sl<CreateDeviceTaskUseCase>().execute(
deviceId: deviceId,
routeId: routeId,
siteId: siteId,
orgId: orgId,
);
final taskId = result.fold<int?>(
(failure) {
final failMsg = failure.toString();
if (failMsg.contains('存在任务') || failMsg.contains('already exists')) {
return null;
}
_showPageToast(message: '作业启动失败', type: ToastType.error);
return -1;
},
(id) => id,
);
if (taskId == null) {
await taskCubit.fetchAndFilterTask(deviceId);
await Future.delayed(const Duration(milliseconds: 300));
final existingTasks = taskCubit.state.activeTasks;
if (existingTasks.isNotEmpty) {
taskCubit.selectTask(existingTasks.first);
_showPageToast(message: '设备正在作业中', type: ToastType.success);
_saveDataToLocal();
return;
}
_showPageToast(message: '设备正在作业中', type: ToastType.warn);
return;
}
if (taskId == -1) {
taskCubit.clearCurrentTask();
setState(() {
isStartWork = false;
_workStatus = WorkStatus.idle;
});
return;
}
taskCubit.updateCurrentTaskId(taskId);
} catch (e) {
_showPageToast(message: '作业启动异常: $e', type: ToastType.error);
taskCubit.clearCurrentTask();
setState(() {
isStartWork = false;
_workStatus = WorkStatus.idle;
});
return;
}
}"""
content = content[:start] + new_code + content[end:]
with open(path, 'w', encoding='utf-8', newline='') as f:
f.write(content)
print('DONE')

View File

@@ -1,81 +0,0 @@
path = r'C:\Users\jsmbz\Documents\flutter-app-02\lib\features\home\presentation\widgets\map\testmap_pages.dart'
with open(path, 'r', encoding='utf-8') as f:
lines = f.readlines()
# Target: lines 915-954 (0-indexed: 914-953) are duplicate response blocks
# We want to replace them with a single clean response block
# Lines before 915 (0-914) = request params + _showPageToast (already good)
# Line 915-954 = duplicates to remove
# Lines 955+ = list refresh, _clearLocalData, setState (keep)
# New response block (same indentation as original, 6 spaces)
clean_resp = [
' debugPrint(\'\U0001f4e5 [保存地块] 响应状态码: ${response.statusCode}\');\n',
' debugPrint(\'\U0001f4e5 [保存地块] 响应体(完整): $responseBody\');\n',
'\n',
' if (response.statusCode == 200) {\n',
' // \U0001f525 解析响应体确认后端是否真的成功\n',
' Map<String, dynamic>? respJson;\n',
' try {\n',
' respJson = jsonDecode(responseBody) as Map<String, dynamic>;\n',
' } catch (_) {\n',
' debugPrint(\'\u274c [保存地块] 响应体非JSON: $responseBody\');\n',
' throw Exception(\'响应体非JSON: $responseBody\');\n',
' }\n',
'\n',
' final apiCode = respJson[\'code\'];\n',
' if (apiCode != null && apiCode.toString() != \'200\') {\n',
' final apiMsg = respJson[\'msg\'] ?? respJson[\'message\'] ?? \'未知错误\';\n',
' debugPrint(\'\u274c [保存地块] 后端返回失败, code=$apiCode, msg=$apiMsg\');\n',
' throw Exception(\'$apiMsg\');\n',
' }\n',
'\n',
' debugPrint(\'\u2705 [保存地块] 保存成功, plotName=$plotName, 响应: $respJson\');\n',
' // \U0001f525 刷新地块列表\n',
' debugPrint(\'\U0001f504 [保存地块] 刷新地块列表, siteId=$siteId\');\n',
' context.read<DevicesCubit>().loadWorkRecordsBySiteId(siteId);\n',
]
# We also need to remove the duplicate list refresh code that comes after line 954
# Line 955-957 is: debugPrint('🔄...'); context.read<DevicesCubit>().loadWorkRecordsBySiteId(siteId);
# We'll include these in the new block above and remove them
# Find the start of duplicate block
dup_start = None
dup_end = None
for i, line in enumerate(lines):
if 'debugPrint' in line and '响应状态码' in line and dup_start is None:
dup_start = i
if dup_start is not None and 'List<LatLng> _getPolygonPoints()' in line:
dup_end = i
break
print(f"Duplicate block: lines {dup_start+1} to {dup_end+1}")
if dup_start is not None and dup_end is not None:
# Keep: lines before dup_start + our clean block + lines after dup_end
before = lines[:dup_start]
after_dup_end = lines[dup_end:]
# Remove the duplicate '🔄 刷新' and context.read lines that appear right after our block
# Those are at the start of after_dup_end
cleaned_after = []
skip_dedup = False
for line in after_dup_end:
# Skip lines that duplicate what's already in our clean_resp block
if '🔄 [保存地块] 刷新地块列表' in line:
skip_dedup = True
continue
if skip_dedup and 'context.read<DevicesCubit>().loadWorkRecordsBySiteId(siteId)' in line:
skip_dedup = False
continue
skip_dedup = False
cleaned_after.append(line)
new_lines = before + clean_resp + cleaned_after
with open(path, 'w', encoding='utf-8') as f:
f.writelines(new_lines)
print(f"OK: Replaced {len(lines) - len(new_lines)} lines")
else:
print(f"FAIL: dup_start={dup_start}, dup_end={dup_end}")

View File

@@ -1,4 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dhttps.protocols=TLSv1.3,TLSv1.2,TLSv1.1 -Djava.net.preferIPv4Stack=true -Djavax.net.ssl.trustStoreType=JKS
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dhttps.protocols=TLSv1.3,TLSv1.2,TLSv1.1 -Djava.net.preferIPv4Stack=true -Djavax.net.ssl.trustStoreType=JKS
# 限制 Kotlin 编译守护进程内存,避免与 Gradle 守护进程叠加挤占系统内存
kotlin.daemon.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=512m
android.useAndroidX=true
# 使用阿里云镜像加速

View File

@@ -288,7 +288,7 @@
"location": "Location",
"refresh": "Refresh",
"select_work_mode": "Select Work Mode",
"bow_mode": "Bow Mode",
"bow_mode": "Coverage Mode",
"bow_mode_subtitle": "Standard full coverage path planning",
"custom_mode": "Custom Mode",
"custom_mode_subtitle": "Manually set work area",

View File

@@ -302,7 +302,7 @@
"location": "定位",
"refresh": "刷新",
"select_work_mode": "选择作业模式",
"bow_mode": "弓字模式",
"bow_mode": "覆盖模式",
"bow_mode_subtitle": "标准全覆盖路径规划",
"custom_mode": "自定义模式",
"custom_mode_subtitle": "手动设定作业区域",

52
build_patch.bat Normal file
View File

@@ -0,0 +1,52 @@
@echo off
chcp 65001 >nul
setlocal enabledelayedexpansion
echo ============================================
echo Re Build Patch
echo ============================================
echo.
set PATCH_VERSION=1.0.2
set TARGET_VERSION_CODE=1
echo [1/3] Building Release APK ...
echo.
call flutter build apk --release
if %errorlevel% neq 0 (
echo.
echo APK build failed!
pause
exit /b 1
)
echo.
echo APK build done
echo.
echo [2/3] Generating patch ...
echo.
call dart run flutter_patcher:pack ^
--apk build/app/outputs/flutter-apk/app-release.apk ^
--version %PATCH_VERSION% ^
--target-version-code %TARGET_VERSION_CODE%
if %errorlevel% neq 0 (
echo.
echo Patch generation failed!
pause
exit /b 1
)
echo.
echo Patch generation done
echo.
echo [3/3] Output:
echo.
echo APK: build\app\outputs\flutter-apk\app-release.apk
echo Patch: dist\libapp.so
echo Manifest: dist\manifest.json
echo.
echo ============================================
echo Next: Upload dist\libapp.so to server
echo ============================================
echo.
pause

4
dist/manifest.json vendored
View File

@@ -1,6 +1,6 @@
{
"version": "1.0.13",
"md5": "181176ea51ec26ac97f34c2a550661f0",
"version": "1.0.10",
"md5": "416e3f0e2a701bb8b4a0856a29dba597",
"targetVersionCode": 1,
"abi": "arm64-v8a"
}

520
docs/SOP_使用文档.md Normal file
View File

@@ -0,0 +1,520 @@
# 迈步 Satabot App 使用说明书
> 版本:1.0.0 | 适用对象:终端用户(运维人员)
---
## 目录
1. [应用简介](#1-应用简介)
2. [安装与登录](#2-安装与登录)
3. [首页](#3-首页)
4. [设备管理](#4-设备管理)
5. [机器人控制](#5-机器人控制)
6. [无人机巡检](#6-无人机巡检)
7. [告警中心](#7-告警中心)
8. [工单管理](#8-工单管理)
9. [问题上报](#9-问题上报)
10. [我的](#10-我的)
11. [蓝牙设备绑定](#11-蓝牙设备绑定)
12. [常见问题](#12-常见问题)
---
## 1. 应用概述
### 1.1 产品简介
迈步 Satabot 是一款面向光伏电站的智能管理移动应用,为电站运维人员提供一站式的设备监控、远程控制、告警处理和工单管理能力。通过本 App,运维人员可以随时随地掌握电站运行状况,快速响应设备异常,提升运维效率。
### 1.2 适用人群
| 角色 | 说明 |
|------|------|
| 电站运维人员 | 日常巡检、设备监控、告警处理、工单执行 |
| 设备管理人员 | 设备绑定、参数配置、运行状态查看 |
| 管理人员 | 电站发电数据查看、工单统计 |
### 1.3 核心功能
| 功能模块 | 说明 |
|---------|------|
| 首页 | 电站发电概览、实时功率、设备统计、快捷入口 |
| 设备管理 | 设备列表、分类筛选、搜索、蓝牙扫描、设备详情 |
| 机器人控制 | 远程方向控制、任务管理、运行参数设置 |
| 无人机巡检 | 机场状态、实时监控、视频直播、飞行任务管理 |
| 告警中心 | 告警统计、告警处理、派发工单、AI 诊断 |
| 工单管理 | 工单全流程(新建→执行→完成/挂起/转派) |
| 问题上报 | 问题类型选择、媒体上传、设备关联 |
| 个人中心 | 个人信息、系统设置、夜间模式、导航自定义 |
### 1.4 设备类型
本 App 管理以下类型的设备:
| 设备类型 | 说明 |
|---------|------|
| 光伏逆变器 | 将直流电转换为交流电的核心设备 |
| 汇流箱 | 汇集多个光伏组件的直流电 |
| 光伏组件 | 将太阳能转化为直流电的组件 |
| 监控设备 | 数据采集与传输设备 |
| 清洁除草机器人 | MC700 系列,光伏面板清洁除草机器人 |
| 无人机机场 | 无人机自动机巢,含无人机巡检 |
### 1.5 底部导航栏
App 底部有以下功能页签,可在"我的 → 底部导航设置"中自定义显示和排序:
| 页签 | 名称 | 说明 |
|------|------|------|
| 首页 | 电站概览 | 发电数据、设备统计、快捷入口 |
| 设备 | 设备管理 | 设备列表、机器人、无人机 |
| 告警 | 告警中心 | 查看和处理设备告警 |
| 工单 | 工单管理 | 工单全流程管理 |
| 上报 | 问题上报 | 上报设备问题 |
| 我的 | 个人中心 | 个人信息、系统设置 |
---
## 2. 安装与登录
### 2.1 安装
- **Android**:扫描二维码下载 APK 安装
- **iOS**:扫描二维码跳转 App Store 安装
- 安装时如提示"未知来源",请在手机设置中允许安装
### 2.2 登录
1. 打开 App,进入登录页面
2. 输入**用户名**和**密码**
3. 勾选"已阅读并同意用户协议"
4. 点击**登录**按钮
5. 登录成功后自动跳转到首页
> **提示**:如果忘记密码,请联系管理员重置。
### 2.3 注册
1. 在登录页面点击**注册账号**
2. 填写用户名、密码等信息
3. 提交后等待管理员审核
### 2.4 修改密码
1. 登录后进入"我的"页面
2. 点击"修改密码"
3. 输入旧密码和新密码
4. 提交完成修改
---
## 3. 首页
### 3.1 页面说明
首页展示当前选中电站的整体运行情况。
### 3.2 切换电站
- 页面顶部左侧显示当前电站名称
- 点击电站名称可下拉切换其他电站
- 切换后所有数据自动刷新
### 3.3 功能区域
| 区域 | 说明 |
|------|------|
| 天气信息 | 显示电站所在地天气 |
| 电站概览 | 装机容量、今日发电量等 |
| 功率卡片 | 实时功率、今日累计发电量、功率趋势图 |
| 统计网格 | 设备总数、在线数、离线数、异常数 |
| 工单卡片 | 待处理工单数量,点击进入工单页 |
| 快捷入口 | 设备管理、告警、工单等快速跳转 |
| TCP 状态 | 右上角圆点显示通信连接状态(绿色=正常) |
### 3.4 下拉刷新
- 在首页任意位置**下拉**可刷新所有数据
---
## 4. 设备管理
### 4.1 进入设备页
点击底部导航栏的**设备**页签。
### 4.2 切换电站
- 页面顶部左侧可切换电站,切换后设备列表自动刷新
### 4.3 搜索设备
- 页面上方搜索框中输入设备名称
- 点击搜索按钮或按确认键进行搜索
### 4.4 设备分类筛选
页面中部有设备类型筛选条:
| 筛选项 | 说明 |
|--------|------|
| 全部 | 显示所有设备 |
| 逆变器 | 仅显示逆变器设备 |
| 汇流箱 | 仅显示汇流箱设备 |
| 组件 | 仅显示光伏组件 |
| 监控 | 仅显示监控设备 |
| 机器人 | 仅显示清洁除草机器人 |
| 无人机机场 | 仅显示无人机机巢 |
### 4.5 添加设备
- 点击页面右上角 **+** 按钮
- 可选择**扫一扫**(扫码绑定)或**蓝牙**(蓝牙搜索绑定)
### 4.6 蓝牙扫描
- 点击搜索框旁边的**蓝牙图标**
- App 开始扫描附近蓝牙设备
- 扫描结果中,名称为纯数字的设备(时间戳格式)自动**置顶**显示
- 可在搜索框中输入关键词过滤设备
- 点击设备可进入设备详情页
### 4.7 设备详情
点击设备列表中的设备,进入设备详情页:
- **光伏设备**:显示设备运行参数(功率、电压、电流、温度等)
- **机器人**:进入机器人控制页(详见第 5 章)
- **无人机机场**:进入机场详情页(详见第 6 章)
---
## 5. 机器人控制
### 5.1 进入方式
设备页 → 筛选"机器人" → 点击机器人设备
### 5.2 机器人详情页
| 区域 | 说明 |
|------|------|
| 设备信息卡 | 显示机器人名称、序列号、在线状态、电量 |
| 状态栏 | 运行状态、工作模式、当前位置 |
| 控制面板 | 方向控制(前进/后退/左转/右转/停止) |
| 检查清单 | 任务执行前的检查项 |
| 任务信息 | 当前任务详情 |
| 操作按钮 | 启动任务、暂停、取消等 |
### 5.3 远程控制
1. 进入机器人详情页后,App 自动申请远程控制权限
2. 获得权限后可使用控制面板上的方向按钮
3. 点击**停止**按钮停止移动
4. 退出页面时自动释放控制权限
### 5.4 任务管理
| 操作 | 说明 |
|------|------|
| 创建任务 | 设置工作区域参数后创建清洁任务 |
| 启动任务 | 选择任务后点击启动 |
| 暂停任务 | 执行中点击暂停 |
| 恢复任务 | 暂停后点击恢复 |
| 取消任务 | 取消当前任务 |
### 5.5 运行参数设置
1. 在机器人详情页点击设置图标
2. 可调整运行参数(速度、切割高度等)
3. 点击保存生效
---
## 6. 无人机巡检
### 6.1 进入方式
设备页 → 筛选"无人机机场" → 点击机场设备
### 6.2 机场详情页
| 信息 | 说明 |
|------|------|
| 机场状态 | 在线状态、仓内温度 |
| 无人机状态 | 电量、位置、飞行状态 |
| 视频监控 | 仓内监控画面 |
### 6.3 实时监控
- 点击"实时监控"进入无人机监控页
- 显示无人机 OSD 数据:经纬度、高度、速度、电量、航向等
### 6.4 视频直播
1. 点击"视频直播"进入直播页面
2. 可切换摄像头:广角 / 变焦 / 红外
3. 支持画面全屏、方向控制
### 6.5 飞行任务
| 操作 | 说明 |
|------|------|
| 查看任务 | 查看已有飞行任务列表 |
| 创建任务 | 选择航线、设置参数后创建 |
| 执行任务 | 选择任务后点击执行 |
| 暂停任务 | 飞行中可暂停 |
| 返航 | 一键返航 |
---
## 7. 告警中心
### 7.1 进入方式
点击底部导航栏的**告警**页签。
### 7.2 告警统计
- 页面上方显示告警数量统计卡片
- 按状态分类:待处理、处理中、已处理、已关闭
### 7.3 告警列表
- 按状态 Tab 分类展示
- 下拉刷新,上拉加载更多
- 点击告警条目进入详情页
### 7.4 告警详情
| 信息 | 说明 |
|------|------|
| 告警信息 | 告警名称、级别、时间、设备 |
| 告警详情 | 详细描述、可能原因 |
| AI 诊断 | AI 辅助分析告警原因和建议 |
### 7.5 处理告警
1. 在告警详情页点击"处理"
2. 选择处理方式(确认/关闭)
3. 填写处理说明
4. 提交完成处理
### 7.6 派发工单
1. 在告警详情页点击"派发工单"
2. 选择工单模型
3. 填写派发信息
4. 提交后自动生成工单
---
## 8. 工单管理
### 8.1 进入方式
点击底部导航栏的**工单**页签。
### 8.2 工单统计
- 页面上方显示各状态工单数量统计
- 点击数字可快速筛选对应状态的工单
### 8.3 工单列表
- 按状态 Tab 分类展示:待处理、处理中、已完成、已挂起
- 下拉刷新,上拉加载更多
- 点击工单条目进入详情页
### 8.4 工单详情
显示工单编号、类型、设备、描述、处理记录等信息。
### 8.5 工单操作
| 操作 | 说明 |
|------|------|
| 开始执行 | 接收工单后开始处理 |
| 完成 | 处理完毕后标记完成 |
| 挂起 | 暂时无法处理,挂起工单 |
| 恢复 | 挂起的工单恢复处理 |
| 转派 | 将工单转给其他人员 |
### 8.6 新建工单
1. 在工单页面点击"上报"按钮
2. 选择工单类型
3. 填写问题描述
4. 关联设备
5. 提交生成工单
---
## 9. 问题上报
### 9.1 进入方式
点击底部导航栏的**上报**页签。
### 9.2 上报流程
1. **选择上报类型**:故障 / 建议 / 投诉等
2. **选择严重等级**:紧急 / 严重 / 一般 / 轻微
3. **选择设备**:从设备列表中关联相关设备
4. **上传媒体**:拍照、录像或选择图片/视频
5. **填写描述**:文字描述问题详情
6. 点击**提交**完成上报
### 9.3 媒体上传
- 点击拍照按钮直接拍照
- 或从手机相册选择图片/视频
- 点击已上传的媒体可预览大图/视频
---
## 10. 我的
### 10.1 进入方式
点击底部导航栏的**我的**页签。
### 10.2 功能列表
| 功能 | 说明 |
|------|------|
| 用户信息卡 | 头像、昵称、联系方式,点击进入编辑页 |
| 快捷操作 | 常用功能快速入口 |
| 个人资料 | 修改昵称、头像等 |
| 系统设置 | 夜间模式、语言切换、清除缓存 |
| 底部导航设置 | 自定义底部 Tab 显示/隐藏和排序 |
| 版本更新 | 检查并下载新版本 |
| 退出登录 | 清除登录状态返回登录页 |
### 10.3 夜间模式
- 我的 → 系统设置 → 夜间模式开关
- 开启后 App 切换为深色主题
### 10.4 底部导航设置
1. 我的 → 底部导航设置(或首页 → 设置入口)
2. 可开关每个 Tab 的显示/隐藏
3. 至少保留一个 Tab
4. 可调整 Tab 排列顺序
5. 设置即时生效
---
## 11. 蓝牙设备绑定
### 11.1 方式一:扫码绑定
1. 设备页 → 点击右上角 **+**
2. 选择"扫一扫"
3. 扫描设备上的二维码
4. App 获取设备名称(如 `MC700AIR-CN-JS-1777272534998-0000000A-75`)
5. 进入绑定页面
6. 选择所属组织和场站
7. 点击提交完成绑定
### 11.2 方式二:蓝牙搜索绑定
1. 设备页 → 点击搜索框旁的**蓝牙图标**
2. App 扫描附近蓝牙设备
3. 在搜索框输入关键词可过滤设备
4. 蓝牙名为纯数字的设备(时间戳格式)自动置顶
5. 点击目标设备进入设备详情页
6. 点击**连接设备**按钮连接蓝牙
7. 连接成功后,**绑定设备**按钮变为可点击状态
8. 未连接时"绑定设备"按钮为灰色,不可点击
9. 点击"绑定设备"
- App 自动获取全部设备列表
- 用蓝牙名(时间戳,如 `1777272534998`)匹配设备
- 匹配成功后自动跳转到绑定页面
10. 选择所属组织和场站
11. 点击提交完成绑定
### 11.3 设备名说明
| 位置 | 名称示例 | 说明 |
|------|---------|------|
| 蓝牙广播名 | `1777272534998` | 纯数字时间戳 |
| 设备完整名 | `MC700AIR-CN-JS-1777272534998-0000000A-75` | 设备序列号 |
| 匹配方式 | 完整名包含蓝牙名 | 蓝牙名是完整名的一部分 |
---
## 12. 常见问题
### Q1:登录提示"登录失败"?
- 检查用户名和密码是否正确
- 检查网络是否正常
- 联系管理员确认账号是否已开通
### Q2:蓝牙扫描不到设备?
- 确认手机蓝牙已开启
- 确认已授予 App 蓝牙权限和定位权限
- 靠近设备后重试
- 安卓手机需开启"位置信息"开关
### Q3:蓝牙连接失败?
- 确认设备已通电
- 确认设备未被其他手机连接
- 靠近设备重试
- 重启手机蓝牙后重试
### Q4:绑定设备按钮灰色不可点击?
- "绑定设备"按钮需要**先连接蓝牙**后才能使用
- 请先点击"连接设备"按钮,连接成功后按钮变为可点击
### Q5:点击绑定设备后提示"未匹配到设备"?
- 确认设备已录入后台系统
- 联系管理员确认设备名中是否包含当前蓝牙名(时间戳)
### Q6:首页数据显示"加载失败"?
- 检查网络连接
- 点击"重试"按钮
- 确认当前账号有权限访问该电站
### Q7:机器人控制无反应?
- 确认机器人在线
- 确认已获得远程控制权限
- 确认机器人未在执行其他任务
- 退出页面重新进入
### Q8:无人机视频不显示?
- 确认无人机和机场在线
- 确认网络畅通
- 尝试切换摄像头
### Q9:TCP 状态显示异常?
- 首页右上角圆点显示 TCP 连接状态
- 红色表示未连接,绿色表示正常
- 如持续异常,检查网络或联系管理员
### Q10:App 版本过旧无法使用?
- 进入"我的"→"版本更新"检查新版本
- 下载并安装最新版本
---
## 联系与反馈
如遇到本说明书未涵盖的问题,请通过以下方式联系:
- **客服电话**:请联系管理员获取
- **问题反馈**:App 内"上报"页面提交问题反馈
- **技术支持**:请联系管理员获取联系方式

View File

@@ -1,61 +0,0 @@
import re
filepath = r'C:\Users\jsmbz\Documents\flutter-app-02\lib\features\home\presentation\widgets\map\testmap_pages.dart'
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
# Pattern: find the second duplicate method (with debugPrint in catch)
# It starts with "/// 从 state.pathData 实时解析" and ends with "}\n\n Widget _buildWorkPanel"
pattern1 = r'\n /// 从 state\.pathData 实时解析 startWorkList\n /// 解决 BlocBuilder 因 state\.pathData 变化触发重建时,onTap 的异步赋值还未完成的时序问题\n List<dynamic> _parseStartWorkListFromPathData\(List<Map<String, dynamic>>\? pathData\) \{\n if \(pathData == null \|\| pathData\.isEmpty\) return \[\];\n final firstRecord = pathData\.first;\n final nestedJsonRaw = firstRecord\['jsonData'\];\n if \(nestedJsonRaw is! String\) return \[\];\n try \{\n final parsedJson = jsonDecode\(nestedJsonRaw\);\n if \(parsedJson is! Map<String, dynamic>\) return \[\];\n final planModel = parsedJson\['planModel'\];\n final isBow = planModel == WorkMode\.bow\.value;\n\n List<dynamic> pathList = \[\];\n final rawPath = parsedJson\['path'\];\n if \(rawPath is String\) \{\n try \{ pathList = jsonDecode\(rawPath\) as List; \} catch \(_\) \{\}\n \} else if \(rawPath is List\) \{\n pathList = rawPath;\n \}\n\n List<dynamic> outerList = \[\];\n final rawOuter = parsedJson\['outer'\];\n if \(rawOuter is String\) \{\n try \{ outerList = jsonDecode\(rawOuter\) as List; \} catch \(_\) \{\}\n \} else if \(rawOuter is List\) \{\n outerList = rawOuter;\n \}\n\n if \(isBow\) \{\n return pathList\.isNotEmpty \? pathList : outerList;\n \} else \{\n return outerList\.isNotEmpty \? outerList : pathList;\n \}\n \} catch \(e\) \{\n debugPrint\('❌ \[_parseStartWorkListFromPathData\] 解析失败: \$e'\);\n return \[\];\n \}\n \}\n\n Widget _buildWorkPanel'
# Try to find and replace the second duplicate method
match = re.search(pattern1, content)
if match:
print(f"Found duplicate method at position {match.start()}-{match.end()}")
content = content[:match.start()] + '\n Widget _buildWorkPanel' + content[match.end():]
else:
print("Pattern 1 not found, trying simpler approach...")
# Just find the second occurrence of _parseStartWorkListFromPathData
first_idx = content.find('_parseStartWorkListFromPathData')
second_idx = content.find('_parseStartWorkListFromPathData', first_idx + 1)
if second_idx != -1:
# Find the start of this method (go back to find " ///" or " List<dynamic>")
start = content.rfind(' /// 从 state.pathData', 0, second_idx)
if start == -1:
start = content.rfind(' List<dynamic> _parseStartWorkListFromPathData', 0, second_idx)
# Find the end of this method
end = content.find(' Widget _buildWorkPanel', second_idx)
if end == -1:
end = content.find('Widget _buildWorkPanel', second_idx)
if start != -1 and end != -1:
print(f"Found duplicate at {start}-{end}")
content = content[:start] + content[end:]
else:
print(f"Could not find boundaries: start={start}, end={end}")
else:
print("No duplicate method found")
# Pattern 2: remove duplicate call in _buildWorkPanel
# Find the second "// 核心修复:" inside _buildWorkPanel builder
first_core = content.find('// 🔥 核心修复:从 state.pathData 实时计算 startWorkList')
second_core = content.find('// 核心修复:从 state.pathData 实时计算 startWorkList', first_core + 1)
if second_core != -1:
# Go back to find the blank line before this duplicate block
start_call = content.rfind('\n', 0, second_core)
start_call = content.rfind('\n', 0, start_call) # go back one more line
# Find the end after the duplicate block
# The block is: "// 核心修复...\n final parsedList...\n if...\n startWorkList...\n }\n"
end_call = content.find('\n return Positioned(', second_core)
if end_call != -1:
print(f"Found duplicate call at {start_call}-{end_call}")
content = content[:start_call] + content[end_call:]
else:
print(f"Could not find end of duplicate call")
else:
print("No duplicate call found")
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
print("Done!")

12
fix_json.ps1 Normal file
View File

@@ -0,0 +1,12 @@
$zhPath = 'C:\Users\jsmbz\Documents\flutter-app-02\assets\languages\zh-CN.json'
$enPath = 'C:\Users\jsmbz\Documents\flutter-app-02\assets\languages\en-US.json'
$zhContent = [System.IO.File]::ReadAllText($zhPath)
$zhContent = $zhContent.Replace('"bow_mode": "弓字模式"', '"bow_mode": "覆盖模式"')
[System.IO.File]::WriteAllText($zhPath, $zhContent)
$enContent = [System.IO.File]::ReadAllText($enPath)
$enContent = $enContent.Replace('"bow_mode": "Bow Mode"', '"bow_mode": "Coverage Mode"')
[System.IO.File]::WriteAllText($enPath, $enContent)
Write-Host "Done!"

View File

@@ -3,7 +3,7 @@ Please report a bug at https://github.com/flutter/flutter/issues.
## command
flutter --no-color run --machine --track-widget-creation --device-id=4TRKWOEE4HR8AMEA --start-paused --dart-define=flutter.inspector.structuredErrors=true --devtools-server-address=http://127.0.0.1:9100 lib\main.dart
flutter --no-color run --machine --track-widget-creation --device-id=192.168.2.48:37689 --start-paused --dart-define=flutter.inspector.structuredErrors=true --devtools-server-address=http://127.0.0.1:9100 lib\main.dart
## exception
@@ -61,10 +61,10 @@ Failed to init NativeSymbolResolver (SymInitialize 3221225476)
## flutter doctor
```
[✓] Flutter (Channel stable, 3.38.7, on Microsoft Windows [版本 10.0.26200.8655], locale zh-CN) [1,125ms]
[✓] Flutter (Channel stable, 3.38.7, on Microsoft Windows [版本 10.0.26200.9168], locale zh-CN) [1,623ms]
• Flutter version 3.38.7 on channel stable at D:\fluttersdk\flutter_windows_3.38.7-stable\flutter
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 3b62efc2a3 (6 months ago), 2026-01-13 13:47:42 -0800
• Framework revision 3b62efc2a3 (8 months ago), 2026-01-13 13:47:42 -0800
• Engine revision 78fc3012e4
• Dart version 3.10.7
• DevTools version 2.51.1
@@ -72,9 +72,9 @@ Failed to init NativeSymbolResolver (SymInitialize 3221225476)
• Flutter download mirror https://storage.flutter-io.cn
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging
[✓] Windows Version (11 家庭版 中文版 64-bit, 25H2, 2009) [5.0s]
[✓] Windows Version (11 家庭版 中文版 64-bit, 25H2, 2009) [9.3s]
[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [10.4s]
[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [14.1s]
• Android SDK at C:\Users\jsmbz\AppData\Local\Android\Sdk
• Emulator version 36.3.10.0 (build_id 14472402) (CL:N/A)
• Platform android-36, build-tools 36.1.0
@@ -85,23 +85,22 @@ Failed to init NativeSymbolResolver (SymInitialize 3221225476)
• Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72)
• All Android licenses accepted.
[✓] Chrome - develop for the web [31ms]
[✓] Chrome - develop for the web [20ms]
• Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe
[✗] Visual Studio - develop Windows apps [26ms]
[✗] Visual Studio - develop Windows apps [17ms]
✗ Visual Studio not installed; this is necessary to develop Windows apps.
Download at https://visualstudio.microsoft.com/downloads/.
Please install the "Desktop development with C++" workload, including all of its default components
[✓] Connected device (4 available) [5.7s]
• PKB110 (mobile) • 4TRKWOEE4HR8AMEA • android-arm64 • Android 16 (API 36)
• Windows (desktop) • windows • windows-x64 • Microsoft Windows [版本 10.0.26200.8655]
• Chrome (web) • chrome • web-javascript • Google Chrome 149.0.7827.116
• Edge (web) • edge • web-javascript • Microsoft Edge 149.0.4022.98
[✓] Connected device (4 available) [8.2s]
• PKB110 (mobile) • 192.168.2.48:37689 • android-arm64 • Android 16 (API 36)
• Windows (desktop) • windows • windows-x64 • Microsoft Windows [版本 10.0.26200.9168]
• Chrome (web) • chrome • web-javascript • Google Chrome 151.0.7922.138
• Edge (web) • edge • web-javascript • Microsoft Edge 151.0.4129.93
[!] Network resources [203.6s]
✗ A cryptographic error occurred while checking "https://github.com/": Connection terminated during handshake
You may be experiencing a man-in-the-middle attack, your network may be compromised, or you may have malware installed on your computer.
[✓] Network resources [1,268ms]
• All expected network resources are available.
! Doctor found issues in 2 categories.
! Doctor found issues in 1 category.
```

View File

@@ -3,7 +3,7 @@ Please report a bug at https://github.com/flutter/flutter/issues.
## command
flutter --no-color run --machine --track-widget-creation --device-id=4TRKWOEE4HR8AMEA --start-paused --dart-define=flutter.inspector.structuredErrors=true --devtools-server-address=http://127.0.0.1:9100 lib\main.dart
flutter --no-color run --machine --track-widget-creation --device-id=192.168.2.39:42831 --start-paused --dart-define=flutter.inspector.structuredErrors=true --devtools-server-address=http://127.0.0.1:9100 lib\main.dart
## exception
@@ -61,10 +61,10 @@ Failed to init NativeSymbolResolver (SymInitialize 3221225476)
## flutter doctor
```
[✓] Flutter (Channel stable, 3.38.7, on Microsoft Windows [版本 10.0.26200.8655], locale zh-CN) [744ms]
[✓] Flutter (Channel stable, 3.38.7, on Microsoft Windows [版本 10.0.26200.9168], locale zh-CN) [841ms]
• Flutter version 3.38.7 on channel stable at D:\fluttersdk\flutter_windows_3.38.7-stable\flutter
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 3b62efc2a3 (6 months ago), 2026-01-13 13:47:42 -0800
• Framework revision 3b62efc2a3 (8 months ago), 2026-01-13 13:47:42 -0800
• Engine revision 78fc3012e4
• Dart version 3.10.7
• DevTools version 2.51.1
@@ -72,9 +72,9 @@ Failed to init NativeSymbolResolver (SymInitialize 3221225476)
• Flutter download mirror https://storage.flutter-io.cn
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging
[✓] Windows Version (11 家庭版 中文版 64-bit, 25H2, 2009) [3.7s]
[✓] Windows Version (11 家庭版 中文版 64-bit, 25H2, 2009) [4.4s]
[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [12.7s]
[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [9.0s]
• Android SDK at C:\Users\jsmbz\AppData\Local\Android\Sdk
• Emulator version 36.3.10.0 (build_id 14472402) (CL:N/A)
• Platform android-36, build-tools 36.1.0
@@ -85,22 +85,25 @@ Failed to init NativeSymbolResolver (SymInitialize 3221225476)
• Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72)
• All Android licenses accepted.
[✓] Chrome - develop for the web [13ms]
[✓] Chrome - develop for the web [11ms]
• Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe
[✗] Visual Studio - develop Windows apps [11ms]
[✗] Visual Studio - develop Windows apps [8ms]
✗ Visual Studio not installed; this is necessary to develop Windows apps.
Download at https://visualstudio.microsoft.com/downloads/.
Please install the "Desktop development with C++" workload, including all of its default components
[✓] Connected device (4 available) [6.1s]
• PKB110 (mobile) • 4TRKWOEE4HR8AMEA • android-arm64 • Android 16 (API 36)
• Windows (desktop) • windows • windows-x64 • Microsoft Windows [版本 10.0.26200.8655]
• Chrome (web) • chrome • web-javascript • Google Chrome 149.0.7827.116
• Edge (web) • edge • web-javascript • Microsoft Edge 149.0.4022.98
[✓] Connected device (7 available) [11.6s]
• 21091116AC (mobile) • 192.168.2.16:35367 • android-arm64 • Android 13 (API 33)
• PKB110 (mobile) • 192.168.2.39:42831 • android-arm64 • Android 16 (API 36)
• PKB110 (wireless) (mobile) • adb-4TRKWOEE4HR8AMEA-ti69Ed._adb-tls-connect._tcp • android-arm64 • Android 16 (API 36)
• 21091116AC (wireless) (mobile) • adb-QK4LCEO78TWOVKOB-v9ALzk._adb-tls-connect._tcp • android-arm64 • Android 13 (API 33)
• Windows (desktop) • windows • windows-x64 • Microsoft Windows [版本 10.0.26200.9168]
• Chrome (web) • chrome • web-javascript • Google Chrome 151.0.7922.138
• Edge (web) • edge • web-javascript • Microsoft Edge 151.0.4129.93
[!] Network resources [22.1s]
✗ An HTTP error occurred while checking "https://github.com/": 信号灯超时时间已到
[✓] Network resources [939ms]
• All expected network resources are available.
! Doctor found issues in 2 categories.
! Doctor found issues in 1 category.
```

View File

@@ -1,107 +0,0 @@
Flutter crash report.
Please report a bug at https://github.com/flutter/flutter/issues.
## command
flutter --no-color run --machine --track-widget-creation --device-id=4TRKWOEE4HR8AMEA --start-paused --dart-define=flutter.inspector.structuredErrors=true --devtools-server-address=http://127.0.0.1:9100 lib\main.dart
## exception
FormatException: FormatException: Unexpected character (at character 1)
Failed to init NativeSymbolResolver (SymInitialize 3221225476)
^
```
#0 _ChunkedJsonParser.fail (dart:convert-patch/convert_patch.dart:1467:5)
#1 _ChunkedJsonParser.parseNumber (dart:convert-patch/convert_patch.dart:1333:9)
#2 _ChunkedJsonParser.parse (dart:convert-patch/convert_patch.dart:935:22)
#3 _JsonStringDecoderSink.addSlice (dart:convert-patch/convert_patch.dart:1555:13)
#4 _JsonStringDecoderSink.add (dart:convert-patch/convert_patch.dart:1560:5)
#5 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#6 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#7 _rootRunUnary (dart:async/zone.dart:1538:47)
#8 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#9 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#10 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#11 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#12 _SinkTransformerStreamSubscription._add (dart:async/stream_transformers.dart:67:11)
#13 _EventSinkWrapper.add (dart:async/stream_transformers.dart:13:11)
#14 _StringAdapterSink.add (dart:convert/string_conversion.dart:228:11)
#15 _StringAdapterSink.addSlice (dart:convert/string_conversion.dart:233:7)
#16 _Utf8ConversionSink.addSlice (dart:convert/string_conversion.dart:313:20)
#17 _Utf8ConversionSink.add (dart:convert/string_conversion.dart:306:5)
#18 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#19 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#20 _rootRunUnary (dart:async/zone.dart:1538:47)
#21 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#22 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#23 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#24 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#25 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#26 _StreamController._add (dart:async/stream_controller.dart:663:7)
#27 _StreamController.add (dart:async/stream_controller.dart:618:5)
#28 _Socket._onData (dart:io-patch/socket_patch.dart:2880:41)
#29 _rootRunUnary (dart:async/zone.dart:1546:13)
#30 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#31 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#32 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#33 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#34 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#35 _StreamController._add (dart:async/stream_controller.dart:663:7)
#36 _StreamController.add (dart:async/stream_controller.dart:618:5)
#37 new _RawSocket.<anonymous closure> (dart:io-patch/socket_patch.dart:2315:31)
#38 _NativeSocket.issueReadEvent.issue (dart:io-patch/socket_patch.dart:1655:14)
#39 _microtaskLoop (dart:async/schedule_microtask.dart:40:35)
#40 _startMicrotaskLoop (dart:async/schedule_microtask.dart:49:5)
#41 _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:127:13)
#42 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:194:5)
```
## flutter doctor
```
[✓] Flutter (Channel stable, 3.38.7, on Microsoft Windows [版本 10.0.26200.8655], locale zh-CN) [510ms]
• Flutter version 3.38.7 on channel stable at D:\fluttersdk\flutter_windows_3.38.7-stable\flutter
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 3b62efc2a3 (6 months ago), 2026-01-13 13:47:42 -0800
• Engine revision 78fc3012e4
• Dart version 3.10.7
• DevTools version 2.51.1
• Pub download mirror https://pub.flutter-io.cn
• Flutter download mirror https://storage.flutter-io.cn
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging
[✓] Windows Version (11 家庭版 中文版 64-bit, 25H2, 2009) [2.9s]
[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [8.9s]
• Android SDK at C:\Users\jsmbz\AppData\Local\Android\Sdk
• Emulator version 36.3.10.0 (build_id 14472402) (CL:N/A)
• Platform android-36, build-tools 36.1.0
• ANDROID_HOME = C:\Users\jsmbz\AppData\Local\Android\Sdk
• Java binary at: D:\devtools\anrdstod\jbr\bin\java
This is the JDK bundled with the latest Android Studio installation on this machine.
To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
• Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72)
• All Android licenses accepted.
[✓] Chrome - develop for the web [9ms]
• Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe
[✗] Visual Studio - develop Windows apps [8ms]
✗ Visual Studio not installed; this is necessary to develop Windows apps.
Download at https://visualstudio.microsoft.com/downloads/.
Please install the "Desktop development with C++" workload, including all of its default components
[✓] Connected device (4 available) [5.2s]
• PKB110 (mobile) • 4TRKWOEE4HR8AMEA • android-arm64 • Android 16 (API 36)
• Windows (desktop) • windows • windows-x64 • Microsoft Windows [版本 10.0.26200.8655]
• Chrome (web) • chrome • web-javascript • Google Chrome 149.0.7827.116
• Edge (web) • edge • web-javascript • Microsoft Edge 150.0.4078.48
[!] Network resources [251.0s]
✗ A cryptographic error occurred while checking "https://github.com/": Connection terminated during handshake
You may be experiencing a man-in-the-middle attack, your network may be compromised, or you may have malware installed on your computer.
! Doctor found issues in 2 categories.
```

View File

@@ -1,106 +0,0 @@
Flutter crash report.
Please report a bug at https://github.com/flutter/flutter/issues.
## command
flutter --no-color run --machine --track-widget-creation --device-id=4TRKWOEE4HR8AMEA --start-paused --dart-define=flutter.inspector.structuredErrors=true --devtools-server-address=http://127.0.0.1:9100 lib\main.dart
## exception
FormatException: FormatException: Unexpected character (at character 1)
Failed to init NativeSymbolResolver (SymInitialize 3221225476)
^
```
#0 _ChunkedJsonParser.fail (dart:convert-patch/convert_patch.dart:1467:5)
#1 _ChunkedJsonParser.parseNumber (dart:convert-patch/convert_patch.dart:1333:9)
#2 _ChunkedJsonParser.parse (dart:convert-patch/convert_patch.dart:935:22)
#3 _JsonStringDecoderSink.addSlice (dart:convert-patch/convert_patch.dart:1555:13)
#4 _JsonStringDecoderSink.add (dart:convert-patch/convert_patch.dart:1560:5)
#5 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#6 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#7 _rootRunUnary (dart:async/zone.dart:1538:47)
#8 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#9 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#10 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#11 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#12 _SinkTransformerStreamSubscription._add (dart:async/stream_transformers.dart:67:11)
#13 _EventSinkWrapper.add (dart:async/stream_transformers.dart:13:11)
#14 _StringAdapterSink.add (dart:convert/string_conversion.dart:228:11)
#15 _StringAdapterSink.addSlice (dart:convert/string_conversion.dart:233:7)
#16 _Utf8ConversionSink.addSlice (dart:convert/string_conversion.dart:313:20)
#17 _Utf8ConversionSink.add (dart:convert/string_conversion.dart:306:5)
#18 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#19 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#20 _rootRunUnary (dart:async/zone.dart:1538:47)
#21 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#22 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#23 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#24 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#25 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#26 _StreamController._add (dart:async/stream_controller.dart:663:7)
#27 _StreamController.add (dart:async/stream_controller.dart:618:5)
#28 _Socket._onData (dart:io-patch/socket_patch.dart:2880:41)
#29 _rootRunUnary (dart:async/zone.dart:1546:13)
#30 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#31 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#32 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#33 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#34 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#35 _StreamController._add (dart:async/stream_controller.dart:663:7)
#36 _StreamController.add (dart:async/stream_controller.dart:618:5)
#37 new _RawSocket.<anonymous closure> (dart:io-patch/socket_patch.dart:2315:31)
#38 _NativeSocket.issueReadEvent.issue (dart:io-patch/socket_patch.dart:1655:14)
#39 _microtaskLoop (dart:async/schedule_microtask.dart:40:35)
#40 _startMicrotaskLoop (dart:async/schedule_microtask.dart:49:5)
#41 _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:127:13)
#42 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:194:5)
```
## flutter doctor
```
[✓] Flutter (Channel stable, 3.38.7, on Microsoft Windows [版本 10.0.26200.8655], locale zh-CN) [1,209ms]
• Flutter version 3.38.7 on channel stable at D:\fluttersdk\flutter_windows_3.38.7-stable\flutter
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 3b62efc2a3 (6 months ago), 2026-01-13 13:47:42 -0800
• Engine revision 78fc3012e4
• Dart version 3.10.7
• DevTools version 2.51.1
• Pub download mirror https://pub.flutter-io.cn
• Flutter download mirror https://storage.flutter-io.cn
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging
[✓] Windows Version (11 家庭版 中文版 64-bit, 25H2, 2009) [5.2s]
[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [10.7s]
• Android SDK at C:\Users\jsmbz\AppData\Local\Android\Sdk
• Emulator version 36.3.10.0 (build_id 14472402) (CL:N/A)
• Platform android-36, build-tools 36.1.0
• ANDROID_HOME = C:\Users\jsmbz\AppData\Local\Android\Sdk
• Java binary at: D:\devtools\anrdstod\jbr\bin\java
This is the JDK bundled with the latest Android Studio installation on this machine.
To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
• Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72)
• All Android licenses accepted.
[✓] Chrome - develop for the web [17ms]
• Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe
[✗] Visual Studio - develop Windows apps [14ms]
✗ Visual Studio not installed; this is necessary to develop Windows apps.
Download at https://visualstudio.microsoft.com/downloads/.
Please install the "Desktop development with C++" workload, including all of its default components
[✓] Connected device (4 available) [6.3s]
• PKB110 (mobile) • 4TRKWOEE4HR8AMEA • android-arm64 • Android 16 (API 36)
• Windows (desktop) • windows • windows-x64 • Microsoft Windows [版本 10.0.26200.8655]
• Chrome (web) • chrome • web-javascript • Google Chrome 149.0.7827.116
• Edge (web) • edge • web-javascript • Microsoft Edge 150.0.4078.48
[✓] Network resources [948ms]
• All expected network resources are available.
! Doctor found issues in 1 category.
```

View File

@@ -1,106 +0,0 @@
Flutter crash report.
Please report a bug at https://github.com/flutter/flutter/issues.
## command
flutter --no-color run --machine --track-widget-creation --device-id=4TRKWOEE4HR8AMEA --start-paused --dart-define=flutter.inspector.structuredErrors=true --devtools-server-address=http://127.0.0.1:9100 lib\main.dart
## exception
FormatException: FormatException: Unexpected character (at character 1)
Failed to init NativeSymbolResolver (SymInitialize 3221225476)
^
```
#0 _ChunkedJsonParser.fail (dart:convert-patch/convert_patch.dart:1467:5)
#1 _ChunkedJsonParser.parseNumber (dart:convert-patch/convert_patch.dart:1333:9)
#2 _ChunkedJsonParser.parse (dart:convert-patch/convert_patch.dart:935:22)
#3 _JsonStringDecoderSink.addSlice (dart:convert-patch/convert_patch.dart:1555:13)
#4 _JsonStringDecoderSink.add (dart:convert-patch/convert_patch.dart:1560:5)
#5 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#6 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#7 _rootRunUnary (dart:async/zone.dart:1538:47)
#8 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#9 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#10 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#11 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#12 _SinkTransformerStreamSubscription._add (dart:async/stream_transformers.dart:67:11)
#13 _EventSinkWrapper.add (dart:async/stream_transformers.dart:13:11)
#14 _StringAdapterSink.add (dart:convert/string_conversion.dart:228:11)
#15 _StringAdapterSink.addSlice (dart:convert/string_conversion.dart:233:7)
#16 _Utf8ConversionSink.addSlice (dart:convert/string_conversion.dart:313:20)
#17 _Utf8ConversionSink.add (dart:convert/string_conversion.dart:306:5)
#18 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#19 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#20 _rootRunUnary (dart:async/zone.dart:1538:47)
#21 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#22 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#23 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#24 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#25 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#26 _StreamController._add (dart:async/stream_controller.dart:663:7)
#27 _StreamController.add (dart:async/stream_controller.dart:618:5)
#28 _Socket._onData (dart:io-patch/socket_patch.dart:2880:41)
#29 _rootRunUnary (dart:async/zone.dart:1546:13)
#30 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#31 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#32 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#33 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#34 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#35 _StreamController._add (dart:async/stream_controller.dart:663:7)
#36 _StreamController.add (dart:async/stream_controller.dart:618:5)
#37 new _RawSocket.<anonymous closure> (dart:io-patch/socket_patch.dart:2315:31)
#38 _NativeSocket.issueReadEvent.issue (dart:io-patch/socket_patch.dart:1655:14)
#39 _microtaskLoop (dart:async/schedule_microtask.dart:40:35)
#40 _startMicrotaskLoop (dart:async/schedule_microtask.dart:49:5)
#41 _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:127:13)
#42 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:194:5)
```
## flutter doctor
```
[✓] Flutter (Channel stable, 3.38.7, on Microsoft Windows [版本 10.0.26200.8875], locale zh-CN) [1,066ms]
• Flutter version 3.38.7 on channel stable at D:\fluttersdk\flutter_windows_3.38.7-stable\flutter
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 3b62efc2a3 (6 months ago), 2026-01-13 13:47:42 -0800
• Engine revision 78fc3012e4
• Dart version 3.10.7
• DevTools version 2.51.1
• Pub download mirror https://pub.flutter-io.cn
• Flutter download mirror https://storage.flutter-io.cn
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging
[✓] Windows Version (11 家庭版 中文版 64-bit, 25H2, 2009) [4.6s]
[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [9.6s]
• Android SDK at C:\Users\jsmbz\AppData\Local\Android\Sdk
• Emulator version 36.3.10.0 (build_id 14472402) (CL:N/A)
• Platform android-36, build-tools 36.1.0
• ANDROID_HOME = C:\Users\jsmbz\AppData\Local\Android\Sdk
• Java binary at: D:\devtools\anrdstod\jbr\bin\java
This is the JDK bundled with the latest Android Studio installation on this machine.
To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
• Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72)
• All Android licenses accepted.
[✓] Chrome - develop for the web [18ms]
• Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe
[✗] Visual Studio - develop Windows apps [15ms]
✗ Visual Studio not installed; this is necessary to develop Windows apps.
Download at https://visualstudio.microsoft.com/downloads/.
Please install the "Desktop development with C++" workload, including all of its default components
[✓] Connected device (4 available) [5.8s]
• PKB110 (mobile) • 4TRKWOEE4HR8AMEA • android-arm64 • Android 16 (API 36)
• Windows (desktop) • windows • windows-x64 • Microsoft Windows [版本 10.0.26200.8875]
• Chrome (web) • chrome • web-javascript • Google Chrome 149.0.7827.201
• Edge (web) • edge • web-javascript • Microsoft Edge 150.0.4078.65
[✓] Network resources [1,532ms]
• All expected network resources are available.
! Doctor found issues in 1 category.
```

View File

@@ -1,106 +0,0 @@
Flutter crash report.
Please report a bug at https://github.com/flutter/flutter/issues.
## command
flutter --no-color run --machine --track-widget-creation --device-id=4TRKWOEE4HR8AMEA --start-paused --dart-define=flutter.inspector.structuredErrors=true --devtools-server-address=http://127.0.0.1:9100 lib\main.dart
## exception
FormatException: FormatException: Unexpected character (at character 1)
Failed to init NativeSymbolResolver (SymInitialize 3221225476)
^
```
#0 _ChunkedJsonParser.fail (dart:convert-patch/convert_patch.dart:1467:5)
#1 _ChunkedJsonParser.parseNumber (dart:convert-patch/convert_patch.dart:1333:9)
#2 _ChunkedJsonParser.parse (dart:convert-patch/convert_patch.dart:935:22)
#3 _JsonStringDecoderSink.addSlice (dart:convert-patch/convert_patch.dart:1555:13)
#4 _JsonStringDecoderSink.add (dart:convert-patch/convert_patch.dart:1560:5)
#5 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#6 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#7 _rootRunUnary (dart:async/zone.dart:1538:47)
#8 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#9 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#10 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#11 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#12 _SinkTransformerStreamSubscription._add (dart:async/stream_transformers.dart:67:11)
#13 _EventSinkWrapper.add (dart:async/stream_transformers.dart:13:11)
#14 _StringAdapterSink.add (dart:convert/string_conversion.dart:228:11)
#15 _StringAdapterSink.addSlice (dart:convert/string_conversion.dart:233:7)
#16 _Utf8ConversionSink.addSlice (dart:convert/string_conversion.dart:313:20)
#17 _Utf8ConversionSink.add (dart:convert/string_conversion.dart:306:5)
#18 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#19 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#20 _rootRunUnary (dart:async/zone.dart:1538:47)
#21 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#22 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#23 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#24 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#25 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#26 _StreamController._add (dart:async/stream_controller.dart:663:7)
#27 _StreamController.add (dart:async/stream_controller.dart:618:5)
#28 _Socket._onData (dart:io-patch/socket_patch.dart:2880:41)
#29 _rootRunUnary (dart:async/zone.dart:1546:13)
#30 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#31 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#32 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#33 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#34 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#35 _StreamController._add (dart:async/stream_controller.dart:663:7)
#36 _StreamController.add (dart:async/stream_controller.dart:618:5)
#37 new _RawSocket.<anonymous closure> (dart:io-patch/socket_patch.dart:2315:31)
#38 _NativeSocket.issueReadEvent.issue (dart:io-patch/socket_patch.dart:1655:14)
#39 _microtaskLoop (dart:async/schedule_microtask.dart:40:35)
#40 _startMicrotaskLoop (dart:async/schedule_microtask.dart:49:5)
#41 _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:127:13)
#42 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:194:5)
```
## flutter doctor
```
[✓] Flutter (Channel stable, 3.38.7, on Microsoft Windows [版本 10.0.26200.8875], locale zh-CN) [1,220ms]
• Flutter version 3.38.7 on channel stable at D:\fluttersdk\flutter_windows_3.38.7-stable\flutter
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 3b62efc2a3 (7 months ago), 2026-01-13 13:47:42 -0800
• Engine revision 78fc3012e4
• Dart version 3.10.7
• DevTools version 2.51.1
• Pub download mirror https://pub.flutter-io.cn
• Flutter download mirror https://storage.flutter-io.cn
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging
[✓] Windows Version (11 家庭版 中文版 64-bit, 25H2, 2009) [5.5s]
[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [155.0s]
• Android SDK at C:\Users\jsmbz\AppData\Local\Android\Sdk
• Emulator version 36.3.10.0 (build_id 14472402) (CL:N/A)
• Platform android-36, build-tools 36.1.0
• ANDROID_HOME = C:\Users\jsmbz\AppData\Local\Android\Sdk
• Java binary at: D:\devtools\anrdstod\jbr\bin\java
This is the JDK bundled with the latest Android Studio installation on this machine.
To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
• Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72)
• All Android licenses accepted.
[✓] Chrome - develop for the web [13ms]
• Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe
[✗] Visual Studio - develop Windows apps [10ms]
✗ Visual Studio not installed; this is necessary to develop Windows apps.
Download at https://visualstudio.microsoft.com/downloads/.
Please install the "Desktop development with C++" workload, including all of its default components
[✓] Connected device (4 available) [6.3s]
• PKB110 (mobile) • 4TRKWOEE4HR8AMEA • android-arm64 • Android 16 (API 36)
• Windows (desktop) • windows • windows-x64 • Microsoft Windows [版本 10.0.26200.8875]
• Chrome (web) • chrome • web-javascript • Google Chrome 149.0.7827.201
• Edge (web) • edge • web-javascript • Microsoft Edge 150.0.4078.99
[✓] Network resources [2.5s]
• All expected network resources are available.
! Doctor found issues in 1 category.
```

View File

@@ -1,106 +0,0 @@
Flutter crash report.
Please report a bug at https://github.com/flutter/flutter/issues.
## command
flutter --no-color run --machine --track-widget-creation --device-id=4TRKWOEE4HR8AMEA --start-paused --dart-define=flutter.inspector.structuredErrors=true --devtools-server-address=http://127.0.0.1:9100 lib\main.dart
## exception
FormatException: FormatException: Unexpected character (at character 1)
Failed to init NativeSymbolResolver (SymInitialize 3221225476)
^
```
#0 _ChunkedJsonParser.fail (dart:convert-patch/convert_patch.dart:1467:5)
#1 _ChunkedJsonParser.parseNumber (dart:convert-patch/convert_patch.dart:1333:9)
#2 _ChunkedJsonParser.parse (dart:convert-patch/convert_patch.dart:935:22)
#3 _JsonStringDecoderSink.addSlice (dart:convert-patch/convert_patch.dart:1555:13)
#4 _JsonStringDecoderSink.add (dart:convert-patch/convert_patch.dart:1560:5)
#5 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#6 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#7 _rootRunUnary (dart:async/zone.dart:1538:47)
#8 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#9 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#10 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#11 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#12 _SinkTransformerStreamSubscription._add (dart:async/stream_transformers.dart:67:11)
#13 _EventSinkWrapper.add (dart:async/stream_transformers.dart:13:11)
#14 _StringAdapterSink.add (dart:convert/string_conversion.dart:228:11)
#15 _StringAdapterSink.addSlice (dart:convert/string_conversion.dart:233:7)
#16 _Utf8ConversionSink.addSlice (dart:convert/string_conversion.dart:313:20)
#17 _Utf8ConversionSink.add (dart:convert/string_conversion.dart:306:5)
#18 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#19 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#20 _rootRunUnary (dart:async/zone.dart:1538:47)
#21 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#22 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#23 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#24 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#25 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#26 _StreamController._add (dart:async/stream_controller.dart:663:7)
#27 _StreamController.add (dart:async/stream_controller.dart:618:5)
#28 _Socket._onData (dart:io-patch/socket_patch.dart:2880:41)
#29 _rootRunUnary (dart:async/zone.dart:1546:13)
#30 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#31 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#32 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#33 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#34 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#35 _StreamController._add (dart:async/stream_controller.dart:663:7)
#36 _StreamController.add (dart:async/stream_controller.dart:618:5)
#37 new _RawSocket.<anonymous closure> (dart:io-patch/socket_patch.dart:2315:31)
#38 _NativeSocket.issueReadEvent.issue (dart:io-patch/socket_patch.dart:1655:14)
#39 _microtaskLoop (dart:async/schedule_microtask.dart:40:35)
#40 _startMicrotaskLoop (dart:async/schedule_microtask.dart:49:5)
#41 _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:127:13)
#42 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:194:5)
```
## flutter doctor
```
[✓] Flutter (Channel stable, 3.38.7, on Microsoft Windows [版本 10.0.26200.8875], locale zh-CN) [926ms]
• Flutter version 3.38.7 on channel stable at D:\fluttersdk\flutter_windows_3.38.7-stable\flutter
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 3b62efc2a3 (7 months ago), 2026-01-13 13:47:42 -0800
• Engine revision 78fc3012e4
• Dart version 3.10.7
• DevTools version 2.51.1
• Pub download mirror https://pub.flutter-io.cn
• Flutter download mirror https://storage.flutter-io.cn
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging
[✓] Windows Version (11 家庭版 中文版 64-bit, 25H2, 2009) [4.9s]
[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [12.8s]
• Android SDK at C:\Users\jsmbz\AppData\Local\Android\Sdk
• Emulator version 36.3.10.0 (build_id 14472402) (CL:N/A)
• Platform android-36, build-tools 36.1.0
• ANDROID_HOME = C:\Users\jsmbz\AppData\Local\Android\Sdk
• Java binary at: D:\devtools\anrdstod\jbr\bin\java
This is the JDK bundled with the latest Android Studio installation on this machine.
To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
• Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72)
• All Android licenses accepted.
[✓] Chrome - develop for the web [14ms]
• Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe
[✗] Visual Studio - develop Windows apps [12ms]
✗ Visual Studio not installed; this is necessary to develop Windows apps.
Download at https://visualstudio.microsoft.com/downloads/.
Please install the "Desktop development with C++" workload, including all of its default components
[✓] Connected device (4 available) [6.5s]
• PKB110 (mobile) • 4TRKWOEE4HR8AMEA • android-arm64 • Android 16 (API 36)
• Windows (desktop) • windows • windows-x64 • Microsoft Windows [版本 10.0.26200.8875]
• Chrome (web) • chrome • web-javascript • Google Chrome 150.0.7871.187
• Edge (web) • edge • web-javascript • Microsoft Edge 150.0.4078.99
[!] Network resources [22.2s]
✗ An HTTP error occurred while checking "https://github.com/": 信号灯超时时间已到
! Doctor found issues in 2 categories.
```

View File

@@ -1,106 +0,0 @@
Flutter crash report.
Please report a bug at https://github.com/flutter/flutter/issues.
## command
flutter --no-color run --machine --track-widget-creation --device-id=4TRKWOEE4HR8AMEA --start-paused --dart-define=flutter.inspector.structuredErrors=true lib\main.dart
## exception
FormatException: FormatException: Unexpected character (at character 1)
Failed to init NativeSymbolResolver (SymInitialize 3221225476)
^
```
#0 _ChunkedJsonParser.fail (dart:convert-patch/convert_patch.dart:1467:5)
#1 _ChunkedJsonParser.parseNumber (dart:convert-patch/convert_patch.dart:1333:9)
#2 _ChunkedJsonParser.parse (dart:convert-patch/convert_patch.dart:935:22)
#3 _JsonStringDecoderSink.addSlice (dart:convert-patch/convert_patch.dart:1555:13)
#4 _JsonStringDecoderSink.add (dart:convert-patch/convert_patch.dart:1560:5)
#5 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#6 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#7 _rootRunUnary (dart:async/zone.dart:1538:47)
#8 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#9 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#10 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#11 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#12 _SinkTransformerStreamSubscription._add (dart:async/stream_transformers.dart:67:11)
#13 _EventSinkWrapper.add (dart:async/stream_transformers.dart:13:11)
#14 _StringAdapterSink.add (dart:convert/string_conversion.dart:228:11)
#15 _StringAdapterSink.addSlice (dart:convert/string_conversion.dart:233:7)
#16 _Utf8ConversionSink.addSlice (dart:convert/string_conversion.dart:313:20)
#17 _Utf8ConversionSink.add (dart:convert/string_conversion.dart:306:5)
#18 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#19 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#20 _rootRunUnary (dart:async/zone.dart:1538:47)
#21 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#22 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#23 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#24 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#25 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#26 _StreamController._add (dart:async/stream_controller.dart:663:7)
#27 _StreamController.add (dart:async/stream_controller.dart:618:5)
#28 _Socket._onData (dart:io-patch/socket_patch.dart:2880:41)
#29 _rootRunUnary (dart:async/zone.dart:1546:13)
#30 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#31 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#32 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#33 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#34 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#35 _StreamController._add (dart:async/stream_controller.dart:663:7)
#36 _StreamController.add (dart:async/stream_controller.dart:618:5)
#37 new _RawSocket.<anonymous closure> (dart:io-patch/socket_patch.dart:2315:31)
#38 _NativeSocket.issueReadEvent.issue (dart:io-patch/socket_patch.dart:1655:14)
#39 _microtaskLoop (dart:async/schedule_microtask.dart:40:35)
#40 _startMicrotaskLoop (dart:async/schedule_microtask.dart:49:5)
#41 _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:127:13)
#42 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:194:5)
```
## flutter doctor
```
[✓] Flutter (Channel stable, 3.38.7, on Microsoft Windows [版本 10.0.26200.8875], locale zh-CN) [4.0s]
• Flutter version 3.38.7 on channel stable at D:\fluttersdk\flutter_windows_3.38.7-stable\flutter
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 3b62efc2a3 (7 months ago), 2026-01-13 13:47:42 -0800
• Engine revision 78fc3012e4
• Dart version 3.10.7
• DevTools version 2.51.1
• Pub download mirror https://pub.flutter-io.cn
• Flutter download mirror https://storage.flutter-io.cn
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging
[✓] Windows Version (11 家庭版 中文版 64-bit, 25H2, 2009) [33.5s]
[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [29.8s]
• Android SDK at C:\Users\jsmbz\AppData\Local\Android\Sdk
• Emulator version 36.3.10.0 (build_id 14472402) (CL:N/A)
• Platform android-36, build-tools 36.1.0
• ANDROID_HOME = C:\Users\jsmbz\AppData\Local\Android\Sdk
• Java binary at: D:\devtools\anrdstod\jbr\bin\java
This is the JDK bundled with the latest Android Studio installation on this machine.
To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
• Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72)
• All Android licenses accepted.
[✓] Chrome - develop for the web [157ms]
• Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe
[✗] Visual Studio - develop Windows apps [138ms]
✗ Visual Studio not installed; this is necessary to develop Windows apps.
Download at https://visualstudio.microsoft.com/downloads/.
Please install the "Desktop development with C++" workload, including all of its default components
[✓] Connected device (4 available) [34.2s]
• PKB110 (mobile) • 4TRKWOEE4HR8AMEA • android-arm64 • Android 16 (API 36)
• Windows (desktop) • windows • windows-x64 • Microsoft Windows [版本 10.0.26200.8875]
• Chrome (web) • chrome • web-javascript • Google Chrome 151.0.7922.75
• Edge (web) • edge • web-javascript • Microsoft Edge 151.0.4129.59
[✓] Network resources [5.8s]
• All expected network resources are available.
! Doctor found issues in 1 category.
```

View File

@@ -1,6 +1,7 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
source 'https://github.com/volcengine/volcengine-specs.git'error: Classes can only extend other classes. (extends_non_class at [maibu_satabot_v2] lib\features\v2\device_list\domain\entities\video_stream_entity.dart:2)error: Classes can only extend other classes. (extends_non_class at [maibu_satabot_v2] lib\features\v2\device_list\domain\entities\video_stream_entity.dart:2)you
platform :ios, '13.0'
source 'https://github.com/volcengine/volcengine-specs.git'
source 'https://cdn.cocoapods.org/'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

@@ -1,7 +1,58 @@
PODS:
- agora_rtc_engine (6.5.3):
- AgoraIrisRTC_iOS (= 4.5.2-build.1)
- AgoraRtcEngine_iOS (= 4.5.2)
- Flutter
- AgoraInfra_iOS (1.2.13.1)
- AgoraIrisRTC_iOS (4.5.2-build.1)
- AgoraRtcEngine_iOS (4.5.2):
- AgoraRtcEngine_iOS/AIAEC (= 4.5.2)
- AgoraRtcEngine_iOS/AIAECLL (= 4.5.2)
- AgoraRtcEngine_iOS/AINS (= 4.5.2)
- AgoraRtcEngine_iOS/AINSLL (= 4.5.2)
- AgoraRtcEngine_iOS/AudioBeauty (= 4.5.2)
- AgoraRtcEngine_iOS/ClearVision (= 4.5.2)
- AgoraRtcEngine_iOS/ContentInspect (= 4.5.2)
- AgoraRtcEngine_iOS/FaceCapture (= 4.5.2)
- AgoraRtcEngine_iOS/FaceDetection (= 4.5.2)
- AgoraRtcEngine_iOS/LipSync (= 4.5.2)
- AgoraRtcEngine_iOS/ReplayKit (= 4.5.2)
- AgoraRtcEngine_iOS/RtcBasic (= 4.5.2)
- AgoraRtcEngine_iOS/SpatialAudio (= 4.5.2)
- AgoraRtcEngine_iOS/VideoAv1CodecDec (= 4.5.2)
- AgoraRtcEngine_iOS/VideoAv1CodecEnc (= 4.5.2)
- AgoraRtcEngine_iOS/VideoCodecDec (= 4.5.2)
- AgoraRtcEngine_iOS/VideoCodecEnc (= 4.5.2)
- AgoraRtcEngine_iOS/VirtualBackground (= 4.5.2)
- AgoraRtcEngine_iOS/VQA (= 4.5.2)
- AgoraRtcEngine_iOS/AIAEC (4.5.2)
- AgoraRtcEngine_iOS/AIAECLL (4.5.2)
- AgoraRtcEngine_iOS/AINS (4.5.2)
- AgoraRtcEngine_iOS/AINSLL (4.5.2)
- AgoraRtcEngine_iOS/AudioBeauty (4.5.2)
- AgoraRtcEngine_iOS/ClearVision (4.5.2)
- AgoraRtcEngine_iOS/ContentInspect (4.5.2)
- AgoraRtcEngine_iOS/FaceCapture (4.5.2)
- AgoraRtcEngine_iOS/FaceDetection (4.5.2)
- AgoraRtcEngine_iOS/LipSync (4.5.2)
- AgoraRtcEngine_iOS/ReplayKit (4.5.2)
- AgoraRtcEngine_iOS/RtcBasic (4.5.2):
- AgoraInfra_iOS (= 1.2.13.1)
- AgoraRtcEngine_iOS/SpatialAudio (4.5.2)
- AgoraRtcEngine_iOS/VideoAv1CodecDec (4.5.2)
- AgoraRtcEngine_iOS/VideoAv1CodecEnc (4.5.2)
- AgoraRtcEngine_iOS/VideoCodecDec (4.5.2)
- AgoraRtcEngine_iOS/VideoCodecEnc (4.5.2)
- AgoraRtcEngine_iOS/VirtualBackground (4.5.2)
- AgoraRtcEngine_iOS/VQA (4.5.2)
- connectivity_plus (0.0.1):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
- flutter_blue_plus_darwin (0.0.2):
- Flutter
- FlutterMacOS
- flutter_webrtc (1.4.0):
- Flutter
- WebRTC-SDK (= 144.7559.01)
@@ -44,6 +95,8 @@ PODS:
- GTMSessionFetcher/Core (2.3.0)
- image_picker_ios (0.0.1):
- Flutter
- iris_method_channel (0.0.1):
- Flutter
- isar_community_flutter_libs (1.0.0):
- Flutter
- MLImage (1.0.0-beta4)
@@ -72,6 +125,8 @@ PODS:
- nanopb/encode (= 2.30910.0)
- nanopb/decode (2.30910.0)
- nanopb/encode (2.30910.0)
- open_file_ios (1.0.3):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- permission_handler_apple (9.3.0):
@@ -89,29 +144,67 @@ PODS:
- Flutter
- vibration (3.0.0):
- Flutter
- video_player_avfoundation (0.0.1):
- Flutter
- FlutterMacOS
- volc_engine_rtc (3.60.4):
- Flutter
- VolcApiEngine (= 1.7.0)
- VolcEngineRTC (= 3.60.103.2100)
- VolcApiEngine (1.7.0)
- VolcEngineRTC (3.60.103.2100):
- VolcEngineRTC/BMF (= 3.60.103.2100)
- VolcEngineRTC/ByteRTCVideoDenoiseExtension (= 3.60.103.2100)
- VolcEngineRTC/ByteRTCVideoSharpenExtension (= 3.60.103.2100)
- VolcEngineRTC/ByteRTCVideoSRExtension (= 3.60.103.2100)
- VolcEngineRTC/Core (= 3.60.103.2100)
- VolcEngineRTC/CVByteNN (= 3.60.103.2100)
- VolcEngineRTC/RealXBase (= 3.60.103.2100)
- VolcEngineRTC/RTCFFmpeg (= 3.60.103.2100)
- VolcEngineRTC/BMF (3.60.103.2100)
- VolcEngineRTC/ByteRTCVideoDenoiseExtension (3.60.103.2100)
- VolcEngineRTC/ByteRTCVideoSharpenExtension (3.60.103.2100)
- VolcEngineRTC/ByteRTCVideoSRExtension (3.60.103.2100)
- VolcEngineRTC/Core (3.60.103.2100)
- VolcEngineRTC/CVByteNN (3.60.103.2100)
- VolcEngineRTC/RealXBase (3.60.103.2100)
- VolcEngineRTC/RTCFFmpeg (3.60.103.2100)
- WebRTC-SDK (144.7559.01)
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES:
- agora_rtc_engine (from `.symlinks/plugins/agora_rtc_engine/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`)
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- iris_method_channel (from `.symlinks/plugins/iris_method_channel/ios`)
- isar_community_flutter_libs (from `.symlinks/plugins/isar_community_flutter_libs/ios`)
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
- open_file_ios (from `.symlinks/plugins/open_file_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- vibration (from `.symlinks/plugins/vibration/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- volc_engine_rtc (from `.symlinks/plugins/volc_engine_rtc/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
SPEC REPOS:
https://github.com/volcengine/volcengine-specs.git:
- VolcApiEngine
- VolcEngineRTC
trunk:
- AgoraInfra_iOS
- AgoraIrisRTC_iOS
- AgoraRtcEngine_iOS
- GoogleDataTransport
- GoogleMLKit
- GoogleToolboxForMac
@@ -128,20 +221,30 @@ SPEC REPOS:
- WebRTC-SDK
EXTERNAL SOURCES:
agora_rtc_engine:
:path: ".symlinks/plugins/agora_rtc_engine/ios"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
Flutter:
:path: Flutter
flutter_blue_plus_darwin:
:path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
flutter_webrtc:
:path: ".symlinks/plugins/flutter_webrtc/ios"
geolocator_apple:
:path: ".symlinks/plugins/geolocator_apple/darwin"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
iris_method_channel:
:path: ".symlinks/plugins/iris_method_channel/ios"
isar_community_flutter_libs:
:path: ".symlinks/plugins/isar_community_flutter_libs/ios"
mobile_scanner:
:path: ".symlinks/plugins/mobile_scanner/ios"
open_file_ios:
:path: ".symlinks/plugins/open_file_ios/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
permission_handler_apple:
@@ -154,12 +257,22 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/url_launcher_ios/ios"
vibration:
:path: ".symlinks/plugins/vibration/ios"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
volc_engine_rtc:
:path: ".symlinks/plugins/volc_engine_rtc/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS:
agora_rtc_engine: 0c7d50312967c4dc31c3c45e50589ce48f57e08a
AgoraInfra_iOS: 3691b2b277a1712a35ae96de25af319de0d73d08
AgoraIrisRTC_iOS: eab58c126439adf5ec99632828a558ea216860da
AgoraRtcEngine_iOS: 97e2398a2addda9057815a2a583a658e36796ff6
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_blue_plus_darwin: ee2377ea51a6c9e481f2ed76ec597717ef5d287d
flutter_webrtc: ec91d94b484ad49cf191ef93413f64a40ffd3b4c
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
@@ -169,6 +282,7 @@ SPEC CHECKSUMS:
GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe
GTMSessionFetcher: 3a63d75eecd6aa32c2fc79f578064e1214dfdec2
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
iris_method_channel: 7d661cf3259b3009ae423508470dbeb9374446ee
isar_community_flutter_libs: bede843185a61a05ff364a05c9b23209523f7e0d
MLImage: 7bb7c4264164ade9bf64f679b40fb29c8f33ee9b
MLKitBarcodeScanning: 04e264482c5f3810cb89ebc134ef6b61e67db505
@@ -176,6 +290,7 @@ SPEC CHECKSUMS:
MLKitVision: 8baa5f46ee3352614169b85250574fde38c36f49
mobile_scanner: b67191637a5ea7ba15652ca208069d2bae1900ab
nanopb: 438bc412db1928dac798aa6fd75726007be04262
open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
@@ -184,9 +299,13 @@ SPEC CHECKSUMS:
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
volc_engine_rtc: 1bc697fba3c7a2f6513a18ec2abfb629188ce6ff
VolcApiEngine: e1f8b47ec5ef64bf6b48e3600be132c42643f8f0
VolcEngineRTC: 889b981b13621208b25942263f0fd5817c93b9bc
WebRTC-SDK: ab9b5319e458c2bfebdc92b3600740da35d5630d
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
PODFILE CHECKSUM: 2da7455b7ebb4d9567497e4f758b13986b3bb736
COCOAPODS: 1.16.2

View File

@@ -494,14 +494,14 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = R5KTW5WPS3;
DEVELOPMENT_TEAM = 93X3AN3P79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -519,7 +519,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -537,7 +537,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -553,7 +553,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -680,14 +680,14 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = R5KTW5WPS3;
DEVELOPMENT_TEAM = 93X3AN3P79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -706,14 +706,14 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = R5KTW5WPS3;
DEVELOPMENT_TEAM = 93X3AN3P79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";

View File

@@ -83,15 +83,10 @@
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
<key>NSCameraUsageDescription</key>
<string>需要使用相机</string>
<key>NSMicrophoneUsageDescription</key>
<string>需要使用麦克风</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<!-- 蓝牙权限 -->
<key>NSBluetoothAlwaysUsageDescription</key>
<string>需要使用蓝牙连接设备</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>需要使用蓝牙连接设备</string>
</dict>
</plist>

View File

@@ -0,0 +1,947 @@
import 'dart:async';
import 'package:agora_rtc_engine/agora_rtc_engine.dart' as agora;
import 'package:flutter/material.dart';
/// 声网视频播放状态
enum AgoraPlayerState {
/// 空闲:未连接或已主动断开
idle,
/// 连接中:初始化引擎 / 加入频道 / 等待远端首帧
connecting,
/// 播放中:已收到远端首帧
playing,
/// 失败:参数缺失、加入频道失败、超时或 SDK 报错
error,
}
/// 通用声网(Agora RTC)视频展示组件
///
/// 只需要 appId / channelId / token 三个值即可拉流播放,组件内部自行完成
/// 引擎创建、加入频道、远端首帧监听、渲染与资源释放,调用方不用管生命周期。
///
/// 两种用法:
///
/// 1)直接当普通 Widget 嵌进布局:
/// ```dart
/// AgoraVideoPlayer(appId: 'x', channelId: 'y', token: 'z')
/// ```
///
/// 2)一行代码弹出(推荐,关闭弹窗即自动释放引擎):
/// ```dart
/// onTap: () => AgoraVideoPlayer.show(
/// context,
/// appId: 'x',
/// channelId: 'y',
/// token: 'z',
/// ),
/// ```
class AgoraVideoPlayer extends StatefulWidget {
/// 声网 App ID
final String appId;
/// 频道名(后端接口里的 channel / room_id)
final String channelId;
/// 频道 Token,未开启鉴权时可传空串
final String token;
/// 本端 uid,纯观看传 0 由 SDK 自动分配
final int uid;
/// 频道模式,默认直播模式
final agora.ChannelProfileType channelProfile;
/// 本端角色,默认观众(只拉流不推流,不占用相机麦克风)
final agora.ClientRoleType clientRole;
/// 画面填充模式
final agora.RenderModeType renderMode;
/// 是否订阅音频,默认只订阅视频
final bool subscribeAudio;
/// Android 使用 SurfaceView 渲染(出现黑屏 / 层级遮挡时可切 true)
final bool useAndroidSurfaceView;
/// iOS、桌面端使用 Flutter Texture 渲染
final bool useFlutterTexture;
/// 是否在 initState 自动连接
final bool autoConnect;
/// 连接超时:超过该时长仍未收到远端首帧判定为失败
final Duration connectTimeout;
/// 状态变化回调
final ValueChanged<AgoraPlayerState>? onStateChanged;
/// 首帧解码成功回调,参数为远端 uid
final ValueChanged<int>? onFirstFrame;
/// 错误回调
final ValueChanged<String>? onError;
/// 视频区背景色
final Color backgroundColor;
/// 正在建连时的提示文案
final String loadingText;
/// 已进房但未收到画面时的提示文案
final String waitingText;
/// 失败时是否显示内置重试按钮
final bool showRetryButton;
/// 自定义失败态 UI,返回 null 则使用内置样式
final Widget Function(BuildContext context, String message)? errorBuilder;
/// 重连令牌:值变化即强制重新连接,即使 appId / channelId / token 完全相同
/// (供「重新加载」按钮使用)
final int reloadToken;
const AgoraVideoPlayer({
super.key,
required this.appId,
required this.channelId,
required this.token,
this.uid = 0,
this.channelProfile =
agora.ChannelProfileType.channelProfileLiveBroadcasting,
this.clientRole = agora.ClientRoleType.clientRoleAudience,
this.renderMode = agora.RenderModeType.renderModeFit,
this.subscribeAudio = false,
this.useAndroidSurfaceView = false,
this.useFlutterTexture = false,
this.autoConnect = true,
this.connectTimeout = const Duration(seconds: 15),
this.onStateChanged,
this.onFirstFrame,
this.onError,
this.backgroundColor = Colors.black,
this.loadingText = '视频加载中...',
this.waitingText = '等待视频流...',
this.showRetryButton = true,
this.errorBuilder,
this.reloadToken = 0,
});
/// 从后端返回的 `app_id=xx&channel=yy&token=zz&user_id=0` 形式参数串构建
factory AgoraVideoPlayer.fromUrl({
Key? key,
required String url,
agora.RenderModeType renderMode = agora.RenderModeType.renderModeFit,
Duration connectTimeout = const Duration(seconds: 15),
ValueChanged<AgoraPlayerState>? onStateChanged,
}) {
final p = parseParams(url);
return AgoraVideoPlayer(
key: key,
appId: p['app_id'] ?? p['appid'] ?? '',
channelId:
p['channel'] ?? p['channel_id'] ?? p['room_id'] ?? p['roomid'] ?? '',
token: p['token'] ?? '',
uid: int.tryParse(p['user_id'] ?? p['uid'] ?? '') ?? 0,
renderMode: renderMode,
connectTimeout: connectTimeout,
onStateChanged: onStateChanged,
);
}
/// 解析 `k=v&k=v` 参数串
static Map<String, String> parseParams(String url) {
final params = <String, String>{};
for (final pair in url.split('&')) {
final kv = pair.split('=');
if (kv.length == 2) {
params[kv[0].trim()] = Uri.decodeComponent(kv[1]);
}
}
return params;
}
/// 【核心入口】一行代码弹出居中视频弹窗(16:9)
///
/// 关闭弹窗时组件被移出 widget 树,自动触发 dispose →
/// leaveChannel + release,调用方不需要手动清理任何东西。
static Future<void> show(
BuildContext context, {
required String appId,
required String channelId,
required String token,
int uid = 0,
String title = '实时视频',
agora.RenderModeType renderMode = agora.RenderModeType.renderModeFit,
bool barrierDismissible = true,
ValueChanged<AgoraPlayerState>? onStateChanged,
}) {
return showDialog<void>(
context: context,
barrierDismissible: barrierDismissible,
barrierColor: Colors.black87,
builder: (dialogContext) => Dialog(
backgroundColor: Colors.black,
elevation: 0,
insetPadding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: 48,
padding: const EdgeInsets.only(left: 16),
color: const Color(0xFF2A2E34),
child: Row(
children: [
Expanded(
child: Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(
Icons.close,
color: Colors.white70,
size: 20,
),
onPressed: () => Navigator.pop(dialogContext),
),
],
),
),
AspectRatio(
aspectRatio: 16 / 9,
child: AgoraVideoPlayer(
appId: appId,
channelId: channelId,
token: token,
uid: uid,
renderMode: renderMode,
onStateChanged: onStateChanged,
),
),
],
),
),
),
);
}
/// 以底部弹层形式弹出(适合列表页预览,不遮挡整屏)
static Future<void> showBottomSheet(
BuildContext context, {
required String appId,
required String channelId,
required String token,
int uid = 0,
double heightFactor = 0.42,
agora.RenderModeType renderMode = agora.RenderModeType.renderModeFit,
ValueChanged<AgoraPlayerState>? onStateChanged,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.black,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (sheetContext) => SizedBox(
height: MediaQuery.of(sheetContext).size.height * heightFactor,
child: Column(
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(2),
),
),
Expanded(
child: AgoraVideoPlayer(
appId: appId,
channelId: channelId,
token: token,
uid: uid,
renderMode: renderMode,
onStateChanged: onStateChanged,
),
),
],
),
),
);
}
/// 【手动参数入口】弹出带 App ID / Channel / Token 三个输入框的弹窗
///
/// 填完点「重新加载」开始拉流;即使参数一模一样也会强制重连。
/// 适用于接口未就绪、需要手工验证频道参数的场景。
/// 关闭弹窗同样会自动释放引擎。
static Future<void> showManualInput(
BuildContext context, {
String title = '实时视频',
String initialAppId = '',
String initialChannelId = '',
String initialToken = '',
bool barrierDismissible = true,
}) {
return showDialog<void>(
context: context,
barrierDismissible: barrierDismissible,
barrierColor: Colors.black87,
builder: (_) => _AgoraManualParamsDialog(
title: title,
initialAppId: initialAppId,
initialChannelId: initialChannelId,
initialToken: initialToken,
),
);
}
@override
State<AgoraVideoPlayer> createState() => _AgoraVideoPlayerState();
}
class _AgoraVideoPlayerState extends State<AgoraVideoPlayer> {
agora.RtcEngine? _engine;
agora.RtcEngineEventHandler? _handler;
AgoraPlayerState _state = AgoraPlayerState.idle;
String? _errorMessage;
int? _remoteUid;
bool _isJoined = false;
bool _isDisposed = false;
bool _firstFrameNotified = false;
Timer? _connectTimer;
/// 连接序号:参数快速变化时用于丢弃过期的异步流程
int _connectSeq = 0;
@override
void initState() {
super.initState();
if (widget.autoConnect) {
_connect();
}
}
@override
void didUpdateWidget(covariant AgoraVideoPlayer oldWidget) {
super.didUpdateWidget(oldWidget);
final changed =
oldWidget.appId != widget.appId ||
oldWidget.channelId != widget.channelId ||
oldWidget.token != widget.token ||
oldWidget.uid != widget.uid ||
oldWidget.reloadToken != widget.reloadToken;
if (changed) {
_log('参数变化,重新连接: ${widget.channelId}');
_connect();
}
}
@override
void dispose() {
_isDisposed = true;
_cancelConnectTimer();
_release();
super.dispose();
}
void _log(String msg) => debugPrint('🎥 [Agora] $msg');
void _updateState(AgoraPlayerState next, {String? message}) {
if (_state == next && message == _errorMessage) return;
_state = next;
_errorMessage = message;
if (next == AgoraPlayerState.playing) {
_cancelConnectTimer();
}
if (mounted) {
setState(() {});
}
widget.onStateChanged?.call(next);
if (next == AgoraPlayerState.error && message != null) {
widget.onError?.call(message);
}
}
void _startConnectTimer() {
_cancelConnectTimer();
_connectTimer = Timer(widget.connectTimeout, () {
if (_isDisposed || _state == AgoraPlayerState.playing) return;
_log('⏰ 连接超时(${widget.connectTimeout.inSeconds}s 未收到首帧)');
_updateState(AgoraPlayerState.error, message: '视频连接超时,请重试');
});
}
void _cancelConnectTimer() {
_connectTimer?.cancel();
_connectTimer = null;
}
/// 建立连接:先释放旧引擎,再创建 → 初始化 → 注册回调 → 加入频道
Future<void> _connect() async {
final seq = ++_connectSeq;
_cancelConnectTimer();
await _release();
if (_isDisposed || seq != _connectSeq) return;
if (widget.appId.isEmpty || widget.channelId.isEmpty) {
_updateState(AgoraPlayerState.error, message: 'appId / channelId 不能为空');
return;
}
_remoteUid = null;
_isJoined = false;
_firstFrameNotified = false;
_updateState(AgoraPlayerState.connecting);
_startConnectTimer();
try {
final engine = agora.createAgoraRtcEngine();
_engine = engine;
await engine.initialize(
agora.RtcEngineContext(
appId: widget.appId,
channelProfile: widget.channelProfile,
),
);
if (_isDisposed || seq != _connectSeq) return;
await engine.enableVideo();
if (_isDisposed || seq != _connectSeq) return;
_handler = _buildEventHandler();
engine.registerEventHandler(_handler!);
_log('加入频道: ${widget.channelId}');
await engine.joinChannel(
token: widget.token,
channelId: widget.channelId,
uid: widget.uid,
options: agora.ChannelMediaOptions(
channelProfile: widget.channelProfile,
clientRoleType: widget.clientRole,
autoSubscribeVideo: true,
autoSubscribeAudio: widget.subscribeAudio,
),
);
if (_isDisposed || seq != _connectSeq) return;
_log('✅ joinChannel 调用成功,等待远端首帧...');
} catch (e) {
_log('❌ 初始化失败: $e');
_updateState(AgoraPlayerState.error, message: '视频连接失败: $e');
}
}
agora.RtcEngineEventHandler _buildEventHandler() {
return agora.RtcEngineEventHandler(
onJoinChannelSuccess: (agora.RtcConnection connection, int elapsed) {
_log('✅ 加入频道成功: ${connection.channelId}, 耗时 ${elapsed}ms');
if (!mounted) return;
setState(() => _isJoined = true);
},
onUserJoined: (agora.RtcConnection connection, int rUid, int elapsed) {
_log('👤 远端用户加入: uid=$rUid');
if (!mounted) return;
// VideoViewController.remote 内部断言 uid != 0,uid 为 0 时构建会崩
if (rUid == 0) {
_log('⚠️ 远端 uid=0,无法用 remote View 渲染,已忽略');
return;
}
setState(() => _remoteUid ??= rUid);
},
onUserOffline: (
agora.RtcConnection connection,
int rUid,
agora.UserOfflineReasonType reason,
) {
_log('👋 远端用户离开: uid=$rUid, reason=$reason');
if (!mounted || _remoteUid != rUid) return;
setState(() => _remoteUid = null);
// 推流端掉线,回到等待态并重新计时
_updateState(AgoraPlayerState.connecting);
_startConnectTimer();
},
onRemoteVideoStateChanged: (
agora.RtcConnection connection,
int rUid,
agora.RemoteVideoState state,
agora.RemoteVideoStateReason reason,
int elapsed,
) {
if (!mounted) return;
_log('📹 远端视频状态: uid=$rUid, state=$state, reason=$reason');
if (state == agora.RemoteVideoState.remoteVideoStateDecoding) {
if (rUid != 0 && _remoteUid == null) {
setState(() => _remoteUid = rUid);
}
_updateState(AgoraPlayerState.playing);
if (!_firstFrameNotified) {
_firstFrameNotified = true;
widget.onFirstFrame?.call(rUid);
}
} else if (state == agora.RemoteVideoState.remoteVideoStateFrozen) {
_updateState(AgoraPlayerState.connecting);
} else if (state == agora.RemoteVideoState.remoteVideoStateFailed) {
_updateState(AgoraPlayerState.error, message: '远端视频流异常: $reason');
}
},
onLeaveChannel: (agora.RtcConnection connection, agora.RtcStats stats) {
_log('已离开频道: ${connection.channelId}');
if (!mounted) return;
setState(() {
_isJoined = false;
_remoteUid = null;
});
},
onError: (agora.ErrorCodeType err, String msg) {
_log('❌ SDK 错误: $err - $msg');
_updateState(AgoraPlayerState.error, message: '声网错误($err): $msg');
},
);
}
Future<void> _release() async {
final engine = _engine;
final handler = _handler;
_engine = null;
_handler = null;
if (engine == null) return;
try {
if (handler != null) {
engine.unregisterEventHandler(handler);
}
await engine.leaveChannel();
await engine.release();
_log('🗑️ 引擎已释放');
} catch (e) {
_log('⚠️ 释放引擎失败: $e');
}
}
@override
Widget build(BuildContext context) {
// 关键:远端 uid 未知时绝不能构建 remote View(SDK 内部有 uid != 0 断言)
final canRender = _engine != null && _isJoined && _remoteUid != null;
return Container(
color: widget.backgroundColor,
child: Stack(
fit: StackFit.expand,
children: [
if (canRender) _buildVideoView(),
if (_state != AgoraPlayerState.playing) _buildOverlay(canRender),
],
),
);
}
Widget _buildVideoView() {
return agora.AgoraVideoView(
controller: agora.VideoViewController.remote(
rtcEngine: _engine!,
canvas: agora.VideoCanvas(
uid: _remoteUid!,
renderMode: widget.renderMode,
),
connection: agora.RtcConnection(channelId: widget.channelId),
useAndroidSurfaceView: widget.useAndroidSurfaceView,
useFlutterTexture: widget.useFlutterTexture,
),
);
}
Widget _buildOverlay(bool canRender) {
if (_state == AgoraPlayerState.error) {
final message = _errorMessage ?? '视频加载失败';
if (widget.errorBuilder != null) {
return widget.errorBuilder!(context, message);
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.redAccent),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
message,
style: const TextStyle(color: Colors.white70, fontSize: 13),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
if (widget.showRetryButton) ...[
const SizedBox(height: 16),
ElevatedButton(
onPressed: _connect,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF165DFF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 8,
),
),
child: const Text('重试'),
),
],
],
),
);
}
final text = canRender ? widget.waitingText : widget.loadingText;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white),
),
const SizedBox(height: 12),
Text(text, style: const TextStyle(color: Colors.white70, fontSize: 13)),
],
),
);
}
}
/// 手动填写声网参数的弹窗,由 [AgoraVideoPlayer.showManualInput] 弹出
///
/// 结构:标题栏 + 三个输入框 + 「重新加载」按钮 + 16:9 视频区。
/// 点「重新加载」会递增 reloadToken,即使参数未变也会强制断开重连。
class _AgoraManualParamsDialog extends StatefulWidget {
final String title;
final String initialAppId;
final String initialChannelId;
final String initialToken;
const _AgoraManualParamsDialog({
required this.title,
this.initialAppId = '',
this.initialChannelId = '',
this.initialToken = '',
});
@override
State<_AgoraManualParamsDialog> createState() =>
_AgoraManualParamsDialogState();
}
class _AgoraManualParamsDialogState extends State<_AgoraManualParamsDialog> {
static const Color _primary = Color(0xFF165DFF);
static const Color _danger = Color(0xFFF53F3F);
static const Color _success = Color(0xFF00B42A);
late final TextEditingController _appIdCtrl = TextEditingController(
text: widget.initialAppId,
);
late final TextEditingController _channelCtrl = TextEditingController(
text: widget.initialChannelId,
);
late final TextEditingController _tokenCtrl = TextEditingController(
text: widget.initialToken,
);
/// 已生效的参数(点「重新加载」才同步,输入过程中不触发重连)
String _appId = '';
String _channelId = '';
String _token = '';
/// 递增即强制重连
int _reloadToken = 0;
/// 是否已发起过加载(未发起时视频区只放占位文案)
bool _started = false;
String? _hint;
AgoraPlayerState _state = AgoraPlayerState.idle;
@override
void initState() {
super.initState();
_appId = widget.initialAppId.trim();
_channelId = widget.initialChannelId.trim();
_token = widget.initialToken.trim();
// 调用方已经把参数给齐时直接开播,不用手点
_started = _appId.isNotEmpty && _channelId.isNotEmpty;
}
@override
void dispose() {
_appIdCtrl.dispose();
_channelCtrl.dispose();
_tokenCtrl.dispose();
super.dispose();
}
void _reload() {
FocusScope.of(context).unfocus();
final appId = _appIdCtrl.text.trim();
final channelId = _channelCtrl.text.trim();
if (appId.isEmpty || channelId.isEmpty) {
setState(() {
_hint = 'App ID 与 Channel 不能为空';
_started = false;
});
return;
}
setState(() {
_appId = appId;
_channelId = channelId;
_token = _tokenCtrl.text.trim();
_reloadToken++;
_started = true;
_hint = null;
});
debugPrint('🎥 [Agora] 手动重新加载: channel=$_channelId, seq=$_reloadToken');
}
String get _stateText {
switch (_state) {
case AgoraPlayerState.idle:
return '未连接';
case AgoraPlayerState.connecting:
return '连接中…';
case AgoraPlayerState.playing:
return '播放中';
case AgoraPlayerState.error:
return '连接失败';
}
}
Color get _stateColor {
switch (_state) {
case AgoraPlayerState.idle:
return Colors.white38;
case AgoraPlayerState.connecting:
return Colors.amberAccent;
case AgoraPlayerState.playing:
return _success;
case AgoraPlayerState.error:
return _danger;
}
}
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.black,
elevation: 0,
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(),
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildInput(
controller: _appIdCtrl,
label: 'App ID',
hint: '声网控制台的应用 ID',
),
_buildInput(
controller: _channelCtrl,
label: 'Channel',
hint: '频道名 / room_id',
),
_buildInput(
controller: _tokenCtrl,
label: 'Token',
hint: '未开启鉴权可留空',
maxLines: 2,
),
if (_hint != null) ...[
const SizedBox(height: 2),
Text(
_hint!,
style: const TextStyle(color: _danger, fontSize: 12),
),
],
const SizedBox(height: 10),
ElevatedButton.icon(
onPressed: _reload,
icon: const Icon(Icons.refresh, size: 18),
label: const Text('重新加载'),
style: ElevatedButton.styleFrom(
backgroundColor: _primary,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
const SizedBox(height: 10),
_buildStatusBar(),
],
),
),
AspectRatio(aspectRatio: 16 / 9, child: _buildVideoArea()),
const SizedBox(height: 8),
],
),
),
),
);
}
Widget _buildHeader() {
return Container(
height: 48,
padding: const EdgeInsets.only(left: 16),
color: const Color(0xFF2A2E34),
child: Row(
children: [
Expanded(
child: Text(
widget.title,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white70, size: 20),
onPressed: () => Navigator.pop(context),
),
],
),
);
}
Widget _buildStatusBar() {
return Row(
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: _stateColor, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(
'状态:$_stateText',
style: TextStyle(color: _stateColor, fontSize: 12),
),
const SizedBox(width: 12),
Flexible(
child: Text(
_started ? 'channel: $_channelId' : '未发起连接',
style: const TextStyle(color: Colors.white24, fontSize: 11),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
Widget _buildVideoArea() {
if (!_started) {
return Container(
color: Colors.black,
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 24),
child: const Text(
'填写 App ID / Channel 后点「重新加载」',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white38, fontSize: 13),
),
);
}
return AgoraVideoPlayer(
appId: _appId,
channelId: _channelId,
token: _token,
reloadToken: _reloadToken,
onStateChanged: (s) {
if (!mounted) return;
setState(() => _state = s);
},
);
}
Widget _buildInput({
required TextEditingController controller,
required String label,
String? hint,
int maxLines = 1,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: TextField(
controller: controller,
maxLines: maxLines,
style: const TextStyle(color: Colors.white, fontSize: 13),
cursorColor: _primary,
decoration: InputDecoration(
labelText: label,
labelStyle: const TextStyle(color: Colors.white54, fontSize: 13),
hintText: hint,
hintStyle: const TextStyle(color: Colors.white24, fontSize: 12),
isDense: true,
filled: true,
fillColor: const Color(0xFF1A1D21),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Colors.white12),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Colors.white12),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: _primary),
),
),
),
);
}
}

View File

@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
/// 胶囊样式全局 Toast:体积小、大圆角、清爽,数秒后自动消失。
/// 通过根 Overlay 展示,不依赖调用方 BuildContext 和 Navigator,
/// 适用于页面销毁(dispose)后仍需弹出提示的场景。
class CapsuleToast {
static OverlayEntry? _entry;
static bool _showing = false;
/// 显示胶囊提示
/// [message] 提示文案
/// [duration] 显示时长,默认 2 秒
/// [showCheck] 是否显示对勾图标
static void show(
String message, {
Duration duration = const Duration(seconds: 2),
bool showCheck = true,
}) {
// 从根 Element 向下遍历子树,查找第一个 OverlayState
// (Overlay 是根的子节点,不能用 findAncestorStateOfType 向上找),
// 无需依赖调用方 context,页面销毁后也能正常弹出。
final root = WidgetsBinding.instance.rootElement;
if (root == null) return;
final overlay = _findOverlayState(root);
if (overlay == null) {
debugPrint('💊 [CapsuleToast] 未找到 Overlay,无法弹出: $message');
return;
}
debugPrint('💊 [CapsuleToast] 弹出: $message');
// 延到下一帧再插入:页面退出(dispose)瞬间 Overlay 可能正在重建,
// 直接插入可能随旧页面一起被销毁。
WidgetsBinding.instance.addPostFrameCallback((_) {
_insertEntry(message, overlay, duration: duration, showCheck: showCheck);
});
}
/// 真正插入 OverlayEntry
static void _insertEntry(
String message,
OverlayState overlay, {
required Duration duration,
required bool showCheck,
}) {
if (!overlay.mounted) return;
// 有新的提示时先移除旧的
if (_showing) {
_entry?.remove();
_showing = false;
}
_entry = OverlayEntry(
builder: (context) => IgnorePointer(
child: SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: Container(
margin: const EdgeInsets.only(top: 64),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xE62C2C2C),
borderRadius: BorderRadius.circular(100),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (showCheck) ...[
const Icon(
Icons.check_circle,
size: 14,
color: Color(0xFF4CD964),
),
const SizedBox(width: 6),
],
Text(
message,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
),
);
overlay.insert(_entry!);
_showing = true;
Future.delayed(duration, () {
if (_showing) {
_entry?.remove();
_showing = false;
}
});
}
/// 从指定 Element 开始深度优先遍历子树,找到第一个 OverlayState
static OverlayState? _findOverlayState(Element root) {
OverlayState? result;
void visitor(Element element) {
if (result != null) return;
if (element is StatefulElement && element.state is OverlayState) {
result = element.state as OverlayState;
return;
}
element.visitChildElements(visitor);
}
root.visitChildElements(visitor);
return result;
}
}

View File

@@ -14,6 +14,7 @@ import '../features/devices/presentation/bloc/device_status_event.dart';
import '../features/devices/presentation/bloc/devices_cubit.dart';
import '../features/remote_control/presentation/bloc/remote_control_cubit.dart';
import '../features/remote_control/presentation/bloc/remote_control_state.dart';
import '../core/logging/log_time.dart';
const int DATA_TIMEOUT_SECONDS = 15;
@@ -49,6 +50,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
@override
void initState() {
super.initState();
debugPrint('${LogTime.now()} 🖥️ [设备数据监控] 页面打开,开始展示当前订阅设备的数据');
_startDataTimeoutTimer();
// 🔥 页面初始化时自动调用刷新
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -64,6 +66,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
@override
void dispose() {
debugPrint('${LogTime.now()} 🚪 [设备数据监控] 页面关闭(MQTT订阅保持,后台继续收数据)');
_dataTimeoutTimer?.cancel();
super.dispose();
}
@@ -228,8 +231,8 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.target_speed') +
"\n(rpm)",
),
_buildTableCell(status.leftTargetSpeed.toStringAsFixed(2)),
_buildTableCell(status.rightTargetSpeed.toStringAsFixed(2)),
_buildTableCell('--'),
_buildTableCell('--'),
],
),
TableRow(
@@ -240,8 +243,8 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.measure_speed') +
"\n(rpm)",
),
_buildTableCell(status.leftMeasureSpeed.toStringAsFixed(2)),
_buildTableCell(status.rightMeasureSpeed.toStringAsFixed(2)),
_buildTableCell('--'),
_buildTableCell('--'),
],
),
TableRow(
@@ -252,8 +255,8 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.current') +
"(A)",
),
_buildTableCell(status.leftCurrent.toStringAsFixed(2)),
_buildTableCell(status.rightCurrent.toStringAsFixed(2)),
_buildTableCell('--'),
_buildTableCell('--'),
],
),
TableRow(
@@ -264,8 +267,8 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.motor_temp') +
"(°C)",
),
_buildTableCell(status.leftMotorTemp.toStringAsFixed(2)),
_buildTableCell(status.rightMotorTemp.toStringAsFixed(2)),
_buildTableCell('--'),
_buildTableCell('--'),
],
),
],
@@ -315,7 +318,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.pitch_angle') +
"(°)",
),
_buildTableCell(status.pitch.toStringAsFixed(2)),
_buildTableCell('--'),
],
),
TableRow(
@@ -326,7 +329,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.roll_angle') +
"(°)",
),
_buildTableCell(status.roll.toStringAsFixed(2)),
_buildTableCell('--'),
],
),
TableRow(
@@ -348,7 +351,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.battery') +
"(%)",
),
_buildTableCell(status.battery),
_buildTableCell('--'),
],
),
TableRow(
@@ -359,7 +362,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.chip_temp') +
"(°C)",
),
_buildTableCell(status.chipTemp.toStringAsFixed(2)),
_buildTableCell('--'),
],
),
TableRow(
@@ -370,7 +373,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.knife_speed') +
"(rpm)",
),
_buildTableCell(status.knifeCuttingSpeed),
_buildTableCell('--'),
],
),
TableRow(
@@ -380,11 +383,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
context,
).translate('running_status.control_mode'),
),
_buildTableCell(
AppLocalizations.of(context).translate(
_getControlModeKey(int.parse(status.controlMode)),
),
),
_buildTableCell('--'),
],
),
TableRow(
@@ -521,28 +520,13 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
// Status Bar
BlocBuilder<DeviceStatusBloc, DeviceStatusState>(
builder: (context, state) {
String qual = _isDataTimeout ? '--' : '--';
String satelliteCnt = _isDataTimeout ? '--' : '--';
String headingStatus = _isDataTimeout ? "--" : "--";
// 🔥 headingStatus/satelliteCnt 来自 realtime/post(尚未推送)
String satelliteCnt = '--';
String headingStatus = '--';
// 🔥 定位质量来自 location/post 的 fix_status
String qual = '--';
if (state is DeviceStatusUpdated) {
headingStatus = state.status.headingStatus == 0
? AppLocalizations.of(
context,
).translate('running_status.not_initialized')
: AppLocalizations.of(
context,
).translate('running_status.initialized');
int qualValue = 0;
try {
qualValue = int.parse(state.status.qual.toString());
} catch (e) {
qualValue = 0;
}
qual = AppLocalizations.of(
context,
).translate(_getLocationQualityKey(qualValue));
satelliteCnt = state.status.satelliteCnt.toString();
qual = _getLocationQualityText(state.status.qual);
}
return Container(
@@ -1170,15 +1154,20 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
);
}
String _getLocationQualityKey(int qualValue) {
/// 🔥 fix_status 枚举翻译(来自 MQTT location/post)
String _getLocationQualityText(int qualValue) {
switch (qualValue) {
case 0:
return 'running_status.invalid';
case 4:
case 5:
return 'running_status.valid';
default:
return 'common.unknown';
case 0: return '无定位';
case 1: return '单点定位';
case 2: return '差分定位';
case 3: return '惯性导航';
case 4: return 'RTK固定解';
case 5: return 'RTK浮点解';
case 6: return '单点(无航向)';
case 7: return '差分(无航向)';
case 8: return 'RTK固定(无航向)';
case 9: return 'RTK浮点(无航向)';
default: return '--';
}
}
}

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:developer' as developer;
import 'dart:io' show Platform;
import 'dart:typed_data';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:permission_handler/permission_handler.dart';
@@ -15,6 +16,9 @@ class BleManager {
BluetoothCharacteristic? _writeCharacteristic;
BluetoothCharacteristic? _readCharacteristic;
/// 🔥 已连接设备的名称(连接时从扫描结果或设备属性保存,断开时清空)
String? _connectedDeviceName;
/// 有状态的协议解析器(支持 BLE 分片)
final ProtocolParser _parser = ProtocolParser();
@@ -32,6 +36,7 @@ class BleManager {
StreamSubscription<List<int>>? _readSubscription;
StreamSubscription<BluetoothAdapterState>? _adapterStateSubscription;
StreamSubscription<BluetoothConnectionState>? _deviceConnectionSubscription;
StreamSubscription<int>? _mtuSubscription;
/// 防止异步竞态:stopScan() 后 in-flight 的 startScan() 不应生效
int _scanGen = 0;
@@ -50,11 +55,19 @@ class BleManager {
bool get isConnected => _connectedDevice != null;
BluetoothDevice? get connectedDevice => _connectedDevice;
BluetoothDevice? get connectingDevice => _connectingDevice;
/// 🔥 获取已连接设备的名称(连接时缓存,断开后为 null)
String? get connectedDeviceName => _connectedDeviceName;
Stream<List<ScanResult>> get scanResults => _scanController.stream;
Stream<BlePacket> get packetStream => _packetController.stream;
Stream<BluetoothAdapterState> get adapterState =>
FlutterBluePlus.adapterState;
/// 🔥 获取当前蓝牙扫描结果列表(供外部查询使用)
List<ScanResult> getScanResults() {
return _scanResults.values.toList();
}
/// 连接状态变化流:连接成功时发出 device,断开时发出 null
Stream<BluetoothDevice?> get connectionStream => _connectionController.stream;
@@ -67,47 +80,46 @@ class BleManager {
}
Future<bool> requestPermissions() async {
final connStatus = await Permission.bluetoothConnect.status;
developer.log(
'[BLE] bluetoothConnect status: $connStatus',
name: 'BleManager',
);
if (connStatus.isDenied) {
developer.log('[BLE] requesting bluetoothConnect...', name: 'BleManager');
await Permission.bluetoothConnect.request();
}
final scanStatus = await Permission.bluetoothScan.status;
developer.log(
'[BLE] bluetoothScan status: $scanStatus',
name: 'BleManager',
);
if (scanStatus.isDenied) {
developer.log('[BLE] requesting bluetoothScan...', name: 'BleManager');
await Permission.bluetoothScan.request();
}
final locStatus = await Permission.locationWhenInUse.status;
developer.log(
'[BLE] locationWhenInUse status: $locStatus',
name: 'BleManager',
);
if (locStatus.isDenied) {
if (Platform.isAndroid) {
final connStatus = await Permission.bluetoothConnect.status;
developer.log(
'[BLE] requesting locationWhenInUse...',
'[BLE] bluetoothConnect status: $connStatus',
name: 'BleManager',
);
await Permission.locationWhenInUse.request();
}
if (connStatus.isDenied) {
await Permission.bluetoothConnect.request();
}
final allGranted =
await Permission.bluetoothConnect.isGranted &&
await Permission.bluetoothScan.isGranted;
developer.log(
'[BLE] all permissions granted: $allGranted',
name: 'BleManager',
);
return allGranted;
final scanStatus = await Permission.bluetoothScan.status;
developer.log(
'[BLE] bluetoothScan status: $scanStatus',
name: 'BleManager',
);
if (scanStatus.isDenied) {
await Permission.bluetoothScan.request();
}
final locStatus = await Permission.locationWhenInUse.status;
developer.log(
'[BLE] locationWhenInUse status: $locStatus',
name: 'BleManager',
);
if (locStatus.isDenied) {
await Permission.locationWhenInUse.request();
}
final allGranted =
await Permission.bluetoothConnect.isGranted &&
await Permission.bluetoothScan.isGranted;
developer.log(
'[BLE] all permissions granted: $allGranted',
name: 'BleManager',
);
return allGranted;
}
// iOS: 蓝牙权限由 Info.plist 声明,系统弹窗授权,无需主动请求
developer.log('[BLE] iOS platform, skipping Android permissions', name: 'BleManager');
return true;
}
Future<void> openBluetooth() async {
@@ -168,15 +180,17 @@ class BleManager {
return;
}
final locEnabled = await _checkLocationService();
if (myGen != _scanGen) return;
if (!locEnabled) {
developer.log(
'[BLE] Location service OFF, directing to settings',
name: 'BleManager',
);
await _openLocationSettings();
return;
if (Platform.isAndroid) {
final locEnabled = await _checkLocationService();
if (myGen != _scanGen) return;
if (!locEnabled) {
developer.log(
'[BLE] Location service OFF, directing to settings',
name: 'BleManager',
);
await _openLocationSettings();
return;
}
}
_scanSubscription?.cancel();
@@ -238,6 +252,18 @@ class BleManager {
/// 连接设备,返回 null 表示成功,返回错误信息字符串表示失败
Future<String?> connect(BluetoothDevice device) async {
// 🔥 连接前先保存设备名(从扫描结果或设备属性中获取)
final scanResult = _scanResults[device.remoteId];
final advName = scanResult?.device.advName ?? '';
final platformName = device.platformName;
_connectedDeviceName = platformName.isNotEmpty
? platformName
: (advName.isNotEmpty ? advName : device.remoteId.toString());
developer.log(
'[BLE] 🔥 保存已连接设备名: $_connectedDeviceName',
name: 'BleManager',
);
// 标记正在连接,通知所有监听者
_connectingDevice = device;
_connectingController.add(device);
@@ -264,14 +290,8 @@ class BleManager {
});
_connectionController.add(device);
await _discoverServices(device);
// 确认协商后的 MTU
try {
final mtu = await device.requestMtu(512);
_negotiatedMtu = mtu;
developer.log('[BLE] MTU: $mtu', name: 'BleManager');
} catch (e) {
developer.log('[BLE] requestMtu failed: $e', name: 'BleManager');
}
// 确认协商后的 MTU(Android 主动请求;iOS 由 CoreBluetooth 自动协商)
await _setupMtu(device);
return null; // 成功
} catch (e) {
// 连接失败,清除连接中状态
@@ -285,14 +305,59 @@ class BleManager {
}
}
/// 平台差异化的 MTU 协商
/// - Android: 主动 requestMtu(512),一般协商到 247/517
/// - iOS: CoreBluetooth 自动协商,requestMtu 会抛异常;读 mtuNow 并订阅 mtu 流
Future<void> _setupMtu(BluetoothDevice device) async {
if (Platform.isAndroid) {
try {
final mtu = await device.requestMtu(512);
_negotiatedMtu = mtu;
developer.log('[BLE] Android MTU negotiated: $mtu', name: 'BleManager');
} catch (e) {
developer.log('[BLE] requestMtu failed: $e', name: 'BleManager');
}
return;
}
if (Platform.isIOS) {
// iOS 上 connect 完成后系统会异步协商 MTU,稍等一拍再读取实际值
try {
await Future.delayed(const Duration(milliseconds: 300));
final mtu = device.mtuNow;
// iOS 保底 185(现代 iPhone CoreBluetooth 常见协商值),避免默认 23 把长帧切成 9 片
_negotiatedMtu = mtu > 23 ? mtu : 185;
developer.log(
'[BLE] iOS MTU (auto-negotiated): mtuNow=$mtu, using=$_negotiatedMtu',
name: 'BleManager',
);
// 订阅后续 MTU 变化(部分外设会在服务发现后再次协商)
_mtuSubscription?.cancel();
_mtuSubscription = device.mtu.listen((m) {
if (m > 23 && m != _negotiatedMtu) {
_negotiatedMtu = m;
developer.log('[BLE] iOS MTU updated: $m', name: 'BleManager');
}
});
} catch (e) {
_negotiatedMtu = 185;
developer.log(
'[BLE] iOS MTU setup failed, fallback to 185: $e',
name: 'BleManager',
);
}
}
}
void _onDeviceDisconnected() {
_connectedDevice = null;
_connectingDevice = null;
_connectedDeviceName = null; // 🔥 清空已连接设备名
_writeCharacteristic = null;
_readCharacteristic = null;
_negotiatedMtu = 23;
_readSubscription?.cancel();
_deviceConnectionSubscription?.cancel();
_mtuSubscription?.cancel();
_connectionController.add(null);
_connectingController.add(null);
// 清空协议解析器缓冲区
@@ -405,7 +470,7 @@ class BleManager {
final totalChunks = (frame.length + maxWriteLen - 1) ~/ maxWriteLen;
if (totalChunks > 1) {
developer.log(
'[BLE] 🔀 分${totalChunks}片写入 (MTU=$_negotiatedMtu, 每片≤${maxWriteLen}B)',
'[BLE] 🔀 分${totalChunks}片写入 (platform=${Platform.isIOS ? "iOS" : "Android"}, MTU=$_negotiatedMtu, 每片≤${maxWriteLen}B)',
name: 'BleManager',
);
}
@@ -422,6 +487,13 @@ class BleManager {
);
}
await char.write(chunk, withoutResponse: false);
// iOS 分片间加小延时,给 MCU 侧协议解析器留出帧间处理时间,
// 避免长帧(如 0x06 写配置 176B)被拆多片时因间隔过短触发丢帧
if (Platform.isIOS &&
totalChunks > 1 &&
offset + maxWriteLen < frame.length) {
await Future.delayed(const Duration(milliseconds: 20));
}
}
developer.log('[BLE] ✅ 写入完成', name: 'BleManager');
}
@@ -440,6 +512,7 @@ class BleManager {
_readSubscription?.cancel();
_adapterStateSubscription?.cancel();
_deviceConnectionSubscription?.cancel();
_mtuSubscription?.cancel();
_scanController.close();
_packetController.close();
_connectionController.close();

View File

@@ -68,27 +68,38 @@ class BleProtocolDecoder {
static BleDecodedResult _decodeStatusInfo(Uint8List data, String hex) {
final fields = <BleField>[];
// 🔥 关键修复:协议帧为 AB AA 02 [24个逗号字段] CRC_lo CRC_hi AA AB。
// ProtocolParser 只剥离了帧尾 AA AB,payload 末尾仍带 2 字节 CRC16(小端)。
// 障碍物标志位是最后一个字段(fields[23]),若不先去掉 CRC,
// 这 2 字节会粘到障碍物字段上导致乱码。此处先剥离尾部 2 字节 CRC。
final Uint8List body =
data.length > 2 ? Uint8List.sublistView(data, 0, data.length - 2) : data;
try {
final text = utf8.decode(data);
// allowMalformed 兜底:非法字节不再抛异常、不再落入 latin1 造成整段乱码
var text = utf8.decode(body, allowMalformed: true);
// 去掉控制字符(\0 \r \n 等)与替换符(U+FFFD):固件常在串尾补 \0,
// 或残留 CRC 被解码成非法字符,粘在最后一个字段(障碍物)上造成乱码
text = text.replaceAll(RegExp(r'[\u0000-\u001F\uFFFD]'), '');
if (text.contains(',')) {
final parts = text.split(',');
return BleDecodedResult(fields: _parseStatusFields(parts), rawHex: hex);
}
} catch (e) {
try {
final text = latin1.decode(data);
if (text.contains(',')) {
final parts = text.split(',');
return BleDecodedResult(
fields: _parseStatusFields(parts),
rawHex: hex,
// 诊断:仅当控制模式槽位(parts[20])出现协议外码(如6)时,打印全字段索引对齐,
// 用于判断是固件扩展码还是字段错位;正常包不打印,避免刷屏
if (parts.length > 20 &&
!const ['0', '1', '2', '3', '4'].contains(parts[20].trim())) {
developer.log(
'[BLE-0x02] 字段对齐诊断: ' +
List.generate(parts.length, (i) => '[$i]=${parts[i]}')
.join(' '),
name: 'BleProtocolDecoder',
);
}
} catch (_) {}
}
return BleDecodedResult(fields: _parseStatusFields(parts), rawHex: hex);
}
} catch (_) {}
if (data.isNotEmpty) {
final status = data[0];
if (body.isNotEmpty) {
final status = body[0];
fields.add(
BleField(
label: '状态码',
@@ -96,8 +107,8 @@ class BleProtocolDecoder {
),
);
}
if (data.length >= 2) {
final mode = data[1];
if (body.length >= 2) {
final mode = body[1];
const modes = {0x00: '待机', 0x01: '遥控', 0x02: '自动', 0x03: '急停'};
fields.add(
BleField(
@@ -106,10 +117,10 @@ class BleProtocolDecoder {
),
);
}
if (data.length >= 3)
fields.add(BleField(label: '电量', value: '${data[2]}%'));
if (data.length >= 4) {
final fault = data[3];
if (body.length >= 3)
fields.add(BleField(label: '电量', value: '${body[2]}%'));
if (body.length >= 4) {
final fault = body[3];
fields.add(
BleField(
label: '故障状态',
@@ -119,13 +130,13 @@ class BleProtocolDecoder {
),
);
}
if (data.length >= 6) {
final speed = (data[5] << 8) | data[4];
if (body.length >= 6) {
final speed = (body[5] << 8) | body[4];
fields.add(BleField(label: '速度', value: '$speed'));
}
if (data.length > 8) {
if (body.length > 8) {
try {
final text = String.fromCharCodes(data.sublist(8));
final text = String.fromCharCodes(body.sublist(8));
if (text.isNotEmpty && !text.contains('\x00')) {
fields.add(BleField(label: '附加', value: text));
}
@@ -141,24 +152,32 @@ class BleProtocolDecoder {
parts.add('');
}
// 协议定义 控制模式: 0无控制 1本地遥控 2蓝牙 3TCP 4其他接口;
// 先按整数解析(容忍前后空格),有映射的一律显示对应文字;
// 超出协议范围的码(如固件扩展值)显示为 未知(n),避免被误当成有效模式
String controlModeText(String v) {
return switch (v) {
'0' => '待机',
'1' => '遥控',
'2' => '自动',
'3' => '急停',
_ => v.isNotEmpty ? v : '--',
final n = int.tryParse(v.trim());
if (n == null) return v.isEmpty ? '--' : v;
return switch (n) {
0 => '无控制',
1 => '本地遥控',
2 => '蓝牙',
3 => 'TCP',
4 => '其他接口',
_ => '未知($n)',
};
}
// 协议定义 定位质量: 0无效 1GPS单点 2DGPS差分/SBAS 4RTK固定解 5RTK浮点解 7手动输入
String qualText(String v) {
final q = int.tryParse(v) ?? -1;
return switch (q) {
0 => '无效',
1 => '单点定位',
2 => '差分定位',
4 => '固定解',
5 => '浮点解',
1 => 'GPS 单点定位',
2 => 'DGPS 差分/SBAS',
4 => 'RTK 固定解',
5 => 'RTK 浮点解',
7 => '手动输入模式',
_ => v.isNotEmpty ? v : '--',
};
}
@@ -173,11 +192,14 @@ class BleProtocolDecoder {
}
String obstacleText(String v) {
final o = int.tryParse(v) ?? -1;
return switch (o) {
// 障碍物标志位为单个数字 0/1;字段尾部可能粘有 CRC/\0 等杂质,
// 取第一个数字字符作为标志,无数字则显示 '--',杜绝杂质乱码
final d = RegExp(r'[0-9]').firstMatch(v);
if (d == null) return '--';
return switch (int.parse(d.group(0)!)) {
0 => '无障碍',
1 => '有障碍',
_ => v.isNotEmpty ? v : '--',
_ => v,
};
}

View File

@@ -1,118 +1,129 @@
import '../env/env_config.dart';
class HttpApiConsts {
// static const String baseUrl = "http://8.159.134.0:8012"; // 旧地址
// static const String baseUrl = "http://1.95.137.212:59015"; // 测试地址
static const String baseUrl = "http://1.95.137.212:8081"; // 生产地址
static String get baseUrl => EnvConfig.baseUrl;
/// 账号相关
// 登录
static const String loginUrl = "$baseUrl/login";
static String get loginUrl => "$baseUrl/login";
///设备相关
// 获取设备列表
static const String getUserDevicesList = "$baseUrl/iot/device/list";
static String get getUserDevicesList => "$baseUrl/iot/device/list";
// 绑定设备
static const String bindDevice = "$baseUrl/iot/device/bind";
static String get bindDevice => "$baseUrl/iot/device/bind";
// 解绑设备
static const String unbindDevice = "$baseUrl/forward/device/unbind";
static String get unbindDevice => "$baseUrl/forward/device/unbind";
// 切换设备
static const String switchDevice = "$baseUrl/forward/device/switchDevice";
static String get switchDevice => "$baseUrl/forward/device/switchDevice";
// 获取光伏电站列表
static const String getSiteList = "$baseUrl/system/site/selectByUserId";
static String get getSiteList => "$baseUrl/system/site/selectByUserId";
// 获取场站下的设备列表
static const String getSiteDeviceList = "$baseUrl/iot/device/getSiteList";
static String get getSiteDeviceList => "$baseUrl/iot/device/getSiteList";
// 获取场站下的无人机机场列表
static const String getSiteUAVList = "$baseUrl/iot/UAV/getSiteUAVList";
static String get getSiteUAVList => "$baseUrl/iot/UAV/getSiteUAVList";
// 获取UAV状态详情
static const String getUAVState = "$baseUrl/iot/UAV/getUAVState";
static String get getUAVState => "$baseUrl/iot/UAV/getUAVState";
// 切换摄像头获取视频流
static const String changeCamera = "$baseUrl/iot/UAV/changeCamera";
static String get changeCamera => "$baseUrl/iot/UAV/changeCamera";
// 获取设备位置
static const String getDeviceLocation = "$baseUrl/iot/device/userDevice";
static String get getDeviceLocation => "$baseUrl/iot/device/userDevice";
// 获取全部设备列表(用于蓝牙绑定匹配)
static String get getDeviceList => "$baseUrl/iot/device/getDeviceList";
// 根据拆分码(蓝牙设备名/时间戳)精确查询设备(用于蓝牙绑定匹配)
static String get getDeviceBySpiltCode =>
"$baseUrl/iot/device/getDeviceBySpiltCode";
// 获取机器人列表
static const String getRobotList = "$baseUrl/iot/device/getSiteList";
static String get getRobotList => "$baseUrl/iot/device/getSiteList";
// 获取飞行任务列表
static const String getFlightTask = "$baseUrl/iot/UAV/getFlightTask";
static String get getFlightTask => "$baseUrl/iot/UAV/getFlightTask";
// 获取飞行任务详情
static const String getFlightTaskDetail =
static String get getFlightTaskDetail =>
"$baseUrl/iot/UAV/getFlightTaskDetail";
// 获取航线列表
static const String getWayline = "$baseUrl/iot/UAV/getWayline";
static String get getWayline => "$baseUrl/iot/UAV/getWayline";
// 创建飞行任务
static const String createFlightTask = "$baseUrl/iot/UAV/createFlightTask";
static String get createFlightTask => "$baseUrl/iot/UAV/createFlightTask";
// 更新飞行任务状态
static const String updateFlightTaskStatus =
static String get updateFlightTaskStatus =>
"$baseUrl/iot/UAV/updateFlightTaskStatus";
// 切换无人机镜头获取视频流
static const String changeUAVLens = "$baseUrl/iot/UAV/changeLens";
static String get changeUAVLens => "$baseUrl/iot/UAV/changeLens";
// 获取无人机详情
static const String getUAVDetail = "$baseUrl/iot/UAV/getUAVDetail";
static String get getUAVDetail => "$baseUrl/iot/UAV/getUAVDetail";
// 飞行任务命令控制(暂停、返航等)
static const String flightTaskCommand = "$baseUrl/iot/UAV/flightTaskCommand";
static String get flightTaskCommand => "$baseUrl/iot/UAV/flightTaskCommand";
/// 告警相关
static const String alarmBaseUrl = "http://1.95.137.212:8081";
static String get alarmBaseUrl => baseUrl;
// 获取告警工单配置列表
static const String alarmOrderConfigList =
static String get alarmOrderConfigList =>
"$alarmBaseUrl/iot/alarmOrderConfig/list";
// 获取告警列表
static const String alarmList = "$baseUrl/iot/alarm/list";
static String get alarmList => "$baseUrl/iot/alarm/list";
// 获取告警详情
static const String alarmDetail = "$baseUrl/iot/alarm";
static String get alarmDetail => "$baseUrl/iot/alarm";
// 处理告警(确认/关闭等)
static const String alarmHandle = "$baseUrl/iot/alarm/handle";
static String get alarmHandle => "$baseUrl/iot/alarm/handle";
/// 工单相关
// 获取工单模型配置列表
static const String orderModelList = "$baseUrl/iot/orderModel/list";
static String get orderModelList => "$baseUrl/iot/orderModel/list";
// 添加工单(上报)
static const String workOrderAdd = "$baseUrl/iot/ioTworkOrder/add";
static String get workOrderAdd => "$baseUrl/iot/ioTworkOrder/add";
// 获取工单列表
static const String workOrderList = "$baseUrl/iot/ioTworkOrder/list";
static String get workOrderList => "$baseUrl/iot/ioTworkOrder/list";
// 获取工单详情
static const String workOrderDetail = "$baseUrl/iot/ioTworkOrder";
static String get workOrderDetail => "$baseUrl/iot/ioTworkOrder";
// 派发工单
static const String workOrderDispat = "$baseUrl/iot/ioTworkOrder/dispatch";
static String get workOrderDispat => "$baseUrl/iot/ioTworkOrder/dispatch";
// 挂起工单
static const String workOrderSuspend = "$baseUrl/iot/ioTworkOrder/suspend";
static String get workOrderSuspend => "$baseUrl/iot/ioTworkOrder/suspend";
// 完成工单
static const String workOrderComplete = "$baseUrl/iot/ioTworkOrder/complete";
static String get workOrderComplete => "$baseUrl/iot/ioTworkOrder/complete";
// 开始执行工单
static const String workOrderStart = "$baseUrl/iot/ioTworkOrder/start";
static String get workOrderStart => "$baseUrl/iot/ioTworkOrder/start";
// 获取工单统计
static const String workOrderCount = "$baseUrl/iot/ioTworkOrder/count";
static String get workOrderCount => "$baseUrl/iot/ioTworkOrder/count";
// 设备运行参数查询
static const String deviceRunParamSelect =
static String get deviceRunParamSelect =>
"$baseUrl/iot/deviceRunParam/selectByDeviceId";
// 设备运行参数保存
static const String deviceRunParamSave = "$baseUrl/iot/deviceRunParam/save";
static String get deviceRunParamSave => "$baseUrl/iot/deviceRunParam/save";
// 设备操作权限校验
static const String hasPermission = "$baseUrl/iot/device/hasPermission";
static String get hasPermission => "$baseUrl/iot/device/hasPermission";
// 释放远程控制权限(请求体:platform/type/deviceId/token)
static String get releaseControlUrl =>
"$baseUrl/forward/device/releaseControl";
/// 用户相关
// 获取用户列表
static const String systemUserList = "$baseUrl/system/user/list";
static String get systemUserList => "$baseUrl/system/user/list";
/// 组织与场站相关
// 获取组织列表
static const String orgList = "$baseUrl/system/org/list";
static String get orgList => "$baseUrl/system/org/list";
// 根据组织ID获取场站列表
static const String siteListByOrgId = "$baseUrl/system/site/selectByOrgId";
static String get siteListByOrgId => "$baseUrl/system/site/selectByOrgId";
// 根据场站ID获取用户列表
static const String userListBySiteId = "$baseUrl/system/user/list";
static String get userListBySiteId => "$baseUrl/system/user/list";
}

View File

@@ -78,6 +78,8 @@ import '../../features/v2/device_list/domain/repositories/device_repository.dart
as device_v2_domain;
import '../../features/v2/device_list/domain/usecases/get_device_status_data_usecase.dart'
as device_v2_usecase;
import '../../features/v2/device_list/domain/usecases/get_all_devices_usecase.dart';
import '../../features/v2/device_list/domain/usecases/get_device_by_spilt_code_usecase.dart';
import '../../features/v2/device_list/presentation/bloc/device_status_bloc.dart'
as device_v2_bloc;
import '../../features/v2/device_list/data/datasources/drone_station_datasource.dart';
@@ -151,10 +153,13 @@ import '../network/mqtt/domain/interfaces/mqtt_client.dart';
import '../network/mqtt/data/infrastructure/mqtt_client_impl.dart';
import '../network/mqtt/data/datasources/drone_osd_datasource.dart';
import '../network/mqtt/data/datasources/task_message_datasource.dart';
import '../network/mqtt/data/datasources/mower_realtime_datasource.dart';
import '../network/mqtt/domain/repositories/drone_osd_repository.dart';
import '../network/mqtt/domain/repositories/task_message_repository.dart';
import '../network/mqtt/domain/repositories/mower_realtime_repository.dart';
import '../network/mqtt/data/repositories/drone_osd_repository_impl.dart';
import '../network/mqtt/data/repositories/task_message_repository_impl.dart';
import '../network/mqtt/data/repositories/mower_realtime_repository_impl.dart';
import '../router/app_router.dart';
import '../storage/impl/user_storage_impl.dart';
import '../storage/user_storage.dart';
@@ -177,15 +182,20 @@ Future<void> init() async {
);
sl.registerLazySingleton(() => PathPlanningService());
/// 1.1.3 MQTT Clients - 两个独立的 MQTT 客户端实例
/// 1.1.3 MQTT Clients - 三个独立的 MQTT 客户端实例
sl.registerLazySingleton<MqttClient>(
() => MqttClientImpl(),
() => MqttClientImpl(clientName: 'droneOsdClient'),
instanceName: 'droneOsdClient',
);
sl.registerLazySingleton<MqttClient>(
() => MqttClientImpl(),
() => MqttClientImpl(clientName: 'taskMessageClient'),
instanceName: 'taskMessageClient',
);
// 🔥 割草机机器状态专用 MQTT 客户端(1.95.137.212:59007)
sl.registerLazySingleton<MqttClient>(
() => MqttClientImpl(clientName: 'mowerRealtimeClient'),
instanceName: 'mowerRealtimeClient',
);
/// 1.1.4 NetMessageDispatcher:消息调度器,并将 TcpClient 注入给它
sl.registerLazySingleton(
@@ -228,6 +238,14 @@ Future<void> init() async {
),
);
/// 割草机机器状态(MQTT):车辆实时+定位,LazySingleton 跨页面共享同一订阅
/// 🔥 使用独立 MQTT 连接 mowerRealtimeClient(1.95.137.212:59007)
sl.registerLazySingleton<MowerRealtimeDataSource>(
() => MowerRealtimeDataSourceImpl(
sl<MqttClient>(instanceName: 'mowerRealtimeClient'),
),
);
/// 1.5 --- MQTT Repositories ---
sl.registerLazySingleton<DroneOsdRepository>(
() => DroneOsdRepositoryImpl(sl<DroneOsdDataSource>()),
@@ -235,6 +253,9 @@ Future<void> init() async {
sl.registerLazySingleton<TaskMessageRepository>(
() => TaskMessageRepositoryImpl(sl<TaskMessageDataSource>()),
);
sl.registerLazySingleton<MowerRealtimeRepository>(
() => MowerRealtimeRepositoryImpl(sl<MowerRealtimeDataSource>()),
);
/// 2. 数据源 (DataSource)
sl.registerLazySingleton<AuthHttpDataSource>(
@@ -357,6 +378,12 @@ Future<void> init() async {
sl.registerLazySingleton<device_v2_usecase.GetDeviceStatusDataUseCase>(
() => device_v2_usecase.GetDeviceStatusDataUseCase(repository: sl()),
);
sl.registerLazySingleton<GetAllDevicesUseCase>(
() => GetAllDevicesUseCase(repository: sl()),
);
sl.registerLazySingleton<GetDeviceBySpiltCodeUseCase>(
() => GetDeviceBySpiltCodeUseCase(repository: sl()),
);
sl.registerFactory<device_v2_bloc.DeviceStatusBloc>(
() => device_v2_bloc.DeviceStatusBloc(sl()),
);

View File

@@ -14,14 +14,18 @@ class EnvConfig {
static bool get isProduction => environment == 'prod';
/// TCP 服务器配置
static String get tcpIp => '1.95.137.212';
static int get tcpPort {
// 测试服: 59016, 生产服: 9001
if (environment == 'prod') {
return 9001;
}
return 9001; // TCP 端口
}
// ─── 服务器 IP(测试/生产共用同一台机器) ───
static const String serverHost = '1.95.137.212';
// ─── TCP 控制长连接 ───
static String get tcpIp => serverHost;
static int get tcpPort => isProduction ? 9001 : 59004;
// ─── HTTP 接口 ───
static int get httpPort => isProduction ? 8081 : 59003;
static String get baseUrl => 'http://$serverHost:$httpPort';
// ─── MQTT(一方后端:任务消息 / 割草机实时状态,prod=1883 / test=59007)───
// 注:第三方无人机 OSD(droneOsd,WebSocket 8083)不纳入环境管理,保持硬编码
static int get mqttPort => isProduction ? 1883 : 59007;
}

View File

@@ -0,0 +1,14 @@
/// 链路日志统一时间戳工具
///
/// 用于打印"什么时间做了什么"的全链路日志,格式:[HH:mm:ss.SSS]
class LogTime {
LogTime._();
static String now() {
final now = DateTime.now();
return '[${now.hour.toString().padLeft(2, '0')}:'
'${now.minute.toString().padLeft(2, '0')}:'
'${now.second.toString().padLeft(2, '0')}.'
'${now.millisecond.toString().padLeft(3, '0')}]';
}
}

View File

@@ -46,19 +46,19 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
}) async {
if (_isDisposed) return;
// 同一设备(SN 相同):只增加引用计数,共享订阅
// 🔥 同一设备(SN 相同):只增加引用计数,不重新订阅
if (_listenerCount > 0 &&
_deviceSn == deviceSn &&
_gatewaySn == gatewaySn &&
_subscription != null) {
_gatewaySn == gatewaySn) {
_listenerCount++;
debugPrint('[DroneOsdDataSource] 引用计数+1: $_listenerCount');
debugPrint('[DroneOsdDataSource] 引用计数+1: $_listenerCount (设备相同,不重新订阅)');
return;
}
// 切换到不同设备:强制清理旧订阅
// 不管引用计数多少,SN 变了就必须先取消旧订阅再订阅新的
if (_deviceSn != null || _gatewaySn != null) {
// 🔥 切换到不同设备或首次订阅时才清理旧订阅
// 重要:只有在设备不同的情况下才调用 stopListening,避免误取消当前订阅
final needCleanup = _deviceSn != null || _gatewaySn != null;
if (needCleanup) {
// 重置引用计数为 1,让 stopListening 能真正取消 MQTT 订阅
_listenerCount = 1;
await stopListening();

View File

@@ -0,0 +1,178 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../../../../logging/log_time.dart';
import '../../domain/interfaces/mqtt_client.dart';
import '../../domain/models/mqtt_message.dart';
import '../../domain/entities/mower_location_entity.dart';
import '../../domain/entities/real_time_message_entity.dart';
import '../../domain/entities/route_status_entity.dart';
/// 割草机实时状态 MQTT 数据源(替代原 TCP 0x02 机器状态推送)
///
/// 订阅主题:
/// - 车辆实时状态 mower/{sn}/property/realtime/post(1Hz,不含定位)
/// - 高频定位 mower/{sn}/property/location/post(5-10Hz)
/// - 路径规划 device/{deviceId}/route/post(任务执行后推送覆盖清扫路线)
abstract class MowerRealtimeDataSource {
Stream<RealTimeMessageEntity> get vehicleStream;
Stream<MowerLocationEntity> get locationStream;
Stream<RouteStatusEntity> get routeStatusStream;
/// 按设备 SN 启动订阅:自动退订旧设备主题、同 SN 去重
Future<void> startListening({required String deviceSn});
Future<void> stopListening();
}
class MowerRealtimeDataSourceImpl implements MowerRealtimeDataSource {
final MqttClient mqttClient;
static const String _realtimeSuffix = 'property/realtime/post';
static const String _locationSuffix = 'property/location/post';
static const String _routeSuffix = 'route/post';
final _vehicleController =
StreamController<RealTimeMessageEntity>.broadcast();
final _locationController = StreamController<MowerLocationEntity>.broadcast();
final _routeController = StreamController<RouteStatusEntity>.broadcast();
StreamSubscription<MqttMessage>? _subscription;
/// 当前监听的设备 SN
String? _deviceSn;
/// 收帧计数(用于标记首帧)
int _vehicleMsgCount = 0;
int _locationMsgCount = 0;
int _routeMsgCount = 0;
MowerRealtimeDataSourceImpl(this.mqttClient);
@override
Stream<RealTimeMessageEntity> get vehicleStream => _vehicleController.stream;
@override
Stream<MowerLocationEntity> get locationStream => _locationController.stream;
@override
Stream<RouteStatusEntity> get routeStatusStream => _routeController.stream;
String _realtimeTopic(String sn) => 'mower/$sn/$_realtimeSuffix';
String _locationTopic(String sn) => 'mower/$sn/$_locationSuffix';
String _routeTopic(String sn) => 'device/$sn/$_routeSuffix';
@override
Future<void> startListening({required String deviceSn}) async {
if (deviceSn.isEmpty) {
debugPrint('${LogTime.now()} ⚠️ [MowerRealtimeDataSource] 订阅跳过:设备SN为空');
return;
}
// 🔥 同一设备重复调用:去重,不重复订阅(参照 DroneOsdDataSource 已验证的模式)
if (_deviceSn == deviceSn && _subscription != null) {
debugPrint('${LogTime.now()} [MowerRealtimeDataSource] 已在监听该设备,跳过: $deviceSn');
return;
}
// 🔥 切换设备:先退订旧设备的主题,防止数据串台
if (_deviceSn != null && _deviceSn!.isNotEmpty) {
await mqttClient.unsubscribe(_realtimeTopic(_deviceSn!));
await mqttClient.unsubscribe(_locationTopic(_deviceSn!));
await mqttClient.unsubscribe(_routeTopic(_deviceSn!));
debugPrint('${LogTime.now()} 🔕 [MowerRealtimeDataSource] 已退订旧设备: $_deviceSn');
}
_deviceSn = deviceSn;
_vehicleMsgCount = 0;
_locationMsgCount = 0;
_routeMsgCount = 0;
await mqttClient.subscribe(_realtimeTopic(deviceSn));
await mqttClient.subscribe(_locationTopic(deviceSn));
await mqttClient.subscribe(_routeTopic(deviceSn));
// 消息流监听只建立一次,按 _deviceSn 过滤
_subscription ??= mqttClient.messageStream?.listen(_handleMessage);
debugPrint('${LogTime.now()} ✅ [MowerRealtimeDataSource] 开始监听设备: $deviceSn');
debugPrint(' 车辆实时: ${_realtimeTopic(deviceSn)}');
debugPrint(' 定位: ${_locationTopic(deviceSn)}');
debugPrint(' 路径规划: ${_routeTopic(deviceSn)}');
}
@override
Future<void> stopListening() async {
if (_deviceSn == null || _deviceSn!.isEmpty) return;
await mqttClient.unsubscribe(_realtimeTopic(_deviceSn!));
await mqttClient.unsubscribe(_locationTopic(_deviceSn!));
await mqttClient.unsubscribe(_routeTopic(_deviceSn!));
debugPrint('${LogTime.now()} 🔕 [MowerRealtimeDataSource] 停止监听设备: $_deviceSn');
_deviceSn = null;
}
void _handleMessage(MqttMessage message) {
final sn = _deviceSn;
if (sn == null || sn.isEmpty) return;
// 🔥 防串台:只处理当前监听设备的主题(mower/ 和 device/ 前缀都要匹配)
if (!message.topic.contains('mower/$sn/') &&
!message.topic.contains('device/$sn/')) return;
// 🔥 第一阶段:打印原始报文,确认格式后再完善解析
debugPrint('${LogTime.now()} 📥 [MQTT-机器状态] ──────── 收到消息 ────────');
debugPrint('${LogTime.now()} 📥 [MQTT-机器状态] topic: ${message.topic}');
debugPrint('${LogTime.now()} 📥 [MQTT-机器状态] payload: ${message.payload}');
try {
final jsonData = jsonDecode(message.payload) as Map<String, dynamic>;
if (message.topic.endsWith(_realtimeSuffix)) {
// {name, value, unit} 数据点格式与 RealTimeMessageEntity 吻合,防御性解析
final vehicle = RealTimeMessageEntity.fromJson(jsonData);
if (vehicle.data.isNotEmpty) {
_vehicleMsgCount++;
if (_vehicleMsgCount == 1) {
debugPrint('${LogTime.now()} 🎉 [MQTT-机器状态] 车辆实时首帧到达: $sn');
}
debugPrint('${LogTime.now()} 📊 [MQTT-机器状态] 车辆实时第$_vehicleMsgCount帧 解析后: type=${vehicle.type}, 数据点=${vehicle.data.length}个');
for (final dp in vehicle.data) {
debugPrint('${LogTime.now()} ├ ${dp.name} = ${dp.value}${dp.unit}');
}
_vehicleController.add(vehicle);
}
} else if (message.topic.endsWith(_locationSuffix)) {
_locationMsgCount++;
if (_locationMsgCount == 1) {
debugPrint('${LogTime.now()} 🎉 [MQTT-机器状态] 定位首帧到达: $sn');
}
// 🔥 关键修复:坐标在 data 子对象中,不是顶层
final dataObj = jsonData['data'] as Map<String, dynamic>? ?? jsonData;
final location = MowerLocationEntity.fromJson(dataObj);
debugPrint('${LogTime.now()} 📍 [MQTT-机器状态] 定位第$_locationMsgCount帧 解析后: lat=${location.latitude}, lng=${location.longitude}, alt=${location.altitude}, heading=${location.heading}, speed=${location.speed}');
_locationController.add(location);
} else if (message.topic.endsWith(_routeSuffix)) {
_routeMsgCount++;
if (_routeMsgCount == 1) {
debugPrint('${LogTime.now()} 🎉 [MQTT-机器状态] 路径规划首帧到达: $sn');
}
final routeMsg = RouteStatusEntity.fromJson(jsonData);
debugPrint('${LogTime.now()} 🛤️ [MQTT-机器状态] 路径规划第$_routeMsgCount帧 - 设备: ${routeMsg.deviceId}, 任务: ${routeMsg.taskId}, 点数: ${routeMsg.data.length}');
_routeController.add(routeMsg);
}
} catch (e) {
debugPrint('${LogTime.now()} ❌ [MowerRealtimeDataSource] 解析消息失败: $e');
}
}
void dispose() {
stopListening();
_subscription?.cancel();
_subscription = null;
_vehicleController.close();
_locationController.close();
_routeController.close();
}
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:mqtt_client/mqtt_client.dart' as mqtt;
import 'package:mqtt_client/mqtt_server_client.dart' as mqtt_server;
import '../../../../logging/log_time.dart';
import '../../domain/interfaces/mqtt_client.dart';
import '../../domain/models/mqtt_config.dart';
import '../../domain/models/mqtt_message.dart';
@@ -13,6 +14,9 @@ class MqttClientImpl implements MqttClient {
final _messageController = StreamController<MqttMessage>.broadcast();
final Map<String, int> _subscriptionRefs = <String, int>{};
/// 客户端名称,用于日志区分(如 droneOsdClient / taskMessageClient / mowerRealtimeClient)
final String clientName;
MqttConfig? _currentConfig;
StreamSubscription? _updatesSubscription;
Timer? _reconnectTimer;
@@ -20,6 +24,8 @@ class MqttClientImpl implements MqttClient {
bool _isConnecting = false;
bool _manualDisconnect = false;
MqttClientImpl({this.clientName = 'MqttClient'});
@override
Stream<MqttMessage>? get messageStream => _messageController.stream;
@@ -50,13 +56,13 @@ class MqttClientImpl implements MqttClient {
_client = _createClient(config);
_configureClient(_client!, config);
debugPrint('[MqttClient] connecting to ${config.connectionAddress}');
debugPrint('${LogTime.now()} 🔌 [$clientName] 正在连接 ${config.connectionAddress}:${config.port} (clientId=${config.clientId})');
await _client!.connect(config.username, config.password);
if (_client!.connectionStatus?.state ==
mqtt.MqttConnectionState.connected) {
_isConnected = true;
debugPrint('[MqttClient] connected');
debugPrint('${LogTime.now()} ✅ [$clientName] 连接成功 ${config.connectionAddress}:${config.port}');
_listenToMessages();
_resubscribeAll();
} else {
@@ -65,7 +71,7 @@ class MqttClientImpl implements MqttClient {
);
}
} catch (e) {
debugPrint('[MqttClient] connect error: $e');
debugPrint('${LogTime.now()} ❌ [$clientName] 连接失败: $e');
_isConnected = false;
_isConnecting = false;
_scheduleReconnect();
@@ -153,7 +159,7 @@ class MqttClientImpl implements MqttClient {
return;
}
debugPrint('[MqttClient] subscribe: $topic');
debugPrint('${LogTime.now()} 📮 [$clientName] 发起订阅: $topic');
_client!.subscribe(topic, mqtt.MqttQos.atLeastOnce);
}
@@ -173,7 +179,7 @@ class MqttClientImpl implements MqttClient {
return;
}
debugPrint('[MqttClient] unsubscribe: $topic');
debugPrint('${LogTime.now()} 🔕 [$clientName] 退订: $topic');
_client!.unsubscribe(topic);
}
@@ -198,7 +204,7 @@ class MqttClientImpl implements MqttClient {
(msg.payload as mqtt.MqttPublishMessage).payload.message,
);
debugPrint('[MqttClient] received [$topic]: $payload');
debugPrint('${LogTime.now()} 📥 [$clientName] 收到消息 [$topic]: $payload');
_messageController.add(MqttMessage(topic: topic, payload: payload));
}
},
@@ -215,7 +221,7 @@ class MqttClientImpl implements MqttClient {
if (_subscriptionRefs.isEmpty || _client == null) return;
for (final topic in _subscriptionRefs.keys) {
debugPrint('[MqttClient] resubscribe: $topic');
debugPrint('${LogTime.now()} 📮 [$clientName] 重连后重新订阅: $topic');
_client!.subscribe(topic, mqtt.MqttQos.atLeastOnce);
}
}
@@ -226,7 +232,7 @@ class MqttClientImpl implements MqttClient {
}
void _onDisconnected() {
debugPrint('[MqttClient] disconnected callback');
debugPrint('${LogTime.now()} ⚠️ [$clientName] 连接断开');
_isConnected = false;
if (!_manualDisconnect) {
_scheduleReconnect();
@@ -234,7 +240,7 @@ class MqttClientImpl implements MqttClient {
}
void _onSubscribed(String topic) {
debugPrint('[MqttClient] subscribed: $topic');
debugPrint('${LogTime.now()} ✅ [$clientName] 订阅确认成功: $topic');
}
void _scheduleReconnect() {
@@ -246,12 +252,12 @@ class MqttClientImpl implements MqttClient {
if (_reconnectTimer?.isActive ?? false) return;
final delay = Duration(milliseconds: config.reconnectDelayMs);
debugPrint('[MqttClient] reconnect scheduled in ${delay.inMilliseconds}ms');
debugPrint('${LogTime.now()} ⏳ [$clientName] ${delay.inMilliseconds}ms 后尝试重连');
_reconnectTimer = Timer(delay, () async {
if (_manualDisconnect || _isConnected || _isConnecting) return;
try {
debugPrint('[MqttClient] reconnecting...');
debugPrint('${LogTime.now()} 🔄 [$clientName] 开始重连...');
await connect(config);
} catch (e) {
debugPrint('[MqttClient] reconnect failed: $e');

View File

@@ -0,0 +1,42 @@
import 'dart:async';
import 'package:fpdart/fpdart.dart';
import '../../../../../core/error/failure.dart';
import '../datasources/mower_realtime_datasource.dart';
import '../../domain/repositories/mower_realtime_repository.dart';
import '../../domain/entities/mower_location_entity.dart';
import '../../domain/entities/real_time_message_entity.dart';
import '../../domain/entities/route_status_entity.dart';
class MowerRealtimeRepositoryImpl implements MowerRealtimeRepository {
final MowerRealtimeDataSource dataSource;
MowerRealtimeRepositoryImpl(this.dataSource);
@override
Stream<RealTimeMessageEntity> get vehicleStream => dataSource.vehicleStream;
@override
Stream<MowerLocationEntity> get locationStream => dataSource.locationStream;
@override
Stream<RouteStatusEntity> get routeStatusStream =>
dataSource.routeStatusStream;
@override
Future<Either<Failure, void>> startListening({
required String deviceSn,
}) async {
try {
await dataSource.startListening(deviceSn: deviceSn);
return right(null);
} catch (e) {
return left(Failure(e.toString()));
}
}
@override
Future<void> stopListening() async {
await dataSource.stopListening();
}
}

View File

@@ -0,0 +1,60 @@
import 'package:equatable/equatable.dart';
/// 割草机定位消息实体(MQTT 主题 mower/{sn}/property/location/post,5-10Hz)
///
/// 报文格式尚未最终确认(第一阶段仅打印),fromLen 对常见字段名做宽容解析,
/// 确认后如与实际不符在此调整即可,不影响上层。
class MowerLocationEntity extends Equatable {
final double? latitude;
final double? longitude;
final double? altitude;
/// 航向角
final double? heading;
/// 速度
final double? speed;
/// 定位质量(fix_status)
final String? fixStatus;
final String? timestamp;
/// 原始报文,便于解析格式确认后补充字段
final Map<String, dynamic> rawData;
const MowerLocationEntity({
this.latitude,
this.longitude,
this.altitude,
this.heading,
this.speed,
this.fixStatus,
this.timestamp,
this.rawData = const {},
});
bool get isValid => latitude != null && longitude != null;
factory MowerLocationEntity.fromJson(Map<String, dynamic> json) {
return MowerLocationEntity(
latitude: _toDouble(json['latitude'] ?? json['lat']),
longitude: _toDouble(json['longitude'] ?? json['lng'] ?? json['lon']),
altitude: _toDouble(json['altitude'] ?? json['height']),
heading: _toDouble(json['heading'] ?? json['yaw']),
speed: _toDouble(json['speed']),
fixStatus: json['fix_status']?.toString(),
timestamp: json['timestamp']?.toString(),
rawData: json,
);
}
static double? _toDouble(dynamic value) {
if (value == null) return null;
if (value is num) return value.toDouble();
return double.tryParse(value.toString());
}
@override
List<Object?> get props => [latitude, longitude, altitude, heading, speed, fixStatus, timestamp];
}

View File

@@ -0,0 +1,50 @@
import 'package:equatable/equatable.dart';
/// MQTT 路径规划消息实体
/// 对应后端 Java DTO: WebRouteStatusMessageDTO
/// Topic: device/{deviceId}/route/post
class RouteStatusEntity extends Equatable {
final List<LatAndLngEntity> data;
final String deviceId;
final int taskId;
final String type;
const RouteStatusEntity({
required this.data,
required this.deviceId,
required this.taskId,
required this.type,
});
factory RouteStatusEntity.fromJson(Map<String, dynamic> json) {
final dataList = json['data'] as List<dynamic>? ?? [];
return RouteStatusEntity(
data: dataList.map((item) => LatAndLngEntity.fromJson(item)).toList(),
deviceId: json['deviceId'] as String? ?? '',
taskId: (json['taskId'] as num?)?.toInt() ?? 0,
type: json['type'] as String? ?? '',
);
}
@override
List<Object?> get props => [data, deviceId, taskId, type];
}
/// 经纬度坐标实体
/// 对应后端 Java DTO: LatAndLngEntity
class LatAndLngEntity extends Equatable {
final double lat;
final double lng;
const LatAndLngEntity({required this.lat, required this.lng});
factory LatAndLngEntity.fromJson(Map<String, dynamic> json) {
return LatAndLngEntity(
lat: (json['lat'] as num?)?.toDouble() ?? 0.0,
lng: (json['lng'] as num?)?.toDouble() ?? 0.0,
);
}
@override
List<Object?> get props => [lat, lng];
}

View File

@@ -1,5 +1,7 @@
import 'package:equatable/equatable.dart';
import '../../../../env/env_config.dart';
/// MQTT 传输协议类型
enum MqttProtocol {
/// 纯 TCP 连接(原生 MQTT)
@@ -67,11 +69,11 @@ class MqttConfig extends Equatable {
);
}
/// 任务状态消息(TCP MQTT)
/// 任务状态消息(TCP MQTT)——端口随环境切换(prod=1883 / test=59007)
factory MqttConfig.taskMessage() {
return const MqttConfig(
host: '1.95.137.212',
port: 1883,
return MqttConfig(
host: EnvConfig.serverHost,
port: EnvConfig.mqttPort,
username: 'maibu',
password: 'jsmbzn520',
protocol: MqttProtocol.tcp,
@@ -81,6 +83,21 @@ class MqttConfig extends Equatable {
);
}
/// 🔥 割草机机器状态(TCP MQTT)——独立连接,端口随环境切换(prod=1883 / test=59007)
/// 仅用于 mower/{sn}/property/realtime/post 和 mower/{sn}/property/location/post
factory MqttConfig.mowerRealtime() {
return MqttConfig(
host: EnvConfig.serverHost,
port: EnvConfig.mqttPort,
username: 'maibu',
password: 'jsmbzn520',
protocol: MqttProtocol.tcp,
clientId: 'mower_realtime_client',
cleanSession: true,
reconnectDelayMs: 3000,
);
}
MqttConfig copyWith({
String? host,
int? port,

View File

@@ -0,0 +1,19 @@
import 'dart:async';
import 'package:fpdart/fpdart.dart';
import '../../../../../core/error/failure.dart';
import '../entities/mower_location_entity.dart';
import '../entities/real_time_message_entity.dart';
import '../entities/route_status_entity.dart';
abstract class MowerRealtimeRepository {
Stream<RealTimeMessageEntity> get vehicleStream;
Stream<MowerLocationEntity> get locationStream;
Stream<RouteStatusEntity> get routeStatusStream;
Future<Either<Failure, void>> startListening({
required String deviceSn,
});
Future<void> stopListening();
}

View File

@@ -24,10 +24,12 @@ class MqttManager {
final droneOsdClient = sl<MqttClient>(instanceName: 'droneOsdClient');
final taskMessageClient = sl<MqttClient>(instanceName: 'taskMessageClient');
final mowerRealtimeClient = sl<MqttClient>(instanceName: 'mowerRealtimeClient');
// 独立连接两个 MQTT 客户端,互不影响
// 独立连接三个 MQTT 客户端,互不影响
await _connectClient(droneOsdClient, MqttConfig.droneOsd(), 'droneOsdClient');
await _connectClient(taskMessageClient, MqttConfig.taskMessage(), 'taskMessageClient');
await _connectClient(mowerRealtimeClient, MqttConfig.mowerRealtime(), 'mowerRealtimeClient');
_isInitialized = true;
debugPrint('✅ [MqttManager] MQTT 初始化完成');
@@ -53,9 +55,11 @@ class MqttManager {
final droneOsdClient = sl<MqttClient>(instanceName: 'droneOsdClient');
final taskMessageClient = sl<MqttClient>(instanceName: 'taskMessageClient');
final mowerRealtimeClient = sl<MqttClient>(instanceName: 'mowerRealtimeClient');
await droneOsdClient.disconnect();
await taskMessageClient.disconnect();
await mowerRealtimeClient.disconnect();
_isInitialized = false;
debugPrint('✅ [MqttManager] 所有 MQTT 连接已断开');

View File

@@ -30,13 +30,19 @@ GoRouter createRouter(AuthCubit authCubit) {
return GoRouter(
navigatorKey: navigatorKey, // 🔥 绑定全局 navigatorKey,使异地登录弹窗能获取 context
initialLocation: RoutePaths.login,
initialLocation: RoutePaths.splash,
refreshListenable: GoRouterRefreshStream(authCubit.stream),
redirect: (context, state) {
final loggedIn = authCubit.state is AuthAuthenticated;
final onSplash = state.matchedLocation == RoutePaths.splash;
final loggingIn = state.matchedLocation == RoutePaths.login;
final registering = state.matchedLocation == RoutePaths.register;
// 启动屏:交给 SplashPage 自己根据就绪信号跳转,路由层不干预
if (onSplash) {
return null;
}
// 如果未登录,只允许访问 login 和 register 页面
if (!loggedIn && !loggingIn && !registering) {
return RoutePaths.login;

View File

@@ -1,4 +1,7 @@
class RoutePaths {
/// 启动页(App 第一个路由)
static const splash = '/splash';
/// 账号相关页面
static const login = '/auth/login';
static const register = '/auth/register';

View File

@@ -1,4 +1,5 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_patcher/flutter_patcher.dart';
import 'package:logger/logger.dart';
import 'package:path_provider/path_provider.dart';
@@ -56,6 +57,7 @@ class VersionCheckService {
final Dio _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
validateStatus: (status) => true,
));
static const String apiUrl = 'http://8.159.134.0:8012/api/update/check';
@@ -67,29 +69,26 @@ class VersionCheckService {
required int currentVersionCode,
}) async {
try {
_logger.i('🔍 检查版本更新');
_logger.i('📱 APK 自带版本: $currentVersion ($currentVersionCode)');
debugPrint('');
debugPrint('========================================');
debugPrint(' UPDATE CHECK START');
debugPrint('========================================');
debugPrint('[1] APK version: $currentVersion ($currentVersionCode)');
// 从文件读取已应用的补丁版本
final patchInfo = await _loadPatchVersionInfo();
final appliedPatchVersion = patchInfo['version'] as String?;
final appliedPatchVersionCode = patchInfo['versionCode'] as int?;
_logger.i('📂 读取到的补丁版本: $appliedPatchVersion ($appliedPatchVersionCode)');
debugPrint('[2] Patch file version: $appliedPatchVersion ($appliedPatchVersionCode)');
// 如果打过补丁,使用补丁的版本;否则使用 APK 自带版本
final requestVersion = appliedPatchVersion ?? currentVersion;
final requestVersionCode = appliedPatchVersionCode ?? currentVersionCode;
// 始终用 APK 基础版本请求服务端,让服务端返回最新版本
// 客户端自己判断本地是否已经是最新
final requestVersion = currentVersion;
final requestVersionCode = currentVersionCode;
_logger.i('🚀 请求后端版本: $requestVersion ($requestVersionCode)');
// 🔥 打印完整的请求参数
_logger.i('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
_logger.i('📤 App 发送的请求参数:');
_logger.i(' URL: $apiUrl');
_logger.i(' version: $requestVersion');
_logger.i(' versionCode: $requestVersionCode');
_logger.i('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
debugPrint('[3] Request to server: version=$requestVersion, versionCode=$requestVersionCode');
debugPrint('[4] URL: $apiUrl?version=$requestVersion&versionCode=$requestVersionCode');
final response = await _dio.get(
apiUrl,
@@ -99,47 +98,67 @@ class VersionCheckService {
},
);
// 🔥 打印完整的后端返回数据
_logger.i('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
_logger.i('📥 后端返回的完整数据:');
_logger.i(' statusCode: ${response.statusCode}');
_logger.i(' 原始响应: ${response.data}');
_logger.i('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
debugPrint('[5] HTTP status: ${response.statusCode}');
debugPrint('[6] Response body: ${response.data}');
if (response.statusCode == 200) {
final data = response.data['data'];
final respCode = response.data['code'];
final data = response.data['data'];
final message = response.data['message'] ?? '';
if (data['hasUpdate'] == false) {
_logger.i('✅ 已是最新版本');
return null;
}
final versionInfo = AppVersionInfo.fromJson(data);
// 🔥 关键修复:无论更新类型是什么,只要本地已应用此版本,就跳过
if (appliedPatchVersion != null && appliedPatchVersion == versionInfo.version) {
_logger.i('✅ 本地已应用版本 ${versionInfo.version},跳过更新(updateType: ${versionInfo.updateType})');
return null;
}
// 🔥 额外检查:如果本地补丁版本code >= 服务端返回的versionCode,也跳过
if (appliedPatchVersionCode != null && appliedPatchVersionCode >= versionInfo.versionCode) {
_logger.i('✅ 本地补丁版本code ($appliedPatchVersionCode) >= 服务端versionCode (${versionInfo.versionCode}),跳过更新');
return null;
}
_logger.i('✅ 发现新版本: ${versionInfo.version}');
_logger.i('🔄 更新类型: ${versionInfo.updateType}');
_logger.i('⚠️ 强制更新: ${versionInfo.forceUpdate}');
return versionInfo;
debugPrint('[7] respCode=$respCode, data=$data, message=$message');
if (respCode != 200 || data == null) {
debugPrint('[RESULT] No update - server returned code=$respCode, data=${data == null ? "NULL" : "NOT_NULL"}, message=$message');
debugPrint('========================================');
debugPrint('');
return null;
}
debugPrint('[7.1] data type: ${data.runtimeType}');
debugPrint('[7.2] data keys: ${data is Map ? data.keys.toList() : "NOT A MAP"}');
debugPrint('[7.3] hasUpdate = ${data['hasUpdate']} (type: ${data['hasUpdate']?.runtimeType})');
debugPrint('[7.4] version = ${data['version']}');
debugPrint('[7.5] versionCode = ${data['versionCode']}');
debugPrint('[7.6] updateType = ${data['updateType']}');
debugPrint('[7.7] patch = ${data['patch']}');
debugPrint('[7.8] fullApk = ${data['fullApk']}');
if (data['hasUpdate'] == false) {
debugPrint('[RESULT] No update - hasUpdate=false');
debugPrint('========================================');
debugPrint('');
return null;
}
final versionInfo = AppVersionInfo.fromJson(data);
debugPrint('[8] Server version: ${versionInfo.version} (${versionInfo.versionCode})');
debugPrint('[9] Update type: ${versionInfo.updateType}, force: ${versionInfo.forceUpdate}');
debugPrint('[10] patchUrl: ${versionInfo.patchUrl}');
debugPrint('[11] patchMd5: ${versionInfo.patchMd5}');
debugPrint('[12] apkUrl: ${versionInfo.apkUrl}');
// 只用版本字符串判断是否已更新:服务端 hasUpdate=true 说了算
if (appliedPatchVersion != null && appliedPatchVersion == versionInfo.version) {
debugPrint('[RESULT] No update - already at version ${versionInfo.version}');
debugPrint('========================================');
debugPrint('');
return null;
}
debugPrint('[8.1] Local patch version: ${appliedPatchVersion ?? "none"}');
debugPrint('[8.2] Server version: ${versionInfo.version}');
debugPrint('[8.3] Version different: ${appliedPatchVersion != versionInfo.version}');
debugPrint('[RESULT] NEW VERSION FOUND: ${versionInfo.version} (${versionInfo.versionCode})');
debugPrint('========================================');
debugPrint('');
return versionInfo;
} catch (e) {
debugPrint('[RESULT] ERROR: $e');
debugPrint('========================================');
debugPrint('');
return null;
} catch (e, stack) {
_logger.e('❌ 版本检查失败: $e');
_logger.e('❌ 堆栈信息: $stack');
throw Exception('网络请求失败: $e');
}
}

View File

@@ -0,0 +1,24 @@
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
/// 图片地址工具:后端返回的图片路径可能是相对路径(如 /profile/avatar/xxx.jpg),
/// 直接交给 Image.network 会因缺少 host 抛 "No host specified in URI" 异常。
/// 此工具负责将相对路径拼接成完整的服务器地址。
class ImageUrlUtil {
ImageUrlUtil._();
/// 补全图片地址
/// - 已是完整 http(s) 地址:原样返回
/// - 以 / 开头的相对路径:拼接 baseUrl
/// - 其他相对路径:拼接 baseUrl + /
/// - null 或空:返回 null
static String? resolve(String? path) {
if (path == null || path.isEmpty) return null;
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
if (path.startsWith('/')) {
return '${HttpApiConsts.baseUrl}$path';
}
return '${HttpApiConsts.baseUrl}/$path';
}
}

View File

@@ -18,19 +18,47 @@ class LoginPage extends StatefulWidget {
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMixin {
final _userCtrl = TextEditingController();
final _pwdCtrl = TextEditingController();
bool _isAgreed = false; // 协议勾选状态
bool _obscurePwd = true; // 密码可见性
// 进场动画控制器
late final AnimationController _entranceController;
@override
void initState() {
super.initState();
_entranceController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
// 等首帧渲染 + 图片解码完成后再播放,避开启动初始化争抢导致的掉帧
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
Future.delayed(const Duration(milliseconds: 120), () {
if (mounted) _entranceController.forward();
});
});
}
@override
void dispose() {
_entranceController.dispose();
_userCtrl.dispose();
_pwdCtrl.dispose();
super.dispose();
}
/// 生成一个错峰区间的动画(0.0 ~ 1.0)
Animation<double> _interval(double begin, double end) {
return CurvedAnimation(
parent: _entranceController,
curve: Interval(begin, end, curve: Curves.easeOut),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -64,130 +92,94 @@ class _LoginPageState extends State<LoginPage> {
// 顶部区域:图片 + 认证信息 + 标题(压缩空间)
const SizedBox(height: 10),
// 顶部居中图片和认证信息
Center(
child: Column(
children: [
Image.asset(
'assets/images/huawei.png',
width: 80,
height: 80,
fit: BoxFit.contain,
),
const SizedBox(height: 6),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: Text(
"通过华为云生态技术认证,请放心使用,证书编号C202605301",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10,
color: context.appColors.textTertiary,
height: 1.3,
// ① 顶部居中图片和认证信息(缩放淡入)
_Entrance(
animation: _interval(0.00, 0.35),
scale: true,
child: Center(
child: Column(
children: [
Image.asset(
'assets/images/huawei.png',
width: 80,
height: 80,
fit: BoxFit.contain,
),
const SizedBox(height: 6),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: Text(
"通过华为云生态技术认证,请放心使用,证书编号C202605301",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10,
color: context.appColors.textTertiary,
height: 1.3,
),
),
),
),
],
),
),
const SizedBox(height: 16),
Text(
"账号登录v1.1.5",
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: context.appColors.textPrimary),
),
const SizedBox(height: 8),
Text("请填写以下信息以验证身份", style: TextStyle(fontSize: 15, color: context.appColors.textTertiary)),
const SizedBox(height: 24),
// 表单区域
// 账号输入框
_buildInputLabel("账号"),
TextField(
controller: _userCtrl,
style: TextStyle(fontSize: 14, color: context.appColors.textPrimary),
cursorColor: context.appColors.textPrimary,
decoration: _inputDecoration(hint: "请输入用户名"),
),
const SizedBox(height: 20),
// 密码输入框
_buildInputLabel("密码"),
TextField(
controller: _pwdCtrl,
obscureText: _obscurePwd,
style: TextStyle(fontSize: 14, color: context.appColors.textPrimary),
cursorColor: context.appColors.textPrimary,
decoration: _inputDecoration(
hint: "请输入密码",
suffixIcon: IconButton(
icon: Icon(_obscurePwd ? Icons.visibility_off_outlined : Icons.visibility_outlined, color: context.appColors.textTertiary, size: 20),
onPressed: () => setState(() => _obscurePwd = !_obscurePwd),
],
),
),
),
const SizedBox(height: 16),
// 协议勾选区域
_buildProtocolSection(),
const SizedBox(height: 30),
// 底部区域:登录按钮 + 注册链接
SizedBox(
width: double.infinity,
child: BlocBuilder<LoginCubit, LoginState>(
builder: (context, state) {
bool isLoading = state is LoginLoading;
return CCPrimaryButton(
onPressed: isLoading
? null
: () {
if (!_isAgreed) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请先阅读并同意用户协议'),
duration: Duration(seconds: 1),
),
);
return;
}
_handleLogin();
},
text: isLoading ? '登录中...' : '登 录',
);
},
),
),
const SizedBox(height: 16),
Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
// ② 标题区(上滑淡入)
_Entrance(
animation: _interval(0.10, 0.45),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"没有账号吗?",
style: TextStyle(fontSize: 14, color: context.appColors.textTertiary),
"账号登录",
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: context.appColors.textPrimary),
),
InkWell(
onTap: () {
debugPrint(' 点击了立即注册');
debugPrint('👉 跳转路径: ${RoutePaths.register}');
context.go(RoutePaths.register);
},
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text(
"立即注册",
style: TextStyle(
fontSize: 14,
color: context.appColors.textPrimary,
fontWeight: FontWeight.bold,
),
const SizedBox(height: 8),
Text("请填写以下信息以验证身份", style: TextStyle(fontSize: 15, color: context.appColors.textTertiary)),
],
),
),
const SizedBox(height: 24),
// ③ 表单区域
// 账号输入框(上滑淡入)
_Entrance(
animation: _interval(0.20, 0.55),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInputLabel("账号"),
TextField(
controller: _userCtrl,
style: TextStyle(fontSize: 14, color: context.appColors.textPrimary),
cursorColor: context.appColors.textPrimary,
decoration: _inputDecoration(hint: "请输入用户名"),
),
],
),
),
const SizedBox(height: 20),
// 密码输入框(上滑淡入)
_Entrance(
animation: _interval(0.28, 0.63),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInputLabel("密码"),
TextField(
controller: _pwdCtrl,
obscureText: _obscurePwd,
style: TextStyle(fontSize: 14, color: context.appColors.textPrimary),
cursorColor: context.appColors.textPrimary,
decoration: _inputDecoration(
hint: "请输入密码",
suffixIcon: IconButton(
icon: Icon(_obscurePwd ? Icons.visibility_off_outlined : Icons.visibility_outlined, color: context.appColors.textTertiary, size: 20),
onPressed: () => setState(() => _obscurePwd = !_obscurePwd),
),
),
),
@@ -195,6 +187,83 @@ class _LoginPageState extends State<LoginPage> {
),
),
const SizedBox(height: 16),
// ④ 协议勾选区域(上滑淡入)
_Entrance(
animation: _interval(0.36, 0.71),
child: _buildProtocolSection(),
),
const SizedBox(height: 30),
// ⑤ 底部区域:登录按钮(上滑淡入)
_Entrance(
animation: _interval(0.45, 0.82),
child: SizedBox(
width: double.infinity,
child: BlocBuilder<LoginCubit, LoginState>(
builder: (context, state) {
bool isLoading = state is LoginLoading;
return CCPrimaryButton(
onPressed: isLoading
? null
: () {
if (!_isAgreed) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请先阅读并同意用户协议'),
duration: Duration(seconds: 1),
),
);
return;
}
_handleLogin();
},
text: isLoading ? '登录中...' : '登 录',
);
},
),
),
),
const SizedBox(height: 16),
// 注册链接(上滑淡入,最后登场)
_Entrance(
animation: _interval(0.55, 0.95),
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"没有账号吗?",
style: TextStyle(fontSize: 14, color: context.appColors.textTertiary),
),
InkWell(
onTap: () {
debugPrint(' 点击了立即注册');
debugPrint('👉 跳转路径: ${RoutePaths.register}');
context.go(RoutePaths.register);
},
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text(
"立即注册",
style: TextStyle(
fontSize: 14,
color: context.appColors.textPrimary,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
),
const SizedBox(height: 20),
],
),
@@ -320,3 +389,37 @@ class _LoginPageState extends State<LoginPage> {
);
}
}
/// 进场动画包装组件:淡入 + 上滑(默认)或淡入 + 缩放(scale=true)
class _Entrance extends StatelessWidget {
const _Entrance({
required this.animation,
required this.child,
this.scale = false,
this.slideOffset = const Offset(0, 0.15),
});
final Animation<double> animation;
final Widget child;
final bool scale;
final Offset slideOffset;
@override
Widget build(BuildContext context) {
final curved = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic);
if (scale) {
final scaleAnim = Tween<double>(begin: 0.85, end: 1.0).animate(curved);
return FadeTransition(
opacity: animation,
child: ScaleTransition(scale: scaleAnim, child: child),
);
}
final slideAnim = Tween<Offset>(begin: slideOffset, end: Offset.zero).animate(curved);
return FadeTransition(
opacity: animation,
child: SlideTransition(position: slideAnim, child: child),
);
}
}

View File

@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/router/route_paths.dart';
import '../bloc/auth_cubit.dart';
import '../bloc/auth_state.dart';
/// 启动页:展示 App Logo 并循环播放「呼吸缩放」动画。
///
/// 该页面作为 App 的第一个路由,衔接原生启动屏(静态 Logo)。
/// 动画会一直循环,直到 [AuthCubit.appStarted] 完成、登录态确定
/// ([AuthAuthenticated] / [AuthUnauthenticated])这一「就绪信号」到来,
/// 才停止动画并跳转到首页或登录页。
class SplashPage extends StatefulWidget {
const SplashPage({super.key});
@override
State<SplashPage> createState() => _SplashPageState();
}
class _SplashPageState extends State<SplashPage>
with SingleTickerProviderStateMixin {
/// 呼吸动画控制器(循环往复)
late final AnimationController _breathController;
/// 就绪信号是否已到达
bool _ready = false;
/// 就绪后的目标登录态
AuthState? _resolvedState;
/// 防止重复跳转
bool _navigated = false;
@override
void initState() {
super.initState();
_breathController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat(reverse: true);
// 处理进入 splash 时登录态已经确定的情况(appStarted 先于首帧完成)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _onReady(context.read<AuthCubit>().state);
});
}
@override
void dispose() {
_breathController.dispose();
super.dispose();
}
/// 记录就绪信号,并尝试跳转
void _onReady(AuthState state) {
if (state is AuthAuthenticated || state is AuthUnauthenticated) {
_resolvedState = state;
_ready = true;
_maybeNavigate();
}
// AuthInitial:尚未就绪,继续停留在启动页播放动画
}
/// 就绪信号到达即离开启动页,不额外增加打开时间
void _maybeNavigate() {
if (_navigated || !mounted) return;
if (!_ready || _resolvedState == null) return;
_navigated = true;
_breathController.stop();
if (_resolvedState is AuthAuthenticated) {
context.go(RoutePaths.home);
} else {
context.go(RoutePaths.login);
}
}
@override
Widget build(BuildContext context) {
return BlocListener<AuthCubit, AuthState>(
listenWhen: (prev, cur) =>
cur is AuthAuthenticated || cur is AuthUnauthenticated,
listener: (context, state) => _onReady(state),
child: Scaffold(
// 与原生启动屏白底保持一致,避免切换时闪色
backgroundColor: Colors.white,
body: Center(
child: AnimatedBuilder(
animation: _breathController,
builder: (context, child) {
// easeInOut 让呼吸更柔和;scale 从 1.0 起始以贴合原生屏尺寸
final t = Curves.easeInOut.transform(_breathController.value);
final scale = 1.0 - 0.22 * t; // 1.0 -> 0.78,幅度更大更明显
final opacity = 1.0 - 0.55 * t; // 1.0 -> 0.45
return Opacity(
opacity: opacity,
child: Transform.scale(scale: scale, child: child),
);
},
// 透明底应用图标,避免白底上出现浅灰"阴影"底块
child: Image.asset(
'assets/images/app_logo_gray_transparent.png',
width: 180,
height: 180,
fit: BoxFit.contain,
),
),
),
),
);
}
}

View File

@@ -3,10 +3,15 @@ import 'package:maibu_satabot_v2/core/router/route_paths.dart';
import 'package:maibu_satabot_v2/features/auth/presentation/pages/login_page.dart';
import '../pages/register_page.dart';
import '../pages/splash_page.dart';
class AuthRoutes {
// 返回一个 List<RouteBase>
static List<RouteBase> routes = [
GoRoute(
path: RoutePaths.splash,
builder: (context, state) => const SplashPage(),
),
GoRoute(
path: RoutePaths.login,
builder: (context, state) => const LoginPage(),

View File

@@ -19,6 +19,7 @@ abstract class DeviceTaskDatasource {
required int taskId,
required int orgId,
required int siteId,
bool forceCancel = true,
});
Future<bool> pauseTask({
@@ -90,13 +91,14 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource {
required int taskId,
required int orgId,
required int siteId,
bool forceCancel = true,
}) async {
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/cancelTask';
final body = {
'deviceId': deviceId,
'taskId': taskId,
'orgId': orgId,
'siteId': siteId,
'orgId': orgId,
'forceCancel': forceCancel,
};
_logger.logWithLevel('[cancelTask] 请求: POST $url');
_logger.logWithLevel('[cancelTask] 参数: ${jsonEncode(body)}');

View File

@@ -34,7 +34,7 @@ class DeviceHttpDatasourceImpl implements DeviceHttpDatasource {
logger.logWithLevel(
'用户点击bindDevice方法API 请求开始',
level: 'INFO',
data: {'url': 'http://1.95.137.212:8081/iot/device/bindDevice'},
data: {'url': '${HttpApiConsts.baseUrl}/iot/device/bindDevice'},
);
var response = await dio.post(
HttpApiConsts.bindDevice,
@@ -44,7 +44,7 @@ class DeviceHttpDatasourceImpl implements DeviceHttpDatasource {
'请求详细参数',
level: 'DEBUG',
data: {
'url': 'http://1.95.137.212:8081/iot/device/bindDevice',
'url': '${HttpApiConsts.baseUrl}/iot/device/bindDevice',
'deviceId': deviceId,
'deviceAlias': deviceAlias,
},
@@ -122,7 +122,7 @@ class DeviceHttpDatasourceImpl implements DeviceHttpDatasource {
logger.logWithLevel(
'用户点击switchDevice方法API 请求开始',
level: 'INFO',
data: {'url': 'http://1.95.137.212:8081/iot/device/switchDevice'},
data: {'url': '${HttpApiConsts.baseUrl}/iot/device/switchDevice'},
);
var response = await dio.post(
HttpApiConsts.switchDevice,
@@ -138,7 +138,7 @@ class DeviceHttpDatasourceImpl implements DeviceHttpDatasource {
'请求详细参数',
level: 'DEBUG',
data: {
'url': 'http://1.95.137.212:8081/iot/device/switchDevice',
'url': '${HttpApiConsts.baseUrl}/iot/device/switchDevice',
'platform': platform,
'deviceId': deviceId,
},
@@ -219,12 +219,12 @@ class DeviceHttpDatasourceImpl implements DeviceHttpDatasource {
'请求详细参数',
level: 'DEBUG',
data: {
'url': 'http://1.95.137.212:8081/iot/device/userDevice',
'url': '${HttpApiConsts.baseUrl}/iot/device/userDevice',
'tenantName': deviceame,
},
);
final response = await dio.get(
'http://1.95.137.212:8081/iot/device/userDevice',
'${HttpApiConsts.baseUrl}/iot/device/userDevice',
queryParameters: {'tenantName': deviceame},
// options: Options(
// headers: {

View File

@@ -11,6 +11,7 @@ import 'package:maibu_satabot_v2/features/devices/domain/errors/device_failure.d
import 'package:maibu_satabot_v2/features/devices/domain/repositories/device_hostrity_work_repository.dart';
import '../../../../core/logging/i_logger_service.dart';
import '../../../../core/consts/http_api_consts.dart';
class DeviceHostrityWorkRepositoryImpl implements DeviceHostrityWorkRepositoryRepository {
final Dio client;
@@ -35,7 +36,7 @@ class DeviceHostrityWorkRepositoryImpl implements DeviceHostrityWorkRepositoryRe
_logger.logWithLevel('用户信息获取成功,Token: $token', level: 'info');
final response = await client.get(
'http://1.95.137.212:8081/iot/device/getDeviceRunStatistics?deviceId=$deviceId',
'${HttpApiConsts.baseUrl}/iot/device/getDeviceRunStatistics?deviceId=$deviceId',
options: Options(headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer $token'}),
);

View File

@@ -37,6 +37,7 @@ class DeviceTaskRepositoryImpl implements DeviceTaskRepository {
required int taskId,
required int orgId,
required int siteId,
bool forceCancel = true,
}) async {
try {
final result = await datasource.cancelTask(
@@ -44,6 +45,7 @@ class DeviceTaskRepositoryImpl implements DeviceTaskRepository {
taskId: taskId,
orgId: orgId,
siteId: siteId,
forceCancel: forceCancel,
);
return Right(result);
} catch (e) {

View File

@@ -5,6 +5,7 @@ import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:xml/xml.dart';
import '../../../../core/consts/http_api_consts.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/storage/user_storage.dart';
import '../../domain/repositories/path_repository.dart';
@@ -47,7 +48,7 @@ class PathRepositoryImpl implements PathRepository {
required String userId,
required String jsonData,
}) async {
final url = Uri.parse('http://1.95.137.212:8081/iot/workRecord/add');
final url = Uri.parse('${HttpApiConsts.baseUrl}/iot/workRecord/add');
final headers = {'Content-Type': 'application/json'};
final body = jsonEncode({
'workName': workName,
@@ -74,7 +75,7 @@ class PathRepositoryImpl implements PathRepository {
}) async {
final timestamp = DateTime.now().millisecondsSinceEpoch;
final url = Uri.parse(
'http://1.95.137.212:8081/iot/workRecord/selectByUserId',
'${HttpApiConsts.baseUrl}/iot/workRecord/selectByUserId',
).replace(queryParameters: {'userId': userId, '_t': timestamp.toString()});
try {
@@ -103,7 +104,7 @@ class PathRepositoryImpl implements PathRepository {
final timestamp = DateTime.now().millisecondsSinceEpoch;
final url =
Uri.parse(
'http://1.95.137.212:8081/iot/workRecord/deleteByWorkName',
'${HttpApiConsts.baseUrl}/iot/workRecord/deleteByWorkName',
).replace(
queryParameters: {'workName': workName, '_t': timestamp.toString()},
);
@@ -132,7 +133,7 @@ class PathRepositoryImpl implements PathRepository {
final timestamp = DateTime.now().millisecondsSinceEpoch;
final url =
Uri.parse(
'http://1.95.137.212:8081/iot/workRecord/selectByWorkName',
'${HttpApiConsts.baseUrl}/iot/workRecord/selectByWorkName',
).replace(
queryParameters: {'workName': workName, '_t': timestamp.toString()},
);
@@ -268,7 +269,7 @@ class PathRepositoryImpl implements PathRepository {
final timestamp = DateTime.now().millisecondsSinceEpoch;
final url =
Uri.parse(
'http://1.95.137.212:8081/iot/workRecord/selectBySiteId',
'${HttpApiConsts.baseUrl}/iot/workRecord/selectBySiteId',
).replace(
queryParameters: {
'siteId': siteId.toString(),
@@ -508,7 +509,7 @@ class PathRepositoryImpl implements PathRepository {
}
/// 鍒涘缓璁惧<E79281>浠诲姟锛堥€氳繃鎺ュ彛鎵ц<E98EB5>浣滀笟锛?
/// 鎺ュ彛鍦板潃: http://1.95.137.212:8081/iot/deviceTask/createDeviceTask
/// 鎺ュ彛鍦板潃: ${HttpApiConsts.baseUrl}/iot/deviceTask/createDeviceTask
/// 鍏ュ弬: {"deviceId":"...","routeId":76,"siteId":22,"orgId":5}
/// 杩斿洖: 鍒涘缓鎴愬姛鐨勪换鍔<E68DA2>D
@override
@@ -531,7 +532,7 @@ class PathRepositoryImpl implements PathRepository {
try {
final response = await dio.post(
'http://1.95.137.212:8081/iot/deviceTask/createDeviceTask',
'${HttpApiConsts.baseUrl}/iot/deviceTask/createDeviceTask',
data: body,
);

View File

@@ -18,6 +18,7 @@ abstract class DeviceTaskRepository {
required int taskId,
required int orgId,
required int siteId,
bool forceCancel = true,
});
/// 暂停任务

View File

@@ -15,6 +15,7 @@ class CancelTaskUseCase implements BaseUseCase<bool, CancelTaskParams> {
taskId: params.taskId,
orgId: params.orgId,
siteId: params.siteId,
forceCancel: params.forceCancel,
);
}
}
@@ -24,11 +25,13 @@ class CancelTaskParams {
final int taskId;
final int orgId;
final int siteId;
final bool forceCancel;
CancelTaskParams({
required this.deviceId,
required this.taskId,
required this.orgId,
required this.siteId,
this.forceCancel = true,
});
}

View File

@@ -7,10 +7,12 @@ import 'package:maibu_satabot_v2/features/devices/domain/entities/running_status
import 'package:maibu_satabot_v2/features/devices/domain/entities/gps_entity.dart';
import 'package:maibu_satabot_v2/core/network/protocol_decoder.dart';
import 'package:maibu_satabot_v2/core/network/mqtt/domain/repositories/task_message_repository.dart';
import 'package:maibu_satabot_v2/core/network/mqtt/domain/repositories/mower_realtime_repository.dart';
import 'package:maibu_satabot_v2/core/network/mqtt/domain/entities/task_arrive_entity.dart';
import 'package:maibu_satabot_v2/core/network/mqtt/domain/entities/task_status_entity.dart';
import '../../../../core/logging/i_logger_service.dart';
import '../../../../core/logging/log_time.dart';
import '../../../../core/network/net_message_dispatcher.dart';
import '../../../../core/network/tcp/tcp_client.dart';
import 'device_status_event.dart';
@@ -20,6 +22,10 @@ import 'devices_cubit.dart';
// 定义全局的TaskMessageRepository获取方式
TaskMessageRepository get _taskMessageRepo => GetIt.I<TaskMessageRepository>();
// 🔥 MQTT机器状态数据源(替代TCP 0x02)
MowerRealtimeRepository get _mowerRealtimeRepo =>
GetIt.I<MowerRealtimeRepository>();
class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
final NetMessageDispatcher _dispatcher;
final TcpClient tcpClient; // 🔥 新增:直接访问 TcpClient
@@ -30,6 +36,10 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
StreamSubscription? _mqttArriveSubscription;
StreamSubscription? _mqttStatusSubscription;
// 🔥 MQTT机器状态订阅(车辆实时+定位,替代TCP 0x02)
StreamSubscription? _mqttVehicleSubscription;
StreamSubscription? _mqttLocationSubscription;
// 🔥 节流相关:500ms节流控制0x02数据推送频率
Timer? _throttleTimer;
static const _throttleDuration = Duration(milliseconds: 500);
@@ -42,11 +52,20 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
// 🔥 当前监听的设备ID
String? _currentDeviceId;
// 🔥 当前 MQTT 机器状态监听的设备 SN(用于换设备时清空旧缓存)
String? _currentListeningSn;
// 🔥 MQTT机器状态收帧计数(用于链路日志)
int _mqttVehicleFrameCount = 0;
int _mqttLocationFrameCount = 0;
DeviceStatusBloc(this._dispatcher, {TcpClient? client})
: tcpClient = client ?? GetIt.I<TcpClient>(),
super(DeviceStatusInitial()) {
// 🔥 核心改动:直接在构造函数中建立TCP监听,类似RemoteControlCubit
_initDirectTcpListener();
// 🔥 已停用:机器状态数据源已改为MQTT(见 startMqttRealtimeListening,
// 由用户切换设备时触发)。TCP监听逻辑保留,回滚时取消下行注释即可。
// _initDirectTcpListener();
// 🔥 初始化MQTT到达点监听
_initMqttArriveListener();
@@ -191,6 +210,101 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
_logger.log('✅ [DeviceStatusBloc] 直接TCP监听器已建立完成');
}
/// 🔥 MQTT机器状态监听(替代TCP 0x02数据源)
///
/// [deviceSn] 与TCP时代 switchDevice / connectBySwitch 的设备号获取方式一致
/// (即 DeviceEntity.deviceName),由用户点击设备列表切换设备时调用。
///
/// 第一阶段:订阅并打印原始报文;确认格式后在第二阶段解析为
/// RunningStatusEntity + GPSEntity,复用现有500ms节流逻辑。
void startMqttRealtimeListening(String deviceSn) {
if (deviceSn.isEmpty) {
debugPrint('${LogTime.now()} ⚠️ [DeviceStatusBloc] MQTT订阅跳过:设备SN为空');
return;
}
debugPrint('${LogTime.now()} 🚀 [DeviceStatusBloc] 启动MQTT机器状态监听: $deviceSn');
// 🔥 换设备兜底:SN 变化时清空上一个设备的缓存与状态,
// 防止旧坐标通过 copyWith(base) 残留污染新设备数据(即使入口漏调 Reset 也能兜住)
if (_currentListeningSn != deviceSn) {
_throttleTimer?.cancel();
_throttleTimer = null;
_cachedStatus = null;
_cachedGps = null;
_currentListeningSn = deviceSn;
if (!isClosed) {
emit(DeviceStatusInitial());
}
debugPrint('${LogTime.now()} 🧹 [DeviceStatusBloc] 切换设备,已清空旧缓存: $deviceSn');
}
// 按SN订阅(数据源内部自动退旧订新、同SN去重)
_mowerRealtimeRepo.startListening(deviceSn: deviceSn).then((result) {
result.fold(
(failure) => debugPrint(
'${LogTime.now()} ❌ [DeviceStatusBloc] MQTT机器状态订阅失败: ${failure.message}',
),
(_) => debugPrint(
'${LogTime.now()} ✅ [DeviceStatusBloc] MQTT机器状态订阅完成: $deviceSn',
),
);
});
// 切换设备时重置收帧计数,便于观察新设备的首帧与频率
_mqttVehicleFrameCount = 0;
_mqttLocationFrameCount = 0;
// 流监听只建立一次,第一阶段仅打印解析结果验证链路
_mqttVehicleSubscription ??= _mowerRealtimeRepo.vehicleStream.listen((
message,
) {
_mqttVehicleFrameCount++;
debugPrint(
'${LogTime.now()} 📊 [MQTT-机器状态] 车辆实时第$_mqttVehicleFrameCount帧 type=${message.type} 数据点=${message.data.length}',
);
// TODO 第二阶段:将数据点映射到 RunningStatusEntity 字段,复用500ms节流emit
});
_mqttLocationSubscription ??= _mowerRealtimeRepo.locationStream.listen((
location,
) {
_mqttLocationFrameCount++;
debugPrint(
'${LogTime.now()} 📍 [MQTT-location] 定位第$_mqttLocationFrameCount帧 lat=${location.latitude}, lng=${location.longitude}, heading=${location.heading}',
);
// 🔥 核心:用 MQTT location 数据更新缓存并节流 emit
if (location.isValid) {
// 更新 _cachedGps
_cachedGps = GPSEntity(location.latitude!, location.longitude!);
// 更新 _cachedStatus 中的航向角、坐标、定位质量(复用已有缓存或新建空壳)
final base = _cachedStatus ?? RunningStatusEntity();
_cachedStatus = base.copyWith(
latitude: location.latitude!,
longitude: location.longitude!,
yaw: location.heading ?? base.yaw,
qual: _fixStatusToQual(location.fixStatus, base.qual),
);
// 500ms节流:与 TCP 0x02 时代逻辑一致
if (_throttleTimer == null || !_throttleTimer!.isActive) {
_throttleTimer = Timer(_throttleDuration, () {
_emitCachedStatus();
});
}
} else {
debugPrint(
'${LogTime.now()} ⚠️ [MQTT-location] 坐标无效,跳过 - lat=${location.latitude}, lng=${location.longitude}',
);
}
});
debugPrint('${LogTime.now()} ✅ [DeviceStatusBloc] MQTT机器状态监听已启动: $deviceSn');
_logger.log('✅ [DeviceStatusBloc] MQTT机器状态监听已启动: $deviceSn');
}
// 🔥 初始化MQTT到达点监听
void _initMqttArriveListener() {
debugPrint('🔗 [DeviceStatusBloc] 初始化MQTT到达点监听器');
@@ -301,10 +415,28 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
_currentDeviceId = deviceId;
}
/// 🔥 将 MQTT location/post 的 fix_status 字符串映射为 qual 整数值
int _fixStatusToQual(String? fixStatus, int fallback) {
if (fixStatus == null) return fallback;
switch (fixStatus) {
case 'no_fix': return 0;
case 'single': return 1;
case 'dgnss': return 2;
case 'ins': return 3;
case 'rtk_fixed': return 4;
case 'rtk_float': return 5;
case 'single_no_heading': return 6;
case 'dgnss_no_heading': return 7;
case 'rtk_fixed_no_heading': return 8;
case 'rtk_float_no_heading': return 9;
default: return fallback;
}
}
// 🔥 节流发射:500ms到期后发射缓存的最新数据
void _emitCachedStatus() {
if (_cachedStatus != null && _cachedGps != null && !isClosed) {
debugPrint('📤 [0x02] 节流发射 | 电压=${_cachedStatus!.voltage}V 电量=${_cachedStatus!.battery}% 经纬度=(${_cachedGps!.latitude}, ${_cachedGps!.longitude}) | ${DateTime.now().toString().substring(11, 19)}');
debugPrint('📤 [节流发射] 电压=${_cachedStatus!.voltage}V 电量=${_cachedStatus!.battery}% 经纬度=(${_cachedGps!.latitude}, ${_cachedGps!.longitude}) yaw=${_cachedStatus!.yaw} | ${DateTime.now().toString().substring(11, 19)}');
emit(DeviceStatusUpdated(_cachedStatus!, _cachedGps!));
}
}
@@ -319,9 +451,18 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
'🔄 [DeviceStatusBloc] 收到重置事件:清空状态 - 当前状态:${state.runtimeType}',
);
// 🔥 关键修复:清空缓存与节流器,避免上一个设备的坐标/状态残留,
// 并通过 copyWith(base) 污染新设备的数据
_throttleTimer?.cancel();
_throttleTimer = null;
_cachedStatus = null;
_cachedGps = null;
_mqttVehicleFrameCount = 0;
_mqttLocationFrameCount = 0;
// 只 emit 初始状态,让 UI 清除旧设备的数据
emit(DeviceStatusInitial());
debugPrint('⚠️ [DeviceStatusBloc] 已emit DeviceStatusInitial');
debugPrint('⚠️ [DeviceStatusBloc] 已emit DeviceStatusInitial,缓存已清空');
}
Future<void> _handleDeviceStatusLoaded(
@@ -423,6 +564,12 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
_mqttStatusSubscription?.cancel();
_mqttStatusSubscription = null;
// 🔥 清理MQTT机器状态订阅
_mqttVehicleSubscription?.cancel();
_mqttVehicleSubscription = null;
_mqttLocationSubscription?.cancel();
_mqttLocationSubscription = null;
// 🔥 清理节流timer和缓存
_throttleTimer?.cancel();
_throttleTimer = null;
@@ -431,6 +578,7 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
// 🔥 重置设备ID
_currentDeviceId = null;
_currentListeningSn = null;
return Future.value();
}

View File

@@ -34,21 +34,17 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
final userCubit = GetIt.I<AppUserCubit>();
final user = userCubit.state.user;
if (user == null) {
emit(state.copyWith(
isLoading: false,
errorMessage: '用户未登录',
));
emit(state.copyWith(isLoading: false, errorMessage: '用户未登录'));
return;
}
// 获取场站ID
// 获取场站信息(siteId 和 orgId 都从当前场站取)
final siteCubit = GetIt.I<SiteCubit>();
final siteId = siteCubit.state.selectedSite?.id;
final selectedSite = siteCubit.state.selectedSite;
final siteId = selectedSite?.id;
final orgId = selectedSite?.orgId ?? 0;
if (siteId == null) {
emit(state.copyWith(
isLoading: false,
errorMessage: '未选择场站',
));
emit(state.copyWith(isLoading: false, errorMessage: '未选择场站'));
return;
}
@@ -57,125 +53,156 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
GetDeviceTaskPoolParams(
userId: user.userId ?? '',
siteId: siteId,
orgId: user.orgId ?? 0,
orgId: orgId,
),
);
result.fold(
(failure) {
_logger.logWithLevel('❌ 获取任务池失败: ${failure.message}');
emit(state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(failure.message),
shouldShowError: true, // 🔥 标记需要显示错误弹窗
));
emit(
state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(failure.message),
shouldShowError: true, // 🔥 标记需要显示错误弹窗
),
);
},
(taskList) {
// 🔥 过滤出当前设备 + 活跃状态的任务(NEW, EXECUTING, PAUSE)
// 🔥 过滤出当前设备 + 活跃状态的任务(NEW, EXECUTING, PAUSE, CANCELING, FAILED)
final activeTasks = taskList.where((task) {
// 1. 设备号匹配
if (task.deviceId != deviceId) return false;
// 2. 状态过滤:只保留新建、执行中、暂停中的任务
// 2. 状态过滤:保留新建、执行中、暂停中、取消中、执行失败的任务
final status = task.taskStatus;
return status == 'NEW' || // 新建
status == 'EXECUTING' || // 执行中
status == 'PAUSE'; // 暂停中
return status == 'NEW' || // 新建
status == 'EXECUTING' || // 执行中
status == 'PAUSE' || // 暂停中
status == 'CANCELING' || // 取消中(用户可处理)
status == 'FAILED'; // 执行失败(用户可恢复或取消)
}).toList();
_logger.logWithLevel(
'✅ 找到 ${activeTasks.length} 个活跃任务',
'✅ [fetchAndFilterTask] deviceId=$deviceId, 任务池总数=${taskList.length}, 过滤后活跃任务数=${activeTasks.length}',
);
for (final t in taskList) {
_logger.logWithLevel(
' - taskId=${t.id}, deviceId=${t.deviceId}, status=${t.taskStatus}',
);
}
// 🔥 如果有多个任务,保存所有选项供用户选择
// 如果只有1个,直接选中
final currentTask = activeTasks.isNotEmpty ? activeTasks.first : null;
emit(state.copyWith(
isLoading: false,
taskPool: taskList,
currentTask: currentTask,
currentTaskId: currentTask?.id,
activeTasks: activeTasks,
));
emit(
state.copyWith(
isLoading: false,
taskPool: taskList,
currentTask: currentTask,
currentTaskId: currentTask?.id,
// 🔥 活跃任务为空时必须显式清空,否则 copyWith 会保留旧任务导致卡片一直显示
clearCurrentTask: currentTask == null,
clearCurrentTaskId: currentTask == null,
activeTasks: activeTasks,
),
);
},
);
} catch (e) {
_logger.logWithLevel('❌ 获取任务池异常: $e');
emit(state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(e),
shouldShowError: true, // 🔥 标记需要显示错误弹窗
));
emit(
state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(e),
shouldShowError: true, // 🔥 标记需要显示错误弹窗
),
);
}
}
/// 取消任务
Future<void> cancelTask(String deviceId) async {
Future<void> cancelTask(String deviceId, {bool forceCancel = true}) async {
final taskId = state.currentTaskId;
if (taskId == null) {
emit(state.copyWith(errorMessage: '无可用任务'));
return;
}
emit(state.copyWith(
isLoading: true,
operationType: DeviceTaskOperationType.cancel,
));
emit(
state.copyWith(
isLoading: true,
operationType: DeviceTaskOperationType.cancel,
),
);
try {
final userCubit = GetIt.I<AppUserCubit>();
final user = userCubit.state.user;
final siteCubit = GetIt.I<SiteCubit>();
final siteId = siteCubit.state.selectedSite?.id;
final selectedSite = siteCubit.state.selectedSite;
final siteId = selectedSite?.id;
final orgId = selectedSite?.orgId ?? 0;
if (user == null || siteId == null) {
emit(state.copyWith(
isLoading: false,
errorMessage: '参数不完整',
operationType: DeviceTaskOperationType.none,
));
emit(
state.copyWith(
isLoading: false,
errorMessage: '参数不完整',
operationType: DeviceTaskOperationType.none,
),
);
return;
}
final params = CancelTaskParams(
deviceId: deviceId,
taskId: taskId,
orgId: user.orgId ?? 0,
orgId: orgId,
siteId: siteId,
forceCancel: forceCancel,
);
_logger.logWithLevel('[取消任务] 请求: POST /iot/deviceTask/cancelTask');
_logger.logWithLevel('[取消任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=${user.orgId ?? 0}, siteId=$siteId');
_logger.logWithLevel(
'[取消任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=$orgId, siteId=$siteId, forceCancel=$forceCancel',
);
final result = await _cancelTaskUseCase.call(params);
result.fold(
(failure) {
_logger.logWithLevel('[取消任务] 响应失败: ${failure.message}');
_logger.logWithLevel('❌ 取消任务失败: ${failure.message}');
emit(state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(failure.message),
operationType: DeviceTaskOperationType.none,
shouldShowError: true, // 🔥 标记需要显示错误弹窗
));
emit(
state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(failure.message),
operationType: DeviceTaskOperationType.none,
shouldShowError: true, // 🔥 标记需要显示错误弹窗
),
);
},
(success) {
_logger.logWithLevel('[取消任务] 响应成功: $success');
_logger.logWithLevel('✅ 取消任务成功');
emit(state.copyWith(
isLoading: false,
operationType: DeviceTaskOperationType.none,
));
emit(
state.copyWith(
isLoading: false,
operationType: DeviceTaskOperationType.none,
),
);
},
);
} catch (e) {
_logger.logWithLevel('❌ 取消任务异常: $e');
emit(state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(e),
operationType: DeviceTaskOperationType.none,
shouldShowError: true, // 🔥 标记需要显示错误弹窗
));
emit(
state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(e),
operationType: DeviceTaskOperationType.none,
shouldShowError: true, // 🔥 标记需要显示错误弹窗
),
);
}
}
@@ -187,64 +214,78 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
return;
}
emit(state.copyWith(
isLoading: true,
operationType: DeviceTaskOperationType.pause,
));
emit(
state.copyWith(
isLoading: true,
operationType: DeviceTaskOperationType.pause,
),
);
try {
final userCubit = GetIt.I<AppUserCubit>();
final user = userCubit.state.user;
final siteCubit = GetIt.I<SiteCubit>();
final siteId = siteCubit.state.selectedSite?.id;
final selectedSite = siteCubit.state.selectedSite;
final siteId = selectedSite?.id;
final orgId = selectedSite?.orgId ?? 0;
if (user == null || siteId == null) {
emit(state.copyWith(
isLoading: false,
errorMessage: '参数不完整',
operationType: DeviceTaskOperationType.none,
));
emit(
state.copyWith(
isLoading: false,
errorMessage: '参数不完整',
operationType: DeviceTaskOperationType.none,
),
);
return;
}
final params2 = PauseTaskParams(
deviceId: deviceId,
taskId: taskId,
orgId: user.orgId ?? 0,
orgId: orgId,
siteId: siteId,
);
_logger.logWithLevel('[暂停任务] 请求: POST /iot/deviceTask/pauseTask');
_logger.logWithLevel('[暂停任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=${user.orgId ?? 0}, siteId=$siteId');
_logger.logWithLevel(
'[暂停任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=$orgId, siteId=$siteId',
);
final result = await _pauseTaskUseCase.call(params2);
result.fold(
(failure) {
_logger.logWithLevel('[暂停任务] 响应失败: ${failure.message}');
_logger.logWithLevel('❌ 暂停任务失败: ${failure.message}');
emit(state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(failure.message),
operationType: DeviceTaskOperationType.none,
shouldShowError: true, // 🔥 标记需要显示错误弹窗
));
emit(
state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(failure.message),
operationType: DeviceTaskOperationType.none,
shouldShowError: true, // 🔥 标记需要显示错误弹窗
),
);
},
(success) {
_logger.logWithLevel('[暂停任务] 响应成功: $success');
_logger.logWithLevel('✅ 暂停任务成功');
emit(state.copyWith(
isLoading: false,
operationType: DeviceTaskOperationType.none,
));
emit(
state.copyWith(
isLoading: false,
operationType: DeviceTaskOperationType.none,
),
);
},
);
} catch (e) {
_logger.logWithLevel('❌ 暂停任务异常: $e');
emit(state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(e),
operationType: DeviceTaskOperationType.none,
shouldShowError: true, // 🔥 标记需要显示错误弹窗
));
emit(
state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(e),
operationType: DeviceTaskOperationType.none,
shouldShowError: true, // 🔥 标记需要显示错误弹窗
),
);
}
}
@@ -256,88 +297,111 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
return;
}
emit(state.copyWith(
isLoading: true,
operationType: DeviceTaskOperationType.recovery,
));
emit(
state.copyWith(
isLoading: true,
operationType: DeviceTaskOperationType.recovery,
),
);
try {
final userCubit = GetIt.I<AppUserCubit>();
final user = userCubit.state.user;
final siteCubit = GetIt.I<SiteCubit>();
final siteId = siteCubit.state.selectedSite?.id;
final selectedSite = siteCubit.state.selectedSite;
final siteId = selectedSite?.id;
final orgId = selectedSite?.orgId ?? 0;
if (user == null || siteId == null) {
emit(state.copyWith(
isLoading: false,
errorMessage: '参数不完整',
operationType: DeviceTaskOperationType.none,
));
emit(
state.copyWith(
isLoading: false,
errorMessage: '参数不完整',
operationType: DeviceTaskOperationType.none,
),
);
return;
}
final params3 = RecoveryTaskParams(
deviceId: deviceId,
taskId: taskId,
orgId: user.orgId ?? 0,
orgId: orgId,
siteId: siteId,
);
_logger.logWithLevel('[恢复任务] 请求: POST /iot/deviceTask/recoveryTask');
_logger.logWithLevel('[恢复任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=${user.orgId ?? 0}, siteId=$siteId');
_logger.logWithLevel(
'[恢复任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=$orgId, siteId=$siteId',
);
final result = await _recoveryTaskUseCase.call(params3);
result.fold(
(failure) {
_logger.logWithLevel('[恢复任务] 响应失败: ${failure.message}');
_logger.logWithLevel('❌ 恢复任务失败: ${failure.message}');
emit(state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(failure.message),
operationType: DeviceTaskOperationType.none,
shouldShowError: true, // 🔥 标记需要显示错误弹窗
));
emit(
state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(failure.message),
operationType: DeviceTaskOperationType.none,
shouldShowError: true, // 🔥 标记需要显示错误弹窗
),
);
},
(data) {
_logger.logWithLevel('[恢复任务] 响应成功: $data');
_logger.logWithLevel('✅ 恢复任务成功: $data');
emit(state.copyWith(
isLoading: false,
operationType: DeviceTaskOperationType.none,
));
emit(
state.copyWith(
isLoading: false,
operationType: DeviceTaskOperationType.none,
),
);
},
);
} catch (e) {
_logger.logWithLevel('❌ 恢复任务异常: $e');
emit(state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(e),
operationType: DeviceTaskOperationType.none,
shouldShowError: true, // 🔥 标记需要显示错误弹窗
));
emit(
state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(e),
operationType: DeviceTaskOperationType.none,
shouldShowError: true, // 🔥 标记需要显示错误弹窗
),
);
}
}
/// 更新当前任务ID(当选择新航线时调用)
void updateCurrentTaskId(int taskId) {
emit(state.copyWith(currentTaskId: taskId));
_logger.logWithLevel('🔄 更新当前任务ID: $taskId');
// 🔥 只有 activeTasks 非空时才设置 currentTask,避免信息栏一直显示
DeviceTaskEntity? taskEntity;
if (state.activeTasks.isNotEmpty) {
taskEntity = state.activeTasks.firstWhere(
(t) => t.id == taskId,
orElse: () => DeviceTaskEntity(
id: taskId,
deviceId: '',
taskStatus: 'EXECUTING',
taskStatusTranslate: '执行中',
),
);
}
emit(state.copyWith(currentTaskId: taskId, currentTask: taskEntity));
_logger.logWithLevel(
'🔄 更新当前任务ID: $taskId, activeTasks: ${state.activeTasks.length}, currentTask: ${taskEntity?.taskStatus ?? "null"}',
);
}
/// 🔥 手动选择任务(用户从弹窗中选择)
void selectTask(DeviceTaskEntity task) {
_logger.logWithLevel('✅ 用户选择任务ID: ${task.id}, 状态: ${task.taskStatus}');
emit(state.copyWith(
currentTask: task,
currentTaskId: task.id,
));
emit(state.copyWith(currentTask: task, currentTaskId: task.id));
}
/// 清除当前任务
void clearCurrentTask() {
emit(state.copyWith(
currentTask: null,
currentTaskId: null,
));
emit(state.copyWith(clearCurrentTask: true, clearCurrentTaskId: true));
_logger.logWithLevel('🧹 清除当前任务');
}

View File

@@ -32,7 +32,9 @@ class DeviceTaskState extends Equatable {
DeviceTaskState copyWith({
List<DeviceTaskEntity>? taskPool,
DeviceTaskEntity? currentTask,
bool clearCurrentTask = false, // 🔥 显式清空 currentTask(copyWith 传 null 无法置空)
int? currentTaskId,
bool clearCurrentTaskId = false, // 🔥 显式清空 currentTaskId
List<DeviceTaskEntity>? activeTasks, // 🔥 新增
bool? isLoading,
String? errorMessage,
@@ -41,8 +43,9 @@ class DeviceTaskState extends Equatable {
}) {
return DeviceTaskState(
taskPool: taskPool ?? this.taskPool,
currentTask: currentTask ?? this.currentTask,
currentTaskId: currentTaskId ?? this.currentTaskId,
currentTask: clearCurrentTask ? null : (currentTask ?? this.currentTask),
currentTaskId:
clearCurrentTaskId ? null : (currentTaskId ?? this.currentTaskId),
activeTasks: activeTasks ?? this.activeTasks, // 🔥 新增
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage,

View File

@@ -16,6 +16,7 @@ import 'package:maibu_satabot_v2/features/devices/domain/usecases/update_devicen
import '../../../../core/consts/tcp_consts.dart';
import '../../../../core/logging/i_logger_service.dart';
import '../../../../core/logging/log_time.dart';
import '../../../../core/network/tcp/tcp_client.dart';
import '../../../../core/network/mqtt/domain/repositories/task_message_repository.dart';
import '../../data/models/device_add_path_point_model.dart';
@@ -268,6 +269,7 @@ class DevicesCubit extends Cubit<DevicesState> {
/// 切换设备
Future<void> switchDevice(DeviceEntity device) async {
debugPrint('${LogTime.now()} 👆 [DevicesCubit] 开始切换设备: ${device.deviceName}');
// 保持现有列表,只改 loading
emit(state.copyWith(isLoading: true));
@@ -276,6 +278,7 @@ class DevicesCubit extends Cubit<DevicesState> {
result.fold(
// 失败处理
(l) {
debugPrint('${LogTime.now()} ❌ [DevicesCubit] 切换设备失败: ${device.deviceName}, 原因: ${l.message}');
emit(state.copyWith(isLoading: false, errorMessage: l.message));
selectDevice(device);
},
@@ -288,7 +291,12 @@ class DevicesCubit extends Cubit<DevicesState> {
// 重置设备状态 Bloc,清除旧设备图表数据
_deviceStatusBloc.add(DeviceStatusReset());
debugPrint('✅ 新设备切换完成(HTTP-only,无新TCP连接): ${device.deviceName}');
// 🔥 机器状态数据源已切换为MQTT:用与TCP相同的方式获取设备号(deviceName),
// 订阅 mower/{sn}/property/* 主题(内部自动退订旧设备、同SN去重)
debugPrint('${LogTime.now()} 📡 [DevicesCubit] HTTP切换成功,开始MQTT订阅: ${device.deviceName}');
_deviceStatusBloc.startMqttRealtimeListening(device.deviceName);
debugPrint('${LogTime.now()} ✅ 新设备切换完成(HTTP-only,无新TCP连接): ${device.deviceName}');
emit(state.copyWith(isLoading: false, selectedDevice: device));
},
@@ -378,7 +386,7 @@ class DevicesCubit extends Cubit<DevicesState> {
emit(state.copyWith(isLoading: false, errorMessage: failure.message));
},
(records) {
// print('🔍 [DevicesCubit] 加载成功,记录数: ${records.length}');
print('🔍 [DevicesCubit] selectBySiteId 加载成功,记录数: ${records.length}');
// 将 WorkRecordEntity 转换为 Map<String, dynamic> 以兼容现有UI
final mappedRecords = records.map((record) {
// 🔥 核心修复:根据 jsonData 类型决定存储方式
@@ -390,14 +398,27 @@ class DevicesCubit extends Cubit<DevicesState> {
} else if (record.jsonData is WorkRecordJsonData) {
jsonDataStr = _workRecordJsonDataToJson(record.jsonData as WorkRecordJsonData);
}
return <String, dynamic>{
final mapped = <String, dynamic>{
'id': record.id.toString(),
'workName': record.workName,
'imgUrl': record.imgUrl ?? '',
'jsonData': jsonDataStr,
};
print(' ├─ [${record.workName}] id=${record.id}, jsonData长度=${jsonDataStr?.length ?? "null"}');
// 打印 jsonData 内部的 path/outer 信息
if (jsonDataStr != null && jsonDataStr.isNotEmpty) {
try {
final decoded = jsonDecode(jsonDataStr);
if (decoded is Map) {
print(' │ path类型=${decoded['path']?.runtimeType}, outer类型=${decoded['outer']?.runtimeType}, planModel=${decoded['planModel']}');
if (decoded['path'] is List) print(' │ path长度=${(decoded['path'] as List).length}');
if (decoded['outer'] is List) print(' │ outer长度=${(decoded['outer'] as List).length}');
}
} catch (_) {}
}
return mapped;
}).toList();
// print('🔍 [DevicesCubit] 转换后的数据: $mappedRecords');
print('🔍 [DevicesCubit] 转换完成,emit workRecords 数量: ${mappedRecords.length}');
emit(state.copyWith(isLoading: false, workRecords: mappedRecords));
},
);
@@ -429,7 +450,7 @@ class DevicesCubit extends Cubit<DevicesState> {
state.copyWith(
isLoading: false,
workRecords: state.workRecords
?.where((record) => record != workName)
?.where((record) => record['workName'] != workName)
.toList(),
),
),
@@ -439,15 +460,36 @@ class DevicesCubit extends Cubit<DevicesState> {
/// 加载指定作业名的路径数据
Future<void> loadSelectedPath(String workName) async {
emit(state.copyWith(isLoading: true));
debugPrint('📡 [loadSelectedPath] 开始查询, workName=$workName');
try {
final result = await _selectWorkRecordUseCase.call(workName);
result.fold(
(failure) => emit(
state.copyWith(isLoading: false, errorMessage: failure.message),
),
(data) => emit(state.copyWith(isLoading: false, pathData: data)),
(failure) {
debugPrint('❌ [loadSelectedPath] 接口失败: ${failure.message}');
emit(
state.copyWith(isLoading: false, errorMessage: failure.message),
);
},
(data) {
debugPrint('✅ [loadSelectedPath] 接口成功, 记录数: ${data.length}');
if (data.isNotEmpty) {
final first = data.first;
debugPrint(' ├─ 第一条记录 keys: ${first.keys.toList()}');
debugPrint(' ├─ path 类型: ${first['path']?.runtimeType}, 长度: ${(first['path'] as List?)?.length ?? "null"}');
debugPrint(' ├─ outer 类型: ${first['outer']?.runtimeType}, 长度: ${(first['outer'] as List?)?.length ?? "null"}');
debugPrint(' └─ planModel: ${first['planModel']}');
if (first['path'] is List && (first['path'] as List).isNotEmpty) {
debugPrint(' └─ path[0]: ${(first['path'] as List).first}');
}
if (first['outer'] is List && (first['outer'] as List).isNotEmpty) {
debugPrint(' └─ outer[0]: ${(first['outer'] as List).first}');
}
}
emit(state.copyWith(isLoading: false, pathData: data));
},
);
} catch (e) {
debugPrint('❌ [loadSelectedPath] 异常: $e');
emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
}
}

View File

@@ -1,11 +1,13 @@
enum WorkMode {
bow(0), // 作业模式:bow,值为0
custom(2); // 作业模式:custom,值为2
bow(0, 'coverage'), // 作业模式:bow,值为0;后端 mode="coverage"(覆盖/区域)
custom(2, 'single_point'); // 作业模式:custom,值为2;后端 mode="single_point"(自定义/单点)
// 定义枚举的数值属性
final int value;
// 🔥 对齐 Web 端:workRecord.mode 字段(Web 注释"区域还是单点")
final String modeCode;
// 枚举构造函数(必须是const)
const WorkMode(this.value);
const WorkMode(this.value, this.modeCode);
}
enum RobotMode { point, robot }

View File

@@ -0,0 +1,595 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import 'package:path_provider/path_provider.dart';
import 'package:xml/xml.dart';
/// GeoServer 基地址(公司端)
const String kGeoserverBase = 'http://1.95.137.212:7900/geoserver';
/// GeoServer 工作区名(后端 siteMapName 形如 workSpace:suzhou)
const String kGeoWorkspace = 'workSpace';
/// WMS 瓦片网络超时。大图层(如 neimeng/zhongke)一屏十几片、GeoServer 实时渲染排队,
/// 单片实际等待可能远超 30s;浏览器预览肯等所以最终能出图,App 若超时太短会集体放弃、
/// 返回透明图导致“一直出不来”。故放宽到 90s,并配合下方并发闸门分批取图。
const Duration kWmsTileTimeout = Duration(seconds: 90);
/// 各场站高清图层的真实范围(取自 GeoServer GetCapabilities,原生 EPSG:4326)
/// key = 后端下发的 siteMapName(形如 workSpace:suzhou)
final Map<String, LatLngBounds> kStationLayerBounds = {
'workSpace:liaoding': LatLngBounds(
const LatLng(41.19998369457442, 120.59074514010273),
const LatLng(41.202002066385475, 120.59343722827124),
),
'workSpace:loudi': LatLngBounds(
const LatLng(27.846682995656035, 111.52814279952669),
const LatLng(27.847939704917945, 111.52928081352842),
),
'workSpace:neimeng': LatLngBounds(
const LatLng(41.321581124009896, 112.62468603332228),
const LatLng(41.325573311689986, 112.63034797059984),
),
'workSpace:result': LatLngBounds(
const LatLng(27.36490549568922, 116.84490756003203),
const LatLng(27.36673908366302, 116.84661654295478),
),
'workSpace:sanxiamen': LatLngBounds(
const LatLng(34.732748259670494, 111.43051478686061),
const LatLng(34.734205267863345, 111.43201022647355),
),
'workSpace:suzhou': LatLngBounds(
const LatLng(31.082450793459877, 120.83906484264836),
const LatLng(31.084141983135456, 120.84164596446476),
),
'workSpace:zhongke': LatLngBounds(
const LatLng(32.0406442971753, 120.8057183175354),
const LatLng(32.045144217672636, 120.81100452381844),
),
};
/// 取某场站图层的范围中心点(用作进场默认定位);未知/为空返回 null
LatLng? stationLayerCenter(String? siteMapName) {
if (siteMapName == null) return null;
// 优先用动态拉取的范围(StationLayerRepo),其内部已含硬编码兜底
final b = StationLayerRepo.instance.bounds[siteMapName];
if (b == null) return null;
return LatLng((b.south + b.north) / 2, (b.west + b.east) / 2);
}
/// 叠加层取瓦片方式开关:
/// - false(当前):WMS GetMap 兜底,不依赖 GWC 缓存
/// - true:WMTS GetTile(公司补配 GWC EPSG:3857 gridset 并 Seed 后切换)
const bool kUseWmtsTile = false;
/// WMS 取图提供器:
/// 实测 GeoServer 对 workSpace:suzhou 只有原生 EPSG:4326 能出图,
/// 请求 EPSG:3857(WMS 1.1.1 / 1.3.0 均验证过)返回纯白图。
/// 因此这里按瓦片 XYZ 反算经纬度 bbox,以 EPSG:4326 请求 WMS。
class GeoWmsProvider extends TileProvider {
GeoWmsProvider({
required this.layerName,
required this.bounds,
this.maxLevel = 21,
});
final String layerName;
final LatLngBounds bounds;
final int maxLevel;
/// Web 墨卡托地球周长(米)
static const double _full = 40075016.685578488;
/// Web 墨卡托 Y(米)→ 纬度(度)
static double _mercatorYToLat(double y) {
final t = y / 6378137.0;
final sinh = (math.exp(t) - math.exp(-t)) / 2; // dart:math 无 sinh,手动实现
return math.atan(sinh) * 180 / math.pi;
}
@override
ImageProvider<Object> getImage(
TileCoordinates coordinates,
TileLayer options,
) {
if (coordinates.z > maxLevel) {
return MemoryImage(TileProvider.transparentImage);
}
final span = _full / math.pow(2, coordinates.z);
final westM = -_full / 2 + coordinates.x * span;
final eastM = westM + span;
final northM = _full / 2 - coordinates.y * span;
final southM = northM - span;
final westLng = westM / _full * 360;
final eastLng = eastM / _full * 360;
final southLat = _mercatorYToLat(southM);
final northLat = _mercatorYToLat(northM);
// 与图层实际范围不相交的瓦片直接返回透明图,不发无谓请求
final intersects = westLng < bounds.east &&
eastLng > bounds.west &&
southLat < bounds.north &&
northLat > bounds.south;
if (!intersects) {
return MemoryImage(TileProvider.transparentImage);
}
final url = Uri.parse('$kGeoserverBase/wms').replace(
queryParameters: {
'SERVICE': 'WMS',
'REQUEST': 'GetMap',
'VERSION': '1.1.1',
'LAYERS': layerName,
'BBOX': '${westLng.toStringAsFixed(6)},${southLat.toStringAsFixed(6)},'
'${eastLng.toStringAsFixed(6)},${northLat.toStringAsFixed(6)}',
'WIDTH': '256',
'HEIGHT': '256',
'FORMAT': 'image/png',
'TRANSPARENT': 'true',
'SRS': 'EPSG:4326',
'STYLES': '',
},
);
return CachedWmsImage(url.toString());
}
}
/// GeoServer 场站无人机高清影像叠加层(WGS84 基准,覆盖在 ESRI 底图上)
/// 按当前选中场站的 siteMapName 动态加载对应图层;地块很小,默认 minZoom = 16
class GeoSuzhouOverlay extends StatelessWidget {
const GeoSuzhouOverlay({
super.key,
this.siteMapName,
this.minZoom = 16,
this.maxZoom = 21,
});
/// 当前选中场站对应的 GeoServer 图层名(形如 workSpace:suzhou)
final String? siteMapName;
/// 小于该级别不请求瓦片(地块太小,低层级看不见)
final double minZoom;
/// 大于该级别不请求瓦片
final double maxZoom;
@override
Widget build(BuildContext context) {
final layer = siteMapName;
final bounds = layer == null ? null : kStationLayerBounds[layer];
// 未选中场站或图层未知 → 不渲染叠加层
if (layer == null || bounds == null) {
return const SizedBox.shrink();
}
if (kUseWmtsTile) {
// === WMTS 版:公司把 EPSG:3857 gridset 加入 GWC 并 Seed 后生效 ===
return TileLayer(
urlTemplate: '$kGeoserverBase/gwc/service/wmts'
'?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0'
'&LAYER=$layer&TILEMATRIXSET=EPSG:3857'
'&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/png',
userAgentPackageName: 'com.example.app',
minZoom: minZoom,
maxZoom: maxZoom,
maxNativeZoom: maxZoom.toInt(),
tileBounds: bounds,
);
}
// === WMS 版(当前兜底):按原生 EPSG:4326 请求,GeoServer 实时渲染 ===
return TileLayer(
tileProvider: GeoWmsProvider(
layerName: layer,
bounds: bounds,
maxLevel: maxZoom.toInt(),
),
userAgentPackageName: 'com.example.app',
minZoom: minZoom,
maxZoom: maxZoom,
maxNativeZoom: maxZoom.toInt(),
tileBounds: bounds,
);
}
}
/// 全部场站高清叠加图层(一次性构建、复用实例,避免每次重建重复请求瓦片)。
/// 用法:在 FlutterMap 的 children 里 spread 进去 —— ...kAllStationTileLayers
/// 效果:滑到任意场站区域(zoom>=16)自动叠加对应高清图;每层各自 tileBounds,框外不请求。
final List<TileLayer> kAllStationTileLayers = kStationLayerBounds.entries
.map(
(entry) => TileLayer(
tileProvider: GeoWmsProvider(
layerName: entry.key,
bounds: entry.value,
maxLevel: 21,
),
userAgentPackageName: 'com.example.app',
minZoom: 16,
maxZoom: 21,
maxNativeZoom: 21,
tileBounds: entry.value,
),
)
.toList();
/// 带磁盘缓存 + 超时的 WMS 瓦片图:
/// 命中本地缓存直接解码(秒开);未命中则联网拉取(带超时),成功后写入缓存;
/// 失败/超时返回 1×1 透明图,避免异常刷屏。解决 WMS 实时渲染“每次都慢”的问题。
class CachedWmsImage extends ImageProvider<CachedWmsImage> {
CachedWmsImage(this.url) : _gen = _generation;
final String url;
/// 构造时捕获的缓存代次,纳入 key 相等性与磁盘文件名。
/// 手动刷新时 _generation++,旧瓦片(内存 key + 磁盘文件)立即全部失效,
/// 只影响 WMS 瓦片、不动 ESRI 底图缓存。
final int _gen;
static int _generation = 0;
static Future<Directory>? _cacheDirFuture;
static Future<Directory> _cacheDir() {
return _cacheDirFuture ??= () async {
try {
final base = await getTemporaryDirectory();
final dir = Directory('${base.path}${Platform.pathSeparator}wms_tiles');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
} catch (e) {
// 初始化失败不要把“坏 Future”永久缓存下来(否则后续所有瓦片都卡在这一步集体失败、
// 表现为图层全不出来),置空以便下次调用重试
_cacheDirFuture = null;
rethrow;
}
}();
}
/// URL + 代次 → 稳定短文件名(FNV-1a 32 位 + 长度,降低碰撞;代次前缀用于失效旧缓存)
static String _fileName(String url, int gen) {
var h = 0x811c9dc5;
for (final c in url.codeUnits) {
h ^= c;
h = (h * 0x01000193) & 0xFFFFFFFF;
}
return 'g${gen}_${h.toRadixString(16).padLeft(8, '0')}_${url.length}.png';
}
/// 清空全部 WMS 瓦片缓存(磁盘文件 + 递增代次使内存 key 失效)。
/// 后端更新了同名图层的影像内容后,调用它强制丢弃旧缓存、重新联网拉取最新图。
static Future<void> clearTileCache() async {
_generation++;
try {
final dir = await _cacheDir();
if (await dir.exists()) {
await for (final e in dir.list()) {
try {
await e.delete();
} catch (_) {}
}
}
debugPrint('🧹 [WMS] 已清空瓦片磁盘缓存,代次 → $_generation');
} catch (e) {
debugPrint('⚠️ [WMS] 清缓存失败: $e');
}
}
// ===== WMS 并发闸门 =====
// 大图层(如 neimeng/zhongke)一屏可达十几片瓦片,若同时发起会把 GeoServer 压到排队渲染,
// 每片等待时间暴涨、集体超时 → 全部返回透明图,表现为“一直出不来”。限制并发分批取,
// 单片能更快拿到渲染资源、在超时内完成(浏览器预览能出图,正是因为它肯等且不这么挤)。
static const int _maxConcurrentWms = 5;
static int _activeWms = 0;
static final List<Completer<void>> _wmsWaiters = [];
static Future<void> _acquireWmsSlot() async {
if (_activeWms < _maxConcurrentWms) {
_activeWms++;
return;
}
final c = Completer<void>();
_wmsWaiters.add(c);
await c.future; // 被唤醒即表示 slot 已由 _releaseWmsSlot 直接转交,_activeWms 不变
}
static void _releaseWmsSlot() {
if (_wmsWaiters.isNotEmpty) {
_wmsWaiters.removeAt(0).complete(); // 有等待者:slot 直接转交队首,_activeWms 保持
} else {
_activeWms--;
}
}
@override
Future<CachedWmsImage> obtainKey(ImageConfiguration configuration) =>
SynchronousFuture<CachedWmsImage>(this);
@override
ImageStreamCompleter loadImage(
CachedWmsImage key,
ImageDecoderCallback decode,
) {
return MultiFrameImageStreamCompleter(
codec: _loadAsync(decode),
scale: 1.0,
debugLabel: 'CachedWmsImage',
);
}
Future<ui.Codec> _loadAsync(ImageDecoderCallback decode) async {
final sw = Stopwatch()..start();
try {
final dir = await _cacheDir();
final file =
File('${dir.path}${Platform.pathSeparator}${_fileName(url, _gen)}');
// 1) 命中磁盘缓存 → 秒开
if (await file.exists()) {
final cached = await file.readAsBytes();
if (cached.isNotEmpty) {
return decode(await ui.ImmutableBuffer.fromUint8List(cached));
}
}
// 2) 未命中 → 联网拉取。先过并发闸门,避免大图层一次性压垮 GeoServer 导致集体超时
debugPrint('🌐 [WMS] MISS 联网取瓦片: ${_shortLayerTag(url)}');
await _acquireWmsSlot();
try {
final resp = await http.get(Uri.parse(url)).timeout(kWmsTileTimeout);
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
try {
await file.writeAsBytes(resp.bodyBytes, flush: false);
} catch (_) {}
debugPrint(
'✅ [WMS] 已缓存(${sw.elapsedMilliseconds}ms, ${resp.bodyBytes.length}B): ${_shortLayerTag(url)}',
);
return decode(await ui.ImmutableBuffer.fromUint8List(resp.bodyBytes));
}
debugPrint(
'⚠️ [WMS] 响应异常 status=${resp.statusCode}(${sw.elapsedMilliseconds}ms): ${_shortLayerTag(url)}',
);
} finally {
_releaseWmsSlot();
}
} catch (e) {
// 超时 / 网络 / 解码异常 → 透明兜底
debugPrint('❌ [WMS] 瓦片失败(${sw.elapsedMilliseconds}ms): $e');
}
return decode(
await ui.ImmutableBuffer.fromUint8List(TileProvider.transparentImage),
);
}
/// 从 URL 提取 LAYERS 参数做简短日志标签(避免整条 URL 刷屏)
static String _shortLayerTag(String url) {
final m = RegExp(r'LAYERS=([^&]+)').firstMatch(url);
return m?.group(1) ?? 'unknown';
}
@override
bool operator ==(Object other) =>
other is CachedWmsImage && other.url == url && other._gen == _gen;
@override
int get hashCode => Object.hash(url, _gen);
}
/// 场站图层范围仓库:
/// - 优先从 GeoServer WMS GetCapabilities 动态拉取各图层真实范围
/// (后台增/改/删图层,App 不发版即可跟上)
/// - 拉取或解析失败时,回退到内置 kStationLayerBounds(离线/异常仍可用)
class StationLayerRepo {
StationLayerRepo._();
static final StationLayerRepo instance = StationLayerRepo._();
Map<String, LatLngBounds> _bounds =
Map<String, LatLngBounds>.of(kStationLayerBounds);
bool _dynamicLoaded = false;
Future<void>? _inflight;
final List<void Function()> _listeners = [];
/// 当前生效的范围表(动态成功后为最新;否则为内置兜底)
Map<String, LatLngBounds> get bounds => _bounds;
/// 范围表是否来自动态拉取
bool get isDynamic => _dynamicLoaded;
void addListener(void Function() cb) {
if (!_listeners.contains(cb)) _listeners.add(cb);
}
void removeListener(void Function() cb) => _listeners.remove(cb);
void _notify() {
for (final cb in List<void Function()>.of(_listeners)) {
cb();
}
}
/// 拉取 GetCapabilities 刷新范围表;并发调用自动合并;失败保持兜底不变
Future<void> refresh() {
return _inflight ??= _doRefresh().whenComplete(() => _inflight = null);
}
Future<void> _doRefresh() async {
final sw = Stopwatch()..start();
try {
// 用工作区专用端点:只返回 workSpace 下图层,XML 小、快、稳(全局端点图层多易超时)
final url = Uri.parse('$kGeoserverBase/$kGeoWorkspace/wms').replace(
queryParameters: {
'SERVICE': 'WMS',
'VERSION': '1.1.1',
'REQUEST': 'GetCapabilities',
},
);
debugPrint('🌐 [StationLayerRepo] 拉取 GetCapabilities...');
final resp = await http.get(url).timeout(const Duration(seconds: 20));
if (resp.statusCode != 200) {
debugPrint(
'⚠️ [StationLayerRepo] status=${resp.statusCode},保持兜底',
);
return;
}
final parsed = parseStationLayerBounds(resp.body);
debugPrint(
'📦 [StationLayerRepo] 解析到 ${parsed.length} 个图层(${sw.elapsedMilliseconds}ms): ${parsed.keys.join(', ')}',
);
if (parsed.isEmpty) {
debugPrint('⚠️ [StationLayerRepo] 解析为空,保持内置兜底');
return;
}
// 数量防护:动态解析出的图层数若明显少于内置兜底(如网络截断只解析到个别图层),
// 直接整体替换会让其余图层凭空消失。改用“兜底打底 + 动态覆盖同名”的合并策略,
// 既跟上后端最新范围,又保证已有图层不会因一次异常响应而集体消失。
if (parsed.length < kStationLayerBounds.length) {
debugPrint(
'⚠️ [StationLayerRepo] 动态解析图层数(${parsed.length}) < 兜底(${kStationLayerBounds.length}),采用合并策略防止图层消失',
);
_bounds = {...kStationLayerBounds, ...parsed};
} else {
_bounds = parsed;
debugPrint('✅ [StationLayerRepo] 已切换为动态范围');
}
_dynamicLoaded = true;
_notify();
} catch (e) {
// 保持兜底,不打断地图
debugPrint(
'❌ [StationLayerRepo] GetCapabilities 失败(${sw.elapsedMilliseconds}ms),保持兜底: $e',
);
}
}
/// 基于当前范围表构建全部场站叠加图层(滑到哪个场站区域就叠加哪个)
List<TileLayer> buildLayers({double minZoom = 16, double maxZoom = 21}) {
return _bounds.entries.map((entry) {
// 自愈兜底:万一范围表里仍残留非法 bounds(历史污染等),回退到内置兜底的同名范围,
// 避免生成“框不住任何瓦片”的坏图层导致该场站整体不显示
final b = _isValidLayerBounds(entry.value)
? entry.value
: (kStationLayerBounds[entry.key] ?? entry.value);
return TileLayer(
tileProvider: GeoWmsProvider(
layerName: entry.key,
bounds: b,
maxLevel: maxZoom.toInt(),
),
userAgentPackageName: 'com.example.app',
minZoom: minZoom,
maxZoom: maxZoom,
maxNativeZoom: maxZoom.toInt(),
tileBounds: b,
);
}).toList();
}
}
/// 校验图层范围是否为合法且有意义的经纬度矩形:
/// - 纬度 ∈ [-90,90]、经度 ∈ [-180,180]
/// - 南北/东西跨度 > 0 且 ≤ 5°(场站地块都是几百米级小范围,跨度过大必是投影错误/脏数据)
/// 非法范围一旦混入范围表,会让 getImage 的相交判断恒为 false → 瓦片全透明 → 图层“整体消失”,
/// 这正是“放大也不出来、只能退出重登(页面重建重置图层列表)才恢复”的根因。
bool _isValidLayerBounds(LatLngBounds b) {
final s = b.south;
final n = b.north;
final w = b.west;
final e = b.east;
if (s < -90 || s > 90 || n < -90 || n > 90) return false;
if (w < -180 || w > 180 || e < -180 || e > 180) return false;
final dLat = n - s;
final dLng = e - w;
if (dLat <= 0 || dLng <= 0) return false;
if (dLat > 5 || dLng > 5) return false;
return true;
}
/// 解析 WMS GetCapabilities,提取 workSpace 下每个图层的 name → EPSG:4326 范围。
/// 同时兼容 1.1.1(LatLongBoundingBox)与 1.3.0(EX_GeographicBoundingBox)。
Map<String, LatLngBounds> parseStationLayerBounds(String xmlStr) {
final result = <String, LatLngBounds>{};
try {
final doc = XmlDocument.parse(xmlStr);
for (final layer in doc.findAllElements('Layer')) {
final nameEls = layer.findElements('Name');
if (nameEls.isEmpty) continue;
final rawName = nameEls.first.innerText.trim();
// 兼容两种命名:全局端点返回 workSpace:xxx;工作区端点可能只返回裸名 xxx
final String name;
if (rawName.startsWith('$kGeoWorkspace:')) {
name = rawName;
} else if (!rawName.contains(':')) {
name = '$kGeoWorkspace:$rawName';
} else {
continue; // 其它工作区图层,跳过
}
LatLngBounds? b;
// WMS 1.1.1
final bbEls = layer.findElements('LatLongBoundingBox');
if (bbEls.isNotEmpty) {
final bb = bbEls.first;
final minx = double.tryParse(bb.getAttribute('minx') ?? '');
final miny = double.tryParse(bb.getAttribute('miny') ?? '');
final maxx = double.tryParse(bb.getAttribute('maxx') ?? '');
final maxy = double.tryParse(bb.getAttribute('maxy') ?? '');
if (minx != null &&
miny != null &&
maxx != null &&
maxy != null &&
maxx > minx &&
maxy > miny) {
b = LatLngBounds(LatLng(miny, minx), LatLng(maxy, maxx));
}
}
// WMS 1.3.0 兜底
if (b == null) {
final exEls = layer.findElements('EX_GeographicBoundingBox');
if (exEls.isNotEmpty) {
final ex = exEls.first;
double? read(String tag) {
final els = ex.findElements(tag);
return els.isEmpty
? null
: double.tryParse(els.first.innerText.trim());
}
final w = read('westBoundLongitude');
final e = read('eastBoundLongitude');
final n = read('northBoundLatitude');
final s = read('southBoundLatitude');
if (w != null && e != null && n != null && s != null && e > w && n > s) {
b = LatLngBounds(LatLng(s, w), LatLng(n, e));
}
}
}
if (b != null) {
if (_isValidLayerBounds(b)) {
result[name] = b;
} else {
// 范围非法(经纬度越界 / 跨度过大或为 0)→ 丢弃该图层,
// 避免污染范围表导致瓦片框不住、图层整体消失
debugPrint(
'⚠️ [StationLayerRepo] 图层 $name 范围非法已丢弃: S=${b.south} N=${b.north} W=${b.west} E=${b.east}',
);
}
}
}
} catch (_) {
// 解析失败返回空表,调用方保持兜底
}
return result;
}

View File

@@ -0,0 +1,104 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:http/http.dart' as http;
/// ESRI World Imagery 底图地址(国内网络可能不可达)
const String kEsriWorldImageryUrl =
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}';
/// 网络超时时长(瓦片加载)
const Duration _kTileTimeout = Duration(seconds: 8);
/// ─────────────────────────────────────────────────────────
/// 容错网络瓦片图
/// ─────────────────────────────────────────────────────────
///
/// 加载失败(超时 / 断网 / 404 等)时返回 1×1 透明 PNG,
/// 避免 SocketException 刷屏并保证地图其余部分正常渲染。
class ResilientNetworkImage extends ImageProvider<ResilientNetworkImage> {
const ResilientNetworkImage(this.url);
final String url;
@override
Future<ResilientNetworkImage> obtainKey(ImageConfiguration configuration) =>
SynchronousFuture<ResilientNetworkImage>(this);
@override
ImageStreamCompleter loadImage(
ResilientNetworkImage key,
ImageDecoderCallback decode,
) {
return MultiFrameImageStreamCompleter(
codec: _loadAsync(decode),
scale: 1.0,
debugLabel: 'ResilientNetworkImage(${url.split('/').last})',
);
}
Future<ui.Codec> _loadAsync(ImageDecoderCallback decode) async {
try {
final client = http.Client();
final response = await client
.get(Uri.parse(url))
.timeout(_kTileTimeout);
if (response.statusCode == 200 && response.bodyBytes.isNotEmpty) {
final buffer = await ui.ImmutableBuffer.fromUint8List(
response.bodyBytes,
);
return decode(buffer);
}
} catch (_) {
// 超时 / 网络异常 / 解码失败 → 走下面的透明兜底
}
// 1×1 透明 PNG,避免 Flutter 框架抛出 MissingImageException
return decode(
await ui.ImmutableBuffer.fromUint8List(_transparentPng),
);
}
@override
bool operator ==(Object other) =>
other is ResilientNetworkImage && other.url == url;
@override
int get hashCode => url.hashCode;
}
/// ─────────────────────────────────────────────────────────
/// 容错 TileProvider —— 直接在 TileLayer.tileProvider 中使用
/// ─────────────────────────────────────────────────────────
///
/// ```dart
/// TileLayer(
/// urlTemplate: kEsriWorldImageryUrl,
/// tileProvider: ResilientTileProvider(),
/// minZoom: 0,
/// maxZoom: 19,
/// )
/// ```
class ResilientTileProvider extends TileProvider {
@override
ImageProvider<Object> getImage(TileCoordinates coordinates, TileLayer options) {
final url = getTileUrl(coordinates, options);
return ResilientNetworkImage(url);
}
}
/// 1×1 透明 PNG 字节(兜底用)
final Uint8List _transparentPng = Uint8List.fromList(<int>[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1×1
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, // RGBA 8bit
0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, // IDAT chunk
0x54, 0x78, 0x9C, 0x62, 0x00, 0x00, 0x00, 0x02,
0x00, 0x01, 0xE5, 0x27, 0xDE, 0xFC, 0x00, 0x00, // compressed data
0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, // IEND chunk
0x60, 0x82,
]);

View File

@@ -156,11 +156,13 @@ class _AmapFlutterMapPageState extends State<AmapFlutterMapPage> {
interactiveFlags: InteractiveFlag.all,
),
children: [
// ESRI World Imagery(WGS84 坐标系,全球高清卫星图)
// 替代高德地图(GCJ02),统一坐标系,无需坐标转换
TileLayer(
urlTemplate:
"https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}&key=$amapKey",
subdomains: const ['1', '2', '3', '4'],
tileProvider: NetworkTileProvider(),
urlTemplate: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
userAgentPackageName: 'com.example.app',
minZoom: 0,
maxZoom: 19,
),
MarkerLayer(
markers: [

File diff suppressed because it is too large Load Diff

View File

@@ -187,8 +187,9 @@ class _VerticalFloatMenuState extends State<VerticalFloatMenu> {
Text(loc.translate('route_planning.select_work_mode'), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 20),
_modeItem(
title: loc.translate('route_planning.bow_mode'),
subtitle: loc.translate('route_planning.bow_mode_subtitle'),
// 🔥 硬编码:差包更新不更新 assets,必须写死文案
title: '覆盖模式',
subtitle: '标准全覆盖路径规划',
isSelected: selectedMode == WorkMode.bow,
onTap: () {
setState(() {
@@ -198,8 +199,8 @@ class _VerticalFloatMenuState extends State<VerticalFloatMenu> {
),
const SizedBox(height: 12),
_modeItem(
title: loc.translate('route_planning.custom_mode'),
subtitle: loc.translate('route_planning.custom_mode_subtitle'),
title: '自定义模式',
subtitle: '手动设定作业区域',
isSelected: selectedMode == WorkMode.custom,
onTap: () {
setState(() {

View File

@@ -50,6 +50,40 @@ class TabConfig extends Equatable {
return enabled;
}
/// roleKey == 'personal'(个人角色):底部导航与「系统设置 → Tab 设置」强制只保留
/// 「设备(device) + 我的(me)」,且常显、开关锁定不可关闭;其他角色不受影响。
static const String personalRoleKey = 'personal';
static const Set<String> personalVisibleTabIds = {'device', 'me'};
/// 底部导航实际渲染的 Tab:
/// - personal:强制返回 设备 + 我的(忽略 isEnabled,恒为启用),按 order 排序
/// - 其他角色:返回用户启用的 Tab(enabledItems)
List<TabConfigItem> navItemsForRole(String? roleKey) {
if (roleKey == personalRoleKey) {
final forced = items
.where((item) => personalVisibleTabIds.contains(item.id))
.map((item) => item.copyWith(isEnabled: true))
.toList();
forced.sort((a, b) => a.order.compareTo(b.order));
return forced;
}
return enabledItems;
}
/// 「Tab 设置」列表应展示的 Tab:
/// - personal:只列出 设备 + 我的,按 order 排序
/// - 其他角色:列出全部
List<TabConfigItem> settingItemsForRole(String? roleKey) {
if (roleKey == personalRoleKey) {
final visible = items
.where((item) => personalVisibleTabIds.contains(item.id))
.toList();
visible.sort((a, b) => a.order.compareTo(b.order));
return visible;
}
return items;
}
TabConfig copyWith({List<TabConfigItem>? items}) {
return TabConfig(items: items ?? this.items);
}

View File

@@ -19,6 +19,7 @@ import 'package:maibu_satabot_v2/features/v2/device_list/presentation/float_bar/
import 'package:maibu_satabot_v2/features/v2/device_list/presentation/float_bar/cubit/float_bar_setting_cubit.dart';
import 'package:maibu_satabot_v2/core/di/injection.dart';
import 'package:maibu_satabot_v2/core/theme/AppTheme.dart';
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
class MainWrapper extends StatefulWidget {
const MainWrapper({super.key});
@@ -30,8 +31,6 @@ class MainWrapper extends StatefulWidget {
class _MainWrapperState extends State<MainWrapper> {
int _currentIndex = 0;
final PageController _pageController = PageController();
final List<GlobalKey> _tabKeys = [];
double? _circleLeft;
@override
void initState() {
@@ -39,22 +38,6 @@ class _MainWrapperState extends State<MainWrapper> {
// 🔥 确保 FloatBarSettingService 已初始化
GetIt.I<FloatBarSettingService>();
WidgetsBinding.instance.addPostFrameCallback((_) {
_updateCirclePosition();
});
}
void _updateCirclePosition() {
if (_tabKeys.isEmpty || _currentIndex >= _tabKeys.length) return;
final key = _tabKeys[_currentIndex];
final renderBox = key.currentContext?.findRenderObject() as RenderBox?;
if (renderBox != null) {
setState(() {
_circleLeft = renderBox.localToGlobal(Offset.zero).dx;
});
}
}
@override
@@ -68,52 +51,36 @@ class _MainWrapperState extends State<MainWrapper> {
_currentIndex = index;
});
_pageController.jumpToPage(index);
WidgetsBinding.instance.addPostFrameCallback((_) {
_updateCirclePosition();
});
}
IconData _getIconData(String iconName) {
/// Tab 图标:选中用 filled(实心),未选用 outlined(描边),对比更清晰
IconData _navIcon(String iconName, bool selected) {
switch (iconName) {
case 'home_rounded':
return Icons.home_rounded;
return selected ? Icons.home_rounded : Icons.home_outlined;
case 'grid_view_rounded':
return Icons.grid_view_rounded;
return selected ? Icons.grid_view_rounded : Icons.grid_view_outlined;
case 'devices_rounded':
return Icons.devices_rounded;
return selected ? Icons.devices_rounded : Icons.devices_outlined;
case 'auto_awesome_rounded':
return Icons.auto_awesome_rounded;
return selected
? Icons.auto_awesome_rounded
: Icons.auto_awesome_outlined;
case 'warning_amber_rounded':
return Icons.warning_amber_rounded;
return selected
? Icons.warning_amber_rounded
: Icons.warning_amber_outlined;
case 'assignment_rounded':
return Icons.assignment_rounded;
return selected ? Icons.assignment_rounded : Icons.assignment_outlined;
case 'post_add_rounded':
return Icons.post_add_rounded;
return selected ? Icons.post_add_rounded : Icons.post_add_outlined;
case 'person_rounded':
return Icons.person_rounded;
return selected ? Icons.person_rounded : Icons.person_outlined;
default:
return Icons.home_rounded;
return selected ? Icons.home_rounded : Icons.home_outlined;
}
}
double _getIndicatorPosition(int itemCount, int currentIndex) {
final screenWidth = MediaQuery.of(context).size.width;
final itemWidth = screenWidth / itemCount;
return currentIndex * itemWidth;
}
double _getCircularLeftPosition(int itemCount, int currentIndex) {
final screenWidth = MediaQuery.of(context).size.width;
final itemWidth = screenWidth / itemCount;
// 圆形应该在每个Tab项的中心
final tabCenterX = 12 + (currentIndex * itemWidth) + (itemWidth / 2);
final circularLeft = tabCenterX - 22; // 22 = 44/2,让圆形中心对齐Tab中心
// 边界限制
final maxLeft = screenWidth - 12 - 44;
return circularLeft.clamp(12.0, maxLeft);
}
Widget _buildCurrentPage(TabConfigItem tab) {
switch (tab.id) {
case 'home_v2':
@@ -147,6 +114,8 @@ class _MainWrapperState extends State<MainWrapper> {
@override
Widget build(BuildContext context) {
// 🔥 个人角色(roleKey == 'personal')底部导航强制只显示「设备 + 我的」
final roleKey = context.watch<AppUserCubit>().state.user?.roleKey;
return BlocBuilder<TabConfigCubit, TabConfigState>(
builder: (context, state) {
if (state is! TabConfigLoaded) {
@@ -156,7 +125,7 @@ class _MainWrapperState extends State<MainWrapper> {
}
final config = state.config;
final enabledItems = config.enabledItems;
final enabledItems = config.navItemsForRole(roleKey);
if (enabledItems.isEmpty) {
return const Scaffold(body: Center(child: Text('请至少启用一个 Tab')));
@@ -166,24 +135,16 @@ class _MainWrapperState extends State<MainWrapper> {
_currentIndex = 0;
}
// 初始化 GlobalKey
if (_tabKeys.length != enabledItems.length) {
_tabKeys.clear();
for (int i = 0; i < enabledItems.length; i++) {
_tabKeys.add(GlobalKey());
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_updateCirclePosition();
});
}
return Scaffold(
extendBody: false,
// 底部导航预留区(SafeArea 那条)与页面同色,消除突兀的底色带
backgroundColor: context.appColors.pageBackground,
body: Stack(
children: [
PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
// 🔥 启用左右滑动切换 Tab
physics: const BouncingScrollPhysics(),
onPageChanged: (index) {
setState(() {
_currentIndex = index;
@@ -205,102 +166,138 @@ class _MainWrapperState extends State<MainWrapper> {
),
],
),
bottomNavigationBar: Container(
decoration: BoxDecoration(
color: context.appColors.cardBackground,
),
child: SafeArea(
top: false,
bottomNavigationBar: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
child: LayoutBuilder(
builder: (context, constraints) {
return Container(
margin: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 16,
),
padding: const EdgeInsets.symmetric(vertical: 2),
decoration: BoxDecoration(
color: context.appColors.cardBackground,
borderRadius: BorderRadius.circular(25),
border: Border.all(
color: context.appColors.divider,
width: 1,
),
boxShadow: [
BoxShadow(
color: context.appColors.cardShadow,
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Stack(
children: [
// 滑动圆形背景
if (_circleLeft != null)
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
left: _circleLeft! - 8, // 减去外层 margin + 向右偏移
top: 6,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: context.appColors.divider,
shape: BoxShape.circle,
border: Border.all(
color: context.appColors.divider,
width: 1,
),
),
),
),
// Tab 项
Row(
children: enabledItems.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
final isSelected = _currentIndex == index;
final itemCount = enabledItems.length;
// Tab 较少时收缩整体宽度并居中,Tab 多时铺满可用宽度
const double preferredTabWidth = 84;
const double barPaddingH = 6;
final double maxBarWidth = constraints.maxWidth;
final double barWidth =
itemCount * preferredTabWidth < maxBarWidth
? itemCount * preferredTabWidth
: maxBarWidth;
final double tabWidth =
(barWidth - barPaddingH * 2) / itemCount;
// 选中高亮:包裹「图标 + 文字」的紧凑胶囊 chip
const double indicatorH = 46;
final double indicatorW =
tabWidth - 8 < 54 ? tabWidth - 8 : 54.0;
return Expanded(
key: _tabKeys[index],
child: InkWell(
onTap: () => _onTabChanged(index),
child: SizedBox(
height: 56,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_getIconData(item.icon),
size: 24,
color: isSelected
? context.appColors.textPrimary
: context.appColors.textTertiary,
),
const SizedBox(height: 4),
Text(
item.name,
style: TextStyle(
fontSize: 11,
color: isSelected
? context.appColors.textPrimary
: context.appColors.textTertiary,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.w400,
),
),
],
// 用 Align + heightFactor 只水平居中,高度贴合内容;
// 切勿用 Center:它在底部栏有界高度约束下会撑满全屏,把 body 挤没
return Align(
alignment: Alignment.center,
heightFactor: 1.0,
child: Container(
width: barWidth,
padding: const EdgeInsets.symmetric(
horizontal: barPaddingH,
vertical: 2,
),
decoration: BoxDecoration(
color: context.appColors.cardBackground,
borderRadius: BorderRadius.circular(30),
border: Border.all(
color: context.appColors.divider,
width: 1,
),
boxShadow: [
BoxShadow(
color: context.appColors.cardShadow,
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: SizedBox(
height: 56,
child: Stack(
children: [
// 蓝色胶囊 chip:纯数学定位,居中包裹图标 + 文字
AnimatedPositioned(
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
left: _currentIndex * tabWidth +
(tabWidth - indicatorW) / 2,
top: (56 - indicatorH) / 2,
width: indicatorW,
height: indicatorH,
child: Container(
decoration: BoxDecoration(
color: context.appColors.primary.withOpacity(
0.12,
),
borderRadius: BorderRadius.circular(
indicatorH / 2,
),
),
),
);
}).toList(),
),
// Tab 项
Row(
children:
enabledItems.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
final isSelected = _currentIndex == index;
return Expanded(
child: InkWell(
borderRadius: BorderRadius.circular(
indicatorH / 2,
),
onTap: () => _onTabChanged(index),
child: Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
AnimatedScale(
scale: isSelected ? 1.1 : 1.0,
duration: const Duration(
milliseconds: 200,
),
curve: Curves.easeOutBack,
child: Icon(
_navIcon(item.icon, isSelected),
size: 22,
color: isSelected
? context.appColors.primary
: context
.appColors.textTertiary,
),
),
const SizedBox(height: 2),
AnimatedDefaultTextStyle(
duration: const Duration(
milliseconds: 200,
),
style: TextStyle(
fontSize: 11,
height: 1.0,
color: isSelected
? context.appColors.primary
: context
.appColors.textTertiary,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.w400,
),
child: Text(item.name),
),
],
),
),
);
}).toList(),
),
],
),
],
),
),
);
},

View File

@@ -8,6 +8,7 @@ import 'package:maibu_satabot_v2/features/my/repository/my_repository.dart';
import 'dart:convert';
import '../../../../core/di/injection.dart';
import '../../../../core/consts/http_api_consts.dart';
import '../../../../core/storage/user_storage.dart';
// 必须导入 DeviceFailure 类
import '../../../../features/devices/domain/errors/device_failure.dart';
@@ -26,7 +27,7 @@ class MyRepositoryImpl implements MyRepository {
// 核心修复:严格匹配抽象类的方法签名
@override
Future<Either<DeviceFailure, int>> updateName(String nickName) async {
final url = Uri.parse('http://1.95.137.212:8081/system/user/profile');
final url = Uri.parse('${HttpApiConsts.baseUrl}/system/user/profile');
// 补充:从 UserStorage 获取 token(接口通常需要认证)
final token = await _getToken();

View File

@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import 'package:flutter/cupertino.dart';
import 'package:get_it/get_it.dart';
import '../../../../core/consts/http_api_consts.dart';
import '../../../../core/logging/i_logger_service.dart';
import '../../../../core/storage/user_storage.dart';
@@ -29,21 +30,29 @@ class RemoteHttpDatasource {
/// 🔥 返回完整权限信息: {hasPermission, owner}
Future<Map<String, dynamic>> requestRemoteControlViaHttp(String deviceName, String platform) async {
print(deviceName+"@@@");
final logPrefix = '🔑 [RemoteHttp][获取权限接口]';
final user = await _userStorage.getUser();
if (user == null) {
debugPrint('❌ [RemoteHttp] 用户未登录,无法请求远程控制权限');
_logger.log('❌ [RemoteHttp] 用户未登录,无法请求远程控制权限');
_logger.logWithLevel('❌ [RemoteHttp] 用户未登录,无法请求远程控制权限', shouldLog: true);
return {'hasPermission': false, 'owner': null};
}
final token = user.token;
debugPrint('🔑 [RemoteHttp] 开始请求远程控制权限 - deviceName: $deviceName, platform: $platform');
_logger.log('🔑 [RemoteHttp] 开始请求远程控制权限 - deviceName: $deviceName, platform: $platform');
debugPrint('$logPrefix 开始请求远程控制权限 - deviceName: $deviceName, platform: $platform');
_logger.logWithLevel(
'$logPrefix 开始请求远程控制权限 - deviceName: $deviceName, platform: $platform',
shouldLog: true,
);
try {
debugPrint('📡 [RemoteHttp] 正在发送 HTTP POST 请求到 /forward/device/remoteControl');
debugPrint('📡 [RemoteHttp] 请求参数: deviceId=$deviceName, platform=$platform');
final requestData = {'deviceId': deviceName, 'platform': platform};
debugPrint('$logPrefix 请求地址: POST /forward/device/remoteControl');
debugPrint('$logPrefix 实际请求体: $requestData');
_logger.logWithLevel(
'$logPrefix 请求地址: POST /forward/device/remoteControl, 请求体: $requestData',
shouldLog: true,
);
final response = await _dio.post(
'/forward/device/remoteControl',
options: Options(
@@ -52,15 +61,14 @@ class RemoteHttpDatasource {
'Authorization': 'Bearer ${token}',
},
),
data: {'deviceId': deviceName, 'platform': platform});
debugPrint('📥 [RemoteHttp] HTTP 请求已发送,等待响应...');
debugPrint("📊 [RemoteHttp] HTTP响应状态码: ${response.statusCode}");
_logger.log("📊 [RemoteHttp] HTTP响应状态码: ${response.statusCode}");
data: requestData);
debugPrint("$logPrefix HTTP响应状态码: ${response.statusCode}");
_logger.logWithLevel("$logPrefix HTTP响应状态码: ${response.statusCode}", shouldLog: true);
final responseData = response.data as Map<String, dynamic>;
debugPrint("📊 [RemoteHttp] 完整响应数据:$responseData");
_logger.log("📊 [RemoteHttp] 完整响应数据:$responseData");
debugPrint("$logPrefix 完整响应数据:$responseData");
_logger.logWithLevel("$logPrefix 完整响应数据:$responseData", shouldLog: true);
// 🔥 关键:先获取响应的 data 字段,再获取 remoteControl 和 owner
final dataField = responseData['data'] as Map<String, dynamic>?;
@@ -68,54 +76,67 @@ class RemoteHttpDatasource {
final bool hasRemoteControl = dataField['remoteControl'] as bool? ?? false;
final String? owner = dataField['owner'] as String?;
debugPrint("✅ [RemoteHttp] 解析成功 - remoteControl=$hasRemoteControl, owner=$owner");
_logger.log("✅ [RemoteHttp] 解析成功 - remoteControl=$hasRemoteControl, owner=$owner");
debugPrint("$logPrefix 解析成功 - remoteControl=$hasRemoteControl, owner=$owner");
_logger.logWithLevel(
"$logPrefix 解析成功 - remoteControl=$hasRemoteControl, owner=$owner",
shouldLog: true,
);
// 🔥 返回完整权限信息
return {'hasPermission': hasRemoteControl, 'owner': owner};
} else {
debugPrint("❌ [RemoteHttp] 缺少 data 字段,响应结构异常");
_logger.log("❌ [RemoteHttp] 缺少 data 字段,响应结构异常");
debugPrint("$logPrefix 缺少 data 字段,响应结构异常");
_logger.logWithLevel("$logPrefix 缺少 data 字段,响应结构异常", shouldLog: true);
return {'hasPermission': false, 'owner': null};
}
} catch (e) {
debugPrint('❌ [RemoteHttp] 请求远程控制权限失败:$e');
_logger.log('❌ [RemoteHttp] 请求远程控制权限失败:$e');
debugPrint('$logPrefix 请求远程控制权限失败:$e');
_logger.logWithLevel('$logPrefix 请求远程控制权限失败:$e', level: 'ERROR', shouldLog: true);
return {'hasPermission': false, 'owner': null};
}
}
///app退出远程控制后释放权限
Future<bool> releaseRemoteControlViaHttp( String platform) async {
// _logger.logWithLevel("app退出远程控制后释放权限 开始");
/// [type] 1=从机器人详情页退出释放, 2=退出远程遥控页面释放
Future<bool> releaseRemoteControlViaHttp({
required String deviceId,
required int type,
}) async {
final user = await _userStorage.getUser();
if (user == null) {
debugPrint('❌ [RemoteHttp] releaseControl 未获取到用户信息,跳过释放');
return false;
}
final token = user.token;
final response = await _dio.post(
'/forward/device/releaseControl',
options: Options(
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ${token}',
},
),
data: {'platform': "app",'type':2});
final requestData = {
'platform': 'app',
'type': type,
'deviceId': deviceId,
'token': token,
};
debugPrint('📤 [RemoteHttp] releaseControl 实际请求体: $requestData');
try {
final response = await _dio.post(
HttpApiConsts.releaseControlUrl,
options: Options(
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ${token}',
},
),
data: requestData);
final responseData = response.data as Map<String, dynamic>;
//debugPrint("📊 [HTTP 响应] 完整数据:$responseData");
//_logger.log("releaseRemoteControlViaHttp 响应] 完整数据:$responseData");
if(responseData['code']==200){
// _logger.logWithLevel("app退出远程控制后释放权限 成功");
return true;
}
return false;
debugPrint('📋 [RemoteHttp] releaseControl 响应: $responseData');
if (responseData['code'] == 200) {
return true;
}
debugPrint(
'⚠️ [RemoteHttp] releaseControl 返回 code=${responseData['code']},释放未成功');
return false;
} catch (e) {
// debugPrint('❌ [RemoteHttp] 解析响应失败:$e');
_logger.log('❌ [RemoteHttp] 解析响应失败:$e');
debugPrint('❌ [RemoteHttp] releaseControl 请求失败:$e');
_logger.log('❌ [RemoteHttp] releaseControl 请求失败:$e');
return false;
}
}
}

View File

@@ -59,7 +59,10 @@ class RemoteTcpDatasource {
final jsonBytes = utf8.encode(jsonStr);
debugPrint('sendSwitchControlRequest[TCP] 发送权限请求指令:$jsonStr');
debugPrint('sendSwitchControlRequest[TCP] 发送权限请求指令:$jsonBytes');
_logger.logWithLevel(
'🔑 [TCP][获取权限] 发送权限请求指令:$jsonStr',
shouldLog: true,
);
// 使用 0x12 指令发送(cmdGetAuth)
// 注意:根据你的协议文档,这里可能是 0x04 或 0x12
@@ -81,8 +84,17 @@ class RemoteTcpDatasource {
debugPrint(
'✅ sendSwitchControlRequest[TCP] 已发送 switch_control 权限请求 (0x${MachineProtocolConstants.cmdGetAuth.toRadixString(16)})',
);
_logger.logWithLevel(
'✅ [TCP][获取权限] 已发送 switch_control 权限请求 (0x${MachineProtocolConstants.cmdGetAuth.toRadixString(16)})',
shouldLog: true,
);
} catch (e) {
debugPrint('❌ sendSwitchControlRequest[TCP] 发送权限请求失败:$e');
_logger.logWithLevel(
'❌ [TCP][获取权限] 发送权限请求失败:$e',
level: 'ERROR',
shouldLog: true,
);
rethrow;
}
}

View File

@@ -61,7 +61,7 @@ class RemoteControlRepositoryImpl implements RemoteControlRepository {
@override
void sendTcpPermissionRequest(String deviceName) {
_remoteTcp.sendSwitchControlRequest(deviceName);
_logger.logWithLevel('🔑 [RemoteControl] TCP已发送权限请求');
_logger.logWithLevel('🔑 [RemoteControl] TCP已发送权限请求', shouldLog: true);
}
@override
@@ -71,7 +71,10 @@ class RemoteControlRepositoryImpl implements RemoteControlRepository {
) async {
try {
// 1. 先发送 HTTP 权限请求检查权限
_logger.logWithLevel('🔑 [RemoteControl] 检查 HTTP 权限...');
_logger.logWithLevel(
'🔑 [RemoteControl] 检查 HTTP 权限... deviceName=$deviceName, platform=$deviceId',
shouldLog: true,
);
final result = await _remoteHttp.requestRemoteControlViaHttp(
deviceName,
deviceId,
@@ -79,16 +82,20 @@ class RemoteControlRepositoryImpl implements RemoteControlRepository {
// 2. 如果没有权限或权限为 null,才发送 TCP 请求
final hasPermission = result['hasPermission'] as bool? ?? false;
_logger.logWithLevel(
'🔑 [RemoteControl] HTTP权限查询结果: hasPermission=$hasPermission, owner=${result['owner']}',
shouldLog: true,
);
if (!hasPermission) {
_logger.logWithLevel('🔑 [RemoteControl] 无权限,发送 TCP 权限请求');
_logger.logWithLevel('🔑 [RemoteControl] 无权限,发送 TCP 权限请求', shouldLog: true);
_remoteTcp.sendSwitchControlRequest(deviceName);
} else {
_logger.logWithLevel('✅ [RemoteControl] 已有权限,无需 TCP 请求');
_logger.logWithLevel('✅ [RemoteControl] 已有权限,无需 TCP 请求', shouldLog: true);
}
return result;
} catch (e) {
_logger.logWithLevel('❌ [RemoteControl] 权限请求失败:$e');
_logger.logWithLevel('❌ [RemoteControl] 权限请求失败:$e', level: 'ERROR', shouldLog: true);
rethrow;
}
}
@@ -149,7 +156,13 @@ class RemoteControlRepositoryImpl implements RemoteControlRepository {
///app退出远程控制后释放权限
@override
Future<bool> releasePermission(String platform) async {
return await _remoteHttp.releaseRemoteControlViaHttp(platform);
Future<bool> releasePermission({
required String deviceId,
required int type,
}) async {
return await _remoteHttp.releaseRemoteControlViaHttp(
deviceId: deviceId,
type: type,
);
}
}

View File

@@ -23,6 +23,7 @@ abstract class RemoteControlRepository {
/// 新增:响应控制权限 同意和拒绝(发送 0x05 指令)
void respondPermission(bool bool, String deviceId);
///APP 推出远程控制页面后释放权限
Future<bool> releasePermission(String platform);
///APP 退出远程控制/详情页后释放权限
/// [type] 1=从机器人详情页退出, 2=退出远程遥控页面
Future<bool> releasePermission({required String deviceId, required int type});
}

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:fpdart/fpdart.dart';
import 'package:maibu_satabot_v2/core/error/failure.dart';
import 'package:maibu_satabot_v2/core/domain/usecases/base_usecase.dart';
@@ -11,10 +12,15 @@ class RequestControlPermissionUseCase extends BaseUseCase<Map<String, dynamic>,
@override
Future<Either<Failure, Map<String, dynamic>>> call(RequestControlPermissionParams params) async {
debugPrint(
'🔑 [权限链][UseCase] 开始执行请求 - deviceName: ${params.deviceName}, platform: ${params.deviceId}',
);
try {
final result = await repository.requestControlPermission(params.deviceName, params.deviceId);
debugPrint('🔑 [权限链][UseCase] 请求成功,返回: $result');
return right(result);
} catch (e) {
debugPrint('❌ [权限链][UseCase] 请求异常: $e');
return left(Failure(e.toString()));
}
}

View File

@@ -13,6 +13,7 @@ import 'package:maibu_satabot_v2/core/network/tcp/tcp_client.dart';
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart';
import 'package:maibu_satabot_v2/features/devices/domain/repositories/device_repository.dart';
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_bloc.dart';
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_event.dart';
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_state.dart';
import 'package:maibu_satabot_v2/features/remote_control/data/models/running_status_model.dart';
import 'package:maibu_satabot_v2/features/remote_control/domain/usecase/request_control_permission_usecase.dart';
@@ -34,7 +35,8 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
StreamSubscription? _kickOutSub; // 新增:用于管理监听生命周期
final NetMessageDispatcher dispatcher;
final DeviceStatusBloc deviceStatusBloc; // 🔥 注入 DeviceStatusBloc
StreamSubscription? _deviceStatusSub; // 🔥 订阅 DeviceStatusBloc 的状态流
StreamSubscription? _deviceStatusSub; // 🔥 订阅 DeviceStatusBloc 的状态流(MQTT,已停用)
StreamSubscription? _tcpStatusSub; // 🔥 订阅 TCP 0x02 机器状态推送(门槛 + 电压/电量/控制模式)
static const platform = MethodChannel('com.maibu.satabot/ping');
int _currentPing = 50;
final ILoggerService _logger = GetIt.I<ILoggerService>();
@@ -69,7 +71,11 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
),
) {
_initPacketListener();
_initDeviceStatusListener(); // 🔥 改为订阅 DeviceStatusBloc
// 🔥 门槛(hasReceivedStatusPush)与电压/电量/控制模式改由 TCP 0x02 驱动,
// 与 MQTT 定位链路解耦;MQTT(DeviceStatusBloc) 仅继续负责定位。
// 回滚:注释下行、改回 _initDeviceStatusListener() 即可。
_initTcpStatusListener();
// _initDeviceStatusListener(); // 🔥 旧:订阅 DeviceStatusBloc(MQTT),已停用
}
// 🔥 新增:订阅 DeviceStatusBloc 的状态流,而不是直接监听TCP
@@ -81,6 +87,9 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
int? _cachePing;
DateTime? _lastStatusPushTime; // 最后一次收到设备状态推送的时间
// 🔥 已停用:门槛与电压/电量/控制模式改由 TCP 0x02 驱动(见 _initTcpStatusListener)。
// 保留本方法用于回滚——恢复构造函数中的调用即可重新走 MQTT(DeviceStatusUpdated)。
// ignore: unused_element
void _initDeviceStatusListener() {
_deviceStatusSub?.cancel();
@@ -128,6 +137,77 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
});
}
/// 🔥 门槛 + 运行状态专用监听:直接监听 TCP 0x02 机器状态推送。
///
/// 与 MQTT 定位链路彻底解耦:
/// - 门槛 hasReceivedStatusPush / _lastStatusPushTime 由 TCP 0x02 驱动;
/// - 电压/电量/控制模式 直接由 0x02 报文的 fromFields 解析(fields[0]/[21]/[20]);
/// - MQTT(DeviceStatusBloc) 仅继续负责定位,不再影响本页门槛与三个值。
void _initTcpStatusListener() {
_tcpStatusSub?.cancel();
_tcpStatusSub = tcpClient.packetStream
.where((p) => p.command == 0x02)
.listen((packet) async {
try {
final message = utf8.decode(packet.payload, allowMalformed: true);
if (message.trim().isEmpty) return;
final fields = message.trim().split(',');
if (fields.length < 18) {
debugPrint('⚠️ [TCP-0x02门槛] 字段不足:${fields.length},期望≥18,跳过');
return;
}
final status = RunningStatusEntity.fromFields(fields);
final controlMode = status.controlMode == '3' ? '远程模式' : '本地模式';
// 🔥 门槛:收到 TCP 0x02 即视为推送活跃(节流前刷新,轻量无阻塞)
_lastStatusPushTime = DateTime.now();
_cacheVoltage = status.voltage.toString();
_cacheBattery = status.battery;
_cacheCtrlMode = controlMode;
// 500ms节流,不到时间不刷新UI
final now = DateTime.now();
if (_lastUiUpdateTime != null &&
now.difference(_lastUiUpdateTime!) <
const Duration(milliseconds: 500)) {
// 节流期间仍需保证门槛标志已置位(否则首帧恰逢节流会导致门槛漏置)
if (!state.hasReceivedStatusPush && !isClosed) {
emit(state.copyWith(hasReceivedStatusPush: true));
}
return;
}
_lastUiUpdateTime = now;
// 🔥 ping 只在真正刷新UI时测(≤2Hz):0x02 原始推送可能高频,
// 若每帧 await getNetworkDelay 会造成大量并发 ping,移到节流后规避。
final c = await getNetworkDelay();
_cachePing = c;
if (!isClosed) {
emit(
state.copyWith(
runningStatusModel: state.runningStatusModel.copyWith(
voltage: _cacheVoltage,
battery: _cacheBattery,
controlMode: _cacheCtrlMode,
),
battery: int.tryParse(_cacheBattery ?? '') ?? 0,
ping: _cachePing,
hasReceivedStatusPush: true, // 🔥 门槛:TCP 0x02 到达即置位
updateType: 'device_status',
),
);
}
} catch (e) {
debugPrint('❌ [TCP-0x02门槛] 解析异常:$e');
}
});
}
// 🔥 超简单方法:传入 IP,得到 ping 值
// 🔥 模拟设备状态更新 - 用于测试
@@ -700,6 +780,7 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
Future<void> close() {
_timer?.cancel();
_deviceStatusSub?.cancel(); // 🔥 取消订阅 DeviceStatusBloc
_tcpStatusSub?.cancel(); // 🔥 取消订阅 TCP 0x02 门槛监听
return super.close();
}
@@ -742,15 +823,20 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
debugPrint('${_getTimePrefix()} ====权限弹窗响应结束=====');
}
/// 请求控制权限(HTTP 查询接口 /forward/device/remoteControl)
///
/// [grabControl] true = 查询后若无权限立即发 TCP 0x12 switch_control 抢占(手动点击时使用);
/// false = 仅查询,把接口 remoteControl 结果如实同步到 UI(进入页面时使用)
Future<void> requestControlPermissionS(
String deviceName,
String deviceId, {
String source = '自动',
bool grabControl = false,
}) async {
final timePrefix = _getTimePrefix();
final logPrefix = '$timePrefix 🔑 [RemoteControl] [请求权限接口-$source]';
debugPrint('$logPrefix =========================================');
debugPrint('$logPrefix 开始请求控制权');
debugPrint('$logPrefix 开始请求控制权 (grabControl=$grabControl)');
debugPrint('$logPrefix deviceName: $deviceName');
debugPrint('$logPrefix platform: $deviceId');
debugPrint(
@@ -808,8 +894,21 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
shouldLog: true,
);
// 🔥 关键逻辑: 如果没有权限 或owner为null,则发送TCP 请求
if (!hasPermission || owner == null) {
// 🔥 仅查询模式:不抢占,把接口 remoteControl 结果如实同步到 UI
if (!grabControl) {
debugPrint(
'$successLogPrefix 👀 仅查询模式,不发送TCP抢占,直接同步 remoteControl=$hasPermission',
);
_logger.logWithLevel(
'$successLogPrefix 👀 仅查询模式,直接同步 remoteControl=$hasPermission',
shouldLog: true,
);
if (state.hasPermission != hasPermission) {
emit(state.copyWith(hasPermission: hasPermission));
}
}
// 🔥 抢占模式: 如果没有权限 或owner为null,则发送TCP 请求
else if (!hasPermission || owner == null) {
debugPrint('$successLogPrefix ⚠️ APP无权限或owner为null,发送TCP请求...');
_logger.logWithLevel(
'$successLogPrefix ⚠️ APP无权限或owner为null,发送TCP请求',
@@ -850,6 +949,23 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
);
}
/// 🔥 退出遥控页时重置权限状态,避免单例状态残留导致下次进入不再查询接口
void resetPermissionState() {
if (!isClosed) {
emit(
state.copyWith(
hasPermission: false,
showPermissionRequestDialog: false,
),
);
debugPrint('🔑 [RemoteControl] 已重置权限状态 hasPermission=false');
_logger.logWithLevel(
'🔑 [RemoteControl] 已重置权限状态 hasPermission=false',
shouldLog: true,
);
}
}
/// 🔥 重置弹窗状态 - 在弹窗关闭后调用(已简化,不再需要标志位)
void resetPermissionCoolDown() {
// 标志位已移除,此方法保留以保持向后兼容性
@@ -972,9 +1088,12 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
}
}
//app退出远程遥控界面释放权限
Future<bool> releasePermission(String platform) async {
return await _repository.releasePermission(platform);
//app退出远程遥控界面释放权限(type=2),退出机器人详情页释放权限(type=1)
Future<bool> releasePermission({
required String deviceId,
required int type,
}) async {
return await _repository.releasePermission(deviceId: deviceId, type: type);
}
/// 🔥 设置待控制的设备(从机器人列表点击进入时调用)
@@ -990,6 +1109,14 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
debugPrint('📦 [RemoteControl] 更新targetDevice状态');
emit(state.copyWith(targetDevice: device));
debugPrint('✅ [RemoteControl] targetDevice状态已更新');
// 🔥 关键修复:切换设备时重置 DeviceStatusBloc 状态并清空缓存,
// 清除上一个设备的坐标/状态,防止路径规划显示旧车位置(与 switchDevice 对齐)
deviceStatusBloc.add(DeviceStatusReset());
// 🔥 MQTT机器状态订阅:切换设备时订阅新设备的topic(与DevicesCubit.switchDevice相同逻辑)
debugPrint('📡 [RemoteControl] 开始MQTT机器状态订阅: ${device.deviceName}');
deviceStatusBloc.startMqttRealtimeListening(device.deviceName);
// 🔥 关键修复:TCP 已在登录时建立,设备切换只用 HTTP
// 不再创建新 TCP 连接,避免重复发送 0x03 触发服务端推送 have_logged_in

View File

@@ -5,13 +5,13 @@ import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import '../../../../core/app/app_user_cubit.dart';
import '../../../../core/router/route_paths.dart';
import '../../../auth/presentation/bloc/auth_cubit.dart'; // 🔥 导入 AuthCubit
import '../../../devices/domain/entities/device_entity.dart';
import '../../../devices/presentation/bloc/devices_cubit.dart';
import '../bloc/remote_control_cubit.dart';
import '../bloc/remote_control_state.dart';
@@ -115,9 +115,6 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
return;
}
// 🔥 弹窗显示当前控制的设备信息
_showTargetDeviceDialog(context, remoteCubit.state.targetDevice!);
// 🔥 只在控制循环未启动时才启动
remoteCubit.startControlLoop();
// debugPrint('✅ [RemoteControl] 控制循环已启动');
@@ -129,18 +126,23 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
// 🔥 离开安全模式:启动 90 秒倒计时
GetIt.I<AuthCubit>().exitSafeMode();
// 释放远程控制权限
_cubit.releasePermission("app");
//print("远程控制要推出啦");
//final deviceState = _devicesCubit?.state;
//if (deviceState?.selectedDevice != null) {
// _cubit.releasePermission("app");
// }
// 释放远程控制权限(type=2:退出远程遥控页面释放)
final deviceId = _cubit.state.targetDevice?.deviceName;
if (deviceId != null && deviceId.isNotEmpty) {
_cubit.releasePermission(deviceId: deviceId, type: 2).then((success) {
CapsuleToast.show(
success ? '已释放远程控制权限' : '控制权限释放失败',
showCheck: success,
);
});
}
_permissionSubscription?.cancel(); // 🔥 取消订阅
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
_cubit.stopControlLoop();
// 🔥 重置权限状态,避免单例 hasPermission 残留导致下次进入不再查询接口
_cubit.resetPermissionState();
super.dispose();
}
@@ -149,7 +151,6 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
// 🔥 只监听 RemoteControlCubit,避免其他状态变化导致频繁 rebuild
final remoteCubit = context.read<RemoteControlCubit>();
final targetDevice = remoteCubit.state.targetDevice;
final hasPermission = remoteCubit.state.hasPermission;
// 🔥 调试日志:检查设备状态
debugPrint('🔍 [RemoteControlPage] build - targetDevice: ${targetDevice?.deviceName}');
@@ -159,26 +160,22 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
return _buildOfflineScaffold();
}
// 🔥 只在没有权限时自动请求,避免重复调用
if (!hasPermission && _isLoadingPermission) {
// 🔥 只使用 targetDevice
final deviceName = remoteCubit.state.targetDevice?.deviceName;
if (deviceName != null && deviceName.isNotEmpty) {
// debugPrint('🔑 [RemoteControl] 检测到无权限,自动发送权限请求 - deviceName: $deviceName');
setState(() => _isLoadingPermission = false); // 🔥 标记为已请求
remoteCubit.requestControlPermissionS(deviceName, "app").then((_) {
if (mounted) {
setState(() => _isLoadingPermission = false); // 🔥 请求完成后重置
}
});
} else {
// debugPrint('❌ [RemoteControl] 无法发送权限请求 - targetDevice 为空');
// debugPrint(' targetDevice: ${remoteCubit.state.targetDevice}');
setState(() => _isLoadingPermission = false);
}
} else {
// debugPrint('✅ [RemoteControl] 已有权限或已请求过,跳过');
// 🔥 进入页面只查询一次真实权限状态(不抢占),按接口 remoteControl 如实展示
if (_isLoadingPermission) {
// 直接置标志位,不在 build 期间调用 setState
_isLoadingPermission = false;
final deviceName = targetDevice.deviceName;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && deviceName.isNotEmpty) {
debugPrint('🔑 [RemoteControlPage] 进入页面,仅查询权限状态 - deviceName: $deviceName');
remoteCubit.requestControlPermissionS(
deviceName,
'app',
source: '进入页面查询',
grabControl: false,
);
}
});
}
return Scaffold(
@@ -201,16 +198,20 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
.targetDevice;
final deviceId = targetDevice?.deviceName;
// 🔥 从 RemoteControlCubit 获取 token(如果有的话)
// 注意:如果 token 不在 RemoteControlCubit 中,需要从其他地方获取
// 这里假设 token 是有效的,直接构建 URL
if (deviceId != null && deviceId.isNotEmpty) {
// 🔥 从 AppUserCubit 获取登录 token:SRS on_play 会校验,缺失会被拒绝拉流
final token = context.read<AppUserCubit>().state.user?.token;
if (deviceId != null &&
deviceId.isNotEmpty &&
token != null &&
token.isNotEmpty) {
_videoStreamUrl =
"webrtc://${TCPConsts.TCP_IP}/live/livestream/$deviceId";
"webrtc://${TCPConsts.TCP_IP}/live/livestream/$deviceId?token=$token";
debugPrint('🎬 [WebRTC] URL构建成功: $_videoStreamUrl');
} else {
_videoStreamUrl = '';
debugPrint('❌ [WebRTC] URL构建失败 - deviceId: $deviceId');
debugPrint(
'❌ [WebRTC] URL构建失败 - deviceId: $deviceId, hasToken: ${token != null}',
);
}
final int originY = context
.watch<RemoteControlCubit>()
@@ -538,36 +539,6 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
);
}
/// 🔥 显示当前控制设备的弹窗
void _showTargetDeviceDialog(BuildContext context, DeviceEntity device) {
showDialog(
context: context,
barrierDismissible: true,
builder: (ctx) => AlertDialog(
title: const Text('🎮 远程控制'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'设备名称: ${device.deviceName}',
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 8),
Text(
'设备ID: ${device.deviceName}',
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('确定'),
),
],
),
);
}
/// 🔥 显示当前控制设备的弹窗 - 已移除:进入远程遥控页不再弹出设备确认弹窗
}

View File

@@ -2,6 +2,7 @@ import 'package:cc_ui_kit/cc_ui_kit.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import '../bloc/remote_control_cubit.dart';
@@ -115,6 +116,13 @@ class CenterControlArea extends StatelessWidget {
void _handleSliderAction(String deviceKey, String action, BuildContext context) {
debugPrint('🎯 [Slider 业务] deviceKey: $deviceKey, action: $action');
// 🔥 门槛检查:与摇杆一致,未收到 TCP 0x02 推送时禁止操作并提示。
// 胶囊 Toast 全局去重、自动消失,适配 StatelessWidget 无实例状态。
if (!context.read<RemoteControlCubit>().isStatusPushActive()) {
CapsuleToast.show('暂未收到该机器的推送,不能控制', showCheck: false);
return;
}
// 🔥 使用deviceKey进行判断,不依赖显示文本
switch (deviceKey) {
case 'chassis':

View File

@@ -97,8 +97,8 @@ class TopStatusBar extends StatelessWidget {
breathing: !remoteState.hasPermission,
onTap: () {
if (!remoteState.hasPermission) {
// 🔥 点击后重新请求权限,和刚进入页面时的逻辑一致
debugPrint('🔑 [TopStatusBar] 👆 用户手动点击“未在控制”,重新请求权限');
// 🔥 点击后发起抢占:查权限 + 无权限则发 TCP switch_control
debugPrint('🔑 [TopStatusBar] 👆 用户手动点击“未在控制”,请求抢占控制权');
final targetDevice =
_remoteControlCubit.state.targetDevice;
if (targetDevice != null) {
@@ -106,6 +106,7 @@ class TopStatusBar extends StatelessWidget {
targetDevice.deviceName,
'app',
source: '手动点击',
grabControl: true,
);
} else {
debugPrint('❌ [TopStatusBar] targetDevice 为空,无法请求权限');

View File

@@ -71,6 +71,11 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
void _updateLoadState(WebRTCLoadState state) {
if (_loadState == state) return;
_loadState = state;
// 🔥 自身状态变化必须触发重建:未传 onLoadingStateChanged 的调用方
// (如远程遥控页)否则无法从"视频加载中"切到 playing/error 界面
if (mounted) {
setState(() {});
}
widget.onLoadingStateChanged?.call(state);
}
@@ -404,19 +409,34 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
// 🔥 在真正渲染出视频帧(playing 态)之前,始终显示加载指示器
// 避免出现"track 已收到但画面是黑的"黑屏情况
if (_loadState != WebRTCLoadState.playing) {
// 🔥 区分 loading 与 error:error 态显示明确失败提示,不再一直转圈
final bool isError = _loadState == WebRTCLoadState.error;
return Container(
color: Colors.black,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(color: Colors.white),
const SizedBox(height: 12),
Text(
'视频加载中...',
style: TextStyle(color: Colors.white70, fontSize: 14),
),
],
children: isError
? [
const Icon(
Icons.videocam_off,
size: 48,
color: Colors.white38,
),
const SizedBox(height: 12),
const Text(
'视频加载失败,请检查设备是否在线或重试',
style: TextStyle(color: Colors.white70, fontSize: 14),
),
]
: [
const CircularProgressIndicator(color: Colors.white),
const SizedBox(height: 12),
const Text(
'视频加载中...',
style: TextStyle(color: Colors.white70, fontSize: 14),
),
],
),
),
);

View File

@@ -1,4 +1,5 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../../../../../core/consts/http_api_consts.dart';
import '../../domain/entities/device_entity.dart';
import '../../domain/entities/device_status_entity.dart';
@@ -12,6 +13,10 @@ abstract class DeviceRemoteDataSource {
int? siteId,
String? typeFilter,
});
Future<List<DeviceDataModel>> getAllDevices();
/// 根据拆分码(蓝牙设备名/时间戳)精确查询设备,未查到时返回 null
Future<DeviceDataModel?> getDeviceBySpiltCode(String code);
}
/// 设备远程数据源实现类 - 从真实 API 获取数据
@@ -64,6 +69,75 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource {
}
}
@override
Future<List<DeviceDataModel>> getAllDevices() async {
try {
final response = await dio.get(
HttpApiConsts.getDeviceList,
queryParameters: {'pageNum': 1, 'pageSize': 9999},
);
if (response.statusCode != 200) {
throw Exception('网络请求失败: ${response.statusCode}');
}
final responseData = response.data;
if (responseData['code'] != 200) {
throw Exception(responseData['msg'] ?? '业务异常');
}
final List<dynamic> rows = responseData['rows'] ?? [];
debugPrint(
'>>> [DeviceRemoteDataSource] getAllDevices 返回 ${rows.length} 条设备',
);
for (int i = 0; i < rows.length && i < 10; i++) {
debugPrint('>>> [DeviceRemoteDataSource] rows[$i]: ${rows[i]}');
}
return rows.map((item) => _parseDeviceFromJson(item)).toList();
} catch (e) {
debugPrint('>>> [DeviceRemoteDataSource] getAllDevices error: $e');
rethrow;
}
}
@override
Future<DeviceDataModel?> getDeviceBySpiltCode(String code) async {
try {
final response = await dio.get(
HttpApiConsts.getDeviceBySpiltCode,
queryParameters: {'code': code},
);
if (response.statusCode != 200) {
throw Exception('网络请求失败: ${response.statusCode}');
}
final responseData = response.data;
if (responseData['code'] != 200) {
throw Exception(responseData['msg'] ?? '业务异常');
}
// 未查到时后端仅返回 {msg, code},无 data 字段
final data = responseData['data'];
if (data == null) {
debugPrint(
'>>> [DeviceRemoteDataSource] getDeviceBySpiltCode 未查到设备 code=$code',
);
return null;
}
// data 结构与 getDeviceList 的单条 row 一致,复用解析(deviceName 即 serialNumber)
final model = _parseDeviceFromJson(Map<String, dynamic>.from(data as Map));
debugPrint(
'>>> [DeviceRemoteDataSource] getDeviceBySpiltCode 命中 code=$code serialNumber=${model.name}',
);
return model;
} catch (e) {
debugPrint('>>> [DeviceRemoteDataSource] getDeviceBySpiltCode error: $e');
rethrow;
}
}
Future<List<DeviceDataModel>> _fetchDevicesFromAPI(int siteId) async {
final response = await dio.get(
HttpApiConsts.getSiteDeviceList,
@@ -107,10 +181,19 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource {
DeviceDataModel _parseDeviceFromJson(Map<String, dynamic> json) {
// 在线状态:使用 onlineStatus 字段(1=在线, 0=离线)
final onlineStatus = json['onlineStatus'] ?? 0;
final deviceId = json['deviceId']?.toString() ?? '';
final name = json['deviceName'] ?? json['name'] ?? '';
final type = json['deviceTypeName'] ?? json['type'] ?? '未知设备';
final isRobot = _isRobotDevice(type, name, deviceId);
debugPrint(
'🔍 [DeviceListDataSource] deviceId=$deviceId name=$name type=$type isRobot=$isRobot',
);
return DeviceDataModel(
deviceId: json['deviceId']?.toString() ?? '',
name: json['deviceName'] ?? json['name'] ?? '',
type: json['deviceTypeName'] ?? json['type'] ?? '未知设备',
deviceId: deviceId,
name: name,
type: type,
status: onlineStatus == 1 ? '在线' : '离线',
power: (json['power'] as num?)?.toDouble() ?? 0,
todayEnergy: (json['todayEnergy'] as num?)?.toDouble() ?? 0,
@@ -118,6 +201,7 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource {
current: (json['current'] as num?)?.toDouble(),
voltage: (json['voltage'] as num?)?.toDouble(),
radiation: (json['radiation'] as num?)?.toDouble(),
isRobot: isRobot,
);
}
@@ -131,9 +215,32 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource {
power: (json['capacity_percent'] as num?)?.toDouble() ?? 0,
todayEnergy: 0,
temperature: (json['environment_temperature'] as num?)?.toDouble(),
isRobot: false,
);
}
/// 🔥 通用判断:是否机器人(型号前缀 + 中文关键字双重匹配,新型号也能自动识别)
bool _isRobotDevice(String type, String name, String deviceId) {
final haystacks = [
type.toLowerCase(),
name.toLowerCase(),
deviceId.toLowerCase(),
];
// 型号家族前缀(MC700/MC700STD/MC700PLUS/MC700AIR/MC700PRO 等;RCHWD/RCHETD 等)
const prefixes = ['mc', 'rch', 'rcetd', 'rchetd'];
// 中文类型关键字
const keywords = ['除草', '巡检', '清洗', '割草', '机器人'];
for (final s in haystacks) {
for (final p in prefixes) {
if (s.startsWith(p)) return true;
}
}
for (final k in keywords) {
if (type.contains(k) || name.contains(k)) return true;
}
return false;
}
String _getStatusText(int status) {
switch (status) {
case 1:

View File

@@ -12,6 +12,7 @@ class DeviceDataModel {
final double? current;
final double? voltage;
final double? radiation;
final bool isRobot;
const DeviceDataModel({
required this.deviceId,
@@ -24,6 +25,7 @@ class DeviceDataModel {
this.current,
this.voltage,
this.radiation,
this.isRobot = false,
});
/// 从 JSON 创建数据模型
@@ -71,6 +73,7 @@ class DeviceDataModel {
current: current,
voltage: voltage,
radiation: radiation,
isRobot: isRobot,
);
}
}

View File

@@ -9,6 +9,7 @@ class RobotDataModel {
final int statusCode; // 接口原始 status 状态码
final double battery;
final String task;
final bool hasHost;
const RobotDataModel({
required this.name,
@@ -20,6 +21,7 @@ class RobotDataModel {
this.statusCode = 0,
required this.battery,
required this.task,
this.hasHost = false,
});
/// 从 JSON 创建数据模型
@@ -46,6 +48,7 @@ class RobotDataModel {
statusCode: statusCode is int ? statusCode : 0,
battery: batteryValue,
task: json['task'] ?? json['currentTask'] ?? '待机中',
hasHost: json['hasHost'] as bool? ?? false,
);
}

View File

@@ -20,4 +20,16 @@ class DeviceRepositoryImpl implements DeviceRepository {
final dataModels = await remoteDataSource.getDeviceList(siteId: siteId, typeFilter: typeFilter);
return dataModels.map((model) => model.toEntity()).toList();
}
@override
Future<List<DeviceEntity>> getAllDevices() async {
final dataModels = await remoteDataSource.getAllDevices();
return dataModels.map((model) => model.toEntity()).toList();
}
@override
Future<DeviceEntity?> getDeviceBySpiltCode(String code) async {
final dataModel = await remoteDataSource.getDeviceBySpiltCode(code);
return dataModel?.toEntity();
}
}

View File

@@ -10,6 +10,7 @@ class DeviceEntity {
final double? current;
final double? voltage;
final double? radiation;
final bool isRobot;
const DeviceEntity({
required this.deviceId,
@@ -22,5 +23,6 @@ class DeviceEntity {
this.current,
this.voltage,
this.radiation,
this.isRobot = false,
});
}

View File

@@ -5,4 +5,8 @@ import '../entities/device_status_entity.dart';
abstract class DeviceRepository {
Future<DeviceStatusEntity> getDeviceStatus();
Future<List<DeviceEntity>> getDeviceList({int? siteId, String? typeFilter});
Future<List<DeviceEntity>> getAllDevices();
/// 根据拆分码(蓝牙设备名/时间戳)精确查询设备,未查到时返回 null
Future<DeviceEntity?> getDeviceBySpiltCode(String code);
}

View File

@@ -0,0 +1,13 @@
import '../entities/device_entity.dart';
import '../repositories/device_repository.dart';
/// 获取全部设备列表用例(用于蓝牙绑定匹配)
class GetAllDevicesUseCase {
final DeviceRepository repository;
const GetAllDevicesUseCase({required this.repository});
Future<List<DeviceEntity>> execute() async {
return await repository.getAllDevices();
}
}

View File

@@ -0,0 +1,13 @@
import '../entities/device_entity.dart';
import '../repositories/device_repository.dart';
/// 根据拆分码(蓝牙设备名/时间戳)精确查询设备用例(用于蓝牙绑定匹配)
class GetDeviceBySpiltCodeUseCase {
final DeviceRepository repository;
const GetDeviceBySpiltCodeUseCase({required this.repository});
Future<DeviceEntity?> execute(String code) async {
return await repository.getDeviceBySpiltCode(code);
}
}

View File

@@ -10,15 +10,15 @@ class FloatBarSettingService {
/// 🔥 静态实例引用
static FloatBarSettingService? _instance;
/// 🔥 静态状态变量 - 所有组件共享
static bool _isEnabled = true;
/// 🔥 静态状态变量 - 所有组件共享(默认关闭)
static bool _isEnabled = false;
/// 🔥 静态 ValueNotifier - 用于通知UI变化
static final ValueNotifier<bool> _settingNotifier = ValueNotifier<bool>(true);
/// 🔥 静态 ValueNotifier - 用于通知UI变化(默认关闭)
static final ValueNotifier<bool> _settingNotifier = ValueNotifier<bool>(false);
FloatBarSettingService(this._prefs) {
_instance = this;
_isEnabled = _prefs.getBool(_key) ?? true;
_isEnabled = _prefs.getBool(_key) ?? false;
_settingNotifier.value = _isEnabled;
debugPrint('✅ [FloatBarSettingService] 初始化完成,初始状态: $_isEnabled');
}

View File

@@ -23,7 +23,7 @@ import '../../../../../../core/network/mqtt/data/datasources/drone_osd_datasourc
enum SimulationMode {
circle, // 圆形路径
polygon, // 多边形路径(六边形)
bow, // 弓字形路径(Zigzag)
bow, // 覆盖模式路径(Coverage)
rectangle, // 矩形路径
}
@@ -524,7 +524,7 @@ class FloatBarWidgetState extends State<FloatBarWidget> {
return path;
}
/// 生成弓字形路径(Zigzag)
/// 生成覆盖模式路径(Coverage)
List<LatLng> _generateBowPath(double centerLat, double centerLng, int points) {
final List<LatLng> path = [];
const double width = 0.004; // 宽度约400米
@@ -544,7 +544,7 @@ class FloatBarWidgetState extends State<FloatBarWidget> {
}
}
debugPrint('〰️ 生成弓字形路径: ${path.length}个点, $rows行');
debugPrint('〰️ 生成覆盖模式路径: ${path.length}个点, $rows行');
return path;
}
@@ -1501,12 +1501,13 @@ class FloatBarWidgetState extends State<FloatBarWidget> {
},
),
children: [
// 高德地图瓦片(最底层)
// ESRI World Imagery(WGS84 坐标系,全球高清卫星图)
// 替代高德地图(GCJ02),统一坐标系,无需坐标转换
TileLayer(
urlTemplate:
'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}',
subdomains: ['1', '2', '3', '4'],
urlTemplate: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
userAgentPackageName: 'com.example.app',
minZoom: 0,
maxZoom: 19,
),
// 轨迹线(中间层)

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:maibu_satabot_v2/core/di/injection.dart';
import 'package:maibu_satabot_v2/core/theme/AppTheme.dart';
import '../cubit/bind_device_cubit.dart';
import '../cubit/bind_device_state.dart';
@@ -44,24 +45,24 @@ class _BindDevicePageState extends State<BindDevicePage> {
}
},
child: Scaffold(
backgroundColor: const Color(0xFFF5F6F8),
backgroundColor: context.appColors.pageBackground,
appBar: AppBar(
backgroundColor: Colors.white,
backgroundColor: context.appColors.cardBackground,
elevation: 0,
leading: IconButton(
icon: const Icon(
icon: Icon(
Icons.arrow_back_ios,
color: Color(0xFF1D2129),
color: context.appColors.textPrimary,
size: 20,
),
onPressed: () => Navigator.of(context).pop(),
),
title: const Text(
title: Text(
'绑定智能装备',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2129),
color: context.appColors.textPrimary,
),
),
centerTitle: true,
@@ -69,21 +70,23 @@ class _BindDevicePageState extends State<BindDevicePage> {
body: BlocBuilder<BindDeviceCubit, BindDeviceState>(
builder: (context, state) {
if (state.isLoading) {
return const Center(
child: CircularProgressIndicator(color: Color(0xFF165DFF)),
return Center(
child: CircularProgressIndicator(
color: context.appColors.primary,
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
color: context.appColors.cardBackground,
borderRadius: BorderRadius.circular(12),
boxShadow: const [
boxShadow: [
BoxShadow(
color: Color(0x0D000000),
color: context.appColors.cardShadow,
blurRadius: 8,
offset: Offset(0, 2),
offset: const Offset(0, 2),
),
],
),
@@ -102,17 +105,17 @@ class _BindDevicePageState extends State<BindDevicePage> {
vertical: 14,
),
decoration: BoxDecoration(
color: const Color(0xFFF7F8FA),
color: context.appColors.fillBackground,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFFE5E6EB),
color: context.appColors.divider,
),
),
child: Text(
widget.scanResult,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
color: Color(0xFF1D2129),
color: context.appColors.textPrimary,
),
),
),
@@ -151,19 +154,19 @@ class _BindDevicePageState extends State<BindDevicePage> {
padding: const EdgeInsets.symmetric(
vertical: 10,
),
side: const BorderSide(
color: Color(0xFFE5E6EB),
side: BorderSide(
color: context.appColors.divider,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
child: Text(
'取消',
style: TextStyle(
fontSize: 14,
height: 1.0,
color: Color(0xFF4E5969),
color: context.appColors.textSecondary,
),
),
),
@@ -178,7 +181,7 @@ class _BindDevicePageState extends State<BindDevicePage> {
? null
: _handleSubmit,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF165DFF),
backgroundColor: context.appColors.primary,
elevation: 0,
padding: const EdgeInsets.symmetric(
vertical: 10,
@@ -243,20 +246,23 @@ class _BindDevicePageState extends State<BindDevicePage> {
return Container(
decoration: BoxDecoration(
color: Colors.white,
color: context.appColors.cardBackground,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE5E6EB)),
border: Border.all(color: context.appColors.divider),
),
child: DropdownButtonFormField<int>(
value: validValue,
decoration: _dropdownDecoration(enabled, '请选择'),
icon: const Icon(Icons.expand_more, color: Color(0xFF86909C)),
icon: Icon(Icons.expand_more, color: context.appColors.textTertiary),
items: state.orgList.map((org) {
return DropdownMenuItem<int>(
value: org.id,
child: Text(
org.name,
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
style: TextStyle(
fontSize: 14,
color: context.appColors.textPrimary,
),
overflow: TextOverflow.ellipsis,
),
);
@@ -285,20 +291,23 @@ class _BindDevicePageState extends State<BindDevicePage> {
return Container(
decoration: BoxDecoration(
color: Colors.white,
color: context.appColors.cardBackground,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE5E6EB)),
border: Border.all(color: context.appColors.divider),
),
child: DropdownButtonFormField<int>(
value: validValue,
decoration: _dropdownDecoration(enabled, hintText),
icon: const Icon(Icons.expand_more, color: Color(0xFF86909C)),
icon: Icon(Icons.expand_more, color: context.appColors.textTertiary),
items: state.siteList.map((site) {
return DropdownMenuItem<int>(
value: site.id,
child: Text(
site.name,
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
style: TextStyle(
fontSize: 14,
color: context.appColors.textPrimary,
),
overflow: TextOverflow.ellipsis,
),
);
@@ -327,20 +336,23 @@ class _BindDevicePageState extends State<BindDevicePage> {
return Container(
decoration: BoxDecoration(
color: Colors.white,
color: context.appColors.cardBackground,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE5E6EB)),
border: Border.all(color: context.appColors.divider),
),
child: DropdownButtonFormField<int>(
value: validValue,
decoration: _dropdownDecoration(enabled, hintText),
icon: const Icon(Icons.expand_more, color: Color(0xFF86909C)),
icon: Icon(Icons.expand_more, color: context.appColors.textTertiary),
items: state.userList.map((user) {
return DropdownMenuItem<int>(
value: user.id,
child: Text(
user.name,
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
style: TextStyle(
fontSize: 14,
color: context.appColors.textPrimary,
),
overflow: TextOverflow.ellipsis,
),
);
@@ -365,15 +377,15 @@ class _BindDevicePageState extends State<BindDevicePage> {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: const Color(0xFFE8F3FF),
color: context.appColors.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: const Color(0xFF165DFF), width: 0.5),
border: Border.all(color: context.appColors.primary, width: 0.5),
),
child: Text(
'当前角色:$roleLabel($roleKey)',
style: const TextStyle(
style: TextStyle(
fontSize: 12,
color: Color(0xFF165DFF),
color: context.appColors.primary,
fontWeight: FontWeight.w500,
),
),
@@ -384,20 +396,23 @@ class _BindDevicePageState extends State<BindDevicePage> {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
color: const Color(0xFFF7F8FA),
color: context.appColors.fillBackground,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE5E6EB)),
border: Border.all(color: context.appColors.divider),
),
child: Row(
children: [
Expanded(
child: Text(
text,
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
style: TextStyle(
fontSize: 14,
color: context.appColors.textPrimary,
),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.lock, size: 16, color: Color(0xFFC9CDD4)),
Icon(Icons.lock, size: 16, color: context.appColors.textTertiary),
],
),
);
@@ -411,7 +426,10 @@ class _BindDevicePageState extends State<BindDevicePage> {
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: hintText,
hintStyle: const TextStyle(color: Color(0xFF86909C), fontSize: 14),
hintStyle: TextStyle(
color: context.appColors.textTertiary,
fontSize: 14,
),
);
}
@@ -422,18 +440,18 @@ class _BindDevicePageState extends State<BindDevicePage> {
RichText(
text: TextSpan(
children: [
const TextSpan(
TextSpan(
text: '* ',
style: TextStyle(
color: Color(0xFFF53F3F),
color: context.appColors.danger,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
TextSpan(
text: label,
style: const TextStyle(
color: Color(0xFF1D2129),
style: TextStyle(
color: context.appColors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w500,
),
@@ -453,8 +471,8 @@ class _BindDevicePageState extends State<BindDevicePage> {
children: [
Text(
label,
style: const TextStyle(
color: Color(0xFF1D2129),
style: TextStyle(
color: context.appColors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w500,
),

Some files were not shown because too many files have changed in this diff Show More