删除未使用的相机和记录类来重构记录功能;更新pubspec中的依赖项。Yaml为本地插件路径;简化MainActivity和相关类,以提高性能和可维护性。
This commit is contained in:
@@ -1,15 +1,16 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:apivideo_live_stream/apivideo_live_stream.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:recording_tool/app/router/app_navigator.dart';
|
||||
import 'package:recording_tool/core/platform/app_platform_info.dart';
|
||||
import 'package:recording_tool/core/platform/device_health_checker.dart';
|
||||
import 'package:recording_tool/features/recording/dialog/dialog-record.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_recording.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_recording_context.dart';
|
||||
import 'package:recording_tool/features/recording/platform/recording_platform.dart';
|
||||
import 'package:recording_tool/features/recording/utils/recording_display_name.dart';
|
||||
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/widget_camera_preview.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/widget_record_footer.dart';
|
||||
@@ -19,11 +20,20 @@ import 'package:recording_tool/features/recording/widgets/widget_recording_hud.d
|
||||
import 'package:recording_tool/features/recording/widgets/widget_recording_loading_overlay.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/widget_recording_saved_dialog.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/widget_recording_touch_lock_overlay.dart';
|
||||
import 'package:recording_tool/features/scan_qrcode/pages/page_scan_qrcode.dart';
|
||||
import 'package:recording_tool/features/scan_qrcode/utils/rtmp_stream_target.dart';
|
||||
import 'package:recording_tool/shared/widgets/widgets.dart';
|
||||
|
||||
/// 录制页入口
|
||||
class RecordingPage extends ConsumerStatefulWidget {
|
||||
const RecordingPage({super.key});
|
||||
const RecordingPage({
|
||||
super.key,
|
||||
required this.recordingContext,
|
||||
required this.streamUrl,
|
||||
});
|
||||
|
||||
final RecordingContext recordingContext;
|
||||
final String streamUrl;
|
||||
|
||||
@override
|
||||
/// 创建页面状态
|
||||
@@ -31,13 +41,60 @@ class RecordingPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
late final ApiVideoLiveStreamController _streamController;
|
||||
var _immersiveApplied = false;
|
||||
var _previewReady = false;
|
||||
var _stoppingByUser = false;
|
||||
var _controllerDisposed = false;
|
||||
String? _mainCameraId;
|
||||
String? _ultraWideCameraId;
|
||||
double _ultraWideZoomRatio = 1.0;
|
||||
|
||||
@override
|
||||
/// 首帧后初始化录制流程
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _bootstrap());
|
||||
_streamController = ApiVideoLiveStreamController(
|
||||
initialAudioConfig: AudioConfig(bitrate: 128000),
|
||||
initialVideoConfig: VideoConfig.withDefaultBitrate(
|
||||
resolution: Resolution.RESOLUTION_1080,
|
||||
fps: 30,
|
||||
),
|
||||
onConnectionSuccess: () => {debugPrint('推流成功')},
|
||||
onConnectionFailed: (reason) {
|
||||
debugPrint('推流失败');
|
||||
|
||||
if (!mounted) return;
|
||||
ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.markRecordingStartFailed('推流失败: $reason');
|
||||
},
|
||||
onDisconnection: () {
|
||||
debugPrint('推流连接已断开');
|
||||
|
||||
if (!mounted || _stoppingByUser) return;
|
||||
final isRecording = ref.read(recordingViewModelProvider).isRecording;
|
||||
if (isRecording) {
|
||||
ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.markRecordingStopped(errorMessage: '推流连接已断开');
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
debugPrint('推流失败:${error.toString()}');
|
||||
if (!mounted) return;
|
||||
ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.setError(error.toString());
|
||||
},
|
||||
);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.setRecordingContext(widget.recordingContext);
|
||||
_bootstrap();
|
||||
});
|
||||
}
|
||||
|
||||
/// 检查设备健康状态并弹窗提示
|
||||
@@ -55,23 +112,81 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 页面启动:健康检查、读剪贴板、进入录制模式、准备相机会话
|
||||
/// 页面启动:健康检查、进入录制模式、准备相机会话
|
||||
Future<void> _bootstrap() async {
|
||||
await _checkAndShowDeviceHealthAlerts();
|
||||
if (!mounted) return;
|
||||
|
||||
final clipboardResult = await ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
if (!mounted) return;
|
||||
if (clipboardResult == ClipboardReadResult.invalid) {
|
||||
AppToast.show('无选手信息');
|
||||
}
|
||||
await _enterRecordingMode();
|
||||
// Allow PlatformView to attach before binding CameraX preview.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
if (!mounted) return;
|
||||
await ref.read(recordingViewModelProvider.notifier).prepareSession();
|
||||
if (!mounted) return;
|
||||
await _initializeLiveStreamPreview();
|
||||
}
|
||||
|
||||
Future<void> _initializeLiveStreamPreview() async {
|
||||
try {
|
||||
await _streamController.initialize();
|
||||
if (!mounted) return;
|
||||
setState(() => _previewReady = true);
|
||||
ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.setPreviewReady(ready: true);
|
||||
await _loadBackCameraCapabilities();
|
||||
} on PlatformException catch (error) {
|
||||
if (!mounted) return;
|
||||
ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.setPreviewReady(
|
||||
ready: false,
|
||||
errorMessage: error.message ?? '相机预览初始化失败',
|
||||
);
|
||||
if (mounted) setState(() => _previewReady = false);
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.setPreviewReady(ready: false, errorMessage: '相机预览初始化失败: $error');
|
||||
setState(() => _previewReady = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadBackCameraCapabilities() async {
|
||||
try {
|
||||
final cameras = await _streamController.getBackCameras();
|
||||
if (cameras.isEmpty) return;
|
||||
final main = cameras.first;
|
||||
final widest = cameras.reduce(
|
||||
(current, next) =>
|
||||
next.horizontalFov > current.horizontalFov ? next : current,
|
||||
);
|
||||
_mainCameraId = main.cameraId;
|
||||
if (widest.cameraId != main.cameraId &&
|
||||
widest.horizontalFov > main.horizontalFov * 1.08 &&
|
||||
main.minFocalLength > 0) {
|
||||
_ultraWideCameraId = widest.cameraId;
|
||||
_ultraWideZoomRatio = (widest.minFocalLength / main.minFocalLength)
|
||||
.clamp(0.4, 0.9)
|
||||
.toDouble();
|
||||
}
|
||||
ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.updateZoomCapabilities(
|
||||
zoomRatio: 1.0,
|
||||
minZoomRatio: _ultraWideCameraId == null
|
||||
? 1.0
|
||||
: _ultraWideZoomRatio,
|
||||
maxZoomRatio: 1.0,
|
||||
);
|
||||
} catch (_) {
|
||||
ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.updateZoomCapabilities(
|
||||
zoomRatio: 1.0,
|
||||
minZoomRatio: 1.0,
|
||||
maxZoomRatio: 1.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Android 进入沉浸式全屏
|
||||
@@ -82,43 +197,6 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
_immersiveApplied = true;
|
||||
}
|
||||
|
||||
/// 解析保存成功弹窗的标题文案
|
||||
String _savedDialogSessionTitle(
|
||||
RecordingModel recordingInfo,
|
||||
String? savedName,
|
||||
) {
|
||||
final clipboard = recordingInfo.clipboardRecordingModel;
|
||||
if (recordingInfo.hasValidClipboardInfo &&
|
||||
clipboard.title.trim().isNotEmpty) {
|
||||
return clipboard.title.trim();
|
||||
}
|
||||
if (savedName != null && savedName.isNotEmpty) {
|
||||
return resolveRecordingDisplayName(savedName);
|
||||
}
|
||||
return '录制完成';
|
||||
}
|
||||
|
||||
/// 从剪贴板粘贴赛事信息(与 header「粘贴选手信息」一致)。
|
||||
Future<void> _pasteEventInfo() async {
|
||||
final result = await ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
if (!mounted) return;
|
||||
if (result != ClipboardReadResult.success) {
|
||||
AppToast.show('无选手信息');
|
||||
}
|
||||
}
|
||||
|
||||
/// 无选手信息时弹窗提示
|
||||
Future<void> _showNoPlayerInfoDialog() {
|
||||
return RecordDialog.showSingle(
|
||||
context,
|
||||
title: '无选手信息!',
|
||||
buttonText: '粘贴',
|
||||
onPressed: _pasteEventInfo,
|
||||
);
|
||||
}
|
||||
|
||||
/// 根据缺失权限生成弹窗文案。
|
||||
String _recordingPermissionDialogTitle(RecordingRequiredPermissions result) {
|
||||
if (!result.cameraGranted && !result.microphoneGranted) {
|
||||
@@ -153,65 +231,109 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 点击开始录制:校验剪贴板、权限与健康状态
|
||||
/// 点击开始录制:校验推流地址、权限与健康状态
|
||||
Future<void> _onStartRecording() async {
|
||||
final recordingInfo = ref.read(recordingViewModelProvider);
|
||||
if (!recordingInfo.hasClipboardFilename) {
|
||||
await _showNoPlayerInfoDialog();
|
||||
if (widget.streamUrl.trim().isEmpty) {
|
||||
AppToast.show('推流地址为空,请重新进入录制页');
|
||||
return;
|
||||
}
|
||||
if (!await _ensureRecordingPermissions()) return;
|
||||
if (!mounted) return;
|
||||
await _checkAndShowDeviceHealthAlerts();
|
||||
if (!mounted) return;
|
||||
await ref.read(recordingViewModelProvider.notifier).startRecording();
|
||||
final viewModel = ref.read(recordingViewModelProvider.notifier);
|
||||
viewModel.markStartingRecording();
|
||||
try {
|
||||
final target = RtmpStreamTarget.parse(widget.streamUrl);
|
||||
await _streamController.startStreaming(
|
||||
streamKey: target.streamKey,
|
||||
url: target.url,
|
||||
);
|
||||
if (!mounted) return;
|
||||
await viewModel.markRecordingStarted(target.fullUrl);
|
||||
} on FormatException catch (error) {
|
||||
viewModel.markRecordingStartFailed(error.message);
|
||||
AppToast.show(error.message);
|
||||
} on PlatformException catch (error) {
|
||||
final message = error.message ?? error.code;
|
||||
viewModel.markRecordingStartFailed('推流失败: $message');
|
||||
AppToast.show('推流失败: $message');
|
||||
} catch (error) {
|
||||
viewModel.markRecordingStartFailed('推流失败: $error');
|
||||
AppToast.showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止录制并按结果显示保存提示。
|
||||
/// 停止录制并按结果显示推流提示。
|
||||
Future<void> _stopRecordingAndShowResult() async {
|
||||
await ref.read(recordingViewModelProvider.notifier).stopRecording();
|
||||
if (!mounted) return;
|
||||
final latest = ref.read(recordingViewModelProvider).session;
|
||||
if (latest.fileSaveFailed) {
|
||||
if (latest.segmentOutputPaths.isNotEmpty) {
|
||||
AppToast.show('视频合并失败,已为你保存分段文件,可在相册中查看');
|
||||
} else {
|
||||
AppToast.show(latest.errorMessage ?? '保存到文件夹失败,请检查文件保存权限');
|
||||
}
|
||||
final viewModel = ref.read(recordingViewModelProvider.notifier);
|
||||
_stoppingByUser = true;
|
||||
try {
|
||||
await _streamController.stopStreaming();
|
||||
if (!mounted) return;
|
||||
await viewModel.markRecordingStopped();
|
||||
} on PlatformException catch (error) {
|
||||
final message = error.message ?? error.code;
|
||||
await viewModel.markRecordingStopped(errorMessage: '停止推流失败: $message');
|
||||
AppToast.show('停止推流失败: $message');
|
||||
return;
|
||||
} catch (error) {
|
||||
await viewModel.markRecordingStopped(errorMessage: '停止推流失败: $error');
|
||||
AppToast.showError(error);
|
||||
return;
|
||||
} finally {
|
||||
_stoppingByUser = false;
|
||||
}
|
||||
await _showRecordingFinishedDialogIfNeeded();
|
||||
}
|
||||
|
||||
Future<void> _setCameraZoomRatio(double ratio) async {
|
||||
final viewModel = ref.read(recordingViewModelProvider.notifier);
|
||||
final targetCameraId = ratio < 1.0 ? _ultraWideCameraId : _mainCameraId;
|
||||
if (targetCameraId == null) {
|
||||
viewModel.setError('当前设备不支持该镜头');
|
||||
return;
|
||||
}
|
||||
await _showRecordingSavedDialogIfNeeded();
|
||||
viewModel.markLensSwitching(true);
|
||||
try {
|
||||
await _streamController.setCameraId(targetCameraId);
|
||||
viewModel.setZoomRatioValue(ratio < 1.0 ? _ultraWideZoomRatio : 1.0);
|
||||
} on PlatformException catch (error) {
|
||||
final isRecording = ref.read(recordingViewModelProvider).isRecording;
|
||||
final message = isRecording
|
||||
? '推流中切换镜头失败,请停止后重试'
|
||||
: (error.message ?? '切换镜头失败,请重试');
|
||||
viewModel.setError(message);
|
||||
} catch (_) {
|
||||
final isRecording = ref.read(recordingViewModelProvider).isRecording;
|
||||
viewModel.setError(isRecording ? '推流中切换镜头失败,请停止后重试' : '切换镜头失败,请重试');
|
||||
} finally {
|
||||
viewModel.markLensSwitching(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空剪贴板信息,准备新一轮录制
|
||||
void _clearClipboardForNewRound() {
|
||||
final notifier = ref.read(recordingViewModelProvider.notifier);
|
||||
notifier.resetClipboardInfo();
|
||||
notifier.clearSavedRecordingResult();
|
||||
/// 返回扫码页,准备新一轮录制。
|
||||
void _recordNewRound() {
|
||||
AppNavigator.pushAndRemoveUntil(const ScanQrCodePage());
|
||||
}
|
||||
|
||||
/// 保存成功后按需弹出完成对话框
|
||||
Future<void> _showRecordingSavedDialogIfNeeded() async {
|
||||
/// 推流结束后按需弹出完成对话框
|
||||
Future<void> _showRecordingFinishedDialogIfNeeded() async {
|
||||
final recordingInfo = ref.read(recordingViewModelProvider);
|
||||
final session = recordingInfo.session;
|
||||
if (session.lastSavedDisplayName == null || session.fileSaveFailed) {
|
||||
if (!session.streamFinished || session.streamFailed) {
|
||||
return;
|
||||
}
|
||||
|
||||
final sessionTitle = _savedDialogSessionTitle(
|
||||
recordingInfo,
|
||||
session.lastSavedDisplayName,
|
||||
);
|
||||
|
||||
await showRecordingSavedDialog(
|
||||
context,
|
||||
sessionTitle: sessionTitle,
|
||||
sessionTitle: recordingInfo.recordingContext.title,
|
||||
onContinueRound: () {
|
||||
ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.clearSavedRecordingResult();
|
||||
},
|
||||
onRecordNewRound: _clearClipboardForNewRound,
|
||||
onRecordNewRound: _recordNewRound,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,6 +341,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
Future<void> _exitRecordingMode() async {
|
||||
if (!_immersiveApplied) return;
|
||||
await ref.read(recordingViewModelProvider.notifier).teardown();
|
||||
await _disposeStreamController();
|
||||
await SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values,
|
||||
@@ -237,9 +360,17 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
);
|
||||
RecordingPlatform.setImmersiveMode(enabled: false);
|
||||
}
|
||||
_disposeStreamController();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _disposeStreamController() async {
|
||||
if (_controllerDisposed) return;
|
||||
_controllerDisposed = true;
|
||||
await _streamController.stop();
|
||||
await _streamController.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
/// 构建录制页 UI
|
||||
Widget build(BuildContext context) {
|
||||
@@ -249,19 +380,20 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
backgroundColor: Colors.black,
|
||||
body: Column(
|
||||
children: [
|
||||
_RecordHeaderSection(
|
||||
onPasteEventInfo: _pasteEventInfo,
|
||||
onClearEventInfo: _clearClipboardForNewRound,
|
||||
),
|
||||
const _RecordHeaderSection(),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
const CameraPreviewWidget(),
|
||||
CameraPreviewWidget(
|
||||
controller: _streamController,
|
||||
isReady: _previewReady,
|
||||
),
|
||||
const _PreviewLoadingLayer(),
|
||||
const RecordTimerWidget(),
|
||||
_RecordingHudLayer(
|
||||
onStart: _onStartRecording,
|
||||
onStop: _stopRecordingAndShowResult,
|
||||
onZoomSelected: _setCameraZoomRatio,
|
||||
),
|
||||
_TouchLockOverlayLayer(
|
||||
onStopRecording: _stopRecordingAndShowResult,
|
||||
@@ -310,34 +442,18 @@ class _RecordingPopScope extends ConsumerWidget {
|
||||
}
|
||||
|
||||
class _RecordHeaderSection extends ConsumerWidget {
|
||||
const _RecordHeaderSection({
|
||||
required this.onPasteEventInfo,
|
||||
required this.onClearEventInfo,
|
||||
});
|
||||
|
||||
final Future<void> Function() onPasteEventInfo;
|
||||
final VoidCallback onClearEventInfo;
|
||||
const _RecordHeaderSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final headerState = ref.watch(
|
||||
recordingViewModelProvider.select(
|
||||
(m) => (
|
||||
m.hasValidClipboardInfo,
|
||||
m.hasValidClipboardInfo ? m.clipboardRecordingModel.title : null,
|
||||
m.session.isRecording,
|
||||
),
|
||||
(m) => (m.recordingContext.title, m.session.isRecording),
|
||||
),
|
||||
);
|
||||
final (hasValidClipboardInfo, eventTitle, isRecording) = headerState;
|
||||
final (eventTitle, isRecording) = headerState;
|
||||
|
||||
return RecordHeaderWidget(
|
||||
hasValidClipboardInfo: hasValidClipboardInfo,
|
||||
eventTitle: eventTitle,
|
||||
isRecording: isRecording,
|
||||
onPasteEventInfo: onPasteEventInfo,
|
||||
onClearEventInfo: onClearEventInfo,
|
||||
);
|
||||
return RecordHeaderWidget(eventTitle: eventTitle, isRecording: isRecording);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,10 +477,15 @@ class _PreviewLoadingLayer extends ConsumerWidget {
|
||||
}
|
||||
|
||||
class _RecordingHudLayer extends ConsumerWidget {
|
||||
const _RecordingHudLayer({required this.onStart, required this.onStop});
|
||||
const _RecordingHudLayer({
|
||||
required this.onStart,
|
||||
required this.onStop,
|
||||
required this.onZoomSelected,
|
||||
});
|
||||
|
||||
final Future<void> Function() onStart;
|
||||
final Future<void> Function() onStop;
|
||||
final ValueChanged<double> onZoomSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -383,8 +504,7 @@ class _RecordingHudLayer extends ConsumerWidget {
|
||||
m.session.zoomRatio,
|
||||
m.session.minZoomRatio,
|
||||
m.session.maxZoomRatio,
|
||||
m.hasValidClipboardInfo,
|
||||
m.clipboardRecordingModel.address.trim(),
|
||||
m.recordingContext.address.trim(),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -401,8 +521,7 @@ class _RecordingHudLayer extends ConsumerWidget {
|
||||
zoomRatio,
|
||||
minZoomRatio,
|
||||
maxZoomRatio,
|
||||
showClipboardHint,
|
||||
clipboardAddress,
|
||||
venue,
|
||||
) = hudState;
|
||||
final viewModel = ref.read(recordingViewModelProvider.notifier);
|
||||
|
||||
@@ -419,8 +538,8 @@ class _RecordingHudLayer extends ConsumerWidget {
|
||||
zoomRatio: zoomRatio,
|
||||
minZoomRatio: minZoomRatio,
|
||||
maxZoomRatio: maxZoomRatio,
|
||||
showClipboardHint: showClipboardHint,
|
||||
clipboardAddress: clipboardAddress,
|
||||
showContextHint: venue.isNotEmpty,
|
||||
contextAddress: venue,
|
||||
onStart: onStart,
|
||||
onStop: onStop,
|
||||
onOpenDnd: () async {
|
||||
@@ -438,9 +557,7 @@ class _RecordingHudLayer extends ConsumerWidget {
|
||||
.isTouchLocked;
|
||||
viewModel.setTouchLocked(!locked);
|
||||
},
|
||||
onZoomSelected: (ratio) async {
|
||||
await viewModel.setZoomRatio(ratio);
|
||||
},
|
||||
onZoomSelected: onZoomSelected,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -491,7 +608,7 @@ class _StartingRecordingOverlay extends ConsumerWidget {
|
||||
}
|
||||
|
||||
return RecordingLoadingOverlayWidget(
|
||||
message: '正在开始录制…',
|
||||
message: '正在连接推流…',
|
||||
backgroundColor: Colors.black.withValues(alpha: 0.24),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user