Files
flutterApp/lib/components/agora_video_player.dart

948 lines
29 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 '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),
),
),
),
);
}
}