23 Commits

Author SHA1 Message Date
dff257c3f3 更换包名+app名字更换 2026-11-12 10:17:40 +08:00
6cf6bea082 对接用户协议和隐私政策 2026-11-11 17:00:51 +08:00
985bcf4b55 对接连续任务列表接口和连续任务下发,便捷操作中暂停任务的对接,底部导航tab项的设置根据用户角色显示和设置 2026-11-11 16:51:21 +08:00
47f518f94c 重整多余的UI组件和未定功能的逻辑链路的修正 2026-11-11 16:33:04 +08:00
34cb9d2805 自主任务接口接入和bug修复和工作记录页面 2026-11-06 08:50:51 +08:00
4e92398349 机器状态数据和地图切换,系统配置页面 2026-11-06 08:49:21 +08:00
84eec7da96 机器列表和远程喊话等ui 2026-11-06 08:47:36 +08:00
e6165b180b 修改名字接口 2026-11-06 08:44:47 +08:00
8eea42b75d 分区相关 2026-11-06 08:44:05 +08:00
85c83ec143 作业首页和机器状态的数据 2026-11-06 08:41:44 +08:00
d882db7995 远程遥控相关 2026-11-06 08:34:19 +08:00
eb8dec2868 页面UI替换1远程遥控和各项弹窗和作业主页面和子页面+路径规划的主页面重构等 2026-10-28 14:38:24 +08:00
8a41113c92 绑定页新UI+逻辑链路的适配+设备的首页无设备页面和详情页面 2026-10-24 17:18:42 +08:00
e3af8cf617 新版UI-登录页面-我的页面(简单适配)-设备的部分(无设备的+有设备) 2026-10-24 09:08:33 +08:00
81dae187ac +ios端的配置修改 2026-10-22 14:11:42 +08:00
56d5d55d6e +ios端的配置修改 2026-10-22 13:22:35 +08:00
615d76a8ce USB 摄像头适配iPadOS17+ios端的配置 2026-10-22 12:54:08 +08:00
58a23ef218 USB 摄像头适配iPadOS17+ 2026-10-22 12:53:24 +08:00
582242e9aa USB 摄像头+远程遥控+中英的适配+topic的集成和状态的使用到相应页面 2026-10-20 17:03:37 +08:00
178cd2533c 安卓权限添加 2026-10-20 16:45:51 +08:00
68984aa7d2 优化视频延迟 2026-10-13 14:39:01 +08:00
e4f7cc7a90 本地视频延迟优化+视频的获取链路播放 2026-10-13 12:40:16 +08:00
9671798f29 多语言切换+首次设置语言 2026-10-12 14:21:09 +08:00
464 changed files with 86042 additions and 1673 deletions

135
.app-report-build/build.mjs Normal file
View File

@@ -0,0 +1,135 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
const require = createRequire(import.meta.url);
const root = process.cwd();
const stage = path.join(root,'.app-report-build');
const out = path.join(root,'汇报成果');
const rt = 'C:/Users/jsmbz/.cache/codex-runtimes/codex-primary-runtime/dependencies';
const nm = rt + '/node/node_modules';
const sk = 'C:/Users/jsmbz/.codex/plugins/cache/openai-primary-runtime/presentations/26.1104.12130/skills/presentations';
const pkgDir = nm + '/@oai/artifact-tool';
const pkg = JSON.parse(await fs.readFile(pkgDir+'/package.json','utf8'));
const ex = pkg.exports?.['.'] ?? pkg.exports;
const entry = (typeof ex === 'string' ? ex : ex?.import?.default ?? ex?.import ?? ex?.default) ?? pkg.module ?? pkg.main;
const { Presentation, PresentationFile } = await import(pathToFileURL(path.join(pkgDir,entry)).href);
const deck = Presentation.create({slideSize:{width:1280,height:720}});
const font = 'Microsoft YaHei';
const color = {bg:'#F6F8F4', ink:'#18342E', quiet:'#4C655E', accent:'#127555', dark:'#12382F', pale:'#D1EAE0', white:'#FFFFFF', gold:'#986320', subtle:'#D8E3DB'};
const noLine = {fill:'none',width:0};
function tx(s, value, x, y, w, h, opts={}) {
const shape = s.shapes.add({name:opts.name??value.slice(0,40),geometry:'textbox',position:{left:x,top:y,width:w,height:h},fill:'none',line:noLine});
shape.text = value;
shape.text.style = {typeface:font,fontSize:opts.size??26,color:opts.color??color.ink,bold:opts.bold??false,verticalAlignment:'middle',alignment:opts.align??'left',insets:{left:0,right:0,top:0,bottom:0}};
return shape;
}
function line(s,y) {
s.shapes.add({name:'行分隔线',geometry:'rect',position:{left:72,top:y,width:1136,height:1},fill:color.subtle,line:noLine});
}
function base(title, intro, page) {
const s = deck.slides.add();
s.background.fill = color.bg;
tx(s,title,72,54,1136,68,{size:48,bold:true});
tx(s,intro,72,130,1136,40,{size:25,color:color.quiet});
tx(s,page,1148,663,60,26,{size:16,color:color.quiet,align:'right'});
return s;
}
function notes(slide, text) {
slide.speakerNotes?.textFrame?.setText?.(text);
}
async function picture(slide, f, alt, left,top,width,height) {
const b = await fs.readFile(f);
slide.images.add({blob:b.buffer.slice(b.byteOffset,b.byteOffset+b.byteLength),contentType:f.endsWith('.png')?'image/png':'image/jpeg',alt,fit:'contain',position:{left,top,width,height}});
}
const cover = deck.slides.add();
cover.background.fill = color.dark;
tx(cover,'SATABOT',76,90,600,46,{size:27,bold:true,color:color.pale});
tx(cover,'草坪作业规划 App',72,193,1136,91,{size:72,bold:true,color:color.white});
tx(cover,'功能进度汇报',72,286,1136,78,{size:59,bold:true,color:color.white});
tx(cover,'核心流程已有实现,新增任务功能待联调',76,412,1130,52,{size:31,color:color.pale});
tx(cover,'2026 年 11 月 9 日',76,600,650,44,{size:24,color:color.pale});
notes(cover,'汇报依据为当前 Flutter 工作区、Git 提交记录、Android 调试产物和使用手册。范围为草坪机器人核心作业流程。');
const features = base('已实现的核心功能','覆盖设备管理、遥控、作业规划和记录查看。','02');
tx(features,'功能',72,196,245,34,{size:22,bold:true,color:color.accent});
tx(features,'用户能做什么',335,196,873,34,{size:22,bold:true,color:color.accent});
const rows = [
['设备管理','扫码绑定或切换设备,查看在线状态、电量和位置。'],
['远程遥控','查看实时视频,使用摇杆控制设备,支持急停操作。'],
['分区与作业规划','在地图上编辑作业区,生成或选择路线,发起自主作业。'],
['作业记录','查看历史作业和轨迹,回顾任务执行情况。'],
];
rows.forEach(([k,v],i)=>{
const y=240+i*83;
line(features,y);
tx(features,k,72,y+10,250,61,{size:26,bold:true});
tx(features,v,335,y+10,873,61,{size:26});
});
tx(features,'以上为代码中已有实现的能力,当前版本的实机效果以验收为准。',72,600,1136,38,{size:21,color:color.quiet});
notes(features,'来源:lib/features/v2/device_list/presentation/pages、lib/features/remote_control、lib/features/regions、lib/features/autonomous_work、lib/features/v2/work/presentation/pages。核心功能按工作区实现归纳,未将页面存在等同于真机验收通过。');
const demo = base('地图上看路线,遥控时看画面','使用手册界面示例','03');
tx(demo,'路线规划',72,190,396,44,{size:27,bold:true,color:color.accent});
tx(demo,'视频遥控',475,190,731,44,{size:27,bold:true,color:color.accent});
await picture(demo,'temp_docx_gen/path_generated.jpg','手册截图中的路线规划效果',72,243,360,329);
await picture(demo,'temp_docx_gen/remote_control.jpg','手册截图中的视频遥控界面',475,243,733,329);
tx(demo,'下发前可预览地图上的规划路线。',72,592,392,48,{size:23});
tx(demo,'在同一界面查看画面并操作摇杆。',475,592,733,48,{size:23});
notes(demo,'截图来源:temp_docx_gen/path_generated.jpg 与 temp_docx_gen/remote_control.jpg。均为仓库使用手册的示意截图,当前验收包的界面及设备实测结果以验收为准。');
const progress = base('主体流程已实现,真机验收待完成','当前已有 Android 调试包,最新代码仍需重新打包联调。','04');
const stages = [
['已实现','设备管理、实时状态、遥控、分区和路线规划已有实现。',color.accent],
['接入中','连续作业、返航方案和作业记录接口仍需联调。',color.gold],
['待验收','验证任务下发与状态回传、断连与急停、轨迹与记录一致性。',color.ink],
];
stages.forEach(([k,v,c],i)=>{
const y=208+i*123;
line(progress,y);
tx(progress,k,72,y+18,250,70,{size:33,bold:true,color:c});
tx(progress,v,335,y+18,873,70,{size:26});
});
notes(progress,'来源:当前 Git 状态、34cb9d28 提交记录、lib/features/consecutive_tasks、lib/features/site_task_plans、lib/features/work_history 及 return_plan_library_cubit.dart。调试包路径为 build/app/outputs/flutter-apk/app-debug.apk。尚未核对调试包与工作区的一致性,也没有本次全流程真机验收记录。');
const timing = base('预计 11 月 27 日提交 Android 验收包','按三周估算,前提是接口与测试设备在 11 月 13 日前就绪。','05');
const milestones = [
['11 月 13 日','完成接口联调','核对连续作业、返航和作业记录的数据。'],
['11 月 20 日','完成真机回归','跑通作业全流程,检查断连与急停并复测关键问题。'],
['11 月 27 日','提交验收包','修复阻塞问题,提供安装包和验收清单。'],
];
milestones.forEach(([d,k,v],i)=>{
const y=210+i*105;
line(timing,y);
tx(timing,d,72,y+14,227,65,{size:30,bold:true,color:color.accent});
tx(timing,k,311,y+14,242,65,{size:26,bold:true});
tx(timing,v,563,y+14,645,65,{size:25});
});
tx(timing,'正式发布时间在验收通过后确认。',72,596,1136,39,{size:24,color:color.quiet});
notes(timing,'三周排期是以 2026 年 11 月 9 日为基准的规划估算,未获得团队排期确认。交付指 Android 验收包。前提为接口可联调、设备到位且关键安全用例通过。');
await fs.mkdir(stage,{recursive:true});
await fs.mkdir(out,{recursive:true});
const candidate = path.join(stage,'candidate.pptx');
const final = path.join(out,'草坪作业规划App功能进度汇报.pptx');
await (await PresentationFile.exportPptx(deck)).save(candidate);
try {
const {finalizePresentation} = await import(pathToFileURL(sk+'/container_tools/artifact_tool_utils.mjs').href);
await finalizePresentation({workspaceDir:root,candidatePath:candidate,finalPath:final,
pythonExecutable:rt+'/python/python.exe',
integrityValidatorPath:sk+'/container_tools/inspect_presentation_package_integrity.py',
layoutValidatorPath:sk+'/container_tools/inspect_presentation_layout_geometry.py',
layoutArgs:['--expected-slide-size-emu','12192000,6858000','--validate-heading-fit'],
fontPolicy:{basis:'design',families:[font]},verifyArtifactToolImport:true,
receiptPath:path.join(stage,'validation.json')});
} catch(e) {
console.error('Finalizer:',e.message);
await fs.copyFile(candidate,final);
}
console.log('OUTPUT',final,(await fs.stat(final)).size);
for (let i=0;i<deck.slides.items.length;i++) {
try {
const png = await deck.export({slide:deck.slides.items[i],format:'png',scale:0.75});
await fs.writeFile(path.join(stage,'slide-'+(i+1)+'.png'),new Uint8Array(await png.arrayBuffer()));
} catch(e) {console.error('Render',i+1,e.message);}
}

Binary file not shown.

View File

@@ -0,0 +1,78 @@
{"kind":"deck","id":"pr/rsjjns","name":"Deck"}
{"kind":"layout","id":"ly/jep0za58","editable":false,"name":"Master","type":"master"}
{"kind":"layout","id":"ly/kb214bmt","editable":false,"name":"Title Slide","type":"title","placeholders":[{"name":"title","type":"title"},{"name":"subtitle","type":"subtitle"}]}
{"kind":"layout","id":"ly/07ylsjqp","editable":false,"name":"Section Header","type":"sectionHeader","placeholders":[{"name":"title","type":"title"}]}
{"kind":"layout","id":"ly/94ra5gfq","editable":false,"name":"Title and Body","type":"tx","placeholders":[{"name":"title","type":"title"},{"name":"body","type":"body"}]}
{"kind":"layout","id":"ly/i9cnexgj","editable":false,"name":"Title and Two Column","type":"twoColTx","placeholders":[{"name":"title","type":"title"},{"name":"Left column","type":"body"},{"name":"Right column","type":"body"}]}
{"kind":"layout","id":"ly/yh8va14r","editable":false,"name":"Title and One Column","type":"tx","placeholders":[{"name":"title","type":"title"},{"name":"body","type":"body"}]}
{"kind":"layout","id":"ly/87ix832p","editable":false,"name":"Blank","type":"blank"}
{"kind":"slide","id":"sl/5ms4nr","slideIndex":0,"layout":{"id":"ly/87ix832p","name":"Blank"},"title":"SATABOT","textShapes":5,"backgroundColor":"rgba(18,56,47,1)"}
{"kind":"textbox","id":"sh/9oja5ov6","slideIndex":0,"position":{"left":76,"top":90,"width":600,"height":46},"name":"SATABOT","text":"SATABOT","geometry":"rect","lineWidth":0,"style":{"fontSize":27,"typeface":"Microsoft YaHei","color":"#D1EAE0","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/m1sbu9wf","slideIndex":0,"position":{"left":72,"top":193,"width":1136,"height":91},"name":"草坪作业规划 App","text":"草坪作业规划 App","geometry":"rect","lineWidth":0,"style":{"fontSize":72,"typeface":"Microsoft YaHei","color":"#FFFFFF","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/nm1s3ed0","slideIndex":0,"position":{"left":72,"top":286,"width":1136,"height":78},"name":"功能进度汇报","text":"功能进度汇报","geometry":"rect","lineWidth":0,"style":{"fontSize":59,"typeface":"Microsoft YaHei","color":"#FFFFFF","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/wrut03eh","slideIndex":0,"position":{"left":76,"top":412,"width":1130,"height":52},"name":"核心流程已有实现,新增任务功能待联调","text":"核心流程已有实现,新增任务功能待联调","geometry":"rect","lineWidth":0,"style":{"fontSize":31,"typeface":"Microsoft YaHei","color":"#D1EAE0","alignment":"left"}}
{"kind":"textbox","id":"sh/xs3a9ov2","slideIndex":0,"position":{"left":76,"top":600,"width":650,"height":44},"name":"2026 年 11 月 9 日","text":"2026 年 11 月 9 日","geometry":"rect","lineWidth":0,"style":{"fontSize":24,"typeface":"Microsoft YaHei","color":"#D1EAE0","alignment":"left"}}
{"kind":"notes","id":"nt/5ms4nr","slideIndex":0,"text":"汇报依据为当前 Flutter 工作区、Git 提交记录、Android 调试产物和使用手册。范围为草坪机器人核心作业流程。"}
{"kind":"slide","id":"sl/4t5sqh","slideIndex":1,"layout":{"id":"ly/87ix832p","name":"Blank"},"title":"已实现的核心功能","textShapes":14,"backgroundColor":"rgba(246,248,244,1)"}
{"kind":"textbox","id":"sh/p0zuhkfu","slideIndex":1,"position":{"left":72,"top":54,"width":1136,"height":68},"name":"已实现的核心功能","text":"已实现的核心功能","geometry":"rect","lineWidth":0,"style":{"fontSize":48,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/2xobm5g3","slideIndex":1,"position":{"left":72,"top":130,"width":1136,"height":40},"name":"覆盖设备管理、遥控、作业规划和记录查看。","text":"覆盖设备管理、遥控、作业规划和记录查看。","geometry":"rect","lineWidth":0,"style":{"fontSize":25,"typeface":"Microsoft YaHei","color":"#4C655E","alignment":"left"}}
{"kind":"textbox","id":"sh/3yxcfaxo","slideIndex":1,"position":{"left":1148,"top":663,"width":60,"height":26},"name":"02","text":"02","geometry":"rect","lineWidth":0,"style":{"fontSize":16,"typeface":"Microsoft YaHei","color":"#4C655E","alignment":"right"}}
{"kind":"textbox","id":"sh/c3qtszy5","slideIndex":1,"position":{"left":72,"top":196,"width":245,"height":34},"name":"功能","text":"功能","geometry":"rect","lineWidth":0,"style":{"fontSize":22,"typeface":"Microsoft YaHei","color":"#127555","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/d4zulkfq","slideIndex":1,"position":{"left":335,"top":196,"width":873,"height":34},"name":"用户能做什么","text":"用户能做什么","geometry":"rect","lineWidth":0,"style":{"fontSize":22,"typeface":"Microsoft YaHei","color":"#127555","bold":true,"alignment":"left"}}
{"kind":"shape","id":"sh/q18bqpgz","slideIndex":1,"position":{"left":72,"top":240,"width":1136,"height":1},"name":"行分隔线","geometry":"rect","fillColor":"#D8E3DB","lineWidth":0}
{"kind":"textbox","id":"sh/b2hcjuxk","slideIndex":1,"position":{"left":72,"top":250,"width":250,"height":61},"name":"设备管理","text":"设备管理","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/07atwzeh","slideIndex":1,"position":{"left":335,"top":250,"width":873,"height":61},"name":"扫码绑定或切换设备,查看在线状态、电量和位置。","text":"扫码绑定或切换设备,查看在线状态、电量和位置。","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"shape","id":"sh/l8jup4f2","slideIndex":1,"position":{"left":72,"top":323,"width":1136,"height":1},"name":"行分隔线","geometry":"rect","fillColor":"#D8E3DB","lineWidth":0}
{"kind":"textbox","id":"sh/xw361ojq","slideIndex":1,"position":{"left":72,"top":333,"width":250,"height":61},"name":"远程遥控","text":"远程遥控","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/wvups325","slideIndex":1,"position":{"left":335,"top":333,"width":873,"height":61},"name":"查看实时视频,使用摇杆控制设备,支持急停操作。","text":"查看实时视频,使用摇杆控制设备,支持急停操作。","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"shape","id":"sh/vulozy1k","slideIndex":1,"position":{"left":72,"top":406,"width":1136,"height":1},"name":"行分隔线","geometry":"rect","fillColor":"#D8E3DB","lineWidth":0}
{"kind":"textbox","id":"sh/atc7qtkf","slideIndex":1,"position":{"left":72,"top":416,"width":250,"height":61},"name":"分区与作业规划","text":"分区与作业规划","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/5knq58j2","slideIndex":1,"position":{"left":335,"top":416,"width":873,"height":61},"name":"在地图上编辑作业区,生成或选择路线,发起自主作业。","text":"在地图上编辑作业区,生成或选择路线,发起自主作业。","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"shape","id":"sh/kjupw32h","slideIndex":1,"position":{"left":72,"top":489,"width":1136,"height":1},"name":"行分隔线","geometry":"rect","fillColor":"#D8E3DB","lineWidth":0}
{"kind":"textbox","id":"sh/jilo3y1w","slideIndex":1,"position":{"left":72,"top":499,"width":250,"height":61},"name":"作业记录","text":"作业记录","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/ihc7utkb","slideIndex":1,"position":{"left":335,"top":499,"width":873,"height":61},"name":"查看历史作业和轨迹,回顾任务执行情况。","text":"查看历史作业和轨迹,回顾任务执行情况。","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"textbox","id":"sh/loj6t43i","slideIndex":1,"position":{"left":72,"top":600,"width":1136,"height":38},"name":"以上为代码中已有实现的能力,当前版本的实机效果以验收为准。","text":"以上为代码中已有实现的能力,当前版本的实机效果以验收为准。","geometry":"rect","lineWidth":0,"style":{"fontSize":21,"typeface":"Microsoft YaHei","color":"#4C655E","alignment":"left"}}
{"kind":"notes","id":"nt/4t5sqh","slideIndex":1,"text":"来源:lib/features/v2/device_list/presentation/pages、lib/features/remote_control、lib/features/regions、lib/features/autonomous_work、lib/features/v2/work/presentation/pages。核心功能按工作区实现归纳,未将页面存在等同于真机验收通过。"}
{"kind":"slide","id":"sl/bpwt6i","slideIndex":2,"layout":{"id":"ly/87ix832p","name":"Blank"},"title":"地图上看路线,遥控时看画面","textShapes":7,"backgroundColor":"rgba(246,248,244,1)"}
{"kind":"textbox","id":"sh/ep8fqxcf","slideIndex":2,"position":{"left":72,"top":54,"width":1136,"height":68},"name":"地图上看路线,遥控时看画面","text":"地图上看路线,遥控时看画面","geometry":"rect","lineWidth":0,"style":{"fontSize":48,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/1szylsb6","slideIndex":2,"position":{"left":72,"top":130,"width":1136,"height":40},"name":"使用手册界面示例","text":"使用手册界面示例","geometry":"rect","lineWidth":0,"style":{"fontSize":25,"typeface":"Microsoft YaHei","color":"#4C655E","alignment":"left"}}
{"kind":"textbox","id":"sh/0rqxsnul","slideIndex":2,"position":{"left":1148,"top":663,"width":60,"height":26},"name":"03","text":"03","geometry":"rect","lineWidth":0,"style":{"fontSize":16,"typeface":"Microsoft YaHei","color":"#4C655E","alignment":"right"}}
{"kind":"textbox","id":"sh/rmxgfit4","slideIndex":2,"position":{"left":72,"top":190,"width":396,"height":44},"name":"路线规划","text":"路线规划","geometry":"rect","lineWidth":0,"style":{"fontSize":27,"typeface":"Microsoft YaHei","color":"#127555","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/qlofmdcj","slideIndex":2,"position":{"left":475,"top":190,"width":731,"height":44},"name":"视频遥控","text":"视频遥控","geometry":"rect","lineWidth":0,"style":{"fontSize":27,"typeface":"Microsoft YaHei","color":"#127555","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/3ixwbyts","slideIndex":2,"position":{"left":72,"top":592,"width":392,"height":48},"name":"下发前可预览地图上的规划路线。","text":"下发前可预览地图上的规划路线。","geometry":"rect","lineWidth":0,"style":{"fontSize":23,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"textbox","id":"sh/2hofidc7","slideIndex":2,"position":{"left":475,"top":592,"width":733,"height":48},"name":"在同一界面查看画面并操作摇杆。","text":"在同一界面查看画面并操作摇杆。","geometry":"rect","lineWidth":0,"style":{"fontSize":23,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"image","id":"im/w7u1wr21","slideIndex":2,"position":{"left":72,"top":243,"width":360,"height":329},"fit":"contain","name":"","alt":"手册截图中的路线规划效果"}
{"kind":"image","id":"im/x8ni5wjm","slideIndex":2,"position":{"left":475,"top":243,"width":733,"height":329},"fit":"contain","name":"","alt":"手册截图中的视频遥控界面"}
{"kind":"notes","id":"nt/bpwt6i","slideIndex":2,"text":"截图来源:temp_docx_gen/path_generated.jpg 与 temp_docx_gen/remote_control.jpg。均为仓库使用手册的示意截图,当前验收包的界面及设备实测结果以验收为准。"}
{"kind":"slide","id":"sl/pfl994","slideIndex":3,"layout":{"id":"ly/87ix832p","name":"Blank"},"title":"主体流程已实现,真机验收待完成","textShapes":9,"backgroundColor":"rgba(246,248,244,1)"}
{"kind":"textbox","id":"sh/yxo32pkj","slideIndex":3,"position":{"left":72,"top":54,"width":1136,"height":68},"name":"主体流程已实现,真机验收待完成","text":"主体流程已实现,真机验收待完成","geometry":"rect","lineWidth":0,"style":{"fontSize":48,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/l0zmdk3a","slideIndex":3,"position":{"left":72,"top":130,"width":1136,"height":40},"name":"当前已有 Android 调试包,最新代码仍需重新打包联调。","text":"当前已有 Android 调试包,最新代码仍需重新打包联调。","geometry":"rect","lineWidth":0,"style":{"fontSize":25,"typeface":"Microsoft YaHei","color":"#4C655E","alignment":"left"}}
{"kind":"textbox","id":"sh/0zql4z2p","slideIndex":3,"position":{"left":1148,"top":663,"width":60,"height":26},"name":"04","text":"04","geometry":"rect","lineWidth":0,"style":{"fontSize":16,"typeface":"Microsoft YaHei","color":"#4C655E","alignment":"right"}}
{"kind":"shape","id":"sh/baxk7al8","slideIndex":3,"position":{"left":72,"top":208,"width":1136,"height":1},"name":"行分隔线","geometry":"rect","fillColor":"#D8E3DB","lineWidth":0}
{"kind":"textbox","id":"sh/q9o3y5kn","slideIndex":3,"position":{"left":72,"top":226,"width":250,"height":70},"name":"已实现","text":"已实现","geometry":"rect","lineWidth":0,"style":{"fontSize":33,"typeface":"Microsoft YaHei","color":"#127555","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/dwf29k3y","slideIndex":3,"position":{"left":335,"top":226,"width":873,"height":70},"name":"设备管理、实时状态、遥控、分区和路线规划已有实现。","text":"设备管理、实时状态、遥控、分区和路线规划已有实现。","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"shape","id":"sh/cb6l0f2t","slideIndex":3,"position":{"left":72,"top":331,"width":1136,"height":1},"name":"行分隔线","geometry":"rect","fillColor":"#D8E3DB","lineWidth":0}
{"kind":"textbox","id":"sh/v614jelc","slideIndex":3,"position":{"left":72,"top":349,"width":250,"height":70},"name":"接入中","text":"接入中","geometry":"rect","lineWidth":0,"style":{"fontSize":33,"typeface":"Microsoft YaHei","color":"#986320","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/a5s3a9kr","slideIndex":3,"position":{"left":335,"top":349,"width":873,"height":70},"name":"连续作业、返航方案和作业记录接口仍需联调。","text":"连续作业、返航方案和作业记录接口仍需联调。","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"shape","id":"sh/o7mhcfix","slideIndex":3,"position":{"left":72,"top":454,"width":1136,"height":1},"name":"行分隔线","geometry":"rect","fillColor":"#D8E3DB","lineWidth":0}
{"kind":"textbox","id":"sh/p8fi50ji","slideIndex":3,"position":{"left":72,"top":472,"width":250,"height":70},"name":"待验收","text":"待验收","geometry":"rect","lineWidth":0,"style":{"fontSize":33,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/2l4za50r","slideIndex":3,"position":{"left":335,"top":472,"width":873,"height":70},"name":"验证任务下发与状态回传、断连与急停、轨迹与记录一致性。","text":"验证任务下发与状态回传、断连与急停、轨迹与记录一致性。","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"notes","id":"nt/pfl994","slideIndex":3,"text":"来源:当前 Git 状态、34cb9d28 提交记录、lib/features/consecutive_tasks、lib/features/site_task_plans、lib/features/work_history 及 return_plan_library_cubit.dart。调试包路径为 build/app/outputs/flutter-apk/app-debug.apk。尚未核对调试包与工作区的一致性,也没有本次全流程真机验收记录。"}
{"kind":"slide","id":"sl/6beug4","slideIndex":4,"layout":{"id":"ly/87ix832p","name":"Blank"},"title":"预计 11 月 27 日提交 Android 验收包","textShapes":13,"backgroundColor":"rgba(246,248,244,1)"}
{"kind":"textbox","id":"sh/lw3m1g7i","slideIndex":4,"position":{"left":72,"top":54,"width":1136,"height":68},"name":"预计 11 月 27 日提交 Android 验收包","text":"预计 11 月 27 日提交 Android 验收包","geometry":"rect","lineWidth":0,"style":{"fontSize":48,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/ytcn6187","slideIndex":4,"position":{"left":72,"top":130,"width":1136,"height":40},"name":"按三周估算,前提是接口与测试设备在 11 月 13 日前就绪。","text":"按三周估算,前提是接口与测试设备在 11 月 13 日前就绪。","geometry":"rect","lineWidth":0,"style":{"fontSize":25,"typeface":"Microsoft YaHei","color":"#4C655E","alignment":"left"}}
{"kind":"textbox","id":"sh/zul4z6pc","slideIndex":4,"position":{"left":1148,"top":663,"width":60,"height":26},"name":"05","text":"05","geometry":"rect","lineWidth":0,"style":{"fontSize":16,"typeface":"Microsoft YaHei","color":"#4C655E","alignment":"right"}}
{"kind":"shape","id":"sh/8ze5cvq9","slideIndex":4,"position":{"left":72,"top":210,"width":1136,"height":1},"name":"行分隔线","geometry":"rect","fillColor":"#D8E3DB","lineWidth":0}
{"kind":"textbox","id":"sh/90nm5g7u","slideIndex":4,"position":{"left":72,"top":224,"width":227,"height":65},"name":"11 月 13 日","text":"11 月 13 日","geometry":"rect","lineWidth":0,"style":{"fontSize":30,"typeface":"Microsoft YaHei","color":"#127555","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/mxwnal83","slideIndex":4,"position":{"left":311,"top":224,"width":242,"height":65},"name":"完成接口联调","text":"完成接口联调","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/7y543qpo","slideIndex":4,"position":{"left":563,"top":224,"width":645,"height":65},"name":"核对连续作业、返航和作业记录的数据。","text":"核对连续作业、返航和作业记录的数据。","geometry":"rect","lineWidth":0,"style":{"fontSize":25,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"shape","id":"sh/g3e5gv65","slideIndex":4,"position":{"left":72,"top":315,"width":1136,"height":1},"name":"行分隔线","geometry":"rect","fillColor":"#D8E3DB","lineWidth":0}
{"kind":"textbox","id":"sh/h476907q","slideIndex":4,"position":{"left":72,"top":329,"width":227,"height":65},"name":"11 月 20 日","text":"11 月 20 日","geometry":"rect","lineWidth":0,"style":{"fontSize":30,"typeface":"Microsoft YaHei","color":"#127555","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/lwruloby","slideIndex":4,"position":{"left":311,"top":329,"width":242,"height":65},"name":"完成真机回归","text":"完成真机回归","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/kvitcjad","slideIndex":4,"position":{"left":563,"top":329,"width":645,"height":65},"name":"跑通作业全流程,检查断连与急停并复测关键问题。","text":"跑通作业全流程,检查断连与急停并复测关键问题。","geometry":"rect","lineWidth":0,"style":{"fontSize":25,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"shape","id":"sh/ju9cjet8","slideIndex":4,"position":{"left":72,"top":420,"width":1136,"height":1},"name":"行分隔线","geometry":"rect","fillColor":"#D8E3DB","lineWidth":0}
{"kind":"textbox","id":"sh/yt0ba9sn","slideIndex":4,"position":{"left":72,"top":434,"width":227,"height":65},"name":"11 月 27 日","text":"11 月 27 日","geometry":"rect","lineWidth":0,"style":{"fontSize":30,"typeface":"Microsoft YaHei","color":"#127555","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/t0bup8ba","slideIndex":4,"position":{"left":311,"top":434,"width":242,"height":65},"name":"提交验收包","text":"提交验收包","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/8z2tg3ap","slideIndex":4,"position":{"left":563,"top":434,"width":645,"height":65},"name":"修复阻塞问题,提供安装包和验收清单。","text":"修复阻塞问题,提供安装包和验收清单。","geometry":"rect","lineWidth":0,"style":{"fontSize":25,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"textbox","id":"sh/7ytcnyt4","slideIndex":4,"position":{"left":72,"top":596,"width":1136,"height":39},"name":"正式发布时间在验收通过后确认。","text":"正式发布时间在验收通过后确认。","geometry":"rect","lineWidth":0,"style":{"fontSize":24,"typeface":"Microsoft YaHei","color":"#4C655E","alignment":"left"}}
{"kind":"notes","id":"nt/6beug4","slideIndex":4,"text":"三周排期是以 2026 年 11 月 9 日为基准的规划估算,未获得团队排期确认。交付指 Android 验收包。前提为接口可联调、设备到位且关键安全用例通过。"}

View File

@@ -0,0 +1,18 @@
const fs = require('node:fs/promises');
const sharp = require('C:/Users/jsmbz/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/sharp');
const JSZip = require('jszip');
const root = require('node:path').join(__dirname,'..');
async function main() {
const files = await fs.readdir(root+'/汇报成果');
const final = files.find(n => n.endsWith('.pptx'));
const zip = await JSZip.loadAsync(await fs.readFile(root+'/汇报成果/'+final),{checkCRC32:true});
const names = Object.keys(zip.files);
const slides = names.filter(f=>/^ppt\/slides\/slide\d+\.xml$/.test(f));
if (slides.length!==5) throw new Error('Unexpected slide count '+slides.length);
console.log('VERIFIED',final,slides.length,'slides',names.filter(f=>/^ppt\/media\/.+/.test(f)).length,'images');
for (let i=1;i<=5;i++) {
const img = await sharp(__dirname+'/slide-'+i+'.png').resize({width:768}).jpeg({quality:72}).toBuffer();
console.log('IMAGE '+img.toString('base64'));
}
}
main().catch(e=>{console.error(e);process.exitCode=1});

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

21
.gitignore vendored
View File

@@ -114,4 +114,23 @@ test_run.log
Thumbs.db
# 环境变量敏感信息
.env*
.env*
# ----------------------------------------------------------------------------
# 备份文件与一次性调试产物(不需要提交)
# ----------------------------------------------------------------------------
*.bak
*.bak-*
pubspec.lock.bak*
fix_lock.py
git_lock_versions.json
# ----------------------------------------------------------------------------
# 临时文档 / 软著手册生成物料(不需要提交)
# ----------------------------------------------------------------------------
temp_*.md
temp_*.txt
temp_*.py
temp_*
temp_docx_gen/
*.docx

View File

@@ -9,6 +9,10 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- packages/**
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`

View File

@@ -16,7 +16,7 @@ if (keystorePropertiesFile.exists()) {
}
android {
namespace = "com.maibu.maibu_satabot_v2"
namespace = "com.maibu.maibusatabotv2"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
@@ -31,7 +31,7 @@ android {
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.maibu.maibu_satabot_v2"
applicationId = "com.maibu.maibusatabotv2"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion

View File

@@ -1,10 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.maibu.maibu_satabot_v2">
package="com.maibu.maibusatabotv2">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
@@ -14,8 +15,9 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-feature android:name="android.hardware.bluetooth" android:required="false"/>
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false"/>
<uses-feature android:name="android.hardware.usb.host" android:required="false"/>
<application
android:label="maibu_satabot_v2"
android:label="飒沓·星枢"
android:name="${applicationName}"
android:icon="@mipmap/launcher_icon"
android:usesCleartextTraffic="true">
@@ -34,6 +36,17 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!--
USB 设备插入时,Android 直接弹出"允许 [App名] 访问此USB设备"权限对话框,
而不再弹出"用哪个APP打开"的选择对话框(避免与其他 UVC 应用竞争)。
用户勾选"总是允许"后,以后插入同类型设备将不再弹窗。
-->
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/device_filter" />
</activity>
<meta-data
android:name="flutterEmbedding"

View File

@@ -1,5 +0,0 @@
package com.maibu.maibu_satabot_v2
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -0,0 +1,43 @@
package com.maibu.maibusatabotv2
import android.os.Build
import android.view.WindowManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
companion object {
private const val WINDOW_CHANNEL = "satabot/window"
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, WINDOW_CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
// 横屏全屏页(如自主作业中心)需要把内容铺到前置摄像头挖孔那一侧,
// 否则系统会在短边留一条黑边(WINDOW 不把该区域交给 App 绘制)。
"setCutoutMode" -> {
val shortEdges = call.argument<Boolean>("shortEdges") ?: false
applyCutoutMode(shortEdges)
result.success(null)
}
else -> result.notImplemented()
}
}
}
private fun applyCutoutMode(shortEdges: Boolean) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return
val attrs = window.attributes
attrs.layoutInDisplayCutoutMode = if (shortEdges) {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
} else {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
}
window.attributes = attrs
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -3,10 +3,10 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<!-- 居中显示 App Logo,消除冷启动白屏观感 -->
<item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
android:src="@drawable/launch_image" />
</item>
</layer-list>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

View File

@@ -3,10 +3,10 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<!-- 居中显示 App Logo,消除冷启动白屏观感 -->
<item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
android:src="@drawable/launch_image" />
</item>
</layer-list>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#2e2e2e</color>
<color name="ic_launcher_background">#FF8500</color>
</resources>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
USB 设备过滤器:告诉 Android "我们 App 能处理这些 USB 设备"。
插入匹配的设备时,系统会直接弹出"允许 XX App 访问"权限对话框,
而不再弹出"用哪个 APP 打开"的Chooser对话框。
class=14 (0x0E): USB Video Class (UVC),即采集卡/USB摄像头
subclass=1: Video Control 接口
subclass=2: Video Streaming 接口
-->
<resources>
<!-- UVC 视频采集设备(采集卡、USB摄像头) -->
<usb-device class="14" subclass="1" protocol="0" />
<usb-device class="14" subclass="2" protocol="0" />
<!-- 部分采集卡以 Misc / Command 类上报,顺带匹配 -->
<usb-device class="239" subclass="2" protocol="1" />
</resources>

View File

@@ -11,6 +11,8 @@ pluginManagement {
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/public") }
google()
mavenCentral()
gradlePluginPortal()

BIN
assets/images/img.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 963 KiB

BIN
assets/images/img_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 963 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

BIN
assets/images/logo_sata.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 677 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 KiB

BIN
assets/images/shebei_bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -501,7 +501,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibusatabotv2;
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.dev.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibusatabotv2.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.dev.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibusatabotv2.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.dev.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibusatabotv2.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -687,7 +687,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibusatabotv2;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -713,7 +713,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibusatabotv2;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";

View File

@@ -1,10 +1,10 @@
import Flutter
import UIKit
@main
@mainlication(
_ application
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
override func app: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 690 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Maibu Satabot V2</string>
<string>飒沓·星枢</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>maibu_satabot_v2</string>
<string>飒沓·星枢</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
@@ -88,5 +88,13 @@
<string>需要使用蓝牙连接设备</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>需要使用蓝牙连接设备</string>
<!-- 本地网络权限(iOS 14+:ONVIF 组播发现 / 局域网 RTSP 摄像头访问必需) -->
<key>NSLocalNetworkUsageDescription</key>
<string>需要访问本地网络以发现并连接局域网摄像头(ONVIF / RTSP)。</string>
<key>NSBonjourServices</key>
<array>
<string>_wsdiscovery._udp</string>
<string>_http._tcp</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
class AppLogo extends StatelessWidget {
const AppLogo({super.key, required this.size});
final double size;
@override
Widget build(BuildContext context) {
return Image.asset(
'assets/images/app_logo.png',
width: size,
height: size,
fit: BoxFit.contain,
cacheWidth: (size * MediaQuery.devicePixelRatioOf(context)).ceil(),
);
}
}

View File

@@ -7,6 +7,11 @@ class CapsuleToast {
static OverlayEntry? _entry;
static bool _showing = false;
/// 弹出代数:每次真正插入新胶囊时递增。
/// 自动消失的定时器只允许关闭「自己这一代」的胶囊,
/// 否则旧提示的定时器会把后弹的新提示提前移除(连点时新 toast 一闪即没)。
static int _generation = 0;
/// 显示胶囊提示
/// [message] 提示文案
/// [duration] 显示时长,默认 2 秒
@@ -49,6 +54,7 @@ class CapsuleToast {
_entry?.remove();
_showing = false;
}
final int gen = ++_generation;
_entry = OverlayEntry(
builder: (context) => IgnorePointer(
@@ -56,7 +62,8 @@ class CapsuleToast {
child: Align(
alignment: Alignment.topCenter,
child: Container(
margin: const EdgeInsets.only(top: 64),
// 左右留白:英文文案明显长于中文,限制胶囊最大宽度避免贴边溢出
margin: const EdgeInsets.only(top: 64, left: 24, right: 24),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xE62C2C2C),
@@ -80,12 +87,18 @@ class CapsuleToast {
),
const SizedBox(width: 6),
],
Text(
message,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
// 长文案(尤其英文)需要可收缩并换行,否则 Row 会横向溢出
Flexible(
child: Text(
message,
maxLines: 3,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
],
@@ -100,6 +113,8 @@ class CapsuleToast {
_showing = true;
Future.delayed(duration, () {
// 期间已弹出过更新的提示 → 本定时器属于旧一代,不能误杀新胶囊
if (gen != _generation) return;
if (_showing) {
_entry?.remove();
_showing = false;

View File

@@ -0,0 +1,56 @@
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
/// 控制模式(`RunningStatusEntity.controlMode`)→ 界面文案的唯一映射。
///
/// 为什么要单独一个文件:这个值以前在 7 个页面各写一遍「== 3 ? 远程 : 本地」,
/// 而数据层又把「后端没给」的默认值写成 `'0'`,两个错误叠在一起,结果是
/// 设备从来没上报过控制模式时,界面一本正经地显示「本地控制」——假信号。
///
/// 协议定义(2026-11 后端重新确认,覆盖早先「0=无控制 / 2=蓝牙 / 4=其他接口」那版):
/// 空串 / null → 「未知」:后端根本没给这个字段
/// 0 → 未知
/// 1 → 本地控制
/// 2 → 自主任务
/// 3 → 远程控制(远程遥控)
/// 其它数字 → 「未知(6)」:协议外的值,不猜它是什么意思
/// 非数字字符串 → 原样显示:后端给什么展示什么
///
/// [controlModeKey] 只返回已定义的 0~4,其余为 null,供各页面沿用本页的
/// 翻译器与自定义文案接;需要一步拿到最终文本用 [controlModeDisplayText]。
/// 已定义的协议值 → 多语言 key;空值、非数字、协议外数字返回 null。
String? controlModeKey(String? raw) {
final int? n = controlModeValue(raw);
if (n == null) return null;
return switch (n) {
0 => 'common.unknown',
1 => 'running_status.local_control',
2 => 'running_status.autonomous_task',
3 => 'running_status.remote_control',
_ => null,
};
}
/// 原始值 → 协议数值。空串/非数字返回 null,表示「没有可信的控制模式数据」。
int? controlModeValue(String? raw) {
final String v = (raw ?? '').trim();
if (v.isEmpty) return null;
return int.tryParse(v);
}
/// 直接得到可显示文案(含未知/协议外/非数字的兜底)。
String controlModeDisplayText(AppLocalizations l10n, String? raw) {
final String? key = controlModeKey(raw);
if (key != null) return l10n.translate(key);
final String v = (raw ?? '').trim();
if (v.isEmpty) return l10n.translate('common.unknown');
final int? n = int.tryParse(v);
// 协议外的数字:带上原值,现场排查时能看出后端到底发了什么
if (n != null) {
return '${l10n.translate('common.unknown')}($n)';
}
// 后端给的不是数字:按它给的显示,不做任何猜测
return v;
}

View File

@@ -15,6 +15,7 @@ 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';
import 'control_mode_text.dart';
const int DATA_TIMEOUT_SECONDS = 15;
@@ -231,8 +232,8 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.target_speed') +
"\n(rpm)",
),
_buildTableCell('--'),
_buildTableCell('--'),
_buildTableCell(status.leftTargetSpeed.toStringAsFixed(0)),
_buildTableCell(status.rightTargetSpeed.toStringAsFixed(0)),
],
),
TableRow(
@@ -243,8 +244,8 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.measure_speed') +
"\n(rpm)",
),
_buildTableCell('--'),
_buildTableCell('--'),
_buildTableCell(status.leftMeasureSpeed.toStringAsFixed(0)),
_buildTableCell(status.rightMeasureSpeed.toStringAsFixed(0)),
],
),
TableRow(
@@ -255,8 +256,8 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.current') +
"(A)",
),
_buildTableCell('--'),
_buildTableCell('--'),
_buildTableCell(status.leftCurrent.toStringAsFixed(2)),
_buildTableCell(status.rightCurrent.toStringAsFixed(2)),
],
),
TableRow(
@@ -267,8 +268,8 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.motor_temp') +
"(°C)",
),
_buildTableCell('--'),
_buildTableCell('--'),
_buildTableCell(status.leftMotorTemp.toStringAsFixed(1)),
_buildTableCell(status.rightMotorTemp.toStringAsFixed(1)),
],
),
],
@@ -318,7 +319,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.pitch_angle') +
"(°)",
),
_buildTableCell('--'),
_buildTableCell(status.pitch.toStringAsFixed(2)),
],
),
TableRow(
@@ -329,7 +330,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.roll_angle') +
"(°)",
),
_buildTableCell('--'),
_buildTableCell(status.roll.toStringAsFixed(2)),
],
),
TableRow(
@@ -351,7 +352,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.battery') +
"(%)",
),
_buildTableCell('--'),
_buildTableCell(status.battery),
],
),
TableRow(
@@ -362,7 +363,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.chip_temp') +
"(°C)",
),
_buildTableCell('--'),
_buildTableCell(status.chipTemp.toStringAsFixed(1)),
],
),
TableRow(
@@ -376,6 +377,28 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
_buildTableCell('--'),
],
),
TableRow(
children: [
_buildTableCell(
AppLocalizations.of(
context,
).translate('running_status.linear_speed') +
"(m/s)",
),
_buildTableCell(status.linearSpeed.toStringAsFixed(2)),
],
),
TableRow(
children: [
_buildTableCell(
AppLocalizations.of(
context,
).translate('running_status.angular_speed') +
"(rad/s)",
),
_buildTableCell(status.angularSpeed.toStringAsFixed(2)),
],
),
TableRow(
children: [
_buildTableCell(
@@ -383,7 +406,13 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
context,
).translate('running_status.control_mode'),
),
_buildTableCell('--'),
_buildTableCell(
// 统一走共用映射:后端没给 = 未知,不再兜成「本地控制」
controlModeDisplayText(
AppLocalizations.of(context),
status.controlMode,
),
),
],
),
TableRow(
@@ -431,14 +460,9 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
);
}
String _getControlModeKey(int modeValue) {
switch (modeValue) {
case 3:
return 'running_status.remote_control';
default:
return 'running_status.local_control';
}
}
// 控制模式文案已下沉到 lib/components/control_mode_text.dart,
// 与运行状态页、设备详情、PV 遥控等共用一份映射(原来此处 default 一律当「本地控制」,
// 把 0=未知、2=自主任务和「后端根本没给」三种情况全显示成了本地)。
@override
Widget build(BuildContext context) {

View File

@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
/// [cancelText]:取消按钮文字(默认"取消")
/// [onConfirm]:确认按钮回调,参数为输入的文本,返回bool表示是否关闭弹窗
/// [inputValidator]:输入内容校验器,返回null表示校验通过,返回字符串表示错误提示
/// [maxWidth]:弹窗内容宽约束;横屏页面传一个较小的值,避免 AlertDialog 被输入框撑满屏幕
Future<void> showInputConfirmDialog({
required BuildContext context,
required String title,
@@ -17,61 +18,122 @@ Future<void> showInputConfirmDialog({
String cancelText = '取消',
required Function(String inputText) onConfirm,
String? Function(String inputText)? inputValidator,
double? maxWidth,
String? initialText,
}) async {
// 输入框控制器
final TextEditingController _controller = TextEditingController();
// 输入框控制器(initialText 非空时预填,用于改名等回显场景)
final TextEditingController controller = TextEditingController(
text: initialText ?? '',
);
await showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(title),
content: TextField(
controller: _controller,
decoration: InputDecoration(
hintText: hintText,
border: const OutlineInputBorder(),
labelText: labelText,
),
autofocus: true, // 自动聚焦输入框
// 横屏页面(如区域编辑器)键盘常占半屏:AlertDialog 的 title/actions 不随
// scrollable 滚动,短屏下会被撑出 RenderFlex 溢出、输入框/按钮被裁而“不好改”。
// 改为自定义 Dialog:键盘弹出时贴顶对齐 + 整体限高 + 字段区可滚,按钮永远可见;
// 没键盘时(如竖屏)仍居中,不改变原有观感。
final MediaQueryData mq = MediaQuery.of(context);
final double keyboardInset = mq.viewInsets.bottom;
final bool lifted = keyboardInset > 0;
// 弹窗可用最大高度:屏高 - 键盘 - 顶部安全区,再夹到一个合理区间
final double maxDialogHeight = (mq.size.height - keyboardInset - 24).clamp(
140.0,
520.0,
);
final double contentWidth = (maxWidth ?? 280) + 40; // 加左右 20 padding
return Dialog(
alignment: lifted ? Alignment.topCenter : Alignment.center,
backgroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
actions: [
// 取消按钮
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(cancelText),
insetPadding: EdgeInsets.fromLTRB(
24,
mq.padding.top + (lifted ? 12 : 24),
24,
keyboardInset + 12,
),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: maxDialogHeight,
maxWidth: contentWidth,
),
// 确认按钮
TextButton(
onPressed: () {
// 获取输入文本并去空格
final String inputText = _controller.text.trim();
// 1. 执行自定义校验
if (inputValidator != null) {
final String? errorMsg = inputValidator(inputText);
if (errorMsg != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorMsg),
backgroundColor: Colors.red,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
title,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
// 字段区单独可滚:极矮屏下也能滚到输入框,不会把标题/按钮顶出去
Flexible(
child: SingleChildScrollView(
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: hintText,
border: const OutlineInputBorder(),
labelText: labelText,
),
autofocus: true, // 自动聚焦输入框
),
);
return;
}
}
),
),
const SizedBox(height: 6),
// 按钮行固定在底部,始终可见可点
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// 取消按钮
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(cancelText),
),
// 确认按钮
TextButton(
onPressed: () {
// 获取输入文本并去空格
final String inputText = controller.text.trim();
// 2. 执行确认回调
onConfirm(inputText);
// 1. 执行自定义校验
if (inputValidator != null) {
final String? errorMsg = inputValidator(inputText);
if (errorMsg != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorMsg),
backgroundColor: Colors.red,
),
);
return;
}
}
// 3. 关闭弹窗
Navigator.of(context).pop();
},
child: Text(confirmText),
// 2. 执行确认回调
onConfirm(inputText);
// 3. 关闭弹窗
Navigator.of(context).pop();
},
child: Text(confirmText),
),
],
),
],
),
),
],
),
);
},
);

View File

@@ -0,0 +1,201 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import 'package:maibu_satabot_v2/core/localization/locale_cubit.dart';
/// 可选语言项:tag 对应 assets/languages/{tag}.json,nativeName 用该语言本地写法展示。
class LanguageItem {
final Locale locale;
final String nativeName;
const LanguageItem(this.locale, this.nativeName);
String get tag => locale.countryCode == null || locale.countryCode!.isEmpty
? locale.languageCode
: '${locale.languageCode}-${locale.countryCode}';
}
/// 全局唯一的“可选语言列表”来源。
/// 后续接入“接口下载语言包”时,只需在这里扩展(例如把已下载的语言追加进来),
/// 登录强制弹窗与设置页入口都会自动同步,无需改多处。
class AppLanguages {
AppLanguages._();
static final AppLanguages instance = AppLanguages._();
final List<LanguageItem> items = const [
LanguageItem(AppLocalizations.chinese, '简体中文'),
LanguageItem(AppLocalizations.english, 'English'),
];
String nameOf(Locale locale) {
final tag = locale.countryCode == null || locale.countryCode!.isEmpty
? locale.languageCode
: '${locale.languageCode}-${locale.countryCode}';
for (final item in items) {
if (item.tag == tag) return item.nativeName;
}
return tag;
}
}
/// 语言选择弹窗。
/// [mandatory] = true:登录时强制弹出,拦截系统返回键;可点某个语言即时切换,或点“继续”确认当前默认语言后关闭。
/// [mandatory] = false:设置页中普通选择,可取消。
class LanguageSelectionDialog extends StatelessWidget {
final bool mandatory;
const LanguageSelectionDialog({super.key, this.mandatory = true});
void _onSelect(BuildContext context, Locale locale) {
if (ModalRoute.of(context)?.isCurrent != true) return;
unawaited(context.read<LocaleCubit>().setLocale(locale));
Navigator.of(context).pop();
}
static const _navy = Color(0xFF1A2B4A);
static const _blue = Color(0xFF1C96F6);
static const _gray = Color(0xFF63758A);
static const _border = Color(0xFFE5EDF7);
static const _softBlue = Color(0xFFEAF3FE);
@override
Widget build(BuildContext context) {
final current = context.select<LocaleCubit, Locale>((c) => c.state);
final isChinese = current.languageCode == 'zh';
final buttonShape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
);
return PopScope(
canPop: !mandatory,
child: Dialog(
backgroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
constraints: const BoxConstraints(maxWidth: 360),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: _softBlue,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.language_rounded, color: _blue),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isChinese ? '选择语言' : 'Select language',
style: const TextStyle(
color: _navy,
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
isChinese ? 'Select language' : '选择语言',
style: const TextStyle(color: _gray, fontSize: 12),
),
],
),
),
],
),
const SizedBox(height: 18),
for (final item in AppLanguages.instance.items) ...[
_languageOption(context, item, selected: item.locale == current),
const SizedBox(height: 8),
],
const SizedBox(height: 8),
if (mandatory)
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF0B6DCC),
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(44),
shape: buttonShape,
),
onPressed: () => _onSelect(context, current),
child: Text(isChinese ? '继续 / Continue' : 'Continue / 继续'),
)
else
OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: _navy,
side: const BorderSide(color: _border),
minimumSize: const Size.fromHeight(44),
shape: buttonShape,
),
onPressed: () => Navigator.of(context).pop(),
child: Text(
AppLocalizations.of(context).translate('common.cancel'),
),
),
],
),
),
),
);
}
Widget _languageOption(
BuildContext context,
LanguageItem item, {
required bool selected,
}) {
return Semantics(
selected: selected,
button: true,
child: Material(
color: selected ? _softBlue : const Color(0xFFF6FAFE),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: selected ? _blue : _border),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => _onSelect(context, item.locale),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
child: Row(
children: [
Expanded(
child: Text(
item.nativeName,
style: const TextStyle(
color: _navy,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 10),
Icon(
selected
? Icons.check_circle_rounded
: Icons.radio_button_unchecked_rounded,
size: 20,
color: selected ? _blue : const Color(0xFFABBBCD),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../core/localization/app_localizations.dart';
import 'capsule_toast.dart';
enum LegalDocument {
privacy('my.privacy_policy', 'https://ri.satabot.com/web/legal/privacy.html'),
terms('my.user_agreement', 'https://ri.satabot.com/web/legal/terms.html');
const LegalDocument(this.titleKey, this.url);
final String titleKey;
final String url;
}
Future<void> openLegalDocument(
BuildContext context,
LegalDocument document,
) async {
final errorText = AppLocalizations.of(context).translate('legal.open_failed');
try {
final opened = await launchUrl(
Uri.parse(document.url),
mode: LaunchMode.inAppBrowserView,
webOnlyWindowName: '_blank',
);
if (opened) return;
} catch (_) {
// 平台无法打开时保留当前页面,并向用户提供重试提示。
}
if (context.mounted) CapsuleToast.show(errorText, showCheck: false);
}

View File

@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import 'package:maibu_satabot_v2/features/home/presentation/widgets/common/enum.dart';
/// 保存打点前的「规划类型」选择框:对应后端 workRecord.routeType(必填)。
///
/// 后端该字段缺失时保存接口直接报「未选择规划类型」。新版「地图与区域编辑」
/// 与旧版路径规划共用 /iot/workRecord/add,所以两边共用这一个弹窗,
/// 保证取值与交互一致。返回 null 表示用户取消。
///
/// [maxWidth]:横屏页传较小值(如 240),避免默认 AlertDialog 撑满整屏;
/// [insetPadding]:横屏页收窄两侧留白;
/// [initial]:默认选中项,默认「割草任务」。
Future<RouteType?> showRouteTypePicker(
BuildContext context, {
String? title,
RouteType? initial,
double? maxWidth,
EdgeInsets? insetPadding,
}) {
return showDialog<RouteType>(
context: context,
builder: (dialogContext) => _RouteTypeDialog(
title: title ?? AppLocalizations.of(dialogContext).translate('region_editor.route_type_title'),
initial: initial ?? RouteType.mowingTask,
maxWidth: maxWidth,
insetPadding: insetPadding,
),
);
}
class _RouteTypeDialog extends StatefulWidget {
const _RouteTypeDialog({
required this.title,
required this.initial,
this.maxWidth,
this.insetPadding,
});
final String title;
final RouteType initial;
final double? maxWidth;
final EdgeInsets? insetPadding;
@override
State<_RouteTypeDialog> createState() => _RouteTypeDialogState();
}
class _RouteTypeDialogState extends State<_RouteTypeDialog> {
AppLocalizations get _l10n => AppLocalizations.of(context);
late RouteType _picked = widget.initial;
@override
Widget build(BuildContext context) {
final Color accent = Theme.of(context).colorScheme.primary;
return AlertDialog(
insetPadding: widget.insetPadding ??
const EdgeInsets.symmetric(horizontal: 40.0, vertical: 24.0),
title: Text(widget.title, style: const TextStyle(fontSize: 18)),
// 横屏可视高度只有 300dp 上下(刘海机型更少):标题/内容/按钮的
// 上下留白全部压紧,再加下面的可滚动兼底,避免 RenderFlex 溢出
titlePadding: const EdgeInsets.fromLTRB(24, 14, 24, 4),
contentPadding: const EdgeInsets.fromLTRB(24, 4, 24, 0),
actionsPadding: const EdgeInsets.fromLTRB(24, 4, 24, 10),
content: SizedBox(
width: widget.maxWidth,
// 兼容极矮屏幕(横屏 + 挖孔 + 系统栏):内容超高时滚动,不再顶破弹窗
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final RouteType type in RouteType.values)
_buildOption(type, accent),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(_l10n.translate('common.cancel')),
),
TextButton(
onPressed: () => Navigator.of(context).pop(_picked),
child: Text(
_l10n.translate('common.ok'),
style: TextStyle(
color: accent,
fontWeight: FontWeight.w600,
),
),
),
],
);
}
Widget _buildOption(RouteType type, Color accent) {
final bool selected = type == _picked;
return InkWell(
onTap: () => setState(() => _picked = type),
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
children: [
// 自绘单选圈:不用 RadioListTile,避开其 groupValue/onChanged 已废弃的告警
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: selected ? accent : Colors.grey.shade400,
width: selected ? 6 : 1.5,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
_l10n.translate(switch (type) {
RouteType.mowingTask => 'region_editor.route_type_mowing',
RouteType.inspectionTask => 'region_editor.route_type_inspection',
RouteType.predefinedPath => 'region_editor.route_type_predefined',
RouteType.other => 'region_editor.route_type_other',
}),
style: TextStyle(
fontSize: 15,
color: selected ? accent : null,
fontWeight: selected ? FontWeight.w600 : null,
),
),
),
],
),
),
);
}
}

View File

@@ -152,18 +152,19 @@ class BleProtocolDecoder {
parts.add('');
}
// 协议定义 控制模式: 0无控制 1本地遥控 2蓝牙 3TCP 4其他接口;
// 协议定义 控制模式: 0未知 1本地 2自主任务 3远程遥控(2026-11 后端重新确认,
// 覆盖早先 0无控制/1本地遥控/2蓝牙/3TCP/4其他接口 那版;
// 与 lib/components/control_mode_text.dart 保持同一口径);
// 先按整数解析(容忍前后空格),有映射的一律显示对应文字;
// 超出协议范围的码(如固件扩展值)显示为 未知(n),避免被误当成有效模式
String controlModeText(String v) {
final n = int.tryParse(v.trim());
if (n == null) return v.isEmpty ? '--' : v;
return switch (n) {
0 => '无控制',
1 => '本地遥控',
2 => '蓝牙',
3 => 'TCP',
4 => '其他接口',
0 => '未知',
1 => '本地',
2 => '自主任务',
3 => '远程遥控',
_ => '未知($n)',
};
}

View File

@@ -2,6 +2,15 @@ import '../env/env_config.dart';
class HttpApiConsts {
static String get baseUrl => EnvConfig.baseUrl;
static String get siteDevicePlans => '$baseUrl/iot/deviceTask/getSiteDevicePlan';
static String get deleteDevicePlan => '$baseUrl/iot/deviceTask/delete';
static String get siteDeviceTaskHistory =>
'$baseUrl/iot/deviceTask/getSiteDeviceTaskHistory';
static String get createConsecutiveTask =>
'$baseUrl/iot/deviceTask/createConsecutiveTask';
static String get saveRegion => '$baseUrl/system/region/save';
static String get regionsBySite => '$baseUrl/system/region/selectBySiteId';
static String get deleteRegions => '$baseUrl/system/region/delete';
/// 账号相关
// 登录
@@ -12,7 +21,9 @@ class HttpApiConsts {
static String get getUserDevicesList => "$baseUrl/iot/device/list";
// 绑定设备
static String get bindDevice => "$baseUrl/iot/device/bind";
// 解绑设备
// 解绑设备(V2 批量接口:body {deviceIds, type},type 取值见 UnbindScope)
static String get unbindDeviceV2 => "$baseUrl/iot/device/unbind";
// 解绑设备(旧 forward 服接口:单设备,body {deviceId, deviceName})
static String get unbindDevice => "$baseUrl/forward/device/unbind";
// 切换设备
static String get switchDevice => "$baseUrl/forward/device/switchDevice";
@@ -42,6 +53,16 @@ class HttpApiConsts {
static String get getDeviceBySpiltCode =>
"$baseUrl/iot/device/getDeviceBySpiltCode";
// 一键寻车(GET,query 参数 deviceId = 设备编码 MC700-…,不是数字主键)
static String get hostSearchDevice => "$baseUrl/iot/host/searchDevice";
// GET,2=自主任务,3=远程遥控;路径 modeChage 沿用后端协议拼写。
static String get hostModeChange => "$baseUrl/iot/host/modeChage";
// 修改设备别名(只写 deviceAlias,deviceName 保持不变)
static String get updateDeviceAlias =>
"$baseUrl/forward/device/updateDeviceAlias";
// 获取机器人列表
static String get getRobotList => "$baseUrl/iot/device/getSiteList";

View File

@@ -0,0 +1,34 @@
import '../../../features/devices/presentation/bloc/device_status_bloc.dart';
import '../../../features/devices/presentation/bloc/device_status_state.dart';
import '../../../features/device_control_mode/domain/entities/device_control_mode.dart';
/// 适配设备详情「运行概况」的同一个实时源;不另建 MQTT 连接或猜测模式。
class DeviceControlModeRealtimeAdapter {
DeviceControlModeRealtimeAdapter(this.statusBloc);
final DeviceStatusBloc statusBloc;
Stream<DeviceControlModeReading> watch(String deviceId) {
return Stream<DeviceControlModeReading>.multi((controller) {
void forward(DeviceStatusState state) {
final belongsToDevice = statusBloc.listeningDeviceSn == deviceId;
controller.add(
DeviceControlModeReading(
deviceId: deviceId,
rawMode: belongsToDevice && state is DeviceStatusUpdated
? state.status.controlMode
: null,
),
);
}
final subscription = statusBloc.stream.listen(
forward,
onError: controller.addError,
);
controller.onCancel = subscription.cancel;
// 换设备时 Bloc 同步清空旧缓存;同设备保留最新快照,与设备详情一致。
statusBloc.startMqttRealtimeListening(deviceId);
forward(statusBloc.state);
});
}
}

View File

@@ -1,4 +1,26 @@
import '../../features/autonomous_work/domain/usecases/load_site_return_plan_usecase.dart';
import '../../features/v2/work/presentation/cubit/return_plan_library_cubit.dart';
import '../../features/site_task_plans/data/datasources/site_task_plan_remote_datasource.dart';
import '../../features/site_task_plans/data/repositories/site_task_plan_repository_impl.dart';
import '../../features/site_task_plans/domain/repositories/site_task_plan_repository.dart';
import '../../features/site_task_plans/domain/usecases/site_task_plan_usecases.dart';
import '../../features/site_task_plans/presentation/cubit/site_task_plans_cubit.dart';
import '../../features/work_history/data/datasources/work_history_remote_datasource.dart';
import '../../features/work_history/data/repositories/work_history_repository_impl.dart';
import '../../features/work_history/domain/repositories/work_history_repository.dart';
import '../../features/work_history/domain/usecases/load_site_work_history_usecase.dart';
import '../../features/work_history/presentation/cubit/work_history_cubit.dart';
import '../../features/consecutive_tasks/data/datasources/consecutive_task_remote_datasource.dart';
import '../../features/consecutive_tasks/data/repositories/consecutive_task_repository_impl.dart';
import '../../features/consecutive_tasks/domain/repositories/consecutive_task_repository.dart';
import '../../features/consecutive_tasks/domain/usecases/create_consecutive_task_usecase.dart';
import '../../features/consecutive_tasks/presentation/cubit/area_task_create_cubit.dart';
import 'package:dio/dio.dart';
import 'adapters/device_control_mode_realtime_adapter.dart';
import '../../features/device_control_mode/data/datasources/control_mode_remote_datasource.dart';
import '../../features/device_control_mode/data/repositories/device_control_mode_repository_impl.dart';
import '../../features/device_control_mode/domain/repositories/device_control_mode_repository.dart';
import '../../features/device_control_mode/domain/usecases/device_control_mode_usecases.dart';
import 'package:flutter/widgets.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
@@ -80,6 +102,8 @@ import '../../features/v2/device_list/domain/usecases/get_device_status_data_use
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/domain/usecases/update_device_alias_usecase.dart';
import '../../features/v2/device_list/domain/usecases/search_device_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';
@@ -101,6 +125,9 @@ import '../../features/v2/device_list/data/repositories/bind_device_repository_i
import '../../features/v2/device_list/domain/repositories/bind_device_repository.dart';
import '../../features/v2/device_list/domain/usecases/bind_device_usecases.dart';
import '../../features/v2/device_list/presentation/cubit/bind_device_cubit.dart';
import '../../features/v2/device_list/presentation/cubit/device_ownership_action_cubit.dart';
import '../../features/v2/device_list/presentation/cubit/rename_device_cubit.dart';
import '../../features/v2/device_list/presentation/cubit/find_robot_cubit.dart';
import '../../features/v2/waring_center/data/datasources/alarm_remote_datasource.dart';
import '../../features/v2/waring_center/data/datasources/impl/alarm_remote_datasource_impl.dart';
import '../../features/v2/waring_center/data/repositories/alarm_repository_impl.dart';
@@ -139,8 +166,19 @@ import '../../features/devices/domain/repositories/device_task_repository.dart';
import '../../features/devices/domain/usecases/get_device_task_pool_usecase.dart';
import '../../features/devices/domain/usecases/cancel_task_usecase.dart';
import '../../features/devices/domain/usecases/pause_task_usecase.dart';
import '../../features/devices/domain/usecases/save_device_schedule_plan_usecase.dart';
import '../../features/v2/work/presentation/cubit/schedule_plan_create_cubit.dart';
import '../../features/v2/work/presentation/cubit/work_home_task_cubit.dart';
import '../../features/regions/data/datasources/region_remote_datasource.dart';
import '../../features/regions/data/repositories/region_repository_impl.dart';
import '../../features/regions/domain/repositories/region_repository.dart';
import '../../features/regions/domain/usecases/region_usecases.dart';
import '../../features/regions/presentation/cubit/region_editor_cubit.dart';
import '../../features/regions/presentation/cubit/region_list_cubit.dart';
import '../../features/devices/domain/usecases/recovery_task_usecase.dart';
import '../../features/devices/presentation/bloc/device_task_cubit.dart';
import '../../features/autonomous_work/data/gateways/autonomous_work_gateway_factory.dart';
import '../../features/autonomous_work/domain/repositories/autonomous_work_gateway.dart';
import '../app/app_user_cubit.dart';
import '../localization/locale_cubit.dart';
import '../theme/theme_cubit.dart';
@@ -384,6 +422,15 @@ Future<void> init() async {
sl.registerLazySingleton<GetDeviceBySpiltCodeUseCase>(
() => GetDeviceBySpiltCodeUseCase(repository: sl()),
);
sl.registerLazySingleton<UpdateDeviceAliasUseCase>(
() => UpdateDeviceAliasUseCase(repository: sl()),
);
sl.registerFactory<RenameDeviceCubit>(() => RenameDeviceCubit(sl()));
// 一键寻车:UI → FindRobotCubit → SearchDeviceUseCase → DeviceRepository → DataSource
sl.registerLazySingleton<SearchDeviceUseCase>(
() => SearchDeviceUseCase(repository: sl()),
);
sl.registerFactory<FindRobotCubit>(() => FindRobotCubit(sl()));
sl.registerFactory<device_v2_bloc.DeviceStatusBloc>(
() => device_v2_bloc.DeviceStatusBloc(sl()),
);
@@ -478,7 +525,7 @@ Future<void> init() async {
);
// 🔥 语言管理 Cubit (单例)
sl.registerLazySingleton(() => LocaleCubit());
sl.registerLazySingleton(() => LocaleCubit(sl<SharedPreferences>()));
// 🔥 主题管理 Cubit (单例,管理亮/暗/跟随系统)
sl.registerLazySingleton(() => ThemeCubit());
@@ -609,11 +656,116 @@ Future<void> init() async {
() => DeviceTaskRepositoryImpl(datasource: sl<DeviceTaskDatasource>()),
);
sl.registerLazySingleton(() => GetDeviceTaskPoolUseCase(sl()));
sl.registerFactory(() => WorkHomeTaskCubit(sl<GetDeviceTaskPoolUseCase>()));
sl.registerLazySingleton(
() => SiteTaskPlanRemoteDatasource(sl<Dio>(), sl<ILoggerService>()),
);
sl.registerLazySingleton<SiteTaskPlanRepository>(
() => SiteTaskPlanRepositoryImpl(sl<SiteTaskPlanRemoteDatasource>()),
);
sl.registerLazySingleton(
() => LoadSiteTaskPlansUseCase(sl<SiteTaskPlanRepository>()),
);
sl.registerLazySingleton(
() => DeleteSiteTaskPlanUseCase(sl<SiteTaskPlanRepository>()),
);
sl.registerFactory(
() => SiteTaskPlansCubit(
sl<LoadSiteTaskPlansUseCase>(),
sl<DeleteSiteTaskPlanUseCase>(),
),
);
sl.registerLazySingleton(() => CancelTaskUseCase(sl()));
sl.registerLazySingleton(() => PauseTaskUseCase(sl()));
sl.registerLazySingleton(() => RecoveryTaskUseCase(sl()));
// 创建定时作业计划(作业计划页·新建 → POST /iot/deviceTask/save)
sl.registerLazySingleton(() => SaveDeviceSchedulePlanUseCase(sl()));
sl.registerFactory<SchedulePlanCreateCubit>(
() => SchedulePlanCreateCubit(sl<SaveDeviceSchedulePlanUseCase>()),
);
sl.registerLazySingleton(() => DeviceTaskCubit(sl(), sl(), sl(), sl()));
/// 10.1 自主作业业务门面(一套功能多套环境实现)
/// 切换环境:AutonomousWorkGatewayFactory.overrideWith(...) 或
/// flutter run --dart-define=AUTONOMOUS_WORK_SCENE=local_demo
sl.registerLazySingleton<AutonomousWorkGateway>(
() => AutonomousWorkGatewayFactory.current(),
);
sl.registerLazySingleton(
() => DeviceControlModeRealtimeAdapter(sl<DeviceStatusBloc>()),
);
sl.registerLazySingleton(
() => ControlModeRemoteDatasource(sl<Dio>(), sl<ILoggerService>()),
);
sl.registerLazySingleton<DeviceControlModeRepository>(
() => DeviceControlModeRepositoryImpl(
realtime: sl<DeviceControlModeRealtimeAdapter>().watch,
remote: sl<ControlModeRemoteDatasource>(),
),
);
sl.registerLazySingleton(
() => WatchDeviceControlModeUseCase(sl<DeviceControlModeRepository>()),
);
sl.registerLazySingleton(
() => ChangeDeviceControlModeUseCase(sl<DeviceControlModeRepository>()),
);
sl.registerLazySingleton(
() => RegionRemoteDatasource(sl<Dio>(), sl<ILoggerService>()),
);
sl.registerLazySingleton<RegionRepository>(
() => RegionRepositoryImpl(
remote: sl<RegionRemoteDatasource>(),
routes: sl<AutonomousWorkGateway>(),
),
);
sl.registerLazySingleton(() => SaveRegionUseCase(sl<RegionRepository>()));
sl.registerLazySingleton(
() => LoadRegionRoutesUseCase(sl<RegionRepository>()),
);
sl.registerLazySingleton(() => LoadRegionsUseCase(sl<RegionRepository>()));
sl.registerLazySingleton(
() => LoadSiteReturnPlanUseCase(sl<AutonomousWorkGateway>()),
);
sl.registerFactory(
() => ReturnPlanLibraryCubit(sl<LoadSiteReturnPlanUseCase>()),
);
sl.registerLazySingleton(() => DeleteRegionsUseCase(sl<RegionRepository>()));
sl.registerLazySingleton(
() => WorkHistoryRemoteDatasource(sl<Dio>(), sl<ILoggerService>()),
);
sl.registerLazySingleton<WorkHistoryRepository>(
() => WorkHistoryRepositoryImpl(sl<WorkHistoryRemoteDatasource>()),
);
sl.registerLazySingleton(
() => LoadSiteWorkHistoryUseCase(sl<WorkHistoryRepository>()),
);
sl.registerFactory(() => WorkHistoryCubit(sl<LoadSiteWorkHistoryUseCase>()));
sl.registerLazySingleton(
() => ConsecutiveTaskRemoteDatasource(sl<Dio>(), sl<ILoggerService>()),
);
sl.registerLazySingleton<ConsecutiveTaskRepository>(
() => ConsecutiveTaskRepositoryImpl(sl<ConsecutiveTaskRemoteDatasource>()),
);
sl.registerLazySingleton(
() => CreateConsecutiveTaskUseCase(sl<ConsecutiveTaskRepository>()),
);
sl.registerFactory(
() => AreaTaskCreateCubit(
sl<LoadRegionsUseCase>(),
sl<LoadRegionRoutesUseCase>(),
sl<CreateConsecutiveTaskUseCase>(),
),
);
sl.registerFactory(
() => RegionListCubit(sl<LoadRegionsUseCase>(), sl<DeleteRegionsUseCase>()),
);
sl.registerFactory(
() => RegionEditorCubit(
sl<SaveRegionUseCase>(),
sl<LoadRegionRoutesUseCase>(),
),
);
/// 11. 工单管理 (Work Order)
sl.registerLazySingleton<WorkOrderRemoteDataSource>(
() => WorkOrderRemoteDataSourceImpl(sl<Dio>()),
@@ -689,7 +841,20 @@ Future<void> init() async {
sl.registerLazySingleton<IsDeviceAtSiteUseCase>(
() => IsDeviceAtSiteUseCase(sl()),
);
sl.registerLazySingleton<UnbindDeviceV2UseCase>(
() => UnbindDeviceV2UseCase(sl()),
);
sl.registerFactory<BindDeviceCubit>(
() => BindDeviceCubit(sl(), sl(), sl(), sl(), sl(), sl<AppUserCubit>()),
);
sl.registerFactory<DeviceOwnershipActionCubit>(
() => DeviceOwnershipActionCubit(
sl(), // GetOrgListUseCase
sl(), // GetSitesByOrgUseCase
sl(), // GetUsersBySiteUseCase
sl(), // BindDeviceV2UseCase(转交)
sl(), // UnbindDeviceV2UseCase(解绑)
sl<AppUserCubit>(),
),
);
}

View File

@@ -1,6 +1,8 @@
import 'package:equatable/equatable.dart';
class UserEntity extends Equatable {
static const adminRoleKey = 'admin';
final String userId;
final String username;
final String nickname;
@@ -25,6 +27,8 @@ class UserEntity extends Equatable {
this.siteId,
});
bool get isAdmin => roleKey == adminRoleKey;
@override
List<Object?> get props => [
userId,

View File

@@ -1,7 +1,7 @@
class EnvConfig {
static const String environment = String.fromEnvironment(
'ENV',
defaultValue: 'prod',
defaultValue: 'test',
);
static String get sentryDsn {
@@ -12,7 +12,7 @@ class EnvConfig {
return 'https://1a72051f8ac6c21cde96ded18a318217@o4510984107261952.ingest.de.sentry.io/4510984468824144';
}
static bool get isProduction => environment == 'prod';
static const bool isProduction = environment == 'prod';
// ─── 服务器 IP(测试/生产共用同一台机器) ───
static const String serverHost = '1.95.137.212';

View File

@@ -9,6 +9,12 @@ import '../../logging/i_logger_service.dart';
import 'package:intl/intl.dart';
class SentryLoggerImpl implements ILoggerService {
// 正式包默认关闭上报,重新启用时传入 --dart-define=ENABLE_SENTRY=true。
static const bool _sentryEnabled = bool.fromEnvironment(
'ENABLE_SENTRY',
defaultValue: !kReleaseMode && !EnvConfig.isProduction,
);
File? _localLogFile;
//调试模式开关
bool _isDebugModeEnabled = false;
@@ -16,20 +22,17 @@ class SentryLoggerImpl implements ILoggerService {
@override
Future<void> init() async {
_isDebugModeEnabled = false;
await SentryFlutter.init((options) {
options.dsn = EnvConfig.sentryDsn;
options.environment = EnvConfig.environment;
// 生产环境只采样 20% 的性能数据,测试环境全开
options.tracesSampleRate = EnvConfig.isProduction ? 0.2 : 1.0;
if (_sentryEnabled) {
await SentryFlutter.init((options) {
options.dsn = EnvConfig.sentryDsn;
options.environment = EnvConfig.environment;
// 生产环境只采样 20% 的性能数据,测试环境全开
options.tracesSampleRate = EnvConfig.isProduction ? 0.2 : 1.0;
options.debug = _isDebugModeEnabled;
});
// 动态设置 Sentry 接收的日志级别
// 如果开启调试模式,接收 DEBUG;否则生产环境只接收 INFO 以上
options.debug = _isDebugModeEnabled; // 开启 SDK 内部调试
});
await _collectDeviceInfo();
await _collectDeviceInfo();
}
await _prepareLocalFile();
}
@@ -67,7 +70,9 @@ class SentryLoggerImpl implements ILoggerService {
@override
void captureException(dynamic exception, {dynamic stackTrace}) {
Sentry.captureException(exception, stackTrace: stackTrace);
if (_sentryEnabled) {
Sentry.captureException(exception, stackTrace: stackTrace);
}
}
@override
@@ -80,13 +85,13 @@ class SentryLoggerImpl implements ILoggerService {
final level = isWarning ? 'WARNING' : 'INFO';
final logEntry = "[$timestamp] [$level] $message ${data ?? ''}\n";
// 1.发送日志到 Sentry
Sentry.captureMessage(
message,
level: isWarning ? SentryLevel.warning : SentryLevel.info,
);
if (_sentryEnabled) {
Sentry.captureMessage(
message,
level: isWarning ? SentryLevel.warning : SentryLevel.info,
);
}
// 2.写入本地文件
_writeToLocalFile(logEntry);
if (kDebugMode) {
@@ -127,10 +132,10 @@ class SentryLoggerImpl implements ILoggerService {
_isDebugModeEnabled;
if (shouldProcess) {
// 写入本地文件
_writeToLocalFile(logEntry);
}
// 上报 Sentry
if (_sentryEnabled && shouldProcess) {
SentryLevel sentryLevel;
switch (level) {
case 'DEBUG': sentryLevel = SentryLevel.debug; break;
@@ -164,11 +169,15 @@ class SentryLoggerImpl implements ILoggerService {
@override
void addBreadcrumb(String message, {String? category}) {
Sentry.addBreadcrumb(Breadcrumb(message: message, category: category));
if (_sentryEnabled) {
Sentry.addBreadcrumb(Breadcrumb(message: message, category: category));
}
}
@override
void setUser(String userId) {
Sentry.configureScope((scope) => scope.setUser(SentryUser(id: userId)));
if (_sentryEnabled) {
Sentry.configureScope((scope) => scope.setUser(SentryUser(id: userId)));
}
}
}

View File

@@ -3,21 +3,32 @@ import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
class AppLocalizations {
static const chinese = Locale('zh', 'CN');
static const english = Locale('en', 'US');
static const supportedLocales = [chinese, english];
static Locale localeForLanguage(String? languageCode) =>
languageCode == 'zh' ? chinese : english;
static final _placeholderPattern = RegExp(r'\{(\w+)\}');
final Locale locale;
Map<String, dynamic> _localizedStrings = {};
AppLocalizations(this.locale);
static AppLocalizations of(BuildContext context) {
return Localizations.of<AppLocalizations>(context, AppLocalizations)!;
}
static const LocalizationsDelegate<AppLocalizations> delegate = _AppLocalizationsDelegate();
static const LocalizationsDelegate<AppLocalizations> delegate =
_AppLocalizationsDelegate();
Future<bool> load() async {
try {
String jsonString = await rootBundle.loadString(
'assets/languages/${locale.languageCode}-${locale.countryCode}.json',
final assetLocale = localeForLanguage(locale.languageCode);
final jsonString = await rootBundle.loadString(
'assets/languages/${assetLocale.toLanguageTag()}.json',
);
Map<String, dynamic> jsonMap = json.decode(jsonString);
_localizedStrings = jsonMap;
@@ -27,11 +38,11 @@ class AppLocalizations {
return false;
}
}
String translate(String key) {
String translate(String key, [Map<String, Object?> values = const {}]) {
final keys = key.split('.');
dynamic value = _localizedStrings;
for (final k in keys) {
if (value is Map<String, dynamic>) {
value = value[k];
@@ -39,26 +50,34 @@ class AppLocalizations {
return key;
}
}
return value?.toString() ?? key;
final text = value?.toString() ?? key;
if (values.isEmpty) return text;
return text.replaceAllMapped(
_placeholderPattern,
(match) => values[match[1]]?.toString() ?? match[0]!,
);
}
}
class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
class _AppLocalizationsDelegate
extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate();
@override
bool isSupported(Locale locale) {
return ['zh', 'en'].contains(locale.languageCode);
return AppLocalizations.supportedLocales.any(
(supported) => supported.languageCode == locale.languageCode,
);
}
@override
Future<AppLocalizations> load(Locale locale) async {
final localizations = AppLocalizations(locale);
await localizations.load();
return localizations;
}
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
}

View File

@@ -2,35 +2,60 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app_localizations.dart';
class LocaleCubit extends Cubit<Locale> {
static const String _localeKey = 'app_locale';
LocaleCubit() : super(const Locale('zh', 'CN')) {
_loadSavedLocale();
static const _languageKey = 'app_locale_lang';
static const _countryKey = 'app_locale_country';
static const _chosenKey = 'app_locale_chosen';
LocaleCubit(this._preferences)
: _hasChosenLanguage = _preferences.get(_chosenKey) == true,
super(_savedLocale(_preferences));
final SharedPreferences _preferences;
bool _hasChosenLanguage;
Future<void> _pendingSave = Future<void>.value();
/// 用户是否已明确选择过语言。
bool get hasChosenLanguage => _hasChosenLanguage;
static Locale _savedLocale(SharedPreferences preferences) {
if (preferences.get(_chosenKey) != true) return AppLocalizations.english;
return AppLocalizations.localeForLanguage(
preferences.get(_languageKey)?.toString(),
);
}
Future<void> _loadSavedLocale() async {
Future<void> setLocale(Locale locale) {
if (isClosed) return Future<void>.value();
final selected = AppLocalizations.localeForLanguage(locale.languageCode);
_hasChosenLanguage = true;
emit(selected);
// 快速切换时按点选顺序写入,最后一次选择用于下次启动。
return _pendingSave = _pendingSave.then((_) => _persist(selected));
}
Future<void> _persist(Locale locale) async {
try {
final prefs = await SharedPreferences.getInstance();
final languageCode = prefs.getString('${_localeKey}_lang') ?? 'zh';
final countryCode = prefs.getString('${_localeKey}_country') ?? 'CN';
emit(Locale(languageCode, countryCode));
} catch (e) {
emit(const Locale('zh', 'CN'));
final languageSaved = await _preferences.setString(
_languageKey,
locale.languageCode,
);
final countrySaved = await _preferences.setString(
_countryKey,
locale.countryCode!,
);
final chosenSaved = await _preferences.setBool(_chosenKey, true);
if (!languageSaved || !countrySaved || !chosenSaved) {
debugPrint('[LocaleCubit] 语言偏好未写入存储');
}
} catch (error) {
debugPrint('[LocaleCubit] 保存语言设置失败: $error');
}
}
Future<void> setLocale(Locale locale) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('${_localeKey}_lang', locale.languageCode);
await prefs.setString('${_localeKey}_country', locale.countryCode ?? '');
emit(locale);
} catch (e) {
debugPrint('❌ [LocaleCubit] 保存语言设置失败: $e');
}
}
bool get isChinese => state.languageCode == 'zh';
bool get isEnglish => state.languageCode == 'en';
}

View File

@@ -0,0 +1,53 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
/// 只观察轨迹链路,不参与任务状态、订阅和控制指令。
/// 高频日志采样;首条和不同原因各自打印,避免定位推送刷满控制台。
class WorkTraceDiagnostics {
WorkTraceDiagnostics({void Function(String)? write, DateTime Function()? now})
: _write = write ?? debugPrint,
_now = now ?? DateTime.now;
final void Function(String) _write;
final DateTime Function() _now;
final _last = <String, DateTime>{};
final _suppressed = <String, int>{};
void log(
String stage,
Map<String, Object?> fields, {
String? sampleKey,
Duration interval = const Duration(seconds: 1),
}) {
try {
final now = _now();
if (sampleKey != null) {
final last = _last[sampleKey];
if (last != null && now.difference(last) < interval) {
_suppressed.update(sampleKey, (v) => v + 1, ifAbsent: () => 1);
return;
}
_last[sampleKey] = now;
}
final skipped = sampleKey == null
? 0
: _suppressed.remove(sampleKey) ?? 0;
final details = <String, Object?>{'time': now.toIso8601String()};
for (final field in fields.entries) {
var value = field.value;
if (value is String && value.length > 1600) {
value = '${value.substring(0, 1600)}…(共${value.length}字符)';
}
details[field.key] = value;
}
if (skipped > 0) details['sampledOut'] = skipped;
_write('[作业轨迹][$stage] ${jsonEncode(details)}');
} catch (_) {
// 日志或序列化失败不能影响点处理、状态发布和画线。
}
}
void clear() {
_last.clear();
_suppressed.clear();
}
}

View File

@@ -8,6 +8,7 @@ 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';
import '../../domain/entities/host_realtime_message_entity.dart';
/// 割草机实时状态 MQTT 数据源(替代原 TCP 0x02 机器状态推送)
///
@@ -20,6 +21,14 @@ abstract class MowerRealtimeDataSource {
Stream<MowerLocationEntity> get locationStream;
Stream<RouteStatusEntity> get routeStatusStream;
/// 🔥 主机实时状态(device/{sn}/host/realTime/message,新增话题,纯附加)
Stream<HostRealtimeMessageEntity> get hostRealtimeStream;
/// 按设备观察概览所需的电量和控制模式,订阅随监听者释放。
Stream<({double? battery, String controlMode})> watchOverviewStatus(
String deviceSn,
);
/// 按设备 SN 启动订阅:自动退订旧设备主题、同 SN 去重
Future<void> startListening({required String deviceSn});
@@ -32,11 +41,14 @@ class MowerRealtimeDataSourceImpl implements MowerRealtimeDataSource {
static const String _realtimeSuffix = 'property/realtime/post';
static const String _locationSuffix = 'property/location/post';
static const String _routeSuffix = 'route/post';
static const String _hostRealtimeSuffix = 'host/realTime/message';
final _vehicleController =
StreamController<RealTimeMessageEntity>.broadcast();
final _locationController = StreamController<MowerLocationEntity>.broadcast();
final _routeController = StreamController<RouteStatusEntity>.broadcast();
final _hostRealtimeController =
StreamController<HostRealtimeMessageEntity>.broadcast();
StreamSubscription<MqttMessage>? _subscription;
@@ -47,6 +59,7 @@ class MowerRealtimeDataSourceImpl implements MowerRealtimeDataSource {
int _vehicleMsgCount = 0;
int _locationMsgCount = 0;
int _routeMsgCount = 0;
int _hostMsgCount = 0;
MowerRealtimeDataSourceImpl(this.mqttClient);
@@ -59,9 +72,78 @@ class MowerRealtimeDataSourceImpl implements MowerRealtimeDataSource {
@override
Stream<RouteStatusEntity> get routeStatusStream => _routeController.stream;
@override
Stream<HostRealtimeMessageEntity> get hostRealtimeStream =>
_hostRealtimeController.stream;
String _realtimeTopic(String sn) => 'mower/$sn/$_realtimeSuffix';
String _locationTopic(String sn) => 'mower/$sn/$_locationSuffix';
String _routeTopic(String sn) => 'device/$sn/$_routeSuffix';
String _hostRealtimeTopic(String sn) => 'device/$sn/$_hostRealtimeSuffix';
@override
Stream<({double? battery, String controlMode})> watchOverviewStatus(
String deviceSn,
) {
final sn = deviceSn.trim();
final messages = mqttClient.messageStream;
if (sn.isEmpty || messages == null) return const Stream.empty();
final topic = _hostRealtimeTopic(sn);
StreamSubscription<MqttMessage>? messageSubscription;
Future<void>? topicSubscription;
double? lastBattery;
late final StreamController<({double? battery, String controlMode})>
controller;
controller = StreamController(
onListen: () {
// 先监听报文再订阅 Topic,接住订阅时立即到达的消息。
messageSubscription = messages.where((msg) => msg.topic == topic).listen(
(message) {
try {
final json = jsonDecode(message.payload);
if (json is! Map) return;
final payload = json['data'];
final data = payload is Map ? payload : json;
final battery = HostRealtimeMessageEntity.parseBatteryPercentage(
data['battery_percentage'],
);
lastBattery = battery ?? lastBattery;
controller.add(
(
battery: lastBattery,
controlMode: data['control_mode']?.toString().trim() ?? '',
),
);
} on FormatException {
// 忽略格式异常的单帧,等待下一次上报。
}
},
onError: controller.addError,
onDone: controller.close,
);
topicSubscription = mqttClient.subscribe(topic).catchError(
(Object error, StackTrace stackTrace) {
if (!controller.isClosed) controller.addError(error, stackTrace);
},
);
},
onCancel: () async {
await messageSubscription?.cancel();
final subscription = topicSubscription;
if (subscription == null) return;
await subscription;
try {
// 客户端按引用计数释放,详情页仍在使用时会保留其订阅。
await mqttClient.unsubscribe(topic);
} catch (error) {
debugPrint('[设备概览] 释放订阅失败 $topic: $error');
}
},
);
return controller.stream.distinct();
}
@override
Future<void> startListening({required String deviceSn}) async {
@@ -81,6 +163,7 @@ class MowerRealtimeDataSourceImpl implements MowerRealtimeDataSource {
await mqttClient.unsubscribe(_realtimeTopic(_deviceSn!));
await mqttClient.unsubscribe(_locationTopic(_deviceSn!));
await mqttClient.unsubscribe(_routeTopic(_deviceSn!));
await mqttClient.unsubscribe(_hostRealtimeTopic(_deviceSn!));
debugPrint('${LogTime.now()} 🔕 [MowerRealtimeDataSource] 已退订旧设备: $_deviceSn');
}
@@ -88,10 +171,12 @@ class MowerRealtimeDataSourceImpl implements MowerRealtimeDataSource {
_vehicleMsgCount = 0;
_locationMsgCount = 0;
_routeMsgCount = 0;
_hostMsgCount = 0;
await mqttClient.subscribe(_realtimeTopic(deviceSn));
await mqttClient.subscribe(_locationTopic(deviceSn));
await mqttClient.subscribe(_routeTopic(deviceSn));
await mqttClient.subscribe(_hostRealtimeTopic(deviceSn));
// 消息流监听只建立一次,按 _deviceSn 过滤
_subscription ??= mqttClient.messageStream?.listen(_handleMessage);
@@ -100,6 +185,7 @@ class MowerRealtimeDataSourceImpl implements MowerRealtimeDataSource {
debugPrint(' 车辆实时: ${_realtimeTopic(deviceSn)}');
debugPrint(' 定位: ${_locationTopic(deviceSn)}');
debugPrint(' 路径规划: ${_routeTopic(deviceSn)}');
debugPrint(' 主机实时: ${_hostRealtimeTopic(deviceSn)}');
}
@override
@@ -109,6 +195,7 @@ class MowerRealtimeDataSourceImpl implements MowerRealtimeDataSource {
await mqttClient.unsubscribe(_realtimeTopic(_deviceSn!));
await mqttClient.unsubscribe(_locationTopic(_deviceSn!));
await mqttClient.unsubscribe(_routeTopic(_deviceSn!));
await mqttClient.unsubscribe(_hostRealtimeTopic(_deviceSn!));
debugPrint('${LogTime.now()} 🔕 [MowerRealtimeDataSource] 停止监听设备: $_deviceSn');
_deviceSn = null;
}
@@ -153,6 +240,16 @@ class MowerRealtimeDataSourceImpl implements MowerRealtimeDataSource {
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(_hostRealtimeSuffix)) {
// 🔥 主机实时(新增话题,纯附加):外层套信封,数据在 json.data,提取方式与定位一致
_hostMsgCount++;
if (_hostMsgCount == 1) {
debugPrint('${LogTime.now()} 🎉 [MQTT-主机实时] 首帧到达: $sn');
}
final dataObj = jsonData['data'] as Map<String, dynamic>? ?? jsonData;
final host = HostRealtimeMessageEntity.fromJson(dataObj);
debugPrint('${LogTime.now()} 🛰️ [MQTT-主机实时] 第$_hostMsgCount帧 电量=${host.batteryPercentage}% 电压=${host.batteryVoltage}V 控制模式=${host.controlMode} 急停=${host.emergencyStop} 运动=${host.motion}');
_hostRealtimeController.add(host);
} else if (message.topic.endsWith(_routeSuffix)) {
_routeMsgCount++;
if (_routeMsgCount == 1) {
@@ -174,5 +271,6 @@ class MowerRealtimeDataSourceImpl implements MowerRealtimeDataSource {
_vehicleController.close();
_locationController.close();
_routeController.close();
_hostRealtimeController.close();
}
}

View File

@@ -7,6 +7,7 @@ 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';
import '../../domain/entities/host_realtime_message_entity.dart';
class MowerRealtimeRepositoryImpl implements MowerRealtimeRepository {
final MowerRealtimeDataSource dataSource;
@@ -23,6 +24,15 @@ class MowerRealtimeRepositoryImpl implements MowerRealtimeRepository {
Stream<RouteStatusEntity> get routeStatusStream =>
dataSource.routeStatusStream;
@override
Stream<HostRealtimeMessageEntity> get hostRealtimeStream =>
dataSource.hostRealtimeStream;
@override
Stream<({double? battery, String controlMode})> watchOverviewStatus(
String deviceSn,
) => dataSource.watchOverviewStatus(deviceSn);
@override
Future<Either<Failure, void>> startListening({
required String deviceSn,

View File

@@ -0,0 +1,154 @@
import 'package:equatable/equatable.dart';
/// 主机实时状态消息(MQTT 主题 device/{sn}/host/realTime/message)
///
/// 报文外层套一层信封,真正数据在 `data` 子对象(由 DataSource 提取后传入)。
/// 字段与后端 Java 实体一一对应,采用防御式解析,缺字段用默认值兜底。
class HostRealtimeMessageEntity extends Equatable {
final double batteryPercentage; // 实时电量百分比
final double batteryVoltage; // 实时电压
final double leftMotorCurrent; // 实时左电机电流
final double rightMotorCurrent; // 实时右电机电流
final double leftMotorTemperature; // 实时左电机温度
final double rightMotorTemperature; // 实时右电机温度
final double mainTemperature; // 实时主温度
final double yaw; // 实时航向角度
final double pitch; // 实时俯仰角度
final String headingStatus; // 实时航向状态
final double totalWorkArea; // 累计作业面积
final double totalWorkDuration; // 累计作业时长
final double totalWorkDistance; // 累计作业里程
final String motion; // moving|stopped|blocked 运动状态
final bool emergencyStop; // 急停状态
final double leftTargetRpm; // 实时左电机目标转速
final double rightTargetRpm; // 实时右电机目标转速
final double leftMeasuredRpm; // 实时左电机测量转速
final double rightMeasuredRpm; // 实时右电机测量转速
final double leftMotorRpm; // 实时左电机转速
final double rightMotorRpm; // 实时右电机转速
final double roll; // 实时翻滚角度
final String controlMode; // 当前控制模式(遥控器拨杆对应)
final double linearSpeed; // 实时线速度 m/s(前进为正,后退为负)
final double angularSpeed; // 实时角速度 rad/s(左转为正,右转(偏航)为负)
/// 原始 data 报文,便于后端补字段时兜底
final Map<String, dynamic> rawData;
const HostRealtimeMessageEntity({
this.batteryPercentage = 0,
this.batteryVoltage = 0,
this.leftMotorCurrent = 0,
this.rightMotorCurrent = 0,
this.leftMotorTemperature = 0,
this.rightMotorTemperature = 0,
this.mainTemperature = 0,
this.yaw = 0,
this.pitch = 0,
this.headingStatus = '',
this.totalWorkArea = 0,
this.totalWorkDuration = 0,
this.totalWorkDistance = 0,
this.motion = '',
this.emergencyStop = false,
this.leftTargetRpm = 0,
this.rightTargetRpm = 0,
this.leftMeasuredRpm = 0,
this.rightMeasuredRpm = 0,
this.leftMotorRpm = 0,
this.rightMotorRpm = 0,
this.roll = 0,
this.controlMode = '',
this.linearSpeed = 0,
this.angularSpeed = 0,
this.rawData = const {},
});
/// [data] 为报文 `data` 子对象(由 DataSource 提取,与 MowerLocationEntity 一致)
factory HostRealtimeMessageEntity.fromJson(Map<String, dynamic> data) {
return HostRealtimeMessageEntity(
batteryPercentage: parseBatteryPercentage(data['battery_percentage']) ?? 0,
batteryVoltage: _toDouble(data['battery_voltage']),
leftMotorCurrent: _toDouble(data['left_motor_current']),
rightMotorCurrent: _toDouble(data['right_motor_current']),
leftMotorTemperature: _toDouble(data['left_motor_temperature']),
rightMotorTemperature: _toDouble(data['right_motor_temperature']),
mainTemperature: _toDouble(data['main_temperature']),
yaw: _toDouble(data['yaw']),
pitch: _toDouble(data['pitch']),
headingStatus: data['heading_status']?.toString() ?? '',
totalWorkArea: _toDouble(data['total_work_area']),
totalWorkDuration: _toDouble(data['total_work_duration']),
totalWorkDistance: _toDouble(data['total_work_distance']),
motion: data['motion']?.toString() ?? '',
emergencyStop: _toBool(data['emergency_stop']),
leftTargetRpm: _toDouble(data['left_target_rpm']),
rightTargetRpm: _toDouble(data['right_target_rpm']),
leftMeasuredRpm: _toDouble(data['left_measured_rpm']),
rightMeasuredRpm: _toDouble(data['right_measured_rpm']),
leftMotorRpm: _toDouble(data['left_motor_rpm']),
rightMotorRpm: _toDouble(data['right_motor_rpm']),
roll: _toDouble(data['roll']),
controlMode: data['control_mode']?.toString() ?? '',
linearSpeed: _toDouble(data['linear_speed']),
angularSpeed: _toDouble(data['angular_speed']),
rawData: data,
);
}
/// 供主机状态和设备概览使用,区分无效值与真实的 0%。
static double? parseBatteryPercentage(Object? value) {
final percentage = value is num
? value.toDouble()
: double.tryParse(value?.toString() ?? '');
if (percentage == null ||
!percentage.isFinite ||
percentage < 0 ||
percentage > 100) {
return null;
}
return percentage;
}
static double _toDouble(dynamic value) {
if (value == null) return 0;
if (value is num) return value.toDouble();
return double.tryParse(value.toString()) ?? 0;
}
static bool _toBool(dynamic value) {
if (value == null) return false;
if (value is bool) return value;
if (value is num) return value != 0;
final s = value.toString().toLowerCase();
return s == 'true' || s == '1';
}
@override
List<Object?> get props => [
batteryPercentage,
batteryVoltage,
leftMotorCurrent,
rightMotorCurrent,
leftMotorTemperature,
rightMotorTemperature,
mainTemperature,
yaw,
pitch,
headingStatus,
totalWorkArea,
totalWorkDuration,
totalWorkDistance,
motion,
emergencyStop,
leftTargetRpm,
rightTargetRpm,
leftMeasuredRpm,
rightMeasuredRpm,
leftMotorRpm,
rightMotorRpm,
roll,
controlMode,
linearSpeed,
angularSpeed,
];
}

View File

@@ -5,11 +5,17 @@ import '../../../../../core/error/failure.dart';
import '../entities/mower_location_entity.dart';
import '../entities/real_time_message_entity.dart';
import '../entities/route_status_entity.dart';
import '../entities/host_realtime_message_entity.dart';
abstract class MowerRealtimeRepository {
Stream<RealTimeMessageEntity> get vehicleStream;
Stream<MowerLocationEntity> get locationStream;
Stream<RouteStatusEntity> get routeStatusStream;
Stream<HostRealtimeMessageEntity> get hostRealtimeStream;
Stream<({double? battery, String controlMode})> watchOverviewStatus(
String deviceSn,
);
Future<Either<Failure, void>> startListening({
required String deviceSn,

View File

@@ -41,7 +41,10 @@ class TcpClient {
Timer? _heartbeatSendTimer; // 🔥 主动发送心跳定时器:每5秒给服务端发一次,防止服务端判定客户端失效
Timer? _reconnectTimer; // 重连定时器
int _reconnectAttempt = 0; // 🔥 重连尝试次数,用于退避计算
static const int _maxReconnectAttempts = 5; // 🔥 最大自动重连次数,防止网络不可达时无限重连
int _reconnectFailCount = 0; // 🔥 连续重连失败次数(含心跳超时),达到阈值后停止重连
int _reconnectFlapCount = 0; // 🔥 连续“连上又断”次数,识别成功-断开循环
static const int _maxReconnectFlaps = 5; // 🔥 最大允许连续成功-断开次数
/// 🔥 重连耗尽回调:连续4次重连失败(含心跳超时)后触发,通知上层弹窗提示用户
VoidCallback? onReconnectExhausted;
/// 🔥 重连开始回调:显示"TCP重连中"提示
@@ -156,6 +159,7 @@ class TcpClient {
_lastAuthPacketSentAt = null;
_reconnectAttempt = 0; // 🔥 重置重连退避
_reconnectFailCount = 0; // 🔥 重置重连失败计数
_reconnectFlapCount = 0; // 🔥 重置连上又断计数
// 5. 销毁 Socket
if (_socket != null) {
@@ -385,6 +389,15 @@ class TcpClient {
stopHeartbeat();
_socket = null;
// 🔥 每次断开都累计一次 flap(连上又断),识别成功-立即断开循环
_reconnectFlapCount++;
debugPrint('📊 [TCP] 连接断开次数: $_reconnectFlapCount/$_maxReconnectFlaps');
if (_reconnectFlapCount >= _maxReconnectFlaps) {
debugPrint('🛑 [TCP] 连续 $_maxReconnectFlaps 次连上后立刻断开,停止重连,通知用户退出登录');
_logger.logWithLevel('🛑 [TCP] 连续 $_maxReconnectFlaps 次连上后立刻断开,停止重连', shouldLog: true);
onReconnectExhausted?.call();
return;
}
// 🔥 连续失败达到4次,停止重连,通知上层弹窗
if (_reconnectFailCount >= 4) {
debugPrint('🛑 [TCP] 重连失败已达 4 次,停止重连,通知用户退出登录');
@@ -400,6 +413,14 @@ class TcpClient {
if (isUserSwitch) return;
if (_reconnectTimer != null) return;
// 🔥 限制总重连次数,防止网络不可达时无限重连
if (_reconnectAttempt >= _maxReconnectAttempts) {
debugPrint('🛑 [TCP] 自动重连已达最大次数 $_maxReconnectAttempts 次,停止重连,通知用户退出登录');
_logger.logWithLevel('🛑 [TCP] 自动重连已达最大次数 $_maxReconnectAttempts 次,停止重连', shouldLog: true);
onReconnectExhausted?.call();
return;
}
// 🔥 重连退避:5s → 10s → 20s → 30s(max)
final delay = [5, 10, 20, 30][_reconnectAttempt.clamp(0, 3)];
_reconnectAttempt++;
@@ -528,8 +549,32 @@ class TcpClient {
_socket!.add(data);
}
void sendRaw(int cmd, List<int> payload) {
if (_socket == null) return;
/// 🔥 断线丢帧日志节流时间戳:控制循环每 200ms 就会撞一次「未连接」,
/// 全量打印会淹掉日志文件,故 5 秒内只记一次(丢帧本身仍如实返回 false)。
DateTime? _lastSendDropLogAt;
/// 把一帧协议数据写入 socket。
///
/// 返回 `false` = 这一帧**根本没交给系统 socket**(未连接,或写入抛异常)。
/// 🔥 原来的 void 版本在 `_socket == null` 时静默 return,调用方完全无从知晓:
/// `stopAllMovement()` 连发的 30 帧停止指令会在断线窗口里一帧都不出网,
/// 而代码以为「已发送」——设备只保留了断开前最后一帧运动指令,正是
/// 「松手了车还在走」的一条隐藏通路。现在必须由调用方判断结果自行兜底。
bool sendRaw(int cmd, List<int> payload) {
if (_socket == null) {
final now = DateTime.now();
if (_lastSendDropLogAt == null ||
now.difference(_lastSendDropLogAt!) >= const Duration(seconds: 5)) {
_lastSendDropLogAt = now;
_logger.logWithLevel(
'🔗 [联调-TCP] sendRaw ❌未连接(_socket=null),本帧丢弃 '
'cmd=0x${cmd.toRadixString(16).padLeft(2, '0')}(同类丢弃 5s 内不再重复打印)',
level: 'ERROR',
shouldLog: true,
);
}
return false;
}
final builder = BytesBuilder()
..addByte(0xAB)
@@ -540,11 +585,33 @@ class TcpClient {
..addByte(0xAA)
..addByte(0xAB);
_socket!.add(builder.takeBytes());
// 🔥 关键修复:强制flush,确保数据立即发送
_socket!.flush();
final packet = builder.takeBytes();
try {
_socket!.add(packet);
// 🔥 关键修复:强制flush,确保数据立即发送
_socket!.flush();
// 联调日志:移到 add+flush 之后打印,证明这帧已真正交给系统 socket 发送缓冲
// ("发出去之后"才有参考价值;仍非后端 ACK,仅代表 App 侧写入成功)(联调结束可移除)
_logger.logWithLevel(
'🔗 [联调-TCP] sendRaw ✅已写入socket cmd=0x${cmd.toRadixString(16).padLeft(2, '0')} '
'len=${packet.length} frameHex=${_hex(packet)}',
shouldLog: true,
);
return true;
} catch (e) {
_logger.logWithLevel(
'🔗 [联调-TCP] sendRaw ❌写入socket失败 cmd=0x${cmd.toRadixString(16).padLeft(2, '0')} err=$e',
level: 'ERROR',
shouldLog: true,
);
return false;
}
}
/// 字节转十六进制字符串,供联调对包使用
String _hex(List<int> bytes) =>
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
void sendHeartbeat() {
if (_socket == null) return;
final builder = BytesBuilder()
@@ -580,6 +647,7 @@ class TcpClient {
/// 重置心跳超时定时器
/// 每次收到服务端心跳(0xFF)时调用,重新开始计时
void _resetHeartbeatTimeout() {
final wasRunning = _heartbeatTimer != null;
_heartbeatTimer?.cancel();
_heartbeatTimer = Timer(_heartbeatTimeout, () {
debugPrint('⚠️ [TCP] 心跳超时 ${_heartbeatTimeout.inSeconds}s 未收到服务端心跳,判定连接断开');
@@ -595,6 +663,12 @@ class TcpClient {
// 🔥 统一走普通断开重连路径(_scheduleReconnect 已包含 HTTP switchDevice)
_handleDisconnect();
});
// 🔥 收到心跳说明连接真正稳定,重置“连上又断”计数
if (wasRunning && _reconnectFlapCount > 0) {
_reconnectFlapCount = 0;
debugPrint('📊 [TCP] 收到心跳,连接稳定,重置连接断开计数');
}
}
/// 🔥 封装完整的TCP初始化方法:连接 + 认证 + 心跳
@@ -784,6 +858,7 @@ class TcpClient {
_connectionCompleter = null;
_reconnectAttempt = 0; // 🔥 重置重连退避
_reconnectFailCount = 0; // 🔥 重置重连失败计数
_reconnectFlapCount = 0; // 🔥 重置连上又断计数
if (_socket != null) {
_socket!.destroy();
_socket = null;

View File

@@ -2,22 +2,27 @@ import 'dart:typed_data';
/// 极简化的 Codec:只负责中间那段变化的“负载数据”
class MachineProtocolCodec {
/// 仅封装 0x00 指令的数据部分 (Byte 3 到 Byte 10,共 8 字节)
/// 仅封装 0x00 指令的数据部分(共 20 字节)
///
/// 协议变更(2026-10):左右轮速 short(int16) 改为线速度/角速度 double:
/// - [0..7] linear float64 小端(线速度 m/s)
/// - [8..15] angular float64 小端(角速度 rad/s)
/// - [16] lift / [17] mower / [18] ignition / [19] emergency(不变)
static Uint8List encodeRemoteControlPayload({
required int left,
required int right,
required double linear,
required double angular,
int lift = 0,
int mower = 0,
int ignition = 0,
int emergency = 0,
}) {
final bd = ByteData(8); // 只分配 8 字节
final bd = ByteData(20); // 8 + 8 + 4 字节
int pos = 0;
bd.setInt16(pos, left, Endian.little);
pos += 2;
bd.setInt16(pos, right, Endian.little);
pos += 2;
bd.setFloat64(pos, linear, Endian.little);
pos += 8;
bd.setFloat64(pos, angular, Endian.little);
pos += 8;
bd.setUint8(pos++, lift);
bd.setUint8(pos++, mower);
bd.setUint8(pos++, ignition);

View File

@@ -14,6 +14,7 @@ import 'package:maibu_satabot_v2/features/v2/workorder/presentation/routes/worko
import '../../features/auth/presentation/bloc/auth_cubit.dart';
import '../../features/auth/presentation/bloc/auth_state.dart';
import '../../features/machine_details/presentation/pages/machine_details_page.dart';
import '../../features/pv_control/presentation/pages/pv_control_page.dart';
import '../../features/main_container/presentation/main_wrapper.dart';
import '../../main.dart'; // 🔥 导入 navigatorKey
import 'go_router_refresh_stream.dart';
@@ -67,6 +68,19 @@ GoRouter createRouter(AuthCubit authCubit) {
},
),
GoRoute(
path: RoutePaths.pvControl,
name: 'pvControl',
builder: (context, state) {
// extra: {'deviceId': String, 'deviceName': String?}
final extra = state.extra as Map<String, dynamic>? ?? {};
return PvControlPage(
deviceId: (extra['deviceId'] as String?) ?? '',
deviceName: extra['deviceName'] as String?,
);
},
),
GoRoute(
path: RoutePaths.home,
builder: (context, state) => const MainWrapper(),

View File

@@ -24,6 +24,9 @@ class RoutePaths {
static const machineDetails = '/machine_details/machine_details';
/// 光伏电站机器人远程控制(横屏大屏)页面
static const pvControl = '/pv_control';
/// 工单相关页面
static const workOrder = '/workorder';
static const workOrderDetail = '/workorder/detail/:orderId';

View File

@@ -1,7 +1,11 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter/services.dart';
import 'package:logger/logger.dart';
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
import 'package:maibu_satabot_v2/core/app/app_user_state.dart';
import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart';
import 'package:open_file/open_file.dart';
import 'package:path_provider/path_provider.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -11,19 +15,52 @@ import 'update_state.dart';
/// 更新 Cubit
class UpdateCubit extends Cubit<UpdateState> {
final VersionCheckService _versionService;
final Logger _logger;
final AppUserCubit _users;
final Logger _logger = Logger();
late final StreamSubscription<AppUserState> _adminChanges;
UserEntity? _updateOwner;
UpdateCubit(this._versionService)
: _logger = Logger(),
super(UpdateInitial());
UpdateCubit(this._versionService, this._users) : super(const UpdateInitial()) {
_adminChanges = _users.stream.listen((_) {
final owner = _updateOwner;
if (owner != null && !_canContinue(owner)) {
_updateOwner = null;
emit(const UpdateInitial());
}
});
}
UserEntity? get _adminUser {
final user = _users.state.user;
return user?.isAdmin == true ? user : null;
}
UserEntity? _beginUpdate() {
if (isClosed) return null;
final admin = _adminUser;
if (admin != null) _updateOwner = admin;
return admin;
}
bool _canContinue(UserEntity admin) =>
!isClosed && identical(_updateOwner, admin) && identical(_adminUser, admin);
@override
Future<void> close() async {
await _adminChanges.cancel();
await super.close();
}
/// 检查版本更新
Future<void> checkUpdate() async {
emit(UpdateChecking());
final admin = _beginUpdate();
if (admin == null) return;
emit(const UpdateChecking());
try {
// 从 pubspec.yaml 读取版本信息
final String versionString = await rootBundle.loadString('pubspec.yaml');
if (!_canContinue(admin)) return;
final versionMatch = RegExp(r'version:\s*(\d+\.\d+\.\d+)\+(\d+)').firstMatch(versionString);
final currentVersion = versionMatch?.group(1) ?? '1.0.0';
@@ -37,6 +74,7 @@ class UpdateCubit extends Cubit<UpdateState> {
currentVersionCode: currentVersionCode,
);
if (!_canContinue(admin)) return;
if (versionInfo == null) {
_logger.i('✅ 已是最新版本');
emit(UpdateUpToDate());
@@ -49,6 +87,7 @@ class UpdateCubit extends Cubit<UpdateState> {
emit(UpdateAvailable(versionInfo));
} catch (e) {
if (!_canContinue(admin)) return;
_logger.e('❌ 检查更新失败: $e');
emit(UpdateFailure('检查更新失败: $e'));
}
@@ -56,6 +95,8 @@ class UpdateCubit extends Cubit<UpdateState> {
/// 应用差量补丁
Future<void> applyPatch(String patchUrl, String version, int targetVersionCode, {String? md5}) async {
final admin = _beginUpdate();
if (admin == null) return;
emit(const UpdateDownloading(0.0, isPatch: true));
try {
@@ -67,11 +108,13 @@ class UpdateCubit extends Cubit<UpdateState> {
targetVersionCode: targetVersionCode,
md5: md5,
onProgress: (progress) {
// 🔥 实时更新下载进度
emit(UpdateDownloading(progress, isPatch: true));
if (_canContinue(admin)) {
emit(UpdateDownloading(progress, isPatch: true));
}
},
);
if (!_canContinue(admin)) return;
if (success) {
emit(const UpdateInstalling(isPatch: true));
emit(UpdateSuccess());
@@ -79,6 +122,7 @@ class UpdateCubit extends Cubit<UpdateState> {
emit(const UpdateFailure('补丁应用失败'));
}
} catch (e) {
if (!_canContinue(admin)) return;
_logger.e('❌ 应用补丁异常: $e');
emit(UpdateFailure('应用补丁失败: $e'));
}
@@ -86,15 +130,20 @@ class UpdateCubit extends Cubit<UpdateState> {
/// 自动下载并安装 APK(App 内下载)
Future<void> downloadAndInstallApk(String apkUrl) async {
final admin = _beginUpdate();
if (admin == null) return;
emit(const UpdateDownloading(0.0, isPatch: false));
try {
_logger.i('⬇️ 开始下载完整 APK');
final apkPath = await _versionService.downloadApk(apkUrl, (progress) {
emit(UpdateDownloading(progress, isPatch: false));
if (_canContinue(admin)) {
emit(UpdateDownloading(progress, isPatch: false));
}
});
if (!_canContinue(admin)) return;
if (apkPath != null) {
_logger.i('✅ APK 下载完成: $apkPath');
// 🔥 关键:先清理版本号记录,并等待用户看到提示
@@ -105,6 +154,7 @@ class UpdateCubit extends Cubit<UpdateState> {
// 🔥 短暂延迟,让用户看到清理完成的提示
await Future.delayed(const Duration(milliseconds: 800));
if (!_canContinue(admin)) return;
// 🔥 直接调起系统安装页面,不再等待用户点击
_logger.i('📦 自动调起系统安装页面');
@@ -113,6 +163,7 @@ class UpdateCubit extends Cubit<UpdateState> {
emit(const UpdateFailure('APK 下载失败'));
}
} catch (e) {
if (!_canContinue(admin)) return;
_logger.e('❌ 下载安装 APK 异常: $e');
emit(UpdateFailure('下载安装 APK 失败: $e'));
}
@@ -120,6 +171,8 @@ class UpdateCubit extends Cubit<UpdateState> {
/// 触发系统安装页面
Future<void> triggerInstall(String apkPath) async {
final admin = _beginUpdate();
if (admin == null) return;
emit(const UpdateInstalling(isPatch: false));
try {
@@ -129,6 +182,7 @@ class UpdateCubit extends Cubit<UpdateState> {
// 🔥 验证文件是否存在
final file = File(apkPath);
if (!await file.exists()) {
if (!_canContinue(admin)) return;
_logger.e('❌ APK 文件不存在: $apkPath');
emit(UpdateFailure('APK 文件不存在,请重新下载'));
return;
@@ -139,6 +193,7 @@ class UpdateCubit extends Cubit<UpdateState> {
// 🔥 关键修复:将APK复制到外部存储,避免某些Android版本的安装限制
final externalDir = await getExternalStorageDirectory();
if (!_canContinue(admin)) return;
String finalApkPath = apkPath;
if (externalDir != null) {
@@ -157,7 +212,9 @@ class UpdateCubit extends Cubit<UpdateState> {
// 打开 APK 文件,触发系统安装界面
_logger.i('🚀 调用 OpenFile.open...');
_logger.i('🚀 最终APK路径: $finalApkPath');
if (!_canContinue(admin)) return;
final result = await OpenFile.open(finalApkPath);
if (!_canContinue(admin)) return;
_logger.i('📦 安装结果: ${result.message} (type: ${result.type})');
// 如果用户取消安装,显示提示信息并保留文件路径
@@ -180,6 +237,7 @@ class UpdateCubit extends Cubit<UpdateState> {
emit(UpdateSuccess());
}
} catch (e) {
if (!_canContinue(admin)) return;
_logger.e('❌ 调起安装页面异常: $e');
emit(UpdateFailure('调起安装页面失败: $e'));
}
@@ -187,24 +245,29 @@ class UpdateCubit extends Cubit<UpdateState> {
/// 整包更新:浏览器下载 + 引导弹窗
Future<void> fullApkUpdateWithBrowser(String apkUrl) async {
final admin = _beginUpdate();
if (admin == null) return;
try {
_logger.i('🌐 整包更新:打开浏览器下载');
final uri = Uri.parse(apkUrl);
if (await canLaunchUrl(uri)) {
final canLaunch = await canLaunchUrl(uri);
if (!_canContinue(admin)) return;
if (canLaunch) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
_logger.i('✅ 已打开浏览器下载');
// 🔥 整包更新后,立即清除补丁版本记录(避免死循环)
await _versionService.clearPatchVersionInfo();
_logger.i('🗑️ 已清除补丁版本记录');
emit(UpdateSuccess());
if (_canContinue(admin)) emit(const UpdateSuccess());
} else {
_logger.e('❌ 无法打开浏览器');
emit(const UpdateFailure('无法打开浏览器'));
}
} catch (e) {
if (!_canContinue(admin)) return;
_logger.e('❌ 打开浏览器异常: $e');
emit(UpdateFailure('打开浏览器失败: $e'));
}
@@ -212,6 +275,7 @@ class UpdateCubit extends Cubit<UpdateState> {
/// 取消更新
void cancelUpdate() {
_updateOwner = null;
_logger.i('❌ 用户取消更新');
emit(UpdateInitial());
}

View File

@@ -13,6 +13,7 @@ import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart';
import 'package:maibu_satabot_v2/core/network/net_message_dispatcher.dart';
import '../../../../core/app/app_user_cubit.dart';
import '../../../../core/localization/app_localizations.dart';
import '../../../../core/infrastructure/logging/sentry_logger_impl.dart';
import '../../../../core/logging/i_logger_service.dart';
import '../../../../core/network/tcp/tcp_client.dart';
@@ -265,15 +266,16 @@ class AuthCubit extends Cubit<AuthState> {
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
final loc = AppLocalizations.of(dialogContext);
return AlertDialog(
title: const Text('登录已过期'),
content: const Text('账号登录状态已过期,请重新登录'),
title: Text(loc.translate('login.token_expired_title')),
content: Text(loc.translate('login.token_expired_content')),
actions: [
ElevatedButton(
onPressed: () {
Navigator.of(dialogContext).pop();
},
child: const Text('确定'),
child: Text(loc.translate('common.ok')),
),
],
);
@@ -317,9 +319,10 @@ class AuthCubit extends Cubit<AuthState> {
context: context,
barrierDismissible: false, // 不允许点击背景关闭
builder: (BuildContext dialogContext) {
final loc = AppLocalizations.of(dialogContext);
return AlertDialog(
title: const Text('账号被顶下线'),
content: const Text('您的账号在其他设备登录,即将退出当前设备...'),
title: Text(loc.translate('login.kicked_out_title')),
content: Text(loc.translate('login.kicked_out_content')),
actions: [],
);
},
@@ -358,6 +361,7 @@ class AuthCubit extends Cubit<AuthState> {
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
final loc = AppLocalizations.of(dialogContext);
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
content: Column(
@@ -365,18 +369,18 @@ class AuthCubit extends Cubit<AuthState> {
children: [
const Icon(Icons.wifi_off_rounded, size: 48, color: Color(0xFFF53F3F)),
const SizedBox(height: 12),
const Text(
'连接异常',
style: TextStyle(
Text(
loc.translate('login.reconnect_failed_title'),
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: Color(0xFF1D2129),
),
),
const SizedBox(height: 8),
const Text(
'TCP重连认证无效请重新登陆',
style: TextStyle(fontSize: 14, color: Color(0xFF86909C)),
Text(
loc.translate('login.reconnect_failed_content'),
style: const TextStyle(fontSize: 14, color: Color(0xFF86909C)),
textAlign: TextAlign.center,
),
],
@@ -395,7 +399,7 @@ class AuthCubit extends Cubit<AuthState> {
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('取消', style: TextStyle(fontSize: 15)),
child: Text(loc.translate('common.cancel'), style: const TextStyle(fontSize: 15)),
),
),
const SizedBox(width: 12),
@@ -414,7 +418,7 @@ class AuthCubit extends Cubit<AuthState> {
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('退出登录', style: TextStyle(fontSize: 15)),
child: Text(loc.translate('login.logout'), style: const TextStyle(fontSize: 15)),
),
),
],
@@ -439,10 +443,10 @@ class AuthCubit extends Cubit<AuthState> {
final context = navigatorKey.currentContext;
if (context != null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('TCP重连中...'),
backgroundColor: Color(0xFFFF7D00),
duration: Duration(seconds: 4),
SnackBar(
content: Text(AppLocalizations.of(context).translate('login.reconnecting')),
backgroundColor: const Color(0xFFFF7D00),
duration: const Duration(seconds: 4),
),
);
}
@@ -456,10 +460,10 @@ class AuthCubit extends Cubit<AuthState> {
if (context != null) {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('连接成功'),
backgroundColor: Color(0xFF00B42A),
duration: Duration(seconds: 2),
SnackBar(
content: Text(AppLocalizations.of(context).translate('login.reconnect_success')),
backgroundColor: const Color(0xFF00B42A),
duration: const Duration(seconds: 2),
),
);
}

View File

@@ -4,6 +4,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
import 'package:maibu_satabot_v2/core/di/injection.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import 'package:maibu_satabot_v2/components/language_selection_dialog.dart';
import 'package:maibu_satabot_v2/core/theme/AppTheme.dart';
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_cubit.dart';
@@ -37,12 +39,24 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
// 等首帧渲染 + 图片解码完成后再播放,避开启动初始化争抢导致的掉帧
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_maybeShowLanguageDialog();
Future.delayed(const Duration(milliseconds: 120), () {
if (mounted) _entranceController.forward();
});
});
}
/// 进入登录页:每次都弹出语言选择框,由用户当场决定。
/// 点某个语言 → 立即切换;点“继续” → 保持当前(默认)语言关闭。
/// 无论哪种,LocaleCubit.setLocale 触发 MaterialApp 重建,登录页立即显示对应语言。
void _maybeShowLanguageDialog() {
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const LanguageSelectionDialog(mandatory: true),
);
}
@override
void dispose() {
_entranceController.dispose();
@@ -74,13 +88,19 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
debugPrint('✅ [LOGIN] 已调用 context.go(RoutePaths.home)');
} else if (state is LoginFailure) {
// 可以在这里弹出简洁的黑白提示框
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('登录失败,${state.message}')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${AppLocalizations.of(context).translate('login.login_failed')},${state.message}')));
}
},
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
// 全面屏下底部要让开系统手势条,否则注册链接会贴到手势条上
padding: EdgeInsets.fromLTRB(
20.0,
0,
20.0,
MediaQuery.of(context).padding.bottom,
),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
@@ -109,7 +129,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: Text(
"通过华为云生态技术认证,请放心使用,证书编号C202605301",
AppLocalizations.of(context).translate('login.cert_text'),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10,
@@ -132,11 +152,11 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"账号登录",
AppLocalizations.of(context).translate('login.account_login_title'),
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: context.appColors.textPrimary),
),
const SizedBox(height: 8),
Text("请填写以下信息以验证身份", style: TextStyle(fontSize: 15, color: context.appColors.textTertiary)),
Text(AppLocalizations.of(context).translate('login.subtitle'), style: TextStyle(fontSize: 15, color: context.appColors.textTertiary)),
],
),
),
@@ -150,12 +170,12 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInputLabel("账号"),
_buildInputLabel(AppLocalizations.of(context).translate('login.username')),
TextField(
controller: _userCtrl,
style: TextStyle(fontSize: 14, color: context.appColors.textPrimary),
cursorColor: context.appColors.textPrimary,
decoration: _inputDecoration(hint: "请输入用户名"),
decoration: _inputDecoration(hint: AppLocalizations.of(context).translate('login.username_hint')),
),
],
),
@@ -169,14 +189,14 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInputLabel("密码"),
_buildInputLabel(AppLocalizations.of(context).translate('login.password')),
TextField(
controller: _pwdCtrl,
obscureText: _obscurePwd,
style: TextStyle(fontSize: 14, color: context.appColors.textPrimary),
cursorColor: context.appColors.textPrimary,
decoration: _inputDecoration(
hint: "请输入密码",
hint: AppLocalizations.of(context).translate('login.password_hint'),
suffixIcon: IconButton(
icon: Icon(_obscurePwd ? Icons.visibility_off_outlined : Icons.visibility_outlined, color: context.appColors.textTertiary, size: 20),
onPressed: () => setState(() => _obscurePwd = !_obscurePwd),
@@ -212,16 +232,18 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
: () {
if (!_isAgreed) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请先阅读并同意用户协议'),
duration: Duration(seconds: 1),
SnackBar(
content: Text(AppLocalizations.of(context).translate('login.agree_first')),
duration: const Duration(seconds: 1),
),
);
return;
}
_handleLogin();
},
text: isLoading ? '登录中...' : '登 录',
text: isLoading
? AppLocalizations.of(context).translate('login.logging_in')
: AppLocalizations.of(context).translate('login.login_submit'),
);
},
),
@@ -237,7 +259,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"没有账号吗?",
AppLocalizations.of(context).translate('login.no_account'),
style: TextStyle(fontSize: 14, color: context.appColors.textTertiary),
),
InkWell(
@@ -250,7 +272,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text(
"立即注册",
AppLocalizations.of(context).translate('login.register_now'),
style: TextStyle(
fontSize: 14,
color: context.appColors.textPrimary,
@@ -335,10 +357,10 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
Expanded(
child: Wrap(
children: [
Text("我已阅读并同意 ", style: TextStyle(fontSize: 13, color: context.appColors.textTertiary)),
_protocolText("《用户协议》", () => _showProtocolDetail("用户协议")),
Text(" 和 ", style: TextStyle(fontSize: 13, color: context.appColors.textTertiary)),
_protocolText("《隐私政策》", () => _showProtocolDetail("隐私政策")),
Text(AppLocalizations.of(context).translate('login.agree_prefix'), style: TextStyle(fontSize: 13, color: context.appColors.textTertiary)),
_protocolText(AppLocalizations.of(context).translate('login.user_agreement_link'), () => _showProtocolDetail(AppLocalizations.of(context).translate('login.user_agreement_link'))),
Text(AppLocalizations.of(context).translate('login.agree_and'), style: TextStyle(fontSize: 13, color: context.appColors.textTertiary)),
_protocolText(AppLocalizations.of(context).translate('login.privacy_policy_link'), () => _showProtocolDetail(AppLocalizations.of(context).translate('login.privacy_policy_link'))),
],
),
),
@@ -371,7 +393,10 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
Divider(height: 32, color: context.appColors.divider),
Expanded(
child: SingleChildScrollView(
child: Text("此处放置您的${title}详细内容...\n" * 20, style: TextStyle(color: context.appColors.textSecondary, height: 1.5)),
child: Text(
(AppLocalizations.of(context).translate('login.protocol_detail_text').replaceAll('%s', title) + '\n') * 20,
style: TextStyle(color: context.appColors.textSecondary, height: 1.5),
),
),
),
const SizedBox(height: 16),
@@ -379,7 +404,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
width: double.infinity,
child: BlocBuilder<LoginCubit, LoginState>(
builder: (context, state) {
return CCPrimaryButton(onPressed: () => Navigator.pop(context), text: "我已了解");
return CCPrimaryButton(onPressed: () => Navigator.pop(context), text: AppLocalizations.of(context).translate('login.understood'));
},
),
),

View File

@@ -0,0 +1,691 @@
import 'package:flutter/material.dart';
import 'package:maibu_satabot_v2/components/legal_document.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:maibu_satabot_v2/components/app_logo.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import 'package:maibu_satabot_v2/core/localization/locale_cubit.dart';
import 'package:maibu_satabot_v2/components/language_selection_dialog.dart';
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
import '../../../../core/router/route_paths.dart';
import '../bloc/login_cubit.dart';
import '../bloc/login_state.dart';
/// 登录页 V2 —— 按最新设计稿实现(账号 + 密码登录)。
///
/// 说明:
/// - 视觉:背景纵向浅蓝渐变、主按钮横向蓝色渐变胶囊、顶部 logo + 语言胶囊、
/// 中部机器人 Hero 卡片、白色登录表单卡片。
/// - 逻辑:完全复用现有 [LoginCubit](账号密码登录,sourceType=2),未改动数据层。
/// - 上架版暂时隐藏注册入口。
/// - 旧登录页 login_page.dart 原样保留,路由默认指向本页。
class LoginPageV2 extends StatefulWidget {
const LoginPageV2({super.key});
@override
State<LoginPageV2> createState() => _LoginPageV2State();
}
class _LoginPageV2State extends State<LoginPageV2>
with SingleTickerProviderStateMixin {
final _userCtrl = TextEditingController();
final _pwdCtrl = TextEditingController();
bool _isAgreed = false; // 协议勾选状态
bool _obscurePwd = true; // 密码可见性
int _selectedTab = 0; // 0=登录, 1=注册(注册仅提示,不切换内容)
// 进场动画控制器
late final AnimationController _entranceController;
// ===== 设计稿配色 =====
static const _bgTop = Color(0xFFFAFCFF);
static const _bgMid = Color(0xFFF0F6FE);
// 底部蓝比设计稿基准色 #EDF6FE 加深:原稿中/底两色几乎无差,渐变看不出层次
static const _bgBottom = Color(0xFFD6E9FB);
static const _navy = Color(0xFF1A2B4A); // 主标题/标签深色
static const _grayText = Color(0xFF86909C); // 次要文字
static const _primaryBlue = Color(0xFF1C96F6);
static const _inputBorder = Color(0xFFE5EDF7);
static const _tabBg = Color(0xFFEEF3FA);
static const _tabUnselectedText = Color(0xFF4E5969);
@override
void initState() {
super.initState();
_entranceController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_maybeShowLanguageDialog();
Future.delayed(const Duration(milliseconds: 120), () {
if (mounted) _entranceController.forward();
});
});
}
/// 进入登录页:弹出语言选择框,由用户当场决定(与旧页保持一致的强制选择体验)。
void _maybeShowLanguageDialog() {
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const LanguageSelectionDialog(mandatory: true),
);
}
@override
void dispose() {
_entranceController.dispose();
_userCtrl.dispose();
_pwdCtrl.dispose();
super.dispose();
}
Animation<double> _interval(double begin, double end) {
return CurvedAnimation(
parent: _entranceController,
curve: Interval(begin, end, curve: Curves.easeOut),
);
}
// 执行登录逻辑(复用现有 LoginCubit:账号 + 密码,sourceType=2)
void _handleLogin() {
context.read<LoginCubit>().login(_userCtrl.text, _pwdCtrl.text, 2);
}
void _onTabTap(int index) {
if (index == 1) {
// 注册暂不支持:仅提示,不切换内容
CapsuleToast.show('注册功能暂未开放', showCheck: false);
return;
}
setState(() => _selectedTab = 0);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold(
backgroundColor: _bgTop,
body: DecoratedBox(
// 底色:纵向浅蓝渐变
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [_bgTop, _bgMid, _bgBottom],
stops: [0.0, 0.4, 1.0],
),
),
child: DecoratedBox(
// 左上角蓝色晕(对应设计稿 logo 一侧的淡蓝)
decoration: const BoxDecoration(
gradient: RadialGradient(
center: Alignment(-1.1, -1.0),
radius: 1.1,
colors: [Color(0xFFD6E8FC), Color(0x00D6E8FC)],
),
),
child: DecoratedBox(
// 右上角蓝色晕(对应设计稿顶部右侧的淡蓝)
decoration: const BoxDecoration(
gradient: RadialGradient(
center: Alignment(1.1, -0.9),
radius: 1.25,
colors: [Color(0xFFC7DFF8), Color(0x00C7DFF8)],
),
),
child: SafeArea(
bottom: false,
child: BlocListener<LoginCubit, LoginState>(
listener: (context, state) {
if (state is LoginSuccess) {
context.go(RoutePaths.home);
} else if (state is LoginFailure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${AppLocalizations.of(context).translate('login.login_failed')},${state.message}',
),
),
);
}
},
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ConstrainedBox(
constraints:
BoxConstraints(minHeight: constraints.maxHeight),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
// 顶部:logo + 语言胶囊
_Entrance(
animation: _interval(0.00, 0.35),
child: _buildTopBar(),
),
const SizedBox(height: 12),
// 欢迎标题 + 副标题
_Entrance(
animation: _interval(0.08, 0.43),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.translate('login.welcome_title'),
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: _navy,
height: 1.2,
),
),
const SizedBox(height: 4),
Text(
l10n.translate('login.welcome_subtitle'),
style: const TextStyle(
fontSize: 13,
color: _grayText,
),
),
],
),
),
const SizedBox(height: 12),
// 机器人 Hero 卡片
_Entrance(
animation: _interval(0.16, 0.51),
child: _buildHeroCard(),
),
const SizedBox(height: 12),
// 白色表单卡片
_Entrance(
animation: _interval(0.26, 0.63),
child: _buildFormCard(),
),
// 暂时隐藏登录页的帮助与客服入口。
// const SizedBox(height: 12),
// _Entrance(
// animation: _interval(0.40, 0.75),
// child: Center(child: _buildHelpRow()),
// ),
const SizedBox(height: 16),
],
),
),
);
},
),
),
),
),
),
),
);
}
// ===== 顶部栏:logo(左)+ 语言胶囊(右)=====
// 注意:本页 LayoutBuilder 的 builder 在布局回调阶段执行,
// 不能在其子树里直接调用 context.select/watch(Provider 会断言失败)。
// 因此用 BlocBuilder 包裹 LocaleCubit 的读取,让它在正常 build 阶段取值。
Widget _buildTopBar() {
return BlocBuilder<LocaleCubit, Locale>(
builder: (context, locale) {
final langName = AppLanguages.instance.nameOf(locale);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const AppLogo(size: 40),
GestureDetector(
onTap: () {
showDialog(
context: context,
builder: (_) =>
const LanguageSelectionDialog(mandatory: false),
);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(100),
border: Border.all(color: _inputBorder),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.language, size: 16, color: _primaryBlue),
const SizedBox(width: 6),
Text(
langName,
style: const TextStyle(fontSize: 13, color: _navy),
),
const SizedBox(width: 4),
const Icon(Icons.expand_more, size: 16, color: _grayText),
],
),
),
),
],
);
},
);
}
// ===== 机器人 Hero 卡片 =====
Widget _buildHeroCard() {
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: SizedBox(
height: 130,
width: double.infinity,
child: Stack(
fit: StackFit.expand,
children: [
Image.asset(
'assets/images/img.png',
fit: BoxFit.cover,
),
// 底部渐变遮罩,衬托文字
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.center,
colors: [
Colors.black.withValues(alpha: 0.35),
Colors.transparent,
],
),
),
),
Positioned(
left: 12,
bottom: 8,
child: Text(
AppLocalizations.of(context).translate('login.hero_caption'),
style: const TextStyle(
color: Colors.white,
fontSize: 11,
letterSpacing: 1.5,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
);
}
// ===== 白色表单卡片 =====
Widget _buildFormCard() {
final l10n = AppLocalizations.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: const Color(0xFF1C96F6).withValues(alpha: 0.06),
blurRadius: 20,
offset: const Offset(0, 6),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSegmentTab(),
const SizedBox(height: 14),
// 账号
_buildFieldLabel(l10n.translate('login.username')),
const SizedBox(height: 6),
_buildTextField(
controller: _userCtrl,
icon: Icons.person_outline,
hint: l10n.translate('login.username_hint'),
),
const SizedBox(height: 12),
// 密码
_buildFieldLabel(l10n.translate('login.password')),
const SizedBox(height: 6),
_buildTextField(
controller: _pwdCtrl,
icon: Icons.lock_outline,
hint: l10n.translate('login.password_hint'),
obscureText: _obscurePwd,
suffix: IconButton(
icon: Icon(
_obscurePwd
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
size: 20,
color: _grayText,
),
onPressed: () => setState(() => _obscurePwd = !_obscurePwd),
),
),
const SizedBox(height: 12),
_buildProtocolSection(),
// 注册入口隐藏时同时隐藏注册专用协议提示。
/*
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.only(left: 32),
child: Text(
AppLocalizations.of(context).translate('register.agree_hint'),
style: const TextStyle(fontSize: 12, color: _grayText),
),
),
*/
const SizedBox(height: 14),
// 登录按钮
BlocBuilder<LoginCubit, LoginState>(
builder: (context, state) {
final isLoading = state is LoginLoading;
return _GradientButton(
isLoading: isLoading,
onPressed: () {
if (!_isAgreed) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)
.translate('login.agree_first')),
duration: const Duration(seconds: 1),
),
);
return;
}
_handleLogin();
},
);
},
),
],
),
);
}
// ===== 登录 / 注册 分段 Tab =====
Widget _buildSegmentTab() {
return Container(
height: 42,
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: _tabBg,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: _buildTabItem(
0,
AppLocalizations.of(context).translate('login.title'),
),
),
// Expanded(child: _buildTabItem(1, '注册')),
],
),
);
}
Widget _buildTabItem(int index, String label) {
final selected = _selectedTab == index;
return GestureDetector(
onTap: () => _onTabTap(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: selected
? const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0xFF2BAFFE), Color(0xFF0B7DEE)],
)
: null,
borderRadius: BorderRadius.circular(9),
),
child: Text(
label,
style: TextStyle(
fontSize: 15,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
color: selected ? Colors.white : _tabUnselectedText,
),
),
),
);
}
Widget _buildFieldLabel(String text) {
return Text(
text,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _navy,
),
);
}
Widget _buildTextField({
required TextEditingController controller,
required IconData icon,
required String hint,
bool obscureText = false,
Widget? suffix,
}) {
return TextField(
controller: controller,
obscureText: obscureText,
style: const TextStyle(fontSize: 14, color: _navy),
cursorColor: _primaryBlue,
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(color: _grayText, fontSize: 14),
filled: true,
fillColor: Colors.white,
prefixIcon: Icon(icon, size: 20, color: _primaryBlue),
prefixIconConstraints:
const BoxConstraints(minWidth: 44, minHeight: 48),
suffixIcon: suffix,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: _inputBorder),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: _primaryBlue, width: 1.2),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: _inputBorder),
),
),
);
}
// ===== 协议勾选 =====
Widget _buildProtocolSection() {
final loc = AppLocalizations.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 24,
width: 24,
child: Checkbox(
value: _isAgreed,
activeColor: _primaryBlue,
checkColor: Colors.white,
side: const BorderSide(color: _inputBorder),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onChanged: (val) => setState(() => _isAgreed = val!),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
loc.translate('login.agree_prefix'),
style: const TextStyle(fontSize: 13, color: _grayText),
),
_protocolText(loc.translate('login.user_agreement_link'),
() => openLegalDocument(context, LegalDocument.terms)),
Text(
loc.translate('login.agree_and'),
style: const TextStyle(fontSize: 13, color: _grayText),
),
_protocolText(loc.translate('login.privacy_policy_link'),
() => openLegalDocument(context, LegalDocument.privacy)),
],
),
),
],
);
}
Widget _protocolText(String text, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Text(
text,
style: const TextStyle(
fontSize: 13,
color: _primaryBlue,
fontWeight: FontWeight.w600,
),
),
);
}
/*
Widget _buildHelpRow() {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.help_outline, size: 16, color: _grayText),
const SizedBox(width: 4),
GestureDetector(
onTap: () => CapsuleToast.show('帮助与客服功能开发中', showCheck: false),
child: const Text(
'帮助与客服',
style: TextStyle(fontSize: 13, color: _grayText),
),
),
],
);
}
*/
}
/// 主按钮:横向蓝色渐变胶囊(#2BAFFE → #1C96F6 → #0B7DEE),带前置箭头。
class _GradientButton extends StatelessWidget {
const _GradientButton({required this.onPressed, required this.isLoading});
final VoidCallback onPressed;
final bool isLoading;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final enabled = !isLoading;
return Opacity(
opacity: enabled ? 1 : 0.7,
child: GestureDetector(
onTap: enabled ? onPressed : null,
child: Container(
width: double.infinity,
height: 46,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
gradient: const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0xFF2BAFFE), Color(0xFF1C96F6), Color(0xFF0B7DEE)],
stops: [0.0, 0.5, 1.0],
),
boxShadow: [
BoxShadow(
color: const Color(0xFF1C96F6).withValues(alpha: 0.30),
blurRadius: 14,
offset: const Offset(0, 6),
),
],
),
child: isLoading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.4,
color: Colors.white,
),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.arrow_forward_rounded,
size: 20,
color: Colors.white,
),
const SizedBox(width: 8),
Text(
l10n.translate('login.login_button'),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
letterSpacing: 1,
),
),
],
),
),
),
);
}
}
/// 进场动画包装:淡入 + 上滑。
class _Entrance extends StatelessWidget {
const _Entrance({required this.animation, required this.child});
final Animation<double> animation;
final Widget child;
@override
Widget build(BuildContext context) {
final curved =
CurvedAnimation(parent: animation, curve: Curves.easeOutCubic);
final slideAnim = Tween<Offset>(
begin: const Offset(0, 0.12),
end: Offset.zero,
).animate(curved);
return FadeTransition(
opacity: animation,
child: SlideTransition(position: slideAnim, child: child),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:cc_ui_kit/cc_ui_kit.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import 'package:maibu_satabot_v2/core/router/route_paths.dart';
import 'package:maibu_satabot_v2/core/theme/AppTheme.dart';
@@ -29,6 +30,7 @@ class _RegisterPageState extends State<RegisterPage> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold(
backgroundColor: context.appColors.pageBackground,
appBar: AppBar(
@@ -46,7 +48,7 @@ class _RegisterPageState extends State<RegisterPage> {
children: [
const SizedBox(height: 40),
Text(
"账号注册",
l10n.translate('register.title'),
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
@@ -55,29 +57,31 @@ class _RegisterPageState extends State<RegisterPage> {
),
const SizedBox(height: 12),
Text(
"请填写以下信息完成注册",
l10n.translate('register.subtitle'),
style: TextStyle(fontSize: 15, color: context.appColors.textTertiary),
),
const SizedBox(height: 40),
_buildInputLabel("用户名"),
_buildInputLabel(l10n.translate('register.username')),
TextField(
controller: _userCtrl,
style: TextStyle(fontSize: 14, color: context.appColors.textPrimary),
cursorColor: context.appColors.textPrimary,
decoration: _inputDecoration(hint: "请设置用户名"),
decoration: _inputDecoration(
hint: l10n.translate('register.username_hint'),
),
),
const SizedBox(height: 24),
_buildInputLabel("密码"),
_buildInputLabel(l10n.translate('register.password')),
TextField(
controller: _pwdCtrl,
obscureText: _obscurePwd,
style: TextStyle(fontSize: 14, color: context.appColors.textPrimary),
cursorColor: context.appColors.textPrimary,
decoration: _inputDecoration(
hint: "请设置密码",
hint: l10n.translate('register.password_hint'),
suffixIcon: IconButton(
icon: Icon(
_obscurePwd
@@ -93,14 +97,14 @@ class _RegisterPageState extends State<RegisterPage> {
const SizedBox(height: 24),
_buildInputLabel("确认密码"),
_buildInputLabel(l10n.translate('register.confirm_password')),
TextField(
controller: _confirmPwdCtrl,
obscureText: _obscureConfirmPwd,
style: TextStyle(fontSize: 14, color: context.appColors.textPrimary),
cursorColor: context.appColors.textPrimary,
decoration: _inputDecoration(
hint: "请再次输入密码",
hint: l10n.translate('register.confirm_password_hint'),
suffixIcon: IconButton(
icon: Icon(
_obscureConfirmPwd
@@ -126,7 +130,7 @@ class _RegisterPageState extends State<RegisterPage> {
width: double.infinity,
child: CCPrimaryButton(
onPressed: _isAgreed ? _handleRegister : null,
text: '注 册',
text: l10n.translate('register.submit'),
),
),
@@ -137,13 +141,13 @@ class _RegisterPageState extends State<RegisterPage> {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"已有账号?",
l10n.translate('register.has_account'),
style: TextStyle(fontSize: 14, color: context.appColors.textTertiary),
),
GestureDetector(
onTap: () => context.go(RoutePaths.login),
child: Text(
"立即登录",
l10n.translate('register.login_now'),
style: TextStyle(
fontSize: 14,
color: context.appColors.textPrimary,
@@ -163,22 +167,23 @@ class _RegisterPageState extends State<RegisterPage> {
}
void _handleRegister() {
final l10n = AppLocalizations.of(context);
if (_userCtrl.text.isEmpty || _pwdCtrl.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请填写完整信息')),
SnackBar(content: Text(l10n.translate('register.incomplete'))),
);
return;
}
if (_pwdCtrl.text != _confirmPwdCtrl.text) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('两次输入的密码不一致')),
SnackBar(content: Text(l10n.translate('register.password_mismatch'))),
);
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('注册功能开发中...')),
SnackBar(content: Text(l10n.translate('register.developing'))),
);
}
@@ -216,6 +221,7 @@ class _RegisterPageState extends State<RegisterPage> {
}
Widget _buildProtocolSection() {
final l10n = AppLocalizations.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@@ -238,12 +244,21 @@ class _RegisterPageState extends State<RegisterPage> {
child: Wrap(
children: [
Text(
"我已阅读并同意 ",
l10n.translate('login.agree_prefix'),
style: TextStyle(fontSize: 13, color: context.appColors.textTertiary),
),
_protocolText("《用户协议》", () => _showProtocolDetail("用户协议")),
Text(" 和 ", style: TextStyle(fontSize: 13, color: context.appColors.textTertiary)),
_protocolText("《隐私政策》", () => _showProtocolDetail("隐私政策")),
_protocolText(
l10n.translate('login.user_agreement_link'),
() => _showProtocolDetail(l10n.translate('my.user_agreement')),
),
Text(
l10n.translate('login.agree_and'),
style: TextStyle(fontSize: 13, color: context.appColors.textTertiary),
),
_protocolText(
l10n.translate('login.privacy_policy_link'),
() => _showProtocolDetail(l10n.translate('my.privacy_policy')),
),
],
),
),
@@ -266,6 +281,7 @@ class _RegisterPageState extends State<RegisterPage> {
}
void _showProtocolDetail(String title) {
final l10n = AppLocalizations.of(context);
showModalBottomSheet(
context: context,
isScrollControlled: true,
@@ -286,7 +302,8 @@ class _RegisterPageState extends State<RegisterPage> {
Expanded(
child: SingleChildScrollView(
child: Text(
"此处放置您的${title}详细内容...\n" * 20,
'${l10n.translate('login.protocol_detail_text').replaceAll('%s', title)}\n' *
20,
style: TextStyle(color: context.appColors.textSecondary, height: 1.5),
),
),
@@ -296,7 +313,7 @@ class _RegisterPageState extends State<RegisterPage> {
width: double.infinity,
child: CCPrimaryButton(
onPressed: () => Navigator.pop(context),
text: "我已了解",
text: l10n.translate('login.understood'),
),
),
],

View File

@@ -1,8 +1,10 @@
import 'package:go_router/go_router.dart';
import 'package:maibu_satabot_v2/core/router/route_paths.dart';
import 'package:maibu_satabot_v2/features/auth/presentation/pages/login_page.dart';
import 'package:maibu_satabot_v2/features/auth/presentation/pages/login_page_v2.dart';
// 旧登录页保留备用:如需回退,将下方 LoginPageV2 换回 LoginPage 并恢复对应 import。
// import 'package:maibu_satabot_v2/features/auth/presentation/pages/login_page.dart';
import '../pages/register_page.dart';
// import '../pages/register_page.dart';
import '../pages/splash_page.dart';
class AuthRoutes {
@@ -14,11 +16,13 @@ class AuthRoutes {
),
GoRoute(
path: RoutePaths.login,
builder: (context, state) => const LoginPage(),
builder: (context, state) => const LoginPageV2(),
),
GoRoute(
path: RoutePaths.register,
builder: (context, state) => const RegisterPage(),
// 上架版关闭注册,旧链接跳转到登录页。
// builder: (context, state) => const RegisterPage(),
redirect: (context, state) => RoutePaths.login,
),
];
}

View File

@@ -0,0 +1,57 @@
import '../../domain/repositories/autonomous_work_gateway.dart';
import 'local_demo_work_gateway.dart';
import 'path_planning_work_gateway.dart';
/// 可选的环境实现清单。新增 B 环境时在这里加一项。
enum AutonomousWorkScene {
pathPlanning,
localDemo,
}
/// 环境选择 + 实例构建。
///
/// 三种切换方式,按优先级:
/// 1. 运行期覆盖(单测 / 灰度 / 某租户专用):[overrideWith]
/// 2. 编译期注入:flutter run --dart-define=AUTONOMOUS_WORK_SCENE=local_demo
/// 3. 默认:path_planning(当前 A 环境)
class AutonomousWorkGatewayFactory {
AutonomousWorkGatewayFactory._();
static AutonomousWorkGateway? _override;
static AutonomousWorkScene resolveScene() {
const raw = String.fromEnvironment(
'AUTONOMOUS_WORK_SCENE',
defaultValue: 'path_planning',
);
switch (raw) {
case 'local_demo':
case 'demo':
return AutonomousWorkScene.localDemo;
case 'path_planning':
default:
return AutonomousWorkScene.pathPlanning;
}
}
static AutonomousWorkGateway build(AutonomousWorkScene scene) {
switch (scene) {
case AutonomousWorkScene.localDemo:
return LocalDemoWorkGateway();
case AutonomousWorkScene.pathPlanning:
return PathPlanningWorkGateway();
}
}
static AutonomousWorkGateway current() =>
_override ?? build(resolveScene());
/// 运行期替换实现(单测、灰度、特定业务线)
static void overrideWith(AutonomousWorkGateway gateway) {
_override = gateway;
}
static void clearOverride() {
_override = null;
}
}

View File

@@ -0,0 +1,247 @@
import 'dart:async';
import 'package:latlong2/latlong.dart';
import '../../domain/entities/work_session_models.dart';
import '../../domain/entities/return_plan_models.dart';
import '../../domain/repositories/autonomous_work_gateway.dart';
/// B 环境示例实现:完全本地内存,不依赖任何后端。
///
/// 用途有两个:
/// 1. 证明「一套页面功能,多套实现」的切换成本 = 新增一个文件;
/// 2. 无网络环境 / UI 联调 / 演示时也能跑完整的启停流程。
///
/// 接真实 B 环境时,拷这个文件改内部实现即可(HTTP、MQTT、本地库都行),
/// 页面与抽屉一行都不用改。
class LocalDemoWorkGateway implements AutonomousWorkGateway {
LocalDemoWorkGateway();
static const List<LatLng> _demoPath = <LatLng>[
LatLng(31.0833, 120.8403),
LatLng(31.0839, 120.8410),
LatLng(31.0845, 120.8412),
LatLng(31.0851, 120.8406),
LatLng(31.0847, 120.8398),
];
/// 覆盖模式的作业区域边界(演示数据:把轨迹包成一个矩形),
/// 对应 A 环境的 outer,地图上画灰色半透明框
static const List<LatLng> _demoOuter = <LatLng>[
LatLng(31.0830, 120.8394),
LatLng(31.0830, 120.8416),
LatLng(31.0854, 120.8416),
LatLng(31.0854, 120.8394),
];
final StreamController<WorkTaskSnapshot?> _controller =
StreamController<WorkTaskSnapshot?>.broadcast();
final List<WorkTaskSnapshot> _tasks = <WorkTaskSnapshot>[];
/// 本地保存的圈地记录(B 环境无后端,只存在内存里)
final List<WorkRouteOption> _savedRoutes = <WorkRouteOption>[];
int _seq = 1000;
int _routeSeq = 2000;
@override
String get gatewayId => 'local_demo';
@override
WorkSceneCapabilities get capabilities => const WorkSceneCapabilities(
routeLibrary: true,
taskQueue: true,
pause: true,
resume: true,
stop: true,
cancelQueued: true,
schedule: true,
routeEditing: false,
progress: true,
multiActiveTaskResolve: true,
);
@override
Future<WorkScope> resolveScope({required String deviceId}) async =>
const WorkScope(siteId: 1, orgId: 1);
@override
Future<WorkResult<List<WorkRouteOption>>> loadRoutes({
required int siteId,
}) async {
return WorkResult.ok(<WorkRouteOption>[
..._savedRoutes,
const WorkRouteOption(
id: 'demo-1',
name: 'A区 · 演示路线01',
pathPoints: _demoPath,
outerPoints: _demoOuter,
planModeLabel: '覆盖模式',
metaText: '5 个路径点 · 4 个边界点 · 覆盖模式',
),
const WorkRouteOption(
id: 'demo-2',
name: 'B区 · 演示路线02',
pathPoints: _demoPath,
coverageMode: false,
planModeLabel: '自定义模式',
metaText: '5 个路径点 · 自定义模式',
),
]);
}
@override
Future<WorkResult<WorkRouteOption>> resolveRouteGeometry({
required String deviceId,
required WorkRouteOption route,
}) async {
// 演示数据自带完整几何,无需再查
if (route.hasGeometry) return WorkResult.ok(route);
return WorkResult.fail('「${route.name}」没有可用的路径数据');
}
@override
Future<WorkResult<bool>> saveRouteRecord({
required String deviceId,
required String name,
required List<LatLng> path,
required List<LatLng> outer,
required int planModel,
// 本地演示无后端校验,只为与线上接口保持同一份签名
required String routeType,
String? imageBase64,
}) async {
_savedRoutes.add(
WorkRouteOption(
id: 'demo-${++_routeSeq}',
name: name,
pathPoints: List<LatLng>.from(path),
outerPoints: planModel == 0 ? List<LatLng>.from(outer) : const [],
coverageMode: planModel == 0,
planModeLabel: planModel == 0 ? '覆盖模式' : '自定义模式',
),
);
return const WorkResult<bool>.ok(true);
}
@override
Future<WorkResult<int?>> saveReturnPlan(ReturnPlanDraft draft) async {
// B 环境演示:无后端,仅校验后本地确认,返回一个假主键占位
if (draft.isEmpty) return const WorkResult.fail('没有可保存的返航点或障碍');
return WorkResult<int?>.ok(++_routeSeq);
}
@override
Future<WorkResult<ReturnPlanDraft?>> fetchReturnPlanBySiteId({
required int siteId,
}) async {
// 本地演示:无后端,始终返回「暂无方案」→ 进入空白新建态
return WorkResult<ReturnPlanDraft?>.ok(null);
}
@override
Future<WorkResult<List<WorkTaskSnapshot>>> loadTasks({
required String deviceId,
}) async {
return WorkResult.ok(List<WorkTaskSnapshot>.unmodifiable(_tasks));
}
@override
Stream<WorkTaskSnapshot?> watchTask({required String deviceId}) =>
_controller.stream;
@override
Future<WorkResult<WorkTaskSnapshot>> startWork(
WorkStartRequest request,
) async {
final reuseId = request.reuseTaskId;
if (reuseId != null) {
for (final task in _tasks) {
if (task.taskId == reuseId) {
_emit(task);
return WorkResult.ok(task);
}
}
}
final snapshot = WorkTaskSnapshot(
taskId: '${++_seq}',
deviceId: request.deviceId,
state: WorkRunState.running,
routeId: request.route.id,
routeName: request.route.name,
rawStatus: 'DEMO_EXECUTING',
createdAt: DateTime.now().toString().substring(0, 19),
progress: 0,
);
_tasks.add(snapshot);
_emit(snapshot);
return WorkResult.ok(snapshot);
}
@override
Future<WorkResult<WorkTaskSnapshot>> pauseWork({
required String deviceId,
required WorkTaskSnapshot task,
}) async =>
_update(task, WorkRunState.paused, 'DEMO_PAUSE');
@override
Future<WorkResult<WorkTaskSnapshot>> resumeWork({
required String deviceId,
required WorkTaskSnapshot task,
}) async =>
_update(task, WorkRunState.running, 'DEMO_EXECUTING');
@override
Future<WorkResult<WorkTaskSnapshot>> stopWork({
required String deviceId,
required WorkTaskSnapshot task,
}) async {
final result = _update(task, WorkRunState.finished, 'DEMO_FINISHED');
_controller.add(null);
return result;
}
@override
Future<WorkResult<WorkTaskSnapshot>> cancelQueued({
required String deviceId,
required WorkTaskSnapshot task,
}) async {
if (task.state == WorkRunState.running) {
return WorkResult.fail('执行中的任务请用「停止作业」终止');
}
_tasks.removeWhere((t) => t.taskId == task.taskId);
_controller.add(null);
return WorkResult.ok(task.copyWith(state: WorkRunState.finished));
}
@override
Future<WorkResult<WorkTaskSnapshot>> activateTask({
required String deviceId,
required WorkTaskSnapshot task,
}) async {
for (final item in _tasks) {
if (item.taskId == task.taskId) {
_emit(item);
return WorkResult.ok(item);
}
}
return WorkResult.fail('任务不存在或已结束');
}
WorkResult<WorkTaskSnapshot> _update(
WorkTaskSnapshot task,
WorkRunState state,
String rawStatus,
) {
final next = task.copyWith(state: state, rawStatus: rawStatus);
final index = _tasks.indexWhere((t) => t.taskId == task.taskId);
if (index >= 0) _tasks[index] = next;
_emit(next);
return WorkResult.ok(next);
}
void _emit(WorkTaskSnapshot snapshot) {
_controller.add(snapshot);
}
}

View File

@@ -0,0 +1,632 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import '../../../../core/app/app_user_cubit.dart';
import '../../../../core/consts/http_api_consts.dart';
import '../../../../core/di/injection.dart';
import '../../../devices/data/models/work_record_entity.dart';
import '../../../devices/domain/repositories/device_task_repository.dart';
import '../../../devices/domain/usecases/get_work_records_by_site_id_usecase.dart';
import '../../../devices/domain/usecases/select_work_record_usecase.dart';
import '../v2_work_task_tracker.dart';
import '../v2_work_store.dart';
import '../../../devices/domain/usecases/create_device_task_usecase.dart';
import '../../../v2/site/presentation/cubit/site_cubit.dart';
import '../../domain/entities/work_session_models.dart';
import '../../domain/entities/return_plan_models.dart';
import '../../domain/repositories/autonomous_work_gateway.dart';
/// 新版作业门面:沿用旧版协议和 HTTP 接口,独立维护任务状态与 MQTT 订阅。
/// 不写旧版 Cubit 或缓存。
class PathPlanningWorkGateway
implements
AutonomousWorkGateway,
WorkTaskHistorySource,
WorkTaskQueueSource {
PathPlanningWorkGateway({V2WorkTaskTracker? tracker})
: _injectedTracker = tracker;
final V2WorkTaskTracker? _injectedTracker;
V2WorkTaskTracker? _ownedTracker;
String? _trackerOwner;
V2WorkTaskTracker get _tracker {
if (_injectedTracker != null) return _injectedTracker;
final owner = sl<AppUserCubit>().state.user?.userId ?? '';
if (_ownedTracker == null || _trackerOwner != owner) {
_trackerOwner = owner;
_ownedTracker = V2WorkTaskTracker(
repository: sl<DeviceTaskRepository>(),
createTask: sl<CreateDeviceTaskUseCase>(),
resolveScope: (deviceId) => resolveScope(deviceId: deviceId),
userId: () => sl<AppUserCubit>().state.user?.userId ?? '',
store: V2WorkStore(owner),
);
}
return _ownedTracker!;
}
@override
String get gatewayId => 'path_planning';
@override
WorkSceneCapabilities get capabilities => const WorkSceneCapabilities(
routeLibrary: true,
taskQueue: true,
pause: true,
resume: true,
stop: true,
cancelQueued: true,
schedule: false, // 定时计划走作业计划创建页(SaveDeviceSchedulePlanUseCase),不属本页能力
routeEditing: true, // 走 RouteEditorPageV2
progress: false, // 任务池不返回进度,页面不臆造百分比
multiActiveTaskResolve: true,
);
@override
Future<WorkScope> resolveScope({required String deviceId}) async {
final site = sl<SiteCubit>().state.selectedSite;
return WorkScope(siteId: site?.id, orgId: site?.orgId);
}
@override
Future<WorkResult<List<WorkRouteOption>>> loadRoutes({
required int siteId,
}) async {
debugPrint('[路线库] A 环境 → /iot/workRecord/selectBySiteId?siteId=$siteId');
try {
final result = await sl<GetWorkRecordsBySiteIdUseCase>()(siteId);
return result.fold((failure) {
debugPrint('[路线库] ← 接口失败: ${failure.message}');
return WorkResult.fail(failure.message);
}, (
records,
) {
debugPrint('[路线库] ← 作业记录条数=${records.length}');
final routes = <WorkRouteOption>[];
for (final record in records) {
final json = record.jsonData;
final option = _routeFromRecord({
'id': record.id,
'workName': record.workName,
'imgUrl': record.imgUrl,
'jsonData': json is WorkRecordJsonData
? {
'path': json.path,
'outer': json.outer,
'planModel': json.planModel,
'img': json.img,
}
: json,
});
if (option != null) routes.add(option);
}
debugPrint('[路线库] 解析出可绘制路线=${routes.length}');
return WorkResult.ok(routes);
});
} catch (e) {
debugPrint('[路线库] ❌ 加载异常: $e');
return WorkResult.fail('路线库加载失败:$e');
}
}
@override
Future<WorkResult<WorkRouteOption>> resolveRouteGeometry({
required String deviceId,
required WorkRouteOption route,
}) async {
// 列表里已经带了几何:不再查(同路径规划:jsonData 有效就不调详情接口)
if (route.hasGeometry) return WorkResult.ok(route);
try {
// 与路径规划完全一致:按作业名回查记录详情,
// selectBySiteId 的 jsonData 可能是空标签,真实 path/outer/planModel 在记录顶层
final result = await sl<SelectWorkRecordUseCase>()(route.name);
String? error;
final records = result.fold<List<Map<String, dynamic>>?>((failure) {
error = failure.message;
return null;
}, (data) => data);
if (error != null) return WorkResult.fail(error!);
if (records == null || records.isEmpty) {
return WorkResult.fail('「${route.name}」没有可用的路径数据');
}
final record = records.first;
final hydrated = _routeFromRecord(<String, dynamic>{
'id': route.id,
'workName': route.name,
'imgUrl': route.imageUrl ?? '',
'jsonData': record['jsonData'],
'path': record['path'],
'outer': record['outer'],
'planModel': record['planModel'],
});
if (hydrated == null || !hydrated.hasGeometry) {
return WorkResult.fail('「${route.name}」没有可用的路径数据');
}
return WorkResult.ok(hydrated);
} catch (e) {
return WorkResult.fail('路线详情加载失败:$e');
}
}
@override
Future<WorkResult<List<WorkTaskSnapshot>>> loadTasks({
required String deviceId,
}) => _tracker.load(deviceId);
@override
Future<WorkResult<List<WorkTaskSnapshot>>> loadTaskHistory({
required String deviceId,
}) async => WorkResult.ok(await _tracker.history(deviceId));
/// 1:1 复刻路径规划「保存地块」:multipart 提交 /iot/workRecord/add。
///
/// 对齐 Web 端:覆盖模式 outer=边界、path=几何、planModel=0、mode=coverage;
/// 自定义模式 outer=[]、path=几何、planModel=2、mode=single_point。
/// file 字段后端强制要求:没有截图也要传一个空 jpg。
@override
Future<WorkResult<bool>> saveRouteRecord({
required String deviceId,
required String name,
required List<LatLng> path,
required List<LatLng> outer,
required int planModel,
required String routeType,
String? imageBase64,
}) async {
// 场站与 orgId 必须同源(selectBySiteId 的查询条件),不要取登录用户的 orgId
final site = sl<SiteCubit>().state.selectedSite;
final siteId = site?.id;
if (siteId == null) {
return WorkResult.fail('请先在场站列表中选择一个场站!');
}
final orgId = site?.orgId;
List<Map<String, double>> toWgs(List<LatLng> points) =>
<Map<String, double>>[
for (final point in points)
{'lng': point.longitude, 'lat': point.latitude},
];
final bool coverage = planModel == 0;
final Map<String, dynamic> savePath = <String, dynamic>{
'outer': coverage ? toWgs(outer) : <dynamic>[],
'path': toWgs(path),
'planModel': planModel,
};
final Map<String, dynamic> workRecord = <String, dynamic>{
'workName': name,
'siteId': siteId,
// jsonData 必须是对象,不能是字符串(后端 PlanPath 类型要求)
'jsonData': savePath,
'mode': coverage ? 'coverage' : 'single_point',
// 后端必填的规划类型(Java 枚举 RouteType.name()),与 mode 平级
'routeType': routeType,
};
if (orgId != null) workRecord['orgId'] = orgId;
// 与路径规划页一致:打印后端实际接收的完整报文,便于排查字段缺失
debugPrint('[区域保存] workRecord 完整JSON: ${jsonEncode(workRecord)}');
final http.MultipartRequest request = http.MultipartRequest(
'POST',
Uri.parse('${HttpApiConsts.baseUrl}/iot/workRecord/add'),
);
final userToken = sl<AppUserCubit>().state.user?.token;
if (userToken != null) {
request.headers['Authorization'] = 'Bearer $userToken';
}
if (imageBase64 != null && imageBase64.isNotEmpty) {
var raw = imageBase64;
if (raw.startsWith('data:image/jpeg;base64,')) {
raw = raw.split(',').last;
}
request.files.add(
http.MultipartFile.fromBytes(
'file',
base64Decode(raw),
filename: 'image.jpg',
contentType: http.MediaType('image', 'jpeg'),
),
);
} else {
request.files.add(
http.MultipartFile.fromBytes(
'file',
<int>[],
filename: 'empty.jpg',
contentType: http.MediaType('image', 'jpeg'),
),
);
}
request.files.add(
http.MultipartFile.fromBytes(
'workRecord',
utf8.encode(jsonEncode(workRecord)),
filename: '', // 与浏览器 Blob 保持一致
contentType: http.MediaType('application', 'json'),
),
);
try {
final http.StreamedResponse response = await request.send();
final String body = await response.stream.bytesToString();
if (response.statusCode != 200) {
return WorkResult.fail('保存失败(HTTP ${response.statusCode}):$body');
}
Map<String, dynamic>? resp;
try {
resp = jsonDecode(body) as Map<String, dynamic>;
} catch (_) {
return WorkResult.fail('保存失败:响应体非 JSON,$body');
}
final code = resp['code'];
if (code != null && code.toString() != '200') {
return WorkResult.fail('${resp['msg'] ?? resp['message'] ?? '保存失败'}');
}
// 与路径规划一致:保存成功后刷新该场站的作业记录
return const WorkResult<bool>.ok(true);
} catch (e) {
return WorkResult.fail('保存失败:$e');
}
}
/// 一键返航方案落库:POST /system/siteMapInfo/save(JSON)。
/// 严格对齐后端字段:restPoints / tempObstaclePoints / siteId / orgId / id。
@override
Future<WorkResult<int?>> saveReturnPlan(ReturnPlanDraft draft) async {
if (draft.isEmpty) {
return const WorkResult.fail('没有可保存的返航点或障碍');
}
Map<String, double> wgs(LatLng p) =>
<String, double>{'lat': p.latitude, 'lng': p.longitude};
// 尺寸归一化:米级宽度从经纬度几何反算会带 1e-9 量级的浮点残渣
// (如 3.0000000009815784),后端字段是普通 double,这里抹到微米精度(1e-6)
// 即可去掉残渣,又不丢失用户真实录入的尺寸。
double normM(double v) => (v * 1e6).roundToDouble() / 1e6;
final Map<String, dynamic> payload = <String, dynamic>{
'restPoints': <Map<String, dynamic>>[
for (final p in draft.restPoints)
<String, dynamic>{
'position': wgs(p.position),
'bindDeviceId': p.bindDeviceId,
'alias': p.alias,
},
],
'tempObstaclePoints': <Map<String, dynamic>>[
for (final o in draft.obstacles)
<String, dynamic>{
'center': wgs(o.center),
// 后端要求 position 与 center 一致
'position': wgs(o.center),
'shapeType': o.shapeType,
'alias': o.alias,
'source': o.source,
'size_x': normM(o.sizeX),
'size_y': normM(o.sizeY),
},
],
'siteId': draft.siteId,
'orgId': draft.orgId,
// 新增不传 id、修改才传:只有拿到真实主键(上次保存回写)才带上走更新
if (draft.id != null) 'id': draft.id,
};
final String url = '${HttpApiConsts.baseUrl}/system/siteMapInfo/save';
// 提交前先打印真实发出的报文,便于排查后端字段/坐标问题
debugPrint('[一键返航] → POST $url');
debugPrint('[一键返航] 提交数据: ${jsonEncode(payload)}');
final http.Request request = http.Request('POST', Uri.parse(url))
..headers['Content-Type'] = 'application/json;charset=utf-8';
final userToken = sl<AppUserCubit>().state.user?.token;
if (userToken != null) {
request.headers['Authorization'] = 'Bearer $userToken';
}
request.body = jsonEncode(payload);
try {
final http.StreamedResponse response = await request.send();
final String body = await response.stream.bytesToString();
debugPrint(
'[一键返航] ← HTTP ${response.statusCode} 响应: $body',
);
if (response.statusCode != 200) {
return WorkResult.fail(
'保存失败(HTTP ${response.statusCode}):$body',
);
}
Map<String, dynamic>? resp;
try {
resp = jsonDecode(body) as Map<String, dynamic>;
} catch (_) {
return WorkResult.fail('保存失败:响应体非 JSON,$body');
}
final code = resp['code'];
if (code != null && code.toString() != '200') {
return WorkResult.fail('${resp['msg'] ?? resp['message'] ?? '保存失败'}');
}
// 若后端回写主键则回填,用于下一次「更新」;否则 null(每次新建)
final data = resp['data'];
final int? savedId = data is num
? data.toInt()
: int.tryParse('${data ?? ''}');
debugPrint('[一键返航] ✅ 保存成功,siteMapInfo id=$savedId');
return WorkResult<int?>.ok(savedId);
} catch (e) {
debugPrint('[一键返航] ❌ 保存异常: $e');
return WorkResult.fail('保存失败:$e');
}
}
/// 查询某场站已有的一键返航方案:GET /system/siteMapInfo/selectBySiteId?siteId=X。
///
/// 一个场站业务上只允许一条,data 为带完整几何的单体(也可能为 null=无方案)。
/// 返回结构已确认为 {code,msg,data:{id,siteId,orgId,restPoints[],tempObstaclePoints[]}},
/// 解析仍保留少量字段别名做容错,坐标/尺寸缺失则跳过该条、不臆造。
@override
Future<WorkResult<ReturnPlanDraft?>> fetchReturnPlanBySiteId({
required int siteId,
}) async {
final String url =
'${HttpApiConsts.baseUrl}/system/siteMapInfo/selectBySiteId?siteId=$siteId';
debugPrint('[一键返航] → GET $url');
try {
final http.Response res = await http.get(
Uri.parse(url),
headers: _returnAuthHeaders(),
);
debugPrint(
'[一键返航] ← HTTP ${res.statusCode} selectBySiteId 响应: ${res.body}',
);
if (res.statusCode != 200) {
return WorkResult.fail('返航方案加载失败(HTTP ${res.statusCode})');
}
dynamic decoded;
try {
decoded = jsonDecode(res.body);
} catch (_) {
return WorkResult.fail('返航方案加载失败:响应体非 JSON');
}
if (decoded is! Map) {
return WorkResult.fail('返航方案响应格式异常');
}
final code = decoded['code'];
if (code != null && code.toString() != '200') {
return WorkResult.fail(
'${decoded['msg'] ?? decoded['message'] ?? '请求失败'}',
);
}
final data = decoded['data'];
if (data is! Map || data.isEmpty) {
// 该场站暂无一键返航方案 → 新建态
return WorkResult<ReturnPlanDraft?>.ok(null);
}
return WorkResult<ReturnPlanDraft?>.ok(
_returnDraftFromJson(Map<String, dynamic>.from(data)),
);
} catch (e) {
debugPrint('[一键返航] ❌ 查询异常: $e');
return WorkResult.fail('返航方案加载失败:$e');
}
}
/// GET 头:Content-Type + Bearer token(与保存接口同源)
Map<String, String> _returnAuthHeaders() {
final headers = <String, String>{
'Content-Type': 'application/json;charset=utf-8',
};
final token = sl<AppUserCubit>().state.user?.token;
if (token != null) headers['Authorization'] = 'Bearer $token';
return headers;
}
/// selectBySiteId 的 data 对象 → [ReturnPlanDraft]。
/// 字段名对齐后端:restPoints.position{lat,lng}/alias/bindDeviceId,
/// tempObstaclePoints.center{lat,lng}/size_x/size_y/source。
ReturnPlanDraft _returnDraftFromJson(Map<String, dynamic> m) {
LatLng? parseLatLng(dynamic node) {
if (node is! Map) return null;
final lat = node['lat'] ?? node['latitude'];
final lng = node['lng'] ?? node['lon'] ?? node['longitude'];
if (lat is! num || lng is! num ||
!lat.isFinite || !lng.isFinite ||
lat < -90 || lat > 90 || lng < -180 || lng > 180) return null;
return LatLng(lat.toDouble(), lng.toDouble());
}
double asSize(dynamic v) {
if (v is num) return v.toDouble();
return double.tryParse('${v ?? ''}') ?? 0;
}
int asId(dynamic v) {
if (v is num) return v.toInt();
return int.tryParse('${v ?? ''}') ?? 0;
}
final List<dynamic> restRaw =
m['restPoints'] is List ? m['restPoints'] as List : const <dynamic>[];
final restPoints = <ReturnRestPoint>[];
for (final e in restRaw) {
if (e is! Map) continue;
final pos =
parseLatLng(e['position']) ??
parseLatLng(e['center']) ??
parseLatLng(e);
if (pos == null) continue;
final alias = (e['alias'] ?? e['name'] ?? '').toString();
final bind =
(e['bindDeviceId'] ?? e['deviceSn'] ?? e['deviceId'])?.toString();
restPoints.add(
ReturnRestPoint(
position: pos,
alias: alias,
bindDeviceId: (bind != null && bind.isNotEmpty) ? bind : null,
),
);
}
final List<dynamic> obsRaw = m['tempObstaclePoints'] is List
? m['tempObstaclePoints'] as List
: const <dynamic>[];
final obstacles = <ReturnObstaclePoint>[];
for (final e in obsRaw) {
if (e is! Map) continue;
final center =
parseLatLng(e['center']) ??
parseLatLng(e['position']) ??
parseLatLng(e);
if (center == null) continue;
obstacles.add(
ReturnObstaclePoint(
center: center,
alias: (e['alias'] ?? e['name'] ?? '').toString(),
shapeType: (e['shapeType'] ?? 'rect').toString(),
sizeX: asSize(e['size_x'] ?? e['sizeX'] ?? e['width']),
sizeY: asSize(e['size_y'] ?? e['sizeY'] ?? e['height']),
source: (e['source'] ?? 'platform').toString(),
),
);
}
return ReturnPlanDraft(
siteId: asId(m['siteId']),
orgId: asId(m['orgId']),
id: m['id'] == null ? null : asId(m['id']),
restPoints: restPoints,
obstacles: obstacles,
);
}
@override
Stream<WorkTaskSnapshot?> watchTask({required String deviceId}) =>
_tracker.watch(deviceId);
@override
Stream<WorkTaskSnapshot?> watchTaskChanges({required String deviceId}) =>
_tracker.watch(deviceId, allTasks: true);
@override
Future<WorkResult<WorkTaskSnapshot>> startWork(WorkStartRequest request) =>
_tracker.start(request);
@override
Future<WorkResult<WorkTaskSnapshot>> pauseWork({
required String deviceId,
required WorkTaskSnapshot task,
}) => _tracker.command(deviceId, task, 'pause');
@override
Future<WorkResult<WorkTaskSnapshot>> resumeWork({
required String deviceId,
required WorkTaskSnapshot task,
}) => _tracker.command(deviceId, task, 'resume');
@override
Future<WorkResult<WorkTaskSnapshot>> stopWork({
required String deviceId,
required WorkTaskSnapshot task,
}) => _tracker.command(deviceId, task, 'cancel');
@override
Future<WorkResult<WorkTaskSnapshot>> cancelQueued({
required String deviceId,
required WorkTaskSnapshot task,
}) => _tracker.command(deviceId, task, 'cancel', forceCancel: false);
@override
Future<WorkResult<WorkTaskSnapshot>> activateTask({
required String deviceId,
required WorkTaskSnapshot task,
}) async {
if (task.deviceId != deviceId) return const WorkResult.fail('设备不匹配');
return _tracker.select(task);
}
/// 作业记录 → 路线选项:字段解析集中在这里,
/// 后端返回结构变化(XML / JSON / 另一套字段名)只改这个函数。
WorkRouteOption? _routeFromRecord(Map<String, dynamic> record) {
final id = record['id']?.toString() ?? '';
if (id.isEmpty) return null;
final name = (record['workName']?.toString().isNotEmpty ?? false)
? record['workName'].toString()
: '未命名路线';
final raw = record['jsonData'];
Map<String, dynamic> data = const <String, dynamic>{};
if (raw is String && raw.trim().isNotEmpty) {
try {
final decoded = jsonDecode(raw);
if (decoded is Map) {
data = Map<String, dynamic>.from(decoded);
}
} catch (_) {
data = const <String, dynamic>{};
}
} else if (raw is Map) {
data = Map<String, dynamic>.from(raw);
}
// 与路径规划页一致:jsonData 可能只是空的 <jsonData/>(解析后 {}),
// 真实 path/outer/planModel 也可能直接挂在记录顶层,两处都读。
final path = _points(data['path'] ?? record['path']);
var outer = _points(data['outer'] ?? record['outer']);
final planModel = int.tryParse(
'${data['planModel'] ?? record['planModel'] ?? ''}',
);
final imgUrl = record['imgUrl']?.toString() ?? '';
// 后端可能返回 outer 经度全为 0 的脏数据:覆盖模式下用 path 兜底,
// 否则地图上只剩一条线、看不到作业区域
final coverageMode = planModel == null || planModel == 0;
if (coverageMode && outer.isEmpty && path.isNotEmpty) {
outer = path;
}
final meta = <String>[
if (path.isNotEmpty) '${path.length} 个路径点',
if (outer.isNotEmpty) '${outer.length} 个边界点',
if (planModel != null) (coverageMode ? '覆盖模式' : '自定义模式'),
];
return WorkRouteOption(
id: id,
name: name,
pathPoints: path,
outerPoints: outer,
coverageMode: coverageMode,
planModeLabel: planModel == null
? null
: (coverageMode ? '覆盖模式' : '自定义模式'),
imageUrl: imgUrl.isNotEmpty
? imgUrl
: (data['img'] is String ? data['img'] as String : null),
metaText: meta.isEmpty ? null : meta.join(' · '),
extras: <String, dynamic>{'raw': data},
);
}
/// 坐标解析:兼容 `List<dynamic>` / JSON 字符串 / `{lat,lng}` / `{lat,lon}` 四种形态
List<LatLng> _points(dynamic source) {
dynamic raw = source;
if (raw is String) {
if (raw.trim().isEmpty) return const <LatLng>[];
try {
raw = jsonDecode(raw);
} catch (_) {
return const <LatLng>[];
}
}
if (raw is! List) return const <LatLng>[];
final points = <LatLng>[];
for (final item in raw) {
if (item is! Map) continue;
final lat = double.tryParse('${item['lat'] ?? ''}');
final lng = double.tryParse('${item['lng'] ?? item['lon'] ?? ''}');
if (lat == null || lng == null) continue;
if (lat == 0 || lng == 0) continue;
points.add(LatLng(lat, lng));
}
return points;
}
}

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