Merge branch 'linfeng/drone_scoring/dev_1.1.0/2026630' into linfeng/drone_video/dev_1.1.0/20260707

This commit is contained in:
2026-07-29 09:19:19 +08:00
21 changed files with 1041 additions and 396 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+3
View File
@@ -17,6 +17,9 @@ enum AuthApi {
/// 人工设置晋级/淘汰 /// 人工设置晋级/淘汰
setTeamStatus('/api/events/device/schedule/team/score'), setTeamStatus('/api/events/device/schedule/team/score'),
/// 获取登录设备信息
getLoginDeviceInfo('/api/events/device/nas/info'),
/// 获取选手的赛事信息 /// 获取选手的赛事信息
playerRegistrationList('/api/events/device/player/registration/list'); playerRegistrationList('/api/events/device/player/registration/list');
+17 -3
View File
@@ -38,11 +38,15 @@ class ApiClient {
return _parseData<T>(raw, parser); 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); 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) { if (!wrapped.isSuccess) {
throw ApiException( throw ApiException(
code: wrapped.code, 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) { } on DioException catch (error) {
throw _mapDioException(error); throw _mapDioException(error);
} }
+17 -1
View File
@@ -11,10 +11,26 @@ class ApiResponse<T> {
Map<String, dynamic> json, { Map<String, dynamic> json, {
T Function(dynamic json)? fromJsonT, 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>( return ApiResponse<T>(
code: (json['code'] as num?)?.toInt() ?? 200, code: (json['code'] as num?)?.toInt() ?? 200,
message: (json['message'] ?? json['msg'] ?? '').toString(), message: (json['message'] ?? json['msg'] ?? '').toString(),
data: fromJsonT == null ? json['data'] as T? : fromJsonT(json['data']), data: data,
); );
} }
} }
+22 -9
View File
@@ -22,15 +22,20 @@ class GetRecordListResModel {
GetRecordListResModel({this.path, this.items}); GetRecordListResModel({this.path, this.items});
factory GetRecordListResModel.fromJson(Map<String, dynamic> json) => factory GetRecordListResModel.fromJson(Map<String, dynamic> json) {
GetRecordListResModel( final rawItems = json['items'];
path: json['path'], return GetRecordListResModel(
items: json['items'] == null path: json['path']?.toString(),
? [] items: rawItems is! List
: List<RecordListItem>.from( ? []
json['items']!.map((x) => RecordListItem.fromJson(x)), : rawItems
), .whereType<Map>()
); .map(
(x) => RecordListItem.fromJson(Map<String, dynamic>.from(x)),
)
.toList(),
);
}
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'path': path, 'path': path,
@@ -40,6 +45,14 @@ class GetRecordListResModel {
}; };
} }
enum RecordItemType {
file('file'),
directory('dir');
final String value;
const RecordItemType(this.value);
}
class RecordListItem { class RecordListItem {
String? name; String? name;
String? path; String? path;
+4
View File
@@ -18,6 +18,7 @@ class JwtDecodedData {
String? oId; String? oId;
List<String>? oIds; List<String>? oIds;
String? organizerId; String? organizerId;
String? nasIpAddr;
JwtDecodedData({ JwtDecodedData({
this.authType, this.authType,
@@ -28,6 +29,7 @@ class JwtDecodedData {
this.oId, this.oId,
this.oIds, this.oIds,
this.organizerId, this.organizerId,
this.nasIpAddr,
}); });
factory JwtDecodedData.fromJson(Map<String, dynamic> json) => JwtDecodedData( factory JwtDecodedData.fromJson(Map<String, dynamic> json) => JwtDecodedData(
@@ -41,6 +43,7 @@ class JwtDecodedData {
? [] ? []
: List<String>.from(json['oIds']!.map((x) => x?.toString())), : List<String>.from(json['oIds']!.map((x) => x?.toString())),
organizerId: json['organizerId']?.toString(), organizerId: json['organizerId']?.toString(),
nasIpAddr: json['nasIpAddr']?.toString(),
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
@@ -52,6 +55,7 @@ class JwtDecodedData {
'oId': oId, 'oId': oId,
'oIds': oIds == null ? [] : List<dynamic>.from(oIds!.map((x) => x)), 'oIds': oIds == null ? [] : List<dynamic>.from(oIds!.map((x) => x)),
'organizerId': organizerId, 'organizerId': organizerId,
'nasIpAddr': nasIpAddr,
}; };
} }
+40 -21
View File
@@ -12,7 +12,6 @@ import 'package:recording_tool/core/utils/util_search_nasIp.dart';
import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dart'; import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dart';
import 'package:recording_tool/features/scan_qrcode/pages/page_scan_qrcode.dart'; import 'package:recording_tool/features/scan_qrcode/pages/page_scan_qrcode.dart';
import 'package:recording_tool/gen/assets.gen.dart'; import 'package:recording_tool/gen/assets.gen.dart';
import 'package:recording_tool/shared/widgets/app_button.dart';
import 'package:recording_tool/shared/widgets/app_dialog.dart'; import 'package:recording_tool/shared/widgets/app_dialog.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart'; import 'package:recording_tool/shared/widgets/app_toast.dart';
@@ -26,10 +25,12 @@ class AuthPageWidget extends ConsumerStatefulWidget {
class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> { class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
late final TextEditingController _controller; late final TextEditingController _controller;
/// 记录点击次数
int _clickCount = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = TextEditingController(text: '999779'); _controller = TextEditingController(text: '');
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
// 静默探测 NAS,不阻塞登录 / 自动跳转 // 静默探测 NAS,不阻塞登录 / 自动跳转
@@ -37,6 +38,12 @@ class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
final token = AppStorage.getString(StorageKeys.authToken); final token = AppStorage.getString(StorageKeys.authToken);
if (token?.isNotEmpty ?? false) { if (token?.isNotEmpty ?? false) {
final success = await ref
.read(authProvider.notifier)
.parseTokenSetState();
if (!success) {
return;
}
AppNavigator.push(const ScanQrCodePage()); AppNavigator.push(const ScanQrCodePage());
} }
}); });
@@ -48,6 +55,12 @@ class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
super.dispose(); super.dispose();
} }
void _showDeviceCodeDialog() async {
final deviceCode = await DeviceUtils.deviceCode();
if (!mounted) return;
AppDialog.confirm(context, title: '设备码:$deviceCode');
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final authState = ref.watch(authProvider); final authState = ref.watch(authProvider);
@@ -61,14 +74,11 @@ class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
backgroundColor: Colors.white, backgroundColor: Colors.white,
body: Stack( body: Stack(
children: [ children: [
Positioned( Positioned.fill(
top: 0,
left: 0,
right: 0,
child: Image.asset( child: Image.asset(
_AuthAssets.pageBg, _AuthAssets.pageBg,
width: double.infinity, width: double.infinity,
fit: BoxFit.fitWidth, fit: BoxFit.fill,
), ),
), ),
SafeArea( SafeArea(
@@ -80,11 +90,20 @@ class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
SizedBox(height: 190.h), SizedBox(height: 190.h),
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(24.r), borderRadius: BorderRadius.circular(24.r),
child: Image.asset( child: GestureDetector(
_AuthAssets.appIcon, onTap: () {
width: 82.w, _clickCount++;
height: 82.w, if (_clickCount >= 5) {
fit: BoxFit.cover, _showDeviceCodeDialog();
_clickCount = 0;
}
},
child: Image.asset(
_AuthAssets.appIcon,
width: 82.w,
height: 82.w,
fit: BoxFit.cover,
),
), ),
), ),
SizedBox(height: 18.h), SizedBox(height: 18.h),
@@ -101,15 +120,15 @@ class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
isLoading: authState.isLoading, isLoading: authState.isLoading,
onPressed: _handleSubmit, onPressed: _handleSubmit,
), ),
SizedBox(height: 20.h), // SizedBox(height: 20.h),
AppButton( // AppButton(
onPressed: () async { // onPressed: () async {
final deviceCode = await DeviceUtils.deviceCode(); // final deviceCode = await DeviceUtils.deviceCode();
if (!mounted) return; // if (!mounted) return;
AppDialog.confirm(context, title: '设备码:$deviceCode'); // AppDialog.confirm(context, title: '设备码:$deviceCode');
}, // },
label: '获取设备码', // label: '获取设备码',
), // ),
], ],
), ),
), ),
+44 -9
View File
@@ -1,8 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:recording_tool/app/config/api_common.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/network/providers/dio_providers.dart';
import 'package:recording_tool/core/utils/device_utils.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_auth.dart';
import 'package:recording_tool/features/auth/model/model_jwt.dart';
class AuthServer { class AuthServer {
/// [passCode] 口令码 /// [passCode] 口令码
@@ -28,17 +30,50 @@ class AuthServer {
/// 获取赛事列表 /// 获取赛事列表
/// [path] 赛事目录 /// [path] 赛事目录
static Future<GetRecordListResModel> getRecordList( static Future<GetRecordListResModel?> getRecordList(
Ref ref, Ref ref,
String path, String path,
) async { ) async {
final apiClient = ref.read(apiClientProvider); try {
final data = await apiClient.get<GetRecordListResModel>( final apiClient = ref.read(apiClientProvider);
'http://sheling.local:9001/${AuthApi.getRecordList.path}', // NAS /api/files 直接返回 {path, items},不是业务网关的 {code,message,data} 包装。
queryParameters: {'path': path}, final data = await apiClient.get<GetRecordListResModel>(
parser: (json) => 'http://sheling.local:9001/${AuthApi.getRecordList.path}',
GetRecordListResModel.fromJson(json as Map<String, dynamic>), queryParameters: {'path': path},
); wrapResponse: false,
return data; parser: (json) {
if (json is! Map) {
throw const FormatException('录像列表响应格式错误');
}
return GetRecordListResModel.fromJson(
Map<String, dynamic>.from(json),
);
},
);
return data;
} catch (error) {
debugPrint('getRecordList failed: $error');
return null;
}
}
/// 获取登录设备信息
static Future<JwtDecodedData?> getLoginDeviceInfo(Ref ref) async {
try {
final apiClient = ref.read(apiClientProvider);
final data = await apiClient.get<JwtDecodedData>(
AuthApi.getLoginDeviceInfo.path,
parser: (json) {
if (json is! Map) {
throw const FormatException('登录设备信息响应格式错误');
}
return JwtDecodedData.fromJson(Map<String, dynamic>.from(json));
},
);
return data;
} catch (error) {
debugPrint('getLoginDeviceInfo failed: $error');
return null;
}
} }
} }
+1
View File
@@ -28,6 +28,7 @@ class AuthState {
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage ?? this.errorMessage, errorMessage: errorMessage ?? this.errorMessage,
jwtDecodedData: jwtDecodedData ?? this.jwtDecodedData, jwtDecodedData: jwtDecodedData ?? this.jwtDecodedData,
recordList: recordList ?? this.recordList,
); );
} }
} }
@@ -1,11 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.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/app_storage.dart';
import 'package:recording_tool/core/cache/storage_keys.dart'; import 'package:recording_tool/core/cache/storage_keys.dart';
import 'package:recording_tool/core/network/api_exception.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/server/server_auth.dart';
import 'package:recording_tool/features/auth/state/state_auth.dart'; import 'package:recording_tool/features/auth/state/state_auth.dart';
@@ -32,7 +30,11 @@ class AuthViewModel extends StateNotifier<AuthState> {
await AppStorage.setString(StorageKeys.authToken, data.deviceAccessToken); await AppStorage.setString(StorageKeys.authToken, data.deviceAccessToken);
state = const AuthState(); state = const AuthState();
if (data.deviceAccessToken.isNotEmpty) { if (data.deviceAccessToken.isNotEmpty) {
parseTokenSetState(data.deviceAccessToken); final ok = await parseTokenSetState();
if (!ok) {
state = const AuthState(errorMessage: '获取赛事信息失败,请重试');
return false;
}
} }
return true; return true;
} on FormatException catch (error) { } on FormatException catch (error) {
@@ -48,29 +50,41 @@ class AuthViewModel extends StateNotifier<AuthState> {
} }
/// 解析 TOKEN,并更新状态 /// 解析 TOKEN,并更新状态
Future<bool> parseTokenSetState(String token) async { Future<bool> parseTokenSetState() async {
final decoded = JwtDecoder.decode(token); final data = await AuthServer.getLoginDeviceInfo(_ref);
final rawData = decoded['data']; if (data == null) return false;
if (rawData is! Map) return false; state = state.copyWith(jwtDecodedData: data);
try { return true;
final data = JwtDecodedData.fromJson(Map<String, dynamic>.from(rawData)); // final decoded = JwtDecoder.decode(token);
state = state.copyWith(jwtDecodedData: data); // final rawData = decoded['data'];
return true; // if (rawData is! Map) return false;
} catch (error) { // try {
debugPrint('认证失败,请重试: $error'); // final data = JwtDecodedData.fromJson(Map<String, dynamic>.from(rawData));
return false; // state = state.copyWith(jwtDecodedData: data);
} // return true;
// } catch (error) {
// debugPrint('认证失败,请重试: $error');
// return false;
// }
} }
/// 获取赛事列表 /// 获取赛事列表
Future<bool> getRecordList(String eventName) async { Future<bool> getRecordList(String eventName) async {
if (eventName.isEmpty) return false; if (eventName.isEmpty) return false;
final data = await AuthServer.getRecordList(_ref, eventName); final data = await AuthServer.getRecordList(_ref, eventName);
if (data.items == null || data.items!.isEmpty) return false; if (data == null) return false;
if (data.items == null) return false;
state = state.copyWith(recordList: data.items); state = state.copyWith(recordList: data.items);
return true; return true;
} }
/// 获取指定目录的录像列表(不更新 state,用于目录下钻)
Future<List<RecordListItem>?> fetchRecordList(String path) async {
if (path.isEmpty) return null;
final data = await AuthServer.getRecordList(_ref, path);
return data?.items;
}
/// 清空授权信息(本地 token + 内存状态) /// 清空授权信息(本地 token + 内存状态)
Future<void> clearAuth() async { Future<void> clearAuth() async {
await AppStorage.remove(StorageKeys.authToken); await AppStorage.remove(StorageKeys.authToken);
@@ -7,6 +7,8 @@ import 'package:recording_tool/features/competition_teams/model/model_competitio
import 'package:recording_tool/features/competition_teams/widgets/widget_manual_winner_dialog.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/request_model/request_model_event.dart';
import 'package:recording_tool/features/events/server/server_events.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_empty_view.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart'; import 'package:recording_tool/shared/widgets/app_toast.dart';
@@ -79,14 +81,14 @@ class _CompetitionTeamDetailPageState
final matchups = _matchups; final matchups = _matchups;
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: const Color(0xFFF2F4F7),
appBar: AppBar(title: Text(_title)), appBar: AppPageBar(title: _title),
body: matchups.isEmpty body: matchups.isEmpty
? const AppEmptyView(message: '暂无对阵信息') ? const AppEmptyView(message: '暂无对阵信息')
: ListView.separated( : ListView.separated(
padding: EdgeInsets.fromLTRB(20.w, 24.h, 20.w, 36.h), padding: EdgeInsets.only(bottom: 36.h),
itemCount: matchups.length, itemCount: matchups.length,
separatorBuilder: (_, _) => SizedBox(height: 22.h), separatorBuilder: (_, _) => SizedBox(height: 12.h),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final raw = matchups[index]; final raw = matchups[index];
final matchup = _toCompetitionMatchup(raw, index); final matchup = _toCompetitionMatchup(raw, index);
@@ -95,8 +97,6 @@ class _CompetitionTeamDetailPageState
} }
return _MatchupCard( return _MatchupCard(
matchup: matchup, matchup: matchup,
index: index,
matchTitle: raw.matchTitle?.trim() ?? '',
onManualProcess: () => _handleManualProcess(raw, index), onManualProcess: () => _handleManualProcess(raw, index),
); );
}, },
@@ -175,91 +175,74 @@ class _CompetitionTeamDetailPageState
} }
class _MatchupCard extends StatelessWidget { class _MatchupCard extends StatelessWidget {
const _MatchupCard({ const _MatchupCard({required this.matchup, required this.onManualProcess});
required this.matchup,
required this.index, /// 背景图 image_team_vs_bg.png 的原始宽高比(1920 x 318)。
required this.matchTitle, static const double _vsBgAspectRatio = 1920 / 318;
required this.onManualProcess,
});
final CompetitionMatchup matchup; final CompetitionMatchup matchup;
final int index;
final String matchTitle;
final VoidCallback onManualProcess; final VoidCallback onManualProcess;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final title = matchTitle.isEmpty ? '${index + 1}' : matchTitle;
return Container( return Container(
key: ValueKey('competition-matchup-${matchup.id}'), key: ValueKey('competition-matchup-${matchup.id}'),
padding: EdgeInsets.fromLTRB(16.w, 10.h, 16.w, 18.h), color: Colors.white,
decoration: BoxDecoration( padding: EdgeInsets.fromLTRB(12.w, 4.h, 12.w, 24.h),
color: Colors.white,
border: Border.all(color: const Color(0xFFC7CCD4)),
borderRadius: BorderRadius.circular(12.r),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row( Align(
children: [ alignment: Alignment.centerRight,
Expanded( child: TextButton(
child: Text( key: ValueKey('manual-process-${matchup.id}'),
title, onPressed: onManualProcess,
maxLines: 1, style: TextButton.styleFrom(
overflow: TextOverflow.ellipsis, padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
style: TextStyle( minimumSize: Size.zero,
fontSize: 14.sp, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
color: const Color(0xFF7A828E), ),
), child: Text(
'人工处理',
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w500,
color: const Color(0xFF078AF2),
), ),
), ),
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), SizedBox(height: 8.h),
Row( AspectRatio(
crossAxisAlignment: CrossAxisAlignment.center, aspectRatio: _vsBgAspectRatio,
children: [ child: DecoratedBox(
Expanded( decoration: BoxDecoration(
child: _TeamPanel( image: DecorationImage(
team: matchup.teamA, image: AssetImage(Assets.images.imageTeamVsBg.path),
alignment: CrossAxisAlignment.start, fit: BoxFit.fill,
winner: matchup.winnerTeamId == matchup.teamA.id,
accentColor: const Color(0xFFFF6B75),
), ),
), ),
Padding( child: Row(
padding: EdgeInsets.symmetric(horizontal: 12.w), children: [
child: Text( Expanded(
'VS', child: _TeamPanel(
style: TextStyle( team: matchup.teamA,
fontSize: 21.sp, isLeft: true,
fontWeight: FontWeight.w700, winner: matchup.winnerTeamId == matchup.teamA.id,
color: const Color(0xFF303640), 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),
),
),
],
), ),
], ],
), ),
@@ -270,83 +253,72 @@ class _MatchupCard extends StatelessWidget {
class _TeamPanel extends StatelessWidget { class _TeamPanel extends StatelessWidget {
const _TeamPanel({ const _TeamPanel({
required this.team, required this.team,
required this.alignment, required this.isLeft,
required this.winner, required this.winner,
required this.accentColor, required this.accentColor,
this.textAlign = TextAlign.start,
}); });
final CompetitionTeam team; final CompetitionTeam team;
final CrossAxisAlignment alignment;
final TextAlign textAlign; /// 左半区(红色面板,右对齐、略偏上);右半区(蓝色面板,左对齐、略偏下)。
final bool isLeft;
final bool winner; final bool winner;
final Color accentColor; final Color accentColor;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( final textAlign = isLeft ? TextAlign.end : TextAlign.start;
crossAxisAlignment: alignment,
final nameRow = Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
AnimatedContainer( Flexible(
duration: const Duration(milliseconds: 180), child: Text(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h), team.name,
decoration: BoxDecoration( maxLines: 1,
color: winner overflow: TextOverflow.ellipsis,
? accentColor.withValues(alpha: 0.14) textAlign: textAlign,
: Colors.transparent, style: TextStyle(
borderRadius: BorderRadius.circular(8.r), fontSize: 15.sp,
), fontWeight: FontWeight.w600,
child: Row( color: const Color(0xFF282E37),
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),
), ),
), ),
if (winner) ...[ if (winner) ...[
SizedBox(height: 8.h), SizedBox(width: 4.w),
Container( Icon(
Icons.emoji_events,
key: ValueKey('winner-${team.id}'), key: ValueKey('winner-${team.id}'),
padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 3.h), size: 14.r,
decoration: BoxDecoration( color: accentColor,
color: accentColor,
borderRadius: BorderRadius.circular(10.r),
),
child: Text(
'胜方',
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
), ),
], ],
], ],
); );
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)),
),
],
),
);
} }
} }
@@ -6,6 +6,7 @@ import 'package:recording_tool/features/competition_teams/model/model_competitio
import 'package:recording_tool/features/competition_teams/pages/page_competition_team_detail.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/server/server_competition_teams.dart';
import 'package:recording_tool/features/competition_teams/view_model/view_model_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_empty_view.dart';
import 'package:recording_tool/shared/widgets/app_error_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_loading_view.dart';
@@ -34,8 +35,8 @@ class _CompetitionTeamListPageState
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = ref.watch(competitionTeamsProvider); final state = ref.watch(competitionTeamsProvider);
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: const Color(0xFFF5F6F8),
appBar: AppBar(title: const Text('参赛队伍')), appBar: AppPageBar(title: '参赛队伍'),
body: SafeArea( body: SafeArea(
top: false, top: false,
child: Builder( child: Builder(
@@ -56,8 +57,8 @@ class _CompetitionTeamListPageState
onRefresh: ref.read(competitionTeamsProvider.notifier).refresh, onRefresh: ref.read(competitionTeamsProvider.notifier).refresh,
onLoadMore: ref.read(competitionTeamsProvider.notifier).loadMore, onLoadMore: ref.read(competitionTeamsProvider.notifier).loadMore,
enablePullUp: state.hasMore, enablePullUp: state.hasMore,
padding: EdgeInsets.fromLTRB(20.w, 18.h, 20.w, 28.h), padding: EdgeInsets.fromLTRB(10.w, 10.h, 10.w, 24.h),
separator: SizedBox(height: 14.h), separator: SizedBox(height: 8.h),
empty: const AppEmptyView(message: '暂无参赛队伍'), empty: const AppEmptyView(message: '暂无参赛队伍'),
itemBuilder: (context, item, index) { itemBuilder: (context, item, index) {
return _CompetitionScheduleCard( return _CompetitionScheduleCard(
@@ -97,33 +98,26 @@ class _CompetitionScheduleCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheduleText = item.scheduleTime.isEmpty ? '时间待定' : item.scheduleTime;
return Material( return Material(
key: ValueKey('competition-team-item-${item.itemId}'), key: ValueKey('competition-team-item-${item.itemId}'),
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(14.r), borderRadius: BorderRadius.circular(6.r),
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(14.r), borderRadius: BorderRadius.circular(6.r),
child: Container( child: Container(
constraints: BoxConstraints(minHeight: 142.h), constraints: BoxConstraints(minHeight: 86.h),
padding: EdgeInsets.all(16.r), padding: EdgeInsets.fromLTRB(12.w, 10.h, 14.w, 10.h),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14.r), color: Colors.white,
border: Border.all(color: const Color(0xFFD7DBE2)), borderRadius: BorderRadius.circular(6.r),
boxShadow: const [
BoxShadow(
color: Color(0x0F1A2230),
blurRadius: 16,
offset: Offset(0, 6),
),
],
), ),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Expanded( Expanded(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
@@ -131,23 +125,21 @@ class _CompetitionScheduleCard extends StatelessWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: 19.sp, fontSize: 13.sp,
fontWeight: FontWeight.w600, height: 1.2,
color: const Color(0xFF20242B), color: const Color(0xFF30343A),
fontWeight: FontWeight.w700,
), ),
), ),
SizedBox(height: 18.h), SizedBox(height: 6.h),
_InfoLine( _MetaText(_formatScheduleTime(item)),
icon: Icons.schedule_outlined,
text: scheduleText,
),
], ],
), ),
), ),
SizedBox(width: 14.w), SizedBox(width: 12.w),
Icon( Icon(
Icons.chevron_right, Icons.chevron_right,
size: 28.r, size: 20.r,
color: const Color(0xFF9AA3AF), color: const Color(0xFF9AA3AF),
), ),
], ],
@@ -158,27 +150,40 @@ class _CompetitionScheduleCard extends StatelessWidget {
} }
} }
class _InfoLine extends StatelessWidget { class _MetaText extends StatelessWidget {
const _InfoLine({required this.icon, required this.text}); const _MetaText(this.text);
final IconData icon;
final String text; final String text;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Text(
children: [ text,
Icon(icon, size: 18.r, color: const Color(0xFF7B8491)), maxLines: 1,
SizedBox(width: 8.w), overflow: TextOverflow.ellipsis,
Expanded( style: TextStyle(
child: Text( fontSize: 10.sp,
text, height: 1.2,
maxLines: 1, color: const Color(0xFF6E747D),
overflow: TextOverflow.ellipsis, fontWeight: FontWeight.w400,
style: TextStyle(fontSize: 15.sp, color: const Color(0xFF525B68)), ),
),
),
],
); );
} }
} }
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,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';
}
@@ -10,26 +10,13 @@ import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dar
import 'package:recording_tool/features/events/model/model_event_info.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/pages/page_event_info.dart';
import 'package:recording_tool/features/events/view_model/view_model_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/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_qr_scanner_dialog.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart'; import 'package:recording_tool/shared/widgets/app_toast.dart';
class ScanQrCodePage extends ConsumerStatefulWidget { class ScanQrCodePage extends ConsumerStatefulWidget {
const ScanQrCodePage({super.key}); 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 @override
ConsumerState<ScanQrCodePage> createState() => _ScanQrCodePageState(); ConsumerState<ScanQrCodePage> createState() => _ScanQrCodePageState();
} }
@@ -208,7 +195,13 @@ class _ScanQrCodePageState extends ConsumerState<ScanQrCodePage> {
AppToast.show('暂无录像'); AppToast.show('暂无录像');
return; return;
} }
AppToast.show('录像列表已更新'); if (!mounted) return;
final items = ref.read(authProvider).recordList ?? const [];
AppNavigator.push(
RecordListPage(breadcrumbs: [eventName.trim()], items: items),
context: context,
);
} catch (error) { } catch (error) {
EasyLoading.dismiss(); EasyLoading.dismiss();
AppToast.show('查询录像失败'); AppToast.show('查询录像失败');
@@ -224,18 +217,41 @@ class _BrandHeader extends StatelessWidget {
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Image.asset( Expanded(
_ScanAssets.appIcon, child: Row(
width: 34.w, children: [
height: 34.w, ClipRRect(
fit: BoxFit.cover, 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,
),
],
),
), ),
SizedBox(width: 8.w), Row(
Image.asset( children: [
_ScanAssets.appNameText, Icon(Icons.chevron_left_rounded, color: Colors.black, size: 28.sp),
width: 117.w, Text(
height: 29.w, '返回',
fit: BoxFit.contain, style: TextStyle(
// color: const Color(0xFF53A7F3),
color: Colors.black,
fontSize: 14.sp,
fontWeight: FontWeight.w400,
),
),
],
), ),
], ],
); );
+10
View File
@@ -76,6 +76,14 @@ class $AssetsImagesGen {
AssetGenImage get imageStart => AssetGenImage get imageStart =>
const AssetGenImage('assets/images/image_start.png'); 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 /// File path: assets/images/image_vs.png
AssetGenImage get imageVs => AssetGenImage get imageVs =>
const AssetGenImage('assets/images/image_vs.png'); const AssetGenImage('assets/images/image_vs.png');
@@ -95,6 +103,8 @@ class $AssetsImagesGen {
imageScan, imageScan,
imageScanQrcode, imageScanQrcode,
imageStart, imageStart,
imageTeamVsBg,
imageVideoIcon,
imageVs, imageVs,
]; ];
} }
+8 -1
View File
@@ -14,6 +14,8 @@ class AppPageBar extends StatelessWidget implements PreferredSizeWidget {
this.backgroundImage, this.backgroundImage,
this.onBack, this.onBack,
this.actions, this.actions,
this.centerTitle = true,
this.titleSpacing,
}); });
final String title; final String title;
@@ -22,6 +24,10 @@ class AppPageBar extends StatelessWidget implements PreferredSizeWidget {
final VoidCallback? onBack; final VoidCallback? onBack;
final List<Widget>? actions; final List<Widget>? actions;
/// 标题是否居中;false 时左对齐,紧随返回按钮。
final bool centerTitle;
final double? titleSpacing;
@override @override
Size get preferredSize => const Size.fromHeight(kToolbarHeight); Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@@ -33,7 +39,8 @@ class AppPageBar extends StatelessWidget implements PreferredSizeWidget {
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
centerTitle: true, centerTitle: centerTitle,
titleSpacing: titleSpacing,
systemOverlayStyle: SystemUiOverlayStyle.light, systemOverlayStyle: SystemUiOverlayStyle.light,
flexibleSpace: SizedBox.expand( flexibleSpace: SizedBox.expand(
child: Image.asset( child: Image.asset(
+2
View File
@@ -55,6 +55,8 @@ dependencies:
path: plugins/apivideo_live_stream path: plugins/apivideo_live_stream
jwt_decoder: ^2.0.1 jwt_decoder: ^2.0.1
network_info_plus: ^7.0.0 network_info_plus: ^7.0.0
video_player: ^2.13.0
get_thumbnail_video: ^0.7.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: