第二次提交
This commit is contained in:
@@ -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) {}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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("同意"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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')",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user