diff --git a/.app-report-build/build.mjs b/.app-report-build/build.mjs new file mode 100644 index 00000000..9369163b --- /dev/null +++ b/.app-report-build/build.mjs @@ -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 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}); diff --git a/.app-report-build/slide-1.png b/.app-report-build/slide-1.png new file mode 100644 index 00000000..d20c8b75 Binary files /dev/null and b/.app-report-build/slide-1.png differ diff --git a/.app-report-build/slide-2.png b/.app-report-build/slide-2.png new file mode 100644 index 00000000..32e3f4bf Binary files /dev/null and b/.app-report-build/slide-2.png differ diff --git a/.app-report-build/slide-3.png b/.app-report-build/slide-3.png new file mode 100644 index 00000000..d9d5f3c6 Binary files /dev/null and b/.app-report-build/slide-3.png differ diff --git a/.app-report-build/slide-4.png b/.app-report-build/slide-4.png new file mode 100644 index 00000000..977eaf6d Binary files /dev/null and b/.app-report-build/slide-4.png differ diff --git a/.app-report-build/slide-5.png b/.app-report-build/slide-5.png new file mode 100644 index 00000000..2da036b3 Binary files /dev/null and b/.app-report-build/slide-5.png differ diff --git a/assets/images/img_1.png b/assets/images/img_1.png new file mode 100644 index 00000000..a383e388 Binary files /dev/null and b/assets/images/img_1.png differ diff --git a/lib/components/app_logo.dart b/lib/components/app_logo.dart new file mode 100644 index 00000000..62402d97 --- /dev/null +++ b/lib/components/app_logo.dart @@ -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(), + ); + } +} diff --git a/lib/features/autonomous_work/domain/usecases/load_site_return_plan_usecase.dart b/lib/features/autonomous_work/domain/usecases/load_site_return_plan_usecase.dart new file mode 100644 index 00000000..c2b512a6 --- /dev/null +++ b/lib/features/autonomous_work/domain/usecases/load_site_return_plan_usecase.dart @@ -0,0 +1,33 @@ +import 'dart:async'; +import '../entities/return_plan_models.dart'; +import '../entities/work_session_models.dart'; +import '../repositories/autonomous_work_gateway.dart'; + +/// 复用已有返航方案网关,不把 siteMapInfo 的 JSON 或 HTTP 带到页面。 +class LoadSiteReturnPlanUseCase { + LoadSiteReturnPlanUseCase(this.gateway); + final AutonomousWorkGateway gateway; + + Future> call({ + required int siteId, + required int orgId, + }) async { + if (siteId <= 0 || orgId <= 0) return const WorkResult.fail('请先选择有效场站'); + try { + final result = await gateway + .fetchReturnPlanBySiteId(siteId: siteId) + .timeout(const Duration(seconds: 20)); + final plan = result.data; + if (result.isOk && + plan != null && + (plan.siteId != siteId || plan.orgId != orgId)) { + return const WorkResult.fail('返航方案不属于当前场站,请刷新后重试'); + } + return result; + } on TimeoutException { + return const WorkResult.fail('返航方案查询超时,请重试'); + } catch (error) { + return WorkResult.fail('返航方案加载失败:$error'); + } + } +} diff --git a/lib/features/consecutive_tasks/data/datasources/consecutive_task_remote_datasource.dart b/lib/features/consecutive_tasks/data/datasources/consecutive_task_remote_datasource.dart new file mode 100644 index 00000000..8a410d1b --- /dev/null +++ b/lib/features/consecutive_tasks/data/datasources/consecutive_task_remote_datasource.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; +import 'package:dio/dio.dart'; +import '../../../../core/consts/http_api_consts.dart'; +import '../../../../core/logging/i_logger_service.dart'; +import '../../domain/entities/consecutive_task_request.dart'; + +class ConsecutiveTaskRemoteDatasource { + ConsecutiveTaskRemoteDatasource(this.dio, this.logger); + final Dio dio; + final ILoggerService logger; + + Future create(ConsecutiveTaskRequest request) async { + final body = { + 'deviceId': request.deviceId.trim(), + 'routeIds': request.routeIds, + 'orgId': request.orgId, + 'siteId': request.siteId, + }; + final clock = Stopwatch()..start(); + final url = HttpApiConsts.createConsecutiveTask; + logger.logWithLevel( + '[区域下发] 请求: POST $url, 参数: ${jsonEncode(body)}', + shouldLog: true, + ); + try { + final response = await dio.post( + url, + data: body, + options: Options( + sendTimeout: const Duration(seconds: 20), + receiveTimeout: const Duration(seconds: 20), + ), + ); + final data = response.data; + logger.logWithLevel( + '[区域下发] 响应: HTTP ${response.statusCode}, ${jsonEncode(data)}, 耗时=${clock.elapsedMilliseconds}ms', + shouldLog: true, + ); + if (response.statusCode != 200 || + data is! Map || + '${data['code']}' != '200') { + throw Exception(data is Map ? data['msg'] ?? '区域任务下发失败' : '区域任务响应格式错误'); + } + final result = data['data']; + // data: 1 是成功结果,不能按 bool 强转。 + if (result == false || result is num && result <= 0) { + throw Exception('区域任务未下发成功,请查看接口响应'); + } + logger.logWithLevel( + '[区域下发] 成功: deviceId=${request.deviceId}, routeIds=${request.routeIds}', + shouldLog: true, + ); + } catch (error) { + logger.logWithLevel( + '[区域下发] 异常: $error, 耗时=${clock.elapsedMilliseconds}ms', + shouldLog: true, + ); + rethrow; + } finally { + clock.stop(); + } + } +} diff --git a/lib/features/consecutive_tasks/data/repositories/consecutive_task_repository_impl.dart b/lib/features/consecutive_tasks/data/repositories/consecutive_task_repository_impl.dart new file mode 100644 index 00000000..9d2d4a3a --- /dev/null +++ b/lib/features/consecutive_tasks/data/repositories/consecutive_task_repository_impl.dart @@ -0,0 +1,20 @@ +import 'package:fpdart/fpdart.dart'; +import '../../../../core/error/failure.dart'; +import '../../domain/entities/consecutive_task_request.dart'; +import '../../domain/repositories/consecutive_task_repository.dart'; +import '../datasources/consecutive_task_remote_datasource.dart'; + +class ConsecutiveTaskRepositoryImpl implements ConsecutiveTaskRepository { + ConsecutiveTaskRepositoryImpl(this.remote); + final ConsecutiveTaskRemoteDatasource remote; + + @override + Future> create(ConsecutiveTaskRequest request) async { + try { + await remote.create(request); + return const Right(unit); + } catch (error) { + return Left(Failure(error.toString())); + } + } +} diff --git a/lib/features/consecutive_tasks/domain/entities/consecutive_task_request.dart b/lib/features/consecutive_tasks/domain/entities/consecutive_task_request.dart new file mode 100644 index 00000000..9a88e697 --- /dev/null +++ b/lib/features/consecutive_tasks/domain/entities/consecutive_task_request.dart @@ -0,0 +1,25 @@ +/// routeIds 的顺序就是机器人执行顺序,不排序、不转换为设备数据库主键。 +class ConsecutiveTaskRequest { + ConsecutiveTaskRequest({ + required this.deviceId, + required List routeIds, + required this.orgId, + required this.siteId, + }) : routeIds = List.unmodifiable(routeIds); + + final String deviceId; + final List routeIds; + final int orgId; + final int siteId; + + String? get validationError { + if (deviceId.trim().isEmpty || int.tryParse(deviceId.trim()) != null) { + return '请选择有效的机器人设备编码'; + } + if (siteId <= 0 || orgId <= 0) return '场站或组织信息无效,请重新选择场站'; + if (routeIds.isEmpty) return '请至少选择一条区域内的路线'; + if (routeIds.any((id) => id <= 0)) return '路线信息无效,请重新选择'; + if (routeIds.toSet().length != routeIds.length) return '执行路线不能重复'; + return null; + } +} diff --git a/lib/features/consecutive_tasks/domain/repositories/consecutive_task_repository.dart b/lib/features/consecutive_tasks/domain/repositories/consecutive_task_repository.dart new file mode 100644 index 00000000..d617dfe5 --- /dev/null +++ b/lib/features/consecutive_tasks/domain/repositories/consecutive_task_repository.dart @@ -0,0 +1,7 @@ +import 'package:fpdart/fpdart.dart'; +import '../../../../core/error/failure.dart'; +import '../entities/consecutive_task_request.dart'; + +abstract class ConsecutiveTaskRepository { + Future> create(ConsecutiveTaskRequest request); +} diff --git a/lib/features/consecutive_tasks/domain/usecases/create_consecutive_task_usecase.dart b/lib/features/consecutive_tasks/domain/usecases/create_consecutive_task_usecase.dart new file mode 100644 index 00000000..4f12987c --- /dev/null +++ b/lib/features/consecutive_tasks/domain/usecases/create_consecutive_task_usecase.dart @@ -0,0 +1,15 @@ +import 'package:fpdart/fpdart.dart'; +import '../../../../core/error/failure.dart'; +import '../entities/consecutive_task_request.dart'; +import '../repositories/consecutive_task_repository.dart'; + +class CreateConsecutiveTaskUseCase { + CreateConsecutiveTaskUseCase(this.repository); + final ConsecutiveTaskRepository repository; + + Future> call(ConsecutiveTaskRequest request) async { + final error = request.validationError; + if (error != null) return Left(Failure(error)); + return repository.create(request); + } +} diff --git a/lib/features/consecutive_tasks/presentation/cubit/area_task_create_cubit.dart b/lib/features/consecutive_tasks/presentation/cubit/area_task_create_cubit.dart new file mode 100644 index 00000000..0b6a147c --- /dev/null +++ b/lib/features/consecutive_tasks/presentation/cubit/area_task_create_cubit.dart @@ -0,0 +1,246 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../regions/domain/entities/region_draft.dart'; +import '../../../regions/domain/usecases/region_usecases.dart'; +import '../../domain/entities/consecutive_task_request.dart'; +import '../../domain/usecases/create_consecutive_task_usecase.dart'; + +enum AreaTaskSubmitStatus { idle, submitting, success, failure } + +class AreaTaskCreateState { + AreaTaskCreateState({ + this.siteId, + this.loading = false, + this.loaded = false, + List regions = const [], + List routes = const [], + Set selectedRegions = const {}, + List orderedRouteIds = const [], + this.loadError, + this.submitStatus = AreaTaskSubmitStatus.idle, + this.submitError, + }) : regions = List.unmodifiable(regions), + routes = List.unmodifiable(routes), + selectedRegions = Set.unmodifiable(selectedRegions), + orderedRouteIds = List.unmodifiable(orderedRouteIds); + + final int? siteId; + final bool loading; + final bool loaded; + final List regions; + final List routes; + final Set selectedRegions; + final List orderedRouteIds; + final String? loadError; + final AreaTaskSubmitStatus submitStatus; + final String? submitError; + bool get isSubmitting => submitStatus == AreaTaskSubmitStatus.submitting; + + Set get availableRouteIds => { + for (final region in regions) + if (selectedRegions.contains(region.id)) ...{ + ...region.boundRouteIds, + if (region.defaultRouteId != null) region.defaultRouteId!, + }, + }; + + List get availableRoutes => + routes.where((route) => availableRouteIds.contains(route.id)).toList(); + + AreaTaskCreateState copyWith({ + bool? loading, + bool? loaded, + List? regions, + List? routes, + Set? selectedRegions, + List? orderedRouteIds, + String? loadError, + AreaTaskSubmitStatus? submitStatus, + String? submitError, + }) => AreaTaskCreateState( + siteId: siteId, + loading: loading ?? this.loading, + loaded: loaded ?? this.loaded, + regions: regions ?? this.regions, + routes: routes ?? this.routes, + selectedRegions: selectedRegions ?? this.selectedRegions, + orderedRouteIds: orderedRouteIds ?? this.orderedRouteIds, + loadError: loadError, + submitStatus: submitStatus ?? this.submitStatus, + submitError: submitError, + ); +} + +/// 分区、路线选择和顺序由本 Cubit 管理;页面不访问 HTTP 或数据源。 +class AreaTaskCreateCubit extends Cubit { + AreaTaskCreateCubit(this._loadRegions, this._loadRoutes, this._create) + : super(AreaTaskCreateState()); + final LoadRegionsUseCase _loadRegions; + final LoadRegionRoutesUseCase _loadRoutes; + final CreateConsecutiveTaskUseCase _create; + int _generation = 0; + + Future load(int siteId) async { + if (state.isSubmitting || state.loading) return; + final generation = ++_generation; + emit(AreaTaskCreateState(siteId: siteId, loading: true)); + try { + final regionsFuture = _loadRegions(siteId); + final routesFuture = _loadRoutes(siteId); + final regionResult = await regionsFuture; + final routeResult = await routesFuture; + if (isClosed || generation != _generation) return; + String? error; + final regions = regionResult.fold((failure) { + error = failure.message; + return []; + }, (value) => value); + final routes = routeResult.fold((failure) { + error ??= failure.message; + return []; + }, (value) => value); + emit( + state.copyWith( + loading: false, + loaded: error == null, + regions: regions + .where( + (region) => + region.siteId == siteId && + region.enabled && + region.id != null, + ) + .toList(), + routes: routes, + loadError: error, + ), + ); + } catch (error) { + if (isClosed || generation != _generation) return; + emit(state.copyWith(loading: false, loadError: error.toString())); + } + } + + void toggleRegion(int id) { + if (!state.loaded || state.isSubmitting) return; + final region = state.regions.where((region) => region.id == id).firstOrNull; + if (region == null) return; + final selected = {...state.selectedRegions}; + final adding = selected.add(id); + if (!adding) selected.remove(id); + final next = state.copyWith(selectedRegions: selected); + final available = next.availableRoutes.map((route) => route.id).toSet(); + final ordered = state.orderedRouteIds.where(available.contains).toList(); + // 新选分区默认勾选它的默认路线,其余绑定路线由用户自主选择。 + final defaultId = region.defaultRouteId; + if (adding && + defaultId != null && + available.contains(defaultId) && + !ordered.contains(defaultId)) { + ordered.add(defaultId); + } + emit( + next.copyWith( + orderedRouteIds: ordered, + submitStatus: AreaTaskSubmitStatus.idle, + ), + ); + } + + void toggleRoute(int id) { + if (state.isSubmitting || + !state.availableRoutes.any((route) => route.id == id)) + return; + final ordered = [...state.orderedRouteIds]; + if (!ordered.remove(id)) ordered.add(id); + emit( + state.copyWith( + orderedRouteIds: ordered, + submitStatus: AreaTaskSubmitStatus.idle, + ), + ); + } + + void reorder(int oldIndex, int newIndex) { + if (state.isSubmitting) return; + final ordered = [...state.orderedRouteIds]; + if (oldIndex < 0 || + oldIndex >= ordered.length || + newIndex < 0 || + newIndex > ordered.length) + return; + if (newIndex > oldIndex) --newIndex; + ordered.insert(newIndex, ordered.removeAt(oldIndex)); + emit( + state.copyWith( + orderedRouteIds: ordered, + submitStatus: AreaTaskSubmitStatus.idle, + ), + ); + } + + Future submit({ + required String deviceId, + required int orgId, + required int siteId, + }) async { + if (state.isSubmitting || + state.submitStatus == AreaTaskSubmitStatus.success) + return; + String? error; + final request = ConsecutiveTaskRequest( + deviceId: deviceId, + routeIds: state.orderedRouteIds, + orgId: orgId, + siteId: siteId, + ); + if (!state.loaded || state.siteId != siteId) { + error = '请重新加载当前场站的分区和路线'; + } else if (state.selectedRegions.isEmpty) { + error = '请至少选择一个任务区域'; + } else if (state.orderedRouteIds.any( + (id) => + !state.availableRouteIds.contains(id) || + !state.routes.any((route) => route.id == id), + )) { + error = '路线与所选区域不匹配,请重新选择'; + } + error ??= request.validationError; + if (error != null) { + emit( + state.copyWith( + submitStatus: AreaTaskSubmitStatus.failure, + submitError: error, + ), + ); + return; + } + emit(state.copyWith(submitStatus: AreaTaskSubmitStatus.submitting)); + try { + final result = await _create(request); + if (isClosed) return; + result.fold( + (failure) => emit( + state.copyWith( + submitStatus: AreaTaskSubmitStatus.failure, + submitError: failure.message, + ), + ), + (_) => emit(state.copyWith(submitStatus: AreaTaskSubmitStatus.success)), + ); + } catch (error) { + if (!isClosed) + emit( + state.copyWith( + submitStatus: AreaTaskSubmitStatus.failure, + submitError: error.toString(), + ), + ); + } + } + + void clearFailure() { + if (state.submitStatus == AreaTaskSubmitStatus.failure) { + emit(state.copyWith(submitStatus: AreaTaskSubmitStatus.idle)); + } + } +} diff --git a/lib/features/consecutive_tasks/presentation/widgets/area_dispatch_section.dart b/lib/features/consecutive_tasks/presentation/widgets/area_dispatch_section.dart new file mode 100644 index 00000000..949305ea --- /dev/null +++ b/lib/features/consecutive_tasks/presentation/widgets/area_dispatch_section.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../core/localization/app_localizations.dart'; +import '../../../v2/device_list/presentation/widgets/autonomous/work_ui.dart'; +import '../cubit/area_task_create_cubit.dart'; + +class AreaDispatchSection extends StatelessWidget { + const AreaDispatchSection({super.key, required this.onReload}); + final VoidCallback onReload; + + @override + Widget build(BuildContext context) { + final cubit = context.read(); + final state = context.watch().state; + String text(String key) => + AppLocalizations.of(context).translate('work_v2.plan_create.$key'); + Widget heading(String label, IconData icon) => Row( + children: [ + Icon(icon, color: kWtBlue, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w800, + color: kWtNavy, + ), + ), + ), + ], + ); + Widget hint(String label) => Text( + label, + style: const TextStyle(fontSize: 12, color: kWtGray, height: 1.5), + ); + + if (state.loading) { + return const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ); + } + if (!state.loaded) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + hint(state.loadError ?? text('area_load_hint')), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: onReload, + icon: const Icon(Icons.refresh_rounded), + label: Text(text('area_reload')), + ), + ), + ], + ); + } + final routes = state.availableRoutes; + final names = {for (final route in state.routes) route.id: route.name}; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: heading( + text('area_select_title'), + Icons.crop_free_rounded, + ), + ), + IconButton( + tooltip: text('area_reload'), + onPressed: state.isSubmitting ? null : onReload, + icon: const Icon(Icons.refresh_rounded, size: 19, color: kWtGray), + ), + ], + ), + hint(text('area_select_hint')), + const SizedBox(height: 8), + if (state.regions.isEmpty) + hint(text('area_empty')) + else + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + for (final region in state.regions) + FilterChip( + label: Text( + region.name.isEmpty ? '#${region.id}' : region.name, + ), + selected: state.selectedRegions.contains(region.id), + onSelected: state.isSubmitting + ? null + : (_) => cubit.toggleRegion(region.id!), + selectedColor: kWtSoftBlue, + checkmarkColor: kWtBlue, + ), + ], + ), + const SizedBox(height: 18), + heading(text('area_routes_title'), Icons.alt_route_rounded), + const SizedBox(height: 6), + hint(text('area_routes_hint')), + const SizedBox(height: 8), + if (state.selectedRegions.isEmpty) + hint(text('area_choose_first')) + else if (routes.isEmpty) + hint(text('area_routes_empty')) + else + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + for (final route in routes) + FilterChip( + label: Text(route.name.isEmpty ? '#${route.id}' : route.name), + selected: state.orderedRouteIds.contains(route.id), + onSelected: state.isSubmitting + ? null + : (_) => cubit.toggleRoute(route.id), + selectedColor: kWtSoftBlue, + checkmarkColor: kWtBlue, + ), + ], + ), + const SizedBox(height: 18), + heading(text('area_order_title'), Icons.format_list_numbered_rounded), + const SizedBox(height: 6), + hint(text('area_order_hint')), + const SizedBox(height: 8), + if (state.orderedRouteIds.isEmpty) + hint(text('area_order_empty')) + else + ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + itemCount: state.orderedRouteIds.length, + onReorder: cubit.reorder, + itemBuilder: (context, index) { + final id = state.orderedRouteIds[index]; + return Container( + key: ValueKey('area_task_route_$id'), + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: kWtSegBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: kWtBorder), + ), + child: ListTile( + dense: true, + contentPadding: const EdgeInsets.only(left: 10, right: 2), + leading: CircleAvatar( + radius: 14, + backgroundColor: kWtBlue, + child: Text( + '${index + 1}', + style: const TextStyle(fontSize: 12, color: Colors.white), + ), + ), + title: Text( + names[id] ?? '#$id', + style: const TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w700, + color: kWtNavy, + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: text('area_remove_route'), + onPressed: state.isSubmitting + ? null + : () => cubit.toggleRoute(id), + icon: const Icon( + Icons.close_rounded, + size: 17, + color: kWtGray, + ), + ), + ReorderableDragStartListener( + index: index, + enabled: !state.isSubmitting, + child: const Padding( + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 16, + ), + child: Icon( + Icons.drag_handle_rounded, + color: kWtGray, + ), + ), + ), + ], + ), + ), + ); + }, + ), + ], + ); + } +} diff --git a/lib/features/regions/presentation/region_error_text.dart b/lib/features/regions/presentation/region_error_text.dart new file mode 100644 index 00000000..4ecf1199 --- /dev/null +++ b/lib/features/regions/presentation/region_error_text.dart @@ -0,0 +1,35 @@ +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; + +/// Translate client-generated errors while preserving messages from the server. +String regionErrorText(String message, AppLocalizations l10n) { + final text = message.replaceFirst(RegExp(r'^(?:Exception|FormatException): '), ''); + final key = switch (text) { + '分区正在保存' => 'region_editor.region_saving', + '分区已保存,请点「新建」绘制下一个分区' => 'region_editor.region_readonly_saved', + '打点经纬度无效' => 'region_editor.invalid_coordinates', + '分区只需要 4 个打点,请撤回或删除后重打' => 'region_editor.too_many_points', + '该位置已打点,请移动到下一个位置' => 'region_editor.point_already_marked', + '分区或场站已切换,请重新打开编辑' => 'region_editor.editing_scope_changed', + '分区范围需要恰好 4 个打点' => 'region_editor.four_points_required', + '分区范围包含无效经纬度' => 'region_editor.invalid_boundary_points', + '分区的 4 个打点不能重复' => 'region_editor.duplicate_boundary_points', + '分区 ID 无效' => 'region_editor.invalid_region_id', + '分区 ID 或场站 ID 无效' => 'region_editor.invalid_region_site_id', + '请选择有效路线' => 'region_editor.invalid_route', + '请选择有效分区' => 'region_editor.invalid_region', + '分区未删除,请重试' => 'region_editor.delete_retry', + '分区未保存,请重试' => 'region_editor.save_retry', + '分区删除失败' => 'region_editor.region_delete_failed', + '分区保存失败' => 'region_editor.region_save_failed', + '分区列表响应格式错误' => 'region_editor.regions_invalid_response', + '分区列表 data 必须为数组' => 'region_editor.regions_data_not_list', + '分区删除响应格式错误' => 'region_editor.region_delete_invalid_response', + '分区保存响应格式错误' => 'region_editor.region_save_invalid_response', + '请先选择场站' => 'work_v2.task_pool.no_site', + '请填写分区名称' => 'region_editor.region_name_required', + '分区加载失败' => 'region_editor.regions_load_failed', + '路线加载失败' => 'region_editor.route_load_failed', + _ => null, + }; + return key == null ? message : l10n.translate(key); +} diff --git a/lib/features/site_task_plans/data/datasources/site_task_plan_remote_datasource.dart b/lib/features/site_task_plans/data/datasources/site_task_plan_remote_datasource.dart new file mode 100644 index 00000000..499d1a0a --- /dev/null +++ b/lib/features/site_task_plans/data/datasources/site_task_plan_remote_datasource.dart @@ -0,0 +1,91 @@ +import 'dart:convert'; +import 'package:dio/dio.dart'; +import '../../../../core/consts/http_api_consts.dart'; +import '../../../../core/logging/i_logger_service.dart'; +import '../../domain/entities/site_task_plan.dart'; +import '../models/site_task_plan_model.dart'; + +class SiteTaskPlanRemoteDatasource { + const SiteTaskPlanRemoteDatasource(this.dio, this.logger); + final Dio dio; + final ILoggerService logger; + + Future> load(int siteId) async { + final data = await _get(HttpApiConsts.siteDevicePlans, { + 'siteId': siteId, + 'pageNum': 1, + 'pageSize': 99999999, + }, operation: '查询'); + if (data is! List) { + throw const FormatException('任务计划查询响应的 data 应为数组'); + } + final plans = {}; + for (final item in data) { + if (item is! Map) throw const FormatException('任务计划行格式错误'); + final returnedSiteId = item['siteId']; + if (returnedSiteId != null && int.tryParse('$returnedSiteId') != siteId) { + continue; + } + if (item['delFlag'] == true || + item['delFlag'] == 1 || + item['delFlag'] == '1' || + item['delFlag'] == 'true') { + continue; + } + final plan = SiteTaskPlanModel.fromJson(item); + plans[plan.id] = plan; + } + return List.unmodifiable(plans.values); + } + + Future delete(int planId) async { + final data = await _get(HttpApiConsts.deleteDevicePlan, { + 'id': planId, + }, operation: '删除'); + // 删除成功返回受影响行数,如 data: 1。 + final affected = data is num ? data : num.tryParse('$data'); + if (affected == null || !affected.isFinite || affected <= 0) { + throw const FormatException('没有删除任务计划,请刷新列表后重试'); + } + } + + Future _get( + String url, + Map parameters, { + required String operation, + }) async { + logger.logWithLevel( + '[任务计划][$operation] GET $url 参数=${jsonEncode(parameters)}', + shouldLog: true, + ); + try { + final response = await dio.get( + url, + queryParameters: parameters, + options: Options(receiveTimeout: const Duration(seconds: 20)), + ); + final body = response.data; + final data = body is Map ? body['data'] : null; + logger.logWithLevel( + '[任务计划][$operation] HTTP ${response.statusCode}, ' + 'code=${body is Map ? body['code'] : null}, ' + 'msg=${body is Map ? body['msg'] : null}, ' + 'data=${data is List ? '${data.length} 条' : data}', + shouldLog: true, + ); + if (response.statusCode != 200 || + body is! Map || + '${body['code']}' != '200') { + throw Exception( + body is Map ? body['msg'] ?? '任务计划${operation}失败' : '任务计划响应格式错误', + ); + } + return data; + } on DioException catch (error) { + logger.logWithLevel('[任务计划][$operation] 请求失败: $error', shouldLog: true); + final body = error.response?.data; + if (body is Map && body['msg'] != null) throw Exception(body['msg']); + rethrow; + } + } +} diff --git a/lib/features/site_task_plans/data/models/site_task_plan_model.dart b/lib/features/site_task_plans/data/models/site_task_plan_model.dart new file mode 100644 index 00000000..bd91a4d7 --- /dev/null +++ b/lib/features/site_task_plans/data/models/site_task_plan_model.dart @@ -0,0 +1,45 @@ +import '../../domain/entities/site_task_plan.dart'; + +class SiteTaskPlanModel { + static String _text(Object? value) => value?.toString().trim() ?? ''; + + static String? _optionalText(Object? value) { + final text = _text(value); + return text.isEmpty ? null : text; + } + + static List _items(Object? value) { + final items = value is List ? value : _text(value).split(RegExp(r'[,,、]')); + return List.unmodifiable(items.map(_text).where((item) => item.isNotEmpty)); + } + + static SiteTaskPlan fromJson(Map json) { + final id = int.tryParse(_text(json['id'])); + if (id == null || id <= 0) throw const FormatException('任务计划缺少有效 ID'); + final rule = json['deviceTaskPlanRules']; + return SiteTaskPlan( + id: id, + name: _text(json['planName']), + deviceId: _text(json['deviceId']), + deviceAlias: _optionalText(json['deviceAlias']), + // 接口沿用 taskStaus 拼写,兼容规范拼写。 + status: _text(json['taskStaus'] ?? json['taskStatus']), + statusLabel: _text( + json['taskStausTranslate'] ?? json['taskStatusTranslate'], + ), + routeId: int.tryParse(_text(json['routeId'])), + startsAt: _optionalText(json['startTime']), + endsAt: _optionalText(json['finishTime']), + rule: rule is Map + ? SiteTaskPlanRule( + type: _text(rule['type']).toUpperCase(), + time: _optionalText(rule['dayTime']), + weekdays: _items(rule['daysTranslate']).isNotEmpty + ? _items(rule['daysTranslate']) + : _items(rule['days']), + monthDays: _items(rule['dayOfMonth']), + ) + : null, + ); + } +} diff --git a/lib/features/site_task_plans/data/repositories/site_task_plan_repository_impl.dart b/lib/features/site_task_plans/data/repositories/site_task_plan_repository_impl.dart new file mode 100644 index 00000000..bf03ddc7 --- /dev/null +++ b/lib/features/site_task_plans/data/repositories/site_task_plan_repository_impl.dart @@ -0,0 +1,35 @@ +import 'package:fpdart/fpdart.dart'; +import '../../../../core/error/failure.dart'; +import '../../domain/entities/site_task_plan.dart'; +import '../../domain/repositories/site_task_plan_repository.dart'; +import '../datasources/site_task_plan_remote_datasource.dart'; + +class SiteTaskPlanRepositoryImpl implements SiteTaskPlanRepository { + const SiteTaskPlanRepositoryImpl(this.remote); + final SiteTaskPlanRemoteDatasource remote; + + Failure _failure(Object error) => Failure( + error is FormatException + ? error.message + : error.toString().replaceFirst('Exception: ', ''), + ); + + @override + Future>> load(int siteId) async { + try { + return Right(await remote.load(siteId)); + } catch (error) { + return Left(_failure(error)); + } + } + + @override + Future> delete(int planId) async { + try { + await remote.delete(planId); + return const Right(unit); + } catch (error) { + return Left(_failure(error)); + } + } +} diff --git a/lib/features/site_task_plans/domain/entities/site_task_plan.dart b/lib/features/site_task_plans/domain/entities/site_task_plan.dart new file mode 100644 index 00000000..a507f980 --- /dev/null +++ b/lib/features/site_task_plans/domain/entities/site_task_plan.dart @@ -0,0 +1,39 @@ +class SiteTaskPlan { + const SiteTaskPlan({ + required this.id, + required this.name, + required this.deviceId, + required this.status, + required this.statusLabel, + this.deviceAlias, + this.routeId, + this.startsAt, + this.endsAt, + this.rule, + }); + + final int id; + final String name; + final String deviceId; + final String? deviceAlias; + final String status; + final String statusLabel; + final int? routeId; + final String? startsAt; + final String? endsAt; + final SiteTaskPlanRule? rule; +} + +class SiteTaskPlanRule { + const SiteTaskPlanRule({ + required this.type, + this.time, + this.weekdays = const [], + this.monthDays = const [], + }); + + final String type; + final String? time; + final List weekdays; + final List monthDays; +} diff --git a/lib/features/site_task_plans/domain/repositories/site_task_plan_repository.dart b/lib/features/site_task_plans/domain/repositories/site_task_plan_repository.dart new file mode 100644 index 00000000..7da1fb27 --- /dev/null +++ b/lib/features/site_task_plans/domain/repositories/site_task_plan_repository.dart @@ -0,0 +1,8 @@ +import 'package:fpdart/fpdart.dart'; +import '../../../../core/error/failure.dart'; +import '../entities/site_task_plan.dart'; + +abstract class SiteTaskPlanRepository { + Future>> load(int siteId); + Future> delete(int planId); +} diff --git a/lib/features/site_task_plans/domain/usecases/site_task_plan_usecases.dart b/lib/features/site_task_plans/domain/usecases/site_task_plan_usecases.dart new file mode 100644 index 00000000..6fe2c370 --- /dev/null +++ b/lib/features/site_task_plans/domain/usecases/site_task_plan_usecases.dart @@ -0,0 +1,24 @@ +import 'package:fpdart/fpdart.dart'; +import '../../../../core/error/failure.dart'; +import '../entities/site_task_plan.dart'; +import '../repositories/site_task_plan_repository.dart'; + +class LoadSiteTaskPlansUseCase { + const LoadSiteTaskPlansUseCase(this.repository); + final SiteTaskPlanRepository repository; + + Future>> call(int siteId) async { + if (siteId <= 0) return Left(Failure('请先选择场站')); + return repository.load(siteId); + } +} + +class DeleteSiteTaskPlanUseCase { + const DeleteSiteTaskPlanUseCase(this.repository); + final SiteTaskPlanRepository repository; + + Future> call(int planId) async { + if (planId <= 0) return Left(Failure('任务计划 ID 无效')); + return repository.delete(planId); + } +} diff --git a/lib/features/site_task_plans/presentation/cubit/site_task_plans_cubit.dart b/lib/features/site_task_plans/presentation/cubit/site_task_plans_cubit.dart new file mode 100644 index 00000000..d232071e --- /dev/null +++ b/lib/features/site_task_plans/presentation/cubit/site_task_plans_cubit.dart @@ -0,0 +1,94 @@ +import 'dart:async'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fpdart/fpdart.dart'; +import '../../../../core/error/failure.dart'; +import '../../domain/entities/site_task_plan.dart'; +import '../../domain/usecases/site_task_plan_usecases.dart'; + +class SiteTaskPlansState { + const SiteTaskPlansState({ + this.siteId, + this.plans = const [], + this.loading = false, + this.deletingId, + this.error, + }); + + final int? siteId; + final List plans; + final bool loading; + final int? deletingId; + final String? error; +} + +class SiteTaskPlansCubit extends Cubit { + SiteTaskPlansCubit(this._loadPlans, this._deletePlan) + : super(const SiteTaskPlansState()); + + final LoadSiteTaskPlansUseCase _loadPlans; + final DeleteSiteTaskPlanUseCase _deletePlan; + int _generation = 0; + + void clear() { + ++_generation; + if (!isClosed) emit(const SiteTaskPlansState()); + } + + Future load(int? siteId) async { + if (isClosed) return; + if (siteId == null || siteId <= 0) { + clear(); + return; + } + final sameSite = state.siteId == siteId; + if (sameSite && (state.loading || state.deletingId != null)) return; + final generation = ++_generation; + final previous = sameSite ? state.plans : const []; + emit(SiteTaskPlansState(siteId: siteId, plans: previous, loading: true)); + + final result = await _loadPlans(siteId); + if (isClosed || generation != _generation) return; + result.fold( + (failure) => emit( + SiteTaskPlansState( + siteId: siteId, + plans: previous, + error: failure.message, + ), + ), + (plans) => emit(SiteTaskPlansState(siteId: siteId, plans: plans)), + ); + } + + /// 场站或会话已切换时丢弃旧结果。成功后先移除该行,再刷新。 + Future?> delete({ + required int siteId, + required int planId, + }) async { + if (isClosed || + state.siteId != siteId || + state.loading || + state.deletingId != null || + !state.plans.any((plan) => plan.id == planId)) { + return Left(Failure('计划列表已变化,请刷新后重试')); + } + final generation = _generation; + final previous = state.plans; + emit( + SiteTaskPlansState(siteId: siteId, plans: previous, deletingId: planId), + ); + final result = await _deletePlan(planId); + if (isClosed || generation != _generation) return null; + result.fold( + (_) => emit(SiteTaskPlansState(siteId: siteId, plans: previous)), + (_) { + final remaining = List.unmodifiable( + previous.where((plan) => plan.id != planId), + ); + emit(SiteTaskPlansState(siteId: siteId, plans: remaining)); + unawaited(load(siteId)); + }, + ); + return result; + } +} diff --git a/lib/features/v2/device_list/presentation/widgets/device_quick_actions.dart b/lib/features/v2/device_list/presentation/widgets/device_quick_actions.dart new file mode 100644 index 00000000..89d78ac9 --- /dev/null +++ b/lib/features/v2/device_list/presentation/widgets/device_quick_actions.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:maibu_satabot_v2/components/capsule_toast.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DeviceQuickActions extends StatefulWidget { + const DeviceQuickActions({ + super.key, + required this.actions, + required this.preferences, + }); + + final Map actions; + final SharedPreferences preferences; + + @override + State createState() => _DeviceQuickActionsState(); +} + +class _DeviceQuickActionsState extends State { + static const _preferenceKey = 'device_detail_quick_action_order'; + static const _navy = Color(0xFF1A2B4A); + static const _blue = Color(0xFF1C96F6); + static const _columns = 3; + + late final List _order; + bool _editing = false; + bool _saving = false; + + @override + void initState() { + super.initState(); + final saved = widget.preferences.getStringList(_preferenceKey) ?? []; + _order = { + ...saved.where(widget.actions.containsKey), + ...widget.actions.keys, + }.toList(); + } + + void _move(String source, String target) { + if (!_editing || _saving) return; + final from = _order.indexOf(source); + final to = _order.indexOf(target); + setState(() => _order.insert(to, _order.removeAt(from))); + } + + Future _save() async { + if (_saving) return; + setState(() => _saving = true); + var saved = false; + try { + saved = await widget.preferences.setStringList( + _preferenceKey, + List.of(_order), + ); + } catch (error) { + debugPrint('常用功能排序保存失败: $error'); + } + if (!mounted) return; + setState(() { + _saving = false; + _editing = !saved; + }); + if (!saved) { + CapsuleToast.show( + AppLocalizations.of( + context, + ).translate('device_detail_v2.fn_save_failed'), + showCheck: false, + ); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 8, + children: [ + Row( + children: [ + Expanded( + child: Text( + l10n.translate('device_detail_v2.fn_title'), + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w800, + color: _navy, + ), + ), + ), + GestureDetector( + onTap: _saving + ? null + : _editing + ? _save + : () => setState(() => _editing = true), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Icon( + _editing ? Icons.check_rounded : Icons.edit_outlined, + size: 13, + color: _blue, + ), + Text( + l10n.translate( + _editing + ? 'device_detail_v2.fn_save_order' + : 'device_detail_v2.fn_edit_order', + ), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _blue, + ), + ), + ], + ), + ), + ], + ), + if (_editing) + Text( + l10n.translate('device_detail_v2.fn_drag_order'), + style: const TextStyle(fontSize: 12, color: Color(0xFF86909C)), + ), + for (var start = 0; start < _order.length; start += _columns) + Row( + spacing: 10, + children: [ + for (final id in _order.skip(start).take(_columns)) + Expanded( + child: _editing + ? _buildDraggable(id) + : GestureDetector( + onTap: widget.actions[id]!.onTap, + child: _buildTile(id), + ), + ), + for (var slot = _order.length; slot < start + _columns; slot++) + const Spacer(), + ], + ), + ], + ); + } + + Widget _buildDraggable(String id) => DragTarget( + onWillAcceptWithDetails: (details) => + !_saving && details.data != id && _order.contains(details.data), + onAcceptWithDetails: (details) => _move(details.data, id), + builder: (context, candidates, _) => LayoutBuilder( + builder: (context, constraints) { + final tile = _buildTile(id, highlighted: candidates.isNotEmpty); + return LongPressDraggable( + data: id, + maxSimultaneousDrags: _saving ? 0 : 1, + feedback: Material( + color: Colors.transparent, + child: SizedBox(width: constraints.maxWidth, child: tile), + ), + childWhenDragging: Opacity(opacity: 0.35, child: tile), + child: tile, + ); + }, + ), + ); + + Widget _buildTile(String id, {bool highlighted = false}) { + final l10n = AppLocalizations.of(context); + return Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4), + decoration: BoxDecoration( + color: highlighted ? const Color(0xFFE5F3FF) : const Color(0xFFF6FAFE), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: highlighted ? _blue : const Color(0xFFD9E8F8), + ), + ), + child: Column( + children: [ + Icon(widget.actions[id]!.icon, size: 22, color: _blue), + const SizedBox(height: 6), + Text( + l10n.translate('device_detail_v2.$id'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _navy, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/v2/device_list/presentation/widgets/device_task_control_tile.dart b/lib/features/v2/device_list/presentation/widgets/device_task_control_tile.dart new file mode 100644 index 00000000..f1626ec3 --- /dev/null +++ b/lib/features/v2/device_list/presentation/widgets/device_task_control_tile.dart @@ -0,0 +1,407 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; + +import '../../../../autonomous_work/domain/entities/work_session_models.dart'; +import '../../../../autonomous_work/domain/repositories/autonomous_work_gateway.dart'; + +/// 与自主作业共用任务池和指令链路,设备编号使用 deviceName(SN)。 +class DeviceTaskControlTile extends StatefulWidget { + const DeviceTaskControlTile({ + super.key, + required this.deviceId, + required this.gateway, + }); + + final String deviceId; + final AutonomousWorkGateway gateway; + + @override + State createState() => _DeviceTaskControlTileState(); +} + +class _DeviceTaskControlTileState extends State + with WidgetsBindingObserver { + static const _blue = Color(0xFF1C96F6); + static const _navy = Color(0xFF1A2B4A); + static const _yellow = Color(0xFFFFF3CD); + final _tasks = {}; + final _updates = {}; + StreamSubscription? _subscription; + int _revision = 0; + int _clearedAt = 0; + int _queryVersion = 0; + int _generation = 0; + bool _busy = false; + bool _dialogOpen = false; + String? _selectedId; + + AppLocalizations get _l10n => AppLocalizations.of(context); + String _text(String key) => _l10n.translate('device_task_control.$key'); + + bool _controllable(WorkTaskSnapshot task) => + task.deviceId == widget.deviceId && + task.rawStatus.toUpperCase() != 'CANCELING' && + (task.state == WorkRunState.running || task.state == WorkRunState.paused); + + List get _candidates => + _tasks.values.where(_controllable).toList(); + + bool get _paused { + final selected = _tasks[_selectedId]; + if (selected != null && _controllable(selected)) { + return selected.state == WorkRunState.paused; + } + return _candidates.isNotEmpty && + _candidates.every((task) => task.state == WorkRunState.paused); + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _listen(); + } + + void _listen() { + final gateway = widget.gateway; + final generation = _generation; + final stream = gateway is WorkTaskQueueSource + ? (gateway as WorkTaskQueueSource).watchTaskChanges( + deviceId: widget.deviceId, + ) + : gateway.watchTask(deviceId: widget.deviceId); + _subscription = stream.listen((task) { + if (!mounted || + generation != _generation || + (task != null && task.deviceId != widget.deviceId)) { + return; + } + setState(() { + _revision++; + if (task == null) { + _clearedAt = _revision; + _tasks.clear(); + _updates.clear(); + } else { + _updates[task.taskId] = (_revision, task); + _apply(task); + } + }); + }, onError: (Object _) {}); + unawaited(_refresh()); + } + + @override + void didUpdateWidget(DeviceTaskControlTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.deviceId == widget.deviceId && + identical(oldWidget.gateway, widget.gateway)) { + return; + } + unawaited(_subscription?.cancel()); + _generation++; + _queryVersion++; + _revision++; + _tasks.clear(); + _updates.clear(); + _busy = false; + _selectedId = null; + _listen(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed && !_busy) unawaited(_refresh()); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + unawaited(_subscription?.cancel()); + super.dispose(); + } + + void _apply(WorkTaskSnapshot task) { + if (task.state == WorkRunState.finished || + task.state == WorkRunState.idle) { + _tasks.remove(task.taskId); + } else { + _tasks[task.taskId] = task; + } + } + + bool _sameTarget(String deviceId, AutonomousWorkGateway gateway) => + mounted && + widget.deviceId == deviceId && + identical(widget.gateway, gateway); + + Future _refresh() async { + final deviceId = widget.deviceId; + final gateway = widget.gateway; + final startedAt = _revision; + final queryVersion = ++_queryVersion; + try { + final result = await gateway.loadTasks(deviceId: deviceId); + if (!_sameTarget(deviceId, gateway) || queryVersion != _queryVersion) { + return false; + } + if (!result.isOk) return false; + setState(() { + _tasks.clear(); + if (_clearedAt <= startedAt) { + for (final task in result.data ?? []) { + if (task.deviceId == deviceId) _apply(task); + } + } + // 请求期间的设备回执优先,避免重新显示已结束或已恢复的任务。 + for (final (revision, task) in _updates.values) { + if (revision > startedAt) _apply(task); + } + }); + return true; + } catch (_) { + return false; + } + } + + Future _dialog(WidgetBuilder builder) async { + if (!mounted) return null; + setState(() => _dialogOpen = true); + try { + return await showDialog(context: context, builder: builder); + } finally { + if (mounted) setState(() => _dialogOpen = false); + } + } + + Future _notice(String key) => _dialog( + (context) => AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), + title: Text(_text('title')), + content: Text(_text(key)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(_l10n.translate('common.ok')), + ), + ], + ), + ); + + String _taskName(WorkTaskSnapshot task) => + task.routeName?.trim().isNotEmpty == true + ? task.routeName! + : _l10n.translate('device_task_control.task_number', {'id': task.taskId}); + + Future _choose( + List tasks, + ) => _dialog( + (context) => SimpleDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), + title: Text(_text('choose_task')), + children: [ + for (final task in tasks) + SimpleDialogOption( + onPressed: () => Navigator.pop(context, task), + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + task.state == WorkRunState.paused + ? Icons.play_circle_outline + : Icons.pause_circle_outline, + color: _blue, + ), + title: Text(_taskName(task)), + subtitle: Text( + '${_l10n.translate('device_task_control.task_number', {'id': task.taskId})} · ${_text(task.state == WorkRunState.paused ? 'paused' : 'running')}', + ), + ), + ), + ], + ), + ); + + Future _onTap() async { + if (_busy) return; + final deviceId = widget.deviceId; + final gateway = widget.gateway; + final generation = _generation; + bool current() => + _sameTarget(deviceId, gateway) && generation == _generation; + setState(() => _busy = true); + try { + if (!await _refresh()) { + if (current()) await _notice('load_failed'); + return; + } + if (!current()) return; + final candidates = _candidates; + if (candidates.isEmpty) { + await _notice(_tasks.isEmpty ? 'no_task' : 'not_running'); + return; + } + final selected = _tasks[_selectedId]; + final task = + selected != null && + _controllable(selected) && + selected.state == WorkRunState.paused + ? selected + : candidates.length == 1 + ? candidates.single + : await _choose(candidates); + if (task == null || !current()) return; + final resume = task.state == WorkRunState.paused; + if (!(resume + ? gateway.capabilities.resume + : gateway.capabilities.pause)) { + await _notice('unavailable'); + return; + } + final confirmed = await _dialog( + (context) => AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + ), + title: Text(_text(resume ? 'resume' : 'pause')), + content: Text( + '${_taskName(task)}\n\n${_l10n.translate(resume ? 'autonomous_work.resume_description' : 'autonomous_work.pause_description', {'route': _taskName(task)})}', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(_l10n.translate('common.cancel')), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text( + _l10n.translate( + resume + ? 'autonomous_work.resume_confirm' + : 'autonomous_work.pause_confirm', + ), + ), + ), + ], + ), + ); + if (confirmed != true || !current()) return; + // 弹窗停留期间可能换任务或任务结束,发送前再次核对同一任务。 + if (!await _refresh()) { + if (current()) await _notice('load_failed'); + return; + } + if (!current()) return; + final latest = _tasks[task.taskId]; + if (latest == null || + !_controllable(latest) || + latest.state != task.state) { + await _notice(_tasks.isEmpty ? 'no_task' : 'task_changed'); + return; + } + final sentAt = _revision; + final result = resume + ? await gateway.resumeWork(deviceId: deviceId, task: latest) + : await gateway.pauseWork(deviceId: deviceId, task: latest); + if (!current()) return; + if (!result.isOk || result.data == null) { + await _notice(resume ? 'resume_failed' : 'pause_failed'); + return; + } + final event = _updates[task.taskId]; + final updated = event != null && event.$1 > sentAt + ? event.$2 + : result.data!; + if (updated.deviceId == deviceId && _clearedAt <= sentAt) { + setState(() { + _apply(updated); + _selectedId = _controllable(updated) ? updated.taskId : null; + }); + } + } catch (_) { + if (current()) await _notice('operation_failed'); + } finally { + if (current()) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final paused = _paused; + final color = paused ? const Color(0xFF9A6700) : _blue; + return Semantics( + button: true, + enabled: !_busy, + child: GestureDetector( + onTap: _busy ? null : _onTap, + behavior: HitTestBehavior.opaque, + child: Container( + key: const ValueKey('device-task-control-background'), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + decoration: BoxDecoration( + color: paused ? _yellow : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: paused ? const Color(0xFFF1CE70) : const Color(0xFFE5EDF7), + ), + ), + child: Row( + children: [ + if (_busy && !_dialogOpen) + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: color, + ), + ) + else + Icon( + paused + ? Icons.play_circle_outline + : Icons.pause_circle_outline, + size: 20, + color: color, + ), + const SizedBox(width: 7), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _text(paused ? 'resume' : 'pause'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w700, + color: _navy, + ), + ), + const SizedBox(height: 2), + Text( + _busy && !_dialogOpen + ? _l10n.translate('common.loading') + : _text(paused ? 'resume_hint' : 'pause_hint'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 9, + color: paused ? color : const Color(0xFF86909C), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/v2/device_list/presentation/widgets/obstacle_resize_layer.dart b/lib/features/v2/device_list/presentation/widgets/obstacle_resize_layer.dart new file mode 100644 index 00000000..668eaf41 --- /dev/null +++ b/lib/features/v2/device_list/presentation/widgets/obstacle_resize_layer.dart @@ -0,0 +1,235 @@ +import 'dart:math' as math; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +import 'autonomous/work_ui.dart'; + +/// 地图保持正北朝上,手柄沿地图的东西和南北方向调整矩形。 +class ObstacleResizeLayer extends StatelessWidget { + const ObstacleResizeLayer({ + super.key, + required this.corners, + required this.dimensions, + required this.onChanged, + }); + + final List corners; + final String dimensions; + final ValueChanged> onChanged; + + static const _handles = [ + Alignment.topLeft, + Alignment.topCenter, + Alignment.topRight, + Alignment.centerLeft, + Alignment.centerRight, + Alignment.bottomLeft, + Alignment.bottomCenter, + Alignment.bottomRight, + ]; + static const double _targetSize = 28; + + @override + Widget build(BuildContext context) { + final camera = MapCamera.of(context); + final bounds = LatLngBounds.fromPoints(corners); + final northWest = camera.latLngToScreenPoint( + LatLng(bounds.north, bounds.west), + ); + final southEast = camera.latLngToScreenPoint( + LatLng(bounds.south, bounds.east), + ); + final rect = Rect.fromPoints( + Offset(northWest.x, northWest.y), + Offset(southEast.x, southEast.y), + ); + // 小矩形在低缩放级别仍要能分别抓住边和角。仅将手柄向外错开。 + final xGap = math.max(0.0, _targetSize - rect.width / 2); + final yGap = math.max(0.0, _targetSize - rect.height / 2); + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fromRect( + rect: rect, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: kWtBlue, width: 2), + ), + ), + ), + ), + Positioned( + top: rect.top - yGap - 36, + left: rect.center.dx, + child: FractionalTranslation( + translation: const Offset(-0.5, 0), + child: IgnorePointer( + child: WorkChip( + text: dimensions, + background: Colors.white, + foreground: kWtNavy, + ), + ), + ), + ), + for (final alignment in _handles) + Positioned( + key: ValueKey(alignment), + left: + alignment.withinRect(rect).dx + + alignment.x * xGap - + _targetSize / 2, + top: + alignment.withinRect(rect).dy + + alignment.y * yGap - + _targetSize / 2, + width: _targetSize, + height: _targetSize, + child: _ResizeHandle( + alignment: alignment, + bounds: bounds, + camera: camera, + onChanged: onChanged, + ), + ), + ], + ); + } +} + +class _ResizeHandle extends StatefulWidget { + const _ResizeHandle({ + required this.alignment, + required this.bounds, + required this.camera, + required this.onChanged, + }); + + final Alignment alignment; + final LatLngBounds bounds; + final MapCamera camera; + final ValueChanged> onChanged; + + @override + State<_ResizeHandle> createState() => _ResizeHandleState(); +} + +class _ResizeHandleState extends State<_ResizeHandle> { + ({ + LatLngBounds bounds, + MapCamera camera, + math.Point anchor, + Offset start, + })? + _drag; + + void _start(DragStartDetails details) { + final bounds = widget.bounds; + final alignment = widget.alignment; + final anchor = LatLng( + alignment.y < 0 + ? bounds.north + : alignment.y > 0 + ? bounds.south + : bounds.center.latitude, + alignment.x < 0 + ? bounds.west + : alignment.x > 0 + ? bounds.east + : bounds.center.longitude, + ); + final camera = widget.camera; + _drag = ( + bounds: bounds, + camera: camera, + anchor: camera.project(anchor), + start: details.globalPosition, + ); + } + + void _update(DragUpdateDetails details) { + final drag = _drag; + if (drag == null) return; + final delta = details.globalPosition - drag.start; + final target = drag.camera.unproject( + drag.anchor + math.Point(delta.dx, delta.dy), + ); + final bounds = drag.bounds; + final alignment = widget.alignment; + // 保留相对边的位置,至少留下 0.1 米,避免拖过另一边时反转。 + const minLat = 0.1 / 111320.0; + final cosLat = math.cos(bounds.center.latitude * math.pi / 180); + final minLng = minLat / math.max(cosLat, 0.000001); + final west = alignment.x < 0 + ? math.max(-180.0, math.min(target.longitude, bounds.east - minLng)) + : bounds.west; + final east = alignment.x > 0 + ? math.min(180.0, math.max(target.longitude, bounds.west + minLng)) + : bounds.east; + final north = alignment.y < 0 + ? math.min(90.0, math.max(target.latitude, bounds.south + minLat)) + : bounds.north; + final south = alignment.y > 0 + ? math.max(-90.0, math.min(target.latitude, bounds.north - minLat)) + : bounds.south; + if (west >= east || south >= north) return; + widget.onChanged([ + LatLng(south, west), + LatLng(south, east), + LatLng(north, east), + LatLng(north, west), + ]); + } + + @override + Widget build(BuildContext context) { + final alignment = widget.alignment; + final horizontalEdge = alignment.x == 0; + final verticalEdge = alignment.y == 0; + return RawGestureDetector( + behavior: HitTestBehavior.opaque, + gestures: { + _ResizePanRecognizer: + GestureRecognizerFactoryWithHandlers<_ResizePanRecognizer>( + _ResizePanRecognizer.new, + (recognizer) { + recognizer + ..dragStartBehavior = DragStartBehavior.down + ..onStart = _start + ..onUpdate = _update + ..onEnd = (_) { + _drag = null; + } + ..onCancel = () { + _drag = null; + }; + }, + ), + }, + child: Center( + child: Container( + width: horizontalEdge ? 18 : 11, + height: verticalEdge ? 18 : 11, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: kWtBlue, width: 2), + ), + ), + ), + ); + } +} + +/// 地图的横竖拖动阈值低于普通 Pan。按住手柄时优先交给矩形编辑。 +class _ResizePanRecognizer extends PanGestureRecognizer { + @override + void addAllowedPointer(PointerDownEvent event) { + super.addAllowedPointer(event); + resolve(GestureDisposition.accepted); + } +} diff --git a/lib/features/v2/device_list/presentation/widgets/overview_device_telemetry.dart b/lib/features/v2/device_list/presentation/widgets/overview_device_telemetry.dart new file mode 100644 index 00000000..9ea8505a --- /dev/null +++ b/lib/features/v2/device_list/presentation/widgets/overview_device_telemetry.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:maibu_satabot_v2/components/control_mode_text.dart'; +import 'package:maibu_satabot_v2/core/di/injection.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; +import 'package:maibu_satabot_v2/core/network/mqtt/domain/repositories/mower_realtime_repository.dart'; + +/// 电量和控制模式共用同一条主机实时订阅。 +class OverviewDeviceTelemetry extends StatefulWidget { + const OverviewDeviceTelemetry({ + super.key, + this.deviceSn, + required this.batteryFallback, + }); + + final String? deviceSn; + final String batteryFallback; + + @override + State createState() => + _OverviewDeviceTelemetryState(); +} + +class _OverviewDeviceTelemetryState extends State { + late Stream<({double? battery, String controlMode})>? _stream = + _watchStatus(); + + Stream<({double? battery, String controlMode})>? _watchStatus() { + final sn = widget.deviceSn; + return sn == null + ? null + : sl().watchOverviewStatus(sn); + } + + @override + void didUpdateWidget(covariant OverviewDeviceTelemetry oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.deviceSn != widget.deviceSn) { + _stream = _watchStatus(); + } + } + + @override + Widget build(BuildContext context) { + return StreamBuilder<({double? battery, String controlMode})>( + key: ValueKey(widget.deviceSn), + stream: _stream, + builder: (context, snapshot) { + final battery = snapshot.data?.battery; + final mode = snapshot.data?.controlMode ?? ''; + return Row( + children: [ + Expanded( + flex: 2, + child: Text( + battery == null ? widget.batteryFallback : '$battery%', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF1A2B4A), + ), + ), + ), + Expanded( + flex: 4, + child: Text( + mode.isEmpty + ? '--' + : controlModeDisplayText( + AppLocalizations.of(context), + mode, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 11, color: Color(0xFF86909C)), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/features/v2/work/presentation/cubit/return_plan_library_cubit.dart b/lib/features/v2/work/presentation/cubit/return_plan_library_cubit.dart new file mode 100644 index 00000000..470afd05 --- /dev/null +++ b/lib/features/v2/work/presentation/cubit/return_plan_library_cubit.dart @@ -0,0 +1,81 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../autonomous_work/domain/entities/return_plan_models.dart'; +import '../../../../autonomous_work/domain/usecases/load_site_return_plan_usecase.dart'; + +class ReturnPlanLibraryState { + const ReturnPlanLibraryState({ + this.siteId, + this.loading = false, + this.loaded = false, + this.plan, + this.error, + }); + final int? siteId; + final bool loading; + final bool loaded; + final ReturnPlanDraft? plan; + final String? error; +} + +class ReturnPlanLibraryCubit extends Cubit { + ReturnPlanLibraryCubit(this._load) : super(const ReturnPlanLibraryState()); + final LoadSiteReturnPlanUseCase _load; + int _generation = 0; + (int, int)? _scope; + + void clear() { + ++_generation; + _scope = null; + if (!isClosed) emit(const ReturnPlanLibraryState()); + } + + Future load({required int? siteId, required int? orgId}) async { + if (isClosed) return; + if (siteId == null || orgId == null || siteId <= 0 || orgId <= 0) { + clear(); + emit(const ReturnPlanLibraryState(error: '请先在首页选择有效场站')); + return; + } + final scope = (siteId, orgId); + if (_scope == scope && state.loading) return; + final keep = _scope == scope && state.loaded; + final previous = keep ? state.plan : null; + _scope = scope; + final generation = ++_generation; + emit( + ReturnPlanLibraryState( + siteId: siteId, + loading: true, + loaded: keep, + plan: previous, + ), + ); + debugPrint('[返航路线] 查询当前场站方案 siteId=$siteId, orgId=$orgId'); + final result = await _load(siteId: siteId, orgId: orgId); + if (isClosed || generation != _generation) return; + if (!result.isOk) { + debugPrint('[返航路线] 查询失败:${result.error}'); + emit( + ReturnPlanLibraryState( + siteId: siteId, + loaded: keep, + plan: previous, + error: result.error, + ), + ); + return; + } + final plan = result.data; + debugPrint( + '[返航路线] 加载完成 id=${plan?.id}, 返航点=${plan?.restPoints.length ?? 0}, 障碍物=${plan?.obstacles.length ?? 0}', + ); + emit(ReturnPlanLibraryState(siteId: siteId, loaded: true, plan: plan)); + } + + @override + Future close() { + ++_generation; + return super.close(); + } +} diff --git a/lib/features/v2/work/presentation/widgets/all_work_sheet.dart b/lib/features/v2/work/presentation/widgets/all_work_sheet.dart new file mode 100644 index 00000000..03d7010e --- /dev/null +++ b/lib/features/v2/work/presentation/widgets/all_work_sheet.dart @@ -0,0 +1,441 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:maibu_satabot_v2/components/capsule_toast.dart'; +import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart'; +import 'package:maibu_satabot_v2/core/di/injection.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; +import 'package:maibu_satabot_v2/features/site_task_plans/domain/entities/site_task_plan.dart'; +import 'package:maibu_satabot_v2/features/site_task_plans/presentation/cubit/site_task_plans_cubit.dart'; +import 'package:maibu_satabot_v2/features/v2/device_list/presentation/widgets/autonomous/work_ui.dart'; +import 'package:maibu_satabot_v2/features/v2/device_list/presentation/widgets/device_confirm_dialog.dart'; +import 'package:maibu_satabot_v2/features/v2/site/presentation/cubit/site_cubit.dart'; + +import 'work_phone_ui.dart'; + +enum _WorkTab { plans, pool, consecutive } + +class AllWorkSheet extends StatefulWidget { + const AllWorkSheet({ + super.key, + required this.taskPoolBuilder, + required this.refreshTaskPool, + }); + + final Widget Function(BuildContext, ScrollController) taskPoolBuilder; + final Future Function() refreshTaskPool; + + @override + State createState() => _AllWorkSheetState(); +} + +class _AllWorkSheetState extends State { + final SiteTaskPlansCubit _plans = sl(); + final List> _subscriptions = []; + _WorkTab _tab = _WorkTab.plans; + late (String?, int?) _scope; + bool _confirmingDeletion = false; + + (String?, int?) get _currentScope => ( + sl().state.user?.userId, + sl().state.selectedSite?.id, + ); + + @override + void initState() { + super.initState(); + _scope = _currentScope; + _subscriptions.add(sl().stream.listen((_) => _scopeChanged())); + _subscriptions.add( + sl().stream.listen((_) => _scopeChanged()), + ); + unawaited(_refreshPlans()); + } + + void _scopeChanged() { + final scope = _currentScope; + if (!mounted || scope == _scope) return; + setState(() => _scope = scope); + _plans.clear(); + unawaited(_refreshPlans()); + } + + Future _refreshPlans() => + _plans.load(_scope.$1?.trim().isNotEmpty == true ? _scope.$2 : null); + + @override + void dispose() { + for (final subscription in _subscriptions) { + unawaited(subscription.cancel()); + } + unawaited(_plans.close()); + super.dispose(); + } + + String _tr(String key) => + AppLocalizations.of(context).translate('work_v2.all_work.$key'); + + String _planName(SiteTaskPlan plan) => + plan.name.isEmpty ? '${_tr('plans')} #${plan.id}' : plan.name; + + Future _confirmDelete(SiteTaskPlan plan, int siteId) async { + if (_confirmingDeletion || _plans.state.deletingId != null) return; + final scope = _scope; + _confirmingDeletion = true; + try { + final confirmed = await showDeviceConfirmDialog( + context, + icon: Icons.delete_outline_rounded, + title: _tr('delete_title'), + message: _tr('delete_message').replaceFirst('%s', _planName(plan)), + confirmText: _tr('delete'), + danger: true, + ); + if (!confirmed || !mounted || scope != _currentScope) return; + final result = await _plans.delete(siteId: siteId, planId: plan.id); + if (!mounted || scope != _currentScope || result == null) return; + result.fold( + (failure) => CapsuleToast.show(failure.message, showCheck: false), + (_) => CapsuleToast.show(_tr('delete_success')), + ); + } finally { + _confirmingDeletion = false; + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.75, + minChildSize: 0.4, + maxChildSize: 0.94, + builder: (context, controller) => + BlocBuilder( + bloc: _plans, + builder: (context, state) { + final refresh = switch (_tab) { + _WorkTab.plans => + state.loading || state.deletingId != null + ? null + : _refreshPlans, + _WorkTab.pool => widget.refreshTaskPool, + _WorkTab.consecutive => null, + }; + return SafeArea( + top: false, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 4), + child: Row( + children: [ + Expanded( + child: Text( + _tr('title'), + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w800, + color: kWtNavy, + ), + ), + ), + if (_tab != _WorkTab.consecutive) + IconButton( + tooltip: _tr('refresh'), + onPressed: refresh, + icon: const Icon( + Icons.refresh_rounded, + color: kWtBlue, + ), + ), + IconButton( + tooltip: l10n.translate('common.close'), + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close_rounded), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: WorkFilterRow( + items: [ + for (final tab in _WorkTab.values) _tr(tab.name), + ], + index: _tab.index, + onChanged: (index) => + setState(() => _tab = _WorkTab.values[index]), + ), + ), + const SizedBox(height: 8), + Expanded( + child: switch (_tab) { + _WorkTab.plans => _planList(state, controller), + _WorkTab.pool => widget.taskPoolBuilder( + context, + controller, + ), + _WorkTab.consecutive => ListView( + key: const ValueKey('all_work_consecutive'), + controller: controller, + padding: const EdgeInsets.all(16), + children: [ + _message( + Icons.playlist_play_rounded, + _tr('consecutive_pending'), + ), + ], + ), + }, + ), + ], + ), + ); + }, + ), + ); + } + + Widget _message(IconData icon, String text, {bool retry = false}) => Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Column( + children: [ + Icon(icon, size: 32, color: kWtMuted), + const SizedBox(height: 10), + Text( + text, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13, color: kWtGray), + ), + if (retry) + TextButton(onPressed: _refreshPlans, child: Text(_tr('retry'))), + ], + ), + ); + + Widget _planList(SiteTaskPlansState state, ScrollController controller) { + final siteId = state.siteId; + final plans = state.plans; + if (plans.isEmpty || siteId == null) { + return RefreshIndicator( + onRefresh: _refreshPlans, + child: ListView( + key: const ValueKey('all_work_plans_empty'), + controller: controller, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + children: [ + if (state.loading) + const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ) + else if (state.error != null) + _message(Icons.error_outline_rounded, state.error!, retry: true) + else if (_scope.$1?.trim().isNotEmpty != true) + _message( + Icons.person_outline_rounded, + AppLocalizations.of( + context, + ).translate('work_v2.task_pool.no_user'), + ) + else if (siteId == null) + _message( + Icons.location_off_outlined, + AppLocalizations.of( + context, + ).translate('work_v2.task_pool.no_site'), + ) + else + _message(Icons.event_note_outlined, _tr('empty')), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: _refreshPlans, + child: ListView.builder( + key: const ValueKey('all_work_plans'), + controller: controller, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + itemCount: plans.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + _tr('count').replaceFirst('%s', '${plans.length}'), + style: const TextStyle(fontSize: 12, color: kWtGray), + ), + if (state.loading) ...[ + const SizedBox(height: 8), + const LinearProgressIndicator(minHeight: 2), + ], + if (state.error != null) + _message( + Icons.error_outline_rounded, + state.error!, + retry: true, + ), + ], + ), + ); + } + final plan = plans[index - 1]; + return _planCard( + plan, + deleting: state.deletingId == plan.id, + onDelete: state.loading || state.deletingId != null + ? null + : () => _confirmDelete(plan, siteId), + ); + }, + ), + ); + } + + String _schedule(SiteTaskPlanRule? rule) { + if (rule == null) return '—'; + final l10n = AppLocalizations.of(context); + final type = switch (rule.type) { + 'DAY' => l10n.translate('work_v2.plan_create.type_day'), + 'WEEK' => l10n.translate('work_v2.plan_create.type_week'), + 'MONTH' => l10n.translate('work_v2.plan_create.type_month'), + _ => rule.type, + }; + final parts = [ + if (type.isNotEmpty) type, + if (rule.type == 'WEEK' && rule.weekdays.isNotEmpty) + rule.weekdays.join(', '), + if (rule.type == 'MONTH' && rule.monthDays.isNotEmpty) + _tr('month_days').replaceFirst('%s', rule.monthDays.join(', ')), + if (rule.time != null) rule.time!, + ]; + return parts.isEmpty ? '—' : parts.join(' · '); + } + + Widget _field(String label, String value) => Padding( + padding: const EdgeInsets.only(top: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 84, + child: Text( + label, + style: const TextStyle(fontSize: 12, color: kWtGray), + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle(fontSize: 12, color: kWtNavy), + ), + ), + ], + ), + ); + + Widget _planCard( + SiteTaskPlan plan, { + required bool deleting, + VoidCallback? onDelete, + }) { + final l10n = AppLocalizations.of(context); + final device = [ + if (plan.deviceAlias != null) plan.deviceAlias!, + if (plan.deviceId.isNotEmpty) plan.deviceId, + ].join(' · '); + final status = plan.statusLabel.isNotEmpty + ? plan.statusLabel + : plan.status.isNotEmpty + ? plan.status + : l10n.translate('work_v2.task_pool.unknown_status'); + return Container( + key: ValueKey('site_task_plan_${plan.id}'), + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.fromLTRB(14, 14, 14, 6), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: kWtBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + _planName(plan), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w800, + color: kWtNavy, + ), + ), + ), + const SizedBox(width: 8), + Flexible( + child: DecoratedBox( + decoration: BoxDecoration( + color: kWtSoftBlue, + borderRadius: BorderRadius.circular(999), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 9, + vertical: 4, + ), + child: Text( + status, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: kWtBlue, + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 4), + _field(_tr('device'), device.isEmpty ? '—' : device), + if (plan.routeId != null) _field(_tr('route'), '#${plan.routeId}'), + _field(_tr('schedule'), _schedule(plan.rule)), + _field(_tr('starts_at'), plan.startsAt ?? '—'), + _field(_tr('ends_at'), plan.endsAt ?? '—'), + const SizedBox(height: 4), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: onDelete, + style: TextButton.styleFrom( + foregroundColor: const Color(0xFFD54941), + ), + icon: deleting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.delete_outline_rounded, size: 18), + label: Text(_tr(deleting ? 'deleting' : 'delete')), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/v2/work/presentation/widgets/return_plan_library_section.dart b/lib/features/v2/work/presentation/widgets/return_plan_library_section.dart new file mode 100644 index 00000000..0972c801 --- /dev/null +++ b/lib/features/v2/work/presentation/widgets/return_plan_library_section.dart @@ -0,0 +1,362 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:latlong2/latlong.dart'; +import '../../../../../core/localization/app_localizations.dart'; +import '../../../../autonomous_work/domain/entities/return_plan_models.dart'; +import '../../../device_list/presentation/widgets/autonomous/work_ui.dart'; +import '../cubit/return_plan_library_cubit.dart'; +import 'return_plan_map_preview.dart'; + +class ReturnPlanLibrarySection extends StatelessWidget { + const ReturnPlanLibrarySection({ + super.key, + required this.cubit, + required this.onReload, + required this.onEdit, + }); + final ReturnPlanLibraryCubit cubit; + final Future Function() onReload; + final Future Function() onEdit; + + String _tr(BuildContext context, String key) => + AppLocalizations.of(context).translate('work_v2.route_lib.$key'); + String _position(LatLng point) => + '${point.latitude.toStringAsFixed(7)}, ${point.longitude.toStringAsFixed(7)}'; + + Widget _card(List children) => Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: kWtBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + ); + + Widget _heading(String text, IconData icon) => Row( + children: [ + Icon(icon, size: 19, color: kWtBlue), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w800, + color: kWtNavy, + ), + ), + ), + ], + ); + + Widget _text(String text) => Text( + text, + style: const TextStyle(fontSize: 12, color: kWtGray, height: 1.5), + ); + + Widget _metric(String value, String label) => Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w800, + color: kWtNavy, + ), + ), + const SizedBox(height: 4), + _text(label), + ], + ), + ); + + void _openMap(BuildContext context, ReturnPlanDraft plan) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (sheetContext) => SafeArea( + top: false, + child: SizedBox( + height: MediaQuery.sizeOf(sheetContext).height * 0.8, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: _heading( + _tr(context, 'return_preview'), + Icons.map_outlined, + ), + ), + IconButton( + tooltip: AppLocalizations.of( + context, + ).translate('common.close'), + onPressed: () => Navigator.of(sheetContext).pop(), + icon: const Icon(Icons.close_rounded), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: ReturnPlanMapPreview(plan: plan, interactive: true), + ), + const SizedBox(height: 8), + _text(_tr(context, 'return_map_note')), + ], + ), + ), + ), + ), + ); + } + + @override + Widget build( + BuildContext context, + ) => BlocBuilder( + bloc: cubit, + builder: (context, state) { + final plan = state.plan; + if (state.loading && !state.loaded) { + return const Center(child: CircularProgressIndicator(strokeWidth: 2)); + } + return RefreshIndicator( + onRefresh: onReload, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: 12), + children: [ + if (state.loading) ...[ + const LinearProgressIndicator(minHeight: 2), + const SizedBox(height: 10), + ], + if (state.error != null) ...[ + _card([ + _text(state.error!), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: onReload, + icon: const Icon(Icons.refresh_rounded), + label: Text(_tr(context, 'reload')), + ), + ), + ]), + const SizedBox(height: 12), + ], + if (state.loaded && plan == null) + _card([ + const Icon(Icons.home_work_outlined, size: 44, color: kWtMuted), + const SizedBox(height: 12), + Text( + _tr(context, 'return_empty'), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: kWtNavy, + ), + ), + const SizedBox(height: 8), + _text(_tr(context, 'return_empty_hint')), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: onEdit, + icon: const Icon(Icons.add_rounded), + label: Text(_tr(context, 'return_create')), + style: FilledButton.styleFrom( + backgroundColor: kWtBlue, + minimumSize: const Size.fromHeight(46), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ]), + if (state.loaded && plan != null) ...[ + _card([ + _heading( + _tr(context, 'return_plan_title'), + Icons.home_work_outlined, + ), + if (plan.id != null) ...[ + const SizedBox(height: 4), + _text('${_tr(context, 'return_plan_id')} #${plan.id}'), + ], + const SizedBox(height: 14), + Row( + children: [ + _metric( + '${plan.restPoints.length}', + _tr(context, 'return_points'), + ), + _metric( + '${plan.obstacles.length}', + _tr(context, 'return_obstacles'), + ), + _metric( + '${plan.restPoints.map((point) => point.bindDeviceId?.trim()).whereType().where((id) => id.isNotEmpty).toSet().length}', + _tr(context, 'return_bound_devices'), + ), + ], + ), + const SizedBox(height: 12), + _text(_tr(context, 'return_plan_hint')), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: onEdit, + icon: const Icon(Icons.edit_outlined, size: 18), + label: Text(_tr(context, 'return_edit')), + style: FilledButton.styleFrom( + backgroundColor: kWtBlue, + minimumSize: const Size.fromHeight(46), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ]), + const SizedBox(height: 12), + if (!plan.isEmpty) ...[ + _card([ + Row( + children: [ + Expanded( + child: _heading( + _tr(context, 'return_preview'), + Icons.map_outlined, + ), + ), + IconButton( + tooltip: _tr(context, 'return_expand'), + onPressed: () => _openMap(context, plan), + icon: const Icon( + Icons.open_in_full_rounded, + size: 18, + color: kWtBlue, + ), + ), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: 190, + child: ReturnPlanMapPreview(plan: plan), + ), + const SizedBox(height: 10), + _text(_tr(context, 'return_map_note')), + ]), + const SizedBox(height: 16), + ], + _heading(_tr(context, 'return_points'), Icons.pin_drop_outlined), + const SizedBox(height: 10), + if (plan.restPoints.isEmpty) + _text(_tr(context, 'return_no_points')), + for (final (index, point) in plan.restPoints.indexed) ...[ + _card([ + Row( + children: [ + CircleAvatar( + radius: 16, + backgroundColor: const Color(0xFFE8F8EC), + child: Text( + '${index + 1}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w800, + color: Color(0xFF22A06B), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + point.alias.trim().isNotEmpty + ? point.alias + : _tr( + context, + 'return_point_index', + ).replaceAll('%s', '${index + 1}'), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: kWtNavy, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + _text( + '${_tr(context, 'return_device')}:${point.bindDeviceId?.trim().isNotEmpty == true ? point.bindDeviceId : _tr(context, 'return_unbound')}', + ), + const SizedBox(height: 5), + SelectableText( + '${_tr(context, 'return_coordinates')}:${_position(point.position)}', + style: const TextStyle(fontSize: 11.5, color: kWtGray), + ), + ]), + const SizedBox(height: 10), + ], + const SizedBox(height: 6), + _heading(_tr(context, 'return_obstacles'), Icons.block_outlined), + const SizedBox(height: 10), + if (plan.obstacles.isEmpty) + _text(_tr(context, 'return_no_obstacles')), + for (final (index, obstacle) in plan.obstacles.indexed) ...[ + _card([ + _heading( + obstacle.alias.trim().isNotEmpty + ? obstacle.alias + : _tr( + context, + 'return_obstacle_index', + ).replaceAll('%s', '${index + 1}'), + Icons.block_outlined, + ), + const SizedBox(height: 10), + _text( + '${_tr(context, 'return_shape')}:${obstacle.shapeType == 'rect' + ? _tr(context, 'return_shape_rect') + : obstacle.shapeType == 'circle' + ? _tr(context, 'return_shape_circle') + : obstacle.shapeType}', + ), + const SizedBox(height: 5), + _text( + '${_tr(context, 'return_size')}:${obstacle.sizeX > 0 && obstacle.sizeY > 0 ? '${obstacle.sizeX} × ${obstacle.sizeY} m' : '—'}', + ), + const SizedBox(height: 5), + _text( + '${_tr(context, 'return_source')}:${obstacle.source == 'platform' ? _tr(context, 'return_platform') : obstacle.source}', + ), + const SizedBox(height: 5), + SelectableText( + '${_tr(context, 'return_coordinates')}:${_position(obstacle.center)}', + style: const TextStyle(fontSize: 11.5, color: kWtGray), + ), + ]), + const SizedBox(height: 10), + ], + ], + ], + ), + ); + }, + ); +} diff --git a/lib/features/v2/work/presentation/widgets/return_plan_map_preview.dart b/lib/features/v2/work/presentation/widgets/return_plan_map_preview.dart new file mode 100644 index 00000000..d7f86315 --- /dev/null +++ b/lib/features/v2/work/presentation/widgets/return_plan_map_preview.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import '../../../../autonomous_work/domain/entities/return_plan_models.dart'; +import '../../../../home/presentation/widgets/map/app_base_map_layer.dart'; +import '../../../../home/presentation/widgets/map/geoserver_suzhou_layer.dart'; + +/// 只展示接口返回的返航点和障碍;这些点不是行驶轨迹,不连接成路线。 +class ReturnPlanMapPreview extends StatefulWidget { + const ReturnPlanMapPreview({ + super.key, + required this.plan, + this.interactive = false, + }); + final ReturnPlanDraft plan; + final bool interactive; + + @override + State createState() => _ReturnPlanMapPreviewState(); +} + +class _ReturnPlanMapPreviewState extends State { + static const _green = Color(0xFF22A06B); + static const _red = Color(0xFFF0483E); + static const _distance = Distance(); + final MapController _map = MapController(); + bool _ready = false; + late List _stationLayers = StationLayerRepo.instance.buildLayers(); + + bool _valid(LatLng point) => + point.latitude.isFinite && + point.longitude.isFinite && + point.latitude >= -90 && + point.latitude <= 90 && + point.longitude >= -180 && + point.longitude <= 180; + + List _corners(ReturnObstaclePoint obstacle) { + if (!_valid(obstacle.center) || + !obstacle.sizeX.isFinite || + !obstacle.sizeY.isFinite || + obstacle.sizeX <= 0 || + obstacle.sizeY <= 0 || + obstacle.shapeType != 'rect') + return const []; + final north = _distance + .offset(obstacle.center, obstacle.sizeY / 2, 0) + .latitude; + final south = _distance + .offset(obstacle.center, obstacle.sizeY / 2, 180) + .latitude; + final east = _distance + .offset(obstacle.center, obstacle.sizeX / 2, 90) + .longitude; + final west = _distance + .offset(obstacle.center, obstacle.sizeX / 2, 270) + .longitude; + return [ + LatLng(north, west), + LatLng(north, east), + LatLng(south, east), + LatLng(south, west), + ]; + } + + List get _points => [ + for (final point in widget.plan.restPoints) + if (_valid(point.position)) point.position, + for (final obstacle in widget.plan.obstacles) + if (_valid(obstacle.center)) ...[obstacle.center, ..._corners(obstacle)], + ]; + + @override + void initState() { + super.initState(); + StationLayerRepo.instance.addListener(_layersChanged); + StationLayerRepo.instance.refresh(); + } + + void _layersChanged() { + if (mounted) + setState(() => _stationLayers = StationLayerRepo.instance.buildLayers()); + } + + @override + void didUpdateWidget(covariant ReturnPlanMapPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.plan, widget.plan)) { + WidgetsBinding.instance.addPostFrameCallback((_) => _fit()); + } + } + + void _fit() { + if (!mounted || !_ready) return; + final points = _points; + if (points.isEmpty) return; + if (points.length == 1 || points.toSet().length == 1) { + _map.move(points.first, 20); + } else { + _map.fitCamera( + CameraFit.bounds( + bounds: LatLngBounds.fromPoints(points), + padding: const EdgeInsets.all(36), + maxZoom: 21, + ), + ); + } + } + + @override + void dispose() { + StationLayerRepo.instance.removeListener(_layersChanged); + _map.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final points = _points; + if (points.isEmpty) return const SizedBox.shrink(); + return ClipRRect( + borderRadius: BorderRadius.circular(14), + child: FlutterMap( + mapController: _map, + options: MapOptions( + initialCenter: points.first, + initialZoom: 19, + minZoom: 3, + maxZoom: 22, + onMapReady: () { + _ready = true; + _fit(); + }, + interactionOptions: InteractionOptions( + flags: widget.interactive + ? InteractiveFlag.all + : InteractiveFlag.none, + ), + ), + children: [ + const AppBaseMapLayer(maxZoom: 22), + ..._stationLayers, + PolygonLayer( + polygons: [ + for (final obstacle in widget.plan.obstacles) + if (_corners(obstacle).isNotEmpty) + Polygon( + points: _corners(obstacle), + color: _red.withValues(alpha: 0.22), + borderColor: _red, + borderStrokeWidth: 2, + ), + ], + ), + MarkerLayer( + markers: [ + for (final (index, point) in widget.plan.restPoints.indexed) + if (_valid(point.position)) + Marker( + point: point.position, + width: 32, + height: 32, + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: _green, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + child: Text( + '${index + 1}', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + for (final obstacle in widget.plan.obstacles) + if (_valid(obstacle.center)) + Marker( + point: obstacle.center, + width: 26, + height: 26, + child: const Icon( + Icons.block_rounded, + size: 24, + color: _red, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/features/work_history/data/datasources/work_history_remote_datasource.dart b/lib/features/work_history/data/datasources/work_history_remote_datasource.dart new file mode 100644 index 00000000..f5f90c3d --- /dev/null +++ b/lib/features/work_history/data/datasources/work_history_remote_datasource.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; +import 'package:dio/dio.dart'; +import '../../../../core/consts/http_api_consts.dart'; +import '../../../../core/logging/i_logger_service.dart'; +import '../../domain/entities/work_history_record.dart'; +import '../models/work_history_model.dart'; + +class WorkHistoryRemoteDatasource { + WorkHistoryRemoteDatasource(this.dio, this.logger); + final Dio dio; + final ILoggerService logger; + + Future load({ + required int siteId, + required int orgId, + required int pageNum, + required int pageSize, + }) async { + final body = { + 'siteId': siteId, + 'orgId': orgId, + 'pageNum': pageNum, + 'pageSize': pageSize, + }; + final url = HttpApiConsts.siteDeviceTaskHistory; + final clock = Stopwatch()..start(); + logger.logWithLevel( + '[作业记录] 请求: POST $url, 参数: ${jsonEncode(body)}', + shouldLog: true, + ); + try { + final response = await dio.post(url, data: body); + final data = response.data; + if (response.statusCode != 200 || + data is! Map || + '${data['code']}' != '200') { + throw Exception(data is Map ? data['msg'] ?? '作业记录查询失败' : '作业记录响应格式错误'); + } + final rows = data['rows']; + final total = int.tryParse('${data['total']}'); + if (rows is! List || total == null || total < 0) { + throw const FormatException('作业记录响应缺少有效 rows 或 total'); + } + final records = {}; + for (final row in rows) { + if (row is! Map) throw const FormatException('作业记录行格式错误'); + if (row['delFlag'] == true || + row['delFlag'] == 1 || + row['delFlag'] == '1') + continue; + final record = WorkHistoryModel.fromJson( + Map.from(row), + ); + if (record.siteId != null && record.siteId != siteId) continue; + if (record.orgId != null && record.orgId != orgId) continue; + records[record.id] = record; + } + final sorted = records.values.toList() + ..sort((a, b) { + final dateOrder = (b.recordDate ?? DateTime(1970)).compareTo( + a.recordDate ?? DateTime(1970), + ); + return dateOrder != 0 ? dateOrder : b.id.compareTo(a.id); + }); + logger.logWithLevel( + '[作业记录] 响应: HTTP ${response.statusCode}, code=${data['code']}, msg=${data['msg']}, total=$total, rows=${rows.length}, 当前场站有效记录=${sorted.length}, 耗时=${clock.elapsedMilliseconds}ms', + shouldLog: true, + ); + return WorkHistoryPage(total: total, records: sorted); + } catch (error) { + logger.logWithLevel( + '[作业记录] 异常: $error, 参数: ${jsonEncode(body)}', + shouldLog: true, + ); + rethrow; + } finally { + clock.stop(); + } + } +} diff --git a/lib/features/work_history/data/models/work_history_model.dart b/lib/features/work_history/data/models/work_history_model.dart new file mode 100644 index 00000000..f7e31857 --- /dev/null +++ b/lib/features/work_history/data/models/work_history_model.dart @@ -0,0 +1,44 @@ +import '../../domain/entities/work_history_record.dart'; + +class WorkHistoryModel { + static int? _int(dynamic value) => int.tryParse('$value'); + static String? _text(dynamic value) { + final text = value?.toString().trim(); + return text == null || text.isEmpty ? null : text; + } + + static WorkHistoryRecord fromJson(Map json) { + final id = _int(json['id']); + if (id == null || id <= 0) throw const FormatException('作业历史缺少有效任务 ID'); + final accepted = json['taskAccepted']; + return WorkHistoryRecord( + id: id, + deviceId: _text(json['deviceId']) ?? '', + deviceAlias: _text(json['deviceAlias']), + workName: _text(json['workName']) ?? '', + // 后端原字段为 taskStaus(缺少 t),优先按真实接口解析。 + status: _text(json['taskStaus'] ?? json['taskStatus']) ?? '', + statusLabel: + _text(json['taskStausTranslate'] ?? json['taskStatusTranslate']) ?? + '', + siteId: _int(json['siteId']), + orgId: _int(json['orgId']), + planId: _int(json['planId']), + routeId: _int(json['routeId']), + createdAt: _text(json['createTime']), + updatedAt: _text(json['updateTime']), + startedAt: _text(json['startTime']), + finishedAt: _text(json['finishTime']), + taskType: _text(json['taskType']), + routeType: _text(json['routeType']), + remark: _text(json['remark']), + accepted: accepted is bool + ? accepted + : accepted == 1 || accepted == '1' + ? true + : accepted == 0 || accepted == '0' + ? false + : null, + ); + } +} diff --git a/lib/features/work_history/data/repositories/work_history_repository_impl.dart b/lib/features/work_history/data/repositories/work_history_repository_impl.dart new file mode 100644 index 00000000..67f03122 --- /dev/null +++ b/lib/features/work_history/data/repositories/work_history_repository_impl.dart @@ -0,0 +1,31 @@ +import 'package:fpdart/fpdart.dart'; +import '../../../../core/error/failure.dart'; +import '../../domain/entities/work_history_record.dart'; +import '../../domain/repositories/work_history_repository.dart'; +import '../datasources/work_history_remote_datasource.dart'; + +class WorkHistoryRepositoryImpl implements WorkHistoryRepository { + WorkHistoryRepositoryImpl(this.remote); + final WorkHistoryRemoteDatasource remote; + + @override + Future> load({ + required int siteId, + required int orgId, + required int pageNum, + required int pageSize, + }) async { + try { + return Right( + await remote.load( + siteId: siteId, + orgId: orgId, + pageNum: pageNum, + pageSize: pageSize, + ), + ); + } catch (error) { + return Left(Failure(error.toString())); + } + } +} diff --git a/lib/features/work_history/domain/entities/work_history_record.dart b/lib/features/work_history/domain/entities/work_history_record.dart new file mode 100644 index 00000000..08a44dc9 --- /dev/null +++ b/lib/features/work_history/domain/entities/work_history_record.dart @@ -0,0 +1,56 @@ +class WorkHistoryRecord { + const WorkHistoryRecord({ + required this.id, + required this.deviceId, + required this.workName, + required this.status, + required this.statusLabel, + this.deviceAlias, + this.siteId, + this.orgId, + this.planId, + this.routeId, + this.createdAt, + this.updatedAt, + this.startedAt, + this.finishedAt, + this.taskType, + this.routeType, + this.remark, + this.accepted, + }); + + final int id; + final String deviceId; + final String? deviceAlias; + final String workName; + final String status; + final String statusLabel; + final int? siteId; + final int? orgId; + final int? planId; + final int? routeId; + final String? createdAt; + final String? updatedAt; + final String? startedAt; + final String? finishedAt; + final String? taskType; + final String? routeType; + final String? remark; + final bool? accepted; + + String get normalizedStatus => status.trim().toUpperCase(); + bool get isCompleted => normalizedStatus == 'FINISH'; + bool get isCanceled => + const {'CANCELED', 'CANCELLED'}.contains(normalizedStatus); + DateTime? get recordDate => DateTime.tryParse(startedAt ?? createdAt ?? ''); +} + +class WorkHistoryPage { + WorkHistoryPage({ + required this.total, + required List records, + }) : records = List.unmodifiable(records); + final int total; + final List records; +} diff --git a/lib/features/work_history/domain/repositories/work_history_repository.dart b/lib/features/work_history/domain/repositories/work_history_repository.dart new file mode 100644 index 00000000..c9437c60 --- /dev/null +++ b/lib/features/work_history/domain/repositories/work_history_repository.dart @@ -0,0 +1,12 @@ +import 'package:fpdart/fpdart.dart'; +import '../../../../core/error/failure.dart'; +import '../entities/work_history_record.dart'; + +abstract class WorkHistoryRepository { + Future> load({ + required int siteId, + required int orgId, + required int pageNum, + required int pageSize, + }); +} diff --git a/lib/features/work_history/presentation/cubit/work_history_cubit.dart b/lib/features/work_history/presentation/cubit/work_history_cubit.dart new file mode 100644 index 00000000..4f803143 --- /dev/null +++ b/lib/features/work_history/presentation/cubit/work_history_cubit.dart @@ -0,0 +1,87 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../domain/entities/work_history_record.dart'; +import '../../domain/usecases/load_site_work_history_usecase.dart'; + +class WorkHistoryState { + const WorkHistoryState({ + this.loading = false, + this.loaded = false, + this.total = 0, + this.records = const [], + this.error, + }); + final bool loading; + final bool loaded; + final int total; + final List records; + final String? error; +} + +/// 仅查询历史;不依赖任务池,也不下发或变更机器人任务。 +class WorkHistoryCubit extends Cubit { + WorkHistoryCubit(this._load) : super(const WorkHistoryState()); + final LoadSiteWorkHistoryUseCase _load; + (int, int)? _scope; + int _generation = 0; + + Future load({required int? siteId, required int? orgId}) async { + if (isClosed) return; + if (siteId == null || orgId == null || siteId <= 0 || orgId <= 0) { + ++_generation; + _scope = null; + emit(const WorkHistoryState(error: '请先在首页选择有效场站')); + return; + } + final scope = (siteId, orgId); + if (_scope == scope && state.loading) return; + final keep = _scope == scope && state.loaded; + _scope = scope; + final generation = ++_generation; + emit( + WorkHistoryState( + loading: true, + loaded: keep, + records: keep ? state.records : const [], + total: keep ? state.total : 0, + ), + ); + try { + final result = await _load(siteId: siteId, orgId: orgId); + if (isClosed || generation != _generation) return; + result.fold( + (failure) => emit( + WorkHistoryState( + loaded: keep, + records: keep ? state.records : const [], + total: keep ? state.total : 0, + error: failure.message, + ), + ), + (page) => emit( + WorkHistoryState( + loaded: true, + total: page.total, + records: page.records, + ), + ), + ); + } catch (error) { + if (!isClosed && generation == _generation) { + emit( + WorkHistoryState( + loaded: keep, + records: keep ? state.records : const [], + total: keep ? state.total : 0, + error: error.toString(), + ), + ); + } + } + } + + @override + Future close() { + ++_generation; + return super.close(); + } +} diff --git a/test/_verify_logout_dialog_test.dart b/test/_verify_logout_dialog_test.dart new file mode 100644 index 00000000..43c497d1 --- /dev/null +++ b/test/_verify_logout_dialog_test.dart @@ -0,0 +1,132 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; +import 'package:maibu_satabot_v2/core/router/route_paths.dart'; +import 'package:maibu_satabot_v2/features/auth/presentation/bloc/auth_cubit.dart'; +import 'package:maibu_satabot_v2/features/v2/my/presentation/pages/my_page_v2.dart'; + +class _ProfileTestAssets extends CachingAssetBundle { + @override + Future load(String key) => rootBundle.load( + key == 'assets/images/logo_me_bg.png' ? 'assets/images/app_logo.png' : key, + ); +} + +class _FakeAuth implements AuthCubit { + final completion = Completer(); + int logoutCalls = 0; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); + + @override + Future logout() { + logoutCalls++; + return completion.future; + } +} + +class _LoadedStrings extends LocalizationsDelegate { + const _LoadedStrings(this.strings); + + final AppLocalizations strings; + + @override + bool isSupported(Locale locale) => locale == strings.locale; + + @override + Future load(Locale locale) => SynchronousFuture(strings); + + @override + bool shouldReload(_LoadedStrings old) => old.strings != strings; +} + +void main() { + final strings = {}; + setUpAll(() async { + for (final locale in AppLocalizations.supportedLocales) { + final loaded = AppLocalizations(locale); + expect(await loaded.load(), isTrue); + strings[locale] = loaded; + } + }); + + for (final locale in AppLocalizations.supportedLocales) { + testWidgets('logout dialog cancels and confirms in $locale', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(480, 800); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + final auth = _FakeAuth(); + GetIt.I.registerSingleton(auth); + addTearDown(() => GetIt.I.unregister()); + final router = GoRouter( + initialLocation: RoutePaths.my, + routes: [ + GoRoute( + path: RoutePaths.my, + builder: (_, _) => BlocProvider( + create: (_) => AppUserCubit(), + child: DefaultAssetBundle( + bundle: _ProfileTestAssets(), + child: const MyPageV2(), + ), + ), + ), + GoRoute( + path: RoutePaths.login, + builder: (_, _) => const Scaffold(body: Text('Login page')), + ), + ], + ); + addTearDown(router.dispose); + final l10n = strings[locale]!; + await tester.pumpWidget(MaterialApp.router( + routerConfig: router, + locale: locale, + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: [ + _LoadedStrings(l10n), + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + )); + await tester.pumpAndSettle(); + final label = l10n.translate('my_v2.logout'); + final dialog = find.byType(Dialog); + final button = find.text(label); + await tester.ensureVisible(button); + await tester.tap(button); + await tester.pumpAndSettle(); + expect(dialog, findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + expect(find.byIcon(Icons.logout_rounded), findsOneWidget); + expect(find.text(l10n.translate('my_v2.logout_confirm')), findsOneWidget); + + await tester.tap(find.text(l10n.translate('my_v2.cancel'))); + await tester.pumpAndSettle(); + expect(dialog, findsNothing); + expect(auth.logoutCalls, 0); + + await tester.tap(button); + await tester.pumpAndSettle(); + await tester.tap(find.descendant(of: dialog, matching: button).last); + await tester.pumpAndSettle(); + expect(auth.logoutCalls, 1); + expect(find.text('Login page'), findsNothing); + auth.completion.complete(); + await tester.pumpAndSettle(); + expect(find.text('Login page'), findsOneWidget); + }); + } +} diff --git a/test/admin_settings_access_test.dart b/test/admin_settings_access_test.dart new file mode 100644 index 00000000..2b4d4dca --- /dev/null +++ b/test/admin_settings_access_test.dart @@ -0,0 +1,234 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart'; +import 'package:maibu_satabot_v2/core/di/injection.dart'; +import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; +import 'package:maibu_satabot_v2/core/localization/locale_cubit.dart'; +import 'package:maibu_satabot_v2/core/theme/AppTheme.dart'; +import 'package:maibu_satabot_v2/core/update/update_cubit.dart'; +import 'package:maibu_satabot_v2/core/update/update_state.dart'; +import 'package:maibu_satabot_v2/core/update/version_check_service.dart'; +import 'package:maibu_satabot_v2/features/main_container/domain/tab_config.dart'; +import 'package:maibu_satabot_v2/features/main_container/presentation/cubit/tab_config_cubit.dart'; +import 'package:maibu_satabot_v2/features/main_container/presentation/main_wrapper.dart'; +import 'package:maibu_satabot_v2/features/v2/device_list/presentation/float_bar/cubit/float_bar_setting_cubit.dart'; +import 'package:maibu_satabot_v2/features/v2/device_list/presentation/float_bar/view/float_bar_widget.dart'; +import 'package:maibu_satabot_v2/features/v2/my/presentation/pages/my_page_v2.dart'; +import 'package:maibu_satabot_v2/features/v2/my/presentation/pages/system_settings_page.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _admin = UserEntity( + userId: 'admin-id', + username: 'Admin', + nickname: 'Admin', + token: 'admin-test', + orgId: 1, + roleKey: 'admin', +); + +const _member = UserEntity( + userId: 'member-id', + username: 'Member', + nickname: 'Member', + token: 'member-test', + orgId: 1, + roleKey: 'manager', +); + +class _VersionService extends Fake implements VersionCheckService { + final started = Completer(); + final result = Completer(); + int checks = 0; + + @override + Future checkUpdate({ + required String currentVersion, + required int currentVersionCode, + }) { + checks++; + started.complete(); + return result.future; + } +} + +class _Strings extends LocalizationsDelegate { + const _Strings(this.value); + + final AppLocalizations value; + + @override + bool isSupported(Locale locale) => true; + + @override + Future load(Locale locale) => SynchronousFuture(value); + + @override + bool shouldReload(_Strings old) => false; +} + +class _ProfileBundle extends CachingAssetBundle { + static final png = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=', + ); + + @override + Future load(String key) => key == 'assets/images/logo_me_bg.png' + ? SynchronousFuture(ByteData.sublistView(png)) + : rootBundle.load(key); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('non-admin tabs ignore the administrator configuration', () { + final config = TabConfig.defaultConfig() + .toggleItem('ai') + .toggleItem('device') + .toggleItem('work') + .toggleItem('me'); + + expect(config.navItemsForRole('admin').map((item) => item.id), ['ai']); + expect(config.settingItemsForRole('admin'), config.items); + for (final role in [null, 'personal', 'user', 'manager', 'siteManager']) { + final tabs = config.navItemsForRole(role); + expect(tabs.map((item) => item.id), ['device', 'work', 'me']); + expect(tabs.every((item) => item.isEnabled), isTrue); + expect(config.settingItemsForRole(role), tabs); + } + }); + + test( + 'a pending administrator version check is discarded after switching users', + () async { + final users = AppUserCubit()..setAuth(_member); + final service = _VersionService(); + final updates = UpdateCubit(service, users); + addTearDown(() async { + await updates.close(); + await users.close(); + }); + + await updates.checkUpdate(); + expect(service.checks, 0); + expect(updates.state, isA()); + + users.setAuth(_admin); + final check = updates.checkUpdate(); + await service.started.future; + expect(updates.state, isA()); + + users.setAuth(_member); + service.result.complete(null); + await check; + await Future.delayed(Duration.zero); + expect(updates.state, isA()); + }, + ); + + testWidgets( + 'switching from admin locks tabs and hides float bar and updates', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final users = AppUserCubit()..setAuth(_admin); + final tabs = TabConfigCubit(prefs) + ..toggleTab('device') + ..toggleTab('work'); + final locale = LocaleCubit(prefs); + final service = _VersionService(); + final updates = UpdateCubit(service, users); + sl.registerSingleton(FloatBarSettingService(prefs)); + addTearDown(() async { + await sl.unregister(); + await updates.close(); + await locale.close(); + await tabs.close(); + await users.close(); + }); + + final strings = (await tester.runAsync(() async { + final value = AppLocalizations(AppLocalizations.chinese); + expect(await value.load(), isTrue); + return value; + }))!; + final bundle = _ProfileBundle(); + final navigator = GlobalKey(); + await tester.pumpWidget( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: users), + BlocProvider.value(value: tabs), + BlocProvider.value(value: locale), + BlocProvider.value(value: updates), + ], + child: MaterialApp( + navigatorKey: navigator, + theme: AppTheme.lightTheme, + locale: AppLocalizations.chinese, + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: [ + _Strings(strings), + ...GlobalMaterialLocalizations.delegates, + ], + builder: (_, child) => + DefaultAssetBundle(bundle: bundle, child: child!), + home: const MainWrapper(), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(MyPageV2), findsOneWidget); + expect(find.text('设备'), findsNothing); + + navigator.currentState!.push( + MaterialPageRoute(builder: (_) => const SystemSettingsPage()), + ); + await tester.pumpAndSettle(); + expect(find.text('AI'), findsOneWidget); + expect( + tester.widget(find.byType(Switch).first).onChanged, + isNotNull, + ); + final updatesTitle = find.text('版本更新'); + await tester.scrollUntilVisible( + updatesTitle, + 200, + scrollable: find.byType(Scrollable).first, + ); + expect(updatesTitle, findsOneWidget); + + users.setAuth(_member); + await FloatBarSettingService.setEnabled(true); + await tester.pumpAndSettle(); + for (final label in ['设备', '作业', '我的']) { + expect(find.text(label), findsOneWidget); + } + for (final label in ['AI', '悬浮条', '版本更新']) { + expect(find.text(label), findsNothing); + } + final switches = tester.widgetList(find.byType(Switch)).toList(); + expect(switches, hasLength(3)); + expect( + switches.every((toggle) => toggle.value && toggle.onChanged == null), + isTrue, + ); + + navigator.currentState!.pop(); + await tester.pumpAndSettle(); + expect(find.byType(MyPageV2), findsOneWidget); + expect(find.byType(FloatBarWidget), findsNothing); + for (final label in ['设备', '作业', '我的']) { + expect(find.text(label), findsOneWidget); + } + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); +} diff --git a/test/device_quick_actions_test.dart b/test/device_quick_actions_test.dart new file mode 100644 index 00000000..9f44d2dc --- /dev/null +++ b/test/device_quick_actions_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; +import 'package:maibu_satabot_v2/features/v2/device_list/presentation/widgets/device_quick_actions.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _Localizations extends LocalizationsDelegate { + const _Localizations(this.strings); + + final AppLocalizations strings; + + @override + bool isSupported(Locale locale) => true; + + @override + Future load(Locale locale) => SynchronousFuture(strings); + + @override + bool shouldReload(_Localizations old) => false; +} + +void main() { + testWidgets('moves a tile across rows and keeps the order after saving', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({ + 'device_detail_quick_action_order': ['fn_sensor', 'fn_remote', 'fn_fault'], + }); + final preferences = await SharedPreferences.getInstance(); + final strings = (await tester.runAsync(() async { + final result = AppLocalizations(AppLocalizations.chinese); + expect(await result.load(), isTrue); + return result; + }))!; + + const video = 'fn_realtime_video'; + const remote = 'fn_remote'; + const findRobot = 'fn_find'; + const power = 'fn_app_power'; + final taps = []; + final actions = { + for (final id in [video, remote, findRobot, power]) + id: (icon: Icons.settings, onTap: () => taps.add(id)), + }; + + Widget app() => MaterialApp( + localizationsDelegates: [_Localizations(strings)], + home: Scaffold( + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + DeviceQuickActions(actions: actions, preferences: preferences), + const SizedBox(height: 1000), + ], + ), + ), + ), + ); + + Finder tile(String id) => + find.text(strings.translate('device_detail_v2.$id')); + Offset center(String id) => tester.getCenter(tile(id)); + final edit = find.text(strings.translate('device_detail_v2.fn_edit_order')); + final save = find.text(strings.translate('device_detail_v2.fn_save_order')); + + Future holdAndDrag(String id, Offset target) async { + final start = center(id); + final gesture = await tester.startGesture(start); + await tester.pump(kLongPressTimeout); + await gesture.moveBy(target - start); + await gesture.up(); + await tester.pumpAndSettle(); + } + + await tester.pumpWidget(app()); + await tester.tap(edit); + await tester.pumpAndSettle(); + + expect(tile('fn_fault'), findsNothing); + expect(tile('fn_sensor'), findsNothing); + final from = center(video); + final to = center(power); + final topLeft = center(remote); + expect(from.dx, greaterThan(topLeft.dx)); + expect(to.dx, topLeft.dx); + expect(to.dy, greaterThan(from.dy)); + + await tester.tap(tile(video)); + await tester.drag(tile(video), center(findRobot) - from); + await tester.pumpAndSettle(); + expect(taps, isEmpty); + expect(center(video), from); + + await holdAndDrag(video, to); + expect(center(findRobot), from); + expect(center(video), to); + + await holdAndDrag(video, topLeft); + expect(center(video), topLeft); + + await tester.tap(save); + await tester.pumpAndSettle(); + expect(edit, findsOneWidget); + await tester.pumpWidget(const SizedBox.shrink()); + await preferences.reload(); + + await tester.pumpWidget(app()); + await tester.pumpAndSettle(); + expect(center(video).dy, center(remote).dy); + expect(center(video).dx, lessThan(center(remote).dx)); + + await tester.tap(tile(video)); + expect(taps, [video]); + }); +} diff --git a/test/device_task_control_tile_test.dart b/test/device_task_control_tile_test.dart new file mode 100644 index 00000000..96e496b1 --- /dev/null +++ b/test/device_task_control_tile_test.dart @@ -0,0 +1,239 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; +import 'package:maibu_satabot_v2/features/autonomous_work/domain/entities/work_session_models.dart'; +import 'package:maibu_satabot_v2/features/autonomous_work/domain/repositories/autonomous_work_gateway.dart'; +import 'package:maibu_satabot_v2/features/v2/device_list/presentation/widgets/device_task_control_tile.dart'; + +import 'support/region_test_localizations.dart'; + +WorkTaskSnapshot task({ + String id = '42', + String device = 'SN-1', + WorkRunState state = WorkRunState.running, + String rawStatus = '', +}) => WorkTaskSnapshot( + taskId: id, + deviceId: device, + state: state, + rawStatus: rawStatus, + routeName: 'Route $id', +); + +class FakeGateway implements AutonomousWorkGateway, WorkTaskQueueSource { + List tasks = [task()]; + final changes = StreamController.broadcast(sync: true); + final calls = []; + bool loadFails = false; + bool commandFails = false; + int loads = 0; + Completer>? pending; + + @override + WorkSceneCapabilities get capabilities => const WorkSceneCapabilities(); + + @override + Stream watchTaskChanges({required String deviceId}) => + changes.stream; + + @override + Future>> loadTasks({ + required String deviceId, + }) async { + loads++; + return loadFails + ? const WorkResult.fail('offline') + : WorkResult.ok([...tasks]); + } + + Future> command( + String deviceId, + WorkTaskSnapshot selected, + bool resume, + ) async { + calls.add('${resume ? 'resume' : 'pause'}/$deviceId/${selected.taskId}'); + if (pending != null) return pending!.future; + if (commandFails) return const WorkResult.fail('command failed'); + final updated = selected.copyWith( + state: resume ? WorkRunState.running : WorkRunState.paused, + ); + tasks = [ + for (final t in tasks) + if (t.taskId == updated.taskId) updated else t, + ]; + changes.add(updated); + return WorkResult.ok(updated); + } + + @override + Future> pauseWork({ + required String deviceId, + required WorkTaskSnapshot task, + }) => command(deviceId, task, false); + + @override + Future> resumeWork({ + required String deviceId, + required WorkTaskSnapshot task, + }) => command(deviceId, task, true); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(RegionTestLocalizations.loadAssets); + + Future mount(WidgetTester tester, FakeGateway gateway) async { + addTearDown(gateway.changes.close); + await tester.pumpWidget( + MaterialApp( + locale: AppLocalizations.chinese, + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + RegionTestLocalizations(), + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: Scaffold( + body: SizedBox( + width: 160, + child: DeviceTaskControlTile(deviceId: 'SN-1', gateway: gateway), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Future open(WidgetTester tester) async { + await tester.tap(find.byType(DeviceTaskControlTile)); + await tester.pumpAndSettle(); + } + + Future confirm(WidgetTester tester, String label) async { + await tester.tap(find.text(label)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + } + + Color? background(WidgetTester tester) => + (tester + .widget( + find.byKey( + const ValueKey('device-task-control-background'), + ), + ) + .decoration + as BoxDecoration) + .color; + + testWidgets('pause turns yellow and next action resumes the same task', ( + tester, + ) async { + final gateway = FakeGateway(); + await mount(tester, gateway); + await open(tester); + expect(gateway.calls, isEmpty); + await confirm(tester, '确认暂停'); + expect(gateway.calls, ['pause/SN-1/42']); + expect(background(tester), const Color(0xFFFFF3CD)); + expect(find.text('恢复任务'), findsOneWidget); + await open(tester); + await confirm(tester, '确认继续作业'); + expect(gateway.calls, ['pause/SN-1/42', 'resume/SN-1/42']); + expect(background(tester), Colors.white); + expect(gateway.loads, 5); + }); + + testWidgets('other devices and ended tasks cannot be operated', ( + tester, + ) async { + final gateway = FakeGateway() + ..tasks = [task(device: 'SN-other'), task(state: WorkRunState.finished)]; + await mount(tester, gateway); + await open(tester); + expect(find.text('当前没有任务'), findsOneWidget); + expect(gateway.calls, isEmpty); + }); + + testWidgets('task ends while confirming: no pause is sent', (tester) async { + final gateway = FakeGateway(); + await mount(tester, gateway); + await open(tester); + gateway.tasks = []; + gateway.changes.add(task(state: WorkRunState.finished)); + await confirm(tester, '确认暂停'); + expect(find.text('当前没有任务'), findsOneWidget); + expect(gateway.calls, isEmpty); + expect(background(tester), Colors.white); + }); + + testWidgets('pool error is distinct from no task and cannot send commands', ( + tester, + ) async { + final gateway = FakeGateway()..loadFails = true; + await mount(tester, gateway); + await open(tester); + expect(find.text('任务池查询失败,请稍后重试。'), findsOneWidget); + expect(gateway.calls, isEmpty); + }); + + testWidgets('failed resume preserves yellow paused state', (tester) async { + final gateway = FakeGateway() + ..tasks = [task(state: WorkRunState.paused)] + ..commandFails = true; + await mount(tester, gateway); + expect(background(tester), const Color(0xFFFFF3CD)); + await open(tester); + await confirm(tester, '确认继续作业'); + expect(find.text('恢复失败,请重试。'), findsOneWidget); + expect(background(tester), const Color(0xFFFFF3CD)); + }); + + testWidgets('multiple tasks require an explicit selection', (tester) async { + final gateway = FakeGateway()..tasks = [task(), task(id: '43')]; + await mount(tester, gateway); + await open(tester); + expect(find.text('选择要暂停或恢复的任务'), findsOneWidget); + await tester.tap(find.text('Route 43')); + await tester.pumpAndSettle(); + await confirm(tester, '确认暂停'); + expect(gateway.calls, ['pause/SN-1/43']); + expect(background(tester), const Color(0xFFFFF3CD)); + await open(tester); + expect(find.text('选择要暂停或恢复的任务'), findsNothing); + await confirm(tester, '确认继续作业'); + expect(gateway.calls, ['pause/SN-1/43', 'resume/SN-1/43']); + }); + + testWidgets('pending command blocks duplicate taps and terminal event wins', ( + tester, + ) async { + final gateway = FakeGateway()..pending = Completer(); + await mount(tester, gateway); + await open(tester); + await confirm(tester, '确认暂停'); + await tester.tap(find.byType(DeviceTaskControlTile)); + await tester.pump(); + expect(gateway.calls, ['pause/SN-1/42']); + gateway.changes.add(task(state: WorkRunState.finished)); + gateway.pending!.complete(WorkResult.ok(task(state: WorkRunState.paused))); + await tester.pumpAndSettle(); + expect(background(tester), Colors.white); + expect(find.text('任务暂停'), findsOneWidget); + }); + + testWidgets('queued task cannot be paused', (tester) async { + final gateway = FakeGateway()..tasks = [task(state: WorkRunState.queued)]; + await mount(tester, gateway); + await open(tester); + expect(find.text('当前任务尚未执行或正在结束,暂不能暂停或恢复。'), findsOneWidget); + expect(gateway.calls, isEmpty); + }); +} diff --git a/test/locale_switch_test.dart b/test/locale_switch_test.dart new file mode 100644 index 00000000..c1845068 --- /dev/null +++ b/test/locale_switch_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; +import 'package:maibu_satabot_v2/core/localization/locale_cubit.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FailingPreferences implements SharedPreferences { + @override + Object? get(String key) => null; + + @override + Future setString(String key, String value) async { + throw StateError('Preferences write unavailable'); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('applies the selection immediately even if saving fails', () async { + final cubit = LocaleCubit(_FailingPreferences()); + addTearDown(cubit.close); + + final saving = cubit.setLocale(const Locale('zh')); + expect(cubit.state, AppLocalizations.chinese); + expect(cubit.hasChosenLanguage, isTrue); + + await saving; + expect(cubit.state, AppLocalizations.chinese); + }); + + test('restores legacy settings and persists the latest selection', () async { + SharedPreferences.setMockInitialValues({ + 'app_locale_chosen': true, + 'app_locale_lang': 'zh', + }); + final preferences = await SharedPreferences.getInstance(); + final cubit = LocaleCubit(preferences); + addTearDown(cubit.close); + expect(cubit.state, AppLocalizations.chinese); + + final first = cubit.setLocale(AppLocalizations.chinese); + final last = cubit.setLocale(AppLocalizations.english); + expect(cubit.state, AppLocalizations.english); + + await Future.wait([first, last]); + expect(preferences.getString('app_locale_lang'), 'en'); + expect(preferences.getString('app_locale_country'), 'US'); + }); + + test('loads the actual language assets without a country code', () async { + final chinese = AppLocalizations(const Locale('zh')); + final english = AppLocalizations(const Locale('en')); + + expect(await chinese.load(), isTrue); + expect(await english.load(), isTrue); + expect(chinese.translate('common.cancel'), '取消'); + expect(english.translate('common.cancel'), 'Cancel'); + }); +} diff --git a/test/mower_overview_status_test.dart b/test/mower_overview_status_test.dart new file mode 100644 index 00000000..f54ce6bf --- /dev/null +++ b/test/mower_overview_status_test.dart @@ -0,0 +1,143 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:maibu_satabot_v2/core/di/injection.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; +import 'package:maibu_satabot_v2/core/network/mqtt/data/datasources/mower_realtime_datasource.dart'; +import 'package:maibu_satabot_v2/core/network/mqtt/data/repositories/mower_realtime_repository_impl.dart'; +import 'package:maibu_satabot_v2/core/network/mqtt/domain/interfaces/mqtt_client.dart'; +import 'package:maibu_satabot_v2/core/network/mqtt/domain/models/mqtt_message.dart'; +import 'package:maibu_satabot_v2/core/network/mqtt/domain/repositories/mower_realtime_repository.dart'; +import 'package:maibu_satabot_v2/features/v2/device_list/presentation/widgets/overview_device_telemetry.dart'; + +class _MqttClient extends Fake implements MqttClient { + final messages = StreamController.broadcast(); + final subscribed = []; + final unsubscribed = []; + + @override + Stream get messageStream => messages.stream; + + @override + Future subscribe(String topic) async => subscribed.add(topic); + + @override + Future unsubscribe(String topic) async => unsubscribed.add(topic); + + void send(String sn, Map payload) { + messages.add(MqttMessage(topic: _topic(sn), payload: jsonEncode(payload))); + } +} + +String _topic(String sn) => 'device/$sn/host/realTime/message'; + +class _Localizations extends LocalizationsDelegate { + const _Localizations(this.strings); + + final AppLocalizations strings; + + @override + bool isSupported(Locale locale) => true; + + @override + Future load(Locale locale) => SynchronousFuture(strings); + + @override + bool shouldReload(_Localizations old) => false; +} + +void main() { + test('cancelling the overview stream releases its MQTT topic', () async { + final mqtt = _MqttClient(); + addTearDown(mqtt.messages.close); + final datasource = MowerRealtimeDataSourceImpl(mqtt); + final first = datasource.watchOverviewStatus('robot-1').listen((_) {}); + final second = datasource.watchOverviewStatus('robot-12').listen((_) {}); + + await first.cancel(); + expect(mqtt.unsubscribed, [_topic('robot-1')]); + await second.cancel(); + expect(mqtt.unsubscribed, [_topic('robot-1'), _topic('robot-12')]); + }); + + testWidgets( + 'overview uses one subscription per robot and clears missing modes', + (tester) async { + final mqtt = _MqttClient(); + sl.registerSingleton( + MowerRealtimeRepositoryImpl(MowerRealtimeDataSourceImpl(mqtt)), + ); + addTearDown(() async { + await mqtt.messages.close(); + await sl.unregister(); + }); + + final strings = (await tester.runAsync(() async { + final strings = AppLocalizations(AppLocalizations.chinese); + expect(await strings.load(), isTrue); + return strings; + }))!; + const first = ValueKey('first'); + const second = ValueKey('second'); + Widget overview(String? firstSn) => MaterialApp( + localizationsDelegates: [_Localizations(strings)], + home: Scaffold( + body: Column( + children: [ + OverviewDeviceTelemetry( + key: first, + deviceSn: firstSn, + batteryFallback: '--', + ), + const OverviewDeviceTelemetry( + key: second, + deviceSn: 'robot-12', + batteryFallback: '--', + ), + ], + ), + ), + ); + Finder cell(Key row, String text) => + find.descendant(of: find.byKey(row), matching: find.text(text)); + + await tester.pumpWidget(overview('robot-1')); + expect( + mqtt.subscribed, + unorderedEquals([_topic('robot-1'), _topic('robot-12')]), + ); + + mqtt.send('robot-1', { + 'data': {'battery_percentage': 73, 'control_mode': 3}, + }); + mqtt.send('robot-12', {'battery_percentage': 27, 'control_mode': '2'}); + await tester.pumpAndSettle(); + expect(cell(first, '73.0%'), findsOneWidget); + expect(cell(first, '远程控制'), findsOneWidget); + expect(cell(second, '27.0%'), findsOneWidget); + expect(cell(second, '自主任务'), findsOneWidget); + + mqtt.send('robot-1', {'control_mode': 1}); + await tester.pumpAndSettle(); + expect(cell(first, '本地控制'), findsOneWidget); + expect(cell(first, '73.0%'), findsOneWidget); + + mqtt.send('robot-1', {'battery_percentage': 72}); + await tester.pumpAndSettle(); + expect(cell(first, '--'), findsOneWidget); + expect(cell(first, '72.0%'), findsOneWidget); + expect(cell(second, '自主任务'), findsOneWidget); + + await tester.pumpWidget(overview(null)); + await tester.pumpAndSettle(); + expect(cell(first, '--'), findsNWidgets(2)); + expect(cell(second, '自主任务'), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + }, + ); +} diff --git a/test/obstacle_resize_layer_test.dart b/test/obstacle_resize_layer_test.dart new file mode 100644 index 00000000..89a21311 --- /dev/null +++ b/test/obstacle_resize_layer_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:maibu_satabot_v2/features/v2/device_list/presentation/widgets/obstacle_resize_layer.dart'; + +void main() { + testWidgets('map handles change only the dragged sides of an obstacle', ( + tester, + ) async { + const center = LatLng(31, 120); + final controller = MapController(); + addTearDown(controller.dispose); + var corners = const [ + LatLng(30.999991, 119.999990), + LatLng(30.999991, 120.000010), + LatLng(31.000009, 120.000010), + LatLng(31.000009, 119.999990), + ]; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, setState) => FlutterMap( + mapController: controller, + options: const MapOptions(initialCenter: center, initialZoom: 22), + children: [ + ObstacleResizeLayer( + corners: corners, + dimensions: '2 m × 2 m', + onChanged: (value) => setState(() => corners = value), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + LatLngBounds bounds() => LatLngBounds.fromPoints(corners); + Future drag(Alignment handle, Offset offset) async { + final handleCenter = tester.getCenter(find.byKey(ValueKey(handle))); + final gesture = await tester.startGesture(handleCenter); + for (var step = 0; step < 6; step++) { + await gesture.moveBy(offset / 6); + await tester.pump(); + } + await gesture.up(); + await tester.pumpAndSettle(); + } + + final initial = bounds(); + await drag(Alignment.centerRight, const Offset(55, 20)); + final wider = bounds(); + expect( + wider.east, + greaterThan(initial.east), + reason: 'camera ${controller.camera.center}', + ); + expect(wider.west, initial.west); + expect(wider.north, initial.north); + expect(wider.south, initial.south); + + await drag(Alignment.topCenter, const Offset(20, -50)); + final taller = bounds(); + expect(taller.north, greaterThan(wider.north)); + expect(taller.south, wider.south); + expect(taller.west, wider.west); + expect(taller.east, wider.east); + + await drag(Alignment.bottomLeft, const Offset(-45, 45)); + final larger = bounds(); + expect(larger.west, lessThan(taller.west)); + expect(larger.south, lessThan(taller.south)); + expect(larger.east, taller.east); + expect(larger.north, taller.north); + + await drag(Alignment.centerRight, const Offset(-280, 0)); + final smallest = bounds(); + expect(smallest.east, greaterThan(smallest.west)); + expect(smallest.west, larger.west); + expect(smallest.east, lessThan(larger.east)); + expect(controller.camera.center, center); + + await tester.dragFrom(const Offset(100, 100), const Offset(80, 0)); + await tester.pumpAndSettle(); + expect(controller.camera.center, isNot(center)); + expect(bounds().east, smallest.east); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/support/region_test_localizations.dart b/test/support/region_test_localizations.dart new file mode 100644 index 00000000..f52ce148 --- /dev/null +++ b/test/support/region_test_localizations.dart @@ -0,0 +1,29 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; + +class RegionTestLocalizations extends LocalizationsDelegate { + const RegionTestLocalizations(); + + static final _translations = {}; + + static Future loadAssets() async { + for (final locale in AppLocalizations.supportedLocales) { + final translations = AppLocalizations(locale); + if (!await translations.load()) { + throw StateError('Cannot load test translations for $locale'); + } + _translations[locale] = translations; + } + } + + @override + bool isSupported(Locale locale) => _translations.containsKey(locale); + + @override + Future load(Locale locale) => + SynchronousFuture(_translations[locale]!); + + @override + bool shouldReload(RegionTestLocalizations old) => false; +} diff --git a/test/work_record_page_scope_test.dart b/test/work_record_page_scope_test.dart new file mode 100644 index 00000000..f1493ccc --- /dev/null +++ b/test/work_record_page_scope_test.dart @@ -0,0 +1,147 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:maibu_satabot_v2/core/di/injection.dart'; +import 'package:maibu_satabot_v2/core/error/failure.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; +import 'package:maibu_satabot_v2/features/v2/device_list/data/models/robot_data_model.dart'; +import 'package:maibu_satabot_v2/features/v2/home/domain/entities/site_entity.dart'; +import 'package:maibu_satabot_v2/features/v2/site/presentation/cubit/site_cubit.dart'; +import 'package:maibu_satabot_v2/features/v2/work/presentation/pages/work_record_page_v2.dart'; +import 'package:maibu_satabot_v2/features/work_history/domain/entities/work_history_record.dart'; +import 'package:maibu_satabot_v2/features/work_history/domain/repositories/work_history_repository.dart'; +import 'package:maibu_satabot_v2/features/work_history/domain/usecases/load_site_work_history_usecase.dart'; +import 'package:maibu_satabot_v2/features/work_history/presentation/cubit/work_history_cubit.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _HistoryRepository implements WorkHistoryRepository { + final requests = <(int, int)>[]; + + @override + Future> load({ + required int siteId, + required int orgId, + required int pageNum, + required int pageSize, + }) async { + requests.add((siteId, orgId)); + return Right( + WorkHistoryPage( + total: 3, + records: const [ + WorkHistoryRecord( + id: 1, + deviceId: 'MC700-A', + workName: '按设备编码记录', + status: 'FINISH', + statusLabel: '已完成', + remark: '作业执行详情测试', + ), + WorkHistoryRecord( + id: 2, + deviceId: '17', + workName: '按设备 ID 记录', + status: 'CANCELED', + statusLabel: '已取消', + ), + WorkHistoryRecord( + id: 3, + deviceId: 'MC700-B', + workName: '其他设备记录', + status: 'FINISH', + statusLabel: '已完成', + ), + ], + ), + ); + } +} + +class _Localizations extends LocalizationsDelegate { + const _Localizations(this.strings); + + final AppLocalizations strings; + + @override + bool isSupported(Locale locale) => true; + + @override + Future load(Locale locale) => SynchronousFuture(strings); + + @override + bool shouldReload(_Localizations old) => false; +} + +void main() { + testWidgets( + 'site and device routes share the record page with distinct scopes', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final siteCubit = SiteCubit(await SharedPreferences.getInstance()); + siteCubit.updateSites(const [ + SiteEntity(id: 11, siteName: '当前场站', orgId: 21, status: 1), + SiteEntity(id: 12, siteName: '设备场站', orgId: 22, status: 1), + ]); + final repository = _HistoryRepository(); + sl.registerSingleton(siteCubit); + sl.registerFactory( + () => WorkHistoryCubit(LoadSiteWorkHistoryUseCase(repository)), + ); + addTearDown(() async { + await sl.unregister(); + await sl.unregister(); + await siteCubit.close(); + }); + final strings = (await tester.runAsync(() async { + final result = AppLocalizations(AppLocalizations.chinese); + expect(await result.load(), isTrue); + return result; + }))!; + final navigator = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigator, + localizationsDelegates: [_Localizations(strings)], + home: const Scaffold(), + ), + ); + + navigator.currentState!.push(WorkRecordPageV2.route()); + await tester.pumpAndSettle(); + expect(find.byType(WorkRecordPageV2), findsOneWidget); + expect(repository.requests, [(11, 21)]); + expect(find.text('其他设备记录'), findsOneWidget); + + navigator.currentState!.pop(); + await tester.pumpAndSettle(); + navigator.currentState!.push( + WorkRecordPageV2.route( + robot: const RobotDataModel( + name: 'MC700-A', + id: '17', + type: 'mower', + status: '', + battery: 0, + task: '', + siteId: 12, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(WorkRecordPageV2), findsOneWidget); + expect(repository.requests, [(11, 21), (12, 22)]); + expect(find.text('本设备已加载 2 条记录'), findsOneWidget); + expect(find.text('按设备编码记录'), findsOneWidget); + expect(find.text('按设备 ID 记录'), findsOneWidget); + expect(find.text('其他设备记录'), findsNothing); + + await tester.tap(find.text('按设备编码记录')); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsOneWidget); + expect(find.text('作业执行详情测试'), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); +} diff --git a/web/legal/app_logo.png b/web/legal/app_logo.png new file mode 100644 index 00000000..1c0b6137 Binary files /dev/null and b/web/legal/app_logo.png differ diff --git a/web/legal/legal.css b/web/legal/legal.css new file mode 100644 index 00000000..8889b5ef --- /dev/null +++ b/web/legal/legal.css @@ -0,0 +1,75 @@ +:root { + color-scheme: light; + font-family: system-ui, "PingFang SC", "Microsoft YaHei", sans-serif; + color: #16243a; + background: #f4f7fb; + line-height: 1.75; +} + +* { box-sizing: border-box; } +body { margin: 0; } + +.page { + max-width: 52rem; + margin: 0 auto; + padding: 1.5rem 1.125rem 3rem; + overflow-wrap: anywhere; +} + +header { + padding: 1.5rem 0 1.75rem; + border-bottom: 1px solid #cbd5e1; +} + +.product { + display: flex; + align-items: center; + gap: .5rem; + margin: 0 0 .5rem; + color: #334966; + font-weight: 650; +} + +.product img { flex: none; } +h1 { margin: 0; font-size: clamp(1.875rem, 5vw, 2.75rem); line-height: 1.25; } +h2 { margin: 1.85rem 0 .5rem; font-size: 1.25rem; line-height: 1.45; } +h3 { margin: 1.3rem 0 .4rem; font-size: 1.05rem; line-height: 1.5; } +p { margin: .65rem 0; } +.meta { color: #475569; font-size: .9375rem; } +ul, ol { margin: .65rem 0; padding-inline-start: 1.4rem; } +li + li { margin-top: .35rem; } +a { color: #164a8b; text-underline-offset: .2em; } +a:focus-visible { outline: 2px solid #164a8b; outline-offset: 3px; } + +.summary { + margin-top: 1.25rem; + padding: .75rem 1rem; + border-left: 3px solid #2871bb; + background: #eaf2fc; +} + +.directory { + margin: 1.25rem 0; + padding: .8rem 1rem; + border: 1px solid #cbd5e1; + border-radius: .5rem; + background: #fff; +} + +.directory p { margin: 0; font-weight: 650; } +.directory ol { margin-bottom: 0; } +section { scroll-margin-top: 1rem; } + +footer { + margin-top: 2rem; + padding-top: 1rem; + border-top: 1px solid #cbd5e1; + color: #475569; + font-size: .9375rem; +} + +@media print { + :root { color: #000; background: #fff; } + .page { max-width: none; padding: 0; } + h2, h3 { break-after: avoid; } +} diff --git a/web/legal/privacy.html b/web/legal/privacy.html new file mode 100644 index 00000000..b651eed4 --- /dev/null +++ b/web/legal/privacy.html @@ -0,0 +1,113 @@ + + + + + + + + 隐私政策|飒沓机器人 + + + + + +
+
+

飒沓机器人 · SATAbot

+

隐私政策

+

更新日期 · 生效日期

+ 查看用户协议 +
+
+

江苏迈步智能装备科技有限公司(以下简称“我们”)运营飒沓机器人移动应用(部分界面显示为“麦步割草机器人”或“Maibu Satabot V2”,以下简称“本 App”),并在本政策所述范围内处理个人信息。本政策适用于本 App 及配套服务。

+
+

登录需要账号和密码。蓝牙连接、设备视频、扫码、工单附件和局域网摄像头按使用场景处理数据。与自然人关联的精确位置、行踪轨迹和可识别人员的现场影像需要特别保护。您可以查看权限清单,也可以通过联系我们申请查阅、删除个人信息、撤回同意或注销账号。

+
+ + +
+

一、我们如何处理个人信息

+
    +
  • 登录和身份验证。您登录时,本 App 将用户名、密码及登录来源发送给业务服务器,供验证身份和建立会话使用。服务端返回用户编号、用户名、昵称、登录凭证、组织与场站编号、角色,以及账号中已配置的头像、电子邮箱和手机号,用于维持登录、展示资料和识别访问权限。本 App 在设备上保存会话及相关资料。您所属组织管理员开通账号或分配角色时,我们还会根据其配置取得账号资料和权限信息。
  • +
  • 设备绑定与控制。扫码、蓝牙发现或云端绑定时,本 App 读取您扫描的设备编码或扫描到的蓝牙广播名称及标识,用于识别和匹配设备。业务服务器处理绑定关系、设备编号、别名、控制指令及其反馈,供权限校验、运行管理和操作追溯使用。蓝牙直连时,本 App 与所连接设备在近距离交换操作指令及状态信息。
  • +
  • 地图、定位与作业。为展示设备位置、规划路径、执行任务和查询记录,我们处理设备或无人机上报的经纬度、时间、运行状态、作业区域、航线、轨迹、任务参数和任务进度,以及您绘制或提交的作业数据。当精确坐标或轨迹能够关联到自然人时,相关数据可能反映人员所在位置和活动范围。仅在定位、调度和安全追溯所需范围内处理,依法需要时取得单独同意。Android 的定位权限也可能用于满足系统的蓝牙扫描要求,设备定位主要来自设备或业务平台。
  • +
  • 设备视频与本地摄像头。查看设备或无人机远程视频时,本 App 使用设备视频流、会话标识及访问凭证建立播放连接。局域网摄像头功能在本地网络发现和连接摄像头,处理局域网地址、摄像头名称、视频流地址及画面,可在本机保留最近发现的设备。USB 摄像头预览在支持的终端读取接入的设备与实时画面,并在本机记住所选设备和分辨率。现场画面可能包含可识别人员或作业位置。请在有权查看的范围内使用,并依法履行现场告知和授权义务。
  • +
  • 告警和工单。创建、派发或处理工单时,我们处理工单标题、说明、所属组织和场站、关联设备或告警、提交人和处理人、处理状态及时间。您主动提交的照片、视频及视频收录的声音会作为附件上传,用于问题核实和协作,具备相应权限的人员可以查看。请仅提交解决问题所需的信息。
  • +
  • 智能分析。如您的版本提供智能分析功能,您主动提交的文字、图片、用户标识和会话编号会发送至分析服务,用于生成答复和查询会话历史。请避免上传能够识别无关人员的图片或包含个人敏感信息的资料。
  • +
  • 版本更新与本地日志。检查版本时,本 App 向更新服务发送版本号和构建号。网络服务在处理请求时会接收来源 IP 地址。为排查运行故障,本 App 在本机保存运行日志,内容可能包含日志时间、连接状态、设备标识及相关操作信息。
  • +
  • 支持与个人信息请求。您主动通过邮件或电话联系我们时,我们处理您提供的回复方式、问题描述、请求内容和必要的核验信息,用于答复、处理申请及记录结果。
  • +
+

我们在提供您请求的服务、履行合同或法定义务所必需的范围内处理信息。依法需要以同意为依据的处理,依相应规则征求同意。拒绝非必要权限或处理仅影响相关功能。登录验证、设备控制和云端任务需要相应账号权限和网络连接。

+
+ +
+

二、系统权限与使用场景

+
    +
  • 相机。在扫码绑定设备、拍摄工单附件或使用支持的外接摄像头时使用。关闭后可继续使用与相机无关的功能。
  • +
  • 照片、视频或系统文件选择器。在您从相册选取二维码、工单附件或分析图片时,读取您选择的文件。具体授权方式因系统版本而异。
  • +
  • 麦克风。在您使用带声音的录像功能时用于收录声音。观看设备视频不要求您开启本机麦克风。
  • +
  • 蓝牙、附近设备和 Android 使用期间定位。用于扫描、连接附近设备并交换控制或状态信息。部分 Android 版本需要定位授权或开启位置服务才能完成蓝牙扫描。关闭相应权限可能影响蓝牙绑定与直连控制。
  • +
  • 本地网络和 USB 设备访问。在您使用局域网摄像头时,用于发现、探测并连接同一网络内的摄像头。使用 USB 摄像头时,通过系统授权访问您接入的设备。
  • +
  • 互联网与网络状态。用于连接登录、业务、地图、视频及更新服务,识别断网和连接变化。Android 还会使用局域网组播能力发现摄像头。
  • +
+

您可在手机系统设置中进入本 App 的权限页面,按系统提供的选项开启或关闭权限。关闭权限后,本 App 将无法继续通过该权限访问相关信息。

+
+ +
+

三、第三方服务与信息提供

+

以下服务根据功能、版本和网络条件启用。SDK 是嵌入 App 的第三方软件组件。地图底图通过网络接口获取。

+
    +
  • 火山引擎 veRTC SDK(北京火山引擎科技有限公司)。在使用火山引擎通道的设备或无人机视频场景中,用于加入房间和接收设备画面。SDK 通过网络处理房间编号、会话用户标识、访问凭证、设备和系统信息、网络连接与质量信息及视频流。火山引擎 veRTC SDK 隐私政策
  • +
  • 声网实时音视频 SDK(上海声网科技有限公司)。在使用声网通道的设备视频场景中,用于加入频道和接收设备画面。SDK 通过网络处理频道编号、会话用户标识、访问凭证、设备和系统信息、网络连接与质量信息及视频流。声网 SDK 隐私政策
  • +
  • Esri ArcGIS World Imagery(Environmental Systems Research Institute, Inc.)。中文地图底图及部分回退场景使用此服务。服务接收请求所涉及的来源 IP、请求头、地图瓦片区域和缩放层级,以返回对应底图。Esri 隐私声明
  • +
  • Google 地图底图(Google LLC)。非中文界面加载地图时会检测该服务的可达性,可达时请求底图,不可达时使用 Esri 底图。请求涉及来源 IP、请求头、地图瓦片区域和缩放层级。Google 隐私权政策
  • +
+

在同一组织或场站内,具备相应权限的管理或作业人员可在业务范围内查看账号展示信息、设备、任务、视频、告警和工单。我们委托服务商处理个人信息时,会约定用途、范围和保护要求,并监督受托方处理。依法需要向其他独立处理者提供个人信息时,我们会告知接收方、联系方式、用途、处理方式和信息种类,并按法律要求取得单独同意。法律要求提供信息时,我们按法定程序及必要范围提供。公开披露个人信息须有相应法律依据。

+
+ +
+

四、保存、保护与跨境处理

+
    +
  • 账号与本地缓存。服务端账号资料按账号管理和服务所需时间保存。退出登录会清除本机登录资料。摄像头配置、偏好和本地日志按其用途保留,可通过系统清除应用数据或卸载删除本机副本,具体取决于系统功能。
  • +
  • 作业记录。任务、告警、工单及其附件按执行、跟进和必要追溯所需时间保存。期限届满或处理目的实现后,我们删除或匿名化处理,法律法规规定应当留存的,按法定期限保留。依法暂不能删除时,仅作存储和采取必要的安全保护措施。
  • +
  • 安全保护。我们通过账号验证、角色与设备权限校验控制业务数据访问,并依法采取与处理风险相适应的保护措施。发生个人信息安全事件时,我们会及时采取补救措施,并按法律要求通知您和有关主管部门。
  • +
  • 境外服务。Esri 和 Google 的地图请求可能由境外节点处理。涉及向境外提供个人信息时,我们依法履行适用的出境义务,并在法律要求时另行告知接收方、数据范围、处理方式及权利行使渠道,取得单独同意。
  • +
+
+ +
+

五、您的权利与账号注销

+
    +
  • 查阅、复制、更正、补充和转移。您可在「我的」查看账号资料。需要复制、更正、补充或依法转移个人信息时,请通过下方邮箱申请。涉及组织分配的账号资料时,我们会按需协助与管理员核实。
  • +
  • 删除、限制处理和撤回同意。您可以通过下方邮箱提出申请,也可以在系统设置关闭相应权限或退出对应功能。撤回同意会影响依赖该处理的功能,撤回前基于同意开展的处理仍按当时的依据执行。
  • +
  • 注销账号。请发送邮件至 sales@jsmaibu.com,注明「SATAbot 账号注销申请」,提供账号名、所属组织或场站及回复方式。我们将核验身份,必要时与组织管理员核实账号归属及作业交接。完成注销后,我们会停用账号并按第四节处理相关个人信息。退出登录仅结束本次登录及清除相应本机资料,账号注销由我们另行办理。
  • +
  • 咨询、投诉和申诉。您可以询问本政策、反映处理问题或对答复提出异议。请勿在邮件中发送密码、登录令牌或其他非必要材料。我们会在收到请求后尽快核查,并于十五个工作日内处理和反馈。法律另有规定的,从其规定。您也可依法向有关主管部门投诉或举报。
  • +
+
+ +
+

六、未成年人保护

+

本 App 面向具备相应授权的设备运维和管理人员。未成年人使用服务须遵守监护要求和设备操控资格要求。依法需要处理不满十四周岁未成年人的个人信息时,我们会取得其父母或其他监护人的同意,并适用专门的个人信息处理规则。监护人发现未经同意处理儿童个人信息的,可联系我们核查并依法处理。

+
+ +
+

七、政策更新与联系我们

+

我们会在本页公布更新后的政策。处理目的、方式、信息种类或您的重要权利发生变化时,我们会以显著方式告知,并在依法需要时重新取得同意。

+

个人信息处理者为江苏迈步智能装备科技有限公司。隐私咨询、个人信息请求和投诉可发至 sales@jsmaibu.com。联系电话为 0513-89110618,通讯地址为江苏省南通市崇川区中科科技园5栋。

+
+
+ +
+ + diff --git a/web/legal/terms.html b/web/legal/terms.html new file mode 100644 index 00000000..19e613eb --- /dev/null +++ b/web/legal/terms.html @@ -0,0 +1,79 @@ + + + + + + + + 用户协议|飒沓机器人 + + + + + +
+
+

飒沓机器人 · SATAbot

+

用户协议

+

更新日期 · 生效日期

+ 查看隐私政策 +
+
+

本协议由您与江苏迈步智能装备科技有限公司(以下简称“我们”)订立,适用于您使用飒沓机器人移动应用(部分界面显示为“麦步割草机器人”或“Maibu Satabot V2”,以下简称“本 App”)及其配套服务。

+
+

请在登录前阅读本协议,并由您主动选择是否接受。请特别留意设备作业安全、账号与权限、服务暂停或终止、责任与争议解决条款。您可以通过本页联系方式要求我们说明相关条款。有关个人信息处理,请阅读独立的《隐私政策》。

+
+ +
+

一、服务内容与使用范围

+

本 App 用于场站和智能设备运维,可提供设备绑定、运行状态查看、蓝牙或远程控制、地图与作业区域编辑、路径规划和任务管理、设备视频查看、无人机巡检、告警处理及工单协作。具体功能取决于设备型号、系统版本、网络条件及您获授的组织、场站和设备权限。

+

设备购买、租赁、维修、企业服务费用及服务期限如有单独约定,按您或您所属组织与相关服务方订立的合同执行。

+
+ +
+

二、账号与操作权限

+

请使用为您开通或分配的账号登录。账号和操作权限可能由您所属组织的管理员配置。请妥善保管账号、密码及设备控制权限,并在人员交接、离职或权限变动时及时通知管理员调整权限。

+

发现账号异常、设备被异常控制或凭证泄露时,请立即停止相关操作并联系管理员和我们。账号、组织或场站权限发生变化后,您可以联系管理员核实。您也可以通过本协议所列邮箱申请注销账号。

+
+ +
+

三、设备作业安全

+
    +
  • 仅操作您有权使用且已接受相应培训的设备。作业前请按设备说明书和现场安全规程检查设备、刀具、制动、急停装置及作业区域,确认人员、动物、障碍物和危险区域已妥善隔离。
  • +
  • 下发自主任务前,请核对设备、区域边界、禁入区、路径和作业参数。地图、定位、实时状态和视频可能因遮挡、网络或定位误差产生延迟或偏差,请结合现场情况判断,并按现场规程安排监护。
  • +
  • 遇到失联、误动作、视频或定位异常、人员进入危险区域等情况,请停止继续下发指令,并按设备说明书及现场规程采取现场急停、断能或隔离措施。不要仅凭 App 界面判断设备已经停止。
  • +
  • 使用无人机功能时,请遵守飞行区域、空域管理和操控资质要求,核实飞行环境、返航条件及现场监护安排。
  • +
+
+ +
+

四、使用规范与内容权利

+

请在授权范围内使用设备、视频和业务数据。请勿绕过权限、冒用账号、干扰通信或设备、篡改遥测数据,或者以未经授权的方式访问、抓取、转发他人的位置、影像和工单资料。

+

您提交工单说明、照片、音视频或作业资料时,应确保有权提供相关内容,并避免提交与业务无关的个人信息或涉密材料。相关内容的权利归其权利人所有。您授权我们在提供您所请求服务的必要范围内保存、处理和展示相关内容。

+

本 App 的软件、界面、标识及说明资料的权利归我们或相应权利人所有。您可在获授权限和服务范围内使用。地图及第三方服务内容的权利归相应权利人所有。

+
+ +
+

五、更新、暂停与终止

+

我们会提供版本更新、安全修复或功能维护。涉及重要功能变更或计划内维护时,我们会通过 App 或其他合理渠道通知您。请安排在设备安全停止后进行升级、切换或退出。

+

发生账号失陷、越权控制、危及人员或设备安全、严重违反本协议等情形时,我们可以按风险程度限制相关功能、暂停账号或终止相关服务。我们会在可行时告知原因和申诉渠道。为防止正在发生的危害需要立即处置时,我们会在处置后及时告知。您可通过本页联系方式提交申诉。

+

您可随时停止使用并退出登录。账号注销、个人信息删除及相关记录的保留方式见《隐私政策》。服务暂停或终止不影响此前依法产生的权利和义务。

+
+ +
+

六、责任与争议解决

+

因网络中断、定位偏差、硬件异常或第三方服务故障影响功能时,各方按法律规定、适用合同和各自责任承担后果。我们依法对自身提供的服务承担相应责任,并对发现的故障采取合理的处置和恢复措施。

+

法律规定不得免除或限制的责任,由相应责任方依法承担。本协议不影响您依法享有的消费者权益或其他法定权利。

+

本协议的订立、履行和解释适用中华人民共和国大陆地区法律。发生争议时,可先协商解决,也可依法向有管辖权的人民法院提起诉讼。

+
+ +
+

七、协议变更与联系我们

+

我们会在本页公布更新后的协议。涉及您重要权利义务的变更,我们会以显著方式提示您,并在依法需要时重新取得您的确认。

+

运营主体为江苏迈步智能装备科技有限公司。您可通过 sales@jsmaibu.com 或 0513-89110618 联系我们。通讯地址为江苏省南通市崇川区中科科技园5栋。

+
+
+ +
+ +