Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43373bec54 | ||
|
|
ac304b10f8 | ||
|
|
df38ecee18 | ||
|
|
7faa8e5c47 | ||
|
|
7d65be0514 | ||
|
|
40799f2d15 | ||
|
|
f7f7413b55 | ||
|
|
8e599f5a86 | ||
|
|
9fca76f679 | ||
|
|
a324825e8a | ||
|
|
6793ca53af | ||
|
|
9e72bc522e | ||
|
|
1096645e79 | ||
|
|
13ecb2fccc | ||
|
|
e3f9c8ebc0 | ||
|
|
6790ea41b2 | ||
|
|
b2dd593e93 | ||
|
|
5ffe524e20 | ||
|
|
356636c4b4 | ||
|
|
686db244fd | ||
|
|
20d501f673 | ||
|
|
ddc1c9bf98 | ||
|
|
30d317c0b9 | ||
|
|
10d9eee60e | ||
|
|
00937bf4b6 | ||
|
|
3c3baede85 | ||
|
|
8c127dd0e7 | ||
|
|
1264ebdd7c |
@@ -50,4 +50,5 @@ app.*.map.json
|
||||
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
test/
|
||||
test/
|
||||
script
|
||||
@@ -19,7 +19,7 @@
|
||||
android:required="true" />
|
||||
|
||||
<application
|
||||
android:label="飞行极控录像工作台"
|
||||
android:label="SportsX裁判工作台"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 382 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 509 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 795 B |
|
Before Width: | Height: | Size: 1011 B |
|
After Width: | Height: | Size: 833 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 633 B |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 5.1 KiB |
@@ -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')"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ class AppConfig {
|
||||
static late EnvironmentValues current;
|
||||
static AppPackageInfo? packageInfo;
|
||||
|
||||
static const appName = '飞行极控录像工作台';
|
||||
static const designSize = Size(375, 812);
|
||||
static const appName = 'SportsX裁判工作台';
|
||||
static const designSize = Size(640, 1024);
|
||||
|
||||
/// 主裁判计分 H5 链接
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 由本机 Wi‑Fi 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,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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -7,7 +8,9 @@ import 'package:recording_tool/core/cache/storage_keys.dart';
|
||||
import 'package:recording_tool/core/utils/device_utils.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 +20,24 @@ 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 {
|
||||
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 +45,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 index(0=teamA,1=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 = teamA,1 = 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',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ class EventRegistrationItem {
|
||||
required this.itemName,
|
||||
required this.groupName,
|
||||
required this.matchPlace,
|
||||
|
||||
this.matchStartTime = '',
|
||||
this.matchEndTime = '',
|
||||
this.completed = false,
|
||||
@@ -136,6 +137,7 @@ class EventRegistrationItem {
|
||||
this.userId = '',
|
||||
this.playerName = '',
|
||||
this.playerPhone = '',
|
||||
this.playerNo = '',
|
||||
});
|
||||
|
||||
final String eventId;
|
||||
@@ -151,6 +153,7 @@ class EventRegistrationItem {
|
||||
final String opponentId;
|
||||
final String opponentName;
|
||||
final List<EventTeamMember> teamMembers;
|
||||
final String playerNo;
|
||||
|
||||
/// 来自报名列表父级,不在 item JSON 内
|
||||
final String userId;
|
||||
@@ -198,6 +201,7 @@ class EventRegistrationItem {
|
||||
: const [],
|
||||
userId: userId,
|
||||
playerName: playerName,
|
||||
playerNo: _readString(map, const ['playerNo']),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
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/model/model_competition_team_detail.dart';
|
||||
import 'package:recording_tool/features/competition_teams/pages/page_event_team_match.dart';
|
||||
import 'package:recording_tool/features/competition_teams/server/server_competition_teams.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/gen/assets.gen.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_webview.dart';
|
||||
import 'package:recording_tool/shared/widgets/widgets.dart';
|
||||
|
||||
@@ -25,13 +29,37 @@ class _EventInfoPageState extends ConsumerState<EventInfoPage> {
|
||||
}
|
||||
|
||||
Future<void> onItemTap(EventRegistrationItem item) async {
|
||||
// debugPrint('item tapped: ${item.itemName}');
|
||||
// await ref.read(eventInfoProvider.notifier).requestStreamKey(item);
|
||||
if (!mounted) return;
|
||||
// final profile = ref.read(eventInfoProvider).profile;
|
||||
CompetitionTeamDetail? detail;
|
||||
if (item.opponentId.isNotEmpty && item.opponentId != '0') {
|
||||
detail = await ref
|
||||
.read(competitionTeamsServerProvider)
|
||||
.fetchTeamDetail(
|
||||
itemId: item.itemId,
|
||||
eventId: item.eventId,
|
||||
scheduleId: item.scheduleId,
|
||||
opponentId: item.opponentId,
|
||||
);
|
||||
if (!mounted) return;
|
||||
AppNavigator.push(
|
||||
buildEventRegistrationDestination(
|
||||
item: item,
|
||||
playerId: widget.playerId,
|
||||
detail: detail,
|
||||
),
|
||||
context: context,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
AppNavigator.push(
|
||||
buildEventRegistrationDestination(item: item, playerId: widget.playerId),
|
||||
buildEventRegistrationDestination(
|
||||
item: item,
|
||||
playerId: widget.playerId,
|
||||
detail: null,
|
||||
),
|
||||
context: context,
|
||||
);
|
||||
}
|
||||
@@ -41,42 +69,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,12 +113,31 @@ class _EventInfoPageState extends ConsumerState<EventInfoPage> {
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildEventRegistrationDestination({
|
||||
required EventRegistrationItem item,
|
||||
required String playerId,
|
||||
required CompetitionTeamDetail? detail,
|
||||
}) {
|
||||
if (item.opponentId.isNotEmpty && item.opponentId != '0') {
|
||||
return EventTeamMatchPage(item: item, playerId: playerId);
|
||||
if (detail != null) {
|
||||
return EventTeamMatchPage(playerId: playerId, detail: detail, item: item);
|
||||
}
|
||||
return WebviewPage(
|
||||
url: AppConfig.current.mainRefereeScoreH5Url,
|
||||
@@ -108,16 +154,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 +171,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 +312,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 +382,149 @@ 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) {
|
||||
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,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,148 @@
|
||||
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 +151,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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
export 'app_avatar.dart';
|
||||
export 'app_bar.dart';
|
||||
export 'app_button.dart';
|
||||
export 'app_card.dart';
|
||||
export 'app_dialog.dart';
|
||||
|
||||
@@ -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:
|
||||
|
||||