Author SHA1 Message Date
linfeng 0c1fb24685 update 2026-07-29 18:47:45 +08:00
linfeng a849457172 点击录新选手返回扫码页 2026-07-29 18:22:37 +08:00
linfeng d6a7e74809 Merge branch 'linfeng/drone_scoring/dev_1.1.0/2026630' into linfeng/drone_video/dev_1.1.0/20260707 2026-07-29 18:12:12 +08:00
linfeng df38ecee18 登录后清空输入框 2026-07-29 18:06:53 +08:00
linfeng 9dea31f76e Merge branch 'linfeng/drone_scoring/dev_1.1.0/2026630' into linfeng/drone_video/dev_1.1.0/20260707 2026-07-29 18:01:06 +08:00
linfeng 7faa8e5c47 更新获取赛事列表功能,增加 NAS IP 地址参数以支持动态请求 2026-07-29 18:00:45 +08:00
linfeng 17799af2e7 Merge branch 'linfeng/drone_scoring/dev_1.1.0/2026630' into linfeng/drone_video/dev_1.1.0/20260707 2026-07-29 11:51:21 +08:00
linfeng 7d65be0514 选手赛项列表增加 选手与该赛项的选手编号属性 2026-07-29 11:51:11 +08:00
linfeng d4920e2b98 1.录像页面,根据个人赛和团队赛 动态 录像页标题
2.录像结束弹窗按钮文本修改
2026-07-29 11:18:58 +08:00
linfeng a19d9dfa6d Merge branch 'linfeng/drone_scoring/dev_1.1.0/2026630' into linfeng/drone_video/dev_1.1.0/20260707 2026-07-29 10:15:25 +08:00
linfeng 40799f2d15 扫码页支持返回上一页 2026-07-29 10:13:47 +08:00
linfeng 934b3ea2fa Merge branch 'linfeng/drone_scoring/dev_1.1.0/2026630' into linfeng/drone_video/dev_1.1.0/20260707 2026-07-29 09:19:19 +08:00
linfeng f7f7413b55 优化扫码页 UI 2026-07-28 18:50:14 +08:00
linfeng 8e599f5a86 完成视频播放UI 调整 2026-07-28 18:02:40 +08:00
linfeng 9fca76f679 1.完成查看录像模块功能
2.完成录像页面播放视频功能
2026-07-28 16:36:32 +08:00
linfeng a324825e8a update 2026-07-28 15:46:52 +08:00
linfeng 6793ca53af 还原参赛队伍列表页、详情页 2026-07-28 15:46:23 +08:00
linfeng 9e72bc522e 解决查看录像页 UI 2026-07-28 14:59:34 +08:00
linfeng 36253aacbe 优化录制页性能 2026-07-28 13:47:25 +08:00
linfeng 1096645e79 update git 湖绿 2026-07-28 12:04:28 +08:00
linfeng a56a42c7c8 1.修改文案
2.副裁 APP 不需要查看参赛队伍
2026-07-28 12:00:16 +08:00
linfeng d29b4ed5bf 1.不展示黄字信息
2.新增忽略文件
2026-07-28 11:35:36 +08:00
linfeng b9c0d60f2a 调整 副裁 设计稿比例 2026-07-28 10:57:06 +08:00
linfeng 43b967d2f8 降级 network_info_plus 依赖版本至 7.0.0 2026-07-28 10:54:36 +08:00
linfeng 58de72ac92 Merge branch 'linfeng/drone_scoring/dev_1.1.0/2026630' into linfeng/drone_video/dev_1.1.0/20260707 2026-07-28 10:54:27 +08:00
linfeng 13ecb2fccc 优化赛事信息页 UI 2026-07-28 10:12:13 +08:00
linfeng e3f9c8ebc0 优化团队赛页面 UI 2026-07-28 10:00:02 +08:00
linfeng 6790ea41b2 优化人工处理弹窗 UI 2026-07-28 09:30:24 +08:00
linfeng b2dd593e93 完成团队赛、 UI 开发 2026-07-27 18:41:20 +08:00
linfeng 5ffe524e20 完成选手信息页 2026-07-27 17:57:09 +08:00
linfeng 356636c4b4 解决解析 TOKEN 类型报错问题 2026-07-27 16:57:05 +08:00
linfeng 686db244fd 1.还原授权页 UI
2.还原扫码页 UI
2026-07-27 16:52:58 +08:00
linfeng 20d501f673 修改启动页 2026-07-27 15:45:06 +08:00
linfeng ddc1c9bf98 更换 APP 图标 2026-07-27 15:43:26 +08:00
linfeng 30d317c0b9 修改名字 2026-07-27 15:30:17 +08:00
linfeng 10d9eee60e 新增人工设置晋级/淘汰功能,更新相关数据模型和API接口;重构比赛页面以支持新逻辑,优化用户交互体验。 2026-07-23 15:35:31 +08:00
linfeng 00937bf4b6 更新比赛页面以传递新的事件注册项,简化数据处理逻辑并优化构建脚本。 2026-07-23 11:55:26 +08:00
linfeng 3c3baede85 移除比赛页面中的备注文本,优化UI布局以提升用户体验。 2026-07-23 11:48:07 +08:00
linfeng 8c127dd0e7 1.重构参赛队伍详情页面,更新数据模型以支持新结构;
2.优化比赛信息展示逻辑,增强用户交互体验;
3.修复API请求以获取队伍详情并处理异常情况。
2026-07-23 11:44:47 +08:00
linfeng 1264ebdd7c 重构参赛队伍相关模型,更新API接口以支持队伍详情获取;
调整UI逻辑以使用新的数据结构,优化比赛信息展示;
更新构建脚本以支持新功能。
2026-07-22 13:35:53 +08:00
linfeng bfc76f469f 更新事件信息页面中的流媒体 URL 逻辑,恢复使用 streamKey 变量以增强灵活性,同时注释掉了之前的硬编码 URL。 2026-07-17 18:24:22 +08:00
linfeng 744e768d3a 在 UtilSearchNasIp 中添加探测开始时间记录和总耗时打印,增强了命中记录的调试信息;更新相关方法以传递开始时间参数。 2026-07-17 17:48:16 +08:00
linfeng 25a07cbd3b 添加 network_info_plus 依赖以支持局域网 IP 获取;在事件信息和录制页面中增强局域网 IP 查询功能,更新相关模型以包含 scheduleId 字段;重构录制页面以改善用户体验,修复推流控制器释放逻辑。 2026-07-17 17:31:04 +08:00
74 changed files with 3978 additions and 1453 deletions
+2 -1
View File
@@ -50,4 +50,5 @@ app.*.map.json
CLAUDE.md
AGENTS.md
test/
test/
script/
+5 -2
View File
@@ -10,6 +10,9 @@
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
@@ -19,7 +22,7 @@
android:required="true" />
<application
android:label="飞行极控录像工作台"
android:label="SportsX裁判工作台"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
@@ -57,4 +60,4 @@
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>
</manifest>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 795 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1011 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

+3 -1
View File
@@ -3,4 +3,6 @@ set -e
flutter build apk --release --split-per-abi
echo "构建完成时间: $(date '+%Y-%m-%d %H:%M:%S')"
# echo "构建完成时间: $(date '+%Y-%m-%d %H:%M:%S')"
pgyer upload build/app/outputs/flutter-apk/app-arm64-v8a-release.apk --build-update-description "副裁判 APP $(date '+%Y-%m-%d %H:%M:%S')"
+53
View File
@@ -1,9 +1,26 @@
PODS:
- apivideo_live_stream (0.0.1):
- ApiVideoLiveStream (= 1.4.1)
- Flutter
- ApiVideoLiveStream (1.4.1):
- HaishinKit (= 1.7.3)
- connectivity_plus (0.0.1):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
- HaishinKit (1.7.3):
- Logboard (~> 2.4.1)
- Logboard (2.4.2)
- mobile_scanner (7.0.0):
- Flutter
- FlutterMacOS
- native_device_orientation (0.0.1):
- Flutter
- permission_handler_apple (9.4.8):
- Flutter
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
@@ -12,36 +29,72 @@ PODS:
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES:
- apivideo_live_stream (from `.symlinks/plugins/apivideo_live_stream/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`)
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
- native_device_orientation (from `.symlinks/plugins/native_device_orientation/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
SPEC REPOS:
trunk:
- ApiVideoLiveStream
- HaishinKit
- Logboard
EXTERNAL SOURCES:
apivideo_live_stream:
:path: ".symlinks/plugins/apivideo_live_stream/ios"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
Flutter:
:path: Flutter
mobile_scanner:
:path: ".symlinks/plugins/mobile_scanner/darwin"
native_device_orientation:
:path: ".symlinks/plugins/native_device_orientation/ios"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS:
apivideo_live_stream: caab45dd35fb3b140d423c099d0f6672378e7abe
ApiVideoLiveStream: 8f9dce7f6d15d5e4bb3c7a25e406bf2a36337a5a
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
HaishinKit: 326e27c4d06427ba53bffc68e516a92033293051
Logboard: 759d82599c439945d430d5a0958455b5a1974a0c
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
native_device_orientation: d6a4dc6887cd8a5ce1049962367aec60139ea0f1
permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
PODFILE CHECKSUM: 858401fbd980bedce6ecd2f9b429bf271f11f74b
+9
View File
@@ -11,6 +11,15 @@ enum AuthApi {
/// 获取参赛队伍列表
getTeamList('/api/events/device/item/group'),
/// 参赛队伍详情
getTeamDetail('/api/events/device/schedule/pending'),
/// 人工设置晋级/淘汰
setTeamStatus('/api/events/device/schedule/team/score'),
/// 获取登录设备信息
getLoginDeviceInfo('/api/events/device/nas/info'),
/// 获取选手的赛事信息
playerRegistrationList('/api/events/device/player/registration/list');
+1 -1
View File
@@ -23,7 +23,7 @@ class AppConfig {
static late EnvironmentValues current;
static AppPackageInfo? packageInfo;
static const appName = '飞行极控录像工作台';
static const appName = 'SportsX裁判工作台';
static const designSize = Size(375, 812);
/// 主裁判计分 H5 链接
+17 -3
View File
@@ -38,11 +38,15 @@ class ApiClient {
return _parseData<T>(raw, parser);
}
if (raw is! Map<String, dynamic>) {
final payload = raw is Map ? Map<String, dynamic>.from(raw) : null;
if (payload == null ||
!(payload.containsKey('code') || payload.containsKey('message'))) {
return _parseData<T>(raw, parser);
}
final wrapped = ApiResponse<T>.fromJson(raw, fromJsonT: parser);
// 先按 {code,message,data} 解包,再用业务 parser 解析 data
// 避免把 Map 直接强转成业务模型。
final wrapped = ApiResponse<dynamic>.fromJson(payload);
if (!wrapped.isSuccess) {
throw ApiException(
code: wrapped.code,
@@ -52,7 +56,17 @@ class ApiClient {
);
}
return wrapped.data as T;
if (wrapped.data == null) {
if (null is T) return null as T;
throw ApiException(
code: wrapped.code,
statusCode: response.statusCode,
message: '响应 data 为空',
details: raw,
);
}
return _parseData<T>(wrapped.data, parser);
} on DioException catch (error) {
throw _mapDioException(error);
}
+17 -1
View File
@@ -11,10 +11,26 @@ class ApiResponse<T> {
Map<String, dynamic> json, {
T Function(dynamic json)? fromJsonT,
}) {
final rawData = json['data'];
T? data;
if (rawData != null && fromJsonT != null) {
data = fromJsonT(rawData);
} else if (rawData != null && fromJsonT == null) {
// 无 parser 时仅在类型已匹配时接收,避免 Map 被强转为业务模型。
if (rawData is T) {
data = rawData;
} else {
throw FormatException(
'ApiResponse data 类型不匹配,且未提供 fromJsonT;'
'期望 $T,实际 ${rawData.runtimeType}',
);
}
}
return ApiResponse<T>(
code: (json['code'] as num?)?.toInt() ?? 200,
message: (json['message'] ?? json['msg'] ?? '').toString(),
data: fromJsonT == null ? json['data'] as T? : fromJsonT(json['data']),
data: data,
);
}
}
+142
View File
@@ -0,0 +1,142 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:network_info_plus/network_info_plus.dart';
/// 局域网 NAS IP 探测(结果仅存内存静态字段)。
class UtilSearchNasIp {
UtilSearchNasIp._();
static const String _defaultPreUrl = 'http://192.168.1.';
static const String _probePath =
':5666/sac/rpcproxy/v1/new-user-guide/status';
static const Duration _timeout = Duration(seconds: 1);
/// 命中的 NAS IPv4(仅内存,进程内有效)。
static String? nasIp;
/// 并发扫同网段 1~255,超时 1s;首个有 HTTP 响应者写入 [nasIp] 并返回。
static Future<String?> discover() async {
if (nasIp != null && nasIp!.isNotEmpty) {
return nasIp;
}
final startedAt = DateTime.now();
final preUrl = await _resolvePreUrl();
final dio = Dio(
BaseOptions(
connectTimeout: _timeout,
receiveTimeout: _timeout,
sendTimeout: _timeout,
validateStatus: (_) => true,
),
);
final cancelToken = CancelToken();
try {
await Future.wait([
for (var host = 1; host <= 255; host++)
_probeHost(
dio: dio,
cancelToken: cancelToken,
preUrl: preUrl,
host: host,
startedAt: startedAt,
),
]);
if (nasIp == null) {
final elapsedMs = DateTime.now().difference(startedAt).inMilliseconds;
debugPrint('NAS 探测未命中,总耗时: ${elapsedMs}ms');
}
return nasIp;
} finally {
dio.close(force: true);
}
}
/// 由本机 WiFi IPv4 推导 `http://a.b.c.`,失败则用默认网段。
static Future<String> _resolvePreUrl() async {
try {
final wifiIp = await NetworkInfo().getWifiIP();
if (wifiIp == null || wifiIp.isEmpty) return _defaultPreUrl;
final parts = wifiIp.split('.');
if (parts.length != 4) return _defaultPreUrl;
return 'http://${parts[0]}.${parts[1]}.${parts[2]}.';
} catch (_) {
return _defaultPreUrl;
}
}
/// 探测单个主机;任意 HTTP 响应视为命中。
static Future<void> _probeHost({
required Dio dio,
required CancelToken cancelToken,
required String preUrl,
required int host,
required DateTime startedAt,
}) async {
if (cancelToken.isCancelled || nasIp != null) return;
final target = '$preUrl$host$_probePath';
try {
await dio.get<dynamic>(target, cancelToken: cancelToken);
_onHit(preUrl, host, cancelToken, startedAt);
} on DioException catch (error) {
if (error.type == DioExceptionType.cancel) return;
// 带 response 的 DioException 也表示已连通
if (error.response != null) {
_onHit(preUrl, host, cancelToken, startedAt);
}
} catch (_) {
// 超时 / 连接失败:未命中
}
}
/// 记录命中:写 [nasIp]、打印 IP 与总耗时,并取消其余请求。
static void _onHit(
String preUrl,
int host,
CancelToken cancelToken,
DateTime startedAt,
) {
if (nasIp != null) return;
final ip = '${_hostPrefix(preUrl)}$host';
nasIp = ip;
final elapsedMs = DateTime.now().difference(startedAt).inMilliseconds;
debugPrint('NAS IP: $ip,探测总耗时: ${elapsedMs}ms');
if (!cancelToken.isCancelled) {
cancelToken.cancel('nas-found');
}
}
/// `http://192.168.1.` → `192.168.1.`
static String _hostPrefix(String preUrl) {
return preUrl.replaceFirst(RegExp(r'^https?://'), '');
}
}
// ---------------------------------------------------------------------------
// 调用案例
// ---------------------------------------------------------------------------
//
// 1) Auth 页静默探测(不阻塞登录):
//
// import 'dart:async';
// import 'package:recording_tool/core/utils/util_search_nasIp.dart';
//
// WidgetsBinding.instance.addPostFrameCallback((_) {
// unawaited(UtilSearchNasIp.discover());
// });
//
// 2) 业务侧读取已命中 IP
//
// final ip = UtilSearchNasIp.nasIp;
// if (ip != null) {
// final base = 'http://$ip:5666';
// // 使用 base 访问 NAS...
// }
//
// 3) 主动等待探测结果(少用,会阻塞当前异步流程):
//
// final ip = await UtilSearchNasIp.discover();
// debugPrint('result: $ip');
//
+22 -9
View File
@@ -22,15 +22,20 @@ class GetRecordListResModel {
GetRecordListResModel({this.path, this.items});
factory GetRecordListResModel.fromJson(Map<String, dynamic> json) =>
GetRecordListResModel(
path: json['path'],
items: json['items'] == null
? []
: List<RecordListItem>.from(
json['items']!.map((x) => RecordListItem.fromJson(x)),
),
);
factory GetRecordListResModel.fromJson(Map<String, dynamic> json) {
final rawItems = json['items'];
return GetRecordListResModel(
path: json['path']?.toString(),
items: rawItems is! List
? []
: rawItems
.whereType<Map>()
.map(
(x) => RecordListItem.fromJson(Map<String, dynamic>.from(x)),
)
.toList(),
);
}
Map<String, dynamic> toJson() => {
'path': path,
@@ -40,6 +45,14 @@ class GetRecordListResModel {
};
}
enum RecordItemType {
file('file'),
directory('dir');
final String value;
const RecordItemType(this.value);
}
class RecordListItem {
String? name;
String? path;
+31 -20
View File
@@ -15,9 +15,10 @@ class JwtDecodedData {
int? deviceId;
String? deviceRole;
String? eventName;
double? oId;
List<double>? oIds;
double? organizerId;
String? oId;
List<String>? oIds;
String? organizerId;
String? nasIpAddr;
JwtDecodedData({
this.authType,
@@ -28,29 +29,39 @@ class JwtDecodedData {
this.oId,
this.oIds,
this.organizerId,
this.nasIpAddr,
});
factory JwtDecodedData.fromJson(Map<String, dynamic> json) => JwtDecodedData(
authType: json["authType"],
deviceCode: json["deviceCode"],
deviceId: json["deviceId"],
deviceRole: json["deviceRole"],
eventName: json["eventName"],
oId: json["oId"]?.toDouble(),
oIds: json["oIds"] == null
authType: json['authType']?.toString(),
deviceCode: json['deviceCode']?.toString(),
deviceId: _readInt(json['deviceId']),
deviceRole: json['deviceRole']?.toString(),
eventName: json['eventName']?.toString(),
oId: json['oId']?.toString(),
oIds: json['oIds'] == null
? []
: List<double>.from(json["oIds"]!.map((x) => x?.toDouble())),
organizerId: json["organizerId"]?.toDouble(),
: List<String>.from(json['oIds']!.map((x) => x?.toString())),
organizerId: json['organizerId']?.toString(),
nasIpAddr: json['nasIpAddr']?.toString(),
);
Map<String, dynamic> toJson() => {
"authType": authType,
"deviceCode": deviceCode,
"deviceId": deviceId,
"deviceRole": deviceRole,
"eventName": eventName,
"oId": oId,
"oIds": oIds == null ? [] : List<dynamic>.from(oIds!.map((x) => x)),
"organizerId": organizerId,
'authType': authType,
'deviceCode': deviceCode,
'deviceId': deviceId,
'deviceRole': deviceRole,
'eventName': eventName,
'oId': oId,
'oIds': oIds == null ? [] : List<dynamic>.from(oIds!.map((x) => x)),
'organizerId': organizerId,
'nasIpAddr': nasIpAddr,
};
}
int? _readInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
if (value is num) return value.toInt();
return int.tryParse(value.toString());
}
+226 -73
View File
@@ -1,13 +1,19 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/core/cache/app_storage.dart';
import 'package:recording_tool/core/cache/storage_keys.dart';
import 'package:recording_tool/core/utils/device_utils.dart';
import 'package:recording_tool/core/utils/util_search_nasIp.dart';
import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dart';
import 'package:recording_tool/features/scan_qrcode/pages/page_scan_qrcode.dart';
import 'package:recording_tool/shared/widgets/widgets.dart';
import 'package:recording_tool/gen/assets.gen.dart';
import 'package:recording_tool/shared/widgets/app_dialog.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
class AuthPageWidget extends ConsumerStatefulWidget {
const AuthPageWidget({super.key});
@@ -17,17 +23,27 @@ class AuthPageWidget extends ConsumerStatefulWidget {
}
class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
late TextEditingController? _controller;
late final TextEditingController _controller;
/// 记录点击次数
int _clickCount = 0;
@override
void initState() {
super.initState();
_controller = TextEditingController();
_controller?.text = '555';
_controller = TextEditingController(text: '');
WidgetsBinding.instance.addPostFrameCallback((_) async {
// 静默探测 NAS,不阻塞登录 / 自动跳转
unawaited(UtilSearchNasIp.discover());
final token = AppStorage.getString(StorageKeys.authToken);
if (token?.isNotEmpty ?? false) {
final success = await ref
.read(authProvider.notifier)
.parseTokenSetState();
if (!success) {
return;
}
AppNavigator.push(const ScanQrCodePage());
}
});
@@ -35,88 +51,225 @@ class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
@override
void dispose() {
_controller?.dispose();
_controller.dispose();
super.dispose();
}
void _showDeviceCodeDialog() async {
final deviceCode = await DeviceUtils.deviceCode();
if (!mounted) return;
AppDialog.confirm(context, title: '设备码:$deviceCode');
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
return Center(
child: Column(
children: [
SizedBox(height: 180.h),
AppText('裁判工作台', fontSize: 30.sp),
SizedBox(height: 20.h),
Text(
'输入执裁口令',
style: TextStyle(fontSize: 18.sp, color: Colors.black),
),
SizedBox(height: 20.h),
Container(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Column(
children: [
AppTextField(controller: _controller),
SizedBox(height: 20.h),
SizedBox(
width: double.maxFinite,
child: AppButton(
label: '确定',
onPressed: () async {
final code = _controller?.text;
final success = await ref
.read(authProvider.notifier)
.auth(code ?? '');
if (!mounted) return;
if (success) {
AppNavigator.push(const ScanQrCodePage());
return;
}
final message = ref.read(authProvider).errorMessage;
if (message != null && message.isNotEmpty) {
AppToast.show(message);
}
},
variant: AppButtonVariant.secondary,
isLoading: authState.isLoading,
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.white,
),
child: Scaffold(
backgroundColor: Colors.white,
body: Stack(
children: [
Positioned.fill(
child: Image.asset(
_AuthAssets.pageBg,
width: double.infinity,
fit: BoxFit.fill,
),
),
SafeArea(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 32.w),
child: SingleChildScrollView(
child: Column(
children: [
SizedBox(height: 190.h),
ClipRRect(
borderRadius: BorderRadius.circular(24.r),
child: GestureDetector(
onTap: () {
_clickCount++;
if (_clickCount >= 5) {
_showDeviceCodeDialog();
_clickCount = 0;
}
},
child: Image.asset(
_AuthAssets.appIcon,
width: 82.w,
height: 82.w,
fit: BoxFit.cover,
),
),
),
SizedBox(height: 18.h),
Image.asset(
_AuthAssets.appNameText,
width: 132.w,
height: 28.w,
fit: BoxFit.cover,
),
SizedBox(height: 78.h),
_PassCodeInput(controller: _controller),
SizedBox(height: 20.h),
_GradientConfirmButton(
isLoading: authState.isLoading,
onPressed: _handleSubmit,
),
// SizedBox(height: 20.h),
// AppButton(
// onPressed: () async {
// final deviceCode = await DeviceUtils.deviceCode();
// if (!mounted) return;
// AppDialog.confirm(context, title: '设备码:$deviceCode');
// },
// label: '获取设备码',
// ),
],
),
),
],
),
),
..._buildTestArea(authState.isLoading),
],
),
);
}
/// 测试区域
List<Widget> _buildTestArea(bool isLoading) {
return [
SizedBox(height: 20.h),
Container(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Column(
children: [
SizedBox(
width: double.maxFinite,
child: AppButton(
label: '获取设备码',
onPressed: () async {
DeviceUtils.deviceCode().then((code) {
AppDialog.confirm(context, title: '设备码', message: code);
});
},
variant: AppButtonVariant.secondary,
isLoading: isLoading,
),
),
],
),
),
];
);
}
Future<void> _handleSubmit() async {
final success = await ref
.read(authProvider.notifier)
.auth(_controller.text);
if (!mounted) return;
if (success) {
_controller.clear();
AppNavigator.push(const ScanQrCodePage());
return;
}
final message = ref.read(authProvider).errorMessage;
if (message != null && message.isNotEmpty) {
AppToast.show(message);
}
}
}
class _PassCodeInput extends StatelessWidget {
const _PassCodeInput({required this.controller});
final TextEditingController controller;
@override
Widget build(BuildContext context) {
return Container(
height: 50.h,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 10.r,
offset: Offset(0, 2.h),
),
],
),
child: TextField(
controller: controller,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
maxLength: 6,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
style: TextStyle(
color: const Color(0xFF2F3338),
fontSize: 16.sp,
fontWeight: FontWeight.w500,
),
decoration: InputDecoration(
hintText: '请输入执裁口令',
hintStyle: TextStyle(
color: const Color(0xFFB6B9BF),
fontSize: 16.sp,
fontWeight: FontWeight.w400,
),
counterText: '',
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(
horizontal: 16.w,
vertical: 14.h,
),
),
),
);
}
}
class _GradientConfirmButton extends StatelessWidget {
const _GradientConfirmButton({
required this.isLoading,
required this.onPressed,
});
final bool isLoading;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Opacity(
opacity: isLoading ? 0.72 : 1,
child: GestureDetector(
onTap: isLoading ? null : onPressed,
child: Container(
height: 50.h,
width: double.infinity,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(25.r),
gradient: const LinearGradient(
colors: [Color(0xFF268DFF), Color(0xFF5DD4F6)],
),
boxShadow: [
BoxShadow(
color: const Color(0xFF2196F3).withValues(alpha: 0.28),
blurRadius: 14.r,
offset: Offset(0, 6.h),
),
],
),
child: isLoading
? SizedBox.square(
dimension: 18.r,
child: CircularProgressIndicator(
strokeWidth: 2.r,
valueColor: const AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
: Text(
'确定',
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w500,
),
),
),
),
);
}
}
class _AuthAssets {
const _AuthAssets._();
static String pageBg = Assets.images.imagePageBg.path;
static String appIcon = Assets.images.imageAppIcon.path;
static String appNameText = Assets.images.imageAppNameText.path;
}
+52 -12
View File
@@ -1,8 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:recording_tool/app/config/api_common.dart';
import 'package:recording_tool/core/network/providers/dio_providers.dart';
import 'package:recording_tool/core/utils/device_utils.dart';
import 'package:recording_tool/features/auth/model/model_auth.dart';
import 'package:recording_tool/features/auth/model/model_jwt.dart';
class AuthServer {
/// [passCode] 口令码
@@ -28,17 +30,55 @@ class AuthServer {
/// 获取赛事列表
/// [path] 赛事目录
static Future<GetRecordListResModel> getRecordList(
Ref ref,
String path,
) async {
final apiClient = ref.read(apiClientProvider);
final data = await apiClient.get<GetRecordListResModel>(
'http://sheling.local:9001/${AuthApi.getRecordList.path}',
queryParameters: {'path': path},
parser: (json) =>
GetRecordListResModel.fromJson(json as Map<String, dynamic>),
);
return data;
/// [nasIpAddr] NAS IP,由调用方传入,避免在 authProvider 内部再 read(authProvider)。
static Future<GetRecordListResModel?> getRecordList(
Ref ref, {
required String path,
required String nasIpAddr,
}) async {
try {
if (nasIpAddr.isEmpty) {
throw const FormatException('无法获取NAS IP地址');
}
final apiClient = ref.read(apiClientProvider);
// NAS /api/files 直接返回 {path, items},不是业务网关的 {code,message,data} 包装。
final data = await apiClient.get<GetRecordListResModel>(
'http://$nasIpAddr:9001/${AuthApi.getRecordList.path}',
queryParameters: {'path': path},
wrapResponse: false,
parser: (json) {
if (json is! Map) {
throw const FormatException('录像列表响应格式错误');
}
return GetRecordListResModel.fromJson(
Map<String, dynamic>.from(json),
);
},
);
return data;
} catch (error) {
debugPrint('getRecordList failed: $error');
return null;
}
}
/// 获取登录设备信息
static Future<JwtDecodedData?> getLoginDeviceInfo(Ref ref) async {
try {
final apiClient = ref.read(apiClientProvider);
final data = await apiClient.get<JwtDecodedData>(
AuthApi.getLoginDeviceInfo.path,
parser: (json) {
if (json is! Map) {
throw const FormatException('登录设备信息响应格式错误');
}
return JwtDecodedData.fromJson(Map<String, dynamic>.from(json));
},
);
return data;
} catch (error) {
debugPrint('getLoginDeviceInfo failed: $error');
return null;
}
}
}
+1
View File
@@ -28,6 +28,7 @@ class AuthState {
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage ?? this.errorMessage,
jwtDecodedData: jwtDecodedData ?? this.jwtDecodedData,
recordList: recordList ?? this.recordList,
);
}
}
@@ -1,10 +1,9 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.dart';
import 'package:jwt_decoder/jwt_decoder.dart';
import 'package:recording_tool/core/cache/app_storage.dart';
import 'package:recording_tool/core/cache/storage_keys.dart';
import 'package:recording_tool/core/network/api_exception.dart';
import 'package:recording_tool/features/auth/model/model_jwt.dart';
import 'package:recording_tool/features/auth/model/model_auth.dart';
import 'package:recording_tool/features/auth/server/server_auth.dart';
import 'package:recording_tool/features/auth/state/state_auth.dart';
@@ -31,7 +30,11 @@ class AuthViewModel extends StateNotifier<AuthState> {
await AppStorage.setString(StorageKeys.authToken, data.deviceAccessToken);
state = const AuthState();
if (data.deviceAccessToken.isNotEmpty) {
parseTokenSetState(data.deviceAccessToken);
final ok = await parseTokenSetState();
if (!ok) {
state = const AuthState(errorMessage: '获取赛事信息失败,请重试');
return false;
}
}
return true;
} on FormatException catch (error) {
@@ -47,25 +50,53 @@ class AuthViewModel extends StateNotifier<AuthState> {
}
/// 解析 TOKEN,并更新状态
Future<bool> parseTokenSetState(String token) async {
final decoded = JwtDecoder.decode(token);
if (decoded['data'] != null && decoded['data'] is Map<String, dynamic>) {
final data = JwtDecodedData.fromJson(decoded['data']);
state = state.copyWith(jwtDecodedData: data);
return true;
}
return false;
Future<bool> parseTokenSetState() async {
final data = await AuthServer.getLoginDeviceInfo(_ref);
if (data == null) return false;
state = state.copyWith(jwtDecodedData: data);
return true;
// final decoded = JwtDecoder.decode(token);
// final rawData = decoded['data'];
// if (rawData is! Map) return false;
// try {
// final data = JwtDecodedData.fromJson(Map<String, dynamic>.from(rawData));
// state = state.copyWith(jwtDecodedData: data);
// return true;
// } catch (error) {
// debugPrint('认证失败,请重试: $error');
// return false;
// }
}
/// 获取赛事列表
Future<bool> getRecordList(String eventName) async {
if (eventName.isEmpty) return false;
final data = await AuthServer.getRecordList(_ref, eventName);
if (data.items == null || data.items!.isEmpty) return false;
final nasIpAddr = state.jwtDecodedData?.nasIpAddr?.trim() ?? '';
if (nasIpAddr.isEmpty) return false;
final data = await AuthServer.getRecordList(
_ref,
path: eventName,
nasIpAddr: nasIpAddr,
);
if (data == null) return false;
if (data.items == null) return false;
state = state.copyWith(recordList: data.items);
return true;
}
/// 获取指定目录的录像列表(不更新 state,用于目录下钻)
Future<List<RecordListItem>?> fetchRecordList(String path) async {
if (path.isEmpty) return null;
final nasIpAddr = state.jwtDecodedData?.nasIpAddr?.trim() ?? '';
if (nasIpAddr.isEmpty) return null;
final data = await AuthServer.getRecordList(
_ref,
path: path,
nasIpAddr: nasIpAddr,
);
return data?.items;
}
/// 清空授权信息(本地 token + 内存状态)
Future<void> clearAuth() async {
await AppStorage.remove(StorageKeys.authToken);
@@ -42,44 +42,59 @@ class CompetitionMatchup {
}
}
/// 参赛项目/组别列表项(来自 /api/events/device/item/group
class CompetitionTeamListItem {
const CompetitionTeamListItem({
required this.id,
required this.eventName,
required this.itemName,
required this.eventId,
required this.itemId,
required this.name,
required this.groupName,
required this.matchPlace,
required this.matchStartTime,
required this.matchEndTime,
required this.matchups,
this.completed = false,
this.matchStartTime = '',
this.matchEndTime = '',
this.matchups = const [],
});
final String id;
final String eventName;
final String itemName;
final String eventId;
final String itemId;
final String name;
final String groupName;
final String matchPlace;
final String matchStartTime;
final String matchEndTime;
/// 对阵明细暂未由列表接口返回,默认空
final List<CompetitionMatchup> matchups;
final bool completed;
String get title => groupName.isEmpty ? itemName : '$itemName $groupName';
String get title => groupName.isEmpty ? name : '$name $groupName';
String get scheduleTime => '$matchStartTime-$matchEndTime';
String get scheduleTime {
if (matchStartTime.isEmpty && matchEndTime.isEmpty) return '';
if (matchStartTime.isNotEmpty && matchEndTime.isNotEmpty) {
return '$matchStartTime-$matchEndTime';
}
if (matchStartTime.isNotEmpty) return matchStartTime;
return matchEndTime;
}
factory CompetitionTeamListItem.fromJson(Map<String, dynamic> json) {
return CompetitionTeamListItem(
eventId: (json['eventId'] ?? '').toString(),
itemId: (json['itemId'] ?? '').toString(),
name: (json['name'] ?? '').toString(),
groupName: (json['groupName'] ?? '').toString(),
matchStartTime: (json['matchStartTime'] ?? '').toString(),
matchEndTime: (json['matchEndTime'] ?? '').toString(),
);
}
CompetitionTeamListItem copyWith({List<CompetitionMatchup>? matchups}) {
return CompetitionTeamListItem(
id: id,
eventName: eventName,
itemName: itemName,
eventId: eventId,
itemId: itemId,
name: name,
groupName: groupName,
matchPlace: matchPlace,
matchStartTime: matchStartTime,
matchEndTime: matchEndTime,
matchups: matchups ?? this.matchups,
completed: completed,
);
}
}
@@ -98,4 +113,59 @@ class CompetitionTeamPageResult {
final int pageSize;
bool get hasMore => page * pageSize < total;
factory CompetitionTeamPageResult.fromJson(
dynamic json, {
required int page,
required int pageSize,
}) {
if (json is List) {
// 整表无分页:本页即全部,hasMore = false
final items = json
.whereType<Map>()
.map(
(item) => CompetitionTeamListItem.fromJson(
Map<String, dynamic>.from(item),
),
)
.toList(growable: false);
final effectivePageSize = items.isEmpty ? pageSize : items.length;
return CompetitionTeamPageResult(
items: items,
total: items.length,
page: page,
pageSize: effectivePageSize,
);
}
if (json is Map) {
final map = Map<String, dynamic>.from(json);
final rawItems =
map['items'] ?? map['rows'] ?? map['list'] ?? map['records'];
final items = rawItems is List
? rawItems
.whereType<Map>()
.map(
(item) => CompetitionTeamListItem.fromJson(
Map<String, dynamic>.from(item),
),
)
.toList(growable: false)
: const <CompetitionTeamListItem>[];
final total = (map['total'] as num?)?.toInt() ?? items.length;
return CompetitionTeamPageResult(
items: items,
total: total,
page: page,
pageSize: pageSize,
);
}
return CompetitionTeamPageResult(
items: const [],
total: 0,
page: page,
pageSize: pageSize,
);
}
}
@@ -0,0 +1,174 @@
/// 参赛队伍详情
class CompetitionTeamDetail {
String? scheduleId;
String? itemId;
String? eventId;
String? itemName;
String? groupName;
String? matchPlace;
String? matchStartTime;
String? matchEndTime;
List<Matchup>? matchups;
CompetitionTeamDetail({
this.scheduleId,
this.itemId,
this.eventId,
this.itemName,
this.groupName,
this.matchPlace,
this.matchStartTime,
this.matchEndTime,
this.matchups,
});
factory CompetitionTeamDetail.fromJson(Map<String, dynamic> json) =>
CompetitionTeamDetail(
scheduleId: json['scheduleId']?.toString(),
itemId: json['itemId']?.toString(),
eventId: json['eventId']?.toString(),
itemName: json['itemName']?.toString(),
groupName: json['groupName']?.toString(),
matchPlace: json['matchPlace']?.toString(),
matchStartTime: json['matchStartTime']?.toString(),
matchEndTime: json['matchEndTime']?.toString(),
matchups: json['matchups'] == null
? const []
: List<Matchup>.from(
(json['matchups'] as List).whereType<Map>().map(
(x) => Matchup.fromJson(Map<String, dynamic>.from(x)),
),
),
);
/// 兼容 data 为对象或数组(取首项)
factory CompetitionTeamDetail.fromResponse(dynamic json) {
if (json is List) {
if (json.isEmpty) return CompetitionTeamDetail(matchups: const []);
final first = json.first;
if (first is Map) {
return CompetitionTeamDetail.fromJson(Map<String, dynamic>.from(first));
}
return CompetitionTeamDetail(matchups: const []);
}
if (json is Map) {
return CompetitionTeamDetail.fromJson(Map<String, dynamic>.from(json));
}
return CompetitionTeamDetail(matchups: const []);
}
Map<String, dynamic> toJson() => {
'scheduleId': scheduleId,
'itemId': itemId,
'eventId': eventId,
'itemName': itemName,
'groupName': groupName,
'matchPlace': matchPlace,
'matchStartTime': matchStartTime,
'matchEndTime': matchEndTime,
'matchups': matchups == null
? []
: List<dynamic>.from(matchups!.map((x) => x.toJson())),
};
}
class Matchup {
String? matchTitle;
String? opponentId;
List<Team>? teamA;
List<Team>? teamB;
Matchup({this.matchTitle, this.opponentId, this.teamA, this.teamB});
factory Matchup.fromJson(Map<String, dynamic> json) => Matchup(
matchTitle: json['matchTitle']?.toString(),
opponentId: json['opponentId']?.toString(),
teamA: json['teamA'] == null
? const []
: List<Team>.from(
(json['teamA'] as List).whereType<Map>().map(
(x) => Team.fromJson(Map<String, dynamic>.from(x)),
),
),
teamB: json['teamB'] == null
? const []
: List<Team>.from(
(json['teamB'] as List).whereType<Map>().map(
(x) => Team.fromJson(Map<String, dynamic>.from(x)),
),
),
);
Map<String, dynamic> toJson() => {
'matchTitle': matchTitle,
'opponentId': opponentId,
'teamA': teamA == null
? []
: List<dynamic>.from(teamA!.map((x) => x.toJson())),
'teamB': teamB == null
? []
: List<dynamic>.from(teamB!.map((x) => x.toJson())),
};
}
class Team {
String? teamName;
List<Player>? players;
Team({this.teamName, this.players});
String get playerNames => (players ?? const <Player>[])
.map((player) => player.name?.trim() ?? '')
.where((name) => name.isNotEmpty)
.join('');
/// 队长 Player.id;无队长则取首个选手
String? get leaderUserId {
final list = players ?? const <Player>[];
for (final player in list) {
final id = player.id?.trim() ?? '';
if (player.isLeader == true && id.isNotEmpty) return id;
}
if (list.isEmpty) return null;
final firstId = list.first.id?.trim() ?? '';
return firstId.isEmpty ? null : firstId;
}
factory Team.fromJson(Map<String, dynamic> json) => Team(
teamName: json['teamName']?.toString(),
players: json['players'] == null
? const []
: List<Player>.from(
(json['players'] as List).whereType<Map>().map(
(x) => Player.fromJson(Map<String, dynamic>.from(x)),
),
),
);
Map<String, dynamic> toJson() => {
'teamName': teamName,
'players': players == null
? []
: List<dynamic>.from(players!.map((x) => x.toJson())),
};
}
class Player {
String? id;
String? name;
bool? isLeader;
Player({this.id, this.name, this.isLeader});
factory Player.fromJson(Map<String, dynamic> json) => Player(
id: json['id']?.toString(),
name: json['name']?.toString(),
isLeader: json['isLeader'] as bool?,
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'isLeader': isLeader,
};
}
@@ -1,162 +1,248 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/core/network/api_exception.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
import 'package:recording_tool/features/competition_teams/view_model/view_model_competition_teams.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team_detail.dart';
import 'package:recording_tool/features/competition_teams/widgets/widget_manual_winner_dialog.dart';
import 'package:recording_tool/features/events/request_model/request_model_event.dart';
import 'package:recording_tool/features/events/server/server_events.dart';
import 'package:recording_tool/gen/assets.gen.dart';
import 'package:recording_tool/shared/widgets/app_bar.dart';
import 'package:recording_tool/shared/widgets/app_empty_view.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
class CompetitionTeamDetailPage extends ConsumerWidget {
const CompetitionTeamDetailPage({super.key, required this.itemId});
class CompetitionTeamDetailPage extends ConsumerStatefulWidget {
const CompetitionTeamDetailPage({super.key, required this.detail});
final String itemId;
final CompetitionTeamDetail detail;
@override
Widget build(BuildContext context, WidgetRef ref) {
final items = ref.watch(
competitionTeamsProvider.select((state) => state.items),
ConsumerState<CompetitionTeamDetailPage> createState() =>
_CompetitionTeamDetailPageState();
}
class _CompetitionTeamDetailPageState
extends ConsumerState<CompetitionTeamDetailPage> {
/// matchupIndex -> winner side index0=teamA1=teamB
final Map<int, int> _winners = {};
CompetitionTeamDetail get _detail => widget.detail;
String get _title {
final name = _detail.itemName?.trim() ?? '';
final group = _detail.groupName?.trim() ?? '';
if (name.isEmpty) return '参赛队伍';
return group.isEmpty ? name : '$name $group';
}
List<Matchup> get _matchups => _detail.matchups ?? const <Matchup>[];
CompetitionTeam _toCompetitionTeam(Team team, {required String fallbackId}) {
return CompetitionTeam(
id: team.leaderUserId ?? fallbackId,
name: team.teamName?.trim().isNotEmpty == true ? team.teamName! : '未命名队伍',
players: (team.players ?? const <Player>[])
.map(
(player) => CompetitionPlayer(
id: player.id?.trim() ?? '',
name: player.name?.trim().isNotEmpty == true
? player.name!
: '选手',
),
)
.toList(growable: false),
);
CompetitionTeamListItem? item;
for (final candidate in items) {
if (candidate.id == itemId) {
item = candidate;
break;
}
}
CompetitionMatchup? _toCompetitionMatchup(Matchup matchup, int index) {
final teamA = matchup.teamA;
final teamB = matchup.teamB;
if (teamA == null || teamA.isEmpty || teamB == null || teamB.isEmpty) {
return null;
}
final winnerSide = _winners[index];
final convertedA = _toCompetitionTeam(teamA.first, fallbackId: 'team-a');
final convertedB = _toCompetitionTeam(teamB.first, fallbackId: 'team-b');
return CompetitionMatchup(
id: '${_detail.scheduleId ?? _detail.itemId ?? 'match'}-$index',
teamA: convertedA,
teamB: convertedB,
winnerTeamId: winnerSide == 0
? convertedA.id
: winnerSide == 1
? convertedB.id
: null,
);
}
@override
Widget build(BuildContext context) {
final matchups = _matchups;
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(title: Text(item?.title ?? '参赛队伍')),
body: item == null
? const AppEmptyView(message: '未找到对阵信息')
backgroundColor: const Color(0xFFF2F4F7),
appBar: AppPageBar(title: _title),
body: matchups.isEmpty
? const AppEmptyView(message: '暂无对阵信息')
: ListView.separated(
padding: EdgeInsets.fromLTRB(20.w, 24.h, 20.w, 36.h),
itemCount: item.matchups.length,
separatorBuilder: (_, _) => SizedBox(height: 22.h),
padding: EdgeInsets.only(bottom: 36.h),
itemCount: matchups.length,
separatorBuilder: (_, _) => SizedBox(height: 12.h),
itemBuilder: (context, index) {
final matchup = item!.matchups[index];
final raw = matchups[index];
final matchup = _toCompetitionMatchup(raw, index);
if (matchup == null) {
return const SizedBox.shrink();
}
return _MatchupCard(
matchup: matchup,
index: index,
onManualProcess: () => _handleManualProcess(
context,
ref,
itemId: item!.id,
matchup: matchup,
),
onManualProcess: () => _handleManualProcess(raw, index),
);
},
),
);
}
Future<void> _handleManualProcess(
BuildContext context,
WidgetRef ref, {
required String itemId,
required CompetitionMatchup matchup,
}) async {
final winnerTeamId = await ManualWinnerDialog.show(
context,
matchup: matchup,
);
if (winnerTeamId == null || !context.mounted) return;
Team? _sideTeam(Matchup matchup, {required bool isTeamA}) {
final list = isTeamA ? matchup.teamA : matchup.teamB;
if (list == null || list.isEmpty) return null;
return list.first;
}
ref
.read(competitionTeamsProvider.notifier)
.selectWinner(
itemId: itemId,
matchupId: matchup.id,
winnerTeamId: winnerTeamId,
);
final winnerName = winnerTeamId == matchup.teamA.id
? matchup.teamA.name
: matchup.teamB.name;
AppToast.show('已设置$winnerName直接获胜');
Future<void> _handleManualProcess(Matchup raw, int index) async {
final winnerSide = await ManualWinnerDialog.show(
context,
matchup: raw,
initialWinnerSideIndex: _winners[index],
);
if (winnerSide == null || !mounted) return;
final teamA = _sideTeam(raw, isTeamA: true);
final teamB = _sideTeam(raw, isTeamA: false);
final winnerTeam = winnerSide == 0 ? teamA : teamB;
final loserTeam = winnerSide == 0 ? teamB : teamA;
final winnerLeaderId = winnerTeam?.leaderUserId;
final loserLeaderId = loserTeam?.leaderUserId;
if (winnerLeaderId == null ||
winnerLeaderId.isEmpty ||
loserLeaderId == null ||
loserLeaderId.isEmpty) {
AppToast.show('无法识别双方队长');
return;
}
try {
await ref
.read(eventsServerProvider)
.setTeamStatus(
req: SetTeamStatusReq(
scheduleId: _detail.scheduleId ?? '',
opponentId: raw.opponentId ?? '',
teamScore: [
TeamScore(
userId: winnerLeaderId,
firstHalfScore: 0,
secondHalfScore: 0,
decisiveScore: 0,
ifWithdraw: true,
),
TeamScore(
userId: loserLeaderId,
firstHalfScore: 0,
secondHalfScore: 0,
decisiveScore: 0,
ifWithdraw: false,
),
],
),
);
if (!mounted) return;
setState(() => _winners[index] = winnerSide);
final winnerName = winnerTeam?.teamName?.trim().isNotEmpty == true
? winnerTeam!.teamName!
: '胜方';
AppToast.show('已设置$winnerName直接获胜');
} catch (error) {
if (!mounted) return;
final message = error is ApiException && error.message.isNotEmpty
? error.message
: '设置失败,请重试';
AppToast.show(message);
}
}
}
class _MatchupCard extends StatelessWidget {
const _MatchupCard({
required this.matchup,
required this.index,
required this.onManualProcess,
});
const _MatchupCard({required this.matchup, required this.onManualProcess});
/// 背景图 image_team_vs_bg.png 的原始宽高比(1920 x 318)。
static const double _vsBgAspectRatio = 1920 / 318;
final CompetitionMatchup matchup;
final int index;
final VoidCallback onManualProcess;
@override
Widget build(BuildContext context) {
return Container(
key: ValueKey('competition-matchup-${matchup.id}'),
padding: EdgeInsets.fromLTRB(16.w, 10.h, 16.w, 18.h),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFC7CCD4)),
borderRadius: BorderRadius.circular(12.r),
),
color: Colors.white,
padding: EdgeInsets.fromLTRB(12.w, 4.h, 12.w, 24.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Text(
'${index + 1}',
Align(
alignment: Alignment.centerRight,
child: TextButton(
key: ValueKey('manual-process-${matchup.id}'),
onPressed: onManualProcess,
style: TextButton.styleFrom(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
'人工处理',
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF7A828E),
fontSize: 15.sp,
fontWeight: FontWeight.w500,
color: const Color(0xFF078AF2),
),
),
const Spacer(),
TextButton(
key: ValueKey('manual-process-${matchup.id}'),
onPressed: onManualProcess,
child: Text(
'人工处理',
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF078AF2),
),
),
),
],
),
),
SizedBox(height: 8.h),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: _TeamPanel(
team: matchup.teamA,
alignment: CrossAxisAlignment.start,
winner: matchup.winnerTeamId == matchup.teamA.id,
accentColor: const Color(0xFFFF6B75),
AspectRatio(
aspectRatio: _vsBgAspectRatio,
child: DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(Assets.images.imageTeamVsBg.path),
fit: BoxFit.fill,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: Text(
'VS',
style: TextStyle(
fontSize: 21.sp,
fontWeight: FontWeight.w700,
color: const Color(0xFF303640),
child: Row(
children: [
Expanded(
child: _TeamPanel(
team: matchup.teamA,
isLeft: true,
winner: matchup.winnerTeamId == matchup.teamA.id,
accentColor: const Color(0xFFFF6B75),
),
),
),
Expanded(
child: _TeamPanel(
team: matchup.teamB,
isLeft: false,
winner: matchup.winnerTeamId == matchup.teamB.id,
accentColor: const Color(0xFF12A6C8),
),
),
],
),
Expanded(
child: _TeamPanel(
team: matchup.teamB,
alignment: CrossAxisAlignment.end,
textAlign: TextAlign.end,
winner: matchup.winnerTeamId == matchup.teamB.id,
accentColor: const Color(0xFF20BFA9),
),
),
],
),
),
],
),
@@ -167,83 +253,72 @@ class _MatchupCard extends StatelessWidget {
class _TeamPanel extends StatelessWidget {
const _TeamPanel({
required this.team,
required this.alignment,
required this.isLeft,
required this.winner,
required this.accentColor,
this.textAlign = TextAlign.start,
});
final CompetitionTeam team;
final CrossAxisAlignment alignment;
final TextAlign textAlign;
/// 左半区(红色面板,右对齐、略偏上);右半区(蓝色面板,左对齐、略偏下)。
final bool isLeft;
final bool winner;
final Color accentColor;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: alignment,
final textAlign = isLeft ? TextAlign.end : TextAlign.start;
final nameRow = Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
decoration: BoxDecoration(
color: winner
? accentColor.withValues(alpha: 0.14)
: Colors.transparent,
borderRadius: BorderRadius.circular(8.r),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
team.name,
textAlign: textAlign,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF282E37),
),
),
),
if (winner) ...[
SizedBox(width: 5.w),
Icon(Icons.emoji_events, size: 17.r, color: accentColor),
],
],
),
),
SizedBox(height: 10.h),
Text(
team.playerNames,
textAlign: textAlign,
style: TextStyle(
fontSize: 15.sp,
height: 1.45,
color: const Color(0xFF4E5662),
Flexible(
child: Text(
team.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: textAlign,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF282E37),
),
),
),
if (winner) ...[
SizedBox(height: 8.h),
Container(
SizedBox(width: 4.w),
Icon(
Icons.emoji_events,
key: ValueKey('winner-${team.id}'),
padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 3.h),
decoration: BoxDecoration(
color: accentColor,
borderRadius: BorderRadius.circular(10.r),
),
child: Text(
'胜方',
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
size: 14.r,
color: accentColor,
),
],
],
);
return Padding(
// 中线两侧留出 VS 图案空间,外侧避开斜切边缘。
padding: isLeft
? EdgeInsets.only(left: 16.w, right: 30.w, bottom: 10.h)
: EdgeInsets.only(left: 30.w, right: 16.w, top: 10.h),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: isLeft
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
nameRow,
SizedBox(height: 4.h),
Text(
team.playerNames,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: textAlign,
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF4E5662)),
),
],
),
);
}
}
@@ -4,11 +4,14 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
import 'package:recording_tool/features/competition_teams/pages/page_competition_team_detail.dart';
import 'package:recording_tool/features/competition_teams/server/server_competition_teams.dart';
import 'package:recording_tool/features/competition_teams/view_model/view_model_competition_teams.dart';
import 'package:recording_tool/shared/widgets/app_bar.dart';
import 'package:recording_tool/shared/widgets/app_empty_view.dart';
import 'package:recording_tool/shared/widgets/app_error_view.dart';
import 'package:recording_tool/shared/widgets/app_loading_view.dart';
import 'package:recording_tool/shared/widgets/app_refresh_list.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
class CompetitionTeamListPage extends ConsumerStatefulWidget {
const CompetitionTeamListPage({super.key});
@@ -32,8 +35,8 @@ class _CompetitionTeamListPageState
Widget build(BuildContext context) {
final state = ref.watch(competitionTeamsProvider);
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(title: const Text('参赛队伍')),
backgroundColor: const Color(0xFFF5F6F8),
appBar: AppPageBar(title: '参赛队伍'),
body: SafeArea(
top: false,
child: Builder(
@@ -54,16 +57,29 @@ class _CompetitionTeamListPageState
onRefresh: ref.read(competitionTeamsProvider.notifier).refresh,
onLoadMore: ref.read(competitionTeamsProvider.notifier).loadMore,
enablePullUp: state.hasMore,
padding: EdgeInsets.fromLTRB(20.w, 18.h, 20.w, 28.h),
separator: SizedBox(height: 14.h),
padding: EdgeInsets.fromLTRB(10.w, 10.h, 10.w, 24.h),
separator: SizedBox(height: 8.h),
empty: const AppEmptyView(message: '暂无参赛队伍'),
itemBuilder: (context, item, index) {
return _CompetitionScheduleCard(
item: item,
onTap: () => AppNavigator.push(
CompetitionTeamDetailPage(itemId: item.id),
context: context,
),
onTap: () async {
try {
final detail = await ref
.read(competitionTeamsServerProvider)
.fetchTeamDetail(
itemId: item.itemId,
eventId: item.eventId,
);
if (!context.mounted) return;
AppNavigator.push(
CompetitionTeamDetailPage(detail: detail),
context: context,
);
} catch (error) {
AppToast.show('对阵详情加载失败,请重试');
}
},
);
},
);
@@ -83,30 +99,25 @@ class _CompetitionScheduleCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Material(
key: ValueKey('competition-team-item-${item.id}'),
key: ValueKey('competition-team-item-${item.itemId}'),
color: Colors.white,
borderRadius: BorderRadius.circular(14.r),
borderRadius: BorderRadius.circular(6.r),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14.r),
borderRadius: BorderRadius.circular(6.r),
child: Container(
constraints: BoxConstraints(minHeight: 142.h),
padding: EdgeInsets.all(16.r),
constraints: BoxConstraints(minHeight: 86.h),
padding: EdgeInsets.fromLTRB(12.w, 10.h, 14.w, 10.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14.r),
border: Border.all(color: const Color(0xFFD7DBE2)),
boxShadow: const [
BoxShadow(
color: Color(0x0F1A2230),
blurRadius: 16,
offset: Offset(0, 6),
),
],
color: Colors.white,
borderRadius: BorderRadius.circular(6.r),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
@@ -114,46 +125,22 @@ class _CompetitionScheduleCard extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 19.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF20242B),
fontSize: 13.sp,
height: 1.2,
color: const Color(0xFF30343A),
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 18.h),
_InfoLine(
icon: Icons.location_on_outlined,
text: item.matchPlace,
),
SizedBox(height: 12.h),
_InfoLine(
icon: Icons.schedule_outlined,
text: item.scheduleTime,
),
SizedBox(height: 6.h),
_MetaText(_formatScheduleTime(item)),
],
),
),
SizedBox(width: 14.w),
Container(
width: 72.w,
height: 72.h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: item.completed
? const Color(0xFFF1F3F6)
: const Color(0xFFEAF3FF),
borderRadius: BorderRadius.circular(16.r),
),
child: Text(
item.completed ? '已完成' : item.matchPlace,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: item.completed ? 14.sp : 20.sp,
fontWeight: FontWeight.w700,
color: item.completed
? const Color(0xFF69717D)
: const Color(0xFF147FEA),
),
),
SizedBox(width: 12.w),
Icon(
Icons.chevron_right,
size: 20.r,
color: const Color(0xFF9AA3AF),
),
],
),
@@ -163,27 +150,40 @@ class _CompetitionScheduleCard extends StatelessWidget {
}
}
class _InfoLine extends StatelessWidget {
const _InfoLine({required this.icon, required this.text});
class _MetaText extends StatelessWidget {
const _MetaText(this.text);
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 18.r, color: const Color(0xFF7B8491)),
SizedBox(width: 8.w),
Expanded(
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 15.sp, color: const Color(0xFF525B68)),
),
),
],
return Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10.sp,
height: 1.2,
color: const Color(0xFF6E747D),
fontWeight: FontWeight.w400,
),
);
}
}
String _formatScheduleTime(CompetitionTeamListItem item) {
final start = DateTime.tryParse(item.matchStartTime);
final end = DateTime.tryParse(item.matchEndTime);
if (start == null && end == null) {
return item.scheduleTime.isEmpty ? '时间待定' : item.scheduleTime;
}
if (start != null && end != null) {
return '${start.month}${start.day}${_formatClock(start)}-${_formatClock(end)}';
}
final value = start ?? end!;
return '${value.month}${value.day}${_formatClock(value)}';
}
String _formatClock(DateTime value) {
return '${value.hour}:${value.minute.toString().padLeft(2, '0')}';
}
@@ -1,86 +1,123 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/config/app_config.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/core/network/api_exception.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team_detail.dart';
import 'package:recording_tool/features/competition_teams/widgets/widget_manual_winner_dialog.dart';
import 'package:recording_tool/features/events/model/model_event_info.dart';
import 'package:recording_tool/shared/widgets/app_button.dart';
import 'package:recording_tool/features/events/request_model/request_model_event.dart';
import 'package:recording_tool/features/events/server/server_events.dart';
import 'package:recording_tool/gen/assets.gen.dart';
import 'package:recording_tool/shared/widgets/app_bar.dart';
import 'package:recording_tool/shared/widgets/app_qr_scanner_dialog.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
import 'package:recording_tool/shared/widgets/app_webview.dart';
typedef TeamQrScanner = Future<String?> Function(BuildContext context);
class EventTeamMatchPage extends StatefulWidget {
class EventTeamMatchPage extends ConsumerStatefulWidget {
const EventTeamMatchPage({
super.key,
required this.item,
required this.playerId,
this.qrScanner,
required this.detail,
required this.item,
});
final EventRegistrationItem item;
final CompetitionTeamDetail detail;
final String playerId;
final TeamQrScanner? qrScanner;
final EventRegistrationItem item;
@override
State<EventTeamMatchPage> createState() => _EventTeamMatchPageState();
ConsumerState<EventTeamMatchPage> createState() => _EventTeamMatchPageState();
}
class _EventTeamMatchPageState extends State<EventTeamMatchPage> {
class _EventTeamMatchPageState extends ConsumerState<EventTeamMatchPage> {
final Set<String> _verifiedUserIds = <String>{};
String? _winnerTeamId;
CompetitionTeam get _homeTeam {
final leader = widget.item.teamLeader;
final teamId = leader?.userId.isNotEmpty == true
? leader!.userId
: widget.item.userId.isNotEmpty
? widget.item.userId
: 'home-team';
final teamName = leader?.name.isNotEmpty == true
? leader!.name
: widget.item.playerName.isNotEmpty
? widget.item.playerName
: '本方队伍';
return CompetitionTeam(
id: teamId,
name: teamName,
players: widget.item.teamMembers
.map(
(member) => CompetitionPlayer(id: member.userId, name: member.name),
)
.toList(growable: false),
);
/// 0 = 红队(teamA)1 = 蓝队(teamB)
int? _winnerSideIndex;
CompetitionTeamDetail get _detail => widget.detail;
/// 备注:teamA 为红队,teamB 为蓝队。
Matchup? get _firstMatchup {
final matchups = _detail.matchups;
if (matchups == null || matchups.isEmpty) return null;
return matchups.first;
}
CompetitionTeam get _opponentTeam {
final opponentId = widget.item.opponentId.isNotEmpty
? widget.item.opponentId
: 'opponent-team';
final opponentName = widget.item.opponentName.isNotEmpty
? widget.item.opponentName
: '对方队伍';
return CompetitionTeam(
id: opponentId,
name: opponentName,
players:
widget.item.opponentId.isEmpty && widget.item.opponentName.isEmpty
? const []
: [CompetitionPlayer(id: opponentId, name: opponentName)],
);
/// 红队(teamA
Team? get _redRawTeam {
final teams = _firstMatchup?.teamA;
if (teams == null || teams.isEmpty) return null;
return teams.first;
}
CompetitionMatchup get _matchup => CompetitionMatchup(
id: widget.item.scheduleId.isNotEmpty
? widget.item.scheduleId
: '${widget.item.itemId}-team-match',
teamA: _homeTeam,
teamB: _opponentTeam,
winnerTeamId: _winnerTeamId,
/// 蓝队(teamB
Team? get _blueRawTeam {
final teams = _firstMatchup?.teamB;
if (teams == null || teams.isEmpty) return null;
return teams.first;
}
CompetitionTeam get _redTeam => _toCompetitionTeam(
_redRawTeam,
fallbackId: 'red-team',
fallbackName: '红队',
);
CompetitionTeam get _blueTeam => _toCompetitionTeam(
_blueRawTeam,
fallbackId: 'blue-team',
fallbackName: '蓝队',
);
List<EventTeamMember> get _redMembers =>
_toEventMembers(_redRawTeam?.players);
List<EventTeamMember> get _blueMembers =>
_toEventMembers(_blueRawTeam?.players);
CompetitionTeam _toCompetitionTeam(
Team? team, {
required String fallbackId,
required String fallbackName,
}) {
final id = team?.leaderUserId ?? '';
final name = team?.teamName?.trim() ?? '';
final players = (team?.players ?? const <Player>[])
.map(
(player) => CompetitionPlayer(
id: player.id?.trim() ?? '',
name: player.name?.trim() ?? '',
),
)
.toList(growable: false);
return CompetitionTeam(
id: id.isNotEmpty ? id : fallbackId,
name: name.isNotEmpty ? name : fallbackName,
players: players,
);
}
List<EventTeamMember> _toEventMembers(List<Player>? players) {
if (players == null || players.isEmpty) return const [];
return players
.map(
(player) => EventTeamMember(
userId: player.id?.trim() ?? '',
name: player.name?.trim() ?? '',
isLeader: player.isLeader ?? false,
),
)
.toList(growable: false);
}
@override
void initState() {
super.initState();
@@ -92,18 +129,15 @@ class _EventTeamMatchPageState extends State<EventTeamMatchPage> {
bool _isKnownMember(String userId) {
if (userId.isEmpty) return false;
if (userId == widget.item.opponentId) return true;
return widget.item.teamMembers.any((member) => member.userId == userId);
return _redMembers.any((member) => member.userId == userId) ||
_blueMembers.any((member) => member.userId == userId);
}
String _memberNameOf(String userId) {
if (userId == widget.item.opponentId) {
return widget.item.opponentName.isEmpty
? '对方队长'
: widget.item.opponentName;
}
for (final member in widget.item.teamMembers) {
if (member.userId == userId) return member.name;
for (final member in [..._redMembers, ..._blueMembers]) {
if (member.userId == userId) {
return member.name.isEmpty ? '参赛成员' : member.name;
}
}
return '';
}
@@ -125,16 +159,70 @@ class _EventTeamMatchPageState extends State<EventTeamMatchPage> {
}
Future<void> _manualProcess() async {
final winnerTeamId = await ManualWinnerDialog.show(
final matchup = _firstMatchup;
if (matchup == null) {
AppToast.show('暂无对阵信息');
return;
}
final winnerSide = await ManualWinnerDialog.show(
context,
matchup: _matchup,
matchup: matchup,
initialWinnerSideIndex: _winnerSideIndex,
);
if (!mounted || winnerTeamId == null) return;
setState(() => _winnerTeamId = winnerTeamId);
final winnerName = winnerTeamId == _homeTeam.id
? _homeTeam.name
: _opponentTeam.name;
AppToast.show('已设置$winnerName直接获胜');
if (!mounted || winnerSide == null) return;
final winnerTeam = winnerSide == 0 ? _redRawTeam : _blueRawTeam;
final loserTeam = winnerSide == 0 ? _blueRawTeam : _redRawTeam;
final winnerLeaderId = winnerTeam?.leaderUserId;
final loserLeaderId = loserTeam?.leaderUserId;
if (winnerLeaderId == null ||
winnerLeaderId.isEmpty ||
loserLeaderId == null ||
loserLeaderId.isEmpty) {
AppToast.show('无法识别双方队长');
return;
}
try {
await ref
.read(eventsServerProvider)
.setTeamStatus(
req: SetTeamStatusReq(
scheduleId: _detail.scheduleId ?? '',
opponentId: widget.item.opponentId,
teamScore: [
TeamScore(
userId: winnerLeaderId,
firstHalfScore: 0,
secondHalfScore: 0,
decisiveScore: 0,
ifWithdraw: true,
),
TeamScore(
userId: loserLeaderId,
firstHalfScore: 0,
secondHalfScore: 0,
decisiveScore: 0,
ifWithdraw: false,
),
],
),
);
if (!mounted) return;
setState(() => _winnerSideIndex = winnerSide);
final winnerName = winnerTeam?.teamName?.trim().isNotEmpty == true
? winnerTeam!.teamName!
: (winnerSide == 0 ? _redTeam.name : _blueTeam.name);
AppToast.show('已设置$winnerName直接获胜');
} catch (error) {
if (!mounted) return;
final message = error is ApiException && error.message.isNotEmpty
? error.message
: '设置失败,请重试';
AppToast.show(message);
}
}
void _startDirectly() {
@@ -146,82 +234,95 @@ class _EventTeamMatchPageState extends State<EventTeamMatchPage> {
@override
Widget build(BuildContext context) {
final homeTeam = _homeTeam;
final opponentTeam = _opponentTeam;
final redTeam = _redTeam;
final blueTeam = _blueTeam;
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(),
backgroundColor: const Color(0xFFF6F7F9),
appBar: myAppBar(
context: context,
titleWidget: Text(
'选手检录',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
color: Colors.white,
fontFamily: 'PingFang SC',
),
),
actions: [
TextButton(
key: const ValueKey('event-team-manual-process'),
onPressed: _manualProcess,
style: TextButton.styleFrom(
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 14.w),
minimumSize: Size(72.w, kToolbarHeight),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
'人工处理',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: 0.8),
fontFamily: 'PingFang SC',
),
),
),
],
),
body: SafeArea(
top: false,
child: Column(
children: [
// SizedBox(height: 10.h),
_MatchMetadata(detail: _detail),
// SizedBox(height: 12.h),
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 20.h),
padding: EdgeInsets.symmetric(horizontal: 0.w, vertical: 20.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_MatchMetadata(item: widget.item),
SizedBox(height: 16.h),
Align(
alignment: Alignment.centerRight,
child: TextButton(
key: const ValueKey('event-team-manual-process'),
onPressed: _manualProcess,
child: Text(
'人工处理',
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w700,
color: const Color(0xFF078AF2),
),
),
),
),
_TeamCard(
team: homeTeam,
members: widget.item.teamMembers,
backgroundColor: const Color(0xFFFFEEF0),
sideLabel: '红方',
team: redTeam,
members: _redMembers,
backgroundImage: Assets.images.imageRedTeam.path,
accentColor: const Color(0xFFE84B5B),
verifiedUserIds: _verifiedUserIds,
winner: _winnerTeamId == homeTeam.id,
winner: _winnerSideIndex == 0,
),
SizedBox(height: 16.h),
SizedBox(height: 8.h),
_TeamCard(
team: opponentTeam,
members: [
EventTeamMember(
userId: widget.item.opponentId,
name: widget.item.opponentName,
isLeader: true,
),
],
backgroundColor: const Color(0xFFEDF5FF),
sideLabel: '蓝方',
team: blueTeam,
members: _blueMembers,
backgroundImage: Assets.images.imageBlueTeam.path,
accentColor: const Color(0xFF287FDD),
verifiedUserIds: _verifiedUserIds,
winner: _winnerTeamId == opponentTeam.id,
emptyMessage: '暂无对方成员数据',
winner: _winnerSideIndex == 1,
emptyMessage: '暂无蓝队成员数据',
),
],
),
),
),
Padding(
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 20.h),
padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 24.h),
child: Column(
children: [
SizedBox(
width: double.infinity,
child: AppButton(
label: '继续扫码',
variant: AppButtonVariant.outline,
onPressed: _continueScan,
),
_TeamActionButton(
label: '继续扫码',
iconPath: Assets.images.imageScan.path,
onPressed: _continueScan,
filled: true,
),
SizedBox(height: 12.h),
SizedBox(
width: double.infinity,
child: AppButton(label: '直接开赛', onPressed: _startDirectly),
_TeamActionButton(
label: '直接开赛',
iconPath: 'assets/images/image_start.png',
onPressed: _startDirectly,
),
],
),
@@ -245,33 +346,41 @@ WebviewPage buildTeamScorePage({
}
class _MatchMetadata extends StatelessWidget {
const _MatchMetadata({required this.item});
const _MatchMetadata({required this.detail});
final EventRegistrationItem item;
final CompetitionTeamDetail detail;
@override
Widget build(BuildContext context) {
final itemName = detail.itemName?.trim() ?? '';
final matchPlace = detail.matchPlace?.trim() ?? '';
final groupName = detail.groupName?.trim() ?? '';
return Container(
width: double.infinity,
padding: EdgeInsets.all(18.r),
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 12.h),
decoration: BoxDecoration(
color: const Color(0xFFF7F9FC),
borderRadius: BorderRadius.circular(14.r),
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 12.r,
offset: Offset(0, 4.h),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
child: Row(
children: [
Row(
children: [
Expanded(
child: _MetadataText(label: '比赛项目', value: item.itemName),
),
SizedBox(width: 16.w),
_MetadataText(label: '场地', value: item.matchPlace),
],
Expanded(
child: _MetadataText(label: '场地', value: matchPlace),
),
SizedBox(width: 6.w),
Expanded(
child: _MetadataText(label: '赛项', value: itemName),
),
SizedBox(width: 6.w),
Expanded(
child: _MetadataText(label: '组别', value: groupName),
),
SizedBox(height: 14.h),
_MetadataText(label: '组别', value: item.groupName),
],
),
);
@@ -288,13 +397,14 @@ class _MetadataText extends StatelessWidget {
Widget build(BuildContext context) {
return Text(
'$label${value.isEmpty ? '暂无' : value}',
maxLines: 2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 17.sp,
fontSize: 13.sp,
height: 1.35,
color: const Color(0xFF303640),
color: const Color(0xFF858B95),
fontWeight: FontWeight.w600,
fontFamily: 'PingFang SC',
),
);
}
@@ -302,18 +412,20 @@ class _MetadataText extends StatelessWidget {
class _TeamCard extends StatelessWidget {
const _TeamCard({
required this.sideLabel,
required this.team,
required this.members,
required this.backgroundColor,
required this.backgroundImage,
required this.accentColor,
required this.verifiedUserIds,
required this.winner,
this.emptyMessage = '暂无成员数据',
});
final String sideLabel;
final CompetitionTeam team;
final List<EventTeamMember> members;
final Color backgroundColor;
final String backgroundImage;
final Color accentColor;
final Set<String> verifiedUserIds;
final bool winner;
@@ -324,30 +436,39 @@ class _TeamCard extends StatelessWidget {
final visibleMembers = members
.where((member) => member.userId.isNotEmpty || member.name.isNotEmpty)
.toList(growable: false);
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
final leader = _leaderOf(visibleMembers);
final players = visibleMembers
.where((member) => !identical(member, leader))
.toList(growable: false);
final leaderVerified =
leader != null && verifiedUserIds.contains(leader.userId);
return Container(
width: double.infinity,
padding: EdgeInsets.all(18.r),
constraints: BoxConstraints(minHeight: 106.h),
padding: EdgeInsets.fromLTRB(12.w, 16.h, 16.w, 12.h),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(14.r),
border: Border.all(
color: winner ? accentColor : accentColor.withValues(alpha: 0.24),
width: winner ? 2 : 1,
image: DecorationImage(
image: AssetImage(backgroundImage),
fit: BoxFit.fill,
),
color: Colors.white,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
'队长${team.name}',
'$sideLabel队伍名称${team.name}队伍',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 19.sp,
fontSize: 16.sp,
fontWeight: FontWeight.w700,
color: const Color(0xFF252B34),
color: const Color(0xFF30343B),
fontFamily: 'PingFang SC',
),
),
),
@@ -366,25 +487,150 @@ class _TeamCard extends StatelessWidget {
'胜方',
style: TextStyle(
color: Colors.white,
fontSize: 12.sp,
fontSize: 11.sp,
fontWeight: FontWeight.w700,
fontFamily: 'PingFang SC',
),
),
),
],
),
SizedBox(height: 14.h),
SizedBox(height: 6.h),
if (visibleMembers.isEmpty)
Text(
emptyMessage,
style: TextStyle(fontSize: 15.sp, color: const Color(0xFF747D89)),
style: TextStyle(
fontSize: 13.sp,
color: const Color(0xFF747D89),
fontFamily: 'PingFang SC',
),
)
else
...visibleMembers.map(
(member) => _MemberRow(
member: member,
verified: verifiedUserIds.contains(member.userId),
accentColor: accentColor,
else ...[
if (leader != null)
_TeamTextLine(
label: '队长',
text: _formatMember(leader),
showOk: verifiedUserIds.contains(leader.userId),
),
if (players.isNotEmpty)
Row(
children: [
Text(
'选手:',
style: TextStyle(
fontSize: 13.sp,
height: 1.35,
color: const Color(0xFF747B86),
fontWeight: FontWeight.w500,
fontFamily: 'PingFang SC',
),
),
...players.map(
(member) => Row(
children: [
Text(
_formatMember(member),
style: TextStyle(
fontSize: 13.sp,
height: 1.35,
color: const Color(0xFF4D545F),
),
),
if (verifiedUserIds.contains(member.userId))
Padding(
padding: EdgeInsets.only(left: 6.w),
child: Image.asset(
Assets.images.imageOk.path,
width: 18.w,
height: 14.h,
fit: BoxFit.contain,
),
),
Text(
(member != players.last ? '' : ''),
style: TextStyle(
fontSize: 13.sp,
height: 1.35,
color: const Color(0xFF4D545F),
),
),
],
),
),
],
),
],
],
),
);
}
EventTeamMember? _leaderOf(List<EventTeamMember> members) {
for (final member in members) {
if (member.isLeader) return member;
}
return members.isEmpty ? null : members.first;
}
String _formatMember(EventTeamMember member) {
final name = member.name.trim().isEmpty ? '暂无' : member.name.trim();
// final id = member.userId.trim();
// return id.isEmpty ? name : '$name$id';
return name;
}
}
class _TeamTextLine extends StatelessWidget {
const _TeamTextLine({
required this.label,
required this.text,
required this.showOk,
});
final String label;
final String text;
final bool showOk;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(top: 4.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'$label',
style: TextStyle(
fontSize: 13.sp,
height: 1.35,
color: const Color(0xFF747B86),
fontWeight: FontWeight.w500,
fontFamily: 'PingFang SC',
),
),
Flexible(
child: Text(
text.isEmpty ? '暂无' : text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
height: 1.35,
color: const Color(0xFF4D545F),
fontWeight: FontWeight.w500,
fontFamily: 'PingFang SC',
),
),
),
if (showOk)
Padding(
padding: EdgeInsets.only(left: 6.w),
child: Image.asset(
Assets.images.imageOk.path,
width: 18.w,
height: 14.h,
fit: BoxFit.contain,
),
),
],
@@ -393,50 +639,78 @@ class _TeamCard extends StatelessWidget {
}
}
class _MemberRow extends StatelessWidget {
const _MemberRow({
required this.member,
required this.verified,
required this.accentColor,
class _TeamActionButton extends StatelessWidget {
const _TeamActionButton({
required this.label,
required this.iconPath,
required this.onPressed,
this.filled = false,
});
final EventTeamMember member;
final bool verified;
final Color accentColor;
final String label;
final String iconPath;
final VoidCallback onPressed;
final bool filled;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 5.h),
child: Row(
children: [
Container(
width: 6.r,
height: 6.r,
decoration: BoxDecoration(
color: accentColor,
shape: BoxShape.circle,
),
final radius = BorderRadius.circular(28.r);
final content = Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
iconPath,
width: filled ? 18.w : 18.w,
height: filled ? 18.h : 18.h,
fit: BoxFit.contain,
),
SizedBox(width: 4.w),
Text(
label,
style: TextStyle(
color: filled ? Colors.white : const Color(0xFF359FED),
fontSize: 16.sp,
fontWeight: FontWeight.w700,
fontFamily: 'PingFang SC',
),
SizedBox(width: 10.w),
Expanded(
child: Text(
'${member.isLeader ? '队长' : '选手'}${member.name.isEmpty ? '暂无' : member.name}',
style: TextStyle(fontSize: 16.sp, color: const Color(0xFF303640)),
),
),
],
);
return SizedBox(
width: double.infinity,
height: 45.h,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: filled
? const LinearGradient(
colors: [Color(0xFF2F8DFF), Color(0xFF57D2EE)],
)
: null,
color: filled ? null : Colors.white,
borderRadius: radius,
border: filled
? null
: Border.all(color: const Color(0xFF9FD0F5), width: 1.w),
boxShadow: filled
? [
BoxShadow(
color: const Color(0xFF2F8DFF).withValues(alpha: 0.24),
blurRadius: 10.r,
offset: Offset(0, 5.h),
),
]
: null,
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onPressed,
borderRadius: radius,
child: Center(child: content),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child: verified
? Icon(
Icons.check_circle,
key: ValueKey('verified-member-${member.userId}'),
color: const Color(0xFF18A957),
size: 24.r,
)
: SizedBox(width: 24.r, height: 24.r),
),
],
),
),
);
}
@@ -1,118 +1,49 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:recording_tool/app/config/api_common.dart';
import 'package:recording_tool/core/network/api_client.dart';
import 'package:recording_tool/core/network/providers/dio_providers.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team_detail.dart';
final competitionTeamsServerProvider = Provider<CompetitionTeamsServer>((ref) {
return const CompetitionTeamsServer();
return CompetitionTeamsServer(ref.watch(apiClientProvider));
});
class CompetitionTeamsServer {
const CompetitionTeamsServer();
CompetitionTeamsServer(this._apiClient);
static const totalCount = 10;
final ApiClient _apiClient;
Future<CompetitionTeamPageResult> fetchPage({
required int page,
required int pageSize,
}) async {
final start = (page - 1) * pageSize;
if (start >= totalCount) {
return CompetitionTeamPageResult(
items: const [],
total: totalCount,
}) {
return _apiClient.get(
AuthApi.getTeamList.path,
queryParameters: {'page': page, 'pageSize': pageSize},
parser: (json) => CompetitionTeamPageResult.fromJson(
json,
page: page,
pageSize: pageSize,
);
}
final end = (start + pageSize).clamp(0, totalCount);
final items = List.generate(
end - start,
(index) => _buildItem(start + index),
);
return CompetitionTeamPageResult(
items: items,
total: totalCount,
page: page,
pageSize: pageSize,
);
}
CompetitionTeamListItem _buildItem(int index) {
final number = index + 1;
final group = index.isEven ? '小学组' : '初中组';
final hour = 9 + index ~/ 2;
return CompetitionTeamListItem(
id: 'competition-$number',
eventName: '全国青少年无人机大赛',
itemName: index % 3 == 0 ? '空中足球赛' : '空中格斗赛',
groupName: group,
matchPlace: '场地 ${index % 4 + 1}',
matchStartTime: '${hour.toString().padLeft(2, '0')}:00',
matchEndTime: '${hour.toString().padLeft(2, '0')}:30',
completed: index == totalCount - 1,
matchups: [
_buildMatchup(itemNumber: number, matchNumber: 1),
_buildMatchup(itemNumber: number, matchNumber: 2),
],
);
}
CompetitionMatchup _buildMatchup({
required int itemNumber,
required int matchNumber,
}) {
final matchupId = 'match-$itemNumber-$matchNumber';
final teamAIndex = (itemNumber + matchNumber - 2) % _teamNames.length;
final teamBIndex = (teamAIndex + 1) % _teamNames.length;
return CompetitionMatchup(
id: matchupId,
teamA: _buildTeam(
id: '$matchupId-team-a',
name: _teamNames[teamAIndex],
playerOffset: teamAIndex * 3,
),
teamB: _buildTeam(
id: '$matchupId-team-b',
name: _teamNames[teamBIndex],
playerOffset: teamBIndex * 3,
),
);
}
CompetitionTeam _buildTeam({
required String id,
required String name,
required int playerOffset,
Future<CompetitionTeamDetail> fetchTeamDetail({
required String itemId,
required String eventId,
String? scheduleId,
String? opponentId,
}) {
return CompetitionTeam(
id: id,
name: name,
players: List.generate(3, (index) {
final playerIndex = (playerOffset + index) % _playerNames.length;
return CompetitionPlayer(
id: '$id-player-$index',
name: _playerNames[playerIndex],
);
}),
return _apiClient.post(
AuthApi.getTeamDetail.path,
data: {
'itemId': itemId,
'eventId': eventId,
'scheduleId': scheduleId,
'opponentId': opponentId,
},
parser: (json) => CompetitionTeamDetail.fromResponse(json),
);
}
static const _teamNames = ['王多鱼队', '钱多多队', '逐风少年队', '蓝翼飞行队', '星火队'];
static const _playerNames = [
'王伟',
'李庆超',
'王大亮',
'韩宁政',
'阮晴桦',
'刘美玲',
'陈子航',
'周白芷',
'林培伦',
'蔡依婷',
'夏志豪',
'赵云飞',
'孙雨泽',
'吴佳琪',
'郑凯文',
];
}
@@ -47,7 +47,7 @@ class CompetitionTeamsViewModel extends StateNotifier<CompetitionTeamsState> {
}) {
final updatedItems = state.items
.map((item) {
if (item.id != itemId) return item;
if (item.itemId != itemId) return item;
final updatedMatchups = item.matchups
.map((matchup) {
if (matchup.id != matchupId) return matchup;
@@ -67,7 +67,7 @@ class CompetitionTeamsViewModel extends StateNotifier<CompetitionTeamsState> {
CompetitionTeamListItem? findItem(String itemId) {
for (final item in state.items) {
if (item.id == itemId) return item;
if (item.itemId == itemId) return item;
}
return null;
}
@@ -1,21 +1,31 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
import 'package:recording_tool/shared/widgets/app_button.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team_detail.dart';
import 'package:recording_tool/gen/assets.gen.dart';
/// 弹窗回传选中侧索引:0 = teamA(红队),1 = teamB(蓝队)
class ManualWinnerDialog extends StatefulWidget {
const ManualWinnerDialog({super.key, required this.matchup});
const ManualWinnerDialog({
super.key,
required this.matchup,
this.initialWinnerSideIndex,
});
final CompetitionMatchup matchup;
final Matchup matchup;
final int? initialWinnerSideIndex;
static Future<String?> show(
static Future<int?> show(
BuildContext context, {
required CompetitionMatchup matchup,
required Matchup matchup,
int? initialWinnerSideIndex,
}) {
return showDialog<String>(
return showDialog<int>(
context: context,
barrierDismissible: false,
builder: (_) => ManualWinnerDialog(matchup: matchup),
builder: (_) => ManualWinnerDialog(
matchup: matchup,
initialWinnerSideIndex: initialWinnerSideIndex,
),
);
}
@@ -24,92 +34,137 @@ class ManualWinnerDialog extends StatefulWidget {
}
class _ManualWinnerDialogState extends State<ManualWinnerDialog> {
String? _selectedTeamId;
/// 0 = teamA1 = teamB
int? _selectedSideIndex;
Team? get _teamA {
final teams = widget.matchup.teamA;
if (teams == null || teams.isEmpty) return null;
return teams.first;
}
Team? get _teamB {
final teams = widget.matchup.teamB;
if (teams == null || teams.isEmpty) return null;
return teams.first;
}
@override
void initState() {
super.initState();
_selectedTeamId = widget.matchup.winnerTeamId;
final initial = widget.initialWinnerSideIndex;
if (initial == 0 || initial == 1) {
_selectedSideIndex = initial;
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
insetPadding: EdgeInsets.symmetric(horizontal: 22.w),
contentPadding: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18.r)),
content: SizedBox(
width: 320.w,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/images/image_dialog_bg.png',
width: 320.w,
height: 112.h,
fit: BoxFit.cover,
),
Padding(
padding: EdgeInsets.fromLTRB(20.w, 8.h, 20.w, 20.h),
child: Column(
children: [
Text(
'请在比赛结束前处理',
key: const ValueKey('manual-winner-dialog-title'),
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w700,
color: const Color(0xFF20242B),
),
),
SizedBox(height: 16.h),
_TeamChoice(
team: widget.matchup.teamA,
color: const Color(0xFFFF6B75),
selected: _selectedTeamId == widget.matchup.teamA.id,
onTap: () => setState(
() => _selectedTeamId = widget.matchup.teamA.id,
),
),
SizedBox(height: 10.h),
_TeamChoice(
team: widget.matchup.teamB,
color: const Color(0xFF20BFA9),
selected: _selectedTeamId == widget.matchup.teamB.id,
onTap: () => setState(
() => _selectedTeamId = widget.matchup.teamB.id,
),
),
SizedBox(height: 20.h),
Row(
children: [
Expanded(
child: AppButton(
label: '取消',
variant: AppButtonVariant.secondary,
onPressed: () => Navigator.of(context).pop(),
),
final teamA = _teamA;
final teamB = _teamB;
final maxHeight = MediaQuery.sizeOf(context).height * 0.78;
return Dialog(
insetPadding: EdgeInsets.symmetric(horizontal: 48.w),
backgroundColor: Colors.transparent,
elevation: 0,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 380.w, maxHeight: maxHeight),
child: Stack(
clipBehavior: Clip.none,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(16.r),
child: Material(
color: Colors.white,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.fromLTRB(18.w, 18.h, 18.w, 18.h),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 26.h),
Text(
'请在比赛结束前处理',
key: const ValueKey('manual-winner-dialog-title'),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18.sp,
height: 1.3,
fontWeight: FontWeight.w700,
color: const Color(0xFF20242B),
fontFamily: 'PingFang SC',
),
),
SizedBox(height: 16.h),
if (teamA != null) ...[
_TeamChoice(
team: teamA,
sideIndex: 0,
color: const Color(0xFFFF6575),
selected: _selectedSideIndex == 0,
onTap: () =>
setState(() => _selectedSideIndex = 0),
),
SizedBox(height: 10.h),
],
if (teamB != null)
_TeamChoice(
team: teamB,
sideIndex: 1,
color: const Color(0xFF21C5AC),
selected: _selectedSideIndex == 1,
onTap: () =>
setState(() => _selectedSideIndex = 1),
),
SizedBox(height: 18.h),
Row(
children: [
Expanded(
child: _DialogActionButton(
label: '取消',
onPressed: () =>
Navigator.of(context).pop(),
),
),
SizedBox(width: 14.w),
Expanded(
child: _DialogActionButton(
label: '确定',
filled: true,
onPressed: _selectedSideIndex == null
? null
: () => Navigator.of(
context,
).pop(_selectedSideIndex),
),
),
],
),
],
),
SizedBox(width: 14.w),
Expanded(
child: AppButton(
label: '确定',
onPressed: _selectedTeamId == null
? null
: () => Navigator.of(
context,
).pop(_selectedTeamId),
),
),
],
),
],
),
],
),
),
),
],
),
),
Positioned(
top: -90.h,
left: 0,
right: 0,
child: Image.asset(
Assets.images.imageDialogBg.path,
width: double.infinity,
height: 112.h,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
),
),
],
),
),
);
@@ -119,37 +174,46 @@ class _ManualWinnerDialogState extends State<ManualWinnerDialog> {
class _TeamChoice extends StatelessWidget {
const _TeamChoice({
required this.team,
required this.sideIndex,
required this.color,
required this.selected,
required this.onTap,
});
final CompetitionTeam team;
final Team team;
final int sideIndex;
final Color color;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final name = team.teamName?.trim().isNotEmpty == true
? team.teamName!
: '未命名队伍';
final players = team.playerNames;
final backgroundColor = selected
? color.withValues(alpha: 0.13)
: color.withValues(alpha: 0.07);
return Semantics(
selected: selected,
button: true,
label: '选择${team.name}直接获胜',
label: '选择$name直接获胜',
child: Material(
color: color.withValues(alpha: selected ? 0.16 : 0.08),
color: backgroundColor,
borderRadius: BorderRadius.circular(12.r),
child: InkWell(
key: ValueKey('winner-choice-${team.id}'),
key: ValueKey('winner-choice-side-$sideIndex'),
onTap: onTap,
borderRadius: BorderRadius.circular(12.r),
child: Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h),
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.r),
border: Border.all(
color: selected ? color : color.withValues(alpha: 0.35),
width: selected ? 2 : 1,
color: selected ? color : color.withValues(alpha: 0.32),
width: 1,
),
),
child: Row(
@@ -160,11 +224,23 @@ class _TeamChoice extends StatelessWidget {
height: 22.r,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: selected ? color : Colors.grey),
color: selected ? color : Colors.transparent,
border: Border.all(
color: selected ? color : const Color(0xFFB0B4BB),
width: 1.2.r,
),
color: Colors.transparent,
),
child: selected
? Icon(Icons.check, size: 15.r, color: Colors.white)
? Center(
child: Container(
width: 10.r,
height: 10.r,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
)
: null,
),
SizedBox(width: 12.w),
@@ -173,31 +249,43 @@ class _TeamChoice extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
team.name,
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w600,
height: 1.25,
fontWeight: FontWeight.w700,
color: const Color(0xFF20242B),
fontFamily: 'PingFang SC',
),
),
SizedBox(height: 4.h),
Text(
team.playerNames,
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF606874),
if (players.isNotEmpty) ...[
SizedBox(height: 4.h),
Text(
players,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.sp,
height: 1.25,
color: const Color(0xFF69717E),
fontFamily: 'PingFang SC',
),
),
),
],
],
),
),
SizedBox(width: 8.w),
Text(
'直接获胜',
maxLines: 1,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
fontSize: 15.sp,
fontWeight: FontWeight.w700,
color: color,
fontFamily: 'PingFang SC',
),
),
],
@@ -208,3 +296,58 @@ class _TeamChoice extends StatelessWidget {
);
}
}
class _DialogActionButton extends StatelessWidget {
const _DialogActionButton({
required this.label,
required this.onPressed,
this.filled = false,
});
final String label;
final VoidCallback? onPressed;
final bool filled;
@override
Widget build(BuildContext context) {
final enabled = onPressed != null;
final radius = BorderRadius.circular(24.r);
final backgroundColor = filled
? (enabled ? null : const Color(0xFFD2D3D8))
: const Color(0xFFF1F1F1);
final gradient = filled && enabled
? const LinearGradient(colors: [Color(0xFF2F8DFF), Color(0xFF58D1EE)])
: null;
return SizedBox(
height: 40.h,
child: DecoratedBox(
decoration: BoxDecoration(
color: backgroundColor,
gradient: gradient,
borderRadius: radius,
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onPressed,
borderRadius: radius,
child: Center(
child: Text(
label,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w500,
color: filled
? (enabled ? Colors.white : const Color(0xFF8D9098))
: const Color(0xFF343941),
fontFamily: 'PingFang SC',
),
),
),
),
),
),
);
}
}
@@ -16,14 +16,21 @@ class StreamKeyReq {
required this.eventId,
required this.itemId,
required this.userId,
required this.scheduleId,
});
final String eventId;
final String itemId;
final String userId;
final String scheduleId;
Map<String, dynamic> toFormDataMap() {
return {'eventId': eventId, 'itemId': itemId, 'userId': userId};
return {
'eventId': eventId,
'itemId': itemId,
'userId': userId,
'scheduleId': scheduleId,
};
}
}
@@ -127,6 +134,7 @@ class EventRegistrationItem {
required this.itemName,
required this.groupName,
required this.matchPlace,
this.matchStartTime = '',
this.matchEndTime = '',
this.completed = false,
@@ -136,6 +144,7 @@ class EventRegistrationItem {
this.userId = '',
this.playerName = '',
this.playerPhone = '',
this.playerNo = '',
});
final String eventId;
@@ -151,6 +160,7 @@ class EventRegistrationItem {
final String opponentId;
final String opponentName;
final List<EventTeamMember> teamMembers;
final String playerNo;
/// 来自报名列表父级,不在 item JSON 内
final String userId;
@@ -198,6 +208,7 @@ class EventRegistrationItem {
: const [],
userId: userId,
playerName: playerName,
playerNo: _readString(map, const ['playerNo']),
);
}
+362 -193
View File
@@ -1,13 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/config/app_config.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/features/competition_teams/pages/page_event_team_match.dart';
import 'package:recording_tool/core/utils/util_search_nasIp.dart';
import 'package:recording_tool/features/events/model/model_event_info.dart';
import 'package:recording_tool/features/events/state/state_event_info.dart';
import 'package:recording_tool/features/events/view_model/view_model_event_info.dart';
import 'package:recording_tool/shared/widgets/app_webview.dart';
import 'package:recording_tool/features/recording/model/model_recording_context.dart';
import 'package:recording_tool/features/recording/pages/page_record.dart';
import 'package:recording_tool/gen/assets.gen.dart';
import 'package:recording_tool/shared/widgets/widgets.dart';
class EventInfoPage extends ConsumerStatefulWidget {
@@ -26,12 +28,42 @@ class _EventInfoPageState extends ConsumerState<EventInfoPage> {
Future<void> onItemTap(EventRegistrationItem item) async {
// debugPrint('item tapped: ${item.itemName}');
// await ref.read(eventInfoProvider.notifier).requestStreamKey(item);
final streamKey = await ref
.read(eventInfoProvider.notifier)
.requestStreamKey(item);
if (streamKey == null || streamKey.isEmpty) {
return;
}
if (!mounted) return;
// final profile = ref.read(eventInfoProvider).profile;
/// 获取局域网 IP
// final ip =
// await ref.read(eventInfoProvider.notifier).getLocalIP() ?? 'sheling';
final ip = UtilSearchNasIp.nasIp ?? 'sheling';
// final ip = '192.168.1.245';
final streamUrl = 'rtmp://$ip:19090/$streamKey';
// final streamUrl = 'rtmp://$ip:19090/测试团队赛录分缓存/空中足球赛/初中组/林培伦vs周白芷';
AppNavigator.push(
buildEventRegistrationDestination(item: item, playerId: widget.playerId),
RecordingPage(
recordingContext: RecordingContext(
eventTitle: item.eventName,
matchName: item.itemName,
group: item.groupName,
venue: item.matchPlace,
time: item.scheduleTime,
playerName: item.playerName,
playerPhone: item.playerPhone,
isTeam: item.opponentId != '0',
opponentName: item.opponentName,
teamLeaderName: item.teamMembers.isNotEmpty
? item.teamMembers
.firstWhere((member) => member.isLeader)
.name
.trim()
: '',
),
streamUrl: streamUrl,
),
context: context,
);
}
@@ -41,42 +73,41 @@ class _EventInfoPageState extends ConsumerState<EventInfoPage> {
final state = ref.watch(eventInfoProvider);
final profile = state.profile;
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: const Color(0xFFF5F6F8),
),
child: Scaffold(
backgroundColor: const Color(0xFFF5F6F8),
appBar: myAppBar(context: context),
body: Stack(
children: [
_Header(onBack: () => AppNavigator.pop(context: context)),
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(62.w, 8.h, 62.w, 40.h),
child: Column(
children: [
_ProfileSection(profile: profile),
SizedBox(height: 20.h),
Text(
state.eventTitle,
style: TextStyle(
fontSize: 20.sp,
height: 1.2,
color: const Color(0xFF2F2F2F),
fontWeight: FontWeight.w500,
),
const _TopGradientHeader(),
SafeArea(
bottom: false,
child: Column(
children: [
// _Header(onBack: () => AppNavigator.pop(context: context)),
SizedBox(height: 10.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 10.w),
child: _ProfileSection(
profile: profile,
eventTitle: state.eventTitle,
),
SizedBox(height: 24.h),
SizedBox(
height: 400.h,
child: _ScheduleList(
state: state,
onRetry: () => ref
.read(eventInfoProvider.notifier)
.loadRegistrationList(),
onItemTap: onItemTap,
),
),
SizedBox(height: 8.h),
Expanded(
child: _ScheduleList(
state: state,
onRetry: () => ref
.read(eventInfoProvider.notifier)
.loadRegistrationList(),
onItemTap: onItemTap,
),
],
),
),
],
),
),
],
@@ -86,20 +117,39 @@ class _EventInfoPageState extends ConsumerState<EventInfoPage> {
}
}
Widget buildEventRegistrationDestination({
required EventRegistrationItem item,
required String playerId,
}) {
if (item.opponentId.isNotEmpty && item.opponentId != '0') {
return EventTeamMatchPage(item: item, playerId: playerId);
class _TopGradientHeader extends StatelessWidget {
const _TopGradientHeader();
@override
Widget build(BuildContext context) {
return Positioned(
top: -10.h,
left: 0,
right: 0,
child: SizedBox(
height: 219.h,
width: double.infinity,
child: Image.asset(Assets.images.imageEventInfoBarBg.path),
),
);
}
return WebviewPage(
url: AppConfig.current.mainRefereeScoreH5Url,
eventRegistrationItem: item,
playerId: playerId,
);
}
// Widget buildEventRegistrationDestination({
// required EventRegistrationItem item,
// required String playerId,
// required CompetitionTeamDetail? detail,
// }) {
// if (detail != null) {
// return EventTeamMatchPage(playerId: playerId, detail: detail, item: item);
// }
// return WebviewPage(
// url: AppConfig.current.mainRefereeScoreH5Url,
// eventRegistrationItem: item,
// playerId: playerId,
// );
// }
class _Header extends StatelessWidget {
const _Header({required this.onBack});
@@ -108,16 +158,16 @@ class _Header extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
height: 52.h,
height: 44.h,
child: Align(
alignment: Alignment.centerLeft,
child: IconButton(
onPressed: onBack,
icon: Icon(Icons.arrow_back_ios_new, size: 32.r),
color: Colors.black,
icon: Icon(Icons.chevron_left_rounded, size: 28.r),
color: Colors.white,
tooltip: '返回',
padding: EdgeInsets.only(left: 16.w),
constraints: BoxConstraints(minWidth: 56.w, minHeight: 52.h),
padding: EdgeInsets.only(left: 10.w),
constraints: BoxConstraints(minWidth: 44.w, minHeight: 44.h),
),
),
);
@@ -125,52 +175,122 @@ class _Header extends StatelessWidget {
}
class _ProfileSection extends StatelessWidget {
const _ProfileSection({required this.profile});
const _ProfileSection({required this.profile, required this.eventTitle});
final EventProfile profile;
final String eventTitle;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
profile.avatarUrl.isEmpty
? AppAvatar(size: 50.r)
: AppAvatar(size: 50.r, imageUrl: profile.avatarUrl),
SizedBox(width: 28.w),
Expanded(
child: Padding(
padding: EdgeInsets.only(top: 30.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
profile.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 22.sp,
height: 1.2,
color: const Color(0xFF2F2F2F),
final title = eventTitle.trim().isEmpty ? '赛事信息' : eventTitle.trim();
return Container(
clipBehavior: Clip.antiAlias,
padding: EdgeInsets.fromLTRB(12.w, 10.h, 12.w, 12.h),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6.r),
),
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
profile.avatarUrl.isEmpty
? AppAvatar(
size: 52.r,
initials: profile.name.isEmpty ? 'S' : profile.name,
)
: AppAvatar(size: 52.r, imageUrl: profile.avatarUrl),
SizedBox(width: 12.w),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
profile.name.isEmpty ? '参赛选手' : profile.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 18.sp,
height: 1.2,
color: const Color(0xFF33363B),
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 5.h),
Text(
profile.phone,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10.sp,
height: 1.2,
color: const Color(0xFF969CA6),
),
),
],
),
),
),
SizedBox(height: 36.h),
Text(
profile.phone,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 22.sp,
height: 1.2,
color: const Color(0xFF2F2F2F),
),
),
],
),
],
),
SizedBox(height: 10.h),
Divider(
height: 1.h,
thickness: 1.h,
color: const Color(0xFFE8EAED),
),
SizedBox(height: 10.h),
_EventTitleHighlight(title: title),
],
),
],
),
);
}
}
class _EventTitleHighlight extends StatelessWidget {
const _EventTitleHighlight({required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.centerLeft,
child: IntrinsicWidth(
child: Stack(
alignment: Alignment.bottomLeft,
children: [
Positioned(
left: 0,
right: 0,
bottom: 2.h,
child: Container(
height: 8.h,
decoration: BoxDecoration(
color: const Color(0xFFB8F5E8),
borderRadius: BorderRadius.circular(4.r),
),
),
),
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16.sp,
height: 1.25,
color: const Color(0xFF33363B),
fontWeight: FontWeight.w600,
),
),
],
),
],
),
);
}
}
@@ -196,97 +316,68 @@ class _ScheduleList extends StatelessWidget {
Text(
'暂无赛事报名信息',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 18.sp, color: const Color(0xFF2F2F2F)),
style: TextStyle(fontSize: 16.sp, color: const Color(0xFF3A3D42)),
),
SizedBox(height: 18.h),
SizedBox(height: 14.h),
TextButton(onPressed: onRetry, child: const Text('重新加载')),
],
),
);
}
return ListView.builder(
return ListView.separated(
padding: EdgeInsets.fromLTRB(10.w, 0, 10.w, 24.h),
itemCount: state.items.length,
separatorBuilder: (_, _) => SizedBox(height: 8.h),
itemBuilder: (context, index) {
final item = state.items[index];
return _ScheduleCard(item: item, onTap: () => onItemTap(item));
return _ScheduleCard(
item: item,
scheduleIndex: index + 1,
onTap: () => onItemTap(item),
);
},
);
}
}
class _ScheduleCard extends StatelessWidget {
const _ScheduleCard({required this.item, required this.onTap});
const _ScheduleCard({
required this.item,
required this.scheduleIndex,
required this.onTap,
});
final EventRegistrationItem item;
final int scheduleIndex;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(6.r),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6.r),
child: Container(
constraints: BoxConstraints(minHeight: 138.h),
padding: EdgeInsets.fromLTRB(10.w, 14.h, 14.w, 0),
constraints: BoxConstraints(minHeight: 86.h),
padding: EdgeInsets.fromLTRB(12.w, 10.h, 14.w, 10.h),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFF7A7A7A)),
color: Colors.white,
borderRadius: BorderRadius.circular(6.r),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.groupName.isEmpty
? item.itemName
: '${item.itemName} ${item.groupName}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 20.sp,
height: 1.2,
color: const Color(0xFF2F2F2F),
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 26.h),
Text(
item.matchPlace,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 20.sp,
height: 1.2,
color: const Color(0xFF2F2F2F),
),
),
SizedBox(height: 26.h),
Text(
item.scheduleTime,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 20.sp,
height: 1.2,
color: const Color(0xFF2F2F2F),
),
),
],
child: _ScheduleDetails(
item: item,
scheduleIndex: scheduleIndex,
),
),
SizedBox(width: 12.w),
SizedBox(
width: 148.w,
height: 120.h,
child: Align(
alignment: Alignment.center,
child: _ScheduleBadge(item: item),
),
),
_ScheduleStatus(item: item),
],
),
),
@@ -295,72 +386,150 @@ class _ScheduleCard extends StatelessWidget {
}
}
class _ScheduleBadge extends StatelessWidget {
const _ScheduleBadge({required this.item});
class _ScheduleDetails extends StatelessWidget {
const _ScheduleDetails({required this.item, required this.scheduleIndex});
final EventRegistrationItem item;
final int scheduleIndex;
@override
Widget build(BuildContext context) {
final memberLine = _formatMemberLine(item);
final opponentLine = _formatOpponentLine(item);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_formatTitle(item),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
height: 1.2,
color: const Color(0xFF30343A),
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 6.h),
_MetaText(_formatVenue(item, scheduleIndex)),
SizedBox(height: 3.h),
_MetaText(_formatScheduleTime(item)),
if (memberLine.isNotEmpty) ...[
SizedBox(height: 3.h),
_MetaText(memberLine),
],
if (opponentLine.isNotEmpty) ...[
SizedBox(height: 3.h),
_MetaText(opponentLine),
],
],
);
}
}
class _MetaText extends StatelessWidget {
const _MetaText(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10.sp,
height: 1.2,
color: const Color(0xFF6E747D),
fontWeight: FontWeight.w400,
),
);
}
}
class _ScheduleStatus extends StatelessWidget {
const _ScheduleStatus({required this.item});
final EventRegistrationItem item;
@override
Widget build(BuildContext context) {
if (item.statusLabel != null) {
return CustomPaint(
painter: _CutCornerBorderPainter(color: const Color(0xFF7A7A7A)),
child: SizedBox(
width: 148.w,
height: 90.h,
child: Center(
child: Text(
item.statusLabel!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 20.sp, color: const Color(0xFF2F2F2F)),
),
),
final status = item.statusLabel;
if (status != null) {
return Text(
status,
style: TextStyle(
fontSize: 10.sp,
height: 1.2,
color: const Color(0xFF1DBF73),
fontWeight: FontWeight.w500,
),
);
}
return Text(
item.matchPlace,
_formatNumberBadge(item),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 34.sp,
fontSize: 20.sp,
height: 1,
color: const Color(0xFF2F2F2F),
color: const Color(0xFFF27D69),
fontWeight: FontWeight.w700,
),
);
}
}
class _CutCornerBorderPainter extends CustomPainter {
const _CutCornerBorderPainter({required this.color});
String _formatTitle(EventRegistrationItem item) {
if (item.groupName.isEmpty) return item.itemName;
return '${item.itemName} ${item.groupName}';
}
final Color color;
String _formatVenue(EventRegistrationItem item, int scheduleIndex) {
return item.matchPlace.trim();
// final place = item.matchPlace.trim();
// if (place.isEmpty) return '$scheduleIndex号场馆$scheduleIndex区';
// return '$place号场馆$scheduleIndex区';
}
@override
void paint(Canvas canvas, Size size) {
final side = 12.r;
final path = Path()
..moveTo(side, 0)
..lineTo(size.width - side, 0)
..lineTo(size.width, side)
..lineTo(size.width, size.height - side)
..lineTo(size.width - side, size.height)
..lineTo(side, size.height)
..lineTo(0, size.height - side)
..lineTo(0, side)
..close();
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = color;
canvas.drawPath(path, paint);
String _formatScheduleTime(EventRegistrationItem item) {
final start = DateTime.tryParse(item.matchStartTime);
final end = DateTime.tryParse(item.matchEndTime);
if (start == null && end == null) return item.scheduleTime;
if (start != null && end != null) {
return '${start.month}${start.day}${_formatClock(start)}-${_formatClock(end)}';
}
final value = start ?? end!;
return '${value.month}${value.day}${_formatClock(value)}';
}
@override
bool shouldRepaint(covariant _CutCornerBorderPainter oldDelegate) {
return oldDelegate.color != color;
}
String _formatClock(DateTime value) {
return '${value.hour}:${value.minute.toString().padLeft(2, '0')}';
}
String _formatMemberLine(EventRegistrationItem item) {
final names = item.teamMembers
.map((member) => member.name.trim())
.where((name) => name.isNotEmpty)
.toList(growable: false);
return names.join('');
}
String _formatOpponentLine(EventRegistrationItem item) {
final opponent = item.opponentName.trim();
if (opponent.isEmpty) return '';
return '对手:$opponent';
}
String _formatNumberBadge(EventRegistrationItem item) {
return item.playerNo.trim().isEmpty ? '' : '${item.playerNo.trim()}';
// final place = item.matchPlace.trim();
// if (place.isEmpty) return '';
// final match = RegExp(r'\d+').firstMatch(place);
// if (match != null) return '${match.group(0)}号';
// return place;
}
@@ -0,0 +1,58 @@
class SetTeamStatusReq {
final String scheduleId;
final String opponentId;
final List<TeamScore> teamScore;
SetTeamStatusReq({
required this.scheduleId,
required this.opponentId,
required this.teamScore,
});
factory SetTeamStatusReq.fromJson(Map<String, dynamic> json) =>
SetTeamStatusReq(
scheduleId: json['scheduleId'],
opponentId: json['opponentId'],
teamScore: List<TeamScore>.from(
json['teamScore'].map((x) => TeamScore.fromJson(x)),
),
);
Map<String, dynamic> toJson() => {
'scheduleId': scheduleId,
'opponentId': opponentId,
'teamScore': List<dynamic>.from(teamScore.map((x) => x.toJson())),
};
}
class TeamScore {
final String userId;
final int firstHalfScore;
final int secondHalfScore;
final int decisiveScore;
final bool ifWithdraw;
TeamScore({
required this.userId,
required this.firstHalfScore,
required this.secondHalfScore,
required this.decisiveScore,
required this.ifWithdraw,
});
factory TeamScore.fromJson(Map<String, dynamic> json) => TeamScore(
userId: json['userId'],
firstHalfScore: json['firstHalfScore'],
secondHalfScore: json['secondHalfScore'],
decisiveScore: json['decisiveScore'],
ifWithdraw: json['ifWithdraw'],
);
Map<String, dynamic> toJson() => {
'userId': userId,
'firstHalfScore': firstHalfScore,
'secondHalfScore': secondHalfScore,
'decisiveScore': decisiveScore,
'ifWithdraw': ifWithdraw,
};
}
@@ -5,6 +5,7 @@ import 'package:recording_tool/core/network/api_client.dart';
import 'package:recording_tool/core/network/http_method.dart';
import 'package:recording_tool/core/network/providers/dio_providers.dart';
import 'package:recording_tool/features/events/model/model_event_info.dart';
import 'package:recording_tool/features/events/request_model/request_model_event.dart';
final eventsServerProvider = Provider<EventsServer>((ref) {
return EventsServer(ref.watch(apiClientProvider));
@@ -34,4 +35,14 @@ class EventsServer {
parser: StreamKeyResponse.fromJson,
);
}
/// 人工设置晋级/淘汰
Future<void> setTeamStatus({required SetTeamStatusReq req}) {
print('req: ${req.toJson()}');
return _apiClient.post(
AuthApi.setTeamStatus.path,
data: req.toJson(),
parser: (json) => json,
);
}
}
@@ -1,7 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.dart';
import 'package:recording_tool/core/network/api_exception.dart';
import 'package:network_info_plus/network_info_plus.dart';
import 'package:recording_tool/features/events/model/model_event_info.dart';
import 'package:recording_tool/features/events/server/server_events.dart';
import 'package:recording_tool/features/events/state/state_event_info.dart';
@@ -20,10 +20,6 @@ class EventInfoViewModel extends StateNotifier<EventInfoState> {
status: '',
);
static const _fallbackEventId = '';
static const _fallbackItemId = '';
static const _fallbackStreamUserId = '';
final Ref _ref;
Future<bool> loadRegistrationList({
@@ -50,28 +46,43 @@ class EventInfoViewModel extends StateNotifier<EventInfoState> {
}
}
Future<void> requestStreamKey(EventRegistrationItem item) async {
/// 获取局域网 IP
Future<String?> getLocalIP() async {
final info = NetworkInfo();
try {
// 获取当前连接 Wi-Fi 分配的局域网 IPv4 地址(例如 192.168.1.105
String? wifiIP = await info.getWifiIP();
return wifiIP;
} catch (e) {
print("获取局域网 IP 失败: $e");
return null;
}
}
Future<String?> requestStreamKey(EventRegistrationItem item) async {
state = state.copyWith(isRequestingStreamKey: true);
final req = StreamKeyReq(
eventId: item.eventId.isNotEmpty ? item.eventId : _fallbackEventId,
itemId: item.itemId.isNotEmpty ? item.itemId : _fallbackItemId,
userId: item.userId.isNotEmpty ? item.userId : _fallbackStreamUserId,
eventId: item.eventId,
itemId: item.itemId,
userId: item.userId,
scheduleId: item.scheduleId,
);
try {
final response = await _ref
.read(eventsServerProvider)
.fetchStreamKey(req);
debugPrint('推流 Key 接口响应: $response');
debugPrint('推流 Key 接口响应: ${response.rawData['streamKey']}');
state = state.copyWith(isRequestingStreamKey: false);
} on ApiException catch (error) {
debugPrint('推流 Key 接口请求失败: ${error.message}');
state = state.copyWith(isRequestingStreamKey: false);
AppToast.show(error.message);
return response.rawData['streamKey'];
// return response['streamKey'];
} catch (error) {
debugPrint('推流 Key 接口请求失败: $error');
state = state.copyWith(isRequestingStreamKey: false);
AppToast.show('推流 Key 获取失败');
AppToast.show('获取选手信息失败');
return null;
}
}
}
@@ -9,6 +9,11 @@ class RecordingContext {
required this.playerPhone,
this.laneNo,
this.status,
this.isTeam = false,
/// 对方队长
this.opponentName = '',
this.teamLeaderName = '',
});
const RecordingContext.empty()
@@ -20,7 +25,10 @@ class RecordingContext {
playerName = '',
playerPhone = '',
laneNo = null,
status = null;
status = null,
isTeam = false,
opponentName = '',
teamLeaderName = '';
final String eventTitle;
final String matchName;
@@ -31,8 +39,12 @@ class RecordingContext {
final String playerPhone;
final String? laneNo;
final String? status;
final bool isTeam;
final String opponentName;
final String teamLeaderName;
String get title => '$matchName $group';
String get title =>
isTeam ? '$teamLeaderName 队伍 vs $opponentName 队伍' : '$playerName $group';
String get address => venue;
+148 -52
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:apivideo_live_stream/apivideo_live_stream.dart';
@@ -8,9 +9,11 @@ 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/core/platform/device_health_snapshot.dart';
import 'package:recording_tool/features/recording/dialog/dialog-record.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_performance.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';
@@ -45,19 +48,27 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
var _immersiveApplied = false;
var _previewReady = false;
var _stoppingByUser = false;
var _bootstrapScheduled = false;
var _bootstrapStarted = false;
var _releasingResources = false;
var _controllerDisposed = false;
Animation<double>? _routeAnimation;
DeviceHealthSnapshot? _deviceHealthSnapshot;
Future<void>? _deviceHealthFuture;
final Stopwatch _pageEntryStopwatch = Stopwatch();
String? _mainCameraId;
String? _ultraWideCameraId;
double _ultraWideZoomRatio = 1.0;
@override
/// 首帧后初始化录制流程
/// 创建推流控制器,业务初始化等待路由动画结束。
void initState() {
super.initState();
_pageEntryStopwatch.start();
_streamController = ApiVideoLiveStreamController(
initialAudioConfig: AudioConfig(bitrate: 128000),
initialVideoConfig: VideoConfig.withDefaultBitrate(
resolution: Resolution.RESOLUTION_1080,
resolution: Resolution.RESOLUTION_720,
fps: 30,
),
onConnectionSuccess: () => {debugPrint('推流成功')},
@@ -88,20 +99,65 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
.setError(error.toString());
},
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final animation = ModalRoute.of(context)?.animation;
if (identical(animation, _routeAnimation)) return;
_routeAnimation?.removeStatusListener(_handleRouteAnimationStatus);
_routeAnimation = animation;
if (animation == null || animation.status == AnimationStatus.completed) {
_scheduleBootstrap();
} else {
animation.addStatusListener(_handleRouteAnimationStatus);
}
}
void _handleRouteAnimationStatus(AnimationStatus status) {
if (status != AnimationStatus.completed) return;
logRecordingPerformance(
'route transition completed',
_pageEntryStopwatch.elapsed,
);
_scheduleBootstrap();
}
void _scheduleBootstrap() {
if (_bootstrapScheduled || _bootstrapStarted || _releasingResources) return;
_bootstrapScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _releasingResources || _bootstrapStarted) return;
_bootstrapStarted = true;
_bootstrapScheduled = false;
_routeAnimation?.removeStatusListener(_handleRouteAnimationStatus);
_routeAnimation = null;
ref
.read(recordingViewModelProvider.notifier)
.setRecordingContext(widget.recordingContext);
_bootstrap();
unawaited(_bootstrap());
});
}
/// 检查设备健康状态并弹窗提示
Future<void> _checkAndShowDeviceHealthAlerts() async {
final snapshot = await AppPlatformInfo.deviceHealth();
if (!mounted) return;
Future<void> _loadDeviceHealth() async {
try {
_deviceHealthSnapshot = await measureRecordingOperation(
'device health',
AppPlatformInfo.deviceHealth,
);
} catch (error) {
debugPrint('读取设备健康状态失败: $error');
}
}
/// 使用当前页面缓存的健康状态,在开始录制前按需提示。
Future<void> _checkAndShowDeviceHealthAlerts() async {
_deviceHealthFuture ??= _loadDeviceHealth();
await _deviceHealthFuture;
if (!mounted) return;
final snapshot = _deviceHealthSnapshot;
if (snapshot == null) return;
final lines = DeviceHealthChecker.warningLines(snapshot);
if (lines.isEmpty) return;
@@ -112,16 +168,40 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
);
}
/// 页面启动:健康检查、进入录制模式、准备相机会话
/// 路由动画完成后再准备权限和相机,避免阻塞转场。
Future<void> _bootstrap() async {
await _checkAndShowDeviceHealthAlerts();
await measureRecordingOperation(
'enter immersive mode',
_enterRecordingMode,
);
if (!mounted) return;
final permissions = await ref
.read(recordingViewModelProvider.notifier)
.prepareRequiredPermissions();
if (!mounted) return;
if (!permissions.allGranted) return;
await measureRecordingOperation(
'preview initialize',
_initializeLiveStreamPreview,
);
if (!mounted || !_previewReady) return;
_deviceHealthFuture ??= _loadDeviceHealth();
unawaited(_loadPostPreviewTasks());
}
await _enterRecordingMode();
if (!mounted) return;
await ref.read(recordingViewModelProvider.notifier).prepareSession();
if (!mounted) return;
await _initializeLiveStreamPreview();
Future<void> _loadPostPreviewTasks() async {
try {
await Future.wait([
measureRecordingOperation(
'camera capabilities',
_loadBackCameraCapabilities,
),
ref.read(recordingViewModelProvider.notifier).loadSystemAdvisories(),
_deviceHealthFuture ??= _loadDeviceHealth(),
]);
} catch (error) {
debugPrint('加载录制页辅助状态失败: $error');
}
}
Future<void> _initializeLiveStreamPreview() async {
@@ -132,7 +212,6 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
ref
.read(recordingViewModelProvider.notifier)
.setPreviewReady(ready: true);
await _loadBackCameraCapabilities();
} on PlatformException catch (error) {
if (!mounted) return;
ref
@@ -154,6 +233,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
Future<void> _loadBackCameraCapabilities() async {
try {
final cameras = await _streamController.getBackCameras();
if (!mounted || _releasingResources) return;
if (cameras.isEmpty) return;
final main = cameras.first;
final widest = cameras.reduce(
@@ -179,6 +259,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
maxZoomRatio: 1.0,
);
} catch (_) {
if (!mounted || _releasingResources) return;
ref
.read(recordingViewModelProvider.notifier)
.updateZoomCapabilities(
@@ -217,8 +298,17 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
final ready = ref.read(recordingViewModelProvider).session.isPreviewReady;
if (ready) return true;
if (!mounted) return false;
AppToast.show('相机预览启动失败,请重试');
return false;
await measureRecordingOperation(
'preview initialize after permission',
_initializeLiveStreamPreview,
);
if (!mounted || !_previewReady) {
AppToast.show('相机预览启动失败,请重试');
return false;
}
_deviceHealthFuture ??= _loadDeviceHealth();
unawaited(_loadPostPreviewTasks());
return true;
}
if (!mounted) return false;
@@ -337,45 +427,58 @@ 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,
);
await RecordingPlatform.setImmersiveMode(enabled: false);
_immersiveApplied = false;
}
@override
/// 页面销毁时恢复系统 UI
/// 路由反向动画结束、页面销毁后统一释放资源。
void dispose() {
if (_immersiveApplied) {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
RecordingPlatform.setImmersiveMode(enabled: false);
}
_disposeStreamController();
_releasingResources = true;
_routeAnimation?.removeStatusListener(_handleRouteAnimationStatus);
_routeAnimation = null;
final viewModel = ref.read(recordingViewModelProvider.notifier);
unawaited(_releaseResources(viewModel));
super.dispose();
}
Future<void> _releaseResources(RecordingViewModel viewModel) async {
await measureRecordingOperation('page resource release', () async {
if (_immersiveApplied) {
_immersiveApplied = false;
try {
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
await RecordingPlatform.setImmersiveMode(enabled: false);
} catch (error) {
debugPrint('恢复系统栏失败: $error');
}
}
try {
await viewModel.teardown();
} catch (error) {
debugPrint('清理录制状态失败: $error');
}
await _disposeStreamController();
});
}
/// 只 dispose 一次;原生 dispose 统一停止推流和预览。
Future<void> _disposeStreamController() async {
if (_controllerDisposed) return;
_controllerDisposed = true;
await _streamController.stop();
await _streamController.dispose();
try {
await measureRecordingOperation(
'stream controller dispose',
_streamController.dispose,
);
} catch (error, stackTrace) {
debugPrint('释放推流控制器失败: $error\n$stackTrace');
}
}
@override
/// 构建录制页 UI
Widget build(BuildContext context) {
return _RecordingPopScope(
onExitRecordingMode: _exitRecordingMode,
child: Scaffold(
backgroundColor: Colors.black,
body: Column(
@@ -411,12 +514,8 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
}
class _RecordingPopScope extends ConsumerWidget {
const _RecordingPopScope({
required this.onExitRecordingMode,
required this.child,
});
const _RecordingPopScope({required this.child});
final Future<void> Function() onExitRecordingMode;
final Widget child;
@override
@@ -427,11 +526,8 @@ class _RecordingPopScope extends ConsumerWidget {
return PopScope(
canPop: !isRecording,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) {
await onExitRecordingMode();
return;
}
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
if (isRecording) {
AppToast.show('录制中无法返回,请先停止录制');
}
@@ -0,0 +1,25 @@
import 'package:flutter/foundation.dart';
Future<T> measureRecordingOperation<T>(
String label,
Future<T> Function() operation,
) async {
if (kReleaseMode) {
return operation();
}
final stopwatch = Stopwatch()..start();
try {
return await operation();
} finally {
stopwatch.stop();
debugPrint(
'[RecordingPerformance] $label: ${stopwatch.elapsedMilliseconds}ms',
);
}
}
void logRecordingPerformance(String label, Duration elapsed) {
if (kReleaseMode) return;
debugPrint('[RecordingPerformance] $label: ${elapsed.inMilliseconds}ms');
}
@@ -8,6 +8,7 @@ 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/model/model_recording_session.dart';
import 'package:recording_tool/features/recording/platform/recording_platform.dart';
import 'package:recording_tool/features/recording/utils/recording_performance.dart';
/// 录制页状态 Provider。
final recordingViewModelProvider =
@@ -32,6 +33,8 @@ class RecordingRequiredPermissions {
class RecordingViewModel extends Notifier<RecordingModel> {
Timer? _elapsedTimer;
DateTime? _recordingStartedAt;
RecordingRequiredPermissions? _cachedRequiredPermissions;
var _sessionGeneration = 0;
/// 初始化状态并注册销毁回调。
@override
@@ -52,55 +55,88 @@ class RecordingViewModel extends Notifier<RecordingModel> {
state = state.copyWith(recordingContext: recordingContext);
}
/// 申请权限并检查系统设置
Future<void> prepareSession() async {
/// 准备相机和麦克风权限;已授权时复用当前会话结果
Future<RecordingRequiredPermissions> prepareRequiredPermissions({
bool forceRefresh = false,
}) async {
final generation = _sessionGeneration;
if (!RecordingPlatform.isSupported) {
_updateSession((s) => s.copyWith(errorMessage: '当前设备不支持录制'));
return;
return const RecordingRequiredPermissions(
cameraGranted: false,
microphoneGranted: false,
);
}
final permissions = await PermissionService.requestMissing([
Permission.camera,
Permission.microphone,
if (Platform.isAndroid) Permission.notification,
]);
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
if (!cameraGranted) {
_updateSession((s) => s.copyWith(errorMessage: '需要相机权限才能录制'));
return;
final cached = _cachedRequiredPermissions;
if (!forceRefresh && cached?.allGranted == true) {
return cached!;
}
final microphoneGranted =
permissions[Permission.microphone]?.isGranted ?? false;
final permissions = await measureRecordingOperation(
'required permissions',
() => PermissionService.requestMissing([
Permission.camera,
Permission.microphone,
]),
);
final result = RecordingRequiredPermissions(
cameraGranted: _isPermissionGranted(permissions[Permission.camera]),
microphoneGranted: _isPermissionGranted(
permissions[Permission.microphone],
),
);
if (generation != _sessionGeneration) return result;
_cachedRequiredPermissions = result;
final errorMessage = !result.cameraGranted
? '需要相机权限才能录制'
: (!result.microphoneGranted ? '需要录音权限才能录制' : null);
_updateSession(
(s) => s.copyWith(
isMicrophoneGranted: result.microphoneGranted,
errorMessage: errorMessage,
),
);
return result;
}
/// 加载不影响相机首帧的通知、勿扰和电池状态。
Future<void> loadSystemAdvisories() async {
final generation = _sessionGeneration;
final notificationFuture = Platform.isAndroid
? PermissionService.requestMissing([Permission.notification])
: Future.value(<Permission, PermissionStatus>{});
final results = await measureRecordingOperation(
'system advisories',
() => Future.wait<dynamic>([
notificationFuture,
RecordingPlatform.hasNotificationPolicyAccess(),
RecordingPlatform.isIgnoringBatteryOptimizations(),
]),
);
final notificationPermissions =
results[0] as Map<Permission, PermissionStatus>;
final notificationsGranted = Platform.isAndroid
? (permissions[Permission.notification]?.isGranted ?? false)
? _isPermissionGranted(notificationPermissions[Permission.notification])
: true;
final hasDnd = results[1] as bool;
final batteryIgnored = results[2] as bool;
if (generation != _sessionGeneration) return;
final warnings = <String>[];
if (Platform.isAndroid && !notificationsGranted) {
warnings.add('未授予通知权限,录制时可能看不到前台服务通知,系统更容易结束后台录制');
}
if (!microphoneGranted) {
warnings.add('未授予麦克风权限,当前将以静音模式录制');
}
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
final batteryIgnored =
await RecordingPlatform.isIgnoringBatteryOptimizations();
_updateSession(
(s) => s.copyWith(
hasDndAccess: hasDnd,
isBatteryOptimizedIgnored: batteryIgnored,
isMicrophoneGranted: microphoneGranted,
notificationsGranted: notificationsGranted,
permissionWarning: warnings.isEmpty ? null : warnings.join('\n'),
errorMessage: null,
clearPermissionWarning: warnings.isEmpty,
),
);
_updateSession((s) => s.copyWith(errorMessage: null));
}
void setPreviewReady({required bool ready, String? errorMessage}) {
@@ -112,27 +148,8 @@ class RecordingViewModel extends Notifier<RecordingModel> {
/// 检测并尝试申请相机、麦克风权限,同步更新 session 中的 isMicrophoneGranted。
Future<RecordingRequiredPermissions>
ensureCameraAndMicrophonePermissions() async {
final permissions = await PermissionService.requestMissing([
Permission.camera,
Permission.microphone,
]);
final cameraGranted = _isPermissionGranted(permissions[Permission.camera]);
final microphoneGranted = _isPermissionGranted(
permissions[Permission.microphone],
);
_updateSession((s) => s.copyWith(isMicrophoneGranted: microphoneGranted));
if (cameraGranted && !state.session.isPreviewReady) {
_updateSession((s) => s.copyWith(errorMessage: null));
_updateSession((s) => s.copyWith(isPreviewReady: true));
}
return RecordingRequiredPermissions(
cameraGranted: cameraGranted,
microphoneGranted: microphoneGranted,
);
final cached = _cachedRequiredPermissions;
return prepareRequiredPermissions(forceRefresh: cached?.allGranted != true);
}
bool _isPermissionGranted(PermissionStatus? status) {
@@ -280,14 +297,15 @@ class RecordingViewModel extends Notifier<RecordingModel> {
_updateSession((s) => s.copyWith(isBatteryOptimizedIgnored: ignored));
}
/// 退出录制页时释放相机、勿扰和状态订阅
/// 退出录制页时释放勿扰和会话状态(沉浸式由页面统一恢复)
Future<void> teardown() async {
await RecordingPlatform.setImmersiveMode(enabled: false);
await RecordingPlatform.disableDoNotDisturb();
_sessionGeneration++;
_cachedRequiredPermissions = null;
_recordingStartedAt = null;
_elapsedTimer?.cancel();
_elapsedTimer = null;
state = state.copyWith(session: const RecordingSessionState());
await RecordingPlatform.disableDoNotDisturb();
}
/// Provider 销毁时取消状态流订阅。
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/features/recording/widgets/record_content_transition.dart';
import 'package:recording_tool/gen/assets.gen.dart';
@@ -59,6 +60,14 @@ class RecordHeaderWidget extends StatelessWidget {
],
),
),
// 返回上一页
IconButton(
onPressed: () => AppNavigator.pop(context: context),
icon: Icon(Icons.arrow_back, size: 32.r),
color: Colors.white,
padding: EdgeInsets.only(left: 16.w),
constraints: BoxConstraints(minWidth: 56.w, minHeight: 52.h),
),
],
),
),
@@ -82,21 +82,21 @@ class RecordingHudWidget extends StatelessWidget {
textAlign: TextAlign.center,
),
),
if (permissionWarning != null)
Padding(
padding: EdgeInsets.symmetric(
horizontal: 16.r,
vertical: 8.r,
),
child: Text(
permissionWarning!,
style: TextStyle(
color: Colors.orangeAccent,
fontSize: 12.sp,
),
textAlign: TextAlign.center,
),
),
// if (permissionWarning != null)
// Padding(
// padding: EdgeInsets.symmetric(
// horizontal: 16.r,
// vertical: 8.r,
// ),
// child: Text(
// permissionWarning!,
// style: TextStyle(
// color: Colors.orangeAccent,
// fontSize: 12.sp,
// ),
// textAlign: TextAlign.center,
// ),
// ),
RecordingSetupHintsWidget(
hasDndAccess: hasDndAccess,
isBatteryIgnored: isBatteryOptimizedIgnored,
@@ -10,9 +10,9 @@ Future<void> showRecordingSavedDialog(
}) {
return RecordDialog.showDouble(
context,
title: '本轮比赛视频已提交 NAS 录制\n请选择后续录制信息',
leftText: '续本轮',
rightText: '制新轮',
title: '本轮比赛视频已保存\n请选择后续录制信息',
leftText: '当前选手',
rightText: '新选手',
onLeftPressed: onContinueRound,
onRightPressed: onRecordNewRound,
barrierDismissible: false,
@@ -1,131 +0,0 @@
import 'package:apivideo_live_stream/apivideo_live_stream.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:recording_tool/features/scan_qrcode/utils/rtmp_stream_target.dart';
import 'package:recording_tool/shared/widgets/app_bar.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
class PushSteamTestWidget extends StatefulWidget {
const PushSteamTestWidget({
super.key,
this.rtmpUrl =
'rtmp://192.168.1.245:19090/蔡依婷vs夏志豪_空中格斗赛_高中组/蔡依婷vs夏志豪_空中格斗赛_高中组',
});
final String rtmpUrl;
@override
State<PushSteamTestWidget> createState() => _PushSteamTestWidgetState();
}
class _PushSteamTestWidgetState extends State<PushSteamTestWidget>
with WidgetsBindingObserver {
late final ApiVideoLiveStreamController _controller;
bool _ready = false;
bool _isStreaming = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_controller = ApiVideoLiveStreamController(
initialAudioConfig: AudioConfig(bitrate: 128000),
initialVideoConfig: VideoConfig.withDefaultBitrate(
resolution: Resolution.RESOLUTION_1080,
fps: 30,
),
onConnectionSuccess: () => {
debugPrint('推流成功'),
setState(() => _isStreaming = true),
},
onConnectionFailed: (reason) {
setState(() => _isStreaming = false);
debugPrint('推流失败: $reason');
},
onDisconnection: () => {
debugPrint('推流断开'),
setState(() => _isStreaming = false),
},
);
WidgetsBinding.instance.addPostFrameCallback((_) => _initialize());
}
Future<void> _initialize() async {
try {
await _controller.initialize();
await _controller.startPreview();
setState(() => _ready = true);
} catch (e) {
debugPrint('初始化失败: $e');
}
}
Future<void> _startPush() async {
try {
final target = RtmpStreamTarget.parse(widget.rtmpUrl);
await _controller.startStreaming(
streamKey: target.streamKey,
url: target.url,
);
} on FormatException catch (e) {
debugPrint('推流地址错误: ${e.message}');
AppToast.show(e.message);
} on PlatformException catch (e) {
final message = e.message ?? e.code;
debugPrint('推流失败: ${e.code} $message');
AppToast.show('推流失败: $message');
} catch (e) {
debugPrint('推流失败: $e');
AppToast.showError(e);
}
}
Future<void> _stopPush() => _controller.stopStreaming();
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.inactive) {
_controller.stop();
} else if (state == AppLifecycleState.resumed) {
_controller.startPreview();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: myAppBar(context: context),
body: Stack(
children: [
if (_ready)
ApiVideoCameraPreview(controller: _controller, fit: BoxFit.cover),
Positioned(
bottom: 32,
left: 0,
right: 0,
child: Center(
child: FloatingActionButton(
backgroundColor: _isStreaming ? Colors.red : Colors.green,
onPressed: _ready
? (_isStreaming ? _stopPush : _startPush)
: null,
child: Icon(_isStreaming ? Icons.stop : Icons.circle),
),
),
),
],
),
);
}
}
@@ -0,0 +1,395 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get_thumbnail_video/index.dart';
import 'package:get_thumbnail_video/video_thumbnail.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/features/auth/model/model_auth.dart';
import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dart';
import 'package:recording_tool/features/scan_qrcode/pages/page_record_video_player.dart';
import 'package:recording_tool/gen/assets.gen.dart';
import 'package:recording_tool/shared/widgets/app_bar.dart';
import 'package:recording_tool/shared/widgets/app_empty_view.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
/// NAS 文件服务地址,与 AuthServer.getRecordList 保持一致。
const _nasBaseUrl = 'http://sheling.local:9001';
const _videoExtensions = {
'mp4',
'mov',
'm4v',
'avi',
'mkv',
'flv',
'ts',
'wmv',
'webm',
'3gp',
};
/// 录像文件浏览页:目录逐级下钻,视频点击全屏播放。
class RecordListPage extends ConsumerWidget {
const RecordListPage({
super.key,
required this.breadcrumbs,
required this.items,
});
/// 面包屑,根页为 [赛事名],下钻时追加目录名。
final List<String> breadcrumbs;
final List<RecordListItem> items;
@override
Widget build(BuildContext context, WidgetRef ref) {
final visibleItems = items
.where((item) => _isDirectory(item) || _isVideoFile(item))
.toList(growable: false);
return Scaffold(
backgroundColor: const Color(0xFFF5F6F8),
appBar: AppPageBar(
centerTitle: false,
titleSpacing: 0,
titleWidget: Text(
breadcrumbs.join(' > '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 15.sp,
fontWeight: FontWeight.w500,
),
),
),
body: SafeArea(
top: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 4.h),
Expanded(
child: visibleItems.isEmpty
? const AppEmptyView(message: '暂无录像内容')
: ListView.separated(
padding: EdgeInsets.fromLTRB(10.w, 8.h, 10.w, 24.h),
itemCount: visibleItems.length,
separatorBuilder: (_, _) => SizedBox(height: 8.h),
itemBuilder: (context, index) {
final item = visibleItems[index];
if (_isDirectory(item)) {
return _DirectoryCard(
item: item,
onTap: () => _openDirectory(context, ref, item),
);
}
return _VideoCard(
item: item,
onTap: () => _openVideo(context, item),
);
},
),
),
],
),
),
);
}
Future<void> _openDirectory(
BuildContext context,
WidgetRef ref,
RecordListItem item,
) async {
final path = item.path?.trim() ?? '';
if (path.isEmpty) {
AppToast.show('目录路径无效');
return;
}
EasyLoading.show(status: '加载中...');
try {
final children = await ref
.read(authProvider.notifier)
.fetchRecordList(path);
EasyLoading.dismiss();
if (children == null) {
AppToast.show('目录加载失败,请重试');
return;
}
if (!context.mounted) return;
final nextBreadcrumbs = [...breadcrumbs, item.name ?? ''];
AppNavigator.push(
RecordListPage(breadcrumbs: nextBreadcrumbs, items: children),
context: context,
// 多级目录复用同一页面类型,用路径区分路由,避免防重复拦截。
name: 'RecordListPage-$path',
);
} catch (error) {
EasyLoading.dismiss();
AppToast.show('目录加载失败,请重试');
}
}
void _openVideo(BuildContext context, RecordListItem item) {
final url = resolveRecordUrl(item.url?.trim() ?? '');
if (url.isEmpty) {
AppToast.show('视频地址无效');
return;
}
AppNavigator.push(
RecordVideoPlayerPage(url: url, title: item.name ?? ''),
context: context,
name: 'RecordVideoPlayerPage-$url',
);
}
}
bool _isDirectory(RecordListItem item) {
return item.type == RecordItemType.directory.value;
}
bool _isVideoFile(RecordListItem item) {
if (item.type != RecordItemType.file.value) return false;
var ext = (item.extension ?? '').toLowerCase();
if (ext.startsWith('.')) ext = ext.substring(1);
if (ext.isEmpty) {
final name = item.name ?? '';
final dotIndex = name.lastIndexOf('.');
if (dotIndex >= 0 && dotIndex < name.length - 1) {
ext = name.substring(dotIndex + 1).toLowerCase();
}
}
return _videoExtensions.contains(ext);
}
/// 相对地址补全 NAS 前缀。
String resolveRecordUrl(String raw) {
if (raw.isEmpty) return '';
if (raw.startsWith('http://') || raw.startsWith('https://')) return raw;
return raw.startsWith('/') ? '$_nasBaseUrl$raw' : '$_nasBaseUrl/$raw';
}
String _formatDate(DateTime? time) {
if (time == null) return '';
return '${time.year}-${time.month}-${time.day}';
}
class _DirectoryCard extends StatelessWidget {
const _DirectoryCard({required this.item, required this.onTap});
final RecordListItem item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final date = _formatDate(item.modTime);
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(6.r),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6.r),
child: Container(
constraints: BoxConstraints(minHeight: 72.h),
padding: EdgeInsets.fromLTRB(20.w, 12.h, 14.w, 12.h),
child: Row(
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.sp,
height: 1.2,
color: const Color(0xFF30343A),
fontWeight: FontWeight.w600,
),
),
if (date.isNotEmpty) ...[
SizedBox(height: 6.h),
Text(
date,
style: TextStyle(
fontSize: 11.sp,
height: 1.2,
color: const Color(0xFF6E747D),
),
),
],
],
),
),
SizedBox(width: 12.w),
Row(
children: [
// Text(
// '视频',
// style: TextStyle(
// fontSize: 14.sp,
// color: const Color(0xFF358BFE),
// fontWeight: FontWeight.w600,
// ),
// ),
Icon(
Icons.chevron_right,
size: 20.r,
color: const Color(0xFF9AA3AF),
),
],
),
],
),
),
),
);
}
}
class _VideoCard extends StatelessWidget {
const _VideoCard({required this.item, required this.onTap});
final RecordListItem item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final date = _formatDate(item.modTime);
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(6.r),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6.r),
child: Padding(
padding: EdgeInsets.all(10.r),
child: Row(
children: [
_VideoThumbnail(url: resolveRecordUrl(item.url?.trim() ?? '')),
SizedBox(width: 12.w),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name ?? '',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.sp,
height: 1.25,
color: const Color(0xFF30343A),
fontWeight: FontWeight.w600,
),
),
if (date.isNotEmpty) ...[
SizedBox(height: 6.h),
Text(
date,
style: TextStyle(
fontSize: 11.sp,
height: 1.2,
color: const Color(0xFF6E747D),
),
),
],
],
),
),
],
),
),
),
);
}
}
/// 视频首帧封面:异步生成缩略图,失败保留灰色占位,中央始终叠加播放图标。
class _VideoThumbnail extends StatefulWidget {
const _VideoThumbnail({required this.url});
final String url;
@override
State<_VideoThumbnail> createState() => _VideoThumbnailState();
}
class _VideoThumbnailState extends State<_VideoThumbnail> {
/// 按 url 缓存首帧,避免列表滚动重建时重复生成。
static final Map<String, Uint8List> _cache = {};
Uint8List? _bytes;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
if (widget.url.isEmpty) return;
final cached = _cache[widget.url];
if (cached != null) {
_bytes = cached;
return;
}
try {
final data = await VideoThumbnail.thumbnailData(
video: widget.url,
imageFormat: ImageFormat.JPEG,
maxWidth: 320,
quality: 60,
);
_cache[widget.url] = data;
if (!mounted) return;
setState(() => _bytes = data);
} catch (_) {
// 生成失败保持占位图,不影响点击播放。
}
}
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(4.r),
child: SizedBox(
width: 108.w,
height: 68.h,
child: Stack(
fit: StackFit.expand,
children: [
ColoredBox(color: const Color(0xFFEDEFF2)),
if (_bytes != null)
Image.memory(_bytes!, fit: BoxFit.cover, gaplessPlayback: true),
Center(
child: Container(
width: 30.r,
height: 30.r,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.35),
shape: BoxShape.circle,
),
child: Image(
image: AssetImage(Assets.images.imageVideoIcon.path),
fit: BoxFit.cover,
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,250 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:video_player/video_player.dart';
/// 录像全屏播放页:黑色背景,支持播放/暂停、进度拖动。
class RecordVideoPlayerPage extends StatefulWidget {
const RecordVideoPlayerPage({
super.key,
required this.url,
required this.title,
});
final String url;
final String title;
@override
State<RecordVideoPlayerPage> createState() => _RecordVideoPlayerPageState();
}
class _RecordVideoPlayerPageState extends State<RecordVideoPlayerPage> {
VideoPlayerController? _controller;
bool _initialized = false;
bool _hasError = false;
bool _showControls = true;
@override
void initState() {
super.initState();
_initController();
}
Future<void> _initController() async {
setState(() {
_hasError = false;
_initialized = false;
});
final old = _controller;
_controller = null;
await old?.dispose();
final controller = VideoPlayerController.networkUrl(Uri.parse(widget.url));
_controller = controller;
try {
await controller.initialize();
if (!mounted) return;
setState(() => _initialized = true);
await controller.play();
} catch (_) {
if (!mounted) return;
setState(() => _hasError = true);
}
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
void _togglePlay() {
final controller = _controller;
if (controller == null || !_initialized) return;
setState(() {
if (controller.value.isPlaying) {
controller.pause();
} else {
controller.play();
}
});
}
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.black,
),
child: Scaffold(
backgroundColor: Colors.black,
body: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _showControls = !_showControls),
child: Stack(
fit: StackFit.expand,
children: [
Center(child: _buildPlayer()),
if (_showControls) _buildTopBar(),
if (_showControls && _initialized) _buildBottomControls(),
],
),
),
),
);
}
Widget _buildPlayer() {
if (_hasError) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'视频加载失败',
style: TextStyle(color: Colors.white70, fontSize: 15.sp),
),
SizedBox(height: 14.h),
OutlinedButton(
onPressed: _initController,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white54),
),
child: const Text('重试'),
),
],
);
}
final controller = _controller;
if (controller == null || !_initialized) {
return const CircularProgressIndicator(color: Colors.white);
}
return AspectRatio(
aspectRatio: controller.value.aspectRatio,
child: VideoPlayer(controller),
);
}
Widget _buildTopBar() {
return Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black54, Colors.transparent],
),
),
child: SafeArea(
bottom: false,
child: Row(
children: [
IconButton(
onPressed: () => AppNavigator.pop(context: context),
icon: Icon(
Icons.chevron_left_rounded,
color: Colors.white,
size: 30.r,
),
),
Expanded(
child: Text(
widget.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 15.sp,
fontWeight: FontWeight.w500,
),
),
),
SizedBox(width: 48.w),
],
),
),
),
);
}
Widget _buildBottomControls() {
final controller = _controller!;
return Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black54, Colors.transparent],
),
),
child: SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(12.w, 8.h, 12.w, 8.h),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
VideoProgressIndicator(
controller,
allowScrubbing: true,
colors: const VideoProgressColors(
playedColor: Colors.white,
bufferedColor: Colors.white38,
backgroundColor: Colors.white24,
),
padding: EdgeInsets.symmetric(vertical: 8.h),
),
ValueListenableBuilder<VideoPlayerValue>(
valueListenable: controller,
builder: (context, value, _) {
return Row(
children: [
IconButton(
onPressed: _togglePlay,
icon: Icon(
value.isPlaying
? Icons.pause_rounded
: Icons.play_arrow_rounded,
color: Colors.white,
size: 30.r,
),
),
Text(
'${_formatDuration(value.position)} / ${_formatDuration(value.duration)}',
style: TextStyle(
color: Colors.white,
fontSize: 12.sp,
),
),
],
);
},
),
],
),
),
),
),
);
}
}
String _formatDuration(Duration duration) {
final minutes = duration.inMinutes.toString().padLeft(2, '0');
final seconds = (duration.inSeconds % 60).toString().padLeft(2, '0');
final hours = duration.inHours;
if (hours > 0) return '$hours:$minutes:$seconds';
return '$minutes:$seconds';
}
@@ -1,166 +1,147 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/core/cache/app_storage.dart';
import 'package:recording_tool/core/cache/storage_keys.dart';
import 'package:recording_tool/features/auth/pages/page_auth.dart';
import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dart';
import 'package:recording_tool/features/competition_teams/pages/page_competition_team_list.dart';
import 'package:recording_tool/features/events/model/model_event_info.dart';
import 'package:recording_tool/features/events/pages/page_event_info.dart';
import 'package:recording_tool/features/events/view_model/view_model_event_info.dart';
import 'package:recording_tool/features/recording/model/model_recording_context.dart';
import 'package:recording_tool/shared/widgets/app_bar.dart';
import 'package:recording_tool/shared/widgets/app_button.dart';
import 'package:recording_tool/features/scan_qrcode/pages/page_record_list.dart';
import 'package:recording_tool/shared/widgets/app_qr_scanner_dialog.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
class ScanQrCodePage extends ConsumerStatefulWidget {
const ScanQrCodePage({super.key});
static const mockRtmpUrl =
'rtmp://192.168.1.245:19090/蔡依婷vs夏志豪_空中格斗赛_高中组/蔡依婷vs夏志豪_空中格斗赛_高中组';
static const mockRecordingContext = RecordingContext(
eventTitle: '全国青少年无人机大赛',
matchName: '空中格斗赛',
group: '高中组',
venue: '场地 1',
time: '7月1日 12:00-15:00',
playerName: '蔡依婷vs夏志豪',
playerPhone: '',
);
@override
ConsumerState<ScanQrCodePage> createState() => _AuthPageWidgetState();
ConsumerState<ScanQrCodePage> createState() => _ScanQrCodePageState();
}
class _AuthPageWidgetState extends ConsumerState<ScanQrCodePage> {
class _ScanQrCodePageState extends ConsumerState<ScanQrCodePage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
final token = AppStorage.getString(StorageKeys.authToken);
if (token?.isNotEmpty ?? false) {
final success = await ref
.read(authProvider.notifier)
.parseTokenSetState(token!);
if (!success) {
AppToast.show('请重新鉴权');
AppNavigator.pushAndRemoveUntil(const AuthPageWidget());
return;
}
// final success = await ref
// .read(authProvider.notifier)
// .parseTokenSetState(token!);
// if (!success) {
// AppToast.show('请重新鉴权');
// AppNavigator.pushAndRemoveUntil(const AuthPageWidget());
// return;
// }
}
});
}
@override
Widget build(BuildContext context) {
final eventName = ref.watch(
authProvider.select(
(state) => state.jwtDecodedData?.eventName?.trim() ?? '',
),
);
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, result) async {
if (!didPop) return;
await ref.read(authProvider.notifier).clearAuth();
},
child: Scaffold(
appBar: myAppBar(context: context, title: '扫码'),
body: Center(
child: Column(
child: AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.white,
),
child: Scaffold(
backgroundColor: Colors.white,
body: Stack(
children: [
SizedBox(height: 100.h),
Text(
'裁判工作台',
style: TextStyle(fontSize: 30, color: Colors.black),
Positioned(
top: 0,
left: 0,
right: 0,
child: Image.asset(
_ScanAssets.pageBg,
width: double.infinity,
fit: BoxFit.fitWidth,
),
),
SizedBox(height: 100.h),
Text(
'扫描选手参赛凭证进行执裁',
style: TextStyle(fontSize: 30, color: Colors.black),
),
SizedBox(height: 20.h),
// Consumer(
// builder: (context, ref, child) {
// return SizedBox(
// width: 280.w,
// height: 80.h,
// child: AppButton(
// label: '查看录像',
// onPressed: () async {
// final data = ref.watch(
// authProvider.select((state) => state.jwtDecodedData),
// );
// if (data == null) return;
// debugPrint('赛事名字: ${data.eventName}');
// final eventName = data.eventName ?? '';
// await ref
// .read(authProvider.notifier)
// .getRecordList(eventName);
// },
// variant: AppButtonVariant.secondary,
// ),
// );
// },
// ),
SizedBox(height: 16.h),
Consumer(
builder: (context, ref, child) {
return SizedBox(
width: 280.w,
height: 80.h,
child: AppButton(
label: '参赛队伍',
onPressed: () async {
await AppNavigator.push(
const CompetitionTeamListPage(),
context: context,
);
},
variant: AppButtonVariant.secondary,
),
);
},
),
SizedBox(height: 16.h),
SizedBox(
width: 280.w,
height: 80.h,
child: AppButton(
label: '扫码',
onPressed: () async {
final String? playerId = await AppQrScannerDialog.show(
context,
);
if (playerId == null || playerId.isEmpty) {
AppToast.show('查询选手信息失败');
return;
}
EasyLoading.show(status: '查询选手信息...');
try {
final success = await ref
.read(eventInfoProvider.notifier)
.loadRegistrationList(
request: PlayerRegistrationListReq(
userId: playerId,
SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 32.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _BrandHeader(),
SizedBox(height: 26.h),
Text(
eventName.isEmpty ? '赛事信息' : eventName,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: const Color(0xFF3C3F44),
fontSize: 24.sp,
fontWeight: FontWeight.w700,
height: 1.25,
),
),
const Spacer(flex: 134),
Center(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _handleScan,
child: Image.asset(
_ScanAssets.scanQrcode,
width: 166.w,
height: 165.w,
fit: BoxFit.contain,
),
),
),
SizedBox(height: 28.h),
Center(
child: Text(
'扫描选手参赛凭证进行执裁',
style: TextStyle(
color: const Color(0xFFABAFB6),
fontSize: 16.sp,
fontWeight: FontWeight.w400,
height: 1.2,
),
),
),
const Spacer(flex: 211),
Row(
children: [
Expanded(
child: _BottomOutlineButton(
label: '查看录像',
onPressed: _handleViewRecords,
),
);
EasyLoading.dismiss();
if (!success) {
AppToast.show('查询选手信息失败');
return;
}
if (!mounted) return;
AppNavigator.push(EventInfoPage(playerId: playerId));
} catch (error) {
AppToast.show('查询选手信息失败');
EasyLoading.dismiss();
}
},
variant: AppButtonVariant.secondary,
),
// SizedBox(width: 12.w),
// Expanded(
// child: _BottomOutlineButton(
// label: '参赛队伍',
// onPressed: () async {
// await AppNavigator.push(
// const CompetitionTeamListPage(),
// context: context,
// );
// },
// ),
// ),
],
),
],
),
),
),
],
@@ -169,4 +150,163 @@ class _AuthPageWidgetState extends ConsumerState<ScanQrCodePage> {
),
);
}
Future<void> _handleScan() async {
final String? playerId = await AppQrScannerDialog.show(context);
if (playerId == null || playerId.isEmpty) {
AppToast.show('查询选手信息失败');
return;
}
EasyLoading.show(status: '查询选手信息...');
try {
final success = await ref
.read(eventInfoProvider.notifier)
.loadRegistrationList(
request: PlayerRegistrationListReq(userId: playerId),
);
EasyLoading.dismiss();
if (!success) {
AppToast.show('查询选手信息失败');
return;
}
if (!mounted) return;
AppNavigator.push(EventInfoPage(playerId: playerId));
} catch (error) {
EasyLoading.dismiss();
AppToast.show('查询选手信息失败');
}
}
Future<void> _handleViewRecords() async {
final eventName = ref.read(authProvider).jwtDecodedData?.eventName ?? '';
if (eventName.trim().isEmpty) {
AppToast.show('暂无赛事信息');
return;
}
EasyLoading.show(status: '查询录像...');
try {
final success = await ref
.read(authProvider.notifier)
.getRecordList(eventName);
EasyLoading.dismiss();
if (!success) {
AppToast.show('暂无录像');
return;
}
if (!mounted) return;
final items = ref.read(authProvider).recordList ?? const [];
AppNavigator.push(
RecordListPage(breadcrumbs: [eventName.trim()], items: items),
context: context,
);
} catch (error) {
EasyLoading.dismiss();
AppToast.show('查询录像失败');
}
}
}
class _BrandHeader extends StatelessWidget {
const _BrandHeader();
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(17.r),
child: Image.asset(
_ScanAssets.appIcon,
width: 34.w,
height: 34.w,
fit: BoxFit.cover,
),
),
// SizedBox(width: .w),
Image.asset(
_ScanAssets.appNameText,
width: 117.w,
height: 29.w,
fit: BoxFit.contain,
),
],
),
),
GestureDetector(
onTap: () {
AppNavigator.pop();
},
child: Row(
children: [
Icon(
Icons.chevron_left_rounded,
color: Colors.black,
size: 28.sp,
),
Text(
'返回',
style: TextStyle(
// color: const Color(0xFF53A7F3),
color: Colors.black,
fontSize: 14.sp,
fontWeight: FontWeight.w400,
),
),
],
),
),
],
);
}
}
class _BottomOutlineButton extends StatelessWidget {
const _BottomOutlineButton({required this.label, required this.onPressed});
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 38.h,
child: Material(
color: Colors.white.withValues(alpha: 0.08),
shape: StadiumBorder(
side: BorderSide(color: const Color(0xFF9CCEFF), width: 1.r),
),
child: InkWell(
onTap: onPressed,
customBorder: const StadiumBorder(),
child: Center(
child: Text(
label,
style: TextStyle(
color: const Color(0xFF53A7F3),
fontSize: 14.sp,
fontWeight: FontWeight.w500,
height: 1.2,
),
),
),
),
),
);
}
}
class _ScanAssets {
const _ScanAssets._();
static const pageBg = 'assets/images/image_page_bg.png';
static const appIcon = 'assets/images/image_app_icon.png';
static const appNameText = 'assets/images/image_app_name_text.png';
static const scanQrcode = 'assets/images/image_scan_qrcode.png';
}
+63 -8
View File
@@ -24,32 +24,87 @@ class $AssetsHtmlGen {
class $AssetsImagesGen {
const $AssetsImagesGen();
/// File path: assets/images/image_copy.png
AssetGenImage get imageCopy =>
const AssetGenImage('assets/images/image_copy.png');
/// File path: assets/images/image_app_bar_bg.png
AssetGenImage get imageAppBarBg =>
const AssetGenImage('assets/images/image_app_bar_bg.png');
/// File path: assets/images/image_delete.png
AssetGenImage get imageDelete =>
const AssetGenImage('assets/images/image_delete.png');
/// File path: assets/images/image_app_icon.png
AssetGenImage get imageAppIcon =>
const AssetGenImage('assets/images/image_app_icon.png');
/// File path: assets/images/image_app_name_text.png
AssetGenImage get imageAppNameText =>
const AssetGenImage('assets/images/image_app_name_text.png');
/// File path: assets/images/image_blue_team.png
AssetGenImage get imageBlueTeam =>
const AssetGenImage('assets/images/image_blue_team.png');
/// File path: assets/images/image_dialog_bg.png
AssetGenImage get imageDialogBg =>
const AssetGenImage('assets/images/image_dialog_bg.png');
/// File path: assets/images/image_event_info_bar_bg.png
AssetGenImage get imageEventInfoBarBg =>
const AssetGenImage('assets/images/image_event_info_bar_bg.png');
/// File path: assets/images/image_logo.png
AssetGenImage get imageLogo =>
const AssetGenImage('assets/images/image_logo.png');
/// File path: assets/images/image_ok.png
AssetGenImage get imageOk =>
const AssetGenImage('assets/images/image_ok.png');
/// File path: assets/images/image_page_bg.png
AssetGenImage get imagePageBg =>
const AssetGenImage('assets/images/image_page_bg.png');
/// File path: assets/images/image_red_team.png
AssetGenImage get imageRedTeam =>
const AssetGenImage('assets/images/image_red_team.png');
/// File path: assets/images/image_scan.png
AssetGenImage get imageScan =>
const AssetGenImage('assets/images/image_scan.png');
/// File path: assets/images/image_scan_qrcode.png
AssetGenImage get imageScanQrcode =>
const AssetGenImage('assets/images/image_scan_qrcode.png');
/// File path: assets/images/image_start.png
AssetGenImage get imageStart =>
const AssetGenImage('assets/images/image_start.png');
/// File path: assets/images/image_team_vs_bg.png
AssetGenImage get imageTeamVsBg =>
const AssetGenImage('assets/images/image_team_vs_bg.png');
/// File path: assets/images/image_video_icon.png
AssetGenImage get imageVideoIcon =>
const AssetGenImage('assets/images/image_video_icon.png');
/// File path: assets/images/image_vs.png
AssetGenImage get imageVs =>
const AssetGenImage('assets/images/image_vs.png');
/// List of all assets
List<AssetGenImage> get values => [
imageCopy,
imageDelete,
imageAppBarBg,
imageAppIcon,
imageAppNameText,
imageBlueTeam,
imageDialogBg,
imageEventInfoBarBg,
imageLogo,
imageOk,
imagePageBg,
imageRedTeam,
imageScan,
imageScanQrcode,
imageStart,
imageTeamVsBg,
imageVideoIcon,
imageVs,
];
}
+78 -25
View File
@@ -1,31 +1,84 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/gen/assets.gen.dart';
AppBar myAppBar({
/// 全局页面标题栏:背景图撑满 + 居中标题 + 返回按钮。
/// 可直接赋给 [Scaffold.appBar]。
class AppPageBar extends StatelessWidget implements PreferredSizeWidget {
const AppPageBar({
super.key,
this.title = '',
this.titleWidget,
this.backgroundImage,
this.onBack,
this.actions,
this.centerTitle = true,
this.titleSpacing,
});
final String title;
final Widget? titleWidget;
final String? backgroundImage;
final VoidCallback? onBack;
final List<Widget>? actions;
/// 标题是否居中;false 时左对齐,紧随返回按钮。
final bool centerTitle;
final double? titleSpacing;
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@override
Widget build(BuildContext context) {
final imagePath = backgroundImage ?? Assets.images.imageAppBarBg.path;
return AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: centerTitle,
titleSpacing: titleSpacing,
systemOverlayStyle: SystemUiOverlayStyle.light,
flexibleSpace: SizedBox.expand(
child: Image.asset(
imagePath,
fit: BoxFit.cover,
alignment: Alignment.center,
),
),
leading: IconButton(
icon: Icon(
Icons.chevron_left_rounded,
color: Colors.white,
size: 28.sp,
),
onPressed: onBack ?? () => AppNavigator.pop(context: context),
),
title:
titleWidget ??
Text(
title,
style: TextStyle(
color: Colors.white,
fontSize: 20.sp,
fontWeight: FontWeight.w600,
fontFamily: 'PingFang SC',
),
),
actions: actions,
);
}
}
/// 兼容旧调用;内部委托 [AppPageBar]。
PreferredSizeWidget myAppBar({
required BuildContext context,
String title = '',
Widget? titleWidget,
}) => AppBar(
leadingWidth: 56.w,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
Navigator.of(context).maybePop();
},
),
title:
titleWidget ??
Text(
title,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 20.sp,
fontFamily: 'PingFang SC',
fontWeight: FontWeight.w400,
),
),
centerTitle: true,
backgroundColor: Colors.transparent,
elevation: 0,
);
List<Widget>? actions,
}) {
return AppPageBar(title: title, titleWidget: titleWidget, actions: actions);
}
+1
View File
@@ -1,4 +1,5 @@
export 'app_avatar.dart';
export 'app_bar.dart';
export 'app_button.dart';
export 'app_card.dart';
export 'app_dialog.dart';
@@ -1,2 +1,2 @@
#Mon Jul 13 17:53:35 CST 2026
#Fri Jul 17 16:25:39 CST 2026
gradle.version=8.2
@@ -2,6 +2,8 @@ package video.api.flutter.livestream
import android.Manifest
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Size
import android.view.Surface
import io.flutter.view.TextureRegistry
@@ -17,7 +19,15 @@ import io.github.thibaultbee.streampack.utils.frontCameraList
import io.github.thibaultbee.streampack.utils.isBackCamera
import io.github.thibaultbee.streampack.utils.isExternalCamera
import io.github.thibaultbee.streampack.utils.isFrontCamera
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class FlutterLiveStreamView(
private val context: Context,
@@ -39,9 +49,21 @@ class FlutterLiveStreamView(
initialOnConnectionListener = this,
initialOnErrorListener = this
)
private val mainHandler = Handler(Looper.getMainLooper())
private val operationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val operationMutex = Mutex()
private val disposeLock = Any()
private val disposeCallbacks =
mutableListOf<Pair<() -> Unit, (Exception) -> Unit>>()
@Volatile
private var _isPreviewing = false
@Volatile
private var _isStreaming = false
@Volatile
private var _isDisposing = false
@Volatile
private var _isDisposed = false
val isStreaming: Boolean
get() = _isStreaming
@@ -63,7 +85,7 @@ class FlutterLiveStreamView(
val wasPreviewing = _isPreviewing
if (wasPreviewing) {
stopPreview()
stopPreviewInternal()
}
streamer.configure(videoConfig)
_videoConfig = videoConfig
@@ -174,13 +196,58 @@ class FlutterLiveStreamView(
setCamera(cameraList.first(), onSuccess, onError)
}
fun dispose() {
stopStream()
streamer.stopPreview()
flutterTexture.release()
fun dispose(onSuccess: () -> Unit, onError: (Exception) -> Unit) {
var shouldStartDispose = false
synchronized(disposeLock) {
if (_isDisposed) {
mainHandler.post(onSuccess)
return
}
disposeCallbacks.add(onSuccess to onError)
if (!_isDisposing) {
_isDisposing = true
shouldStartDispose = true
}
}
if (!shouldStartDispose) return
operationScope.launch {
var failure: Exception? = null
operationMutex.withLock {
try {
stopStreamInternal()
} catch (e: Exception) {
android.util.Log.w("ApiVideoLiveStream", "stopStream during dispose failed", e)
failure = e
}
try {
stopPreviewInternal()
} catch (e: Exception) {
android.util.Log.w("ApiVideoLiveStream", "stopPreview during dispose failed", e)
failure = failure ?: e
}
}
withContext(Dispatchers.Main.immediate) {
try {
flutterTexture.release()
} catch (e: Exception) {
failure = failure ?: e
}
val callbacks = synchronized(disposeLock) {
_isDisposed = true
_isDisposing = false
disposeCallbacks.toList().also { disposeCallbacks.clear() }
}
callbacks.forEach { (success, error) ->
failure?.let(error) ?: success()
}
}
operationScope.cancel()
}
}
fun startStream(url: String) {
check(!_isDisposing && !_isDisposed) { "Live stream has been disposed" }
runBlocking {
streamer.connect(url)
try {
@@ -194,18 +261,33 @@ class FlutterLiveStreamView(
}
}
fun stopStream() {
val isConnected = streamer.isConnected
runBlocking {
streamer.stopStream()
streamer.disconnect()
if (isConnected) {
onDisconnected()
}
_isStreaming = false
fun stopStream(onSuccess: () -> Unit, onError: (Exception) -> Unit) {
runBackgroundOperation(onSuccess, onError) {
stopStreamInternal()
}
}
private suspend fun stopStreamInternal() {
if (!_isStreaming && !streamer.isConnected) return
val isConnected = streamer.isConnected
var failure: Exception? = null
try {
streamer.stopStream()
} catch (e: Exception) {
failure = e
}
try {
streamer.disconnect()
} catch (e: Exception) {
failure = failure ?: e
}
if (isConnected) {
onDisconnected()
}
_isStreaming = false
failure?.let { throw it }
}
fun startPreview(onSuccess: () -> Unit, onError: (Exception) -> Unit) {
permissionsManager.requestPermission(
Manifest.permission.CAMERA,
@@ -239,11 +321,40 @@ class FlutterLiveStreamView(
})
}
fun stopPreview() {
fun stopPreview(onSuccess: () -> Unit, onError: (Exception) -> Unit) {
runBackgroundOperation(onSuccess, onError) {
stopPreviewInternal()
}
}
private fun stopPreviewInternal() {
if (!_isPreviewing) return
streamer.stopPreview()
_isPreviewing = false
}
private fun runBackgroundOperation(
onSuccess: () -> Unit,
onError: (Exception) -> Unit,
operation: suspend () -> Unit,
) {
if (_isDisposing || _isDisposed) {
mainHandler.post(onSuccess)
return
}
operationScope.launch {
val failure = try {
operationMutex.withLock { operation() }
null
} catch (e: Exception) {
e
}
withContext(Dispatchers.Main.immediate) {
failure?.let(onError) ?: onSuccess()
}
}
}
private fun getSurface(resolution: Size): Surface {
val surfaceTexture = flutterTexture.surfaceTexture().apply {
setDefaultBufferSize(
@@ -50,27 +50,44 @@ class MethodCallHandlerImpl(
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"create" -> {
try {
flutterView?.dispose()
flutterView = FlutterLiveStreamView(
context,
textureRegistry,
permissionsManager,
{ sendConnected() },
{ sendDisconnected() },
{ sendConnectionFailed(it) },
{ sendError(it) },
{ sendVideoSizeChanged(it) }
val previousView = flutterView
if (previousView == null) {
createFlutterView(result)
} else {
previousView.dispose(
onSuccess = {
if (flutterView === previousView) {
flutterView = null
}
createFlutterView(result)
},
onError = {
result.error("failed_to_replace_live_stream", it.message, null)
},
)
result.success(mapOf("textureId" to flutterView!!.textureId))
} catch (e: Exception) {
result.error("failed_to_create_live_stream", e.message, null)
}
}
"dispose" -> {
flutterView?.dispose()
flutterView = null
val view = flutterView
if (view == null) {
result.success(null)
} else {
view.dispose(
onSuccess = {
if (flutterView === view) {
flutterView = null
}
result.success(null)
},
onError = {
if (flutterView === view) {
flutterView = null
}
result.error("failed_to_dispose_live_stream", it.message, null)
},
)
}
}
"setVideoConfig" -> {
@@ -128,8 +145,17 @@ class MethodCallHandlerImpl(
}
"stopPreview" -> {
flutterView?.stopPreview()
result.success(null)
val view = flutterView
if (view == null) {
result.success(null)
} else {
view.stopPreview(
onSuccess = { result.success(null) },
onError = {
result.error("failed_to_stop_preview", it.message, null)
},
)
}
}
"startStreaming" -> {
@@ -163,8 +189,17 @@ class MethodCallHandlerImpl(
}
"stopStreaming" -> {
flutterView?.stopStream()
result.success(null)
val view = flutterView
if (view == null) {
result.success(null)
} else {
view.stopStream(
onSuccess = { result.success(null) },
onError = {
result.error("failed_to_stop_stream", it.message, null)
},
)
}
}
"getIsStreaming" -> result.success(mapOf("isStreaming" to flutterView!!.isStreaming))
@@ -275,6 +310,24 @@ class MethodCallHandlerImpl(
}
}
private fun createFlutterView(result: MethodChannel.Result) {
try {
flutterView = FlutterLiveStreamView(
context,
textureRegistry,
permissionsManager,
{ sendConnected() },
{ sendDisconnected() },
{ sendConnectionFailed(it) },
{ sendError(it) },
{ sendVideoSizeChanged(it) },
)
result.success(mapOf("textureId" to flutterView!!.textureId))
} catch (e: Exception) {
result.error("failed_to_create_live_stream", e.message, null)
}
}
private fun sendConnected() {
sendEvent("connected")
}
@@ -68,7 +68,6 @@ class _ApiVideoCameraPreviewState extends State<ApiVideoCameraPreview> {
@override
void dispose() {
widget.controller.stopPreview();
widget.controller.removeWidgetListener(_widgetListener);
widget.controller.removeEventsListener(_eventsListener);
super.dispose();
@@ -26,6 +26,8 @@ class ApiVideoLiveStreamController {
int get textureId => _textureId;
bool _isInitialized = false;
Future<void>? _initializeFuture;
Future<void>? _disposeFuture;
/// Gets the current state of the video player.
bool get isInitialized => _isInitialized;
@@ -68,7 +70,14 @@ class ApiVideoLiveStreamController {
}
/// Creates a new live stream instance with initial audio and video configurations.
Future<void> initialize() async {
Future<void> initialize() {
if (_disposeFuture != null) {
throw StateError('Cannot initialize a disposed controller');
}
return _initializeFuture ??= _initialize();
}
Future<void> _initialize() async {
_textureId = await _platform.initialize() ?? kUninitializedTextureId;
_eventSubscription = _platform
@@ -91,12 +100,23 @@ class ApiVideoLiveStreamController {
}
/// Disposes the live stream instance.
Future<void> dispose() async {
Future<void> dispose() {
return _disposeFuture ??= _dispose();
}
Future<void> _dispose() async {
try {
await _initializeFuture;
} catch (_) {
// A partially initialized native view still needs to be disposed.
}
await _eventSubscription?.cancel();
_eventSubscription = null;
_eventsListeners.clear();
_widgetListeners.clear();
await _platform.dispose();
return;
_isInitialized = false;
_textureId = kUninitializedTextureId;
}
/// Sets new video parameters.
+3
View File
@@ -54,6 +54,9 @@ dependencies:
apivideo_live_stream:
path: plugins/apivideo_live_stream
jwt_decoder: ^2.0.1
network_info_plus: ^7.0.0
video_player: ^2.13.0
get_thumbnail_video: ^0.7.3
dev_dependencies:
flutter_test: