第二次提交

This commit is contained in:
cc
2026-01-18 20:21:14 +08:00
parent 4346601481
commit 289e706337
129 changed files with 4615 additions and 135 deletions

View File

@@ -0,0 +1,92 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remote_control_state.dart';
import '../../domain/entities/machine_control_status_entity.dart';
import '../../domain/repositories/remote_control_repository.dart';
class RemoteControlCubit extends Cubit<RemoteControlState> {
final RemoteControlRepository _repository;
Timer? _timer;
RemoteControlCubit(this._repository)
: super(RemoteControlState(controlEntity: MachineControlStatusEntity())) {
_initPacketListener();
}
// 1. 初始化回包监听 (如 0x12 权限)
void _initPacketListener() {
_repository.responseStream.listen((packet) {
if (packet.command == 0x12) {
// 根据负载判断是否有权限,更新状态
emit(state.copyWith(hasPermission: true));
}
});
}
// 2. 开启 100ms 控制循环 (在进入遥控页面或点击“开始”时调用)
void startControlLoop() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
// 核心调用:直接把 state 里的实体丢给 repository
_repository.sendControlMachineCmd(state.controlEntity);
});
emit(state.copyWith(status: RemoteControlStatus.controlling));
}
// 3. 更新摇杆数据
void updateJoystick(double x, double y) {
final updatedEntity = state.controlEntity.copyWith(
x: x.toInt(),
y: y.toInt(),
);
emit(state.copyWith(controlEntity: updatedEntity));
}
// 4. 更新功能开关 (比如割刀速度、灯光、点火等)
void updateFunction({int? mower, int? lift, int? ignition, bool? emergency}) {
final updatedEntity = state.controlEntity.copyWith(
mower: mower,
lift: lift,
ignition: ignition,
emergency: emergency,
);
emit(state.copyWith(controlEntity: updatedEntity));
}
void updateOriginY(int y) {}
// 5. 停止控制循环
void stopControlLoop() {
_timer?.cancel();
_timer = null;
emit(state.copyWith(status: RemoteControlStatus.initial));
}
void toggleLock() {}
void togglePermissionDialog(bool show) {
emit(state.copyWith(showPermissionRequestDialog: show));
}
void requestControlPermission() {
// 1. 关闭弹窗
emit(state.copyWith(showPermissionRequestDialog: false));
// 2. 这里执行你发送 0x12 指令的逻辑
// _sendProtocolData(0x12, ...);
}
@override
Future<void> close() {
_timer?.cancel(); // 退出页面时务必销毁定时器
return super.close();
}
void updateChassisLift(int i) {}
void updateEmergency(bool bool) {}
void respondPermission(bool bool) {}
}

View File

@@ -0,0 +1,78 @@
import 'package:equatable/equatable.dart';
import '../../domain/entities/machine_control_status_entity.dart';
enum RemoteControlStatus { initial, controlling, error }
class RemoteControlState extends Equatable {
final RemoteControlStatus status;
final MachineControlStatusEntity controlEntity; // 之前的业务实体
final String? errorMessage;
final bool hasPermission; // 是否获得了 0x12 权限
final bool isEmergency;
final bool isLocked;
final int ping;
final int battery;
final String permissionPlatform;
final String currentPlatform;
final bool showPermissionRequestDialog;
const RemoteControlState({
this.status = RemoteControlStatus.initial,
required this.controlEntity,
this.errorMessage,
this.hasPermission = false,
this.isEmergency = false,
this.isLocked = false,
this.ping = 0,
this.battery = 0,
this.permissionPlatform = '',
this.currentPlatform = '',
this.showPermissionRequestDialog = false,
});
// 方便 UI 更新部分属性
RemoteControlState copyWith({
RemoteControlStatus? status,
MachineControlStatusEntity? controlEntity,
String? errorMessage,
bool? hasPermission,
bool? isEmergency,
bool? isLocked,
int? ping,
int? battery,
String? permissionPlatform,
String? currentPlatform,
bool? showPermissionRequestDialog,
}) {
return RemoteControlState(
status: status ?? this.status,
controlEntity: controlEntity ?? this.controlEntity,
errorMessage: errorMessage ?? this.errorMessage,
hasPermission: hasPermission ?? this.hasPermission,
isEmergency: isEmergency ?? this.isEmergency,
isLocked: isLocked ?? this.isLocked,
ping: ping ?? this.ping,
battery: battery ?? this.battery,
permissionPlatform: permissionPlatform ?? this.permissionPlatform,
currentPlatform: currentPlatform ?? this.currentPlatform,
showPermissionRequestDialog:
showPermissionRequestDialog ?? this.showPermissionRequestDialog,
);
}
@override
List<Object?> get props => [
status,
controlEntity,
errorMessage,
hasPermission,
isEmergency,
isLocked,
ping,
battery,
permissionPlatform,
currentPlatform,
showPermissionRequestDialog,
];
}

View File

@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
import '../../../../core/app/app_user_cubit.dart';
import '../../../devices/presentation/bloc/devices_cubit.dart';
import '../bloc/remote_control_cubit.dart';
import '../bloc/remote_control_state.dart';
import '../widgets/center_control_area.dart';
import '../widgets/emergency_overlay.dart';
import '../widgets/left_joystick_area.dart';
import '../widgets/my_video_player.dart';
import '../widgets/right_joystick_area.dart';
import '../widgets/top_status_bar.dart'; // 假设路径
class RemoteControlPage extends StatefulWidget {
const RemoteControlPage({super.key});
@override
State<RemoteControlPage> createState() => _RemoteControlPageState();
}
class _RemoteControlPageState extends State<RemoteControlPage> {
@override
void initState() {
super.initState();
// 强制横屏与沉浸式
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
}
@override
void dispose() {
// 恢复竖屏
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
@override
Widget build(BuildContext context) {
// 1. 监听全局状态 (实现方案二:掉线实时刷新)
final userState = context.watch<AppUserCubit>().state;
final deviceState = context.watch<DevicesCubit>().state;
final currentDevice = deviceState.selectedDevice;
// 2. 监听局部遥控状态 (对应 Android Compose 的 collectAsState)
final remoteState = context.watch<RemoteControlCubit>().state;
// 如果设备突然掉线,显示遮罩 (对应 Android 的 finishEvent 逻辑)
if (currentDevice == null /*|| !currentDevice.isOnline*/ ) {
return _buildOfflineScaffold();
}
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
// 底层:视频流 (传入 device 和 user)
Positioned.fill(
child: MyVideoPlayer(
deviceIp: TCPConsts.TCP_IP,
device: currentDevice,
user: userState.user!, // 假设 AppUserCubit 存有 user 实体
),
),
// 中层:急停呼吸灯光晕 (对应 Android 的 EmergencyBreathingOverlay)
if (remoteState.isEmergency)
const Positioned.fill(child: EmergencyOverlay()),
// 顶层:UI 控制层
SafeArea(
child: Column(
children: [
// 顶部胶囊状态条
const TopStatusBar(),
// 左右摇杆及中间控制区
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// 左摇杆:前后控制
LeftJoystickArea(isLocked: remoteState.isLocked),
// 中间区:底盘升降/急停按钮
const CenterControlArea(),
// 右摇杆:左右控制
RightJoystickArea(isLocked: remoteState.isLocked),
],
),
),
),
],
),
),
// 权限请求弹窗 (对应 Android 的 PermissionRequestDialog)
if (remoteState.showPermissionRequestDialog)
_buildPermissionDialog(context, remoteState),
],
),
);
}
Widget _buildOfflineScaffold() {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.signal_wifi_off, color: Colors.white, size: 60),
const SizedBox(height: 20),
const Text(
"设备已断开连接",
style: TextStyle(color: Colors.white, fontSize: 18),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () => context.pop(),
child: const Text("返回"),
),
],
),
),
);
}
Widget _buildPermissionDialog(
BuildContext context,
RemoteControlState state,
) {
return Container(
color: Colors.black54,
child: AlertDialog(
title: const Text("权限变更"),
content: Text("${state.permissionPlatform}端正请求控制权,同意释放吗?"),
actions: [
TextButton(
onPressed: () =>
context.read<RemoteControlCubit>().respondPermission(false),
child: const Text("拒绝"),
),
TextButton(
onPressed: () =>
context.read<RemoteControlCubit>().respondPermission(true),
child: const Text("同意"),
),
],
),
);
}
}

View File

@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/remote_control_cubit.dart';
import 'emergency_stop_button.dart';
import 'middle_expandslider.dart';
class CenterControlArea extends StatelessWidget {
const CenterControlArea({super.key});
@override
Widget build(BuildContext context) {
final remoteState = context.watch<RemoteControlCubit>().state;
// 如果处于急停激活状态,根据 Compose 逻辑,中间只显示急停状态
if (remoteState.isEmergency) {
return const Column(
mainAxisSize: MainAxisSize.min,
children: [
EmergencyStopButton(), // 刚才写的带 3 秒解除逻辑的按钮
SizedBox(height: 16),
],
);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// 左推杆:底盘升降 (对应 Compose 的第一个 MiddleExpandSlider)
_buildVerticalSlider(
label: "底盘",
onTop: () =>
context.read<RemoteControlCubit>().updateChassisLift(1),
onMiddle: () =>
context.read<RemoteControlCubit>().updateChassisLift(0),
onBottom: () =>
context.read<RemoteControlCubit>().updateChassisLift(2),
iconTop: Icons.expand_less,
iconMiddle: Icons.layers,
iconBottom: Icons.expand_more,
),
const SizedBox(width: 24),
// 中间:急停按钮
const EmergencyStopButton(),
const SizedBox(width: 24),
// 右推杆:备用/其他 (对应 Compose 的第二个 MiddleExpandSlider)
_buildVerticalSlider(
label: "云台",
onTop: () {}, // 预留接口
onMiddle: () {},
onBottom: () {},
iconTop: Icons.keyboard_arrow_up,
iconMiddle: Icons.videocam,
iconBottom: Icons.keyboard_arrow_down,
),
],
),
);
}
// 快捷构建推杆的方法
Widget _buildVerticalSlider({
required String label,
required VoidCallback onTop,
required VoidCallback onMiddle,
required VoidCallback onBottom,
required IconData iconTop,
required IconData iconMiddle,
required IconData iconBottom,
}) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
MiddleExpandSlider(
onTop: onTop,
onMiddle: onMiddle,
onBottom: onBottom,
iconTop: iconTop,
iconMiddle: iconMiddle,
iconBottom: iconBottom,
),
const SizedBox(height: 8),
Text(label, style: const TextStyle(color: Colors.grey, fontSize: 12)),
],
);
}
}

View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
class EmergencyOverlay extends StatefulWidget {
const EmergencyOverlay({super.key});
@override
State<EmergencyOverlay> createState() => _EmergencyOverlayState();
}
class _EmergencyOverlayState extends State<EmergencyOverlay>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _opacityAnimation;
@override
void initState() {
super.initState();
// 设置动画循环时间,例如 600ms 闪烁一次
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
)..repeat(reverse: true); // 反转运行实现呼吸效果
_opacityAnimation = Tween<double>(
begin: 0.0,
end: 0.5,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _opacityAnimation,
builder: (context, child) {
return IgnorePointer(
// 极其重要:确保光晕不遮挡下方的点击事件
child: Container(
decoration: BoxDecoration(
// 使用径向渐变,让四周红,中间透明
border: Border.all(
color: Colors.red.withOpacity(_opacityAnimation.value),
width: 20, // 边框宽度决定了红边的厚度
),
boxShadow: [
BoxShadow(
color: Colors.red.withOpacity(_opacityAnimation.value),
blurRadius: 40,
spreadRadius: 10,
),
],
),
),
);
},
);
}
}

View File

@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/remote_control_cubit.dart';
class EmergencyStopButton extends StatefulWidget {
const EmergencyStopButton({super.key});
@override
State<EmergencyStopButton> createState() => _EmergencyStopButtonState();
}
class _EmergencyStopButtonState extends State<EmergencyStopButton>
with TickerProviderStateMixin {
late AnimationController _progressController;
bool _isPressing = false;
@override
void initState() {
super.initState();
// 对应 Compose 中的 durationMs = 3000L
_progressController = AnimationController(
vsync: this,
duration: const Duration(seconds: 3),
);
}
@override
void dispose() {
_progressController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// 监听 Cubit 中的急停状态
final isEmergencyActive = context
.watch<RemoteControlCubit>()
.state
.isEmergency;
return GestureDetector(
// 1. 处理点击:仅在未急停时触发
onTap: () {
if (!isEmergencyActive) {
context.read<RemoteControlCubit>().updateEmergency(true);
HapticFeedback.heavyImpact(); // 震动反馈
}
},
// 2. 处理长按开始:仅在已急停时触发解除逻辑
onLongPressStart: (_) {
if (isEmergencyActive) {
setState(() => _isPressing = true);
_progressController.forward(from: 0).then((_) {
if (_isPressing) {
// 进度走完且仍在按压
context.read<RemoteControlCubit>().updateEmergency(false);
HapticFeedback.vibrate();
setState(() => _isPressing = false);
}
});
}
},
// 3. 处理松手:重置进度
onLongPressEnd: (_) {
_isPressing = false;
_progressController.stop();
_progressController.value = 0;
setState(() {});
},
child: Stack(
alignment: Alignment.center,
children: [
// 对应 Compose 的 Canvas 绘制进度环
if (_isPressing)
SizedBox(
width: 130,
height: 130,
child: CircularProgressIndicator(
value: _progressController.value,
strokeWidth: 10,
color: Colors.red.withOpacity(0.8),
backgroundColor: Colors.red.withOpacity(0.2),
),
),
// 按钮本体 (对应 Android 的 120.dp Box)
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
// 根据状态切换颜色,对应 Color(0x80C53030)
color: isEmergencyActive
? Colors.red.withOpacity(0.5)
: const Color(0x80C53030),
boxShadow: isEmergencyActive
? [
BoxShadow(
color: Colors.red.withOpacity(0.5),
blurRadius: 20,
),
]
: [],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.report_problem_outlined, // 对应 remote_alert 图标
color: Colors.white,
size: 45,
),
const SizedBox(height: 6),
Text(
isEmergencyActive ? "急停中" : "急停",
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w900,
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,50 @@
import 'package:cc_ui_kit/cc_ui_kit.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/remote_control_cubit.dart';
class LeftJoystickArea extends StatelessWidget {
final bool isLocked;
const LeftJoystickArea({super.key, required this.isLocked});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min, // 紧凑布局
children: [
// 使用 IgnorePointer 处理锁定逻辑,对应 Android 的 isLocked 判断
IgnorePointer(
ignoring: isLocked,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: isLocked ? 0.3 : 1.0, // 锁定后变透明
child: CCJoystick(
radius: 100, // 对应 size(200.dp)
axisHint: AxisHint.forwardBackward,
onValueChanged: (value) {
// 对应 viewModel.updateOriginY(y)
context.read<RemoteControlCubit>().updateOriginY(value.y);
},
onPress: () {
// 对应 VibrateOnce(current, 100)
HapticFeedback.mediumImpact();
},
),
),
),
const SizedBox(height: 8),
const Text(
"前后控制",
style: TextStyle(
color: Colors.grey,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
);
}
}

View File

@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class MiddleExpandSlider extends StatefulWidget {
final VoidCallback onTop; // 对应 Compose 的 onTop (例如:升)
final VoidCallback onMiddle; // 对应 Compose 的 onMiddle (例如:停止/复位)
final VoidCallback onBottom; // 对应 Compose 的 onBottom (例如:降)
final IconData iconTop;
final IconData iconMiddle;
final IconData iconBottom;
const MiddleExpandSlider({
super.key,
required this.onTop,
required this.onMiddle,
required this.onBottom,
required this.iconTop,
required this.iconMiddle,
required this.iconBottom,
});
@override
State<MiddleExpandSlider> createState() => _MiddleExpandSliderState();
}
class _MiddleExpandSliderState extends State<MiddleExpandSlider> {
// 0: Top, 1: Middle, 2: Bottom
int _currentIndex = 1;
// 处理滑动更新逻辑
void _handleDragUpdate(DragUpdateDetails details, double maxHeight) {
// 将 120 的高度分为三等份
double localY = details.localPosition.dy;
int newIndex;
if (localY < maxHeight / 3) {
newIndex = 0;
} else if (localY > (maxHeight / 3) * 2) {
newIndex = 2;
} else {
newIndex = 1;
}
if (newIndex != _currentIndex) {
setState(() => _currentIndex = newIndex);
// 触发对应的指令回调
if (_currentIndex == 0) widget.onTop();
if (_currentIndex == 1) widget.onMiddle();
if (_currentIndex == 2) widget.onBottom();
// 触感反馈:对应 Android 的 VibrateOnce(current, 20)
HapticFeedback.lightImpact();
}
}
// 对应 Compose 的松手回弹逻辑
void _handleDragEnd() {
if (_currentIndex != 1) {
setState(() => _currentIndex = 1);
widget.onMiddle(); // 回到中间,停止动作
HapticFeedback.selectionClick();
}
}
@override
Widget build(BuildContext context) {
const double sliderHeight = 120.0;
const double sliderWidth = 45.0;
return GestureDetector(
onVerticalDragUpdate: (details) =>
_handleDragUpdate(details, sliderHeight),
onVerticalDragEnd: (_) => _handleDragEnd(),
child: Container(
width: sliderWidth,
height: sliderHeight,
decoration: BoxDecoration(
color: Colors.grey.withOpacity(
0.2,
), // 对应 Color.Gray.copy(alpha = 0.4f)
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white.withOpacity(0.2), width: 0.5),
),
child: Stack(
alignment: Alignment.center,
children: [
// 1. 背景图标层:提示用户上下功能
Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Icon(widget.iconTop, color: Colors.white12, size: 20),
Icon(widget.iconMiddle, color: Colors.white12, size: 20),
Icon(widget.iconBottom, color: Colors.white12, size: 20),
],
),
// 2. 活动滑块:对应 Compose 中的蓝色选中状态
AnimatedPositioned(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOutBack, // 增加一点点弹簧感
top: _currentIndex == 0 ? 5 : (_currentIndex == 1 ? 40 : 75),
child: Container(
width: 38,
height: 40,
decoration: BoxDecoration(
color: const Color(0xCC0078D4), // 对应 Compose 里的蓝色
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: const Color(0xCC0078D4).withOpacity(0.4),
blurRadius: 8,
spreadRadius: 1,
),
],
),
child: Icon(
_currentIndex == 0
? widget.iconTop
: (_currentIndex == 1
? widget.iconMiddle
: widget.iconBottom),
color: Colors.white,
size: 22,
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,111 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart';
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart';
class MyVideoPlayer extends StatefulWidget {
final String deviceIp;
final DeviceEntity device;
final UserEntity user;
const MyVideoPlayer({
Key? key,
required this.deviceIp,
required this.device,
required this.user,
}) : super(key: key);
@override
State<MyVideoPlayer> createState() => _MyVideoPlayerState();
}
class _MyVideoPlayerState extends State<MyVideoPlayer> {
// 1. 定义本地服务器
InAppLocalhostServer? _localServer;
InAppWebViewController? _webViewController;
bool _isServerRunning = false;
var actualPort;
@override
void initState() {
super.initState();
_startServer();
}
// 2. 启动服务器 (适用于 Windows, Android, iOS)
Future<void> _startServer() async {
// 1. 手动找一个系统分配的空闲端口
int availablePort = 0;
try {
// 绑定到端口 0,系统会随机分配一个
var socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
availablePort = socket.port;
await socket.close(); // 立即释放,给 LocalhostServer 用
} catch (e) {
availablePort = 8080; // 万一失败,给个保底
}
// 2. 使用找到的明确端口启动
_localServer = InAppLocalhostServer(port: availablePort);
await _localServer!.start();
// 确认端口(有些版本需要通过这种方式确认)
actualPort = availablePort;
debugPrint("服务器启动在端口: $actualPort");
setState(() {
_isServerRunning = true;
});
}
@override
void dispose() {
_localServer?.close(); // 页面销毁时关闭服务器
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_isServerRunning) {
return const Center(child: CircularProgressIndicator());
}
return InAppWebView(
// 3. 通过 localhost 地址访问,而不是 file://
initialUrlRequest: URLRequest(
url: WebUri("http://localhost:$actualPort/assets/www/playwebrtc.html"),
),
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
mediaPlaybackRequiresUserGesture: false,
allowsInlineMediaPlayback: true,
// Windows/Android 开启硬件加速优化图传
preferredContentMode: UserPreferredContentMode.DESKTOP,
),
onWebViewCreated: (controller) => _webViewController = controller,
onPermissionRequest: (controller, request) async {
return PermissionResponse(
resources: request.resources,
action: PermissionResponseAction.GRANT,
);
},
onLoadStop: (controller, url) {
_initWebRTC(
widget.deviceIp,
widget.device.deviceName,
widget.user.token,
);
},
);
}
void _initWebRTC(String deviceIp, String deviceId, String token) {
final streamUrl =
"webrtc://$deviceIp/live/livestream/$deviceId?token=$token";
_webViewController?.evaluateJavascript(
source: "setStreamUrl('$streamUrl')",
);
}
}

View File

@@ -0,0 +1,52 @@
import 'package:cc_ui_kit/cc_ui_kit.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/remote_control_cubit.dart';
class RightJoystickArea extends StatelessWidget {
final bool isLocked;
const RightJoystickArea({super.key, required this.isLocked});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min, // 垂直方向紧凑布局
children: [
// 1. 交互锁定逻辑:对应 Compose 的 isLocked 判断
IgnorePointer(
ignoring: isLocked,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: isLocked ? 0.3 : 1.0, // 锁定后变透明/灰色
child: CCJoystick(
radius: 100, // 对应 size(200.dp)
axisHint: AxisHint.leftRight, // 关键:指定为左右控制
onValueChanged: (value) {
// 对应 viewModel.updateOriginX(x)
context.read<RemoteControlCubit>().updateOriginY(value.x);
},
onPress: () {
// 对应 VibrateOnce(current, 100)
HapticFeedback.mediumImpact();
},
),
),
),
const SizedBox(height: 8),
// 2. 底部文字:对应 Text("左右控制", color = Color.Gray)
const Text(
"左右控制",
style: TextStyle(
color: Colors.grey,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
],
);
}
}

View File

@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
class StatusChip extends StatefulWidget {
final String text;
final Color color;
final IconData icon;
final bool breathing; // 是否开启呼吸灯特效
final VoidCallback? onTap;
const StatusChip({
super.key,
required this.text,
required this.color,
required this.icon,
this.breathing = false,
this.onTap,
});
@override
State<StatusChip> createState() => _StatusChipState();
}
class _StatusChipState extends State<StatusChip>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _opacityAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1000),
);
if (widget.breathing) _controller.repeat(reverse: true);
_opacityAnimation = Tween<double>(
begin: 0.4,
end: 1.0,
).animate(CurvedAnimation(parent: _controller, curve: Curves.linear));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _opacityAnimation,
builder: (context, child) {
final currentAlpha = widget.breathing ? _opacityAnimation.value : 0.6;
return GestureDetector(
onTap: widget.onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: widget.color.withOpacity(currentAlpha),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: widget.color.withOpacity(0.35),
width: 0.5,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(widget.icon, color: Colors.white, size: 14),
const SizedBox(width: 6),
Text(
widget.text,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
},
);
}
}

View File

@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:maibu_satabot_v2/features/remote_control/presentation/widgets/status_chip.dart';
import '../../../devices/presentation/bloc/devices_cubit.dart';
import '../bloc/remote_control_cubit.dart';
class TopStatusBar extends StatelessWidget {
const TopStatusBar({super.key});
@override
Widget build(BuildContext context) {
// 监听全局设备状态
final deviceState = context.watch<DevicesCubit>().state;
final device = deviceState.selectedDevice;
// 监听局部遥控状态
final remoteState = context.watch<RemoteControlCubit>().state;
return Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
// 1. 返回按钮 (对应 SimpleSmallFunctionButton)
_buildIconButton(Icons.arrow_back_ios_new, () => context.pop()),
const SizedBox(width: 16),
// 2. 控制状态 (对应 StatusChipLeft)
StatusChip(
text: remoteState.hasPermission ? "正在控制" : "未在控制",
color: remoteState.hasPermission
? const Color(0xFF1DB954)
: Colors.red,
icon: Icons.eighteen_mp,
breathing: !remoteState.hasPermission,
onTap: () {
if (!remoteState.hasPermission) {
// 弹出请求权限对话框逻辑
context.read<RemoteControlCubit>().togglePermissionDialog(true);
}
},
),
const SizedBox(width: 8),
// 3. 锁定状态 (对应 SmallFunctionButton)
_buildIconButton(
remoteState.isLocked ? Icons.lock : Icons.lock_open,
() => context.read<RemoteControlCubit>().toggleLock(),
isSelected: remoteState.isLocked,
),
const SizedBox(width: 8),
// 4. 刷新按钮
_buildIconButton(Icons.refresh, () {
// 刷新 WebView 逻辑
}),
const Spacer(),
// 5. 信号延迟 (对应 pingStatusChip)
_buildPingChip(remoteState.ping),
const SizedBox(width: 8),
// 6. 电量 (对应 StatusChipRight)
_buildBatteryChip(remoteState?.battery ?? 0),
],
),
);
}
// 对应 Android 里的 SimpleSmallFunctionButton 样式
Widget _buildIconButton(
IconData icon,
VoidCallback onTap, {
bool isSelected = false,
}) {
return InkWell(
onTap: onTap,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: isSelected
? const Color(0xCC0078D4)
: Colors.grey.withOpacity(0.4),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white.withOpacity(0.3), width: 0.5),
),
child: Icon(icon, color: Colors.white, size: 18),
),
);
}
Widget _buildPingChip(int ping) {
Color color = ping < 100
? Colors.green
: (ping < 200 ? Colors.orange : Colors.red);
return StatusChip(
text: "$ping ms",
color: color,
icon: Icons.network_check,
);
}
Widget _buildBatteryChip(int level) {
IconData icon = level > 80
? Icons.battery_full
: (level > 20 ? Icons.battery_3_bar : Icons.battery_alert);
Color color = level > 80
? Colors.green
: (level > 30 ? Colors.orange : Colors.red);
return StatusChip(text: "$level%", color: color, icon: icon);
}
}