368 lines
14 KiB
Dart
368 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
||
|
||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||
|
||
import '../models/pv_video_fit.dart';
|
||
import '../models/pv_video_source.dart';
|
||
import '../theme/pv_colors.dart';
|
||
import '../theme/pv_hud_theme.dart';
|
||
|
||
/// 页面设置面板(底部弹出,遮罩半透明可透视下方视频,边调边看效果)。
|
||
///
|
||
/// 纯展示 + 回调:所有设置状态由 PvControlPage 持有。当前五项:
|
||
/// - 视频源:以太网 RTSP / HDMI(USB 采集卡) / WebRTC,切换即重建对应链路 cubit;
|
||
/// - 视频展示:完整显示(不裁画面、可能留黑边)/ 铺满屏幕(无黑边、裁掉边缘);
|
||
/// - 模拟障碍物信息:真实推送未接入前手动触发,开启 = 相当于收到机器告警推送,
|
||
/// 预警横幅 + AR 障碍框 + 屏幕四周泛红**同时**显示;
|
||
/// - 倒车影像:开启后左栏后视卡叠加倒车引导线效果(模拟倒车挡);
|
||
/// - 遥控摇杆(仅手动控制 HUD 显示):关闭后隐藏左右两个方向摇杆与回中按钮。
|
||
///
|
||
/// 皮肤两套(由 [light] 选择):普通态大屏沿用原深蓝面板;手动控制全屏视图
|
||
/// (HUD)用近白玻璃卡,与其上的顶栏 / 侧栏 / 底部条保持同一视觉体系。
|
||
class PvSettingsSheet extends StatelessWidget {
|
||
const PvSettingsSheet({
|
||
super.key,
|
||
required this.videoSource,
|
||
required this.videoFit,
|
||
required this.simObstacle,
|
||
required this.simReverse,
|
||
required this.showJoysticks,
|
||
required this.onVideoSourceChanged,
|
||
required this.onVideoFitChanged,
|
||
required this.onSimObstacleChanged,
|
||
required this.onSimReverseChanged,
|
||
required this.onShowJoysticksChanged,
|
||
this.light = false,
|
||
});
|
||
|
||
final PvVideoSource videoSource;
|
||
final PvVideoFit videoFit;
|
||
final bool simObstacle;
|
||
final bool simReverse;
|
||
|
||
/// 是否显示左右两个方向摇杆(仅手动控制 HUD 有摇杆,设置面板可关掉)。
|
||
final bool showJoysticks;
|
||
|
||
final ValueChanged<PvVideoSource> onVideoSourceChanged;
|
||
final ValueChanged<PvVideoFit> onVideoFitChanged;
|
||
final ValueChanged<bool> onSimObstacleChanged;
|
||
final ValueChanged<bool> onSimReverseChanged;
|
||
final ValueChanged<bool> onShowJoysticksChanged;
|
||
|
||
/// 是否套用 HUD 浅色玻璃皮肤(默认 false = 普通态大屏的深色皮肤,与改造前一致)。
|
||
final bool light;
|
||
|
||
/// 视频源选项:顺序与文案按需求固定(以太网 RTSP 源、HDMI、WebRTC)。
|
||
static const List<(PvVideoSource, String)> _sources = [
|
||
(PvVideoSource.localRtsp, 'pv_control.set_source_rtsp'),
|
||
(PvVideoSource.usbCamera, 'pv_control.set_source_hdmi'),
|
||
(PvVideoSource.webrtc, 'pv_control.set_source_webrtc'),
|
||
];
|
||
|
||
/// 视频展示选项:文案说清“取舍”以免用户选完还是困惑。
|
||
static const List<(PvVideoFit, String)> _fits = [
|
||
(PvVideoFit.contain, 'pv_control.set_fit_contain'),
|
||
(PvVideoFit.cover, 'pv_control.set_fit_cover'),
|
||
];
|
||
|
||
_SheetSkin get _skin => light ? _SheetSkin.light : _SheetSkin.dark;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final tr = AppLocalizations.of(context);
|
||
final skin = _skin;
|
||
// 横屏可用高度有限:最大高度限幅 + 可滚动防底部裁切;
|
||
// 视频源行用 Wrap,窄屏 / 大字体下药丸自动换行不溢出。
|
||
// 限幅放宽到 0.86:多一行开关后旧值会把最后一行压到手势条下面。
|
||
final maxH = MediaQuery.sizeOf(context).height * 0.86;
|
||
return ConstrainedBox(
|
||
constraints: BoxConstraints(maxHeight: maxH),
|
||
child: SingleChildScrollView(
|
||
child: Padding(
|
||
// 底部留白加大:面板整体往上抬,不贴着屏幕底边
|
||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 18),
|
||
child: Container(
|
||
padding: const EdgeInsets.fromLTRB(12, 2, 8, 6),
|
||
decoration: BoxDecoration(
|
||
color: skin.panel,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(color: skin.border),
|
||
boxShadow: skin.panelShadow,
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// 标题行 + 关闭(比原尺寸小一号,给下面的开关行腾高度)
|
||
Row(
|
||
children: [
|
||
Icon(Icons.settings_outlined, color: skin.accent, size: 16),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
tr.translate('pv_control.nav_settings'),
|
||
style: PvType.cardTitle.copyWith(
|
||
color: skin.textPrimary,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
IconButton(
|
||
icon: Icon(
|
||
Icons.close,
|
||
color: skin.textSecondary,
|
||
size: 18,
|
||
),
|
||
visualDensity: VisualDensity.compact,
|
||
padding: EdgeInsets.zero,
|
||
constraints:
|
||
const BoxConstraints(minWidth: 28, minHeight: 28),
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
),
|
||
],
|
||
),
|
||
// 视频源(Wrap:空间不够自动换第二行)
|
||
Wrap(
|
||
spacing: 6,
|
||
runSpacing: 2,
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
children: [
|
||
Text(
|
||
tr.translate('pv_control.set_video_source'),
|
||
style: PvType.label.copyWith(color: skin.textSecondary),
|
||
),
|
||
for (final (src, label) in _sources)
|
||
_pill(
|
||
label: tr.translate(label),
|
||
selected: src == videoSource,
|
||
onTap: () => onVideoSourceChanged(src),
|
||
),
|
||
],
|
||
),
|
||
// 视频展示(适配方式)+ 一句取舍说明
|
||
const SizedBox(height: 4),
|
||
Wrap(
|
||
spacing: 6,
|
||
runSpacing: 2,
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
children: [
|
||
Text(
|
||
tr.translate('pv_control.set_video_fit'),
|
||
style: PvType.label.copyWith(color: skin.textSecondary),
|
||
),
|
||
for (final (fitMode, label) in _fits)
|
||
_pill(
|
||
label: tr.translate(label),
|
||
selected: fitMode == videoFit,
|
||
onTap: () => onVideoFitChanged(fitMode),
|
||
),
|
||
Text(
|
||
videoFit == PvVideoFit.contain
|
||
? tr.translate('pv_control.set_fit_contain_hint')
|
||
: tr.translate('pv_control.set_fit_cover_hint'),
|
||
style: PvType.caption.copyWith(color: skin.textTertiary),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 2),
|
||
_switchTile(
|
||
icon: Icons.warning_amber_rounded,
|
||
iconColor: simObstacle ? skin.danger : skin.textTertiary,
|
||
title: tr.translate('pv_control.set_sim_obstacle'),
|
||
subtitle: tr.translate('pv_control.set_sim_obstacle_sub'),
|
||
value: simObstacle,
|
||
onChanged: onSimObstacleChanged,
|
||
),
|
||
_switchTile(
|
||
icon: Icons.directions_car,
|
||
iconColor: simReverse ? skin.accent : skin.textTertiary,
|
||
title: tr.translate('pv_control.set_sim_reverse'),
|
||
subtitle: tr.translate('pv_control.set_sim_reverse_sub'),
|
||
value: simReverse,
|
||
onChanged: onSimReverseChanged,
|
||
),
|
||
// 摇杆开关:新版远程遥控的设置面板里始终可见。
|
||
// 大屏页本身没有摇杆,这个开关预设的是「全屏手动控制」里的显示与否,
|
||
// 进 HUD 后立即生效(淡出淡入不需要重启页面)。
|
||
_switchTile(
|
||
icon: Icons.gamepad_outlined,
|
||
iconColor: showJoysticks ? skin.accent : skin.textTertiary,
|
||
title: tr.translate('pv_control.set_show_joysticks'),
|
||
subtitle: tr.translate('pv_control.set_show_joysticks_sub'),
|
||
value: showJoysticks,
|
||
onChanged: onShowJoysticksChanged,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 药丸选项:选中态强调色描边 + 浅底,与当前皮肤同体系(视频源 / 视频展示共用)。
|
||
/// 左右间距由外层 Wrap 的 spacing 提供,此处只留纵向呼吸。
|
||
Widget _pill({
|
||
required String label,
|
||
required bool selected,
|
||
required VoidCallback onTap,
|
||
}) {
|
||
final skin = _skin;
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
behavior: HitTestBehavior.opaque,
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 180),
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: selected ? skin.accentSoft : skin.inner,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(
|
||
color: selected ? skin.accent : skin.border,
|
||
),
|
||
),
|
||
child: Text(
|
||
label,
|
||
style: PvType.body.copyWith(
|
||
color: selected ? skin.accentText : skin.textSecondary,
|
||
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 开关行:图标 + 标题/说明 + Switch(整行可点按切换,热区更大)。
|
||
Widget _switchTile({
|
||
required IconData icon,
|
||
required Color iconColor,
|
||
required String title,
|
||
required String subtitle,
|
||
required bool value,
|
||
required ValueChanged<bool> onChanged,
|
||
}) {
|
||
final skin = _skin;
|
||
return InkWell(
|
||
onTap: () => onChanged(!value),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||
child: Row(
|
||
children: [
|
||
Icon(icon, size: 18, color: iconColor),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
title,
|
||
style: PvType.cardTitle.copyWith(color: skin.textPrimary),
|
||
),
|
||
Text(
|
||
subtitle,
|
||
style: PvType.caption.copyWith(
|
||
color: skin.textSecondary,
|
||
height: 1.3,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// Switch 自身禁用手势,统一走整行 onTap,避免两套状态;
|
||
// shrinkWrap 去掉默认 48dp 点按区,视觉上跟着整体缩一号
|
||
IgnorePointer(
|
||
child: Switch(
|
||
value: value,
|
||
onChanged: (_) {},
|
||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||
activeThumbColor: skin.switchActiveThumb,
|
||
activeTrackColor: skin.switchActiveTrack,
|
||
inactiveThumbColor: skin.switchInactiveThumb,
|
||
inactiveTrackColor: skin.switchInactiveTrack,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 设置面板两套皮肤取色:`light` = 手动控制 HUD 的浅色玻璃卡,
|
||
/// `dark` = 普通态大屏的深蓝面板(数值与改造前完全一致)。
|
||
class _SheetSkin {
|
||
const _SheetSkin({
|
||
required this.panel,
|
||
required this.inner,
|
||
required this.border,
|
||
required this.accent,
|
||
required this.accentSoft,
|
||
required this.accentText,
|
||
required this.textPrimary,
|
||
required this.textSecondary,
|
||
required this.textTertiary,
|
||
required this.danger,
|
||
required this.switchActiveThumb,
|
||
required this.switchActiveTrack,
|
||
this.switchInactiveThumb,
|
||
this.switchInactiveTrack,
|
||
this.panelShadow,
|
||
});
|
||
|
||
/// 面板底 / 药丸未选中底 / 描边
|
||
final Color panel;
|
||
final Color inner;
|
||
final Color border;
|
||
|
||
/// 强调色(图标、选中描边)/ 选中浅底 / 选中文字
|
||
final Color accent;
|
||
final Color accentSoft;
|
||
final Color accentText;
|
||
|
||
final Color textPrimary;
|
||
final Color textSecondary;
|
||
final Color textTertiary;
|
||
final Color danger;
|
||
|
||
final Color switchActiveThumb;
|
||
final Color switchActiveTrack;
|
||
final Color? switchInactiveThumb;
|
||
final Color? switchInactiveTrack;
|
||
|
||
/// 面板投影(深色皮肤保持无投影)
|
||
final List<BoxShadow>? panelShadow;
|
||
|
||
static const dark = _SheetSkin(
|
||
panel: PvColors.card,
|
||
inner: PvColors.cardInner,
|
||
border: PvColors.cardBorder,
|
||
accent: PvColors.accent,
|
||
accentSoft: Color(0x3322D3EE), // accent @ 20%
|
||
accentText: PvColors.accentBright,
|
||
textPrimary: PvColors.textPrimary,
|
||
textSecondary: PvColors.textSecondary,
|
||
textTertiary: PvColors.textTertiary,
|
||
danger: PvColors.danger,
|
||
switchActiveThumb: PvColors.accentBright,
|
||
switchActiveTrack: Color(0x5922D3EE), // accent @ 35%
|
||
);
|
||
|
||
static const light = _SheetSkin(
|
||
panel: PvHudColors.panelSolid,
|
||
inner: PvHudColors.pill,
|
||
border: PvHudColors.border,
|
||
accent: PvHudColors.blue,
|
||
accentSoft: PvHudColors.blueBg,
|
||
accentText: PvHudColors.blue,
|
||
textPrimary: PvHudColors.textPrimary,
|
||
textSecondary: PvHudColors.textSecondary,
|
||
textTertiary: PvHudColors.textTertiary,
|
||
danger: PvHudColors.danger,
|
||
switchActiveThumb: Colors.white,
|
||
switchActiveTrack: Color(0xFF2F6BFF),
|
||
switchInactiveThumb: Colors.white,
|
||
switchInactiveTrack: Color(0xFFDDE4EC),
|
||
panelShadow: [
|
||
BoxShadow(color: Color(0x1F0E2233), blurRadius: 24, offset: Offset(0, 8)),
|
||
],
|
||
);
|
||
}
|