diff --git a/.gitignore b/.gitignore index 7e8096d..4aa934c 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,4 @@ app.*.map.json CLAUDE.md AGENTS.md test/ -script/ \ No newline at end of file +script/ diff --git a/assets/images/image_team_vs_bg.png b/assets/images/image_team_vs_bg.png new file mode 100644 index 0000000..a450210 Binary files /dev/null and b/assets/images/image_team_vs_bg.png differ diff --git a/assets/images/image_video_icon.png b/assets/images/image_video_icon.png new file mode 100644 index 0000000..1b332ac Binary files /dev/null and b/assets/images/image_video_icon.png differ diff --git a/lib/app/config/api_common.dart b/lib/app/config/api_common.dart index 3b84cb3..fda1018 100644 --- a/lib/app/config/api_common.dart +++ b/lib/app/config/api_common.dart @@ -17,6 +17,9 @@ enum AuthApi { /// 人工设置晋级/淘汰 setTeamStatus('/api/events/device/schedule/team/score'), + /// 获取登录设备信息 + getLoginDeviceInfo('/api/events/device/nas/info'), + /// 获取选手的赛事信息 playerRegistrationList('/api/events/device/player/registration/list'); diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index 3c6991e..fc3fb27 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -38,11 +38,15 @@ class ApiClient { return _parseData(raw, parser); } - if (raw is! Map) { + final payload = raw is Map ? Map.from(raw) : null; + if (payload == null || + !(payload.containsKey('code') || payload.containsKey('message'))) { return _parseData(raw, parser); } - final wrapped = ApiResponse.fromJson(raw, fromJsonT: parser); + // 先按 {code,message,data} 解包,再用业务 parser 解析 data, + // 避免把 Map 直接强转成业务模型。 + final wrapped = ApiResponse.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(wrapped.data, parser); } on DioException catch (error) { throw _mapDioException(error); } diff --git a/lib/core/network/api_response.dart b/lib/core/network/api_response.dart index 1f9fb46..99aa70a 100644 --- a/lib/core/network/api_response.dart +++ b/lib/core/network/api_response.dart @@ -11,10 +11,26 @@ class ApiResponse { Map 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( 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, ); } } diff --git a/lib/features/auth/model/model_auth.dart b/lib/features/auth/model/model_auth.dart index 9ff9118..a380c33 100644 --- a/lib/features/auth/model/model_auth.dart +++ b/lib/features/auth/model/model_auth.dart @@ -22,15 +22,20 @@ class GetRecordListResModel { GetRecordListResModel({this.path, this.items}); - factory GetRecordListResModel.fromJson(Map json) => - GetRecordListResModel( - path: json['path'], - items: json['items'] == null - ? [] - : List.from( - json['items']!.map((x) => RecordListItem.fromJson(x)), - ), - ); + factory GetRecordListResModel.fromJson(Map json) { + final rawItems = json['items']; + return GetRecordListResModel( + path: json['path']?.toString(), + items: rawItems is! List + ? [] + : rawItems + .whereType() + .map( + (x) => RecordListItem.fromJson(Map.from(x)), + ) + .toList(), + ); + } Map 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; diff --git a/lib/features/auth/model/model_jwt.dart b/lib/features/auth/model/model_jwt.dart index af3d24c..6974552 100644 --- a/lib/features/auth/model/model_jwt.dart +++ b/lib/features/auth/model/model_jwt.dart @@ -18,6 +18,7 @@ class JwtDecodedData { String? oId; List? oIds; String? organizerId; + String? nasIpAddr; JwtDecodedData({ this.authType, @@ -28,6 +29,7 @@ class JwtDecodedData { this.oId, this.oIds, this.organizerId, + this.nasIpAddr, }); factory JwtDecodedData.fromJson(Map json) => JwtDecodedData( @@ -41,6 +43,7 @@ class JwtDecodedData { ? [] : List.from(json['oIds']!.map((x) => x?.toString())), organizerId: json['organizerId']?.toString(), + nasIpAddr: json['nasIpAddr']?.toString(), ); Map toJson() => { @@ -52,6 +55,7 @@ class JwtDecodedData { 'oId': oId, 'oIds': oIds == null ? [] : List.from(oIds!.map((x) => x)), 'organizerId': organizerId, + 'nasIpAddr': nasIpAddr, }; } diff --git a/lib/features/auth/pages/page_auth.dart b/lib/features/auth/pages/page_auth.dart index bd27fe6..3981a88 100644 --- a/lib/features/auth/pages/page_auth.dart +++ b/lib/features/auth/pages/page_auth.dart @@ -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/scan_qrcode/pages/page_scan_qrcode.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_toast.dart'; @@ -26,10 +25,12 @@ class AuthPageWidget extends ConsumerStatefulWidget { class _AuthPageWidgetState extends ConsumerState { late final TextEditingController _controller; + /// 记录点击次数 + int _clickCount = 0; @override void initState() { super.initState(); - _controller = TextEditingController(text: '999779'); + _controller = TextEditingController(text: ''); WidgetsBinding.instance.addPostFrameCallback((_) async { // 静默探测 NAS,不阻塞登录 / 自动跳转 @@ -37,6 +38,12 @@ class _AuthPageWidgetState extends ConsumerState { 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()); } }); @@ -48,6 +55,12 @@ class _AuthPageWidgetState extends ConsumerState { 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); @@ -61,14 +74,11 @@ class _AuthPageWidgetState extends ConsumerState { backgroundColor: Colors.white, body: Stack( children: [ - Positioned( - top: 0, - left: 0, - right: 0, + Positioned.fill( child: Image.asset( _AuthAssets.pageBg, width: double.infinity, - fit: BoxFit.fitWidth, + fit: BoxFit.fill, ), ), SafeArea( @@ -80,11 +90,20 @@ class _AuthPageWidgetState extends ConsumerState { SizedBox(height: 190.h), ClipRRect( borderRadius: BorderRadius.circular(24.r), - child: Image.asset( - _AuthAssets.appIcon, - width: 82.w, - height: 82.w, - fit: BoxFit.cover, + 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), @@ -101,15 +120,15 @@ class _AuthPageWidgetState extends ConsumerState { 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: '获取设备码', - ), + // SizedBox(height: 20.h), + // AppButton( + // onPressed: () async { + // final deviceCode = await DeviceUtils.deviceCode(); + // if (!mounted) return; + // AppDialog.confirm(context, title: '设备码:$deviceCode'); + // }, + // label: '获取设备码', + // ), ], ), ), diff --git a/lib/features/auth/server/server_auth.dart b/lib/features/auth/server/server_auth.dart index 2c3ad90..415d18c 100644 --- a/lib/features/auth/server/server_auth.dart +++ b/lib/features/auth/server/server_auth.dart @@ -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,50 @@ class AuthServer { /// 获取赛事列表 /// [path] 赛事目录 - static Future getRecordList( + static Future getRecordList( Ref ref, String path, ) async { - final apiClient = ref.read(apiClientProvider); - final data = await apiClient.get( - 'http://sheling.local:9001/${AuthApi.getRecordList.path}', - queryParameters: {'path': path}, - parser: (json) => - GetRecordListResModel.fromJson(json as Map), - ); - return data; + try { + final apiClient = ref.read(apiClientProvider); + // NAS /api/files 直接返回 {path, items},不是业务网关的 {code,message,data} 包装。 + final data = await apiClient.get( + 'http://sheling.local:9001/${AuthApi.getRecordList.path}', + queryParameters: {'path': path}, + wrapResponse: false, + parser: (json) { + if (json is! Map) { + throw const FormatException('录像列表响应格式错误'); + } + return GetRecordListResModel.fromJson( + Map.from(json), + ); + }, + ); + return data; + } catch (error) { + debugPrint('getRecordList failed: $error'); + return null; + } + } + + /// 获取登录设备信息 + static Future getLoginDeviceInfo(Ref ref) async { + try { + final apiClient = ref.read(apiClientProvider); + final data = await apiClient.get( + AuthApi.getLoginDeviceInfo.path, + parser: (json) { + if (json is! Map) { + throw const FormatException('登录设备信息响应格式错误'); + } + return JwtDecodedData.fromJson(Map.from(json)); + }, + ); + return data; + } catch (error) { + debugPrint('getLoginDeviceInfo failed: $error'); + return null; + } } } diff --git a/lib/features/auth/state/state_auth.dart b/lib/features/auth/state/state_auth.dart index 31b1251..e502897 100644 --- a/lib/features/auth/state/state_auth.dart +++ b/lib/features/auth/state/state_auth.dart @@ -28,6 +28,7 @@ class AuthState { isLoading: isLoading ?? this.isLoading, errorMessage: errorMessage ?? this.errorMessage, jwtDecodedData: jwtDecodedData ?? this.jwtDecodedData, + recordList: recordList ?? this.recordList, ); } } diff --git a/lib/features/auth/view_model_auth/view_model_auth.dart b/lib/features/auth/view_model_auth/view_model_auth.dart index 75c05a6..b223f6f 100644 --- a/lib/features/auth/view_model_auth/view_model_auth.dart +++ b/lib/features/auth/view_model_auth/view_model_auth.dart @@ -1,11 +1,9 @@ -import 'package:flutter/foundation.dart'; 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'; @@ -32,7 +30,11 @@ class AuthViewModel extends StateNotifier { 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) { @@ -48,29 +50,41 @@ class AuthViewModel extends StateNotifier { } /// 解析 TOKEN,并更新状态 - Future parseTokenSetState(String token) async { - final decoded = JwtDecoder.decode(token); - final rawData = decoded['data']; - if (rawData is! Map) return false; - try { - final data = JwtDecodedData.fromJson(Map.from(rawData)); - state = state.copyWith(jwtDecodedData: data); - return true; - } catch (error) { - debugPrint('认证失败,请重试: $error'); - return false; - } + Future 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.from(rawData)); + // state = state.copyWith(jwtDecodedData: data); + // return true; + // } catch (error) { + // debugPrint('认证失败,请重试: $error'); + // return false; + // } } /// 获取赛事列表 Future 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; + if (data == null) return false; + if (data.items == null) return false; state = state.copyWith(recordList: data.items); return true; } + /// 获取指定目录的录像列表(不更新 state,用于目录下钻) + Future?> fetchRecordList(String path) async { + if (path.isEmpty) return null; + final data = await AuthServer.getRecordList(_ref, path); + return data?.items; + } + /// 清空授权信息(本地 token + 内存状态) Future clearAuth() async { await AppStorage.remove(StorageKeys.authToken); diff --git a/lib/features/competition_teams/pages/page_competition_team_detail.dart b/lib/features/competition_teams/pages/page_competition_team_detail.dart index 6381959..dcaf37c 100644 --- a/lib/features/competition_teams/pages/page_competition_team_detail.dart +++ b/lib/features/competition_teams/pages/page_competition_team_detail.dart @@ -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/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'; @@ -79,14 +81,14 @@ class _CompetitionTeamDetailPageState final matchups = _matchups; return Scaffold( - backgroundColor: Colors.white, - appBar: AppBar(title: Text(_title)), + 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), + padding: EdgeInsets.only(bottom: 36.h), itemCount: matchups.length, - separatorBuilder: (_, _) => SizedBox(height: 22.h), + separatorBuilder: (_, _) => SizedBox(height: 12.h), itemBuilder: (context, index) { final raw = matchups[index]; final matchup = _toCompetitionMatchup(raw, index); @@ -95,8 +97,6 @@ class _CompetitionTeamDetailPageState } return _MatchupCard( matchup: matchup, - index: index, - matchTitle: raw.matchTitle?.trim() ?? '', onManualProcess: () => _handleManualProcess(raw, index), ); }, @@ -175,91 +175,74 @@ class _CompetitionTeamDetailPageState } class _MatchupCard extends StatelessWidget { - const _MatchupCard({ - required this.matchup, - required this.index, - required this.matchTitle, - 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 String matchTitle; final VoidCallback onManualProcess; @override Widget build(BuildContext context) { - final title = matchTitle.isEmpty ? '第 ${index + 1} 场' : matchTitle; 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: [ - Expanded( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 14.sp, - color: const Color(0xFF7A828E), - ), + 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: 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), - 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), - ), - ), - ], + ), ), ], ), @@ -270,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)), + ), + ], + ), + ); } } diff --git a/lib/features/competition_teams/pages/page_competition_team_list.dart b/lib/features/competition_teams/pages/page_competition_team_list.dart index 336cb95..c6c2071 100644 --- a/lib/features/competition_teams/pages/page_competition_team_list.dart +++ b/lib/features/competition_teams/pages/page_competition_team_list.dart @@ -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/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'; @@ -34,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( @@ -56,8 +57,8 @@ 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( @@ -97,33 +98,26 @@ class _CompetitionScheduleCard extends StatelessWidget { @override Widget build(BuildContext context) { - final scheduleText = item.scheduleTime.isEmpty ? '时间待定' : item.scheduleTime; - return Material( 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( @@ -131,23 +125,21 @@ 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.schedule_outlined, - text: scheduleText, - ), + SizedBox(height: 6.h), + _MetaText(_formatScheduleTime(item)), ], ), ), - SizedBox(width: 14.w), + SizedBox(width: 12.w), Icon( Icons.chevron_right, - size: 28.r, + size: 20.r, color: const Color(0xFF9AA3AF), ), ], @@ -158,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')}'; +} diff --git a/lib/features/scan_qrcode/pages/page_push_test.dart b/lib/features/scan_qrcode/pages/page_push_test.dart deleted file mode 100644 index 5551031..0000000 --- a/lib/features/scan_qrcode/pages/page_push_test.dart +++ /dev/null @@ -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 createState() => _PushSteamTestWidgetState(); -} - -class _PushSteamTestWidgetState extends State - 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 _initialize() async { - try { - await _controller.initialize(); - await _controller.startPreview(); - - setState(() => _ready = true); - } catch (e) { - debugPrint('初始化失败: $e'); - } - } - - Future _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 _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), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/scan_qrcode/pages/page_record_list.dart b/lib/features/scan_qrcode/pages/page_record_list.dart new file mode 100644 index 0000000..74b0269 --- /dev/null +++ b/lib/features/scan_qrcode/pages/page_record_list.dart @@ -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 breadcrumbs; + final List 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 _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 _cache = {}; + + Uint8List? _bytes; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/scan_qrcode/pages/page_record_video_player.dart b/lib/features/scan_qrcode/pages/page_record_video_player.dart new file mode 100644 index 0000000..7afe19e --- /dev/null +++ b/lib/features/scan_qrcode/pages/page_record_video_player.dart @@ -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 createState() => _RecordVideoPlayerPageState(); +} + +class _RecordVideoPlayerPageState extends State { + VideoPlayerController? _controller; + bool _initialized = false; + bool _hasError = false; + bool _showControls = true; + + @override + void initState() { + super.initState(); + _initController(); + } + + Future _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( + 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( + 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'; +} diff --git a/lib/features/scan_qrcode/pages/page_scan_qrcode.dart b/lib/features/scan_qrcode/pages/page_scan_qrcode.dart index fbc7722..2bc78f2 100644 --- a/lib/features/scan_qrcode/pages/page_scan_qrcode.dart +++ b/lib/features/scan_qrcode/pages/page_scan_qrcode.dart @@ -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/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/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 createState() => _ScanQrCodePageState(); } @@ -208,7 +195,13 @@ class _ScanQrCodePageState extends ConsumerState { AppToast.show('暂无录像'); 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) { EasyLoading.dismiss(); AppToast.show('查询录像失败'); @@ -224,18 +217,41 @@ class _BrandHeader extends StatelessWidget { return Row( mainAxisSize: MainAxisSize.min, children: [ - Image.asset( - _ScanAssets.appIcon, - width: 34.w, - height: 34.w, - fit: BoxFit.cover, + 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, + ), + ], + ), ), - SizedBox(width: 8.w), - Image.asset( - _ScanAssets.appNameText, - width: 117.w, - height: 29.w, - fit: BoxFit.contain, + 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, + ), + ), + ], ), ], ); diff --git a/lib/gen/assets.gen.dart b/lib/gen/assets.gen.dart index de8e257..1d2179d 100644 --- a/lib/gen/assets.gen.dart +++ b/lib/gen/assets.gen.dart @@ -76,6 +76,14 @@ class $AssetsImagesGen { 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'); @@ -95,6 +103,8 @@ class $AssetsImagesGen { imageScan, imageScanQrcode, imageStart, + imageTeamVsBg, + imageVideoIcon, imageVs, ]; } diff --git a/lib/shared/widgets/app_bar.dart b/lib/shared/widgets/app_bar.dart index a04eed1..5af0981 100644 --- a/lib/shared/widgets/app_bar.dart +++ b/lib/shared/widgets/app_bar.dart @@ -14,6 +14,8 @@ class AppPageBar extends StatelessWidget implements PreferredSizeWidget { this.backgroundImage, this.onBack, this.actions, + this.centerTitle = true, + this.titleSpacing, }); final String title; @@ -22,6 +24,10 @@ class AppPageBar extends StatelessWidget implements PreferredSizeWidget { final VoidCallback? onBack; final List? actions; + /// 标题是否居中;false 时左对齐,紧随返回按钮。 + final bool centerTitle; + final double? titleSpacing; + @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); @@ -33,7 +39,8 @@ class AppPageBar extends StatelessWidget implements PreferredSizeWidget { backgroundColor: Colors.transparent, elevation: 0, scrolledUnderElevation: 0, - centerTitle: true, + centerTitle: centerTitle, + titleSpacing: titleSpacing, systemOverlayStyle: SystemUiOverlayStyle.light, flexibleSpace: SizedBox.expand( child: Image.asset( diff --git a/pubspec.yaml b/pubspec.yaml index 47952c1..261d5c2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,6 +55,8 @@ dependencies: 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: