Compare commits

...

3 Commits

10 changed files with 1616 additions and 95 deletions

View File

@@ -0,0 +1,947 @@
import 'dart:async';
import 'package:agora_rtc_engine/agora_rtc_engine.dart' as agora;
import 'package:flutter/material.dart';
/// 声网视频播放状态
enum AgoraPlayerState {
/// 空闲:未连接或已主动断开
idle,
/// 连接中:初始化引擎 / 加入频道 / 等待远端首帧
connecting,
/// 播放中:已收到远端首帧
playing,
/// 失败:参数缺失、加入频道失败、超时或 SDK 报错
error,
}
/// 通用声网(Agora RTC)视频展示组件
///
/// 只需要 appId / channelId / token 三个值即可拉流播放,组件内部自行完成
/// 引擎创建、加入频道、远端首帧监听、渲染与资源释放,调用方不用管生命周期。
///
/// 两种用法:
///
/// 1)直接当普通 Widget 嵌进布局:
/// ```dart
/// AgoraVideoPlayer(appId: 'x', channelId: 'y', token: 'z')
/// ```
///
/// 2)一行代码弹出(推荐,关闭弹窗即自动释放引擎):
/// ```dart
/// onTap: () => AgoraVideoPlayer.show(
/// context,
/// appId: 'x',
/// channelId: 'y',
/// token: 'z',
/// ),
/// ```
class AgoraVideoPlayer extends StatefulWidget {
/// 声网 App ID
final String appId;
/// 频道名(后端接口里的 channel / room_id)
final String channelId;
/// 频道 Token,未开启鉴权时可传空串
final String token;
/// 本端 uid,纯观看传 0 由 SDK 自动分配
final int uid;
/// 频道模式,默认直播模式
final agora.ChannelProfileType channelProfile;
/// 本端角色,默认观众(只拉流不推流,不占用相机麦克风)
final agora.ClientRoleType clientRole;
/// 画面填充模式
final agora.RenderModeType renderMode;
/// 是否订阅音频,默认只订阅视频
final bool subscribeAudio;
/// Android 使用 SurfaceView 渲染(出现黑屏 / 层级遮挡时可切 true)
final bool useAndroidSurfaceView;
/// iOS、桌面端使用 Flutter Texture 渲染
final bool useFlutterTexture;
/// 是否在 initState 自动连接
final bool autoConnect;
/// 连接超时:超过该时长仍未收到远端首帧判定为失败
final Duration connectTimeout;
/// 状态变化回调
final ValueChanged<AgoraPlayerState>? onStateChanged;
/// 首帧解码成功回调,参数为远端 uid
final ValueChanged<int>? onFirstFrame;
/// 错误回调
final ValueChanged<String>? onError;
/// 视频区背景色
final Color backgroundColor;
/// 正在建连时的提示文案
final String loadingText;
/// 已进房但未收到画面时的提示文案
final String waitingText;
/// 失败时是否显示内置重试按钮
final bool showRetryButton;
/// 自定义失败态 UI,返回 null 则使用内置样式
final Widget Function(BuildContext context, String message)? errorBuilder;
/// 重连令牌:值变化即强制重新连接,即使 appId / channelId / token 完全相同
/// (供「重新加载」按钮使用)
final int reloadToken;
const AgoraVideoPlayer({
super.key,
required this.appId,
required this.channelId,
required this.token,
this.uid = 0,
this.channelProfile =
agora.ChannelProfileType.channelProfileLiveBroadcasting,
this.clientRole = agora.ClientRoleType.clientRoleAudience,
this.renderMode = agora.RenderModeType.renderModeFit,
this.subscribeAudio = false,
this.useAndroidSurfaceView = false,
this.useFlutterTexture = false,
this.autoConnect = true,
this.connectTimeout = const Duration(seconds: 15),
this.onStateChanged,
this.onFirstFrame,
this.onError,
this.backgroundColor = Colors.black,
this.loadingText = '视频加载中...',
this.waitingText = '等待视频流...',
this.showRetryButton = true,
this.errorBuilder,
this.reloadToken = 0,
});
/// 从后端返回的 `app_id=xx&channel=yy&token=zz&user_id=0` 形式参数串构建
factory AgoraVideoPlayer.fromUrl({
Key? key,
required String url,
agora.RenderModeType renderMode = agora.RenderModeType.renderModeFit,
Duration connectTimeout = const Duration(seconds: 15),
ValueChanged<AgoraPlayerState>? onStateChanged,
}) {
final p = parseParams(url);
return AgoraVideoPlayer(
key: key,
appId: p['app_id'] ?? p['appid'] ?? '',
channelId:
p['channel'] ?? p['channel_id'] ?? p['room_id'] ?? p['roomid'] ?? '',
token: p['token'] ?? '',
uid: int.tryParse(p['user_id'] ?? p['uid'] ?? '') ?? 0,
renderMode: renderMode,
connectTimeout: connectTimeout,
onStateChanged: onStateChanged,
);
}
/// 解析 `k=v&k=v` 参数串
static Map<String, String> parseParams(String url) {
final params = <String, String>{};
for (final pair in url.split('&')) {
final kv = pair.split('=');
if (kv.length == 2) {
params[kv[0].trim()] = Uri.decodeComponent(kv[1]);
}
}
return params;
}
/// 【核心入口】一行代码弹出居中视频弹窗(16:9)
///
/// 关闭弹窗时组件被移出 widget 树,自动触发 dispose →
/// leaveChannel + release,调用方不需要手动清理任何东西。
static Future<void> show(
BuildContext context, {
required String appId,
required String channelId,
required String token,
int uid = 0,
String title = '实时视频',
agora.RenderModeType renderMode = agora.RenderModeType.renderModeFit,
bool barrierDismissible = true,
ValueChanged<AgoraPlayerState>? onStateChanged,
}) {
return showDialog<void>(
context: context,
barrierDismissible: barrierDismissible,
barrierColor: Colors.black87,
builder: (dialogContext) => Dialog(
backgroundColor: Colors.black,
elevation: 0,
insetPadding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: 48,
padding: const EdgeInsets.only(left: 16),
color: const Color(0xFF2A2E34),
child: Row(
children: [
Expanded(
child: Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(
Icons.close,
color: Colors.white70,
size: 20,
),
onPressed: () => Navigator.pop(dialogContext),
),
],
),
),
AspectRatio(
aspectRatio: 16 / 9,
child: AgoraVideoPlayer(
appId: appId,
channelId: channelId,
token: token,
uid: uid,
renderMode: renderMode,
onStateChanged: onStateChanged,
),
),
],
),
),
),
);
}
/// 以底部弹层形式弹出(适合列表页预览,不遮挡整屏)
static Future<void> showBottomSheet(
BuildContext context, {
required String appId,
required String channelId,
required String token,
int uid = 0,
double heightFactor = 0.42,
agora.RenderModeType renderMode = agora.RenderModeType.renderModeFit,
ValueChanged<AgoraPlayerState>? onStateChanged,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.black,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (sheetContext) => SizedBox(
height: MediaQuery.of(sheetContext).size.height * heightFactor,
child: Column(
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(2),
),
),
Expanded(
child: AgoraVideoPlayer(
appId: appId,
channelId: channelId,
token: token,
uid: uid,
renderMode: renderMode,
onStateChanged: onStateChanged,
),
),
],
),
),
);
}
/// 【手动参数入口】弹出带 App ID / Channel / Token 三个输入框的弹窗
///
/// 填完点「重新加载」开始拉流;即使参数一模一样也会强制重连。
/// 适用于接口未就绪、需要手工验证频道参数的场景。
/// 关闭弹窗同样会自动释放引擎。
static Future<void> showManualInput(
BuildContext context, {
String title = '实时视频',
String initialAppId = '',
String initialChannelId = '',
String initialToken = '',
bool barrierDismissible = true,
}) {
return showDialog<void>(
context: context,
barrierDismissible: barrierDismissible,
barrierColor: Colors.black87,
builder: (_) => _AgoraManualParamsDialog(
title: title,
initialAppId: initialAppId,
initialChannelId: initialChannelId,
initialToken: initialToken,
),
);
}
@override
State<AgoraVideoPlayer> createState() => _AgoraVideoPlayerState();
}
class _AgoraVideoPlayerState extends State<AgoraVideoPlayer> {
agora.RtcEngine? _engine;
agora.RtcEngineEventHandler? _handler;
AgoraPlayerState _state = AgoraPlayerState.idle;
String? _errorMessage;
int? _remoteUid;
bool _isJoined = false;
bool _isDisposed = false;
bool _firstFrameNotified = false;
Timer? _connectTimer;
/// 连接序号:参数快速变化时用于丢弃过期的异步流程
int _connectSeq = 0;
@override
void initState() {
super.initState();
if (widget.autoConnect) {
_connect();
}
}
@override
void didUpdateWidget(covariant AgoraVideoPlayer oldWidget) {
super.didUpdateWidget(oldWidget);
final changed =
oldWidget.appId != widget.appId ||
oldWidget.channelId != widget.channelId ||
oldWidget.token != widget.token ||
oldWidget.uid != widget.uid ||
oldWidget.reloadToken != widget.reloadToken;
if (changed) {
_log('参数变化,重新连接: ${widget.channelId}');
_connect();
}
}
@override
void dispose() {
_isDisposed = true;
_cancelConnectTimer();
_release();
super.dispose();
}
void _log(String msg) => debugPrint('🎥 [Agora] $msg');
void _updateState(AgoraPlayerState next, {String? message}) {
if (_state == next && message == _errorMessage) return;
_state = next;
_errorMessage = message;
if (next == AgoraPlayerState.playing) {
_cancelConnectTimer();
}
if (mounted) {
setState(() {});
}
widget.onStateChanged?.call(next);
if (next == AgoraPlayerState.error && message != null) {
widget.onError?.call(message);
}
}
void _startConnectTimer() {
_cancelConnectTimer();
_connectTimer = Timer(widget.connectTimeout, () {
if (_isDisposed || _state == AgoraPlayerState.playing) return;
_log('⏰ 连接超时(${widget.connectTimeout.inSeconds}s 未收到首帧)');
_updateState(AgoraPlayerState.error, message: '视频连接超时,请重试');
});
}
void _cancelConnectTimer() {
_connectTimer?.cancel();
_connectTimer = null;
}
/// 建立连接:先释放旧引擎,再创建 → 初始化 → 注册回调 → 加入频道
Future<void> _connect() async {
final seq = ++_connectSeq;
_cancelConnectTimer();
await _release();
if (_isDisposed || seq != _connectSeq) return;
if (widget.appId.isEmpty || widget.channelId.isEmpty) {
_updateState(AgoraPlayerState.error, message: 'appId / channelId 不能为空');
return;
}
_remoteUid = null;
_isJoined = false;
_firstFrameNotified = false;
_updateState(AgoraPlayerState.connecting);
_startConnectTimer();
try {
final engine = agora.createAgoraRtcEngine();
_engine = engine;
await engine.initialize(
agora.RtcEngineContext(
appId: widget.appId,
channelProfile: widget.channelProfile,
),
);
if (_isDisposed || seq != _connectSeq) return;
await engine.enableVideo();
if (_isDisposed || seq != _connectSeq) return;
_handler = _buildEventHandler();
engine.registerEventHandler(_handler!);
_log('加入频道: ${widget.channelId}');
await engine.joinChannel(
token: widget.token,
channelId: widget.channelId,
uid: widget.uid,
options: agora.ChannelMediaOptions(
channelProfile: widget.channelProfile,
clientRoleType: widget.clientRole,
autoSubscribeVideo: true,
autoSubscribeAudio: widget.subscribeAudio,
),
);
if (_isDisposed || seq != _connectSeq) return;
_log('✅ joinChannel 调用成功,等待远端首帧...');
} catch (e) {
_log('❌ 初始化失败: $e');
_updateState(AgoraPlayerState.error, message: '视频连接失败: $e');
}
}
agora.RtcEngineEventHandler _buildEventHandler() {
return agora.RtcEngineEventHandler(
onJoinChannelSuccess: (agora.RtcConnection connection, int elapsed) {
_log('✅ 加入频道成功: ${connection.channelId}, 耗时 ${elapsed}ms');
if (!mounted) return;
setState(() => _isJoined = true);
},
onUserJoined: (agora.RtcConnection connection, int rUid, int elapsed) {
_log('👤 远端用户加入: uid=$rUid');
if (!mounted) return;
// VideoViewController.remote 内部断言 uid != 0,uid 为 0 时构建会崩
if (rUid == 0) {
_log('⚠️ 远端 uid=0,无法用 remote View 渲染,已忽略');
return;
}
setState(() => _remoteUid ??= rUid);
},
onUserOffline: (
agora.RtcConnection connection,
int rUid,
agora.UserOfflineReasonType reason,
) {
_log('👋 远端用户离开: uid=$rUid, reason=$reason');
if (!mounted || _remoteUid != rUid) return;
setState(() => _remoteUid = null);
// 推流端掉线,回到等待态并重新计时
_updateState(AgoraPlayerState.connecting);
_startConnectTimer();
},
onRemoteVideoStateChanged: (
agora.RtcConnection connection,
int rUid,
agora.RemoteVideoState state,
agora.RemoteVideoStateReason reason,
int elapsed,
) {
if (!mounted) return;
_log('📹 远端视频状态: uid=$rUid, state=$state, reason=$reason');
if (state == agora.RemoteVideoState.remoteVideoStateDecoding) {
if (rUid != 0 && _remoteUid == null) {
setState(() => _remoteUid = rUid);
}
_updateState(AgoraPlayerState.playing);
if (!_firstFrameNotified) {
_firstFrameNotified = true;
widget.onFirstFrame?.call(rUid);
}
} else if (state == agora.RemoteVideoState.remoteVideoStateFrozen) {
_updateState(AgoraPlayerState.connecting);
} else if (state == agora.RemoteVideoState.remoteVideoStateFailed) {
_updateState(AgoraPlayerState.error, message: '远端视频流异常: $reason');
}
},
onLeaveChannel: (agora.RtcConnection connection, agora.RtcStats stats) {
_log('已离开频道: ${connection.channelId}');
if (!mounted) return;
setState(() {
_isJoined = false;
_remoteUid = null;
});
},
onError: (agora.ErrorCodeType err, String msg) {
_log('❌ SDK 错误: $err - $msg');
_updateState(AgoraPlayerState.error, message: '声网错误($err): $msg');
},
);
}
Future<void> _release() async {
final engine = _engine;
final handler = _handler;
_engine = null;
_handler = null;
if (engine == null) return;
try {
if (handler != null) {
engine.unregisterEventHandler(handler);
}
await engine.leaveChannel();
await engine.release();
_log('🗑️ 引擎已释放');
} catch (e) {
_log('⚠️ 释放引擎失败: $e');
}
}
@override
Widget build(BuildContext context) {
// 关键:远端 uid 未知时绝不能构建 remote View(SDK 内部有 uid != 0 断言)
final canRender = _engine != null && _isJoined && _remoteUid != null;
return Container(
color: widget.backgroundColor,
child: Stack(
fit: StackFit.expand,
children: [
if (canRender) _buildVideoView(),
if (_state != AgoraPlayerState.playing) _buildOverlay(canRender),
],
),
);
}
Widget _buildVideoView() {
return agora.AgoraVideoView(
controller: agora.VideoViewController.remote(
rtcEngine: _engine!,
canvas: agora.VideoCanvas(
uid: _remoteUid!,
renderMode: widget.renderMode,
),
connection: agora.RtcConnection(channelId: widget.channelId),
useAndroidSurfaceView: widget.useAndroidSurfaceView,
useFlutterTexture: widget.useFlutterTexture,
),
);
}
Widget _buildOverlay(bool canRender) {
if (_state == AgoraPlayerState.error) {
final message = _errorMessage ?? '视频加载失败';
if (widget.errorBuilder != null) {
return widget.errorBuilder!(context, message);
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.redAccent),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
message,
style: const TextStyle(color: Colors.white70, fontSize: 13),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
if (widget.showRetryButton) ...[
const SizedBox(height: 16),
ElevatedButton(
onPressed: _connect,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF165DFF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 8,
),
),
child: const Text('重试'),
),
],
],
),
);
}
final text = canRender ? widget.waitingText : widget.loadingText;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white),
),
const SizedBox(height: 12),
Text(text, style: const TextStyle(color: Colors.white70, fontSize: 13)),
],
),
);
}
}
/// 手动填写声网参数的弹窗,由 [AgoraVideoPlayer.showManualInput] 弹出
///
/// 结构:标题栏 + 三个输入框 + 「重新加载」按钮 + 16:9 视频区。
/// 点「重新加载」会递增 reloadToken,即使参数未变也会强制断开重连。
class _AgoraManualParamsDialog extends StatefulWidget {
final String title;
final String initialAppId;
final String initialChannelId;
final String initialToken;
const _AgoraManualParamsDialog({
required this.title,
this.initialAppId = '',
this.initialChannelId = '',
this.initialToken = '',
});
@override
State<_AgoraManualParamsDialog> createState() =>
_AgoraManualParamsDialogState();
}
class _AgoraManualParamsDialogState extends State<_AgoraManualParamsDialog> {
static const Color _primary = Color(0xFF165DFF);
static const Color _danger = Color(0xFFF53F3F);
static const Color _success = Color(0xFF00B42A);
late final TextEditingController _appIdCtrl = TextEditingController(
text: widget.initialAppId,
);
late final TextEditingController _channelCtrl = TextEditingController(
text: widget.initialChannelId,
);
late final TextEditingController _tokenCtrl = TextEditingController(
text: widget.initialToken,
);
/// 已生效的参数(点「重新加载」才同步,输入过程中不触发重连)
String _appId = '';
String _channelId = '';
String _token = '';
/// 递增即强制重连
int _reloadToken = 0;
/// 是否已发起过加载(未发起时视频区只放占位文案)
bool _started = false;
String? _hint;
AgoraPlayerState _state = AgoraPlayerState.idle;
@override
void initState() {
super.initState();
_appId = widget.initialAppId.trim();
_channelId = widget.initialChannelId.trim();
_token = widget.initialToken.trim();
// 调用方已经把参数给齐时直接开播,不用手点
_started = _appId.isNotEmpty && _channelId.isNotEmpty;
}
@override
void dispose() {
_appIdCtrl.dispose();
_channelCtrl.dispose();
_tokenCtrl.dispose();
super.dispose();
}
void _reload() {
FocusScope.of(context).unfocus();
final appId = _appIdCtrl.text.trim();
final channelId = _channelCtrl.text.trim();
if (appId.isEmpty || channelId.isEmpty) {
setState(() {
_hint = 'App ID 与 Channel 不能为空';
_started = false;
});
return;
}
setState(() {
_appId = appId;
_channelId = channelId;
_token = _tokenCtrl.text.trim();
_reloadToken++;
_started = true;
_hint = null;
});
debugPrint('🎥 [Agora] 手动重新加载: channel=$_channelId, seq=$_reloadToken');
}
String get _stateText {
switch (_state) {
case AgoraPlayerState.idle:
return '未连接';
case AgoraPlayerState.connecting:
return '连接中…';
case AgoraPlayerState.playing:
return '播放中';
case AgoraPlayerState.error:
return '连接失败';
}
}
Color get _stateColor {
switch (_state) {
case AgoraPlayerState.idle:
return Colors.white38;
case AgoraPlayerState.connecting:
return Colors.amberAccent;
case AgoraPlayerState.playing:
return _success;
case AgoraPlayerState.error:
return _danger;
}
}
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.black,
elevation: 0,
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(),
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildInput(
controller: _appIdCtrl,
label: 'App ID',
hint: '声网控制台的应用 ID',
),
_buildInput(
controller: _channelCtrl,
label: 'Channel',
hint: '频道名 / room_id',
),
_buildInput(
controller: _tokenCtrl,
label: 'Token',
hint: '未开启鉴权可留空',
maxLines: 2,
),
if (_hint != null) ...[
const SizedBox(height: 2),
Text(
_hint!,
style: const TextStyle(color: _danger, fontSize: 12),
),
],
const SizedBox(height: 10),
ElevatedButton.icon(
onPressed: _reload,
icon: const Icon(Icons.refresh, size: 18),
label: const Text('重新加载'),
style: ElevatedButton.styleFrom(
backgroundColor: _primary,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
const SizedBox(height: 10),
_buildStatusBar(),
],
),
),
AspectRatio(aspectRatio: 16 / 9, child: _buildVideoArea()),
const SizedBox(height: 8),
],
),
),
),
);
}
Widget _buildHeader() {
return Container(
height: 48,
padding: const EdgeInsets.only(left: 16),
color: const Color(0xFF2A2E34),
child: Row(
children: [
Expanded(
child: Text(
widget.title,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white70, size: 20),
onPressed: () => Navigator.pop(context),
),
],
),
);
}
Widget _buildStatusBar() {
return Row(
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: _stateColor, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(
'状态:$_stateText',
style: TextStyle(color: _stateColor, fontSize: 12),
),
const SizedBox(width: 12),
Flexible(
child: Text(
_started ? 'channel: $_channelId' : '未发起连接',
style: const TextStyle(color: Colors.white24, fontSize: 11),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
Widget _buildVideoArea() {
if (!_started) {
return Container(
color: Colors.black,
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 24),
child: const Text(
'填写 App ID / Channel 后点「重新加载」',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white38, fontSize: 13),
),
);
}
return AgoraVideoPlayer(
appId: _appId,
channelId: _channelId,
token: _token,
reloadToken: _reloadToken,
onStateChanged: (s) {
if (!mounted) return;
setState(() => _state = s);
},
);
}
Widget _buildInput({
required TextEditingController controller,
required String label,
String? hint,
int maxLines = 1,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: TextField(
controller: controller,
maxLines: maxLines,
style: const TextStyle(color: Colors.white, fontSize: 13),
cursorColor: _primary,
decoration: InputDecoration(
labelText: label,
labelStyle: const TextStyle(color: Colors.white54, fontSize: 13),
hintText: hint,
hintStyle: const TextStyle(color: Colors.white24, fontSize: 12),
isDense: true,
filled: true,
fillColor: const Color(0xFF1A1D21),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Colors.white12),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Colors.white12),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: _primary),
),
),
),
);
}
}

View File

@@ -95,10 +95,9 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource {
}) async {
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/cancelTask';
final body = {
'deviceId': deviceId,
'taskId': taskId,
'orgId': orgId,
'siteId': siteId,
'orgId': orgId,
'forceCancel': forceCancel,
};
_logger.logWithLevel('[cancelTask] 请求: POST $url');

View File

@@ -450,7 +450,7 @@ class DevicesCubit extends Cubit<DevicesState> {
state.copyWith(
isLoading: false,
workRecords: state.workRecords
?.where((record) => record != workName)
?.where((record) => record['workName'] != workName)
.toList(),
),
),

View File

@@ -1,12 +1,27 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import 'package:path_provider/path_provider.dart';
import 'package:xml/xml.dart';
/// GeoServer 基地址(公司端)
const String kGeoserverBase = 'http://1.95.137.212:7900/geoserver';
/// GeoServer 工作区名(后端 siteMapName 形如 workSpace:suzhou)
const String kGeoWorkspace = 'workSpace';
/// WMS 瓦片网络超时。大图层(如 neimeng/zhongke)一屏十几片、GeoServer 实时渲染排队,
/// 单片实际等待可能远超 30s;浏览器预览肯等所以最终能出图,App 若超时太短会集体放弃、
/// 返回透明图导致“一直出不来”。故放宽到 90s,并配合下方并发闸门分批取图。
const Duration kWmsTileTimeout = Duration(seconds: 90);
/// 各场站高清图层的真实范围(取自 GeoServer GetCapabilities,原生 EPSG:4326)
/// key = 后端下发的 siteMapName(形如 workSpace:suzhou)
final Map<String, LatLngBounds> kStationLayerBounds = {
@@ -43,7 +58,8 @@ final Map<String, LatLngBounds> kStationLayerBounds = {
/// 取某场站图层的范围中心点(用作进场默认定位);未知/为空返回 null
LatLng? stationLayerCenter(String? siteMapName) {
if (siteMapName == null) return null;
final b = kStationLayerBounds[siteMapName];
// 优先用动态拉取的范围(StationLayerRepo),其内部已含硬编码兜底
final b = StationLayerRepo.instance.bounds[siteMapName];
if (b == null) return null;
return LatLng((b.south + b.north) / 2, (b.west + b.east) / 2);
}
@@ -123,7 +139,7 @@ class GeoWmsProvider extends TileProvider {
'STYLES': '',
},
);
return NetworkImage(url.toString());
return CachedWmsImage(url.toString());
}
}
@@ -204,3 +220,376 @@ final List<TileLayer> kAllStationTileLayers = kStationLayerBounds.entries
),
)
.toList();
/// 带磁盘缓存 + 超时的 WMS 瓦片图:
/// 命中本地缓存直接解码(秒开);未命中则联网拉取(带超时),成功后写入缓存;
/// 失败/超时返回 1×1 透明图,避免异常刷屏。解决 WMS 实时渲染“每次都慢”的问题。
class CachedWmsImage extends ImageProvider<CachedWmsImage> {
CachedWmsImage(this.url) : _gen = _generation;
final String url;
/// 构造时捕获的缓存代次,纳入 key 相等性与磁盘文件名。
/// 手动刷新时 _generation++,旧瓦片(内存 key + 磁盘文件)立即全部失效,
/// 只影响 WMS 瓦片、不动 ESRI 底图缓存。
final int _gen;
static int _generation = 0;
static Future<Directory>? _cacheDirFuture;
static Future<Directory> _cacheDir() {
return _cacheDirFuture ??= () async {
try {
final base = await getTemporaryDirectory();
final dir = Directory('${base.path}${Platform.pathSeparator}wms_tiles');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
} catch (e) {
// 初始化失败不要把“坏 Future”永久缓存下来(否则后续所有瓦片都卡在这一步集体失败、
// 表现为图层全不出来),置空以便下次调用重试
_cacheDirFuture = null;
rethrow;
}
}();
}
/// URL + 代次 → 稳定短文件名(FNV-1a 32 位 + 长度,降低碰撞;代次前缀用于失效旧缓存)
static String _fileName(String url, int gen) {
var h = 0x811c9dc5;
for (final c in url.codeUnits) {
h ^= c;
h = (h * 0x01000193) & 0xFFFFFFFF;
}
return 'g${gen}_${h.toRadixString(16).padLeft(8, '0')}_${url.length}.png';
}
/// 清空全部 WMS 瓦片缓存(磁盘文件 + 递增代次使内存 key 失效)。
/// 后端更新了同名图层的影像内容后,调用它强制丢弃旧缓存、重新联网拉取最新图。
static Future<void> clearTileCache() async {
_generation++;
try {
final dir = await _cacheDir();
if (await dir.exists()) {
await for (final e in dir.list()) {
try {
await e.delete();
} catch (_) {}
}
}
debugPrint('🧹 [WMS] 已清空瓦片磁盘缓存,代次 → $_generation');
} catch (e) {
debugPrint('⚠️ [WMS] 清缓存失败: $e');
}
}
// ===== WMS 并发闸门 =====
// 大图层(如 neimeng/zhongke)一屏可达十几片瓦片,若同时发起会把 GeoServer 压到排队渲染,
// 每片等待时间暴涨、集体超时 → 全部返回透明图,表现为“一直出不来”。限制并发分批取,
// 单片能更快拿到渲染资源、在超时内完成(浏览器预览能出图,正是因为它肯等且不这么挤)。
static const int _maxConcurrentWms = 5;
static int _activeWms = 0;
static final List<Completer<void>> _wmsWaiters = [];
static Future<void> _acquireWmsSlot() async {
if (_activeWms < _maxConcurrentWms) {
_activeWms++;
return;
}
final c = Completer<void>();
_wmsWaiters.add(c);
await c.future; // 被唤醒即表示 slot 已由 _releaseWmsSlot 直接转交,_activeWms 不变
}
static void _releaseWmsSlot() {
if (_wmsWaiters.isNotEmpty) {
_wmsWaiters.removeAt(0).complete(); // 有等待者:slot 直接转交队首,_activeWms 保持
} else {
_activeWms--;
}
}
@override
Future<CachedWmsImage> obtainKey(ImageConfiguration configuration) =>
SynchronousFuture<CachedWmsImage>(this);
@override
ImageStreamCompleter loadImage(
CachedWmsImage key,
ImageDecoderCallback decode,
) {
return MultiFrameImageStreamCompleter(
codec: _loadAsync(decode),
scale: 1.0,
debugLabel: 'CachedWmsImage',
);
}
Future<ui.Codec> _loadAsync(ImageDecoderCallback decode) async {
final sw = Stopwatch()..start();
try {
final dir = await _cacheDir();
final file =
File('${dir.path}${Platform.pathSeparator}${_fileName(url, _gen)}');
// 1) 命中磁盘缓存 → 秒开
if (await file.exists()) {
final cached = await file.readAsBytes();
if (cached.isNotEmpty) {
return decode(await ui.ImmutableBuffer.fromUint8List(cached));
}
}
// 2) 未命中 → 联网拉取。先过并发闸门,避免大图层一次性压垮 GeoServer 导致集体超时
debugPrint('🌐 [WMS] MISS 联网取瓦片: ${_shortLayerTag(url)}');
await _acquireWmsSlot();
try {
final resp = await http.get(Uri.parse(url)).timeout(kWmsTileTimeout);
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
try {
await file.writeAsBytes(resp.bodyBytes, flush: false);
} catch (_) {}
debugPrint(
'✅ [WMS] 已缓存(${sw.elapsedMilliseconds}ms, ${resp.bodyBytes.length}B): ${_shortLayerTag(url)}',
);
return decode(await ui.ImmutableBuffer.fromUint8List(resp.bodyBytes));
}
debugPrint(
'⚠️ [WMS] 响应异常 status=${resp.statusCode}(${sw.elapsedMilliseconds}ms): ${_shortLayerTag(url)}',
);
} finally {
_releaseWmsSlot();
}
} catch (e) {
// 超时 / 网络 / 解码异常 → 透明兜底
debugPrint('❌ [WMS] 瓦片失败(${sw.elapsedMilliseconds}ms): $e');
}
return decode(
await ui.ImmutableBuffer.fromUint8List(TileProvider.transparentImage),
);
}
/// 从 URL 提取 LAYERS 参数做简短日志标签(避免整条 URL 刷屏)
static String _shortLayerTag(String url) {
final m = RegExp(r'LAYERS=([^&]+)').firstMatch(url);
return m?.group(1) ?? 'unknown';
}
@override
bool operator ==(Object other) =>
other is CachedWmsImage && other.url == url && other._gen == _gen;
@override
int get hashCode => Object.hash(url, _gen);
}
/// 场站图层范围仓库:
/// - 优先从 GeoServer WMS GetCapabilities 动态拉取各图层真实范围
/// (后台增/改/删图层,App 不发版即可跟上)
/// - 拉取或解析失败时,回退到内置 kStationLayerBounds(离线/异常仍可用)
class StationLayerRepo {
StationLayerRepo._();
static final StationLayerRepo instance = StationLayerRepo._();
Map<String, LatLngBounds> _bounds =
Map<String, LatLngBounds>.of(kStationLayerBounds);
bool _dynamicLoaded = false;
Future<void>? _inflight;
final List<void Function()> _listeners = [];
/// 当前生效的范围表(动态成功后为最新;否则为内置兜底)
Map<String, LatLngBounds> get bounds => _bounds;
/// 范围表是否来自动态拉取
bool get isDynamic => _dynamicLoaded;
void addListener(void Function() cb) {
if (!_listeners.contains(cb)) _listeners.add(cb);
}
void removeListener(void Function() cb) => _listeners.remove(cb);
void _notify() {
for (final cb in List<void Function()>.of(_listeners)) {
cb();
}
}
/// 拉取 GetCapabilities 刷新范围表;并发调用自动合并;失败保持兜底不变
Future<void> refresh() {
return _inflight ??= _doRefresh().whenComplete(() => _inflight = null);
}
Future<void> _doRefresh() async {
final sw = Stopwatch()..start();
try {
// 用工作区专用端点:只返回 workSpace 下图层,XML 小、快、稳(全局端点图层多易超时)
final url = Uri.parse('$kGeoserverBase/$kGeoWorkspace/wms').replace(
queryParameters: {
'SERVICE': 'WMS',
'VERSION': '1.1.1',
'REQUEST': 'GetCapabilities',
},
);
debugPrint('🌐 [StationLayerRepo] 拉取 GetCapabilities...');
final resp = await http.get(url).timeout(const Duration(seconds: 20));
if (resp.statusCode != 200) {
debugPrint(
'⚠️ [StationLayerRepo] status=${resp.statusCode},保持兜底',
);
return;
}
final parsed = parseStationLayerBounds(resp.body);
debugPrint(
'📦 [StationLayerRepo] 解析到 ${parsed.length} 个图层(${sw.elapsedMilliseconds}ms): ${parsed.keys.join(', ')}',
);
if (parsed.isEmpty) {
debugPrint('⚠️ [StationLayerRepo] 解析为空,保持内置兜底');
return;
}
// 数量防护:动态解析出的图层数若明显少于内置兜底(如网络截断只解析到个别图层),
// 直接整体替换会让其余图层凭空消失。改用“兜底打底 + 动态覆盖同名”的合并策略,
// 既跟上后端最新范围,又保证已有图层不会因一次异常响应而集体消失。
if (parsed.length < kStationLayerBounds.length) {
debugPrint(
'⚠️ [StationLayerRepo] 动态解析图层数(${parsed.length}) < 兜底(${kStationLayerBounds.length}),采用合并策略防止图层消失',
);
_bounds = {...kStationLayerBounds, ...parsed};
} else {
_bounds = parsed;
debugPrint('✅ [StationLayerRepo] 已切换为动态范围');
}
_dynamicLoaded = true;
_notify();
} catch (e) {
// 保持兜底,不打断地图
debugPrint(
'❌ [StationLayerRepo] GetCapabilities 失败(${sw.elapsedMilliseconds}ms),保持兜底: $e',
);
}
}
/// 基于当前范围表构建全部场站叠加图层(滑到哪个场站区域就叠加哪个)
List<TileLayer> buildLayers({double minZoom = 16, double maxZoom = 21}) {
return _bounds.entries.map((entry) {
// 自愈兜底:万一范围表里仍残留非法 bounds(历史污染等),回退到内置兜底的同名范围,
// 避免生成“框不住任何瓦片”的坏图层导致该场站整体不显示
final b = _isValidLayerBounds(entry.value)
? entry.value
: (kStationLayerBounds[entry.key] ?? entry.value);
return TileLayer(
tileProvider: GeoWmsProvider(
layerName: entry.key,
bounds: b,
maxLevel: maxZoom.toInt(),
),
userAgentPackageName: 'com.example.app',
minZoom: minZoom,
maxZoom: maxZoom,
maxNativeZoom: maxZoom.toInt(),
tileBounds: b,
);
}).toList();
}
}
/// 校验图层范围是否为合法且有意义的经纬度矩形:
/// - 纬度 ∈ [-90,90]、经度 ∈ [-180,180]
/// - 南北/东西跨度 > 0 且 ≤ 5°(场站地块都是几百米级小范围,跨度过大必是投影错误/脏数据)
/// 非法范围一旦混入范围表,会让 getImage 的相交判断恒为 false → 瓦片全透明 → 图层“整体消失”,
/// 这正是“放大也不出来、只能退出重登(页面重建重置图层列表)才恢复”的根因。
bool _isValidLayerBounds(LatLngBounds b) {
final s = b.south;
final n = b.north;
final w = b.west;
final e = b.east;
if (s < -90 || s > 90 || n < -90 || n > 90) return false;
if (w < -180 || w > 180 || e < -180 || e > 180) return false;
final dLat = n - s;
final dLng = e - w;
if (dLat <= 0 || dLng <= 0) return false;
if (dLat > 5 || dLng > 5) return false;
return true;
}
/// 解析 WMS GetCapabilities,提取 workSpace 下每个图层的 name → EPSG:4326 范围。
/// 同时兼容 1.1.1(LatLongBoundingBox)与 1.3.0(EX_GeographicBoundingBox)。
Map<String, LatLngBounds> parseStationLayerBounds(String xmlStr) {
final result = <String, LatLngBounds>{};
try {
final doc = XmlDocument.parse(xmlStr);
for (final layer in doc.findAllElements('Layer')) {
final nameEls = layer.findElements('Name');
if (nameEls.isEmpty) continue;
final rawName = nameEls.first.innerText.trim();
// 兼容两种命名:全局端点返回 workSpace:xxx;工作区端点可能只返回裸名 xxx
final String name;
if (rawName.startsWith('$kGeoWorkspace:')) {
name = rawName;
} else if (!rawName.contains(':')) {
name = '$kGeoWorkspace:$rawName';
} else {
continue; // 其它工作区图层,跳过
}
LatLngBounds? b;
// WMS 1.1.1
final bbEls = layer.findElements('LatLongBoundingBox');
if (bbEls.isNotEmpty) {
final bb = bbEls.first;
final minx = double.tryParse(bb.getAttribute('minx') ?? '');
final miny = double.tryParse(bb.getAttribute('miny') ?? '');
final maxx = double.tryParse(bb.getAttribute('maxx') ?? '');
final maxy = double.tryParse(bb.getAttribute('maxy') ?? '');
if (minx != null &&
miny != null &&
maxx != null &&
maxy != null &&
maxx > minx &&
maxy > miny) {
b = LatLngBounds(LatLng(miny, minx), LatLng(maxy, maxx));
}
}
// WMS 1.3.0 兜底
if (b == null) {
final exEls = layer.findElements('EX_GeographicBoundingBox');
if (exEls.isNotEmpty) {
final ex = exEls.first;
double? read(String tag) {
final els = ex.findElements(tag);
return els.isEmpty
? null
: double.tryParse(els.first.innerText.trim());
}
final w = read('westBoundLongitude');
final e = read('eastBoundLongitude');
final n = read('northBoundLatitude');
final s = read('southBoundLatitude');
if (w != null && e != null && n != null && s != null && e > w && n > s) {
b = LatLngBounds(LatLng(s, w), LatLng(n, e));
}
}
}
if (b != null) {
if (_isValidLayerBounds(b)) {
result[name] = b;
} else {
// 范围非法(经纬度越界 / 跨度过大或为 0)→ 丢弃该图层,
// 避免污染范围表导致瓦片框不住、图层整体消失
debugPrint(
'⚠️ [StationLayerRepo] 图层 $name 范围非法已丢弃: S=${b.south} N=${b.north} W=${b.west} E=${b.east}',
);
}
}
}
} catch (_) {
// 解析失败返回空表,调用方保持兜底
}
return result;
}

View File

@@ -141,6 +141,8 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
bool _isToastShowing = false;
final MapController _mapController = MapController();
/// 全部场站高清叠加图层:初始用内置兜底,GetCapabilities 拉取成功后自动替换为动态范围
List<TileLayer> _stationLayers = kAllStationTileLayers;
// 初始化轨迹管理器(泛型指定为LatLng)
late final TracePoint<PlotPoint> _traceManager;
late String workMode = "覆盖模式"; // 作业模式:默认值为"覆盖模式"
@@ -274,9 +276,24 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
}
});
// 🌐 场站高清图层:监听动态范围更新 + 触发一次 GetCapabilities 刷新
StationLayerRepo.instance.addListener(_onStationLayersUpdated);
StationLayerRepo.instance.refresh();
// 🔄 进场刷新场站数据:避免 siteMapName/默认中心沿用上次缓存(无需退回首页重新请求接口)
sl<SiteCubit>().reloadSites();
debugPrint('✅ [MapPage] initState 完成');
}
/// GeoServer 动态范围拉取完成 → 用最新范围重建叠加图层
void _onStationLayersUpdated() {
if (!mounted) return;
setState(() {
_stationLayers = StationLayerRepo.instance.buildLayers();
});
}
// 🔥 核心修复:监听路径规划应答流
void _setupPathPlanningListener() {
debugPrint('🔗 [MapPage] 开始监听路径规划指令应答 (CMD: 0x01)');
@@ -1607,6 +1624,37 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
_mapController.move(_currentLatLng!, 17);
}
/// 手动刷新高清场站图层(不动用户的作业/障碍物绘制数据):
/// ① 清 WMS 瓦片缓存(拿后端最新影像)② 重拉场站接口(更新 siteMapName 映射)
/// ③ 重拉 GetCapabilities(更新图层范围数组)④ 重建图层 ⑤ 定位到最新场站中心
Future<void> _onRefreshStationLayers() async {
debugPrint('🔄 [刷新图层] 开始手动刷新高清场站图层...');
_showPageToast(message: '正在刷新高清图层...', type: ToastType.loading);
try {
await CachedWmsImage.clearTileCache();
await sl<SiteCubit>().reloadSites();
await StationLayerRepo.instance.refresh();
if (!mounted) return;
setState(() {
_stationLayers = StationLayerRepo.instance.buildLayers();
});
final c = _getDefaultValidCenter();
_mapController.move(c, 18);
_cancelAllToast();
_showPageToast(message: '已刷新到最新图层', type: ToastType.success);
debugPrint('✅ [刷新图层] 完成,定位到 $c');
} catch (e) {
debugPrint('❌ [刷新图层] 失败: $e');
if (!mounted) return;
_cancelAllToast();
final msg = e.toString();
_showPageToast(
message: '刷新失败: ${msg.length > 50 ? msg.substring(0, 50) : msg}',
type: ToastType.error,
);
}
}
// ========== 障碍物模式:完成当前障碍物绘制 ==========
void _completeObstacle() {
debugPrint(
@@ -1716,6 +1764,7 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
@override
void dispose() {
StationLayerRepo.instance.removeListener(_onStationLayersUpdated);
_traceManager.reset();
_mapController.dispose();
_pathPlanningSub?.cancel(); // 🔥 取消订阅
@@ -2178,10 +2227,10 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
),
/// GeoServer 全部场站无人机高清影像叠加层(WGS84 基准,无偏移)
/// 一次性叠加所有场站图层:滑到哪个场站区域(zoom>=16)就自动显示哪个高清图
/// 每层各自 tileBounds,框外瓦片不请求,开销极小;苏州等手动滑过去即可叠加
/// 当前 WMS 兜底(原生 EPSG:4326);公司补配 GWC EPSG:3857 后改 geoserver_suzhou_layer.dart
...kAllStationTileLayers,
/// 图层范围优先取自 GeoServer GetCapabilities(动态),失败回退内置兜底
/// 滑到哪个场站区域(zoom>=16)就自动显示哪个高清图;每层各自 tileBounds,框外不请求
/// WMS 瓦片带磁盘缓存+超时(CachedWmsImage),二次进入秒开
..._stationLayers,
//if (isDjMapShow) DJITileLayer(stationName: _currentStation.name, mapController: _mapController),
// 绘制path折线(Line模式)
@@ -4542,6 +4591,37 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
),
),
/// 悬浮“刷新高清图层”按钮:后端改了图层/影像/场站映射后,
/// 点它强制拉最新(清瓦片缓存 + 重拉场站接口 + 重拉 GetCapabilities + 重新定位)
Positioned(
top: 110,
left: 10,
child: GestureDetector(
onTap: _onRefreshStationLayers,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.8),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black12,
blurRadius: 3,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: const Icon(
Icons.refresh,
color: Colors.black87,
size: 22,
),
),
),
),
/// 核心:地图正中心固定定位标
if (_currentRobotMode == RobotMode.point)
Positioned(
@@ -4900,18 +4980,22 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
.deleteWorkRecord(
deletedPlot.plotName,
);
final userId =
context
.read<AppUserCubit>()
// 🔥 修复:删除后按“当前全局场站”重查,与打开列表(onListBox)走同一接口 selectBySiteId
// 原逻辑用 loadWorkRecords(userId) 走 selectByUserId(按登录用户查),
// 与列表口径不一致,导致删除后重查的数据不对;重新点列表才恢复正常。
final selectedSite =
sl<SiteCubit>()
.state
.user
?.userId ??
"";
.selectedSite;
if (selectedSite != null) {
await context
.read<DevicesCubit>()
.loadWorkRecords(userId);
.loadWorkRecordsBySiteId(
selectedSite.id,
);
}
setState(() {});
if (mounted) setState(() {});
});
},
),

View File

@@ -17,6 +17,7 @@ import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/power_tre
import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/plant_overview_card.dart';
import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/tcp_status_indicator.dart';
import 'package:maibu_satabot_v2/components/device_status_modal.dart';
import 'package:maibu_satabot_v2/components/agora_video_player.dart';
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_bloc.dart';
import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart';
@@ -31,6 +32,14 @@ class HomeV2Page extends StatefulWidget {
class _HomeV2PageState extends State<HomeV2Page> {
late HomeV2Bloc _bloc;
// ===== 电站实时视频(声网)默认测试配置 =====
// 弹窗打开就用这组值自动开播,输入框仍保留,可随时改完点「重新加载」
// TODO: 接口对接后改为后端下发的真实值
static const String _agoraAppId = '875f6494d87548e783fefbf33f598c97';
static const String _agoraChannel = '36test-01';
static const String _agoraToken =
'007eJxTYLhsYtaz3e2gTMKZ27JzrOs/KB3OTFaYKfGX2+/Y6zem5TMUGCzMTdPMTCxNUoAME4tUcwvjtNS0pDRj4zRTS4tkS3P55PlZDYGMDGsfyTEyMkAgiM/JYGxWklpcomtgyMAAAN9rIEI=';
@override
void initState() {
super.initState();
@@ -203,6 +212,7 @@ class _HomeV2PageState extends State<HomeV2Page> {
const SizedBox(height: 10),
PlantOverviewCard(
overview: state.homeData.plantOverview,
onTap: _showPlantVideo,
),
const SizedBox(height: 10),
WorkOrderCard(stats: state.homeData.workOrderStats),
@@ -247,6 +257,19 @@ class _HomeV2PageState extends State<HomeV2Page> {
}
}
/// 点击「电站概览」卡片 → 弹出声网视频组件
///
/// 默认带上测试配置自动开播,同时保留三个输入框可手动修改
void _showPlantVideo() {
AgoraVideoPlayer.showManualInput(
context,
title: '电站实时视频',
initialAppId: _agoraAppId,
initialChannelId: _agoraChannel,
initialToken: _agoraToken,
);
}
void _showPlantSelector() {
if (_bloc.state is! HomeV2Loaded) return;

View File

@@ -6,10 +6,21 @@ import 'package:maibu_satabot_v2/features/v2/home/domain/entities/home_entity.da
class PlantOverviewCard extends StatelessWidget {
final PlantOverview overview;
const PlantOverviewCard({super.key, required this.overview});
/// 点击整张卡片的回调(如弹出电站实时视频)
final VoidCallback? onTap;
const PlantOverviewCard({super.key, required this.overview, this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: _buildContent(context),
);
}
Widget _buildContent(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View File

@@ -53,12 +53,13 @@ class MyRemoteDataSourceImpl implements MyRemoteDataSource {
title: '帮助与反馈',
icon: 'help',
),
MenuItemModel(
id: 'about_us',
title: '关于我们',
icon: 'info',
subTitle: 'V1.0.1',
),
// 关于我们(已注释掉,其位置由“退出登录”占用)
// MenuItemModel(
// id: 'about_us',
// title: '关于我们',
// icon: 'info',
// subTitle: 'V1.0.1',
// ),
];
}

View File

@@ -1,10 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter/services.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/core/storage/user_storage.dart';
import 'package:maibu_satabot_v2/core/theme/AppTheme.dart';
import 'package:maibu_satabot_v2/core/theme/theme_cubit.dart';
import 'package:maibu_satabot_v2/features/auth/presentation/bloc/auth_cubit.dart';
import 'package:maibu_satabot_v2/features/v2/my/presentation/cubit/my_cubit.dart';
import 'package:maibu_satabot_v2/features/v2/my/presentation/states/my_state.dart';
import 'package:maibu_satabot_v2/features/v2/my/di/my_di.dart';
@@ -125,8 +130,13 @@ class _MyPageContent extends StatelessWidget {
child: ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: state.menuItems.length,
// 末尾追加退出登录,占据“关于我们”原来的位置
itemCount: state.menuItems.length + 1,
itemBuilder: (context, index) {
// 退出登录(列表最后一项,在可滚动区域内避免底部溢出)
if (index == state.menuItems.length) {
return _buildLogoutButton(context);
}
final item = state.menuItems[index];
// 🔥 夜间模式开关值以全局 ThemeCubit 状态为准
final displayItem = item.id == 'night_mode'
@@ -221,4 +231,61 @@ class _MyPageContent extends StatelessWidget {
),
);
}
/// 退出登录(作为菜单列表最后一项,占据“关于我们”原来的位置)
Widget _buildLogoutButton(BuildContext context) {
return GestureDetector(
onTap: () {
_showLogoutDialog(context);
},
behavior: HitTestBehavior.opaque,
child: SizedBox(
height: 56,
child: Center(
child: Text(
AppLocalizations.of(context).translate('login.logout'),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Color(0xFFF53F3F),
),
),
),
),
);
}
/// 显示退出登录确认对话框
void _showLogoutDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context).translate('login.logout')),
content: Text(
AppLocalizations.of(context).translate('login.confirm_logout'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context).translate('my_v2.cancel')),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
// 执行退出登录逻辑
await GetIt.I<UserStorage>().deleteUser();
GetIt.I<AuthCubit>().logout();
context.go(RoutePaths.login);
},
style: TextButton.styleFrom(
foregroundColor: const Color(0xFFF53F3F),
),
child: Text(
AppLocalizations.of(context).translate('my_v2.confirm'),
),
),
],
),
);
}
}

View File

@@ -77,9 +77,9 @@ class _SystemSettingsPageState extends State<SystemSettingsPage> {
// 版本更新
_buildUpdateSection(),
const SizedBox(height: 12),
// 退出登录
_buildLogoutButton(context),
const SizedBox(height: 20),
// 退出登录(已挪至“我的”页面最下边,此处注释掉)
// _buildLogoutButton(context),
// const SizedBox(height: 20),
],
),
);
@@ -421,70 +421,70 @@ class _SystemSettingsPageState extends State<SystemSettingsPage> {
);
}
/// 退出登录按钮
Widget _buildLogoutButton(BuildContext context) {
return GestureDetector(
onTap: () {
_showLogoutDialog(context);
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: context.appColors.cardBackground,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: context.appColors.cardShadow,
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Center(
child: Text(
AppLocalizations.of(context).translate('login.logout'),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Color(0xFFF53F3F),
),
),
),
),
);
}
/// 退出登录按钮(已挪至“我的”页面最下边,此处注释掉)
// Widget _buildLogoutButton(BuildContext context) {
// return GestureDetector(
// onTap: () {
// _showLogoutDialog(context);
// },
// child: Container(
// padding: const EdgeInsets.symmetric(vertical: 16),
// decoration: BoxDecoration(
// color: context.appColors.cardBackground,
// borderRadius: BorderRadius.circular(12),
// boxShadow: [
// BoxShadow(
// color: context.appColors.cardShadow,
// blurRadius: 8,
// offset: Offset(0, 2),
// ),
// ],
// ),
// child: Center(
// child: Text(
// AppLocalizations.of(context).translate('login.logout'),
// style: const TextStyle(
// fontSize: 16,
// fontWeight: FontWeight.w500,
// color: Color(0xFFF53F3F),
// ),
// ),
// ),
// ),
// );
// }
/// 显示退出登录确认对话框
void _showLogoutDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context).translate('login.logout')),
content: Text(
AppLocalizations.of(context).translate('login.confirm_logout'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context).translate('my_v2.cancel')),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
// 执行退出登录逻辑
await GetIt.I<UserStorage>().deleteUser();
GetIt.I<AuthCubit>().logout();
context.go(RoutePaths.login);
},
style: TextButton.styleFrom(
foregroundColor: const Color(0xFFF53F3F),
),
child: Text(
AppLocalizations.of(context).translate('my_v2.confirm'),
),
),
],
),
);
}
/// 显示退出登录确认对话框(已挪至“我的”页面最下边,此处注释掉)
// void _showLogoutDialog(BuildContext context) {
// showDialog(
// context: context,
// builder: (context) => AlertDialog(
// title: Text(AppLocalizations.of(context).translate('login.logout')),
// content: Text(
// AppLocalizations.of(context).translate('login.confirm_logout'),
// ),
// actions: [
// TextButton(
// onPressed: () => Navigator.pop(context),
// child: Text(AppLocalizations.of(context).translate('my_v2.cancel')),
// ),
// TextButton(
// onPressed: () async {
// Navigator.pop(context);
// // 执行退出登录逻辑
// await GetIt.I<UserStorage>().deleteUser();
// GetIt.I<AuthCubit>().logout();
// context.go(RoutePaths.login);
// },
// style: TextButton.styleFrom(
// foregroundColor: const Color(0xFFF53F3F),
// ),
// child: Text(
// AppLocalizations.of(context).translate('my_v2.confirm'),
// ),
// ),
// ],
// ),
// );
// }
}