Files
flutterApp/lib/features/pv_control/presentation/widgets/pv_settings_sheet.dart

368 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)),
],
);
}