Compare commits
43 Commits
feature-te
...
feature/ne
| Author | SHA1 | Date | |
|---|---|---|---|
| 208147cdf2 | |||
| b3a700491a | |||
| d230279f4f | |||
| 32397f8899 | |||
| c8b597f683 | |||
| a1e01da241 | |||
| 3547f80383 | |||
| 4b88a291a5 | |||
| 877ff932d4 | |||
| 43c0b68e1a | |||
| 356c2a1925 | |||
| 3ba480f63f | |||
| 85e9632326 | |||
| b4bedd9737 | |||
| d76bcdcdf2 | |||
| b468e577a6 | |||
| db81816b78 | |||
| 5141d6e83f | |||
| 61bce2c1cf | |||
| 0fa67a127b | |||
| 7c01229b9e | |||
| a48778bc8a | |||
| e8070b5183 | |||
| fc3cf4de0f | |||
| db323a26ce | |||
| 11509d1aa6 | |||
| 97a2126fcf | |||
| 0978f387fd | |||
| e9532ddafa | |||
| 43fbd0af19 | |||
| 4b03da8142 | |||
| d0fcc067ca | |||
| ca805ee957 | |||
| d119407866 | |||
| 5a02522bb4 | |||
| 54dac30915 | |||
| 875766cc28 | |||
| e8a79168e8 | |||
| 7773fe7a60 | |||
| b6e2a40aa7 | |||
| bb2becdf7b | |||
| ca616faf11 | |||
| 83220869da |
21
.gitignore
vendored
21
.gitignore
vendored
@@ -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 打开时生成,不需要提交)
|
||||
# ----------------------------------------------------------------------------
|
||||
~$*
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 编辑器与系统文件
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
Binary file not shown.
61
_fix_dup.py
61
_fix_dup.py
@@ -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')
|
||||
92
_fix_flow.py
92
_fix_flow.py
@@ -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")
|
||||
51
_fix_impl.py
51
_fix_impl.py
@@ -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)}')
|
||||
@@ -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)}')
|
||||
@@ -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)}')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
52
build_patch.bat
Normal 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
4
dist/manifest.json
vendored
@@ -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
520
docs/SOP_使用文档.md
Normal 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 内"上报"页面提交问题反馈
|
||||
- **技术支持**:请联系管理员获取联系方式
|
||||
61
fix_dup.py
61
fix_dup.py
@@ -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
12
fix_json.ps1
Normal 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!"
|
||||
@@ -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.
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
```
|
||||
|
||||
107
flutter_03.log
107
flutter_03.log
@@ -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.
|
||||
```
|
||||
106
flutter_04.log
106
flutter_04.log
@@ -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.
|
||||
```
|
||||
106
flutter_05.log
106
flutter_05.log
@@ -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.
|
||||
```
|
||||
106
flutter_06.log
106
flutter_06.log
@@ -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.
|
||||
```
|
||||
106
flutter_07.log
106
flutter_07.log
@@ -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.
|
||||
```
|
||||
106
flutter_08.log
106
flutter_08.log
@@ -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.
|
||||
```
|
||||
@@ -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'
|
||||
|
||||
121
ios/Podfile.lock
121
ios/Podfile.lock
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
947
lib/components/agora_video_player.dart
Normal file
947
lib/components/agora_video_player.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
125
lib/components/capsule_toast.dart
Normal file
125
lib/components/capsule_toast.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 '--';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
|
||||
24
lib/core/env/env_config.dart
vendored
24
lib/core/env/env_config.dart
vendored
@@ -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;
|
||||
}
|
||||
|
||||
14
lib/core/logging/log_time.dart
Normal file
14
lib/core/logging/log_time.dart
Normal 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')}]';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 连接已断开');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
class RoutePaths {
|
||||
/// 启动页(App 第一个路由)
|
||||
static const splash = '/splash';
|
||||
|
||||
/// 账号相关页面
|
||||
static const login = '/auth/login';
|
||||
static const register = '/auth/register';
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
24
lib/core/utils/image_url_util.dart
Normal file
24
lib/core/utils/image_url_util.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
114
lib/features/auth/presentation/pages/splash_page.dart
Normal file
114
lib/features/auth/presentation/pages/splash_page.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)}');
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ abstract class DeviceTaskRepository {
|
||||
required int taskId,
|
||||
required int orgId,
|
||||
required int siteId,
|
||||
bool forceCancel = true,
|
||||
});
|
||||
|
||||
/// 暂停任务
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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('🧹 清除当前任务');
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -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
@@ -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(() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
/// 🔥 显示当前控制设备的弹窗 - 已移除:进入远程遥控页不再弹出设备确认弹窗
|
||||
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 为空,无法请求权限');
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
// 轨迹线(中间层)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user