2 Commits
Author SHA1 Message Date
linfeng e5b6246458 更新应用配置以添加主裁判计分 H5 链接;
重构事件注册模型以支持团队成员信息,并简化事件信息页面的导航逻辑。
2026-07-20 10:10:51 +08:00
linfeng 4e0a675198 1.新增参赛队伍页面交互
2.新增人工处理功能交互
2026-07-20 09:29:53 +08:00
12 changed files with 1521 additions and 13 deletions
+10
View File
@@ -8,11 +8,13 @@ class EnvironmentValues {
required this.environment, required this.environment,
required this.baseUrl, required this.baseUrl,
required this.enableNetworkLog, required this.enableNetworkLog,
this.mainRefereeScoreH5Url = '',
}); });
final AppEnvironment environment; final AppEnvironment environment;
final String baseUrl; final String baseUrl;
final bool enableNetworkLog; final bool enableNetworkLog;
final String mainRefereeScoreH5Url;
} }
class AppConfig { class AppConfig {
@@ -24,6 +26,8 @@ class AppConfig {
static const appName = '飞行极控录像工作台'; static const appName = '飞行极控录像工作台';
static const designSize = Size(375, 812); static const designSize = Size(375, 812);
/// 主裁判计分 H5 链接
static void configure({ static void configure({
required AppEnvironment environment, required AppEnvironment environment,
AppPackageInfo? packageInfo, AppPackageInfo? packageInfo,
@@ -34,16 +38,22 @@ class AppConfig {
environment: AppEnvironment.dev, environment: AppEnvironment.dev,
// baseUrl: 'http://192.168.1.104:8000', // baseUrl: 'http://192.168.1.104:8000',
baseUrl: 'https://apitest.dronex.cc', baseUrl: 'https://apitest.dronex.cc',
mainRefereeScoreH5Url:
'https://drone.apptest.sportsx.cc/#/pages/h5/score-entry',
enableNetworkLog: true, enableNetworkLog: true,
), ),
AppEnvironment.staging => const EnvironmentValues( AppEnvironment.staging => const EnvironmentValues(
environment: AppEnvironment.staging, environment: AppEnvironment.staging,
baseUrl: 'https://apitest.dronex.cc', baseUrl: 'https://apitest.dronex.cc',
mainRefereeScoreH5Url:
'https://drone.apptest.sportsx.cc/#/pages/h5/score-entry',
enableNetworkLog: true, enableNetworkLog: true,
), ),
AppEnvironment.prod => const EnvironmentValues( AppEnvironment.prod => const EnvironmentValues(
environment: AppEnvironment.prod, environment: AppEnvironment.prod,
baseUrl: 'https://api.dronex.cc', baseUrl: 'https://api.dronex.cc',
mainRefereeScoreH5Url:
'https://drone.sportsx.cc/#/pages/h5/score-entry',
enableNetworkLog: false, enableNetworkLog: false,
), ),
}; };
@@ -0,0 +1,101 @@
class CompetitionPlayer {
const CompetitionPlayer({required this.id, required this.name});
final String id;
final String name;
}
class CompetitionTeam {
const CompetitionTeam({
required this.id,
required this.name,
required this.players,
});
final String id;
final String name;
final List<CompetitionPlayer> players;
String get playerNames => players.map((player) => player.name).join('');
}
class CompetitionMatchup {
const CompetitionMatchup({
required this.id,
required this.teamA,
required this.teamB,
this.winnerTeamId,
});
final String id;
final CompetitionTeam teamA;
final CompetitionTeam teamB;
final String? winnerTeamId;
CompetitionMatchup copyWith({String? winnerTeamId}) {
return CompetitionMatchup(
id: id,
teamA: teamA,
teamB: teamB,
winnerTeamId: winnerTeamId ?? this.winnerTeamId,
);
}
}
class CompetitionTeamListItem {
const CompetitionTeamListItem({
required this.id,
required this.eventName,
required this.itemName,
required this.groupName,
required this.matchPlace,
required this.matchStartTime,
required this.matchEndTime,
required this.matchups,
this.completed = false,
});
final String id;
final String eventName;
final String itemName;
final String groupName;
final String matchPlace;
final String matchStartTime;
final String matchEndTime;
final List<CompetitionMatchup> matchups;
final bool completed;
String get title => groupName.isEmpty ? itemName : '$itemName $groupName';
String get scheduleTime => '$matchStartTime-$matchEndTime';
CompetitionTeamListItem copyWith({List<CompetitionMatchup>? matchups}) {
return CompetitionTeamListItem(
id: id,
eventName: eventName,
itemName: itemName,
groupName: groupName,
matchPlace: matchPlace,
matchStartTime: matchStartTime,
matchEndTime: matchEndTime,
matchups: matchups ?? this.matchups,
completed: completed,
);
}
}
class CompetitionTeamPageResult {
const CompetitionTeamPageResult({
required this.items,
required this.total,
required this.page,
required this.pageSize,
});
final List<CompetitionTeamListItem> items;
final int total;
final int page;
final int pageSize;
bool get hasMore => page * pageSize < total;
}
@@ -0,0 +1,249 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
import 'package:recording_tool/features/competition_teams/view_model/view_model_competition_teams.dart';
import 'package:recording_tool/features/competition_teams/widgets/widget_manual_winner_dialog.dart';
import 'package:recording_tool/shared/widgets/app_empty_view.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
class CompetitionTeamDetailPage extends ConsumerWidget {
const CompetitionTeamDetailPage({super.key, required this.itemId});
final String itemId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final items = ref.watch(
competitionTeamsProvider.select((state) => state.items),
);
CompetitionTeamListItem? item;
for (final candidate in items) {
if (candidate.id == itemId) {
item = candidate;
break;
}
}
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(title: Text(item?.title ?? '参赛队伍')),
body: item == null
? const AppEmptyView(message: '未找到对阵信息')
: ListView.separated(
padding: EdgeInsets.fromLTRB(20.w, 24.h, 20.w, 36.h),
itemCount: item.matchups.length,
separatorBuilder: (_, _) => SizedBox(height: 22.h),
itemBuilder: (context, index) {
final matchup = item!.matchups[index];
return _MatchupCard(
matchup: matchup,
index: index,
onManualProcess: () => _handleManualProcess(
context,
ref,
itemId: item!.id,
matchup: matchup,
),
);
},
),
);
}
Future<void> _handleManualProcess(
BuildContext context,
WidgetRef ref, {
required String itemId,
required CompetitionMatchup matchup,
}) async {
final winnerTeamId = await ManualWinnerDialog.show(
context,
matchup: matchup,
);
if (winnerTeamId == null || !context.mounted) return;
ref
.read(competitionTeamsProvider.notifier)
.selectWinner(
itemId: itemId,
matchupId: matchup.id,
winnerTeamId: winnerTeamId,
);
final winnerName = winnerTeamId == matchup.teamA.id
? matchup.teamA.name
: matchup.teamB.name;
AppToast.show('已设置$winnerName直接获胜');
}
}
class _MatchupCard extends StatelessWidget {
const _MatchupCard({
required this.matchup,
required this.index,
required this.onManualProcess,
});
final CompetitionMatchup matchup;
final int index;
final VoidCallback onManualProcess;
@override
Widget build(BuildContext context) {
return Container(
key: ValueKey('competition-matchup-${matchup.id}'),
padding: EdgeInsets.fromLTRB(16.w, 10.h, 16.w, 18.h),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFC7CCD4)),
borderRadius: BorderRadius.circular(12.r),
),
child: Column(
children: [
Row(
children: [
Text(
'${index + 1}',
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF7A828E),
),
),
const Spacer(),
TextButton(
key: ValueKey('manual-process-${matchup.id}'),
onPressed: onManualProcess,
child: Text(
'人工处理',
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF078AF2),
),
),
),
],
),
SizedBox(height: 8.h),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: _TeamPanel(
team: matchup.teamA,
alignment: CrossAxisAlignment.start,
winner: matchup.winnerTeamId == matchup.teamA.id,
accentColor: const Color(0xFFFF6B75),
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: Text(
'VS',
style: TextStyle(
fontSize: 21.sp,
fontWeight: FontWeight.w700,
color: const Color(0xFF303640),
),
),
),
Expanded(
child: _TeamPanel(
team: matchup.teamB,
alignment: CrossAxisAlignment.end,
textAlign: TextAlign.end,
winner: matchup.winnerTeamId == matchup.teamB.id,
accentColor: const Color(0xFF20BFA9),
),
),
],
),
],
),
);
}
}
class _TeamPanel extends StatelessWidget {
const _TeamPanel({
required this.team,
required this.alignment,
required this.winner,
required this.accentColor,
this.textAlign = TextAlign.start,
});
final CompetitionTeam team;
final CrossAxisAlignment alignment;
final TextAlign textAlign;
final bool winner;
final Color accentColor;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: alignment,
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),
),
),
if (winner) ...[
SizedBox(height: 8.h),
Container(
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,
),
),
),
],
],
);
}
}
@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
import 'package:recording_tool/features/competition_teams/pages/page_competition_team_detail.dart';
import 'package:recording_tool/features/competition_teams/view_model/view_model_competition_teams.dart';
import 'package:recording_tool/shared/widgets/app_empty_view.dart';
import 'package:recording_tool/shared/widgets/app_error_view.dart';
import 'package:recording_tool/shared/widgets/app_loading_view.dart';
import 'package:recording_tool/shared/widgets/app_refresh_list.dart';
class CompetitionTeamListPage extends ConsumerStatefulWidget {
const CompetitionTeamListPage({super.key});
@override
ConsumerState<CompetitionTeamListPage> createState() =>
_CompetitionTeamListPageState();
}
class _CompetitionTeamListPageState
extends ConsumerState<CompetitionTeamListPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(competitionTeamsProvider.notifier).loadInitial();
});
}
@override
Widget build(BuildContext context) {
final state = ref.watch(competitionTeamsProvider);
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(title: const Text('参赛队伍')),
body: SafeArea(
top: false,
child: Builder(
builder: (context) {
if (state.isInitialLoading && state.items.isEmpty) {
return const AppLoadingView(message: '正在加载参赛队伍...');
}
if (state.errorMessage != null && state.items.isEmpty) {
return AppErrorView(
message: state.errorMessage!,
onRetry: () =>
ref.read(competitionTeamsProvider.notifier).loadInitial(),
);
}
return AppRefreshList<CompetitionTeamListItem>(
items: state.items,
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),
empty: const AppEmptyView(message: '暂无参赛队伍'),
itemBuilder: (context, item, index) {
return _CompetitionScheduleCard(
item: item,
onTap: () => AppNavigator.push(
CompetitionTeamDetailPage(itemId: item.id),
context: context,
),
);
},
);
},
),
),
);
}
}
class _CompetitionScheduleCard extends StatelessWidget {
const _CompetitionScheduleCard({required this.item, required this.onTap});
final CompetitionTeamListItem item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
key: ValueKey('competition-team-item-${item.id}'),
color: Colors.white,
borderRadius: BorderRadius.circular(14.r),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14.r),
child: Container(
constraints: BoxConstraints(minHeight: 142.h),
padding: EdgeInsets.all(16.r),
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),
),
],
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 19.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF20242B),
),
),
SizedBox(height: 18.h),
_InfoLine(
icon: Icons.location_on_outlined,
text: item.matchPlace,
),
SizedBox(height: 12.h),
_InfoLine(
icon: Icons.schedule_outlined,
text: item.scheduleTime,
),
],
),
),
SizedBox(width: 14.w),
Container(
width: 72.w,
height: 72.h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: item.completed
? const Color(0xFFF1F3F6)
: const Color(0xFFEAF3FF),
borderRadius: BorderRadius.circular(16.r),
),
child: Text(
item.completed ? '已完成' : item.matchPlace,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: item.completed ? 14.sp : 20.sp,
fontWeight: FontWeight.w700,
color: item.completed
? const Color(0xFF69717D)
: const Color(0xFF147FEA),
),
),
),
],
),
),
),
);
}
}
class _InfoLine extends StatelessWidget {
const _InfoLine({required this.icon, required 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)),
),
),
],
);
}
}
@@ -0,0 +1,443 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/config/app_config.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
import 'package:recording_tool/features/competition_teams/widgets/widget_manual_winner_dialog.dart';
import 'package:recording_tool/features/events/model/model_event_info.dart';
import 'package:recording_tool/shared/widgets/app_button.dart';
import 'package:recording_tool/shared/widgets/app_qr_scanner_dialog.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
import 'package:recording_tool/shared/widgets/app_webview.dart';
typedef TeamQrScanner = Future<String?> Function(BuildContext context);
class EventTeamMatchPage extends StatefulWidget {
const EventTeamMatchPage({
super.key,
required this.item,
required this.playerId,
this.qrScanner,
});
final EventRegistrationItem item;
final String playerId;
final TeamQrScanner? qrScanner;
@override
State<EventTeamMatchPage> createState() => _EventTeamMatchPageState();
}
class _EventTeamMatchPageState extends State<EventTeamMatchPage> {
final Set<String> _verifiedUserIds = <String>{};
String? _winnerTeamId;
CompetitionTeam get _homeTeam {
final leader = widget.item.teamLeader;
final teamId = leader?.userId.isNotEmpty == true
? leader!.userId
: widget.item.userId.isNotEmpty
? widget.item.userId
: 'home-team';
final teamName = leader?.name.isNotEmpty == true
? leader!.name
: widget.item.playerName.isNotEmpty
? widget.item.playerName
: '本方队伍';
return CompetitionTeam(
id: teamId,
name: teamName,
players: widget.item.teamMembers
.map(
(member) => CompetitionPlayer(id: member.userId, name: member.name),
)
.toList(growable: false),
);
}
CompetitionTeam get _opponentTeam {
final opponentId = widget.item.opponentId.isNotEmpty
? widget.item.opponentId
: 'opponent-team';
final opponentName = widget.item.opponentName.isNotEmpty
? widget.item.opponentName
: '对方队伍';
return CompetitionTeam(
id: opponentId,
name: opponentName,
players:
widget.item.opponentId.isEmpty && widget.item.opponentName.isEmpty
? const []
: [CompetitionPlayer(id: opponentId, name: opponentName)],
);
}
CompetitionMatchup get _matchup => CompetitionMatchup(
id: widget.item.scheduleId.isNotEmpty
? widget.item.scheduleId
: '${widget.item.itemId}-team-match',
teamA: _homeTeam,
teamB: _opponentTeam,
winnerTeamId: _winnerTeamId,
);
@override
void initState() {
super.initState();
final initialPlayerId = widget.playerId.trim();
if (_isKnownMember(initialPlayerId)) {
_verifiedUserIds.add(initialPlayerId);
}
}
bool _isKnownMember(String userId) {
if (userId.isEmpty) return false;
if (userId == widget.item.opponentId) return true;
return widget.item.teamMembers.any((member) => member.userId == userId);
}
String _memberNameOf(String userId) {
if (userId == widget.item.opponentId) {
return widget.item.opponentName.isEmpty
? '对方队长'
: widget.item.opponentName;
}
for (final member in widget.item.teamMembers) {
if (member.userId == userId) return member.name;
}
return '';
}
Future<void> _continueScan() async {
final scan = widget.qrScanner ?? AppQrScannerDialog.show;
final result = (await scan(context))?.trim();
if (!mounted || result == null || result.isEmpty) return;
if (!_isKnownMember(result)) {
AppToast.show('未找到对应参赛成员');
return;
}
if (_verifiedUserIds.contains(result)) {
AppToast.show('${_memberNameOf(result)}已完成核验');
return;
}
setState(() => _verifiedUserIds.add(result));
AppToast.show('${_memberNameOf(result)}核验成功');
}
Future<void> _manualProcess() async {
final winnerTeamId = await ManualWinnerDialog.show(
context,
matchup: _matchup,
);
if (!mounted || winnerTeamId == null) return;
setState(() => _winnerTeamId = winnerTeamId);
final winnerName = winnerTeamId == _homeTeam.id
? _homeTeam.name
: _opponentTeam.name;
AppToast.show('已设置$winnerName直接获胜');
}
void _startDirectly() {
AppNavigator.push(
buildTeamScorePage(item: widget.item, playerId: widget.playerId),
context: context,
);
}
@override
Widget build(BuildContext context) {
final homeTeam = _homeTeam;
final opponentTeam = _opponentTeam;
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(),
body: SafeArea(
top: false,
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 20.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_MatchMetadata(item: widget.item),
SizedBox(height: 16.h),
Align(
alignment: Alignment.centerRight,
child: TextButton(
key: const ValueKey('event-team-manual-process'),
onPressed: _manualProcess,
child: Text(
'人工处理',
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w700,
color: const Color(0xFF078AF2),
),
),
),
),
_TeamCard(
team: homeTeam,
members: widget.item.teamMembers,
backgroundColor: const Color(0xFFFFEEF0),
accentColor: const Color(0xFFE84B5B),
verifiedUserIds: _verifiedUserIds,
winner: _winnerTeamId == homeTeam.id,
),
SizedBox(height: 16.h),
_TeamCard(
team: opponentTeam,
members: [
EventTeamMember(
userId: widget.item.opponentId,
name: widget.item.opponentName,
isLeader: true,
),
],
backgroundColor: const Color(0xFFEDF5FF),
accentColor: const Color(0xFF287FDD),
verifiedUserIds: _verifiedUserIds,
winner: _winnerTeamId == opponentTeam.id,
emptyMessage: '暂无对方成员数据',
),
],
),
),
),
Padding(
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 20.h),
child: Column(
children: [
SizedBox(
width: double.infinity,
child: AppButton(
label: '继续扫码',
variant: AppButtonVariant.outline,
onPressed: _continueScan,
),
),
SizedBox(height: 12.h),
SizedBox(
width: double.infinity,
child: AppButton(label: '直接开赛', onPressed: _startDirectly),
),
],
),
),
],
),
),
);
}
}
WebviewPage buildTeamScorePage({
required EventRegistrationItem item,
required String playerId,
}) {
return WebviewPage(
url: AppConfig.current.mainRefereeScoreH5Url,
eventRegistrationItem: item,
playerId: playerId,
);
}
class _MatchMetadata extends StatelessWidget {
const _MatchMetadata({required this.item});
final EventRegistrationItem item;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: EdgeInsets.all(18.r),
decoration: BoxDecoration(
color: const Color(0xFFF7F9FC),
borderRadius: BorderRadius.circular(14.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: _MetadataText(label: '比赛项目', value: item.itemName),
),
SizedBox(width: 16.w),
_MetadataText(label: '场地', value: item.matchPlace),
],
),
SizedBox(height: 14.h),
_MetadataText(label: '组别', value: item.groupName),
],
),
);
}
}
class _MetadataText extends StatelessWidget {
const _MetadataText({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Text(
'$label${value.isEmpty ? '暂无' : value}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 17.sp,
height: 1.35,
color: const Color(0xFF303640),
fontWeight: FontWeight.w600,
),
);
}
}
class _TeamCard extends StatelessWidget {
const _TeamCard({
required this.team,
required this.members,
required this.backgroundColor,
required this.accentColor,
required this.verifiedUserIds,
required this.winner,
this.emptyMessage = '暂无成员数据',
});
final CompetitionTeam team;
final List<EventTeamMember> members;
final Color backgroundColor;
final Color accentColor;
final Set<String> verifiedUserIds;
final bool winner;
final String emptyMessage;
@override
Widget build(BuildContext context) {
final visibleMembers = members
.where((member) => member.userId.isNotEmpty || member.name.isNotEmpty)
.toList(growable: false);
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: double.infinity,
padding: EdgeInsets.all(18.r),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(14.r),
border: Border.all(
color: winner ? accentColor : accentColor.withValues(alpha: 0.24),
width: winner ? 2 : 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'队长:${team.name}',
style: TextStyle(
fontSize: 19.sp,
fontWeight: FontWeight.w700,
color: const Color(0xFF252B34),
),
),
),
if (winner)
Container(
key: ValueKey('event-team-winner-${team.id}'),
padding: EdgeInsets.symmetric(
horizontal: 10.w,
vertical: 4.h,
),
decoration: BoxDecoration(
color: accentColor,
borderRadius: BorderRadius.circular(12.r),
),
child: Text(
'胜方',
style: TextStyle(
color: Colors.white,
fontSize: 12.sp,
fontWeight: FontWeight.w700,
),
),
),
],
),
SizedBox(height: 14.h),
if (visibleMembers.isEmpty)
Text(
emptyMessage,
style: TextStyle(fontSize: 15.sp, color: const Color(0xFF747D89)),
)
else
...visibleMembers.map(
(member) => _MemberRow(
member: member,
verified: verifiedUserIds.contains(member.userId),
accentColor: accentColor,
),
),
],
),
);
}
}
class _MemberRow extends StatelessWidget {
const _MemberRow({
required this.member,
required this.verified,
required this.accentColor,
});
final EventTeamMember member;
final bool verified;
final Color accentColor;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 5.h),
child: Row(
children: [
Container(
width: 6.r,
height: 6.r,
decoration: BoxDecoration(
color: accentColor,
shape: BoxShape.circle,
),
),
SizedBox(width: 10.w),
Expanded(
child: Text(
'${member.isLeader ? '队长' : '选手'}${member.name.isEmpty ? '暂无' : member.name}',
style: TextStyle(fontSize: 16.sp, color: const Color(0xFF303640)),
),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child: verified
? Icon(
Icons.check_circle,
key: ValueKey('verified-member-${member.userId}'),
color: const Color(0xFF18A957),
size: 24.r,
)
: SizedBox(width: 24.r, height: 24.r),
),
],
),
);
}
}
@@ -0,0 +1,118 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
final competitionTeamsServerProvider = Provider<CompetitionTeamsServer>((ref) {
return const CompetitionTeamsServer();
});
class CompetitionTeamsServer {
const CompetitionTeamsServer();
static const totalCount = 10;
Future<CompetitionTeamPageResult> fetchPage({
required int page,
required int pageSize,
}) async {
final start = (page - 1) * pageSize;
if (start >= totalCount) {
return CompetitionTeamPageResult(
items: const [],
total: totalCount,
page: page,
pageSize: pageSize,
);
}
final end = (start + pageSize).clamp(0, totalCount);
final items = List.generate(
end - start,
(index) => _buildItem(start + index),
);
return CompetitionTeamPageResult(
items: items,
total: totalCount,
page: page,
pageSize: pageSize,
);
}
CompetitionTeamListItem _buildItem(int index) {
final number = index + 1;
final group = index.isEven ? '小学组' : '初中组';
final hour = 9 + index ~/ 2;
return CompetitionTeamListItem(
id: 'competition-$number',
eventName: '全国青少年无人机大赛',
itemName: index % 3 == 0 ? '空中足球赛' : '空中格斗赛',
groupName: group,
matchPlace: '场地 ${index % 4 + 1}',
matchStartTime: '${hour.toString().padLeft(2, '0')}:00',
matchEndTime: '${hour.toString().padLeft(2, '0')}:30',
completed: index == totalCount - 1,
matchups: [
_buildMatchup(itemNumber: number, matchNumber: 1),
_buildMatchup(itemNumber: number, matchNumber: 2),
],
);
}
CompetitionMatchup _buildMatchup({
required int itemNumber,
required int matchNumber,
}) {
final matchupId = 'match-$itemNumber-$matchNumber';
final teamAIndex = (itemNumber + matchNumber - 2) % _teamNames.length;
final teamBIndex = (teamAIndex + 1) % _teamNames.length;
return CompetitionMatchup(
id: matchupId,
teamA: _buildTeam(
id: '$matchupId-team-a',
name: _teamNames[teamAIndex],
playerOffset: teamAIndex * 3,
),
teamB: _buildTeam(
id: '$matchupId-team-b',
name: _teamNames[teamBIndex],
playerOffset: teamBIndex * 3,
),
);
}
CompetitionTeam _buildTeam({
required String id,
required String name,
required int playerOffset,
}) {
return CompetitionTeam(
id: id,
name: name,
players: List.generate(3, (index) {
final playerIndex = (playerOffset + index) % _playerNames.length;
return CompetitionPlayer(
id: '$id-player-$index',
name: _playerNames[playerIndex],
);
}),
);
}
static const _teamNames = ['王多鱼队', '钱多多队', '逐风少年队', '蓝翼飞行队', '星火队'];
static const _playerNames = [
'王伟',
'李庆超',
'王大亮',
'韩宁政',
'阮晴桦',
'刘美玲',
'陈子航',
'周白芷',
'林培伦',
'蔡依婷',
'夏志豪',
'赵云飞',
'孙雨泽',
'吴佳琪',
'郑凯文',
];
}
@@ -0,0 +1,42 @@
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
class CompetitionTeamsState {
const CompetitionTeamsState({
this.items = const [],
this.page = 0,
this.hasMore = true,
this.isInitialLoading = false,
this.isRefreshing = false,
this.isLoadingMore = false,
this.errorMessage,
});
final List<CompetitionTeamListItem> items;
final int page;
final bool hasMore;
final bool isInitialLoading;
final bool isRefreshing;
final bool isLoadingMore;
final String? errorMessage;
CompetitionTeamsState copyWith({
List<CompetitionTeamListItem>? items,
int? page,
bool? hasMore,
bool? isInitialLoading,
bool? isRefreshing,
bool? isLoadingMore,
String? errorMessage,
bool clearError = false,
}) {
return CompetitionTeamsState(
items: items ?? this.items,
page: page ?? this.page,
hasMore: hasMore ?? this.hasMore,
isInitialLoading: isInitialLoading ?? this.isInitialLoading,
isRefreshing: isRefreshing ?? this.isRefreshing,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
errorMessage: clearError ? null : errorMessage ?? this.errorMessage,
);
}
}
@@ -0,0 +1,98 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
import 'package:recording_tool/features/competition_teams/server/server_competition_teams.dart';
import 'package:recording_tool/features/competition_teams/state/state_competition_teams.dart';
final competitionTeamsProvider =
StateNotifierProvider<CompetitionTeamsViewModel, CompetitionTeamsState>((
ref,
) {
return CompetitionTeamsViewModel(ref);
});
class CompetitionTeamsViewModel extends StateNotifier<CompetitionTeamsState> {
CompetitionTeamsViewModel(this._ref) : super(const CompetitionTeamsState());
static const pageSize = 5;
final Ref _ref;
Future<void> loadInitial() async {
state = state.copyWith(
isInitialLoading: true,
isLoadingMore: false,
isRefreshing: false,
clearError: true,
);
await _loadPage(page: 1, replace: true);
}
Future<void> refresh() async {
if (state.isRefreshing) return;
state = state.copyWith(isRefreshing: true, clearError: true);
await _loadPage(page: 1, replace: true);
}
Future<void> loadMore() async {
if (!state.hasMore || state.isLoadingMore || state.isInitialLoading) return;
state = state.copyWith(isLoadingMore: true, clearError: true);
await _loadPage(page: state.page + 1, replace: false);
}
void selectWinner({
required String itemId,
required String matchupId,
required String winnerTeamId,
}) {
final updatedItems = state.items
.map((item) {
if (item.id != itemId) return item;
final updatedMatchups = item.matchups
.map((matchup) {
if (matchup.id != matchupId) return matchup;
final validWinner =
winnerTeamId == matchup.teamA.id ||
winnerTeamId == matchup.teamB.id;
return validWinner
? matchup.copyWith(winnerTeamId: winnerTeamId)
: matchup;
})
.toList(growable: false);
return item.copyWith(matchups: updatedMatchups);
})
.toList(growable: false);
state = state.copyWith(items: updatedItems);
}
CompetitionTeamListItem? findItem(String itemId) {
for (final item in state.items) {
if (item.id == itemId) return item;
}
return null;
}
Future<void> _loadPage({required int page, required bool replace}) async {
try {
final result = await _ref
.read(competitionTeamsServerProvider)
.fetchPage(page: page, pageSize: pageSize);
state = state.copyWith(
items: replace ? result.items : [...state.items, ...result.items],
page: result.page,
hasMore: result.hasMore,
isInitialLoading: false,
isRefreshing: false,
isLoadingMore: false,
clearError: true,
);
} catch (error) {
state = state.copyWith(
isInitialLoading: false,
isRefreshing: false,
isLoadingMore: false,
errorMessage: '参赛队伍加载失败,请重试',
);
}
}
}
@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
import 'package:recording_tool/shared/widgets/app_button.dart';
class ManualWinnerDialog extends StatefulWidget {
const ManualWinnerDialog({super.key, required this.matchup});
final CompetitionMatchup matchup;
static Future<String?> show(
BuildContext context, {
required CompetitionMatchup matchup,
}) {
return showDialog<String>(
context: context,
barrierDismissible: false,
builder: (_) => ManualWinnerDialog(matchup: matchup),
);
}
@override
State<ManualWinnerDialog> createState() => _ManualWinnerDialogState();
}
class _ManualWinnerDialogState extends State<ManualWinnerDialog> {
String? _selectedTeamId;
@override
void initState() {
super.initState();
_selectedTeamId = widget.matchup.winnerTeamId;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
insetPadding: EdgeInsets.symmetric(horizontal: 22.w),
contentPadding: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18.r)),
content: SizedBox(
width: 320.w,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/images/image_dialog_bg.png',
width: 320.w,
height: 112.h,
fit: BoxFit.cover,
),
Padding(
padding: EdgeInsets.fromLTRB(20.w, 8.h, 20.w, 20.h),
child: Column(
children: [
Text(
'请在比赛结束前处理',
key: const ValueKey('manual-winner-dialog-title'),
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w700,
color: const Color(0xFF20242B),
),
),
SizedBox(height: 16.h),
_TeamChoice(
team: widget.matchup.teamA,
color: const Color(0xFFFF6B75),
selected: _selectedTeamId == widget.matchup.teamA.id,
onTap: () => setState(
() => _selectedTeamId = widget.matchup.teamA.id,
),
),
SizedBox(height: 10.h),
_TeamChoice(
team: widget.matchup.teamB,
color: const Color(0xFF20BFA9),
selected: _selectedTeamId == widget.matchup.teamB.id,
onTap: () => setState(
() => _selectedTeamId = widget.matchup.teamB.id,
),
),
SizedBox(height: 20.h),
Row(
children: [
Expanded(
child: AppButton(
label: '取消',
variant: AppButtonVariant.secondary,
onPressed: () => Navigator.of(context).pop(),
),
),
SizedBox(width: 14.w),
Expanded(
child: AppButton(
label: '确定',
onPressed: _selectedTeamId == null
? null
: () => Navigator.of(
context,
).pop(_selectedTeamId),
),
),
],
),
],
),
),
],
),
),
),
);
}
}
class _TeamChoice extends StatelessWidget {
const _TeamChoice({
required this.team,
required this.color,
required this.selected,
required this.onTap,
});
final CompetitionTeam team;
final Color color;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
selected: selected,
button: true,
label: '选择${team.name}直接获胜',
child: Material(
color: color.withValues(alpha: selected ? 0.16 : 0.08),
borderRadius: BorderRadius.circular(12.r),
child: InkWell(
key: ValueKey('winner-choice-${team.id}'),
onTap: onTap,
borderRadius: BorderRadius.circular(12.r),
child: Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.r),
border: Border.all(
color: selected ? color : color.withValues(alpha: 0.35),
width: selected ? 2 : 1,
),
),
child: Row(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 160),
width: 22.r,
height: 22.r,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: selected ? color : Colors.grey),
color: selected ? color : Colors.transparent,
),
child: selected
? Icon(Icons.check, size: 15.r, color: Colors.white)
: null,
),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
team.name,
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF20242B),
),
),
SizedBox(height: 4.h),
Text(
team.playerNames,
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF606874),
),
),
],
),
),
SizedBox(width: 8.w),
Text(
'直接获胜',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: color,
),
),
],
),
),
),
),
);
}
}
@@ -43,6 +43,27 @@ class EventProfile {
final String avatarLabel; final String avatarLabel;
} }
class EventTeamMember {
const EventTeamMember({
required this.userId,
required this.name,
required this.isLeader,
});
final String userId;
final String name;
final bool isLeader;
factory EventTeamMember.fromJson(Map<dynamic, dynamic> json) {
final map = Map<String, dynamic>.from(json);
return EventTeamMember(
userId: _readString(map, const ['userId']),
name: _readString(map, const ['name']),
isLeader: _readBool(map, const ['isLeader']),
);
}
}
class EventRegistrationList { class EventRegistrationList {
const EventRegistrationList({ const EventRegistrationList({
required this.userId, required this.userId,
@@ -111,7 +132,7 @@ class EventRegistrationItem {
this.completed = false, this.completed = false,
this.opponentId = '', this.opponentId = '',
this.opponentName = '', this.opponentName = '',
this.teamMembers, this.teamMembers = const [],
this.userId = '', this.userId = '',
this.playerName = '', this.playerName = '',
this.playerPhone = '', this.playerPhone = '',
@@ -129,18 +150,24 @@ class EventRegistrationItem {
final bool completed; final bool completed;
final String opponentId; final String opponentId;
final String opponentName; final String opponentName;
final List<dynamic>? teamMembers; final List<EventTeamMember> teamMembers;
/// 来自报名列表父级,不在 item JSON 内 /// 来自报名列表父级,不在 item JSON 内
final String userId; final String userId;
final String playerName; final String playerName;
final String playerPhone; final String playerPhone;
String get scheduleTime => String get scheduleTime => _formatScheduleTime(matchStartTime, matchEndTime);
_formatScheduleTime(matchStartTime, matchEndTime);
String? get statusLabel => completed ? '已完成' : null; String? get statusLabel => completed ? '已完成' : null;
EventTeamMember? get teamLeader {
for (final member in teamMembers) {
if (member.isLeader) return member;
}
return teamMembers.isEmpty ? null : teamMembers.first;
}
factory EventRegistrationItem.fromJson( factory EventRegistrationItem.fromJson(
Map<dynamic, dynamic> json, { Map<dynamic, dynamic> json, {
String userId = '', String userId = '',
@@ -164,8 +191,11 @@ class EventRegistrationItem {
opponentId: _readString(map, const ['opponentId']), opponentId: _readString(map, const ['opponentId']),
opponentName: _readString(map, const ['opponentName']), opponentName: _readString(map, const ['opponentName']),
teamMembers: teamMembersRaw is List teamMembers: teamMembersRaw is List
? List<dynamic>.from(teamMembersRaw) ? teamMembersRaw
: null, .whereType<Map>()
.map(EventTeamMember.fromJson)
.toList(growable: false)
: const [],
userId: userId, userId: userId,
playerName: playerName, playerName: playerName,
); );
+18 -6
View File
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/config/app_config.dart';
import 'package:recording_tool/app/router/app_navigator.dart'; import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/features/competition_teams/pages/page_event_team_match.dart';
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/state/state_event_info.dart'; import 'package:recording_tool/features/events/state/state_event_info.dart';
import 'package:recording_tool/features/events/view_model/view_model_event_info.dart'; import 'package:recording_tool/features/events/view_model/view_model_event_info.dart';
@@ -28,13 +30,9 @@ class _EventInfoPageState extends ConsumerState<EventInfoPage> {
if (!mounted) return; if (!mounted) return;
// final profile = ref.read(eventInfoProvider).profile; // final profile = ref.read(eventInfoProvider).profile;
/// 个人赛
AppNavigator.push( AppNavigator.push(
WebviewPage( buildEventRegistrationDestination(item: item, playerId: widget.playerId),
url: 'https://drone.apptest.sportsx.cc/#/pages/h5/score-entry', context: context,
eventRegistrationItem: item,
playerId: widget.playerId,
),
); );
} }
@@ -88,6 +86,20 @@ class _EventInfoPageState extends ConsumerState<EventInfoPage> {
} }
} }
Widget buildEventRegistrationDestination({
required EventRegistrationItem item,
required String playerId,
}) {
if (item.opponentId.isNotEmpty) {
return EventTeamMatchPage(item: item, playerId: playerId);
}
return WebviewPage(
url: AppConfig.current.mainRefereeScoreH5Url,
eventRegistrationItem: item,
playerId: playerId,
);
}
class _Header extends StatelessWidget { class _Header extends StatelessWidget {
const _Header({required this.onBack}); const _Header({required this.onBack});
@@ -7,6 +7,7 @@ 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/features/auth/pages/page_auth.dart'; import 'package:recording_tool/features/auth/pages/page_auth.dart';
import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dart'; import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dart';
import 'package:recording_tool/features/competition_teams/pages/page_competition_team_list.dart';
import 'package:recording_tool/features/events/model/model_event_info.dart'; import 'package:recording_tool/features/events/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';
@@ -112,7 +113,12 @@ class _AuthPageWidgetState extends ConsumerState<ScanQrCodePage> {
height: 80.h, height: 80.h,
child: AppButton( child: AppButton(
label: '参赛队伍', label: '参赛队伍',
onPressed: () async {}, onPressed: () async {
await AppNavigator.push(
const CompetitionTeamListPage(),
context: context,
);
},
variant: AppButtonVariant.secondary, variant: AppButtonVariant.secondary,
), ),
); );