Compare commits
104 Commits
feature/my
...
next-arch-
| Author | SHA1 | Date | |
|---|---|---|---|
| dff257c3f3 | |||
| 6cf6bea082 | |||
| 985bcf4b55 | |||
| 47f518f94c | |||
| 34cb9d2805 | |||
| 4e92398349 | |||
| 84eec7da96 | |||
| e6165b180b | |||
| 8eea42b75d | |||
| 85c83ec143 | |||
| d882db7995 | |||
| eb8dec2868 | |||
| 8a41113c92 | |||
| e3af8cf617 | |||
| 81dae187ac | |||
| 56d5d55d6e | |||
| 615d76a8ce | |||
| 58a23ef218 | |||
| 582242e9aa | |||
| 178cd2533c | |||
| 68984aa7d2 | |||
| e4f7cc7a90 | |||
| 9671798f29 | |||
| dc006c2935 | |||
| 167229371f | |||
| 208147cdf2 | |||
| b3a700491a | |||
| d230279f4f | |||
| 32397f8899 | |||
| c8b597f683 | |||
| a1e01da241 | |||
| 3547f80383 | |||
| 4b88a291a5 | |||
| 877ff932d4 | |||
| 43c0b68e1a | |||
| 356c2a1925 | |||
| 3ba480f63f | |||
| 85e9632326 | |||
| b4bedd9737 | |||
| d76bcdcdf2 | |||
| b468e577a6 | |||
| db81816b78 | |||
| 5141d6e83f | |||
| 61bce2c1cf | |||
| 0fa67a127b | |||
| 7c01229b9e | |||
| a48778bc8a | |||
| e8070b5183 | |||
| fc3cf4de0f | |||
| db323a26ce | |||
| 11509d1aa6 | |||
| 97a2126fcf | |||
| 0978f387fd | |||
| e9532ddafa | |||
| 43fbd0af19 | |||
| 4b03da8142 | |||
| d0fcc067ca | |||
| ca805ee957 | |||
| d119407866 | |||
| 5a02522bb4 | |||
| 54dac30915 | |||
| 875766cc28 | |||
| e8a79168e8 | |||
| 7773fe7a60 | |||
| b6e2a40aa7 | |||
| bb2becdf7b | |||
| ca616faf11 | |||
| 83220869da | |||
| ab75e8c5b0 | |||
| 87609c7dc4 | |||
| 9dc445eeb5 | |||
| db70665ced | |||
| bf2fc49c19 | |||
| 1a4d0aedfd | |||
| 2124f3bc02 | |||
| 0db64a96e7 | |||
| d5a83c1221 | |||
| b7ea788298 | |||
| 06718ed957 | |||
| a6143f2ba7 | |||
| 443ce601a0 | |||
| 71aea48d9f | |||
| 52fe17c0d5 | |||
| b08e1e0b57 | |||
| 8eb878dd81 | |||
| 058a5d9588 | |||
| ff4a3836c7 | |||
| 354f7d6290 | |||
| 7c9734d918 | |||
| d8879dad62 | |||
| 9eab402551 | |||
| b6480778ed | |||
| 93e69a5e4b | |||
| 9154174614 | |||
| 335c468aac | |||
| 938aac2db9 | |||
| afef5c4ef1 | |||
| b9f747de05 | |||
| 07b6b19b34 | |||
| f1a279cf57 | |||
| 03f797e717 | |||
| a4ac503abd | |||
| fb529b2485 | |||
| 4b3ad72627 |
135
.app-report-build/build.mjs
Normal 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);}
|
||||
}
|
||||
BIN
.app-report-build/candidate.pptx
Normal file
78
.app-report-build/candidate.pptx.inspect.ndjson
Normal 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 验收包。前提为接口可联调、设备到位且关键安全用例通过。"}
|
||||
18
.app-report-build/check.cjs
Normal 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});
|
||||
BIN
.app-report-build/slide-1.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
.app-report-build/slide-2.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
.app-report-build/slide-3.png
Normal file
|
After Width: | Height: | Size: 286 KiB |
BIN
.app-report-build/slide-4.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
.app-report-build/slide-5.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
42
.gitignore
vendored
@@ -20,7 +20,9 @@ build/
|
||||
gen/
|
||||
local.properties
|
||||
.gradle/
|
||||
.kotlin/
|
||||
captures/
|
||||
hs_err_pid*.log
|
||||
.idea/modules.xml
|
||||
.idea/workspace.xml
|
||||
|
||||
@@ -79,6 +81,25 @@ Debug/
|
||||
# ----------------------------------------------------------------------------
|
||||
lib/generated_plugin_registrant.dart
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Node.js 依赖目录(前端/工具链产物,不需要提交)
|
||||
# ----------------------------------------------------------------------------
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 日志与崩溃转储(本地调试产物,不需要提交)
|
||||
# ----------------------------------------------------------------------------
|
||||
flutter_*.log
|
||||
test_run.log
|
||||
*.log
|
||||
.sentry-native/
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Office 临时锁文件(Word/Excel 打开时生成,不需要提交)
|
||||
# ----------------------------------------------------------------------------
|
||||
~$*
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 编辑器与系统文件
|
||||
# ----------------------------------------------------------------------------
|
||||
@@ -93,4 +114,23 @@ lib/generated_plugin_registrant.dart
|
||||
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
|
||||
@@ -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`
|
||||
|
||||
45
android/.kotlin/errors/errors-1779355041254.log
Normal file
@@ -0,0 +1,45 @@
|
||||
kotlin version: 2.2.20
|
||||
error message: Failed connecting to the daemon in 4 retries
|
||||
|
||||
error message: Daemon compilation failed: Could not connect to Kotlin compile daemon
|
||||
java.lang.RuntimeException: Could not connect to Kotlin compile daemon
|
||||
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:214)
|
||||
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159)
|
||||
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111)
|
||||
at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:74)
|
||||
at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:63)
|
||||
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:66)
|
||||
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:62)
|
||||
at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:100)
|
||||
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:62)
|
||||
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44)
|
||||
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:210)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:205)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:67)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:60)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:167)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:60)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:54)
|
||||
at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41)
|
||||
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:59)
|
||||
at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:174)
|
||||
at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
|
||||
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194)
|
||||
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127)
|
||||
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169)
|
||||
at org.gradle.internal.Factories$1.create(Factories.java:31)
|
||||
at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:263)
|
||||
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127)
|
||||
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132)
|
||||
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164)
|
||||
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133)
|
||||
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Unknown Source)
|
||||
at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
|
||||
at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
|
||||
at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:48)
|
||||
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
|
||||
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
|
||||
at java.base/java.lang.Thread.run(Unknown Source)
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
@@ -5,8 +8,15 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
// 🔥 加载签名配置
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val keystoreProperties = Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.maibu.maibu_satabot_v2"
|
||||
namespace = "com.maibu.maibusatabotv2"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
@@ -21,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
|
||||
@@ -30,11 +40,20 @@ android {
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
65
android/app/proguard-rules.pro
vendored
@@ -6,3 +6,68 @@
|
||||
# 如果你用 dio + json_serializable / gson
|
||||
-keepattributes Signature
|
||||
-keepattributes *Annotation*
|
||||
|
||||
# ===== 火山引擎 RTC SDK 保留规则 =====
|
||||
# 保留所有火山引擎相关类
|
||||
-dontwarn com.ss.bytertc.**
|
||||
-keep class com.ss.bytertc.** { *; }
|
||||
|
||||
# 荣耀音频相关类
|
||||
-dontwarn com.hihonor.android.magicx.media.audio.interfaces.**
|
||||
-keep class com.hihonor.android.magicx.media.audio.interfaces.** { *; }
|
||||
|
||||
# ===== 声网 Agora RTC SDK 保留规则 =====
|
||||
# 保留所有声网相关类
|
||||
-dontwarn io.agora.**
|
||||
-keep class io.agora.** { *; }
|
||||
|
||||
# 保留native方法
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# ===== FastJSON 保留规则 =====
|
||||
# AWT 相关
|
||||
-dontwarn java.awt.**
|
||||
-keep class java.awt.Color { *; }
|
||||
-keep class java.awt.Font { *; }
|
||||
-keep class java.awt.Point { *; }
|
||||
-keep class java.awt.Rectangle { *; }
|
||||
|
||||
# Javax Money
|
||||
-dontwarn javax.money.**
|
||||
-keep class javax.money.CurrencyUnit { *; }
|
||||
-dontwarn org.javamoney.moneta.**
|
||||
-keep class org.javamoney.moneta.Money { *; }
|
||||
|
||||
# JAX-RS
|
||||
-dontwarn javax.ws.rs.**
|
||||
-keep class javax.ws.rs.Consumes { *; }
|
||||
-keep class javax.ws.rs.Produces { *; }
|
||||
-keep class javax.ws.rs.core.Response { *; }
|
||||
-keep class javax.ws.rs.core.StreamingOutput { *; }
|
||||
-keep class javax.ws.rs.ext.MessageBodyReader { *; }
|
||||
-keep class javax.ws.rs.ext.MessageBodyWriter { *; }
|
||||
-keep class javax.ws.rs.ext.Provider { *; }
|
||||
|
||||
# Jersey
|
||||
-dontwarn org.glassfish.jersey.internal.spi.**
|
||||
-keep class org.glassfish.jersey.internal.spi.AutoDiscoverable { *; }
|
||||
|
||||
# Joda Time
|
||||
-dontwarn org.joda.time.**
|
||||
-keep class org.joda.time.DateTime { *; }
|
||||
-keep class org.joda.time.DateTimeZone { *; }
|
||||
-keep class org.joda.time.Duration { *; }
|
||||
-keep class org.joda.time.Instant { *; }
|
||||
-keep class org.joda.time.LocalDate { *; }
|
||||
-keep class org.joda.time.LocalDateTime { *; }
|
||||
-keep class org.joda.time.LocalTime { *; }
|
||||
-keep class org.joda.time.Period { *; }
|
||||
-keep class org.joda.time.ReadablePartial { *; }
|
||||
-keep class org.joda.time.format.DateTimeFormat { *; }
|
||||
-keep class org.joda.time.format.DateTimeFormatter { *; }
|
||||
|
||||
# Springfox
|
||||
-dontwarn springfox.documentation.spring.web.json.**
|
||||
-keep class springfox.documentation.spring.web.json.Json { *; }
|
||||
|
||||
@@ -1,85 +1,55 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
|
||||
<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_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> <!-- 关键! -->
|
||||
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<!-- 如果还需要扫描闪光灯控制,可选添加 -->
|
||||
<uses-feature android:name="android.hardware.camera" />
|
||||
<uses-feature android:name="android.hardware.camera.autofocus" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
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"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<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="飒沓机器人"
|
||||
android:label="飒沓·星枢"
|
||||
android:name="${applicationName}"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:largeHeap="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@mipmap/launcher_icon">
|
||||
<meta-data android:name="android.max_aspect" android:value="2.1" />
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"/>
|
||||
<intent-filter>
|
||||
<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>
|
||||
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<!-- 高德 Key -->
|
||||
<meta-data
|
||||
android:name="com.amap.api.v2.apikey"
|
||||
android:value="bbb1f0f20eed6bf679eddf2625630aba" />
|
||||
|
||||
|
||||
|
||||
android:value="2"/>
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.maibu.maibu_satabot_v2
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 16 KiB |
BIN
android/app/src/main/res/drawable-hdpi/launch_image.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 8.8 KiB |
BIN
android/app/src/main/res/drawable-mdpi/launch_image.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 24 KiB |
BIN
android/app/src/main/res/drawable-xhdpi/launch_image.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 49 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/launch_image.png
Normal file
|
After Width: | Height: | Size: 318 KiB |
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 75 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/launch_image.png
Normal file
|
After Width: | Height: | Size: 508 KiB |
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 23 KiB |
@@ -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>
|
||||
17
android/app/src/main/res/xml/device_filter.xml
Normal 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>
|
||||
@@ -1,7 +1,16 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
// 使用阿里云镜像加速(优先)
|
||||
maven { url = uri("https://maven.aliyun.com/repository/google") }
|
||||
maven { url = uri("https://maven.aliyun.com/repository/central") }
|
||||
maven { url = uri("https://maven.aliyun.com/repository/public") }
|
||||
|
||||
google()
|
||||
mavenCentral()
|
||||
// 火山引擎 RTC 仓库
|
||||
maven { url = uri("https://artifact.bytedance.com/repository/Volcengine/") }
|
||||
// BytePlus 公共仓库(包含火山引擎依赖)
|
||||
maven { url = uri("https://artifact.byteplus.com/repository/public/") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,21 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dhttps.protocols=TLSv1.3,TLSv1.2,TLSv1.1 -Djava.net.preferIPv4Stack=true -Djavax.net.ssl.trustStoreType=JKS
|
||||
# 限制 Kotlin 编译守护进程内存,避免与 Gradle 守护进程叠加挤占系统内存
|
||||
kotlin.daemon.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=512m
|
||||
android.useAndroidX=true
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
android.enableJetifier=true
|
||||
|
||||
# 禁用代理(覆盖全局配置)
|
||||
systemProp.http.proxyHost=
|
||||
systemProp.http.proxyPort=
|
||||
systemProp.https.proxyHost=
|
||||
systemProp.https.proxyPort=
|
||||
|
||||
# 放行SSL证书与协议,解决TLS握手异常
|
||||
systemProp.http.ssl.insecure=true
|
||||
systemProp.http.ssl.allowall=true
|
||||
systemProp.http.ssl.ignore.validity.dates=true
|
||||
|
||||
# Android SDK 镜像配置(使用清华大学镜像)
|
||||
systemProp.android.sdkmanager.channel=stable
|
||||
@@ -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/app_logo_gray_transparent.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
assets/images/chucao.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
assets/images/dianzhan.png
Normal file
|
After Width: | Height: | Size: 521 KiB |
BIN
assets/images/huawei.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
assets/images/img.png
Normal file
|
After Width: | Height: | Size: 963 KiB |
BIN
assets/images/img_1.png
Normal file
|
After Width: | Height: | Size: 963 KiB |
BIN
assets/images/login-logo.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
assets/images/logo_me_bg.png
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
assets/images/logo_sata.png
Normal file
|
After Width: | Height: | Size: 677 KiB |
BIN
assets/images/logo_stata-en.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
assets/images/me_left_logo.png
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
assets/images/qinxi.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
assets/images/robot_logo_login.png
Normal file
|
After Width: | Height: | Size: 524 KiB |
BIN
assets/images/shebei-logo.png
Normal file
|
After Width: | Height: | Size: 774 KiB |
BIN
assets/images/shebei_bg.png
Normal file
|
After Width: | Height: | Size: 822 KiB |
BIN
assets/images/xunjian.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
assets/images/yiwancheng.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
52
build_patch.bat
Normal file
@@ -0,0 +1,52 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo ============================================
|
||||
echo Re Build Patch
|
||||
echo ============================================
|
||||
echo.
|
||||
|
||||
set PATCH_VERSION=1.0.2
|
||||
set TARGET_VERSION_CODE=1
|
||||
|
||||
echo [1/3] Building Release APK ...
|
||||
echo.
|
||||
call flutter build apk --release
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo APK build failed!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo.
|
||||
echo APK build done
|
||||
echo.
|
||||
|
||||
echo [2/3] Generating patch ...
|
||||
echo.
|
||||
call dart run flutter_patcher:pack ^
|
||||
--apk build/app/outputs/flutter-apk/app-release.apk ^
|
||||
--version %PATCH_VERSION% ^
|
||||
--target-version-code %TARGET_VERSION_CODE%
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo Patch generation failed!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo.
|
||||
echo Patch generation done
|
||||
echo.
|
||||
|
||||
echo [3/3] Output:
|
||||
echo.
|
||||
echo APK: build\app\outputs\flutter-apk\app-release.apk
|
||||
echo Patch: dist\libapp.so
|
||||
echo Manifest: dist\manifest.json
|
||||
echo.
|
||||
echo ============================================
|
||||
echo Next: Upload dist\libapp.so to server
|
||||
echo ============================================
|
||||
echo.
|
||||
pause
|
||||
@@ -2,4 +2,5 @@ description: This file stores settings for Dart & Flutter DevTools.
|
||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||
extensions:
|
||||
- get_it: true
|
||||
- provider: true
|
||||
- provider: true
|
||||
- shared_preferences: true
|
||||
3
dist/download/.gitkeep
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# 完整安装包目录
|
||||
|
||||
将 app-release.apk 放在此目录下用于测试
|
||||
6
dist/manifest.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": "1.0.10",
|
||||
"md5": "416e3f0e2a701bb8b4a0856a29dba597",
|
||||
"targetVersionCode": 1,
|
||||
"abi": "arm64-v8a"
|
||||
}
|
||||
3
dist/patch/.gitkeep
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# 差量更新包目录
|
||||
|
||||
将生成的 .patch 文件放在此目录下
|
||||
9
dist/version/check.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"hasUpdate": true,
|
||||
"patch": {
|
||||
"version": "1.0.2",
|
||||
"patchUrl": "http://127.0.0.1:8080/libapp.so",
|
||||
"md5": "",
|
||||
"targetVersionCode": 1
|
||||
}
|
||||
}
|
||||
213
docs/DEVICE_TASK_CUBIT_USAGE.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# DeviceTaskCubit 使用指南
|
||||
|
||||
## 功能概述
|
||||
|
||||
DeviceTaskCubit 提供了设备任务管理的完整功能:
|
||||
- 获取任务池并过滤出当前设备的任务
|
||||
- 取消任务
|
||||
- 暂停任务
|
||||
- 恢复任务
|
||||
- 全局管理 taskId
|
||||
|
||||
## 在页面中集成
|
||||
|
||||
### 1. 在 BlocProvider 中注册
|
||||
|
||||
```dart
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import '../presentation/bloc/device_task_cubit.dart';
|
||||
|
||||
class YourPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => GetIt.I<DeviceTaskCubit>(),
|
||||
child: YourPageContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 获取任务池(选择航线时调用)
|
||||
|
||||
```dart
|
||||
// 当用户选择航线后,获取该设备的任务
|
||||
final deviceId = targetDevice?.deviceId ?? '';
|
||||
if (deviceId.isNotEmpty) {
|
||||
context.read<DeviceTaskCubit>().fetchAndFilterTask(deviceId);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 监听状态变化(自动显示错误弹窗)
|
||||
|
||||
```dart
|
||||
BlocConsumer<DeviceTaskCubit, DeviceTaskState>(
|
||||
listener: (context, state) {
|
||||
// 🔥 自动显示错误弹窗(2秒后自动消失)
|
||||
if (state.shouldShowError && state.errorMessage != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.errorMessage!),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 操作成功提示
|
||||
if (state.operationType == DeviceTaskOperationType.cancel &&
|
||||
!state.isLoading) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('取消任务成功')),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
// 显示加载状态
|
||||
if (state.isLoading) {
|
||||
return const CircularProgressIndicator();
|
||||
}
|
||||
|
||||
// 显示当前任务ID
|
||||
final taskId = state.currentTaskId;
|
||||
return Text('当前任务ID: $taskId');
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
**关键点:**
|
||||
- `shouldShowError` 为 true 时,表示发生了错误,需要显示弹窗
|
||||
- 弹窗会自动在 2 秒后消失
|
||||
- **不会影响页面展示**,页面继续正常运行
|
||||
|
||||
### 4. 执行任务操作
|
||||
|
||||
#### 取消任务
|
||||
```dart
|
||||
final deviceId = targetDevice?.deviceId ?? '';
|
||||
context.read<DeviceTaskCubit>().cancelTask(deviceId);
|
||||
```
|
||||
|
||||
#### 暂停任务
|
||||
```dart
|
||||
final deviceId = targetDevice?.deviceId ?? '';
|
||||
context.read<DeviceTaskCubit>().pauseTask(deviceId);
|
||||
```
|
||||
|
||||
#### 恢复任务
|
||||
```dart
|
||||
final deviceId = targetDevice?.deviceId ?? '';
|
||||
context.read<DeviceTaskCubit>().recoveryTask(deviceId);
|
||||
```
|
||||
|
||||
### 5. 手动更新 taskId(选择新航线时)
|
||||
|
||||
```dart
|
||||
// 如果需要在选择航线时手动设置 taskId
|
||||
context.read<DeviceTaskCubit>().updateCurrentTaskId(newTaskId);
|
||||
```
|
||||
|
||||
### 6. 清除当前任务
|
||||
|
||||
```dart
|
||||
// 退出页面或切换设备时清除
|
||||
context.read<DeviceTaskCubit>().clearCurrentTask();
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **自动获取用户信息和场站ID**
|
||||
- Cubit 内部会自动从 AppUserCubit 和 SiteCubit 获取所需参数
|
||||
- 无需手动传递 userId、orgId、siteId
|
||||
|
||||
2. **统一错误处理**
|
||||
- 所有错误都会通过 ErrorHandler 转换为友好提示
|
||||
- 不会显示原始错误信息(如 HTTP 404、连接超时等)
|
||||
|
||||
3. **taskId 全局管理**
|
||||
- fetchAndFilterTask 会自动过滤并保存当前设备的 taskId
|
||||
- 所有操作接口都会使用这个全局保存的 taskId
|
||||
|
||||
4. **状态监听**
|
||||
- operationType 可以区分当前正在进行的操作类型
|
||||
- isLoading 表示是否正在执行网络请求
|
||||
|
||||
## 完整示例
|
||||
|
||||
```dart
|
||||
class RoutePlanningPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => GetIt.I<DeviceTaskCubit>(),
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: const Text('路径规划')),
|
||||
body: BlocConsumer<DeviceTaskCubit, DeviceTaskState>(
|
||||
listener: (context, state) {
|
||||
if (state.errorMessage != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.errorMessage!)),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
children: [
|
||||
// 显示当前任务ID
|
||||
Text('当前任务ID: ${state.currentTaskId ?? "无"}'),
|
||||
|
||||
// 操作按钮
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final deviceId = 'YOUR_DEVICE_ID';
|
||||
context.read<DeviceTaskCubit>().pauseTask(deviceId);
|
||||
},
|
||||
child: const Text('暂停'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final deviceId = 'YOUR_DEVICE_ID';
|
||||
context.read<DeviceTaskCubit>().recoveryTask(deviceId);
|
||||
},
|
||||
child: const Text('恢复'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final deviceId = 'YOUR_DEVICE_ID';
|
||||
context.read<DeviceTaskCubit>().cancelTask(deviceId);
|
||||
},
|
||||
child: const Text('取消'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 加载指示器
|
||||
if (state.isLoading)
|
||||
const CircularProgressIndicator(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 错误处理优化
|
||||
|
||||
项目中已实现统一的错误处理机制:
|
||||
- 所有接口错误都会转换为友好的中文提示
|
||||
- 不会显示原始的错误信息(如 "HTTP 404"、"Connection timeout" 等)
|
||||
- 提示会自动消失(2秒后)
|
||||
- 页面不会崩溃
|
||||
|
||||
示例错误提示:
|
||||
- "网络连接超时,请检查网络设置"
|
||||
- "登录已过期,请重新登录"
|
||||
- "操作失败,请稍后重试"
|
||||
- "未知错误,请稍后重试"
|
||||
296
docs/DRONE_STATION_OSD_CARD_USAGE.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# 无人机机场 OSD 实时数据展示功能
|
||||
|
||||
## 📋 功能概述
|
||||
|
||||
在无人机机场详情页面添加了 **OSD 实时数据卡片**,通过 MQTT 订阅 `thing/product/${gatewaySn}/osd` topic,实时展示机场的遥测数据。
|
||||
|
||||
## 🎯 核心特性
|
||||
|
||||
### 1. 滑动窗口设计
|
||||
- **左右滑动切换**:用户可以左右滑动查看不同的 OSD 数据字段
|
||||
- **底部指示器**:显示当前页码和总页数
|
||||
- **滑动提示**:左右箭头提示用户可以滑动
|
||||
|
||||
### 2. 智能状态管理
|
||||
- **在线时**:显示实时 OSD 数据(电量、温度、风速等)
|
||||
- **离线时**:显示"机场离线,暂无实时数据"提示
|
||||
- **加载中**:显示加载动画
|
||||
|
||||
### 3. 数据字段展示
|
||||
|
||||
目前展示的 OSD 字段包括:
|
||||
|
||||
| 字段 | 图标 | 颜色规则 | 示例值 |
|
||||
|------|------|---------|--------|
|
||||
| 电量 | 🔋 battery_full | >50% 绿色, 20-50% 橙色, <20% 红色 | 85% |
|
||||
| 温度 | 🌡️ thermostat | 蓝色 | 25°C |
|
||||
| 风速 | 💨 air | 绿色 | 3.5 m/s |
|
||||
| 降雨量 | 💧 water_drop | 蓝色 | 小雨 / 12 mm |
|
||||
| 网络状态 | 📶 network_check | 灰色 | 等级 4 |
|
||||
| GPS | 🛰️ gps_fixed | 紫色 | 12 颗 |
|
||||
|
||||
## 📁 文件结构
|
||||
|
||||
```
|
||||
lib/features/v2/device_list/
|
||||
├── presentation/
|
||||
│ ├── pages/
|
||||
│ │ └── drone_station_detail_page.dart # 详情页(集成 OSD 卡片)
|
||||
│ └── widgets/
|
||||
│ └── drone_station_osd_card.dart # OSD 实时数据卡片组件
|
||||
```
|
||||
|
||||
## 🔧 技术实现
|
||||
|
||||
### 1. MQTT 数据订阅
|
||||
|
||||
```dart
|
||||
// 订阅机场 OSD topic
|
||||
final stationTopic = 'thing/product/$gatewaySn/osd';
|
||||
|
||||
// 监听数据流
|
||||
_subscription = _dataSource.stationOsdStream.listen((osd) {
|
||||
setState(() {
|
||||
_currentOsd = osd;
|
||||
_parseOsdFields(osd); // 解析并更新 UI
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 数据解析
|
||||
|
||||
```dart
|
||||
void _parseOsdFields(DroneOsdEntity osd) {
|
||||
final data = osd.rawData;
|
||||
|
||||
_osdFields = [
|
||||
{
|
||||
'icon': Icons.battery_full_rounded,
|
||||
'label': '电量',
|
||||
'value': data['battery'] != null ? '${data['battery']}%' : '未知',
|
||||
'color': _getBatteryColor(data['battery']),
|
||||
},
|
||||
// ... 其他字段
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 滑动卡片实现
|
||||
|
||||
使用 `PageView.builder` 实现左右滑动:
|
||||
|
||||
```dart
|
||||
PageView.builder(
|
||||
itemCount: _osdFields.length,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
_currentIndex = index;
|
||||
});
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final field = _osdFields[index];
|
||||
return _buildOsdPage(field, index);
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
## 🎨 UI 设计
|
||||
|
||||
### 卡片布局
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ [图标] 电量 ◀ ▶ │
|
||||
│ │
|
||||
│ 85% │
|
||||
│ │
|
||||
│ 1 / 6 │
|
||||
│ │
|
||||
│ ● ○ ○ ○ ○ ○ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### 状态指示器
|
||||
- **当前页**:长条蓝色圆点(16px)
|
||||
- **其他页**:短条灰色圆点(6px)
|
||||
|
||||
## 📊 数据映射规则
|
||||
|
||||
### 降雨量格式化
|
||||
|
||||
```dart
|
||||
String _formatRainfall(dynamic rainfall) {
|
||||
if (rainfall is String) {
|
||||
switch (rainfall.toLowerCase()) {
|
||||
case 'no_rain': return '无降雨';
|
||||
case 'light_rain': return '小雨';
|
||||
case 'moderate_rain': return '中雨';
|
||||
case 'heavy_rain': return '大雨';
|
||||
default: return '$rainfall mm';
|
||||
}
|
||||
}
|
||||
return '$rainfall mm';
|
||||
}
|
||||
```
|
||||
|
||||
### 电量颜色规则
|
||||
|
||||
```dart
|
||||
Color _getBatteryColor(dynamic battery) {
|
||||
final value = battery is num ? battery.toDouble() : 0.0;
|
||||
if (value > 50) return Color(0xFF00B42A); // 绿色
|
||||
if (value > 20) return Color(0xFFFF7D00); // 橙色
|
||||
return Color(0xFFF53F3F); // 红色
|
||||
}
|
||||
```
|
||||
|
||||
## 🔄 生命周期管理
|
||||
|
||||
### 启动监听
|
||||
```dart
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startListening(); // 开始监听 MQTT
|
||||
}
|
||||
```
|
||||
|
||||
### 停止监听
|
||||
```dart
|
||||
@override
|
||||
void dispose() {
|
||||
_stopListening(); // 取消订阅,释放资源
|
||||
super.dispose();
|
||||
}
|
||||
```
|
||||
|
||||
### 动态更新
|
||||
```dart
|
||||
@override
|
||||
void didUpdateWidget(DroneStationOsdCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// gatewaySn 或 isOnline 变化时重新订阅
|
||||
if (oldWidget.gatewaySn != widget.gatewaySn ||
|
||||
oldWidget.isOnline != widget.isOnline) {
|
||||
_stopListening();
|
||||
_startListening();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🚀 使用方法
|
||||
|
||||
### 在详情页中集成
|
||||
|
||||
```dart
|
||||
DroneStationOsdCard(
|
||||
gatewaySn: widget.station.gatewaySn,
|
||||
isOnline: detail.isOnline,
|
||||
)
|
||||
```
|
||||
|
||||
### 参数说明
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `gatewaySn` | String | 机场网关序列号(用于 MQTT topic) |
|
||||
| `isOnline` | bool | 机场是否在线(决定是否显示实时数据) |
|
||||
|
||||
## 📝 扩展建议
|
||||
|
||||
### 1. 添加更多 OSD 字段
|
||||
|
||||
在 `_parseOsdFields` 方法中添加新字段:
|
||||
|
||||
```dart
|
||||
{
|
||||
'icon': Icons.new_icon,
|
||||
'label': '新字段名称',
|
||||
'value': data['newField'] != null ? '${data['newField']}' : '未知',
|
||||
'color': const Color(0xFF165DFF),
|
||||
},
|
||||
```
|
||||
|
||||
### 2. 自定义滑动方向
|
||||
|
||||
修改 `PageView` 的 `scrollDirection`:
|
||||
|
||||
```dart
|
||||
PageView.builder(
|
||||
scrollDirection: Axis.vertical, // 改为上下滑动
|
||||
// ...
|
||||
)
|
||||
```
|
||||
|
||||
### 3. 自动轮播
|
||||
|
||||
添加定时器自动切换卡片:
|
||||
|
||||
```dart
|
||||
Timer.periodic(Duration(seconds: 3), (timer) {
|
||||
if (_osdFields.isNotEmpty) {
|
||||
setState(() {
|
||||
_currentIndex = (_currentIndex + 1) % _osdFields.length;
|
||||
});
|
||||
_pageController.animateToPage(_currentIndex, ...);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 4. 手势优化
|
||||
|
||||
添加双击放大、长按复制等功能:
|
||||
|
||||
```dart
|
||||
GestureDetector(
|
||||
onDoubleTap: () {
|
||||
// 双击放大
|
||||
},
|
||||
onLongPress: () {
|
||||
// 长按复制数值
|
||||
Clipboard.setData(ClipboardData(text: field['value']));
|
||||
},
|
||||
child: _buildOsdPage(field, index),
|
||||
)
|
||||
```
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **MQTT 连接**:确保应用已连接到 MQTT 服务器
|
||||
2. **topic 格式**:必须为 `thing/product/${gatewaySn}/osd`
|
||||
3. **数据格式**:MQTT 消息 payload 必须是 JSON 格式
|
||||
4. **内存管理**:页面销毁时必须取消订阅,避免内存泄漏
|
||||
5. **在线判断**:只有机场在线时才订阅 MQTT,离线时不订阅
|
||||
|
||||
## 🐛 调试技巧
|
||||
|
||||
### 查看 MQTT 日志
|
||||
|
||||
```dart
|
||||
debugPrint('🔊 [DroneStationOsdCard] 开始监听机场 OSD: ${widget.gatewaySn}');
|
||||
debugPrint('✅ [DroneStationOsdCard] 收到机场 OSD 数据');
|
||||
```
|
||||
|
||||
### 检查数据解析
|
||||
|
||||
```dart
|
||||
debugPrint('📊 [DroneStationOsdCard] OSD 原始数据: ${osd.rawData}');
|
||||
debugPrint('📊 [DroneStationOsdCard] 解析后字段数: ${_osdFields.length}');
|
||||
```
|
||||
|
||||
## 📦 依赖项
|
||||
|
||||
- `flutter_bloc`: 状态管理
|
||||
- `mqtt_client`: MQTT 通信
|
||||
- `equatable`: 实体类比较
|
||||
- `get_it`: 依赖注入
|
||||
|
||||
## ✅ 完成清单
|
||||
|
||||
- [x] 创建 `DroneStationOsdCard` 组件
|
||||
- [x] 实现 MQTT 数据订阅
|
||||
- [x] 实现滑动窗口 UI
|
||||
- [x] 实现数据解析和格式化
|
||||
- [x] 集成到详情页
|
||||
- [x] 添加状态管理(在线/离线/加载中)
|
||||
- [x] 添加生命周期管理
|
||||
- [x] 添加调试日志
|
||||
303
docs/ERROR_HANDLING_BEST_PRACTICE.md
Normal file
@@ -0,0 +1,303 @@
|
||||
# Flutter 项目统一错误处理规范
|
||||
|
||||
## 核心原则
|
||||
|
||||
**所有接口异常都不应该影响页面展示,只显示友好提示弹窗(2秒后自动消失)**
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 1. Cubit/Bloc 层处理
|
||||
|
||||
#### ✅ 正确做法
|
||||
|
||||
```dart
|
||||
class MyCubit extends Cubit<MyState> {
|
||||
Future<void> fetchData() async {
|
||||
try {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
|
||||
final result = await useCase.call(params);
|
||||
|
||||
result.fold(
|
||||
(failure) {
|
||||
// 🔥 设置错误信息 + 标记需要显示弹窗
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: ErrorHandler.getErrorMessage(failure.message),
|
||||
shouldShowError: true, // 关键!
|
||||
));
|
||||
},
|
||||
(data) {
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
data: data,
|
||||
));
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
// 🔥 捕获所有异常
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: ErrorHandler.getErrorMessage(e),
|
||||
shouldShowError: true, // 关键!
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### ❌ 错误做法
|
||||
|
||||
```dart
|
||||
// ❌ 不要抛出异常到页面层
|
||||
throw Exception('网络错误');
|
||||
|
||||
// ❌ 不要显示原始错误信息
|
||||
emit(state.copyWith(errorMessage: e.toString()));
|
||||
|
||||
// ❌ 不要让页面崩溃
|
||||
if (error) throw error;
|
||||
```
|
||||
|
||||
### 2. State 设计
|
||||
|
||||
```dart
|
||||
class MyState extends Equatable {
|
||||
final bool isLoading;
|
||||
final String? errorMessage;
|
||||
final bool shouldShowError; // 🔥 关键字段
|
||||
|
||||
const MyState({
|
||||
this.isLoading = false,
|
||||
this.errorMessage,
|
||||
this.shouldShowError = false, // 默认 false
|
||||
});
|
||||
|
||||
MyState copyWith({
|
||||
bool? isLoading,
|
||||
String? errorMessage,
|
||||
bool? shouldShowError,
|
||||
}) {
|
||||
return MyState(
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
errorMessage: errorMessage,
|
||||
shouldShowError: shouldShowError ?? false, // 🔥 默认重置为 false
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**关键点:**
|
||||
- `shouldShowError` 默认为 `false`
|
||||
- 每次 emit 时如果不传,会自动重置为 `false`
|
||||
- 这样可以确保错误弹窗只显示一次
|
||||
|
||||
### 3. 页面层监听
|
||||
|
||||
```dart
|
||||
BlocConsumer<MyCubit, MyState>(
|
||||
listener: (context, state) {
|
||||
// 🔥 自动显示错误弹窗
|
||||
if (state.shouldShowError && state.errorMessage != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.errorMessage!),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
// 页面正常渲染,不受错误影响
|
||||
if (state.isLoading) {
|
||||
return const CircularProgressIndicator();
|
||||
}
|
||||
|
||||
return YourContent();
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### 4. ErrorHandler 工具类
|
||||
|
||||
```dart
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ErrorHandler {
|
||||
/// 获取友好的错误消息
|
||||
static String getErrorMessage(Object error) {
|
||||
if (error is DioException) {
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return '网络连接超时,请检查网络设置';
|
||||
|
||||
case DioExceptionType.connectionError:
|
||||
return '网络连接失败,请检查网络';
|
||||
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = error.response?.statusCode;
|
||||
if (statusCode == 401) {
|
||||
return '登录已过期,请重新登录';
|
||||
} else if (statusCode == 403) {
|
||||
return '没有权限执行此操作';
|
||||
} else if (statusCode == 404) {
|
||||
return '请求的资源不存在';
|
||||
} else if (statusCode == 500) {
|
||||
return '服务器异常,请稍后重试';
|
||||
} else {
|
||||
return '服务器响应异常';
|
||||
}
|
||||
|
||||
default:
|
||||
return '请求失败,请稍后重试';
|
||||
}
|
||||
} else {
|
||||
return '操作失败,请稍后重试';
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
### DeviceTaskCubit 示例
|
||||
|
||||
```dart
|
||||
class DeviceTaskCubit extends Cubit<DeviceTaskState> {
|
||||
Future<void> cancelTask(String deviceId) async {
|
||||
final taskId = state.currentTaskId;
|
||||
if (taskId == null) {
|
||||
emit(state.copyWith(errorMessage: '无可用任务'));
|
||||
return;
|
||||
}
|
||||
|
||||
emit(state.copyWith(
|
||||
isLoading: true,
|
||||
operationType: DeviceTaskOperationType.cancel,
|
||||
));
|
||||
|
||||
try {
|
||||
final result = await _cancelTaskUseCase.call(params);
|
||||
|
||||
result.fold(
|
||||
(failure) {
|
||||
_logger.logWithLevel('❌ 取消任务失败: ${failure.message}');
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: ErrorHandler.getErrorMessage(failure.message),
|
||||
operationType: DeviceTaskOperationType.none,
|
||||
shouldShowError: true, // 🔥 标记需要显示弹窗
|
||||
));
|
||||
},
|
||||
(success) {
|
||||
_logger.logWithLevel('✅ 取消任务成功');
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
operationType: DeviceTaskOperationType.none,
|
||||
));
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.logWithLevel('❌ 取消任务异常: $e');
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: ErrorHandler.getErrorMessage(e),
|
||||
operationType: DeviceTaskOperationType.none,
|
||||
shouldShowError: true, // 🔥 标记需要显示弹窗
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 页面使用示例
|
||||
|
||||
```dart
|
||||
class TaskPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => GetIt.I<DeviceTaskCubit>(),
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: const Text('任务管理')),
|
||||
body: BlocConsumer<DeviceTaskCubit, DeviceTaskState>(
|
||||
listener: (context, state) {
|
||||
// 🔥 自动显示错误弹窗
|
||||
if (state.shouldShowError && state.errorMessage != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.errorMessage!),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
children: [
|
||||
// 页面内容不受错误影响
|
||||
Text('当前任务ID: ${state.currentTaskId ?? "无"}'),
|
||||
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<DeviceTaskCubit>().cancelTask(deviceId);
|
||||
},
|
||||
child: const Text('取消任务'),
|
||||
),
|
||||
|
||||
if (state.isLoading)
|
||||
const CircularProgressIndicator(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 错误提示文案规范
|
||||
|
||||
| 错误类型 | 提示文案 |
|
||||
|---------|---------|
|
||||
| 网络超时 | "网络连接超时,请检查网络设置" |
|
||||
| 连接失败 | "网络连接失败,请检查网络" |
|
||||
| 401 未授权 | "登录已过期,请重新登录" |
|
||||
| 403 禁止访问 | "没有权限执行此操作" |
|
||||
| 404 资源不存在 | "请求的资源不存在" |
|
||||
| 500 服务器错误 | "服务器异常,请稍后重试" |
|
||||
| 其他业务错误 | "操作失败,请稍后重试" |
|
||||
| 未知错误 | "未知错误,请稍后重试" |
|
||||
|
||||
**注意:**
|
||||
- ✅ 使用友好的中文提示
|
||||
- ❌ 不要显示 HTTP 状态码
|
||||
- ❌ 不要显示技术术语(如 "DioException"、"timeout")
|
||||
- ❌ 不要显示完整的错误堆栈
|
||||
|
||||
## 优势
|
||||
|
||||
1. **页面不崩溃** - 所有异常都被捕获
|
||||
2. **用户体验好** - 友好的中文提示
|
||||
3. **自动消失** - 2秒后弹窗自动关闭
|
||||
4. **不影响操作** - 用户可以继续使用页面
|
||||
5. **统一规范** - 全项目统一的错误处理方式
|
||||
|
||||
## 检查清单
|
||||
|
||||
在开发新功能时,确保:
|
||||
|
||||
- [ ] Cubit 中所有 try-catch 都使用了 `ErrorHandler.getErrorMessage()`
|
||||
- [ ] State 中有 `shouldShowError` 字段
|
||||
- [ ] 错误时设置 `shouldShowError: true`
|
||||
- [ ] copyWith 中 `shouldShowError` 默认为 `false`
|
||||
- [ ] 页面 BlocConsumer listener 中监听 `shouldShowError`
|
||||
- [ ] 使用 SnackBar 显示错误(2秒自动消失)
|
||||
- [ ] 不显示原始错误信息
|
||||
388
docs/ERROR_HANDLING_FIX.md
Normal file
@@ -0,0 +1,388 @@
|
||||
# 统一错误处理修复记录
|
||||
|
||||
## 问题描述
|
||||
|
||||
之前项目中存在多个地方直接将原始错误信息(如 `DioException [connection error]...`)显示在页面上,严重影响用户体验。这些错误包括:
|
||||
- 网络连接失败
|
||||
- HTTP 状态码错误
|
||||
- 超时错误
|
||||
- 其他技术异常
|
||||
|
||||
**问题表现:**
|
||||
- 全屏显示错误页面
|
||||
- 直接展示技术错误信息(如 `DioException`、`HTTP 500` 等)
|
||||
- 用户无法继续操作
|
||||
- 错误信息不友好,普通用户看不懂
|
||||
|
||||
## 解决方案
|
||||
|
||||
采用统一的错误处理机制,确保:
|
||||
1. ✅ **所有错误都被拦截**,不会直接显示原始错误
|
||||
2. ✅ **友好的中文提示**,使用 SnackBar 浮动显示 2 秒后自动消失
|
||||
3. ✅ **不影响页面渲染**,错误发生时页面保持不变,用户可以继续操作
|
||||
4. ✅ **统一的错误处理模式**,所有 BLoC/Cubit 都遵循相同规范
|
||||
|
||||
## 修复的文件列表
|
||||
|
||||
### 1. DeviceStatusBloc (设备状态)
|
||||
**文件路径:** `lib/features/v2/device_list/presentation/bloc/device_status_bloc.dart`
|
||||
|
||||
**修改内容:**
|
||||
- ✅ 添加 `ErrorHandler` 导入
|
||||
- ✅ 将 `emit(DeviceStatusError(e.toString()))` 改为使用 `ErrorHandler.getErrorMessage(e)`
|
||||
- ✅ 设置 `shouldShowError: true` 标记
|
||||
|
||||
**State 更新:**
|
||||
```dart
|
||||
class DeviceStatusError extends DeviceStatusState {
|
||||
final String message;
|
||||
final bool shouldShowError; // 🔥 新增
|
||||
|
||||
const DeviceStatusError({
|
||||
required this.message,
|
||||
this.shouldShowError = false,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**页面更新:**
|
||||
```dart
|
||||
// device_status_page.dart
|
||||
BlocConsumer<DeviceStatusBloc, DeviceStatusState>(
|
||||
listener: (context, state) {
|
||||
if (state is DeviceStatusError && state.shouldShowError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.message),
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is DeviceStatusError) {
|
||||
return const SizedBox.shrink(); // 🔥 不再显示全屏错误
|
||||
}
|
||||
// ... 正常内容
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. DroneStationBloc (无人机机场)
|
||||
**文件路径:** `lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart`
|
||||
|
||||
**修改内容:**
|
||||
- ✅ 添加 `ErrorHandler` 导入
|
||||
- ✅ 更新所有错误状态:
|
||||
- `DroneStationError`
|
||||
- `UAVDetailError`
|
||||
- `VideoStreamError`
|
||||
- `UavVideoStreamError`
|
||||
- ✅ 所有错误都设置 `shouldShowError: true`
|
||||
|
||||
**State 更新:**
|
||||
```dart
|
||||
// lib/features/v2/device_list/presentation/bloc/drone_station_state.dart
|
||||
class DroneStationError extends DroneStationState {
|
||||
final String message;
|
||||
final bool shouldShowError; // 🔥 新增
|
||||
|
||||
const DroneStationError({
|
||||
required this.message,
|
||||
this.shouldShowError = false,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**页面更新:**
|
||||
```dart
|
||||
// device_status_page.dart - _buildDroneStationList
|
||||
BlocConsumer<DroneStationBloc, DroneStationState>(
|
||||
listener: (context, state) {
|
||||
if (state is DroneStationError && state.shouldShowError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(...);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is DroneStationError) {
|
||||
return const SizedBox.shrink(); // 🔥 保持页面
|
||||
}
|
||||
// ... 正常内容
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. RobotListBloc (机器人列表)
|
||||
**文件路径:** `lib/features/v2/device_list/presentation/bloc/robot_list_bloc.dart`
|
||||
|
||||
**修改内容:**
|
||||
- ✅ 添加 `ErrorHandler` 导入
|
||||
- ✅ 将 `emit(RobotListError(e.toString()))` 改为使用 `ErrorHandler`
|
||||
- ✅ 设置 `shouldShowError: true`
|
||||
|
||||
**State 更新:**
|
||||
```dart
|
||||
class RobotListError extends RobotListState {
|
||||
final String message;
|
||||
final bool shouldShowError; // 🔥 新增
|
||||
|
||||
const RobotListError({
|
||||
required this.message,
|
||||
this.shouldShowError = false,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**页面更新:**
|
||||
```dart
|
||||
// robot_list_page.dart
|
||||
BlocConsumer<RobotListBloc, RobotListState>(
|
||||
listener: (context, state) {
|
||||
if (state is RobotListError && state.shouldShowError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(...);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is RobotListError) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
// ... 正常内容
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. HomeV2Bloc (首页 V2)
|
||||
**文件路径:** `lib/features/v2/home/presentation/bloc/home_v2_bloc.dart`
|
||||
|
||||
**修改内容:**
|
||||
- ✅ 添加 `ErrorHandler` 导入
|
||||
- ✅ 将所有 `emit(HomeV2Error(failure.message))` 改为使用 `ErrorHandler`
|
||||
- ✅ 设置 `shouldShowError: true`
|
||||
|
||||
**State 更新:**
|
||||
```dart
|
||||
class HomeV2Error extends HomeV2State {
|
||||
final String message;
|
||||
final bool shouldShowError; // 🔥 新增
|
||||
|
||||
const HomeV2Error({
|
||||
required this.message,
|
||||
this.shouldShowError = false,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**页面更新:**
|
||||
```dart
|
||||
// home_v2_page.dart
|
||||
BlocConsumer<HomeV2Bloc, HomeV2State>(
|
||||
listener: (context, state) {
|
||||
if (state is HomeV2Error && state.shouldShowError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(...);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is HomeV2Error) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
// ... 正常内容
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 统一错误处理流程
|
||||
|
||||
### 1. BLoC/Cubit 层
|
||||
```dart
|
||||
try {
|
||||
// 业务逻辑
|
||||
final result = await useCase.call(params);
|
||||
result.fold(
|
||||
(failure) => emit(ErrorState(
|
||||
message: ErrorHandler.getErrorMessage(failure.message),
|
||||
shouldShowError: true, // 🔥 关键:标记需要显示弹窗
|
||||
)),
|
||||
(data) => emit(SuccessState(data)),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(ErrorState(
|
||||
message: ErrorHandler.getErrorMessage(e),
|
||||
shouldShowError: true, // 🔥 关键:标记需要显示弹窗
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
### 2. State 层
|
||||
```dart
|
||||
class ErrorState extends SomeState {
|
||||
final String message;
|
||||
final bool shouldShowError; // 🔥 关键:用于标记是否显示弹窗
|
||||
|
||||
const ErrorState({
|
||||
required this.message,
|
||||
this.shouldShowError = false, // 默认不显示
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Page 层
|
||||
```dart
|
||||
BlocConsumer<SomeBloc, SomeState>(
|
||||
listener: (context, state) {
|
||||
// 🔥 监听错误并显示友好提示
|
||||
if (state is ErrorState && state.shouldShowError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.message),
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is ErrorState) {
|
||||
// 🔥 返回空容器,保持当前页面不变
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
// ... 正常内容
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ErrorHandler 工具类
|
||||
|
||||
**文件路径:** `lib/core/network/error_handler.dart`
|
||||
|
||||
**功能:**
|
||||
- 将所有 Dio 异常转换为友好的中文提示
|
||||
- 支持多种错误类型:
|
||||
- 连接超时 → "网络连接超时,请检查网络设置"
|
||||
- 连接失败 → "网络连接失败,请检查网络"
|
||||
- 401 → "登录已过期,请重新登录"
|
||||
- 403 → "没有权限执行此操作"
|
||||
- 404 → "请求的资源不存在"
|
||||
- 500 → "服务器异常,请稍后重试"
|
||||
- 其他 → "操作失败,请稍后重试"
|
||||
|
||||
**使用方法:**
|
||||
```dart
|
||||
// 在 BLoC/Cubit 中
|
||||
String friendlyMessage = ErrorHandler.getErrorMessage(error);
|
||||
|
||||
// 或在页面中直接显示
|
||||
ErrorHandler.handleError(context, error);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 修复效果对比
|
||||
|
||||
### 修复前 ❌
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ │
|
||||
│ ⚠️ Error Icon │
|
||||
│ │
|
||||
│ DioException [connection │
|
||||
│ error]: The connection │
|
||||
│ errored: Failed host │
|
||||
│ lookup... │
|
||||
│ │
|
||||
│ [ 重试 ] │
|
||||
│ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
- 全屏错误页面
|
||||
- 显示原始技术错误
|
||||
- 用户无法继续操作
|
||||
- 体验极差
|
||||
|
||||
### 修复后 ✅
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ │
|
||||
│ [正常页面内容] │
|
||||
│ │
|
||||
│ 用户可以看到页面 │
|
||||
│ 可以继续操作 │
|
||||
│ │
|
||||
└─────────────────────────────┘
|
||||
↓
|
||||
┌──────────────┐
|
||||
│ 网络连接失败 │ ← SnackBar 浮动显示
|
||||
│ 请检查网络 │ 2秒后自动消失
|
||||
└──────────────┘
|
||||
```
|
||||
- 页面保持不变
|
||||
- 友好的中文提示
|
||||
- SnackBar 浮动显示
|
||||
- 2秒自动消失
|
||||
- 用户可以继续操作
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### ✅ DO - 应该这样做
|
||||
1. 所有 BLoC/Cubit 的错误处理都使用 `ErrorHandler.getErrorMessage()`
|
||||
2. 所有错误状态都设置 `shouldShowError: true`
|
||||
3. 页面使用 `BlocConsumer` 监听错误
|
||||
4. 错误时使用 `SizedBox.shrink()` 保持页面
|
||||
5. SnackBar 设置为 `floating` 和 `orange` 背景
|
||||
|
||||
### ❌ DON'T - 不要这样做
|
||||
1. ❌ 直接使用 `e.toString()` 作为错误消息
|
||||
2. ❌ 在页面中显示全屏错误页面
|
||||
3. ❌ 直接展示原始技术错误(DioException、HTTP 状态码等)
|
||||
4. ❌ 让用户点击"重试"按钮才能恢复
|
||||
5. ❌ 不同页面使用不同的错误处理方式
|
||||
|
||||
---
|
||||
|
||||
## 后续工作
|
||||
|
||||
### 待迁移的 BLoC/Cubit
|
||||
以下 BLoC/Cubit 可能还需要迁移到统一的错误处理模式:
|
||||
- [ ] 其他未检查的 BLoC
|
||||
- [ ] 第三方库相关的错误处理
|
||||
- [ ] WebSocket 连接的错误处理
|
||||
|
||||
### 优化建议
|
||||
1. 考虑为不同类型的错误设置不同的 SnackBar 颜色
|
||||
2. 可以添加错误日志上报功能
|
||||
3. 可以考虑添加错误重试机制(在后台自动重试)
|
||||
4. 可以为特定场景定制错误提示文案
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
通过本次修复,我们实现了:
|
||||
✅ 统一的错误处理机制
|
||||
✅ 友好的用户提示
|
||||
✅ 不影响页面渲染
|
||||
✅ 提升用户体验
|
||||
✅ 代码规范统一
|
||||
|
||||
**核心原则:**
|
||||
> 任何接口异常都不应该影响页面的展示,给个几秒弹窗就行!
|
||||
|
||||
---
|
||||
|
||||
**修复日期:** 2026-06-15
|
||||
**修复人员:** AI Assistant
|
||||
**影响范围:** 设备管理、首页相关的所有 BLoC/Cubit
|
||||
520
docs/SOP_使用文档.md
Normal file
@@ -0,0 +1,520 @@
|
||||
# 迈步 Satabot App 使用说明书
|
||||
|
||||
> 版本:1.0.0 | 适用对象:终端用户(运维人员)
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [应用简介](#1-应用简介)
|
||||
2. [安装与登录](#2-安装与登录)
|
||||
3. [首页](#3-首页)
|
||||
4. [设备管理](#4-设备管理)
|
||||
5. [机器人控制](#5-机器人控制)
|
||||
6. [无人机巡检](#6-无人机巡检)
|
||||
7. [告警中心](#7-告警中心)
|
||||
8. [工单管理](#8-工单管理)
|
||||
9. [问题上报](#9-问题上报)
|
||||
10. [我的](#10-我的)
|
||||
11. [蓝牙设备绑定](#11-蓝牙设备绑定)
|
||||
12. [常见问题](#12-常见问题)
|
||||
|
||||
---
|
||||
|
||||
## 1. 应用概述
|
||||
|
||||
### 1.1 产品简介
|
||||
|
||||
迈步 Satabot 是一款面向光伏电站的智能管理移动应用,为电站运维人员提供一站式的设备监控、远程控制、告警处理和工单管理能力。通过本 App,运维人员可以随时随地掌握电站运行状况,快速响应设备异常,提升运维效率。
|
||||
|
||||
### 1.2 适用人群
|
||||
|
||||
| 角色 | 说明 |
|
||||
|------|------|
|
||||
| 电站运维人员 | 日常巡检、设备监控、告警处理、工单执行 |
|
||||
| 设备管理人员 | 设备绑定、参数配置、运行状态查看 |
|
||||
| 管理人员 | 电站发电数据查看、工单统计 |
|
||||
|
||||
### 1.3 核心功能
|
||||
|
||||
| 功能模块 | 说明 |
|
||||
|---------|------|
|
||||
| 首页 | 电站发电概览、实时功率、设备统计、快捷入口 |
|
||||
| 设备管理 | 设备列表、分类筛选、搜索、蓝牙扫描、设备详情 |
|
||||
| 机器人控制 | 远程方向控制、任务管理、运行参数设置 |
|
||||
| 无人机巡检 | 机场状态、实时监控、视频直播、飞行任务管理 |
|
||||
| 告警中心 | 告警统计、告警处理、派发工单、AI 诊断 |
|
||||
| 工单管理 | 工单全流程(新建→执行→完成/挂起/转派) |
|
||||
| 问题上报 | 问题类型选择、媒体上传、设备关联 |
|
||||
| 个人中心 | 个人信息、系统设置、夜间模式、导航自定义 |
|
||||
|
||||
### 1.4 设备类型
|
||||
|
||||
本 App 管理以下类型的设备:
|
||||
|
||||
| 设备类型 | 说明 |
|
||||
|---------|------|
|
||||
| 光伏逆变器 | 将直流电转换为交流电的核心设备 |
|
||||
| 汇流箱 | 汇集多个光伏组件的直流电 |
|
||||
| 光伏组件 | 将太阳能转化为直流电的组件 |
|
||||
| 监控设备 | 数据采集与传输设备 |
|
||||
| 清洁除草机器人 | MC700 系列,光伏面板清洁除草机器人 |
|
||||
| 无人机机场 | 无人机自动机巢,含无人机巡检 |
|
||||
|
||||
### 1.5 底部导航栏
|
||||
|
||||
App 底部有以下功能页签,可在"我的 → 底部导航设置"中自定义显示和排序:
|
||||
|
||||
| 页签 | 名称 | 说明 |
|
||||
|------|------|------|
|
||||
| 首页 | 电站概览 | 发电数据、设备统计、快捷入口 |
|
||||
| 设备 | 设备管理 | 设备列表、机器人、无人机 |
|
||||
| 告警 | 告警中心 | 查看和处理设备告警 |
|
||||
| 工单 | 工单管理 | 工单全流程管理 |
|
||||
| 上报 | 问题上报 | 上报设备问题 |
|
||||
| 我的 | 个人中心 | 个人信息、系统设置 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 安装与登录
|
||||
|
||||
### 2.1 安装
|
||||
|
||||
- **Android**:扫描二维码下载 APK 安装
|
||||
- **iOS**:扫描二维码跳转 App Store 安装
|
||||
- 安装时如提示"未知来源",请在手机设置中允许安装
|
||||
|
||||
### 2.2 登录
|
||||
|
||||
1. 打开 App,进入登录页面
|
||||
2. 输入**用户名**和**密码**
|
||||
3. 勾选"已阅读并同意用户协议"
|
||||
4. 点击**登录**按钮
|
||||
5. 登录成功后自动跳转到首页
|
||||
|
||||
> **提示**:如果忘记密码,请联系管理员重置。
|
||||
|
||||
### 2.3 注册
|
||||
|
||||
1. 在登录页面点击**注册账号**
|
||||
2. 填写用户名、密码等信息
|
||||
3. 提交后等待管理员审核
|
||||
|
||||
### 2.4 修改密码
|
||||
|
||||
1. 登录后进入"我的"页面
|
||||
2. 点击"修改密码"
|
||||
3. 输入旧密码和新密码
|
||||
4. 提交完成修改
|
||||
|
||||
---
|
||||
|
||||
## 3. 首页
|
||||
|
||||
### 3.1 页面说明
|
||||
|
||||
首页展示当前选中电站的整体运行情况。
|
||||
|
||||
### 3.2 切换电站
|
||||
|
||||
- 页面顶部左侧显示当前电站名称
|
||||
- 点击电站名称可下拉切换其他电站
|
||||
- 切换后所有数据自动刷新
|
||||
|
||||
### 3.3 功能区域
|
||||
|
||||
| 区域 | 说明 |
|
||||
|------|------|
|
||||
| 天气信息 | 显示电站所在地天气 |
|
||||
| 电站概览 | 装机容量、今日发电量等 |
|
||||
| 功率卡片 | 实时功率、今日累计发电量、功率趋势图 |
|
||||
| 统计网格 | 设备总数、在线数、离线数、异常数 |
|
||||
| 工单卡片 | 待处理工单数量,点击进入工单页 |
|
||||
| 快捷入口 | 设备管理、告警、工单等快速跳转 |
|
||||
| TCP 状态 | 右上角圆点显示通信连接状态(绿色=正常) |
|
||||
|
||||
### 3.4 下拉刷新
|
||||
|
||||
- 在首页任意位置**下拉**可刷新所有数据
|
||||
|
||||
---
|
||||
|
||||
## 4. 设备管理
|
||||
|
||||
### 4.1 进入设备页
|
||||
|
||||
点击底部导航栏的**设备**页签。
|
||||
|
||||
### 4.2 切换电站
|
||||
|
||||
- 页面顶部左侧可切换电站,切换后设备列表自动刷新
|
||||
|
||||
### 4.3 搜索设备
|
||||
|
||||
- 页面上方搜索框中输入设备名称
|
||||
- 点击搜索按钮或按确认键进行搜索
|
||||
|
||||
### 4.4 设备分类筛选
|
||||
|
||||
页面中部有设备类型筛选条:
|
||||
|
||||
| 筛选项 | 说明 |
|
||||
|--------|------|
|
||||
| 全部 | 显示所有设备 |
|
||||
| 逆变器 | 仅显示逆变器设备 |
|
||||
| 汇流箱 | 仅显示汇流箱设备 |
|
||||
| 组件 | 仅显示光伏组件 |
|
||||
| 监控 | 仅显示监控设备 |
|
||||
| 机器人 | 仅显示清洁除草机器人 |
|
||||
| 无人机机场 | 仅显示无人机机巢 |
|
||||
|
||||
### 4.5 添加设备
|
||||
|
||||
- 点击页面右上角 **+** 按钮
|
||||
- 可选择**扫一扫**(扫码绑定)或**蓝牙**(蓝牙搜索绑定)
|
||||
|
||||
### 4.6 蓝牙扫描
|
||||
|
||||
- 点击搜索框旁边的**蓝牙图标**
|
||||
- App 开始扫描附近蓝牙设备
|
||||
- 扫描结果中,名称为纯数字的设备(时间戳格式)自动**置顶**显示
|
||||
- 可在搜索框中输入关键词过滤设备
|
||||
- 点击设备可进入设备详情页
|
||||
|
||||
### 4.7 设备详情
|
||||
|
||||
点击设备列表中的设备,进入设备详情页:
|
||||
|
||||
- **光伏设备**:显示设备运行参数(功率、电压、电流、温度等)
|
||||
- **机器人**:进入机器人控制页(详见第 5 章)
|
||||
- **无人机机场**:进入机场详情页(详见第 6 章)
|
||||
|
||||
---
|
||||
|
||||
## 5. 机器人控制
|
||||
|
||||
### 5.1 进入方式
|
||||
|
||||
设备页 → 筛选"机器人" → 点击机器人设备
|
||||
|
||||
### 5.2 机器人详情页
|
||||
|
||||
| 区域 | 说明 |
|
||||
|------|------|
|
||||
| 设备信息卡 | 显示机器人名称、序列号、在线状态、电量 |
|
||||
| 状态栏 | 运行状态、工作模式、当前位置 |
|
||||
| 控制面板 | 方向控制(前进/后退/左转/右转/停止) |
|
||||
| 检查清单 | 任务执行前的检查项 |
|
||||
| 任务信息 | 当前任务详情 |
|
||||
| 操作按钮 | 启动任务、暂停、取消等 |
|
||||
|
||||
### 5.3 远程控制
|
||||
|
||||
1. 进入机器人详情页后,App 自动申请远程控制权限
|
||||
2. 获得权限后可使用控制面板上的方向按钮
|
||||
3. 点击**停止**按钮停止移动
|
||||
4. 退出页面时自动释放控制权限
|
||||
|
||||
### 5.4 任务管理
|
||||
|
||||
| 操作 | 说明 |
|
||||
|------|------|
|
||||
| 创建任务 | 设置工作区域参数后创建清洁任务 |
|
||||
| 启动任务 | 选择任务后点击启动 |
|
||||
| 暂停任务 | 执行中点击暂停 |
|
||||
| 恢复任务 | 暂停后点击恢复 |
|
||||
| 取消任务 | 取消当前任务 |
|
||||
|
||||
### 5.5 运行参数设置
|
||||
|
||||
1. 在机器人详情页点击设置图标
|
||||
2. 可调整运行参数(速度、切割高度等)
|
||||
3. 点击保存生效
|
||||
|
||||
---
|
||||
|
||||
## 6. 无人机巡检
|
||||
|
||||
### 6.1 进入方式
|
||||
|
||||
设备页 → 筛选"无人机机场" → 点击机场设备
|
||||
|
||||
### 6.2 机场详情页
|
||||
|
||||
| 信息 | 说明 |
|
||||
|------|------|
|
||||
| 机场状态 | 在线状态、仓内温度 |
|
||||
| 无人机状态 | 电量、位置、飞行状态 |
|
||||
| 视频监控 | 仓内监控画面 |
|
||||
|
||||
### 6.3 实时监控
|
||||
|
||||
- 点击"实时监控"进入无人机监控页
|
||||
- 显示无人机 OSD 数据:经纬度、高度、速度、电量、航向等
|
||||
|
||||
### 6.4 视频直播
|
||||
|
||||
1. 点击"视频直播"进入直播页面
|
||||
2. 可切换摄像头:广角 / 变焦 / 红外
|
||||
3. 支持画面全屏、方向控制
|
||||
|
||||
### 6.5 飞行任务
|
||||
|
||||
| 操作 | 说明 |
|
||||
|------|------|
|
||||
| 查看任务 | 查看已有飞行任务列表 |
|
||||
| 创建任务 | 选择航线、设置参数后创建 |
|
||||
| 执行任务 | 选择任务后点击执行 |
|
||||
| 暂停任务 | 飞行中可暂停 |
|
||||
| 返航 | 一键返航 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 告警中心
|
||||
|
||||
### 7.1 进入方式
|
||||
|
||||
点击底部导航栏的**告警**页签。
|
||||
|
||||
### 7.2 告警统计
|
||||
|
||||
- 页面上方显示告警数量统计卡片
|
||||
- 按状态分类:待处理、处理中、已处理、已关闭
|
||||
|
||||
### 7.3 告警列表
|
||||
|
||||
- 按状态 Tab 分类展示
|
||||
- 下拉刷新,上拉加载更多
|
||||
- 点击告警条目进入详情页
|
||||
|
||||
### 7.4 告警详情
|
||||
|
||||
| 信息 | 说明 |
|
||||
|------|------|
|
||||
| 告警信息 | 告警名称、级别、时间、设备 |
|
||||
| 告警详情 | 详细描述、可能原因 |
|
||||
| AI 诊断 | AI 辅助分析告警原因和建议 |
|
||||
|
||||
### 7.5 处理告警
|
||||
|
||||
1. 在告警详情页点击"处理"
|
||||
2. 选择处理方式(确认/关闭)
|
||||
3. 填写处理说明
|
||||
4. 提交完成处理
|
||||
|
||||
### 7.6 派发工单
|
||||
|
||||
1. 在告警详情页点击"派发工单"
|
||||
2. 选择工单模型
|
||||
3. 填写派发信息
|
||||
4. 提交后自动生成工单
|
||||
|
||||
---
|
||||
|
||||
## 8. 工单管理
|
||||
|
||||
### 8.1 进入方式
|
||||
|
||||
点击底部导航栏的**工单**页签。
|
||||
|
||||
### 8.2 工单统计
|
||||
|
||||
- 页面上方显示各状态工单数量统计
|
||||
- 点击数字可快速筛选对应状态的工单
|
||||
|
||||
### 8.3 工单列表
|
||||
|
||||
- 按状态 Tab 分类展示:待处理、处理中、已完成、已挂起
|
||||
- 下拉刷新,上拉加载更多
|
||||
- 点击工单条目进入详情页
|
||||
|
||||
### 8.4 工单详情
|
||||
|
||||
显示工单编号、类型、设备、描述、处理记录等信息。
|
||||
|
||||
### 8.5 工单操作
|
||||
|
||||
| 操作 | 说明 |
|
||||
|------|------|
|
||||
| 开始执行 | 接收工单后开始处理 |
|
||||
| 完成 | 处理完毕后标记完成 |
|
||||
| 挂起 | 暂时无法处理,挂起工单 |
|
||||
| 恢复 | 挂起的工单恢复处理 |
|
||||
| 转派 | 将工单转给其他人员 |
|
||||
|
||||
### 8.6 新建工单
|
||||
|
||||
1. 在工单页面点击"上报"按钮
|
||||
2. 选择工单类型
|
||||
3. 填写问题描述
|
||||
4. 关联设备
|
||||
5. 提交生成工单
|
||||
|
||||
---
|
||||
|
||||
## 9. 问题上报
|
||||
|
||||
### 9.1 进入方式
|
||||
|
||||
点击底部导航栏的**上报**页签。
|
||||
|
||||
### 9.2 上报流程
|
||||
|
||||
1. **选择上报类型**:故障 / 建议 / 投诉等
|
||||
2. **选择严重等级**:紧急 / 严重 / 一般 / 轻微
|
||||
3. **选择设备**:从设备列表中关联相关设备
|
||||
4. **上传媒体**:拍照、录像或选择图片/视频
|
||||
5. **填写描述**:文字描述问题详情
|
||||
6. 点击**提交**完成上报
|
||||
|
||||
### 9.3 媒体上传
|
||||
|
||||
- 点击拍照按钮直接拍照
|
||||
- 或从手机相册选择图片/视频
|
||||
- 点击已上传的媒体可预览大图/视频
|
||||
|
||||
---
|
||||
|
||||
## 10. 我的
|
||||
|
||||
### 10.1 进入方式
|
||||
|
||||
点击底部导航栏的**我的**页签。
|
||||
|
||||
### 10.2 功能列表
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 用户信息卡 | 头像、昵称、联系方式,点击进入编辑页 |
|
||||
| 快捷操作 | 常用功能快速入口 |
|
||||
| 个人资料 | 修改昵称、头像等 |
|
||||
| 系统设置 | 夜间模式、语言切换、清除缓存 |
|
||||
| 底部导航设置 | 自定义底部 Tab 显示/隐藏和排序 |
|
||||
| 版本更新 | 检查并下载新版本 |
|
||||
| 退出登录 | 清除登录状态返回登录页 |
|
||||
|
||||
### 10.3 夜间模式
|
||||
|
||||
- 我的 → 系统设置 → 夜间模式开关
|
||||
- 开启后 App 切换为深色主题
|
||||
|
||||
### 10.4 底部导航设置
|
||||
|
||||
1. 我的 → 底部导航设置(或首页 → 设置入口)
|
||||
2. 可开关每个 Tab 的显示/隐藏
|
||||
3. 至少保留一个 Tab
|
||||
4. 可调整 Tab 排列顺序
|
||||
5. 设置即时生效
|
||||
|
||||
---
|
||||
|
||||
## 11. 蓝牙设备绑定
|
||||
|
||||
### 11.1 方式一:扫码绑定
|
||||
|
||||
1. 设备页 → 点击右上角 **+**
|
||||
2. 选择"扫一扫"
|
||||
3. 扫描设备上的二维码
|
||||
4. App 获取设备名称(如 `MC700AIR-CN-JS-1777272534998-0000000A-75`)
|
||||
5. 进入绑定页面
|
||||
6. 选择所属组织和场站
|
||||
7. 点击提交完成绑定
|
||||
|
||||
### 11.2 方式二:蓝牙搜索绑定
|
||||
|
||||
1. 设备页 → 点击搜索框旁的**蓝牙图标**
|
||||
2. App 扫描附近蓝牙设备
|
||||
3. 在搜索框输入关键词可过滤设备
|
||||
4. 蓝牙名为纯数字的设备(时间戳格式)自动置顶
|
||||
5. 点击目标设备进入设备详情页
|
||||
6. 点击**连接设备**按钮连接蓝牙
|
||||
7. 连接成功后,**绑定设备**按钮变为可点击状态
|
||||
8. 未连接时"绑定设备"按钮为灰色,不可点击
|
||||
9. 点击"绑定设备"
|
||||
- App 自动获取全部设备列表
|
||||
- 用蓝牙名(时间戳,如 `1777272534998`)匹配设备
|
||||
- 匹配成功后自动跳转到绑定页面
|
||||
10. 选择所属组织和场站
|
||||
11. 点击提交完成绑定
|
||||
|
||||
### 11.3 设备名说明
|
||||
|
||||
| 位置 | 名称示例 | 说明 |
|
||||
|------|---------|------|
|
||||
| 蓝牙广播名 | `1777272534998` | 纯数字时间戳 |
|
||||
| 设备完整名 | `MC700AIR-CN-JS-1777272534998-0000000A-75` | 设备序列号 |
|
||||
| 匹配方式 | 完整名包含蓝牙名 | 蓝牙名是完整名的一部分 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 常见问题
|
||||
|
||||
### Q1:登录提示"登录失败"?
|
||||
|
||||
- 检查用户名和密码是否正确
|
||||
- 检查网络是否正常
|
||||
- 联系管理员确认账号是否已开通
|
||||
|
||||
### Q2:蓝牙扫描不到设备?
|
||||
|
||||
- 确认手机蓝牙已开启
|
||||
- 确认已授予 App 蓝牙权限和定位权限
|
||||
- 靠近设备后重试
|
||||
- 安卓手机需开启"位置信息"开关
|
||||
|
||||
### Q3:蓝牙连接失败?
|
||||
|
||||
- 确认设备已通电
|
||||
- 确认设备未被其他手机连接
|
||||
- 靠近设备重试
|
||||
- 重启手机蓝牙后重试
|
||||
|
||||
### Q4:绑定设备按钮灰色不可点击?
|
||||
|
||||
- "绑定设备"按钮需要**先连接蓝牙**后才能使用
|
||||
- 请先点击"连接设备"按钮,连接成功后按钮变为可点击
|
||||
|
||||
### Q5:点击绑定设备后提示"未匹配到设备"?
|
||||
|
||||
- 确认设备已录入后台系统
|
||||
- 联系管理员确认设备名中是否包含当前蓝牙名(时间戳)
|
||||
|
||||
### Q6:首页数据显示"加载失败"?
|
||||
|
||||
- 检查网络连接
|
||||
- 点击"重试"按钮
|
||||
- 确认当前账号有权限访问该电站
|
||||
|
||||
### Q7:机器人控制无反应?
|
||||
|
||||
- 确认机器人在线
|
||||
- 确认已获得远程控制权限
|
||||
- 确认机器人未在执行其他任务
|
||||
- 退出页面重新进入
|
||||
|
||||
### Q8:无人机视频不显示?
|
||||
|
||||
- 确认无人机和机场在线
|
||||
- 确认网络畅通
|
||||
- 尝试切换摄像头
|
||||
|
||||
### Q9:TCP 状态显示异常?
|
||||
|
||||
- 首页右上角圆点显示 TCP 连接状态
|
||||
- 红色表示未连接,绿色表示正常
|
||||
- 如持续异常,检查网络或联系管理员
|
||||
|
||||
### Q10:App 版本过旧无法使用?
|
||||
|
||||
- 进入"我的"→"版本更新"检查新版本
|
||||
- 下载并安装最新版本
|
||||
|
||||
---
|
||||
|
||||
## 联系与反馈
|
||||
|
||||
如遇到本说明书未涵盖的问题,请通过以下方式联系:
|
||||
|
||||
- **客服电话**:请联系管理员获取
|
||||
- **问题反馈**:App 内"上报"页面提交问题反馈
|
||||
- **技术支持**:请联系管理员获取联系方式
|
||||
197
docs/TAB_CONFIG_DEVELOPMENT_GUIDE.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Tab 配置系统开发规范
|
||||
|
||||
## 📋 功能说明
|
||||
|
||||
Tab 配置系统支持动态开关控制底部导航栏的 Tab 项显示/隐藏,配置自动保存到 SharedPreferences。
|
||||
|
||||
## 🔧 添加新 Tab 项的步骤
|
||||
|
||||
当需要添加新的 Tab 项时,必须按以下顺序修改 **3 个文件**:
|
||||
|
||||
### 1️⃣ 修改 `tab_config.dart` - 添加默认配置
|
||||
|
||||
**文件路径:** `lib/features/main_container/domain/tab_config.dart`
|
||||
|
||||
在 `TabConfig.defaultConfig()` 方法的 `items` 列表中添加新的 `TabConfigItem`:
|
||||
|
||||
```dart
|
||||
TabConfigItem(
|
||||
id: 'your_tab_id', // 唯一标识符(小写+下划线)
|
||||
name: '显示名称', // 中文名称(简洁,建议2-4个字)
|
||||
nameEn: 'Display Name', // 英文名称
|
||||
icon: 'icon_name_rounded', // 图标名称(Material Icons)
|
||||
isEnabled: true, // 默认是否启用(true/false)
|
||||
order: 7, // 排序序号(从1开始递增)
|
||||
),
|
||||
```
|
||||
|
||||
**注意事项:**
|
||||
- `id` 必须唯一,建议使用有意义的英文标识
|
||||
- `name` 要简洁,符合 TAB 命名规范
|
||||
- `order` 决定 Tab 在导航栏中的显示顺序
|
||||
- `isEnabled` 控制默认是否显示该 Tab
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ 修改 `custom_main_container.dart` - 添加页面映射
|
||||
|
||||
**文件路径:** `lib/features/main_container/presentation/pages/custom_main_container.dart`
|
||||
|
||||
#### 2.1 导入新页面
|
||||
|
||||
在文件顶部添加新页面的 import:
|
||||
|
||||
```dart
|
||||
import 'package:maibu_satabot_v2/features/xxx/presentation/pages/your_page.dart';
|
||||
```
|
||||
|
||||
#### 2.2 在 `_buildPages` 方法中添加 case
|
||||
|
||||
```dart
|
||||
List<Widget> _buildPages(List<dynamic> enabledTabs) {
|
||||
return enabledTabs.map((tab) {
|
||||
switch (tab.id) {
|
||||
case 'home_v2':
|
||||
return const HomeV2Page();
|
||||
case 'home':
|
||||
return const HomePage();
|
||||
case 'device':
|
||||
return const DeviceStatusPage();
|
||||
case 'ai':
|
||||
return const AiPage();
|
||||
case 'warning':
|
||||
return const WarningCenterPage();
|
||||
case 'my':
|
||||
return const MyPage();
|
||||
case 'your_tab_id': // 👈 新增这个 case
|
||||
return const YourPage();
|
||||
default:
|
||||
return const HomePage();
|
||||
}
|
||||
}).toList();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ 修改 `tab_settings_page.dart` - 添加图标映射
|
||||
|
||||
**文件路径:** `lib/features/main_container/presentation/pages/tab_settings_page.dart`
|
||||
|
||||
在 `_getIconData` 方法中添加新图标的映射:
|
||||
|
||||
```dart
|
||||
IconData _getIconData(String iconName) {
|
||||
switch (iconName) {
|
||||
case 'home_rounded':
|
||||
return Icons.home_rounded;
|
||||
case 'grid_view_rounded':
|
||||
return Icons.grid_view_rounded;
|
||||
case 'devices_rounded':
|
||||
return Icons.devices_rounded;
|
||||
case 'auto_awesome_rounded':
|
||||
return Icons.auto_awesome_rounded;
|
||||
case 'warning_amber_rounded':
|
||||
return Icons.warning_amber_rounded;
|
||||
case 'person_rounded':
|
||||
return Icons.person_rounded;
|
||||
case 'your_icon_name': // 👈 新增这个 case
|
||||
return Icons.your_icon_name;
|
||||
default:
|
||||
return Icons.home_rounded;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意:** 如果使用的是 Material Icons 标准图标,通常不需要修改此文件,除非使用了特殊图标。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 检查清单
|
||||
|
||||
添加新 Tab 后,请确认:
|
||||
|
||||
- [ ] 已在 `tab_config.dart` 中添加 `TabConfigItem` 配置
|
||||
- [ ] 已在 `custom_main_container.dart` 中导入新页面
|
||||
- [ ] 已在 `custom_main_container.dart` 的 `switch` 中添加 case 分支
|
||||
- [ ] 已在 `tab_settings_page.dart` 中添加图标映射(如需要)
|
||||
- [ ] `id`、`order` 没有与其他 Tab 冲突
|
||||
- [ ] 测试开关功能是否正常
|
||||
- [ ] 测试切换 Tab 是否正常显示对应页面
|
||||
|
||||
---
|
||||
|
||||
## 🚀 完整示例:添加"消息"Tab
|
||||
|
||||
假设要添加一个"消息"Tab,显示消息中心页面:
|
||||
|
||||
### 步骤 1:修改 `tab_config.dart`
|
||||
|
||||
```dart
|
||||
TabConfigItem(
|
||||
id: 'message',
|
||||
name: '消息',
|
||||
nameEn: 'Messages',
|
||||
icon: 'notifications_rounded',
|
||||
isEnabled: true,
|
||||
order: 7,
|
||||
),
|
||||
```
|
||||
|
||||
### 步骤 2:修改 `custom_main_container.dart`
|
||||
|
||||
```dart
|
||||
// 顶部添加导入
|
||||
import 'package:maibu_satabot_v2/features/message/presentation/pages/message_page.dart';
|
||||
|
||||
// 在 _buildPages 方法中添加
|
||||
case 'message':
|
||||
return const MessagePage();
|
||||
```
|
||||
|
||||
### 步骤 3:修改 `tab_settings_page.dart`
|
||||
|
||||
```dart
|
||||
case 'notifications_rounded':
|
||||
return Icons.notifications_rounded;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 常见问题
|
||||
|
||||
### Q1: 添加新 Tab 后看不到?
|
||||
**A:** 点击 Tab 配置页面的"重置为默认"按钮,或清除应用数据重新运行。
|
||||
|
||||
### Q2: 点击 Tab 后页面不显示?
|
||||
**A:** 检查 `custom_main_container.dart` 中的 `switch-case` 是否正确添加了新 Tab 的映射。
|
||||
|
||||
### Q3: 图标显示不正确?
|
||||
**A:** 检查 `tab_settings_page.dart` 中的 `_getIconData` 方法是否添加了对应的图标映射。
|
||||
|
||||
### Q4: Tab 顺序不对?
|
||||
**A:** 检查 `tab_config.dart` 中各 Tab 的 `order` 值,确保按期望顺序排列。
|
||||
|
||||
---
|
||||
|
||||
## 📝 核心原理
|
||||
|
||||
1. **配置管理:** `TabConfigCubit` 管理所有 Tab 的配置和选中状态
|
||||
2. **持久化:** 配置自动保存到 SharedPreferences,重启应用后保持
|
||||
3. **动态渲染:** 底部导航栏根据 `enabledItems` 动态渲染启用的 Tab
|
||||
4. **页面切换:** 使用 `IndexedStack` 保持各页面状态,通过 `selectedIndex` 切换
|
||||
|
||||
---
|
||||
|
||||
## 🎯 最佳实践
|
||||
|
||||
1. **Tab 命名:** 保持简洁,中文 2-4 个字,避免过长
|
||||
2. **默认状态:** 新功能建议设置 `isEnabled: false`,通过灰度发布逐步开放
|
||||
3. **排序规划:** 预留 order 间隔(如 10, 20, 30),方便后续插入新 Tab
|
||||
4. **图标选择:** 优先使用 Material Icons 的 `_rounded` 版本,风格统一
|
||||
5. **测试验证:** 每次修改后测试开关、切换、持久化等功能
|
||||
|
||||
---
|
||||
|
||||
**最后更新:** 2026-05-20
|
||||
**维护者:** 开发团队
|
||||
195
docs/UAV_VIDEO_COMPLETION_SUMMARY.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# 无人机实时视频接口对接完成总结
|
||||
|
||||
## ✅ 已完成工作
|
||||
|
||||
### 1. API 层
|
||||
- [x] 添加 API 常量 `changeUAVLens` 到 `HttpApiConsts`
|
||||
|
||||
### 2. 实体层 (Domain/Entities)
|
||||
- [x] 创建 `UavVideoStreamEntity` 实体类
|
||||
- [x] 定义 `UavLensType` 枚举(广角、变焦、红外)
|
||||
- [x] 定义 `VideoQualityType` 枚举(自适应、低、中、高清晰度)
|
||||
- [x] 实现 RTC 参数解析方法(appId、roomId、token、userId)
|
||||
|
||||
### 3. 数据源层 (Data/Datasources)
|
||||
- [x] 扩展 `DroneStationDataSource` 接口,添加 `getUavVideoStream` 方法
|
||||
- [x] 实现 `DroneStationDataSourceImpl.getUavVideoStream`
|
||||
- [x] 支持可选的镜头类型参数
|
||||
- [x] 支持自定义清晰度和 Token 有效期
|
||||
- [x] 添加详细的日志输出用于调试
|
||||
|
||||
### 4. 仓库层 (Data/Repositories)
|
||||
- [x] 扩展 `DroneStationRepository` 接口
|
||||
- [x] 实现 `DroneStationRepositoryImpl.getUavVideoStream`
|
||||
- [x] 使用 `Either<Failure, T>` 模式处理错误
|
||||
|
||||
### 5. 用例层 (Domain/UseCases)
|
||||
- [x] 创建 `GetUavVideoStreamUseCase`
|
||||
- [x] 封装业务逻辑,供 BLoC 调用
|
||||
- [x] 提供合理的默认值
|
||||
|
||||
### 6. 状态管理层 (Presentation/BLoC)
|
||||
- [x] 添加 `UavVideoStreamLoad` 事件
|
||||
- [x] 添加 `UavVideoStreamLoading` 状态
|
||||
- [x] 添加 `UavVideoStreamLoaded` 状态
|
||||
- [x] 添加 `UavVideoStreamError` 状态
|
||||
- [x] 在 `DroneStationBloc` 中实现事件处理逻辑
|
||||
|
||||
### 7. 依赖注入 (DI)
|
||||
- [x] 导入 `GetUavVideoStreamUseCase`
|
||||
- [x] 注册 `GetUavVideoStreamUseCase` 为单例
|
||||
- [x] 更新 `DroneStationBloc` 工厂,注入新的 UseCase
|
||||
|
||||
### 8. UI 层 (Presentation/Pages)
|
||||
- [x] 创建示例页面 `UavLiveVideoPage`
|
||||
- [x] 实现镜头切换功能(PopupMenuButton)
|
||||
- [x] 实现加载状态显示
|
||||
- [x] 实现错误处理和重试机制
|
||||
- [x] 预留 RTC SDK 集成位置
|
||||
|
||||
### 9. 测试
|
||||
- [x] 编写单元测试 `get_uav_video_stream_usecase_test.dart`
|
||||
- [x] 测试成功场景
|
||||
- [x] 测试失败场景
|
||||
- [x] 测试默认参数
|
||||
|
||||
### 10. 文档
|
||||
- [x] 创建集成指南 `UAV_VIDEO_INTEGRATION_GUIDE.md`
|
||||
- [x] 创建使用示例 `UAV_VIDEO_USAGE_EXAMPLES.md`
|
||||
|
||||
## 📁 文件清单
|
||||
|
||||
### 新增文件(7个)
|
||||
1. `lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart` - 实体类
|
||||
2. `lib/features/v2/device_list/domain/usecases/get_uav_video_stream_usecase.dart` - 用例
|
||||
3. `lib/features/v2/device_list/presentation/pages/uav_live_video_page.dart` - 示例页面
|
||||
4. `test/features/v2/device_list/domain/usecases/get_uav_video_stream_usecase_test.dart` - 单元测试
|
||||
5. `docs/UAV_VIDEO_INTEGRATION_GUIDE.md` - 集成指南
|
||||
6. `docs/UAV_VIDEO_USAGE_EXAMPLES.md` - 使用示例
|
||||
7. `docs/UAV_VIDEO_COMPLETION_SUMMARY.md` - 本文件
|
||||
|
||||
### 修改文件(9个)
|
||||
1. `lib/core/consts/http_api_consts.dart` - 添加 API 常量
|
||||
2. `lib/features/v2/device_list/data/datasources/drone_station_datasource.dart` - 添加接口方法
|
||||
3. `lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart` - 实现接口
|
||||
4. `lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart` - 实现仓库
|
||||
5. `lib/features/v2/device_list/domain/repositories/drone_station_repository.dart` - 添加仓库接口
|
||||
6. `lib/features/v2/device_list/presentation/bloc/drone_station_event.dart` - 添加事件
|
||||
7. `lib/features/v2/device_list/presentation/bloc/drone_station_state.dart` - 添加状态
|
||||
8. `lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart` - 添加事件处理
|
||||
9. `lib/core/di/injection.dart` - 注册依赖
|
||||
|
||||
## 核心特性
|
||||
|
||||
### 清洁架构设计
|
||||
- **分层清晰**:Entity → UseCase → Repository → DataSource → BLoC → UI
|
||||
- **依赖倒置**:高层模块不依赖低层模块的具体实现
|
||||
- **可测试性**:每层都可以独立测试
|
||||
|
||||
### 可扩展性
|
||||
- **多镜头支持**:通过枚举轻松扩展更多镜头类型
|
||||
- **多清晰度支持**:支持自适应、低、中、高四种清晰度
|
||||
- **多 RTC SDK 支持**:兼容火山引擎和声网两种 RTC SDK
|
||||
- **可复用**:可在任何页面中使用,无需重复实现
|
||||
|
||||
### 健壮性
|
||||
- **错误处理**:使用 `Either<Failure, T>` 统一处理错误
|
||||
- **默认值**:提供合理的默认参数,简化调用
|
||||
- **日志记录**:详细的日志输出,方便调试
|
||||
- **重试机制**:支持手动重试和自动刷新
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 方式一:直接使用示例页面
|
||||
```dart
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => UavLiveVideoPage(
|
||||
droneSn: '1581F8HGX253U00A063U',
|
||||
cameraIndex: '176-0-0',
|
||||
),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
### 方式二:在自定义页面中集成
|
||||
参考 `docs/UAV_VIDEO_USAGE_EXAMPLES.md` 中的详细示例。
|
||||
|
||||
## 📋 API 说明
|
||||
|
||||
### 请求参数
|
||||
```json
|
||||
{
|
||||
"sn": "1581F8HGX253U00A063U", // 必填:设备序列号
|
||||
"lensType": "", // 可选:wide/zoom/ir
|
||||
"cameraIndex": "176-0-0", // 必填:摄像头编号
|
||||
"qualityType": "adaptive", // 可选:adaptive/low/medium/high
|
||||
"videoExpire": 720000000 // 可选:Token有效期(毫秒)
|
||||
}
|
||||
```
|
||||
|
||||
### 返回数据
|
||||
```json
|
||||
{
|
||||
"msg": "操作成功",
|
||||
"code": 200,
|
||||
"data": {
|
||||
"sn": "1581F8HGX253U00A063U",
|
||||
"camera_index": "176-0-0",
|
||||
"url": "app_id=xxx&room_id=xxx&token=xxx&user_id=xxx",
|
||||
"expire_ts": 1781155234,
|
||||
"url_type": "volc" // volc 或 agora
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 后续工作
|
||||
|
||||
1. [ ] 集成 RTC SDK 显示实际视频画面
|
||||
- 根据 `urlType` 选择火山引擎或声网 SDK
|
||||
- 初始化 RTC 引擎并加入房间
|
||||
- 渲染远程视频流
|
||||
|
||||
2. [ ] 添加视频录制功能
|
||||
- 开始/停止录制
|
||||
- 保存录制文件
|
||||
|
||||
3. [ ] 添加截图功能
|
||||
- 截取当前帧
|
||||
- 保存到相册
|
||||
|
||||
4. [ ] 优化视频加载超时处理
|
||||
- 设置合理的超时时间
|
||||
- 提供友好的超时提示
|
||||
|
||||
5. [ ] 支持多路视频同时观看
|
||||
- 分屏显示多个摄像头
|
||||
- 切换主视图
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [完整集成指南](./UAV_VIDEO_INTEGRATION_GUIDE.md)
|
||||
- [使用示例](./UAV_VIDEO_USAGE_EXAMPLES.md)
|
||||
- [API 接口定义](../lib/core/consts/http_api_consts.dart)
|
||||
- [实体类定义](../lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart)
|
||||
- [BLoC 状态管理](../lib/features/v2/device_list/presentation/bloc/)
|
||||
|
||||
## ✅ 验证结果
|
||||
|
||||
- [x] 所有文件编译通过,无错误
|
||||
- [x] 单元测试编写完成
|
||||
- [x] 依赖注入配置正确
|
||||
- [x] 文档齐全
|
||||
|
||||
## 💡 注意事项
|
||||
|
||||
1. **不要在页面上直接调用 API**:必须通过 BLoC 管理状态
|
||||
2. **及时释放资源**:在 `dispose()` 中关闭 BLoC
|
||||
3. **合理设置 Token 有效期**:建议设置为较长的时间,避免频繁刷新
|
||||
4. **检查 RTC 参数**:确保 AppId、RoomId、Token、UserId 正确解析
|
||||
5. **根据 urlType 选择 SDK**:volc 使用火山引擎,agora 使用声网
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
无人机实时视频接口已成功对接,采用清洁架构设计,具有良好的可扩展性和可维护性。所有代码已通过编译检查,单元测试覆盖主要场景,文档齐全。下一步只需集成 RTC SDK 即可显示实际视频画面。
|
||||
323
docs/UAV_VIDEO_INTEGRATION_GUIDE.md
Normal file
@@ -0,0 +1,323 @@
|
||||
# 无人机实时视频接口对接说明
|
||||
|
||||
## 概述
|
||||
|
||||
本模块实现了无人机实时视频流的获取功能,采用清洁架构设计,支持多种镜头类型切换(广角、变焦、红外),并兼容火山引擎和声网两种RTC SDK。
|
||||
|
||||
## API 接口
|
||||
|
||||
### 接口地址
|
||||
```
|
||||
POST http://1.95.137.212:59015/iot/UAV/changeLens
|
||||
```
|
||||
|
||||
### 请求参数
|
||||
```json
|
||||
{
|
||||
"sn": "1581F8HGX253U00A063U", // 无人机设备序列号(必填)
|
||||
"lensType": "", // 镜头类型:wide(广角)、zoom(变焦)、ir(红外),可为空
|
||||
"cameraIndex": "176-0-0", // 摄像头编号(必填)
|
||||
"qualityType": "adaptive", // 清晰度:adaptive(自适应)、low、medium、high(默认adaptive)
|
||||
"videoExpire": 720000000 // Token有效期(毫秒,默认720000000)
|
||||
}
|
||||
```
|
||||
|
||||
### 返回数据
|
||||
```json
|
||||
{
|
||||
"msg": "操作成功",
|
||||
"code": 200,
|
||||
"data": {
|
||||
"sn": "1581F8HGX253U00A063U",
|
||||
"camera_index": "176-0-0",
|
||||
"url": "app_id=xxx&expire_time=xxx&room_id=xxx&token=xxx&user_id=xxx",
|
||||
"expire_ts": 1781155234,
|
||||
"url_type": "volc" // volc(火山引擎) 或 agora(声网)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 目录结构
|
||||
```
|
||||
lib/features/v2/device_list/
|
||||
├── domain/
|
||||
│ ├── entities/
|
||||
│ │ └── uav_video_stream_entity.dart # 实体类
|
||||
│ ├── repositories/
|
||||
│ │ ── drone_station_repository.dart # 仓库接口
|
||||
│ └── usecases/
|
||||
│ └── get_uav_video_stream_usecase.dart # 用例
|
||||
├── data/
|
||||
│ ├── datasources/
|
||||
│ │ ├── drone_station_datasource.dart # 数据源接口
|
||||
│ │ └── drone_station_datasource_impl.dart # 数据源实现
|
||||
│ └── repositories/
|
||||
│ └── drone_station_repository_impl.dart # 仓库实现
|
||||
── presentation/
|
||||
├── bloc/
|
||||
│ ├── drone_station_bloc.dart # BLoC状态管理
|
||||
│ ├── drone_station_event.dart # 事件定义
|
||||
│ └── drone_station_state.dart # 状态定义
|
||||
└── pages/
|
||||
── uav_live_video_page.dart # 示例页面
|
||||
```
|
||||
|
||||
### 核心组件
|
||||
|
||||
#### 1. 实体类 (Entity)
|
||||
**文件**: `domain/entities/uav_video_stream_entity.dart`
|
||||
|
||||
定义了枚举类型和实体类:
|
||||
- `UavLensType`: 镜头类型枚举(wide、zoom、ir)
|
||||
- `VideoQualityType`: 视频质量枚举(adaptive、low、medium、high)
|
||||
- `UavVideoStreamEntity`: 视频流实体,包含解析RTC参数的方法
|
||||
|
||||
#### 2. 数据源 (DataSource)
|
||||
**文件**: `data/datasources/drone_station_datasource_impl.dart`
|
||||
|
||||
实现了API调用逻辑:
|
||||
- 发送POST请求到 `/iot/UAV/changeLens`
|
||||
- 处理响应并转换为实体对象
|
||||
- 包含详细的日志输出用于调试
|
||||
|
||||
#### 3. 仓库 (Repository)
|
||||
**文件**: `data/repositories/drone_station_repository_impl.dart`
|
||||
|
||||
使用 `fpdart` 的 `Either` 类型处理错误:
|
||||
- 成功时返回 `Right(UavVideoStreamEntity)`
|
||||
- 失败时返回 `Left(Failure)`
|
||||
|
||||
#### 4. 用例 (UseCase)
|
||||
**文件**: `domain/usecases/get_uav_video_stream_usecase.dart`
|
||||
|
||||
封装业务逻辑,供BLoC调用。
|
||||
|
||||
#### 5. BLoC 状态管理
|
||||
**文件**: `presentation/bloc/drone_station_bloc.dart`
|
||||
|
||||
新增事件和状态:
|
||||
- **事件**: `UavVideoStreamLoad` - 加载视频流
|
||||
- **状态**:
|
||||
- `UavVideoStreamLoading` - 加载中
|
||||
- `UavVideoStreamLoaded` - 加载成功
|
||||
- `UavVideoStreamError` - 加载失败
|
||||
|
||||
#### 6. 依赖注入
|
||||
**文件**: `core/di/injection.dart`
|
||||
|
||||
已注册以下单例:
|
||||
```dart
|
||||
sl.registerLazySingleton<GetUavVideoStreamUseCase>(
|
||||
() => GetUavVideoStreamUseCase(sl()),
|
||||
);
|
||||
sl.registerFactory<DroneStationBloc>(
|
||||
() => DroneStationBloc(sl(), sl(), sl(), sl()),
|
||||
);
|
||||
```
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 方式一:直接使用示例页面
|
||||
|
||||
```dart
|
||||
import 'package:maibu_satabot_v2/features/v2/device_list/presentation/pages/uav_live_video_page.dart';
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => UavLiveVideoPage(
|
||||
droneSn: '1581F8HGX253U00A063U',
|
||||
cameraIndex: '176-0-0',
|
||||
),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
### 方式二:在现有页面中使用
|
||||
|
||||
#### 1. 导入必要的类
|
||||
```dart
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../../core/di/injection.dart';
|
||||
import '../../domain/entities/uav_video_stream_entity.dart';
|
||||
import '../bloc/drone_station_bloc.dart';
|
||||
import '../bloc/drone_station_event.dart';
|
||||
import '../bloc/drone_station_state.dart';
|
||||
```
|
||||
|
||||
#### 2. 初始化 BLoC
|
||||
```dart
|
||||
late DroneStationBloc _bloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bloc = sl<DroneStationBloc>();
|
||||
// 加载视频流
|
||||
_loadVideoStream(UavLensType.wide);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 加载视频流
|
||||
```dart
|
||||
void _loadVideoStream(UavLensType lensType) {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
_bloc.add(
|
||||
UavVideoStreamLoad(
|
||||
sn: widget.droneSn,
|
||||
cameraIndex: widget.cameraIndex,
|
||||
lensType: lensType,
|
||||
qualityType: VideoQualityType.adaptive,
|
||||
videoExpire: 720000000,
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 监听状态变化
|
||||
```dart
|
||||
BlocConsumer<DroneStationBloc, DroneStationState>(
|
||||
listener: (context, state) {
|
||||
if (state is UavVideoStreamLoaded) {
|
||||
final videoStream = state.videoStream;
|
||||
debugPrint('URL Type: ${videoStream.urlType}');
|
||||
debugPrint('AppId: ${videoStream.appId}');
|
||||
debugPrint('RoomId: ${videoStream.roomId}');
|
||||
debugPrint('UserId: ${videoStream.userId}');
|
||||
|
||||
// TODO: 根据 urlType 初始化对应的 RTC 引擎
|
||||
if (videoStream.urlType.toLowerCase() == 'volc') {
|
||||
// 使用火山引擎 RTC SDK
|
||||
} else if (videoStream.urlType.toLowerCase() == 'agora') {
|
||||
// 使用声网 RTC SDK
|
||||
}
|
||||
} else if (state is UavVideoStreamError) {
|
||||
setState(() {
|
||||
_errorMessage = state.message;
|
||||
});
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
// 根据状态渲染UI
|
||||
if (state is UavVideoStreamLoading) {
|
||||
return const CircularProgressIndicator();
|
||||
}
|
||||
|
||||
if (state is UavVideoStreamLoaded) {
|
||||
// 显示视频画面
|
||||
return Container();
|
||||
}
|
||||
|
||||
if (state is UavVideoStreamError) {
|
||||
return Text('错误: ${state.message}');
|
||||
}
|
||||
|
||||
return Container();
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
#### 5. 切换镜头类型
|
||||
```dart
|
||||
// 切换到广角镜头
|
||||
_loadVideoStream(UavLensType.wide);
|
||||
|
||||
// 切换到变焦镜头
|
||||
_loadVideoStream(UavLensType.zoom);
|
||||
|
||||
// 切换到红外镜头
|
||||
_loadVideoStream(UavLensType.ir);
|
||||
```
|
||||
|
||||
## RTC 集成
|
||||
|
||||
获取到视频流后,需要根据 `urlType` 选择对应的 RTC SDK:
|
||||
|
||||
### 火山引擎 (volc)
|
||||
```dart
|
||||
final appId = videoStream.appId;
|
||||
final roomId = videoStream.roomId;
|
||||
final token = videoStream.token;
|
||||
final userId = videoStream.userId;
|
||||
|
||||
// 使用 volc_engine_rtc SDK
|
||||
final engine = await RTCEngine.createRTCEngine(
|
||||
RTCVideoContext(appId: appId, eventHandler: handler),
|
||||
);
|
||||
final room = await engine.createRTCRoom(roomId);
|
||||
await room.joinRoom(token: token, userId: userId);
|
||||
```
|
||||
|
||||
### 声网 (agora)
|
||||
```dart
|
||||
final appId = videoStream.appId;
|
||||
final channelId = videoStream.roomId;
|
||||
final token = videoStream.token;
|
||||
final uid = int.tryParse(videoStream.userId) ?? 0;
|
||||
|
||||
// 使用 agora_rtc_engine SDK
|
||||
final engine = createAgoraRtcEngine();
|
||||
await engine.initialize(RtcEngineContext(appId: appId));
|
||||
await engine.joinChannel(
|
||||
token: token,
|
||||
channelId: channelId,
|
||||
uid: uid,
|
||||
options: ChannelMediaOptions(...),
|
||||
);
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **可扩展性**: 本模块采用清洁架构设计,所有业务逻辑与UI分离,便于在其他页面复用。
|
||||
|
||||
2. **错误处理**: 使用 `Either<Failure, T>` 模式统一处理错误,确保异常不会直接抛出。
|
||||
|
||||
3. **日志记录**: 数据源层包含详细的日志输出,方便调试和问题排查。
|
||||
|
||||
4. **默认值**:
|
||||
- `lensType` 可为空,后端会根据实际情况选择默认镜头
|
||||
- `qualityType` 默认为 `adaptive`(自适应)
|
||||
- `videoExpire` 默认为 `720000000` 毫秒
|
||||
|
||||
5. **Token 有效期**: `videoExpire` 参数单位为毫秒,建议设置较长的有效期以避免频繁刷新。
|
||||
|
||||
6. **多镜头支持**: 通过 `UavLensType` 枚举可以轻松扩展更多镜头类型。
|
||||
|
||||
## 后续工作
|
||||
|
||||
1. [ ] 集成 RTC SDK 显示实际视频画面
|
||||
2. [ ] 添加视频录制功能
|
||||
3. [ ] 添加截图功能
|
||||
4. [ ] 优化视频加载超时处理
|
||||
5. [ ] 添加视频质量切换功能
|
||||
6. [ ] 支持多路视频同时观看
|
||||
|
||||
## 相关文件清单
|
||||
|
||||
### 新增文件
|
||||
- `lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart`
|
||||
- `lib/features/v2/device_list/domain/usecases/get_uav_video_stream_usecase.dart`
|
||||
- `lib/features/v2/device_list/presentation/pages/uav_live_video_page.dart`
|
||||
|
||||
### 修改文件
|
||||
- `lib/core/consts/http_api_consts.dart` - 添加 API 常量
|
||||
- `lib/features/v2/device_list/data/datasources/drone_station_datasource.dart` - 添加接口方法
|
||||
- `lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart` - 实现接口
|
||||
- `lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart` - 实现仓库
|
||||
- `lib/features/v2/device_list/domain/repositories/drone_station_repository.dart` - 添加仓库接口
|
||||
- `lib/features/v2/device_list/presentation/bloc/drone_station_event.dart` - 添加事件
|
||||
- `lib/features/v2/device_list/presentation/bloc/drone_station_state.dart` - 添加状态
|
||||
- `lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart` - 添加事件处理
|
||||
- `lib/core/di/injection.dart` - 注册依赖
|
||||
298
docs/UAV_VIDEO_PAGE_INTEGRATION.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# 无人机视频页面集成完成
|
||||
|
||||
## 📋 更新内容
|
||||
|
||||
已将新的无人机实时视频接口集成到现有的**无人机视频回传/远程控制**页面中。
|
||||
|
||||
---
|
||||
|
||||
## 🎯 集成位置
|
||||
|
||||
### 1. 无人机详情页面 → 无人机状态卡片
|
||||
|
||||
**文件**: `lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart`
|
||||
|
||||
**修改内容**:
|
||||
```dart
|
||||
// 点击"无人机状态"卡片时,传递设备序列号和摄像头索引
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => DroneVideoControlPage(
|
||||
droneSn: detail.deviceSn, // ✅ 传递设备序列号
|
||||
cameraIndex: detail.gatewayCameraList != null &&
|
||||
detail.gatewayCameraList!.isNotEmpty
|
||||
? detail.gatewayCameraList!.first.cameraIndex
|
||||
: '176-0-0', // 默认值
|
||||
),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 无人机视频控制页面
|
||||
|
||||
**文件**: `lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart`
|
||||
|
||||
**新增功能**:
|
||||
|
||||
#### ✅ 接收参数
|
||||
```dart
|
||||
class DroneVideoControlPage extends StatefulWidget {
|
||||
final String droneSn; // 无人机设备序列号
|
||||
final String cameraIndex; // 摄像头编号
|
||||
|
||||
const DroneVideoControlPage({
|
||||
super.key,
|
||||
required this.droneSn,
|
||||
required this.cameraIndex,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### ✅ 自动加载视频流
|
||||
```dart
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bloc = sl<DroneStationBloc>();
|
||||
// 默认加载广角镜头的视频流
|
||||
_loadVideoStream(UavLensType.wide);
|
||||
}
|
||||
```
|
||||
|
||||
#### ✅ 镜头切换功能
|
||||
在视频播放器右下角添加了镜头类型选择器:
|
||||
- **广角** (wide)
|
||||
- **变焦** (zoom)
|
||||
- **红外** (ir)
|
||||
|
||||
点击可快速切换不同镜头的视频流。
|
||||
|
||||
#### ✅ 加载状态显示
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ │
|
||||
│ ⏳ 正在加载... │
|
||||
│ │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
#### ✅ 错误处理和重试
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ │
|
||||
│ ❌ 加载失败 │
|
||||
│ 错误信息: xxxxx │
|
||||
│ │
|
||||
│ [ 重试 ] │
|
||||
│ │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 工作流程
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[无人机详情页] --> B[点击无人机状态卡片]
|
||||
B --> C[打开视频控制页面]
|
||||
C --> D[自动调用changeLens接口]
|
||||
D --> E{加载结果}
|
||||
E -->|成功| F[显示视频画面]
|
||||
E -->|失败| G[显示错误+重试按钮]
|
||||
F --> H[用户切换镜头]
|
||||
H --> D
|
||||
G -->|点击重试| D
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 UI 展示
|
||||
|
||||
### 视频播放器区域
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ ● REC 00:12:36 │
|
||||
│ │
|
||||
│ │
|
||||
│ [视频画面/加载状态] │
|
||||
│ │
|
||||
│ │
|
||||
│ ┌────────┐ │
|
||||
│ │广角 ▼│ │ ← 镜头切换
|
||||
│ └────────┘ │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 镜头切换菜单
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
│ 广角 │ ← 当前选中
|
||||
│ 变焦 │
|
||||
│ 红外 │
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 技术实现
|
||||
|
||||
### 1. BLoC 状态管理
|
||||
```dart
|
||||
// 监听视频流状态
|
||||
BlocConsumer<DroneStationBloc, DroneStationState>(
|
||||
listener: (context, state) {
|
||||
if (state is UavVideoStreamLoaded) {
|
||||
// 获取视频流数据
|
||||
_videoStream = state.videoStream;
|
||||
|
||||
// TODO: 初始化 RTC 引擎并显示视频
|
||||
debugPrint('AppId: ${state.videoStream.appId}');
|
||||
debugPrint('RoomId: ${state.videoStream.roomId}');
|
||||
debugPrint('UserId: ${state.videoStream.userId}');
|
||||
} else if (state is UavVideoStreamError) {
|
||||
// 显示错误信息
|
||||
_errorMessage = state.message;
|
||||
}
|
||||
},
|
||||
// ...
|
||||
)
|
||||
```
|
||||
|
||||
### 2. 视频流加载
|
||||
```dart
|
||||
void _loadVideoStream(UavLensType lensType) {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
_currentLensType = lensType;
|
||||
});
|
||||
|
||||
_bloc.add(
|
||||
UavVideoStreamLoad(
|
||||
sn: widget.droneSn,
|
||||
cameraIndex: widget.cameraIndex,
|
||||
lensType: lensType,
|
||||
qualityType: VideoQualityType.adaptive,
|
||||
videoExpire: 720000000,
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 镜头切换
|
||||
```dart
|
||||
PopupMenuButton<UavLensType>(
|
||||
icon: Text(_getLensTypeName(_currentLensType)),
|
||||
onSelected: (UavLensType lensType) {
|
||||
if (lensType != _currentLensType) {
|
||||
_loadVideoStream(lensType); // 重新加载视频流
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(value: UavLensType.wide, child: Text('广角')),
|
||||
PopupMenuItem(value: UavLensType.zoom, child: Text('变焦')),
|
||||
PopupMenuItem(value: UavLensType.ir, child: Text('红外')),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已完成的功能
|
||||
|
||||
- ✅ 从无人机详情页传递设备序列号和摄像头索引
|
||||
- ✅ 自动调用 `changeLens` 接口获取视频流
|
||||
- ✅ 支持三种镜头类型切换(广角/变焦/红外)
|
||||
- ✅ 加载状态显示
|
||||
- ✅ 错误处理和重试机制
|
||||
- ✅ 打印视频流参数(AppId、RoomId、UserId等)
|
||||
- ✅ 清洁架构设计,易于扩展和维护
|
||||
|
||||
---
|
||||
|
||||
## 🔜 下一步工作(TODO)
|
||||
|
||||
### 1. 集成 RTC SDK 显示实际视频
|
||||
|
||||
在 `_buildVideoPlayer()` 方法中,当 `_videoStream != null` 时:
|
||||
|
||||
```dart
|
||||
if (_videoStream != null) {
|
||||
// 根据 urlType 选择对应的 RTC SDK
|
||||
if (_videoStream!.urlType == 'volc') {
|
||||
// 使用火山引擎 RTC SDK
|
||||
return VolcEngineRtcView(
|
||||
appId: _videoStream!.appId,
|
||||
roomId: _videoStream!.roomId,
|
||||
token: _videoStream!.token,
|
||||
userId: _videoStream!.userId,
|
||||
);
|
||||
} else if (_videoStream!.urlType == 'agora') {
|
||||
// 使用声网 RTC SDK
|
||||
return AgoraRtcView(
|
||||
appId: _videoStream!.appId,
|
||||
channelId: _videoStream!.roomId,
|
||||
token: _videoStream!.token,
|
||||
uid: int.parse(_videoStream!.userId),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 参考现有实现
|
||||
|
||||
可以参考 `drone_station_detail_page.dart` 中的以下方法:
|
||||
- `_initFloatingVolcEngine()` - 火山引擎初始化
|
||||
- `_initFloatingAgoraEngine()` - 声网引擎初始化
|
||||
- `_disposeVolcEngine()` / `_disposeAgoraEngine()` - 资源释放
|
||||
|
||||
### 3. 添加更多控制功能
|
||||
|
||||
- 云台控制(上下左右)
|
||||
- 变焦倍数调节
|
||||
- 拍照/录像
|
||||
- AI识别结果展示
|
||||
|
||||
---
|
||||
|
||||
## 📝 测试步骤
|
||||
|
||||
1. **进入无人机详情页**
|
||||
- 确保无人机在线且有摄像头列表
|
||||
|
||||
2. **点击"无人机状态"卡片**
|
||||
- 应该跳转到视频控制页面
|
||||
|
||||
3. **观察加载过程**
|
||||
- 看到 "正在加载视频流..." 提示
|
||||
- 成功后显示视频画面(当前为占位图)
|
||||
|
||||
4. **测试镜头切换**
|
||||
- 点击右下角的镜头类型按钮
|
||||
- 选择"广角"、"变焦"或"红外"
|
||||
- 观察是否重新加载并切换成功
|
||||
|
||||
5. **测试错误处理**
|
||||
- 断开网络连接
|
||||
- 应该显示错误信息和重试按钮
|
||||
- 点击重试应该重新加载
|
||||
|
||||
---
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
✅ **集成完成!**
|
||||
|
||||
现在无人机视频控制页面已经:
|
||||
- 正确接收设备参数
|
||||
- 自动调用新的视频流接口
|
||||
- 支持多镜头切换
|
||||
- 完善的加载和错误处理
|
||||
|
||||
只需最后一步:**集成 RTC SDK 显示实际视频画面**,即可完整实现无人机实时视频监控功能!
|
||||
642
docs/UAV_VIDEO_USAGE_EXAMPLES.md
Normal file
@@ -0,0 +1,642 @@
|
||||
# 无人机实时视频接口使用示例
|
||||
|
||||
## 快速开始
|
||||
|
||||
本指南展示如何在你的项目中快速集成无人机实时视频功能。
|
||||
|
||||
## 1. 基础用法 - 直接导航到视频页面
|
||||
|
||||
最简单的方式是直接导航到 `UavLiveVideoPage`:
|
||||
|
||||
```dart
|
||||
import 'package:maibu_satabot_v2/features/v2/device_list/presentation/pages/uav_live_video_page.dart';
|
||||
|
||||
// 在任意地方调用
|
||||
void _openVideo() {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => UavLiveVideoPage(
|
||||
droneSn: '1581F8HGX253U00A063U', // 无人机序列号
|
||||
cameraIndex: '176-0-0', // 摄像头编号
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 高级用法 - 在自定义页面中集成
|
||||
|
||||
如果你需要在自己的页面中集成视频功能,可以按照以下步骤操作:
|
||||
|
||||
### 步骤 1: 创建页面并初始化 BLoC
|
||||
|
||||
```dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../../core/di/injection.dart';
|
||||
import '../../domain/entities/uav_video_stream_entity.dart';
|
||||
import '../bloc/drone_station_bloc.dart';
|
||||
import '../bloc/drone_station_event.dart';
|
||||
import '../bloc/drone_station_state.dart';
|
||||
|
||||
class MyCustomVideoPage extends StatefulWidget {
|
||||
final String droneSn;
|
||||
final String cameraIndex;
|
||||
|
||||
const MyCustomVideoPage({
|
||||
super.key,
|
||||
required this.droneSn,
|
||||
required this.cameraIndex,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MyCustomVideoPage> createState() => _MyCustomVideoPageState();
|
||||
}
|
||||
|
||||
class _MyCustomVideoPageState extends State<MyCustomVideoPage> {
|
||||
late DroneStationBloc _bloc;
|
||||
UavVideoStreamEntity? _videoStream;
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
UavLensType? _currentLensType;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bloc = sl<DroneStationBloc>();
|
||||
// 默认加载广角镜头
|
||||
_loadVideoStream(UavLensType.wide);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 2: 实现加载视频流方法
|
||||
|
||||
```dart
|
||||
/// 加载视频流
|
||||
void _loadVideoStream(UavLensType lensType) {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
_currentLensType = lensType;
|
||||
});
|
||||
|
||||
_bloc.add(
|
||||
UavVideoStreamLoad(
|
||||
sn: widget.droneSn,
|
||||
cameraIndex: widget.cameraIndex,
|
||||
lensType: lensType,
|
||||
qualityType: VideoQualityType.adaptive, // 自适应清晰度
|
||||
videoExpire: 720000000, // Token有效期(毫秒)
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 3: 构建 UI 并监听状态变化
|
||||
|
||||
```dart
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _bloc,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
'无人机实时视频',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
// 镜头切换按钮
|
||||
PopupMenuButton<UavLensType>(
|
||||
icon: const Icon(Icons.videocam, color: Colors.white),
|
||||
tooltip: '切换镜头',
|
||||
onSelected: (lensType) {
|
||||
_loadVideoStream(lensType);
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: UavLensType.wide,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.videocam, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('广角镜头'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: UavLensType.zoom,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.zoom_in, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('变焦镜头'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: UavLensType.ir,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.thermostat, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('红外镜头'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: BlocConsumer<DroneStationBloc, DroneStationState>(
|
||||
listener: (context, state) {
|
||||
// 监听状态变化
|
||||
if (state is UavVideoStreamLoaded) {
|
||||
setState(() {
|
||||
_videoStream = state.videoStream;
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
debugPrint('=== 视频流加载成功 ===');
|
||||
debugPrint('URL Type: ${state.videoStream.urlType}');
|
||||
debugPrint('AppId: ${state.videoStream.appId}');
|
||||
debugPrint('RoomId: ${state.videoStream.roomId}');
|
||||
debugPrint('UserId: ${state.videoStream.userId}');
|
||||
|
||||
// TODO: 这里可以初始化 RTC 引擎并显示视频
|
||||
_initRtcEngine(state.videoStream);
|
||||
} else if (state is UavVideoStreamError) {
|
||||
setState(() {
|
||||
_errorMessage = state.message;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
// 根据状态渲染不同的 UI
|
||||
|
||||
// 加载中状态
|
||||
if (_isLoading || state is UavVideoStreamLoading) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(color: Colors.white),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'正在加载视频流...',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (_errorMessage != null || state is UavVideoStreamError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: Colors.red,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage ?? (state as UavVideoStreamError).message,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (_currentLensType != null) {
|
||||
_loadVideoStream(_currentLensType!);
|
||||
}
|
||||
},
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 无视频信号
|
||||
if (_videoStream == null) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'暂无视频信号',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 视频已加载,显示视频画面
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
color: Colors.black,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.videocam_off,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'视频流已获取\nURL Type: ${_videoStream!.urlType}\nCamera: $_currentLensType',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'请集成 RTC SDK 后在此处显示视频画面',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 4: 初始化 RTC 引擎(可选)
|
||||
|
||||
```dart
|
||||
/// 初始化 RTC 引擎
|
||||
Future<void> _initRtcEngine(UavVideoStreamEntity videoStream) async {
|
||||
final appId = videoStream.appId;
|
||||
final roomId = videoStream.roomId;
|
||||
final token = videoStream.token;
|
||||
final userId = videoStream.userId.isNotEmpty
|
||||
? videoStream.userId
|
||||
: 'user_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
if (appId.isEmpty || roomId.isEmpty || token.isEmpty) {
|
||||
debugPrint('RTC 参数缺失');
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('=== RTC 初始化 ===');
|
||||
debugPrint('AppId: $appId');
|
||||
debugPrint('RoomId: $roomId');
|
||||
debugPrint('UserId: $userId');
|
||||
debugPrint('URL Type: ${videoStream.urlType}');
|
||||
|
||||
// 根据 urlType 选择不同的 RTC SDK
|
||||
final sdkType = videoStream.urlType.toLowerCase() == 'agora'
|
||||
? RtcSdkType.agora
|
||||
: RtcSdkType.volcengine;
|
||||
|
||||
if (sdkType == RtcSdkType.agora) {
|
||||
await _initAgoraEngine(appId, roomId, token, userId);
|
||||
} else {
|
||||
await _initVolcEngine(appId, roomId, token, userId);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 实现具体的 RTC 引擎初始化逻辑
|
||||
// 参考 drone_station_detail_page.dart 中的实现
|
||||
```
|
||||
|
||||
## 3. 常见场景示例
|
||||
|
||||
### 场景 1: 从列表页跳转到视频页
|
||||
|
||||
```dart
|
||||
// 在设备列表中点击某个设备
|
||||
void _onDeviceTap(Device device) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => UavLiveVideoPage(
|
||||
droneSn: device.sn,
|
||||
cameraIndex: device.cameraIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 场景 2: 支持多个摄像头切换
|
||||
|
||||
```dart
|
||||
class MultiCameraVideoPage extends StatefulWidget {
|
||||
final String droneSn;
|
||||
final List<String> cameraIndices;
|
||||
|
||||
const MultiCameraVideoPage({
|
||||
super.key,
|
||||
required this.droneSn,
|
||||
required this.cameraIndices,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MultiCameraVideoPage> createState() => _MultiCameraVideoPageState();
|
||||
}
|
||||
|
||||
class _MultiCameraVideoPageState extends State<MultiCameraVideoPage> {
|
||||
late DroneStationBloc _bloc;
|
||||
int _currentCameraIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bloc = sl<DroneStationBloc>();
|
||||
_loadCurrentCamera();
|
||||
}
|
||||
|
||||
void _loadCurrentCamera() {
|
||||
_bloc.add(
|
||||
UavVideoStreamLoad(
|
||||
sn: widget.droneSn,
|
||||
cameraIndex: widget.cameraIndices[_currentCameraIndex],
|
||||
lensType: UavLensType.wide,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _switchCamera(int index) {
|
||||
setState(() {
|
||||
_currentCameraIndex = index;
|
||||
});
|
||||
_loadCurrentCamera();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('摄像头 ${_currentCameraIndex + 1}/${widget.cameraIndices.length}'),
|
||||
actions: [
|
||||
// 切换摄像头按钮
|
||||
IconButton(
|
||||
icon: const Icon(Icons.switch_camera),
|
||||
onPressed: () {
|
||||
final nextIndex = (_currentCameraIndex + 1) % widget.cameraIndices.length;
|
||||
_switchCamera(nextIndex);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: BlocBuilder<DroneStationBloc, DroneStationState>(
|
||||
builder: (context, state) {
|
||||
// ... 根据状态渲染UI
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 场景 3: 自动刷新视频流(Token 过期时)
|
||||
|
||||
```dart
|
||||
class AutoRefreshVideoPage extends StatefulWidget {
|
||||
final String droneSn;
|
||||
final String cameraIndex;
|
||||
|
||||
const AutoRefreshVideoPage({
|
||||
super.key,
|
||||
required this.droneSn,
|
||||
required this.cameraIndex,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AutoRefreshVideoPage> createState() => _AutoRefreshVideoPageState();
|
||||
}
|
||||
|
||||
class _AutoRefreshVideoPageState extends State<AutoRefreshVideoPage> {
|
||||
late DroneStationBloc _bloc;
|
||||
Timer? _refreshTimer;
|
||||
static const _tokenRefreshInterval = Duration(minutes: 55); // 每55分钟刷新一次(Token有效期约1小时)
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bloc = sl<DroneStationBloc>();
|
||||
_loadVideoStream();
|
||||
|
||||
// 启动定时刷新
|
||||
_startAutoRefresh();
|
||||
}
|
||||
|
||||
void _startAutoRefresh() {
|
||||
_refreshTimer?.cancel();
|
||||
_refreshTimer = Timer.periodic(_tokenRefreshInterval, (timer) {
|
||||
debugPrint('⏰ 自动刷新视频流 Token');
|
||||
_loadVideoStream();
|
||||
});
|
||||
}
|
||||
|
||||
void _loadVideoStream() {
|
||||
_bloc.add(
|
||||
UavVideoStreamLoad(
|
||||
sn: widget.droneSn,
|
||||
cameraIndex: widget.cameraIndex,
|
||||
lensType: UavLensType.wide,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_refreshTimer?.cancel();
|
||||
_bloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 最佳实践
|
||||
|
||||
### ✅ 推荐做法
|
||||
|
||||
1. **始终检查错误状态**
|
||||
```dart
|
||||
if (state is UavVideoStreamError) {
|
||||
// 显示友好的错误提示
|
||||
showSnackBar(context, '视频加载失败: ${state.message}');
|
||||
}
|
||||
```
|
||||
|
||||
2. **提供重试机制**
|
||||
```dart
|
||||
ElevatedButton(
|
||||
onPressed: () => _loadVideoStream(_currentLensType!),
|
||||
child: const Text('重试'),
|
||||
)
|
||||
```
|
||||
|
||||
3. **记录关键日志**
|
||||
```dart
|
||||
debugPrint('视频流加载成功: URL Type=${videoStream.urlType}');
|
||||
```
|
||||
|
||||
4. **合理设置 Token 有效期**
|
||||
```dart
|
||||
videoExpire: 720000000, // 约8天,避免频繁刷新
|
||||
```
|
||||
|
||||
5. **及时释放资源**
|
||||
```dart
|
||||
@override
|
||||
void dispose() {
|
||||
_bloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ 避免的做法
|
||||
|
||||
1. **不要在页面外直接调用 API**
|
||||
```dart
|
||||
// 错误:绕过 BLoC 直接调用
|
||||
final result = await repository.getUavVideoStream(...);
|
||||
|
||||
// ✅ 正确:通过 BLoC 管理状态
|
||||
_bloc.add(UavVideoStreamLoad(...));
|
||||
```
|
||||
|
||||
2. **不要忘记处理加载状态**
|
||||
```dart
|
||||
// ❌ 错误:没有加载指示器
|
||||
if (state is UavVideoStreamLoaded) { ... }
|
||||
|
||||
// ✅ 正确:显示加载状态
|
||||
if (state is UavVideoStreamLoading) {
|
||||
return CircularProgressIndicator();
|
||||
}
|
||||
```
|
||||
|
||||
3. **不要硬编码参数**
|
||||
```dart
|
||||
// 错误:硬编码
|
||||
sn: '1581F8HGX253U00A063U',
|
||||
|
||||
// ✅ 正确:使用变量
|
||||
sn: widget.droneSn,
|
||||
```
|
||||
|
||||
## 5. 故障排查
|
||||
|
||||
### 问题 1: 视频流加载失败
|
||||
|
||||
**可能原因:**
|
||||
- 网络问题
|
||||
- 设备序列号错误
|
||||
- 摄像头编号错误
|
||||
- Token 过期
|
||||
|
||||
**解决方案:**
|
||||
1. 检查网络连接
|
||||
2. 验证 `droneSn` 和 `cameraIndex` 是否正确
|
||||
3. 查看控制台日志输出
|
||||
4. 尝试重新加载
|
||||
|
||||
### 问题 2: 视频画面不显示
|
||||
|
||||
**可能原因:**
|
||||
- RTC SDK 未正确初始化
|
||||
- RTC 参数解析失败
|
||||
- SDK 版本不兼容
|
||||
|
||||
**解决方案:**
|
||||
1. 检查 `urlType` 字段,确认使用正确的 SDK
|
||||
2. 验证 AppId、RoomId、Token、UserId 是否正确解析
|
||||
3. 查看 RTC SDK 的日志输出
|
||||
4. 参考 `drone_station_detail_page.dart` 中的实现
|
||||
|
||||
### 问题 3: 镜头切换无效
|
||||
|
||||
**可能原因:**
|
||||
- 后端不支持该镜头类型
|
||||
- 摄像头不支持指定的镜头
|
||||
|
||||
**解决方案:**
|
||||
1. 检查后端返回的错误信息
|
||||
2. 尝试其他镜头类型
|
||||
3. 联系后端确认支持的镜头类型
|
||||
|
||||
## 6. 扩展功能
|
||||
|
||||
### 添加视频录制功能
|
||||
|
||||
```dart
|
||||
// TODO: 集成 RTC SDK 的录制功能
|
||||
Future<void> _startRecording() async {
|
||||
// 根据使用的 RTC SDK 调用相应的录制 API
|
||||
}
|
||||
|
||||
Future<void> _stopRecording() async {
|
||||
// 停止录制并保存文件
|
||||
}
|
||||
```
|
||||
|
||||
### 添加截图功能
|
||||
|
||||
```dart
|
||||
// TODO: 集成 RTC SDK 的截图功能
|
||||
Future<void> _takeScreenshot() async {
|
||||
// 根据使用的 RTC SDK 调用相应的截图 API
|
||||
// 保存截图到相册
|
||||
}
|
||||
```
|
||||
|
||||
### 添加视频质量切换
|
||||
|
||||
```dart
|
||||
void _changeQuality(VideoQualityType quality) {
|
||||
_bloc.add(
|
||||
UavVideoStreamLoad(
|
||||
sn: widget.droneSn,
|
||||
cameraIndex: widget.cameraIndex,
|
||||
lensType: _currentLensType,
|
||||
qualityType: quality, // 切换清晰度
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 相关文档
|
||||
|
||||
- [完整集成指南](./UAV_VIDEO_INTEGRATION_GUIDE.md)
|
||||
- [API 接口文档](../lib/core/consts/http_api_consts.dart)
|
||||
- [实体类定义](../lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart)
|
||||
- [BLoC 状态管理](../lib/features/v2/device_list/presentation/bloc/)
|
||||
|
||||
## 8. 技术支持
|
||||
|
||||
如遇到问题,请:
|
||||
1. 查看控制台日志输出
|
||||
2. 参考示例代码 `uav_live_video_page.dart`
|
||||
3. 查阅集成指南文档
|
||||
4. 联系开发团队
|
||||
12
fix_json.ps1
Normal file
@@ -0,0 +1,12 @@
|
||||
$zhPath = 'C:\Users\jsmbz\Documents\flutter-app-02\assets\languages\zh-CN.json'
|
||||
$enPath = 'C:\Users\jsmbz\Documents\flutter-app-02\assets\languages\en-US.json'
|
||||
|
||||
$zhContent = [System.IO.File]::ReadAllText($zhPath)
|
||||
$zhContent = $zhContent.Replace('"bow_mode": "弓字模式"', '"bow_mode": "覆盖模式"')
|
||||
[System.IO.File]::WriteAllText($zhPath, $zhContent)
|
||||
|
||||
$enContent = [System.IO.File]::ReadAllText($enPath)
|
||||
$enContent = $enContent.Replace('"bow_mode": "Bow Mode"', '"bow_mode": "Coverage Mode"')
|
||||
[System.IO.File]::WriteAllText($enPath, $enContent)
|
||||
|
||||
Write-Host "Done!"
|
||||
106
flutter_01.log
Normal file
@@ -0,0 +1,106 @@
|
||||
Flutter crash report.
|
||||
Please report a bug at https://github.com/flutter/flutter/issues.
|
||||
|
||||
## command
|
||||
|
||||
flutter --no-color run --machine --track-widget-creation --device-id=192.168.2.48:37689 --start-paused --dart-define=flutter.inspector.structuredErrors=true --devtools-server-address=http://127.0.0.1:9100 lib\main.dart
|
||||
|
||||
## exception
|
||||
|
||||
FormatException: FormatException: Unexpected character (at character 1)
|
||||
Failed to init NativeSymbolResolver (SymInitialize 3221225476)
|
||||
^
|
||||
|
||||
|
||||
```
|
||||
#0 _ChunkedJsonParser.fail (dart:convert-patch/convert_patch.dart:1467:5)
|
||||
#1 _ChunkedJsonParser.parseNumber (dart:convert-patch/convert_patch.dart:1333:9)
|
||||
#2 _ChunkedJsonParser.parse (dart:convert-patch/convert_patch.dart:935:22)
|
||||
#3 _JsonStringDecoderSink.addSlice (dart:convert-patch/convert_patch.dart:1555:13)
|
||||
#4 _JsonStringDecoderSink.add (dart:convert-patch/convert_patch.dart:1560:5)
|
||||
#5 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
|
||||
#6 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
|
||||
#7 _rootRunUnary (dart:async/zone.dart:1538:47)
|
||||
#8 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
|
||||
#9 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
|
||||
#10 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
|
||||
#11 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
|
||||
#12 _SinkTransformerStreamSubscription._add (dart:async/stream_transformers.dart:67:11)
|
||||
#13 _EventSinkWrapper.add (dart:async/stream_transformers.dart:13:11)
|
||||
#14 _StringAdapterSink.add (dart:convert/string_conversion.dart:228:11)
|
||||
#15 _StringAdapterSink.addSlice (dart:convert/string_conversion.dart:233:7)
|
||||
#16 _Utf8ConversionSink.addSlice (dart:convert/string_conversion.dart:313:20)
|
||||
#17 _Utf8ConversionSink.add (dart:convert/string_conversion.dart:306:5)
|
||||
#18 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
|
||||
#19 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
|
||||
#20 _rootRunUnary (dart:async/zone.dart:1538:47)
|
||||
#21 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
|
||||
#22 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
|
||||
#23 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
|
||||
#24 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
|
||||
#25 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
|
||||
#26 _StreamController._add (dart:async/stream_controller.dart:663:7)
|
||||
#27 _StreamController.add (dart:async/stream_controller.dart:618:5)
|
||||
#28 _Socket._onData (dart:io-patch/socket_patch.dart:2880:41)
|
||||
#29 _rootRunUnary (dart:async/zone.dart:1546:13)
|
||||
#30 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
|
||||
#31 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
|
||||
#32 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
|
||||
#33 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
|
||||
#34 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
|
||||
#35 _StreamController._add (dart:async/stream_controller.dart:663:7)
|
||||
#36 _StreamController.add (dart:async/stream_controller.dart:618:5)
|
||||
#37 new _RawSocket.<anonymous closure> (dart:io-patch/socket_patch.dart:2315:31)
|
||||
#38 _NativeSocket.issueReadEvent.issue (dart:io-patch/socket_patch.dart:1655:14)
|
||||
#39 _microtaskLoop (dart:async/schedule_microtask.dart:40:35)
|
||||
#40 _startMicrotaskLoop (dart:async/schedule_microtask.dart:49:5)
|
||||
#41 _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:127:13)
|
||||
#42 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:194:5)
|
||||
```
|
||||
|
||||
## flutter doctor
|
||||
|
||||
```
|
||||
[✓] Flutter (Channel stable, 3.38.7, on Microsoft Windows [版本 10.0.26200.9168], locale zh-CN) [1,623ms]
|
||||
• Flutter version 3.38.7 on channel stable at D:\fluttersdk\flutter_windows_3.38.7-stable\flutter
|
||||
• Upstream repository https://github.com/flutter/flutter.git
|
||||
• Framework revision 3b62efc2a3 (8 months ago), 2026-01-13 13:47:42 -0800
|
||||
• Engine revision 78fc3012e4
|
||||
• Dart version 3.10.7
|
||||
• DevTools version 2.51.1
|
||||
• Pub download mirror https://pub.flutter-io.cn
|
||||
• Flutter download mirror https://storage.flutter-io.cn
|
||||
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging
|
||||
|
||||
[✓] Windows Version (11 家庭版 中文版 64-bit, 25H2, 2009) [9.3s]
|
||||
|
||||
[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [14.1s]
|
||||
• Android SDK at C:\Users\jsmbz\AppData\Local\Android\Sdk
|
||||
• Emulator version 36.3.10.0 (build_id 14472402) (CL:N/A)
|
||||
• Platform android-36, build-tools 36.1.0
|
||||
• ANDROID_HOME = C:\Users\jsmbz\AppData\Local\Android\Sdk
|
||||
• Java binary at: D:\devtools\anrdstod\jbr\bin\java
|
||||
This is the JDK bundled with the latest Android Studio installation on this machine.
|
||||
To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
|
||||
• Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72)
|
||||
• All Android licenses accepted.
|
||||
|
||||
[✓] Chrome - develop for the web [20ms]
|
||||
• Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe
|
||||
|
||||
[✗] Visual Studio - develop Windows apps [17ms]
|
||||
✗ Visual Studio not installed; this is necessary to develop Windows apps.
|
||||
Download at https://visualstudio.microsoft.com/downloads/.
|
||||
Please install the "Desktop development with C++" workload, including all of its default components
|
||||
|
||||
[✓] Connected device (4 available) [8.2s]
|
||||
• PKB110 (mobile) • 192.168.2.48:37689 • android-arm64 • Android 16 (API 36)
|
||||
• Windows (desktop) • windows • windows-x64 • Microsoft Windows [版本 10.0.26200.9168]
|
||||
• Chrome (web) • chrome • web-javascript • Google Chrome 151.0.7922.138
|
||||
• Edge (web) • edge • web-javascript • Microsoft Edge 151.0.4129.93
|
||||
|
||||
[✓] Network resources [1,268ms]
|
||||
• All expected network resources are available.
|
||||
|
||||
! Doctor found issues in 1 category.
|
||||
```
|
||||
109
flutter_02.log
Normal file
@@ -0,0 +1,109 @@
|
||||
Flutter crash report.
|
||||
Please report a bug at https://github.com/flutter/flutter/issues.
|
||||
|
||||
## command
|
||||
|
||||
flutter --no-color run --machine --track-widget-creation --device-id=192.168.2.39:42831 --start-paused --dart-define=flutter.inspector.structuredErrors=true --devtools-server-address=http://127.0.0.1:9100 lib\main.dart
|
||||
|
||||
## exception
|
||||
|
||||
FormatException: FormatException: Unexpected character (at character 1)
|
||||
Failed to init NativeSymbolResolver (SymInitialize 3221225476)
|
||||
^
|
||||
|
||||
|
||||
```
|
||||
#0 _ChunkedJsonParser.fail (dart:convert-patch/convert_patch.dart:1467:5)
|
||||
#1 _ChunkedJsonParser.parseNumber (dart:convert-patch/convert_patch.dart:1333:9)
|
||||
#2 _ChunkedJsonParser.parse (dart:convert-patch/convert_patch.dart:935:22)
|
||||
#3 _JsonStringDecoderSink.addSlice (dart:convert-patch/convert_patch.dart:1555:13)
|
||||
#4 _JsonStringDecoderSink.add (dart:convert-patch/convert_patch.dart:1560:5)
|
||||
#5 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
|
||||
#6 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
|
||||
#7 _rootRunUnary (dart:async/zone.dart:1538:47)
|
||||
#8 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
|
||||
#9 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
|
||||
#10 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
|
||||
#11 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
|
||||
#12 _SinkTransformerStreamSubscription._add (dart:async/stream_transformers.dart:67:11)
|
||||
#13 _EventSinkWrapper.add (dart:async/stream_transformers.dart:13:11)
|
||||
#14 _StringAdapterSink.add (dart:convert/string_conversion.dart:228:11)
|
||||
#15 _StringAdapterSink.addSlice (dart:convert/string_conversion.dart:233:7)
|
||||
#16 _Utf8ConversionSink.addSlice (dart:convert/string_conversion.dart:313:20)
|
||||
#17 _Utf8ConversionSink.add (dart:convert/string_conversion.dart:306:5)
|
||||
#18 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
|
||||
#19 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
|
||||
#20 _rootRunUnary (dart:async/zone.dart:1538:47)
|
||||
#21 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
|
||||
#22 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
|
||||
#23 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
|
||||
#24 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
|
||||
#25 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
|
||||
#26 _StreamController._add (dart:async/stream_controller.dart:663:7)
|
||||
#27 _StreamController.add (dart:async/stream_controller.dart:618:5)
|
||||
#28 _Socket._onData (dart:io-patch/socket_patch.dart:2880:41)
|
||||
#29 _rootRunUnary (dart:async/zone.dart:1546:13)
|
||||
#30 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
|
||||
#31 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
|
||||
#32 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
|
||||
#33 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
|
||||
#34 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
|
||||
#35 _StreamController._add (dart:async/stream_controller.dart:663:7)
|
||||
#36 _StreamController.add (dart:async/stream_controller.dart:618:5)
|
||||
#37 new _RawSocket.<anonymous closure> (dart:io-patch/socket_patch.dart:2315:31)
|
||||
#38 _NativeSocket.issueReadEvent.issue (dart:io-patch/socket_patch.dart:1655:14)
|
||||
#39 _microtaskLoop (dart:async/schedule_microtask.dart:40:35)
|
||||
#40 _startMicrotaskLoop (dart:async/schedule_microtask.dart:49:5)
|
||||
#41 _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:127:13)
|
||||
#42 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:194:5)
|
||||
```
|
||||
|
||||
## flutter doctor
|
||||
|
||||
```
|
||||
[✓] Flutter (Channel stable, 3.38.7, on Microsoft Windows [版本 10.0.26200.9168], locale zh-CN) [841ms]
|
||||
• Flutter version 3.38.7 on channel stable at D:\fluttersdk\flutter_windows_3.38.7-stable\flutter
|
||||
• Upstream repository https://github.com/flutter/flutter.git
|
||||
• Framework revision 3b62efc2a3 (8 months ago), 2026-01-13 13:47:42 -0800
|
||||
• Engine revision 78fc3012e4
|
||||
• Dart version 3.10.7
|
||||
• DevTools version 2.51.1
|
||||
• Pub download mirror https://pub.flutter-io.cn
|
||||
• Flutter download mirror https://storage.flutter-io.cn
|
||||
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging
|
||||
|
||||
[✓] Windows Version (11 家庭版 中文版 64-bit, 25H2, 2009) [4.4s]
|
||||
|
||||
[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [9.0s]
|
||||
• Android SDK at C:\Users\jsmbz\AppData\Local\Android\Sdk
|
||||
• Emulator version 36.3.10.0 (build_id 14472402) (CL:N/A)
|
||||
• Platform android-36, build-tools 36.1.0
|
||||
• ANDROID_HOME = C:\Users\jsmbz\AppData\Local\Android\Sdk
|
||||
• Java binary at: D:\devtools\anrdstod\jbr\bin\java
|
||||
This is the JDK bundled with the latest Android Studio installation on this machine.
|
||||
To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
|
||||
• Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72)
|
||||
• All Android licenses accepted.
|
||||
|
||||
[✓] Chrome - develop for the web [11ms]
|
||||
• Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe
|
||||
|
||||
[✗] Visual Studio - develop Windows apps [8ms]
|
||||
✗ Visual Studio not installed; this is necessary to develop Windows apps.
|
||||
Download at https://visualstudio.microsoft.com/downloads/.
|
||||
Please install the "Desktop development with C++" workload, including all of its default components
|
||||
|
||||
[✓] Connected device (7 available) [11.6s]
|
||||
• 21091116AC (mobile) • 192.168.2.16:35367 • android-arm64 • Android 13 (API 33)
|
||||
• PKB110 (mobile) • 192.168.2.39:42831 • android-arm64 • Android 16 (API 36)
|
||||
• PKB110 (wireless) (mobile) • adb-4TRKWOEE4HR8AMEA-ti69Ed._adb-tls-connect._tcp • android-arm64 • Android 16 (API 36)
|
||||
• 21091116AC (wireless) (mobile) • adb-QK4LCEO78TWOVKOB-v9ALzk._adb-tls-connect._tcp • android-arm64 • Android 13 (API 33)
|
||||
• Windows (desktop) • windows • windows-x64 • Microsoft Windows [版本 10.0.26200.9168]
|
||||
• Chrome (web) • chrome • web-javascript • Google Chrome 151.0.7922.138
|
||||
• Edge (web) • edge • web-javascript • Microsoft Edge 151.0.4129.93
|
||||
|
||||
[✓] Network resources [939ms]
|
||||
• All expected network resources are available.
|
||||
|
||||
! Doctor found issues in 1 category.
|
||||
```
|
||||
@@ -1,5 +1,7 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
# platform :ios, '13.0'
|
||||
platform :ios, '13.0'
|
||||
source 'https://github.com/volcengine/volcengine-specs.git'
|
||||
source 'https://cdn.cocoapods.org/'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
121
ios/Podfile.lock
@@ -1,7 +1,58 @@
|
||||
PODS:
|
||||
- agora_rtc_engine (6.5.3):
|
||||
- AgoraIrisRTC_iOS (= 4.5.2-build.1)
|
||||
- AgoraRtcEngine_iOS (= 4.5.2)
|
||||
- Flutter
|
||||
- AgoraInfra_iOS (1.2.13.1)
|
||||
- AgoraIrisRTC_iOS (4.5.2-build.1)
|
||||
- AgoraRtcEngine_iOS (4.5.2):
|
||||
- AgoraRtcEngine_iOS/AIAEC (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/AIAECLL (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/AINS (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/AINSLL (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/AudioBeauty (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/ClearVision (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/ContentInspect (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/FaceCapture (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/FaceDetection (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/LipSync (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/ReplayKit (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/RtcBasic (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/SpatialAudio (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/VideoAv1CodecDec (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/VideoAv1CodecEnc (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/VideoCodecDec (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/VideoCodecEnc (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/VirtualBackground (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/VQA (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/AIAEC (4.5.2)
|
||||
- AgoraRtcEngine_iOS/AIAECLL (4.5.2)
|
||||
- AgoraRtcEngine_iOS/AINS (4.5.2)
|
||||
- AgoraRtcEngine_iOS/AINSLL (4.5.2)
|
||||
- AgoraRtcEngine_iOS/AudioBeauty (4.5.2)
|
||||
- AgoraRtcEngine_iOS/ClearVision (4.5.2)
|
||||
- AgoraRtcEngine_iOS/ContentInspect (4.5.2)
|
||||
- AgoraRtcEngine_iOS/FaceCapture (4.5.2)
|
||||
- AgoraRtcEngine_iOS/FaceDetection (4.5.2)
|
||||
- AgoraRtcEngine_iOS/LipSync (4.5.2)
|
||||
- AgoraRtcEngine_iOS/ReplayKit (4.5.2)
|
||||
- AgoraRtcEngine_iOS/RtcBasic (4.5.2):
|
||||
- AgoraInfra_iOS (= 1.2.13.1)
|
||||
- AgoraRtcEngine_iOS/SpatialAudio (4.5.2)
|
||||
- AgoraRtcEngine_iOS/VideoAv1CodecDec (4.5.2)
|
||||
- AgoraRtcEngine_iOS/VideoAv1CodecEnc (4.5.2)
|
||||
- AgoraRtcEngine_iOS/VideoCodecDec (4.5.2)
|
||||
- AgoraRtcEngine_iOS/VideoCodecEnc (4.5.2)
|
||||
- AgoraRtcEngine_iOS/VirtualBackground (4.5.2)
|
||||
- AgoraRtcEngine_iOS/VQA (4.5.2)
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_blue_plus_darwin (0.0.2):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- flutter_webrtc (1.4.0):
|
||||
- Flutter
|
||||
- WebRTC-SDK (= 144.7559.01)
|
||||
@@ -44,6 +95,8 @@ PODS:
|
||||
- GTMSessionFetcher/Core (2.3.0)
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- iris_method_channel (0.0.1):
|
||||
- Flutter
|
||||
- isar_community_flutter_libs (1.0.0):
|
||||
- Flutter
|
||||
- MLImage (1.0.0-beta4)
|
||||
@@ -72,6 +125,8 @@ PODS:
|
||||
- nanopb/encode (= 2.30910.0)
|
||||
- nanopb/decode (2.30910.0)
|
||||
- nanopb/encode (2.30910.0)
|
||||
- open_file_ios (1.0.3):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- permission_handler_apple (9.3.0):
|
||||
@@ -89,29 +144,67 @@ PODS:
|
||||
- Flutter
|
||||
- vibration (3.0.0):
|
||||
- Flutter
|
||||
- video_player_avfoundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- volc_engine_rtc (3.60.4):
|
||||
- Flutter
|
||||
- VolcApiEngine (= 1.7.0)
|
||||
- VolcEngineRTC (= 3.60.103.2100)
|
||||
- VolcApiEngine (1.7.0)
|
||||
- VolcEngineRTC (3.60.103.2100):
|
||||
- VolcEngineRTC/BMF (= 3.60.103.2100)
|
||||
- VolcEngineRTC/ByteRTCVideoDenoiseExtension (= 3.60.103.2100)
|
||||
- VolcEngineRTC/ByteRTCVideoSharpenExtension (= 3.60.103.2100)
|
||||
- VolcEngineRTC/ByteRTCVideoSRExtension (= 3.60.103.2100)
|
||||
- VolcEngineRTC/Core (= 3.60.103.2100)
|
||||
- VolcEngineRTC/CVByteNN (= 3.60.103.2100)
|
||||
- VolcEngineRTC/RealXBase (= 3.60.103.2100)
|
||||
- VolcEngineRTC/RTCFFmpeg (= 3.60.103.2100)
|
||||
- VolcEngineRTC/BMF (3.60.103.2100)
|
||||
- VolcEngineRTC/ByteRTCVideoDenoiseExtension (3.60.103.2100)
|
||||
- VolcEngineRTC/ByteRTCVideoSharpenExtension (3.60.103.2100)
|
||||
- VolcEngineRTC/ByteRTCVideoSRExtension (3.60.103.2100)
|
||||
- VolcEngineRTC/Core (3.60.103.2100)
|
||||
- VolcEngineRTC/CVByteNN (3.60.103.2100)
|
||||
- VolcEngineRTC/RealXBase (3.60.103.2100)
|
||||
- VolcEngineRTC/RTCFFmpeg (3.60.103.2100)
|
||||
- WebRTC-SDK (144.7559.01)
|
||||
- webview_flutter_wkwebview (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- agora_rtc_engine (from `.symlinks/plugins/agora_rtc_engine/ios`)
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
|
||||
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
|
||||
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- iris_method_channel (from `.symlinks/plugins/iris_method_channel/ios`)
|
||||
- isar_community_flutter_libs (from `.symlinks/plugins/isar_community_flutter_libs/ios`)
|
||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
|
||||
- open_file_ios (from `.symlinks/plugins/open_file_ios/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- vibration (from `.symlinks/plugins/vibration/ios`)
|
||||
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
|
||||
- volc_engine_rtc (from `.symlinks/plugins/volc_engine_rtc/ios`)
|
||||
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
||||
|
||||
SPEC REPOS:
|
||||
https://github.com/volcengine/volcengine-specs.git:
|
||||
- VolcApiEngine
|
||||
- VolcEngineRTC
|
||||
trunk:
|
||||
- AgoraInfra_iOS
|
||||
- AgoraIrisRTC_iOS
|
||||
- AgoraRtcEngine_iOS
|
||||
- GoogleDataTransport
|
||||
- GoogleMLKit
|
||||
- GoogleToolboxForMac
|
||||
@@ -128,20 +221,30 @@ SPEC REPOS:
|
||||
- WebRTC-SDK
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
agora_rtc_engine:
|
||||
:path: ".symlinks/plugins/agora_rtc_engine/ios"
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_blue_plus_darwin:
|
||||
:path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
|
||||
flutter_webrtc:
|
||||
:path: ".symlinks/plugins/flutter_webrtc/ios"
|
||||
geolocator_apple:
|
||||
:path: ".symlinks/plugins/geolocator_apple/darwin"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
iris_method_channel:
|
||||
:path: ".symlinks/plugins/iris_method_channel/ios"
|
||||
isar_community_flutter_libs:
|
||||
:path: ".symlinks/plugins/isar_community_flutter_libs/ios"
|
||||
mobile_scanner:
|
||||
:path: ".symlinks/plugins/mobile_scanner/ios"
|
||||
open_file_ios:
|
||||
:path: ".symlinks/plugins/open_file_ios/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
permission_handler_apple:
|
||||
@@ -154,12 +257,22 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
vibration:
|
||||
:path: ".symlinks/plugins/vibration/ios"
|
||||
video_player_avfoundation:
|
||||
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
|
||||
volc_engine_rtc:
|
||||
:path: ".symlinks/plugins/volc_engine_rtc/ios"
|
||||
webview_flutter_wkwebview:
|
||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
agora_rtc_engine: 0c7d50312967c4dc31c3c45e50589ce48f57e08a
|
||||
AgoraInfra_iOS: 3691b2b277a1712a35ae96de25af319de0d73d08
|
||||
AgoraIrisRTC_iOS: eab58c126439adf5ec99632828a558ea216860da
|
||||
AgoraRtcEngine_iOS: 97e2398a2addda9057815a2a583a658e36796ff6
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_blue_plus_darwin: ee2377ea51a6c9e481f2ed76ec597717ef5d287d
|
||||
flutter_webrtc: ec91d94b484ad49cf191ef93413f64a40ffd3b4c
|
||||
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
||||
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
|
||||
@@ -169,6 +282,7 @@ SPEC CHECKSUMS:
|
||||
GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe
|
||||
GTMSessionFetcher: 3a63d75eecd6aa32c2fc79f578064e1214dfdec2
|
||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||
iris_method_channel: 7d661cf3259b3009ae423508470dbeb9374446ee
|
||||
isar_community_flutter_libs: bede843185a61a05ff364a05c9b23209523f7e0d
|
||||
MLImage: 7bb7c4264164ade9bf64f679b40fb29c8f33ee9b
|
||||
MLKitBarcodeScanning: 04e264482c5f3810cb89ebc134ef6b61e67db505
|
||||
@@ -176,6 +290,7 @@ SPEC CHECKSUMS:
|
||||
MLKitVision: 8baa5f46ee3352614169b85250574fde38c36f49
|
||||
mobile_scanner: b67191637a5ea7ba15652ca208069d2bae1900ab
|
||||
nanopb: 438bc412db1928dac798aa6fd75726007be04262
|
||||
open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
@@ -184,9 +299,13 @@ SPEC CHECKSUMS:
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
|
||||
video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
|
||||
volc_engine_rtc: 1bc697fba3c7a2f6513a18ec2abfb629188ce6ff
|
||||
VolcApiEngine: e1f8b47ec5ef64bf6b48e3600be132c42643f8f0
|
||||
VolcEngineRTC: 889b981b13621208b25942263f0fd5817c93b9bc
|
||||
WebRTC-SDK: ab9b5319e458c2bfebdc92b3600740da35d5630d
|
||||
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
|
||||
|
||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||
PODFILE CHECKSUM: 2da7455b7ebb4d9567497e4f758b13986b3bb736
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -494,14 +494,14 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = R5KTW5WPS3;
|
||||
DEVELOPMENT_TEAM = 93X3AN3P79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibusatabotv2;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
@@ -519,7 +519,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibusatabotv2.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -537,7 +537,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibusatabotv2.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -553,7 +553,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibusatabotv2.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -680,14 +680,14 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = R5KTW5WPS3;
|
||||
DEVELOPMENT_TEAM = 93X3AN3P79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibusatabotv2;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
@@ -706,14 +706,14 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = R5KTW5WPS3;
|
||||
DEVELOPMENT_TEAM = 93X3AN3P79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibusatabotv2;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 690 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 15 KiB |
@@ -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>
|
||||
@@ -66,5 +66,35 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<!-- 相机权限 -->
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>需要使用相机拍照和录像</string>
|
||||
<!-- 相册权限 -->
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>需要访问相册以选择图片和视频</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>需要保存拍摄的照片和视频到相册</string>
|
||||
<!-- 麦克风权限(录像需要) -->
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>录像时需要使用麦克风录制声音</string>
|
||||
<!-- 允许 HTTP 连接(火山引擎视频流) -->
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<!-- 蓝牙权限 -->
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<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>
|
||||
|
||||
947
lib/components/agora_video_player.dart
Normal file
@@ -0,0 +1,947 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:agora_rtc_engine/agora_rtc_engine.dart' as agora;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 声网视频播放状态
|
||||
enum AgoraPlayerState {
|
||||
/// 空闲:未连接或已主动断开
|
||||
idle,
|
||||
|
||||
/// 连接中:初始化引擎 / 加入频道 / 等待远端首帧
|
||||
connecting,
|
||||
|
||||
/// 播放中:已收到远端首帧
|
||||
playing,
|
||||
|
||||
/// 失败:参数缺失、加入频道失败、超时或 SDK 报错
|
||||
error,
|
||||
}
|
||||
|
||||
/// 通用声网(Agora RTC)视频展示组件
|
||||
///
|
||||
/// 只需要 appId / channelId / token 三个值即可拉流播放,组件内部自行完成
|
||||
/// 引擎创建、加入频道、远端首帧监听、渲染与资源释放,调用方不用管生命周期。
|
||||
///
|
||||
/// 两种用法:
|
||||
///
|
||||
/// 1)直接当普通 Widget 嵌进布局:
|
||||
/// ```dart
|
||||
/// AgoraVideoPlayer(appId: 'x', channelId: 'y', token: 'z')
|
||||
/// ```
|
||||
///
|
||||
/// 2)一行代码弹出(推荐,关闭弹窗即自动释放引擎):
|
||||
/// ```dart
|
||||
/// onTap: () => AgoraVideoPlayer.show(
|
||||
/// context,
|
||||
/// appId: 'x',
|
||||
/// channelId: 'y',
|
||||
/// token: 'z',
|
||||
/// ),
|
||||
/// ```
|
||||
class AgoraVideoPlayer extends StatefulWidget {
|
||||
/// 声网 App ID
|
||||
final String appId;
|
||||
|
||||
/// 频道名(后端接口里的 channel / room_id)
|
||||
final String channelId;
|
||||
|
||||
/// 频道 Token,未开启鉴权时可传空串
|
||||
final String token;
|
||||
|
||||
/// 本端 uid,纯观看传 0 由 SDK 自动分配
|
||||
final int uid;
|
||||
|
||||
/// 频道模式,默认直播模式
|
||||
final agora.ChannelProfileType channelProfile;
|
||||
|
||||
/// 本端角色,默认观众(只拉流不推流,不占用相机麦克风)
|
||||
final agora.ClientRoleType clientRole;
|
||||
|
||||
/// 画面填充模式
|
||||
final agora.RenderModeType renderMode;
|
||||
|
||||
/// 是否订阅音频,默认只订阅视频
|
||||
final bool subscribeAudio;
|
||||
|
||||
/// Android 使用 SurfaceView 渲染(出现黑屏 / 层级遮挡时可切 true)
|
||||
final bool useAndroidSurfaceView;
|
||||
|
||||
/// iOS、桌面端使用 Flutter Texture 渲染
|
||||
final bool useFlutterTexture;
|
||||
|
||||
/// 是否在 initState 自动连接
|
||||
final bool autoConnect;
|
||||
|
||||
/// 连接超时:超过该时长仍未收到远端首帧判定为失败
|
||||
final Duration connectTimeout;
|
||||
|
||||
/// 状态变化回调
|
||||
final ValueChanged<AgoraPlayerState>? onStateChanged;
|
||||
|
||||
/// 首帧解码成功回调,参数为远端 uid
|
||||
final ValueChanged<int>? onFirstFrame;
|
||||
|
||||
/// 错误回调
|
||||
final ValueChanged<String>? onError;
|
||||
|
||||
/// 视频区背景色
|
||||
final Color backgroundColor;
|
||||
|
||||
/// 正在建连时的提示文案
|
||||
final String loadingText;
|
||||
|
||||
/// 已进房但未收到画面时的提示文案
|
||||
final String waitingText;
|
||||
|
||||
/// 失败时是否显示内置重试按钮
|
||||
final bool showRetryButton;
|
||||
|
||||
/// 自定义失败态 UI,返回 null 则使用内置样式
|
||||
final Widget Function(BuildContext context, String message)? errorBuilder;
|
||||
|
||||
/// 重连令牌:值变化即强制重新连接,即使 appId / channelId / token 完全相同
|
||||
/// (供「重新加载」按钮使用)
|
||||
final int reloadToken;
|
||||
|
||||
const AgoraVideoPlayer({
|
||||
super.key,
|
||||
required this.appId,
|
||||
required this.channelId,
|
||||
required this.token,
|
||||
this.uid = 0,
|
||||
this.channelProfile =
|
||||
agora.ChannelProfileType.channelProfileLiveBroadcasting,
|
||||
this.clientRole = agora.ClientRoleType.clientRoleAudience,
|
||||
this.renderMode = agora.RenderModeType.renderModeFit,
|
||||
this.subscribeAudio = false,
|
||||
this.useAndroidSurfaceView = false,
|
||||
this.useFlutterTexture = false,
|
||||
this.autoConnect = true,
|
||||
this.connectTimeout = const Duration(seconds: 15),
|
||||
this.onStateChanged,
|
||||
this.onFirstFrame,
|
||||
this.onError,
|
||||
this.backgroundColor = Colors.black,
|
||||
this.loadingText = '视频加载中...',
|
||||
this.waitingText = '等待视频流...',
|
||||
this.showRetryButton = true,
|
||||
this.errorBuilder,
|
||||
this.reloadToken = 0,
|
||||
});
|
||||
|
||||
/// 从后端返回的 `app_id=xx&channel=yy&token=zz&user_id=0` 形式参数串构建
|
||||
factory AgoraVideoPlayer.fromUrl({
|
||||
Key? key,
|
||||
required String url,
|
||||
agora.RenderModeType renderMode = agora.RenderModeType.renderModeFit,
|
||||
Duration connectTimeout = const Duration(seconds: 15),
|
||||
ValueChanged<AgoraPlayerState>? onStateChanged,
|
||||
}) {
|
||||
final p = parseParams(url);
|
||||
return AgoraVideoPlayer(
|
||||
key: key,
|
||||
appId: p['app_id'] ?? p['appid'] ?? '',
|
||||
channelId:
|
||||
p['channel'] ?? p['channel_id'] ?? p['room_id'] ?? p['roomid'] ?? '',
|
||||
token: p['token'] ?? '',
|
||||
uid: int.tryParse(p['user_id'] ?? p['uid'] ?? '') ?? 0,
|
||||
renderMode: renderMode,
|
||||
connectTimeout: connectTimeout,
|
||||
onStateChanged: onStateChanged,
|
||||
);
|
||||
}
|
||||
|
||||
/// 解析 `k=v&k=v` 参数串
|
||||
static Map<String, String> parseParams(String url) {
|
||||
final params = <String, String>{};
|
||||
for (final pair in url.split('&')) {
|
||||
final kv = pair.split('=');
|
||||
if (kv.length == 2) {
|
||||
params[kv[0].trim()] = Uri.decodeComponent(kv[1]);
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/// 【核心入口】一行代码弹出居中视频弹窗(16:9)
|
||||
///
|
||||
/// 关闭弹窗时组件被移出 widget 树,自动触发 dispose →
|
||||
/// leaveChannel + release,调用方不需要手动清理任何东西。
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required String appId,
|
||||
required String channelId,
|
||||
required String token,
|
||||
int uid = 0,
|
||||
String title = '实时视频',
|
||||
agora.RenderModeType renderMode = agora.RenderModeType.renderModeFit,
|
||||
bool barrierDismissible = true,
|
||||
ValueChanged<AgoraPlayerState>? onStateChanged,
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
barrierColor: Colors.black87,
|
||||
builder: (dialogContext) => Dialog(
|
||||
backgroundColor: Colors.black,
|
||||
elevation: 0,
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
color: const Color(0xFF2A2E34),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.close,
|
||||
color: Colors.white70,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: AgoraVideoPlayer(
|
||||
appId: appId,
|
||||
channelId: channelId,
|
||||
token: token,
|
||||
uid: uid,
|
||||
renderMode: renderMode,
|
||||
onStateChanged: onStateChanged,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 以底部弹层形式弹出(适合列表页预览,不遮挡整屏)
|
||||
static Future<void> showBottomSheet(
|
||||
BuildContext context, {
|
||||
required String appId,
|
||||
required String channelId,
|
||||
required String token,
|
||||
int uid = 0,
|
||||
double heightFactor = 0.42,
|
||||
agora.RenderModeType renderMode = agora.RenderModeType.renderModeFit,
|
||||
ValueChanged<AgoraPlayerState>? onStateChanged,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.black,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (sheetContext) => SizedBox(
|
||||
height: MediaQuery.of(sheetContext).size.height * heightFactor,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white24,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: AgoraVideoPlayer(
|
||||
appId: appId,
|
||||
channelId: channelId,
|
||||
token: token,
|
||||
uid: uid,
|
||||
renderMode: renderMode,
|
||||
onStateChanged: onStateChanged,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 【手动参数入口】弹出带 App ID / Channel / Token 三个输入框的弹窗
|
||||
///
|
||||
/// 填完点「重新加载」开始拉流;即使参数一模一样也会强制重连。
|
||||
/// 适用于接口未就绪、需要手工验证频道参数的场景。
|
||||
/// 关闭弹窗同样会自动释放引擎。
|
||||
static Future<void> showManualInput(
|
||||
BuildContext context, {
|
||||
String title = '实时视频',
|
||||
String initialAppId = '',
|
||||
String initialChannelId = '',
|
||||
String initialToken = '',
|
||||
bool barrierDismissible = true,
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
barrierColor: Colors.black87,
|
||||
builder: (_) => _AgoraManualParamsDialog(
|
||||
title: title,
|
||||
initialAppId: initialAppId,
|
||||
initialChannelId: initialChannelId,
|
||||
initialToken: initialToken,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<AgoraVideoPlayer> createState() => _AgoraVideoPlayerState();
|
||||
}
|
||||
|
||||
class _AgoraVideoPlayerState extends State<AgoraVideoPlayer> {
|
||||
agora.RtcEngine? _engine;
|
||||
agora.RtcEngineEventHandler? _handler;
|
||||
|
||||
AgoraPlayerState _state = AgoraPlayerState.idle;
|
||||
String? _errorMessage;
|
||||
int? _remoteUid;
|
||||
bool _isJoined = false;
|
||||
bool _isDisposed = false;
|
||||
bool _firstFrameNotified = false;
|
||||
Timer? _connectTimer;
|
||||
|
||||
/// 连接序号:参数快速变化时用于丢弃过期的异步流程
|
||||
int _connectSeq = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.autoConnect) {
|
||||
_connect();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AgoraVideoPlayer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final changed =
|
||||
oldWidget.appId != widget.appId ||
|
||||
oldWidget.channelId != widget.channelId ||
|
||||
oldWidget.token != widget.token ||
|
||||
oldWidget.uid != widget.uid ||
|
||||
oldWidget.reloadToken != widget.reloadToken;
|
||||
if (changed) {
|
||||
_log('参数变化,重新连接: ${widget.channelId}');
|
||||
_connect();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
_cancelConnectTimer();
|
||||
_release();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _log(String msg) => debugPrint('🎥 [Agora] $msg');
|
||||
|
||||
void _updateState(AgoraPlayerState next, {String? message}) {
|
||||
if (_state == next && message == _errorMessage) return;
|
||||
_state = next;
|
||||
_errorMessage = message;
|
||||
if (next == AgoraPlayerState.playing) {
|
||||
_cancelConnectTimer();
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
widget.onStateChanged?.call(next);
|
||||
if (next == AgoraPlayerState.error && message != null) {
|
||||
widget.onError?.call(message);
|
||||
}
|
||||
}
|
||||
|
||||
void _startConnectTimer() {
|
||||
_cancelConnectTimer();
|
||||
_connectTimer = Timer(widget.connectTimeout, () {
|
||||
if (_isDisposed || _state == AgoraPlayerState.playing) return;
|
||||
_log('⏰ 连接超时(${widget.connectTimeout.inSeconds}s 未收到首帧)');
|
||||
_updateState(AgoraPlayerState.error, message: '视频连接超时,请重试');
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelConnectTimer() {
|
||||
_connectTimer?.cancel();
|
||||
_connectTimer = null;
|
||||
}
|
||||
|
||||
/// 建立连接:先释放旧引擎,再创建 → 初始化 → 注册回调 → 加入频道
|
||||
Future<void> _connect() async {
|
||||
final seq = ++_connectSeq;
|
||||
_cancelConnectTimer();
|
||||
await _release();
|
||||
if (_isDisposed || seq != _connectSeq) return;
|
||||
|
||||
if (widget.appId.isEmpty || widget.channelId.isEmpty) {
|
||||
_updateState(AgoraPlayerState.error, message: 'appId / channelId 不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
_remoteUid = null;
|
||||
_isJoined = false;
|
||||
_firstFrameNotified = false;
|
||||
_updateState(AgoraPlayerState.connecting);
|
||||
_startConnectTimer();
|
||||
|
||||
try {
|
||||
final engine = agora.createAgoraRtcEngine();
|
||||
_engine = engine;
|
||||
|
||||
await engine.initialize(
|
||||
agora.RtcEngineContext(
|
||||
appId: widget.appId,
|
||||
channelProfile: widget.channelProfile,
|
||||
),
|
||||
);
|
||||
if (_isDisposed || seq != _connectSeq) return;
|
||||
|
||||
await engine.enableVideo();
|
||||
if (_isDisposed || seq != _connectSeq) return;
|
||||
|
||||
_handler = _buildEventHandler();
|
||||
engine.registerEventHandler(_handler!);
|
||||
|
||||
_log('加入频道: ${widget.channelId}');
|
||||
await engine.joinChannel(
|
||||
token: widget.token,
|
||||
channelId: widget.channelId,
|
||||
uid: widget.uid,
|
||||
options: agora.ChannelMediaOptions(
|
||||
channelProfile: widget.channelProfile,
|
||||
clientRoleType: widget.clientRole,
|
||||
autoSubscribeVideo: true,
|
||||
autoSubscribeAudio: widget.subscribeAudio,
|
||||
),
|
||||
);
|
||||
if (_isDisposed || seq != _connectSeq) return;
|
||||
_log('✅ joinChannel 调用成功,等待远端首帧...');
|
||||
} catch (e) {
|
||||
_log('❌ 初始化失败: $e');
|
||||
_updateState(AgoraPlayerState.error, message: '视频连接失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
agora.RtcEngineEventHandler _buildEventHandler() {
|
||||
return agora.RtcEngineEventHandler(
|
||||
onJoinChannelSuccess: (agora.RtcConnection connection, int elapsed) {
|
||||
_log('✅ 加入频道成功: ${connection.channelId}, 耗时 ${elapsed}ms');
|
||||
if (!mounted) return;
|
||||
setState(() => _isJoined = true);
|
||||
},
|
||||
|
||||
onUserJoined: (agora.RtcConnection connection, int rUid, int elapsed) {
|
||||
_log('👤 远端用户加入: uid=$rUid');
|
||||
if (!mounted) return;
|
||||
// VideoViewController.remote 内部断言 uid != 0,uid 为 0 时构建会崩
|
||||
if (rUid == 0) {
|
||||
_log('⚠️ 远端 uid=0,无法用 remote View 渲染,已忽略');
|
||||
return;
|
||||
}
|
||||
setState(() => _remoteUid ??= rUid);
|
||||
},
|
||||
|
||||
onUserOffline: (
|
||||
agora.RtcConnection connection,
|
||||
int rUid,
|
||||
agora.UserOfflineReasonType reason,
|
||||
) {
|
||||
_log('👋 远端用户离开: uid=$rUid, reason=$reason');
|
||||
if (!mounted || _remoteUid != rUid) return;
|
||||
setState(() => _remoteUid = null);
|
||||
// 推流端掉线,回到等待态并重新计时
|
||||
_updateState(AgoraPlayerState.connecting);
|
||||
_startConnectTimer();
|
||||
},
|
||||
|
||||
onRemoteVideoStateChanged: (
|
||||
agora.RtcConnection connection,
|
||||
int rUid,
|
||||
agora.RemoteVideoState state,
|
||||
agora.RemoteVideoStateReason reason,
|
||||
int elapsed,
|
||||
) {
|
||||
if (!mounted) return;
|
||||
_log('📹 远端视频状态: uid=$rUid, state=$state, reason=$reason');
|
||||
|
||||
if (state == agora.RemoteVideoState.remoteVideoStateDecoding) {
|
||||
if (rUid != 0 && _remoteUid == null) {
|
||||
setState(() => _remoteUid = rUid);
|
||||
}
|
||||
_updateState(AgoraPlayerState.playing);
|
||||
if (!_firstFrameNotified) {
|
||||
_firstFrameNotified = true;
|
||||
widget.onFirstFrame?.call(rUid);
|
||||
}
|
||||
} else if (state == agora.RemoteVideoState.remoteVideoStateFrozen) {
|
||||
_updateState(AgoraPlayerState.connecting);
|
||||
} else if (state == agora.RemoteVideoState.remoteVideoStateFailed) {
|
||||
_updateState(AgoraPlayerState.error, message: '远端视频流异常: $reason');
|
||||
}
|
||||
},
|
||||
|
||||
onLeaveChannel: (agora.RtcConnection connection, agora.RtcStats stats) {
|
||||
_log('已离开频道: ${connection.channelId}');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isJoined = false;
|
||||
_remoteUid = null;
|
||||
});
|
||||
},
|
||||
|
||||
onError: (agora.ErrorCodeType err, String msg) {
|
||||
_log('❌ SDK 错误: $err - $msg');
|
||||
_updateState(AgoraPlayerState.error, message: '声网错误($err): $msg');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _release() async {
|
||||
final engine = _engine;
|
||||
final handler = _handler;
|
||||
_engine = null;
|
||||
_handler = null;
|
||||
if (engine == null) return;
|
||||
|
||||
try {
|
||||
if (handler != null) {
|
||||
engine.unregisterEventHandler(handler);
|
||||
}
|
||||
await engine.leaveChannel();
|
||||
await engine.release();
|
||||
_log('🗑️ 引擎已释放');
|
||||
} catch (e) {
|
||||
_log('⚠️ 释放引擎失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 关键:远端 uid 未知时绝不能构建 remote View(SDK 内部有 uid != 0 断言)
|
||||
final canRender = _engine != null && _isJoined && _remoteUid != null;
|
||||
|
||||
return Container(
|
||||
color: widget.backgroundColor,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (canRender) _buildVideoView(),
|
||||
if (_state != AgoraPlayerState.playing) _buildOverlay(canRender),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVideoView() {
|
||||
return agora.AgoraVideoView(
|
||||
controller: agora.VideoViewController.remote(
|
||||
rtcEngine: _engine!,
|
||||
canvas: agora.VideoCanvas(
|
||||
uid: _remoteUid!,
|
||||
renderMode: widget.renderMode,
|
||||
),
|
||||
connection: agora.RtcConnection(channelId: widget.channelId),
|
||||
useAndroidSurfaceView: widget.useAndroidSurfaceView,
|
||||
useFlutterTexture: widget.useFlutterTexture,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOverlay(bool canRender) {
|
||||
if (_state == AgoraPlayerState.error) {
|
||||
final message = _errorMessage ?? '视频加载失败';
|
||||
if (widget.errorBuilder != null) {
|
||||
return widget.errorBuilder!(context, message);
|
||||
}
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.redAccent),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (widget.showRetryButton) ...[
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _connect,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF165DFF),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final text = canRender ? widget.waitingText : widget.loadingText;
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(text, style: const TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 手动填写声网参数的弹窗,由 [AgoraVideoPlayer.showManualInput] 弹出
|
||||
///
|
||||
/// 结构:标题栏 + 三个输入框 + 「重新加载」按钮 + 16:9 视频区。
|
||||
/// 点「重新加载」会递增 reloadToken,即使参数未变也会强制断开重连。
|
||||
class _AgoraManualParamsDialog extends StatefulWidget {
|
||||
final String title;
|
||||
final String initialAppId;
|
||||
final String initialChannelId;
|
||||
final String initialToken;
|
||||
|
||||
const _AgoraManualParamsDialog({
|
||||
required this.title,
|
||||
this.initialAppId = '',
|
||||
this.initialChannelId = '',
|
||||
this.initialToken = '',
|
||||
});
|
||||
|
||||
@override
|
||||
State<_AgoraManualParamsDialog> createState() =>
|
||||
_AgoraManualParamsDialogState();
|
||||
}
|
||||
|
||||
class _AgoraManualParamsDialogState extends State<_AgoraManualParamsDialog> {
|
||||
static const Color _primary = Color(0xFF165DFF);
|
||||
static const Color _danger = Color(0xFFF53F3F);
|
||||
static const Color _success = Color(0xFF00B42A);
|
||||
|
||||
late final TextEditingController _appIdCtrl = TextEditingController(
|
||||
text: widget.initialAppId,
|
||||
);
|
||||
late final TextEditingController _channelCtrl = TextEditingController(
|
||||
text: widget.initialChannelId,
|
||||
);
|
||||
late final TextEditingController _tokenCtrl = TextEditingController(
|
||||
text: widget.initialToken,
|
||||
);
|
||||
|
||||
/// 已生效的参数(点「重新加载」才同步,输入过程中不触发重连)
|
||||
String _appId = '';
|
||||
String _channelId = '';
|
||||
String _token = '';
|
||||
|
||||
/// 递增即强制重连
|
||||
int _reloadToken = 0;
|
||||
|
||||
/// 是否已发起过加载(未发起时视频区只放占位文案)
|
||||
bool _started = false;
|
||||
String? _hint;
|
||||
AgoraPlayerState _state = AgoraPlayerState.idle;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_appId = widget.initialAppId.trim();
|
||||
_channelId = widget.initialChannelId.trim();
|
||||
_token = widget.initialToken.trim();
|
||||
// 调用方已经把参数给齐时直接开播,不用手点
|
||||
_started = _appId.isNotEmpty && _channelId.isNotEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_appIdCtrl.dispose();
|
||||
_channelCtrl.dispose();
|
||||
_tokenCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _reload() {
|
||||
FocusScope.of(context).unfocus();
|
||||
final appId = _appIdCtrl.text.trim();
|
||||
final channelId = _channelCtrl.text.trim();
|
||||
if (appId.isEmpty || channelId.isEmpty) {
|
||||
setState(() {
|
||||
_hint = 'App ID 与 Channel 不能为空';
|
||||
_started = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_appId = appId;
|
||||
_channelId = channelId;
|
||||
_token = _tokenCtrl.text.trim();
|
||||
_reloadToken++;
|
||||
_started = true;
|
||||
_hint = null;
|
||||
});
|
||||
debugPrint('🎥 [Agora] 手动重新加载: channel=$_channelId, seq=$_reloadToken');
|
||||
}
|
||||
|
||||
String get _stateText {
|
||||
switch (_state) {
|
||||
case AgoraPlayerState.idle:
|
||||
return '未连接';
|
||||
case AgoraPlayerState.connecting:
|
||||
return '连接中…';
|
||||
case AgoraPlayerState.playing:
|
||||
return '播放中';
|
||||
case AgoraPlayerState.error:
|
||||
return '连接失败';
|
||||
}
|
||||
}
|
||||
|
||||
Color get _stateColor {
|
||||
switch (_state) {
|
||||
case AgoraPlayerState.idle:
|
||||
return Colors.white38;
|
||||
case AgoraPlayerState.connecting:
|
||||
return Colors.amberAccent;
|
||||
case AgoraPlayerState.playing:
|
||||
return _success;
|
||||
case AgoraPlayerState.error:
|
||||
return _danger;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: Colors.black,
|
||||
elevation: 0,
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildInput(
|
||||
controller: _appIdCtrl,
|
||||
label: 'App ID',
|
||||
hint: '声网控制台的应用 ID',
|
||||
),
|
||||
_buildInput(
|
||||
controller: _channelCtrl,
|
||||
label: 'Channel',
|
||||
hint: '频道名 / room_id',
|
||||
),
|
||||
_buildInput(
|
||||
controller: _tokenCtrl,
|
||||
label: 'Token',
|
||||
hint: '未开启鉴权可留空',
|
||||
maxLines: 2,
|
||||
),
|
||||
if (_hint != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_hint!,
|
||||
style: const TextStyle(color: _danger, fontSize: 12),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _reload,
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: const Text('重新加载'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _primary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_buildStatusBar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
AspectRatio(aspectRatio: 16 / 9, child: _buildVideoArea()),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
color: const Color(0xFF2A2E34),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white70, size: 20),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusBar() {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(color: _stateColor, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'状态:$_stateText',
|
||||
style: TextStyle(color: _stateColor, fontSize: 12),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Text(
|
||||
_started ? 'channel: $_channelId' : '未发起连接',
|
||||
style: const TextStyle(color: Colors.white24, fontSize: 11),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVideoArea() {
|
||||
if (!_started) {
|
||||
return Container(
|
||||
color: Colors.black,
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: const Text(
|
||||
'填写 App ID / Channel 后点「重新加载」',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.white38, fontSize: 13),
|
||||
),
|
||||
);
|
||||
}
|
||||
return AgoraVideoPlayer(
|
||||
appId: _appId,
|
||||
channelId: _channelId,
|
||||
token: _token,
|
||||
reloadToken: _reloadToken,
|
||||
onStateChanged: (s) {
|
||||
if (!mounted) return;
|
||||
setState(() => _state = s);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInput({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
String? hint,
|
||||
int maxLines = 1,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
maxLines: maxLines,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13),
|
||||
cursorColor: _primary,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
labelStyle: const TextStyle(color: Colors.white54, fontSize: 13),
|
||||
hintText: hint,
|
||||
hintStyle: const TextStyle(color: Colors.white24, fontSize: 12),
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF1A1D21),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: Colors.white12),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: Colors.white12),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: _primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
18
lib/components/app_logo.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||