621 lines
29 KiB
Dart
621 lines
29 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'dart:async';
|
|
import 'package:lanebow/services/public_event_service.dart';
|
|
import 'package:dio/dio.dart';
|
|
import 'package:lanebow/screens/public_event/public_event_entry_screen.dart';
|
|
import 'package:lanebow/models/public_event.dart';
|
|
import 'package:lanebow/models/public_participant.dart';
|
|
import 'package:lanebow/models/public_team.dart';
|
|
import 'package:lanebow/utils/ranking_utils_public.dart';
|
|
import 'package:lanebow/screens/public_event/join_dialog.dart';
|
|
|
|
enum _ResultSort { total, avg }
|
|
|
|
class PublicEventScreen extends StatefulWidget {
|
|
final String publicHash;
|
|
final PublicEventService? service;
|
|
const PublicEventScreen({super.key, required this.publicHash, this.service});
|
|
|
|
@override
|
|
State<PublicEventScreen> createState() => _PublicEventScreenState();
|
|
}
|
|
|
|
class _PublicEventScreenState extends State<PublicEventScreen> {
|
|
late final PublicEventService _service = widget.service ?? PublicEventService();
|
|
bool _loading = true;
|
|
String? _error;
|
|
PublicEvent? _eventModel;
|
|
List<PublicParticipant> _participants = const [];
|
|
List<PublicTeam> _teams = const [];
|
|
final Map<String, String> _scoreStates = {}; // key: pid-g -> idle|saving|saved|error
|
|
final Map<String, Timer> _debouncers = {};
|
|
final Map<String, Timer> _postTimers = {};
|
|
_ResultSort _resultSort = _ResultSort.total;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_load();
|
|
}
|
|
|
|
Color _scoreColor(PublicParticipant p, int g) {
|
|
final idx = g - 1;
|
|
int base = 0;
|
|
if (idx >= 0 && idx < p.rawScores.length && p.rawScores[idx] != null) {
|
|
base = p.rawScores[idx]!;
|
|
}
|
|
int hc = 0;
|
|
if (idx >= 0 && idx < p.handicaps.length) {
|
|
hc = p.handicaps[idx];
|
|
}
|
|
final total = base + hc;
|
|
if (base == 0) return Colors.grey;
|
|
return total >= 200 ? Colors.red : Colors.black87;
|
|
}
|
|
|
|
Widget _stateIcon(String key) {
|
|
final st = _scoreStates[key];
|
|
if (st == 'saving') return const Icon(Icons.autorenew, size: 14, color: Colors.grey);
|
|
if (st == 'saved') return const Icon(Icons.check_circle, size: 14, color: Colors.green);
|
|
if (st == 'error') return const Icon(Icons.error_outline, size: 14, color: Colors.orange);
|
|
return const Icon(Icons.circle, size: 10, color: Colors.grey);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
for (final t in _debouncers.values) {
|
|
t.cancel();
|
|
}
|
|
_debouncers.clear();
|
|
for (final t in _postTimers.values) {
|
|
t.cancel();
|
|
}
|
|
_postTimers.clear();
|
|
super.dispose();
|
|
}
|
|
|
|
String _keyFor(String pid, int g) => pid + '-' + g.toString();
|
|
|
|
void _onScoreChanged(PublicParticipant p, int gameNumber, String value) {
|
|
final key = _keyFor(p.participantId, gameNumber);
|
|
_scoreStates[key] = 'idle';
|
|
// 3자리 입력 시 즉시 저장, 아니면 디바운스
|
|
_debouncers[key]?.cancel();
|
|
if (value.length == 3) {
|
|
final v = int.tryParse(value);
|
|
if (v != null && v >= 0 && v <= 300) {
|
|
_saveInlineScore(p, gameNumber, v);
|
|
return;
|
|
}
|
|
}
|
|
_debouncers[key] = Timer(const Duration(milliseconds: 200), () {
|
|
final v = int.tryParse(value);
|
|
if (v != null && v >= 0 && v <= 300) {
|
|
_saveInlineScore(p, gameNumber, v);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _saveInlineScore(PublicParticipant p, int gameNumber, int score) async {
|
|
final key = _keyFor(p.participantId, gameNumber);
|
|
_scoreStates[key] = 'saving';
|
|
setState(() {});
|
|
try {
|
|
await _service.updateScore(widget.publicHash, participantId: p.participantId, gameNumber: gameNumber, score: score);
|
|
// 로컬 모델 즉시 갱신
|
|
final idx = _participants.indexWhere((pp) => pp.participantId == p.participantId);
|
|
if (idx >= 0) {
|
|
final cur = _participants[idx];
|
|
final newScores = List<int?>.from(cur.rawScores);
|
|
if (gameNumber >= 1 && gameNumber <= newScores.length) newScores[gameNumber - 1] = score;
|
|
_participants[idx] = PublicParticipant(
|
|
participantId: cur.participantId,
|
|
memberId: cur.memberId,
|
|
name: cur.name,
|
|
gender: cur.gender,
|
|
memberType: cur.memberType,
|
|
average: cur.average,
|
|
handicap: cur.handicap,
|
|
status: cur.status,
|
|
paymentStatus: cur.paymentStatus,
|
|
comment: cur.comment,
|
|
isGuest: cur.isGuest,
|
|
rawScores: newScores,
|
|
handicaps: cur.handicaps,
|
|
);
|
|
}
|
|
_scoreStates[key] = 'saved';
|
|
setState(() {});
|
|
_postTimers[key]?.cancel();
|
|
_postTimers[key] = Timer(const Duration(milliseconds: 1200), () {
|
|
if (!mounted) return;
|
|
_scoreStates[key] = 'idle';
|
|
setState(() {});
|
|
});
|
|
} on DioException catch (e) {
|
|
final status = e.response?.statusCode;
|
|
if (status == 401 || status == 403) {
|
|
if (!mounted) return;
|
|
Navigator.of(context).pushReplacement(
|
|
MaterialPageRoute(builder: (_) => PublicEventEntryScreen(publicHash: widget.publicHash)),
|
|
);
|
|
return;
|
|
}
|
|
_scoreStates[key] = 'error';
|
|
setState(() {});
|
|
} catch (_) {
|
|
_scoreStates[key] = 'error';
|
|
setState(() {});
|
|
}
|
|
}
|
|
|
|
Future<void> _openScoreDialog(String participantId, {int? initial}) async {
|
|
final controller = TextEditingController(text: initial?.toString() ?? '');
|
|
int selectedGame = 1;
|
|
String? errorText;
|
|
final gameCount = _eventModel?.gameCount ?? 3;
|
|
final result = await showDialog<Map<String, int>?>(
|
|
context: context,
|
|
builder: (ctx) {
|
|
return StatefulBuilder(builder: (ctx2, setState) {
|
|
void validate() {
|
|
final v = int.tryParse(controller.text);
|
|
if (v == null || v < 0 || v > 300) {
|
|
setState(() => errorText = '0~300 사이의 정수를 입력하세요');
|
|
} else {
|
|
setState(() => errorText = null);
|
|
}
|
|
}
|
|
return AlertDialog(
|
|
title: const Text('점수 입력'),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
DropdownButton<int>(
|
|
key: const Key('game_select'),
|
|
value: selectedGame,
|
|
items: [for (int i = 1; i <= gameCount; i++) DropdownMenuItem(value: i, child: Text('게임 ' + i.toString()))],
|
|
onChanged: (v) => setState(() => selectedGame = v ?? 1),
|
|
),
|
|
TextField(
|
|
key: const Key('score_input'),
|
|
controller: controller,
|
|
keyboardType: TextInputType.number,
|
|
decoration: InputDecoration(labelText: '점수(0-300)', errorText: errorText),
|
|
onChanged: (_) => validate(),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(null),
|
|
child: const Text('취소'),
|
|
),
|
|
ElevatedButton(
|
|
key: const Key('score_save_button'),
|
|
onPressed: () {
|
|
final v = int.tryParse(controller.text);
|
|
if (v == null || v < 0 || v > 300) {
|
|
validate();
|
|
return;
|
|
}
|
|
Navigator.of(ctx).pop({'game': selectedGame, 'score': v});
|
|
},
|
|
child: const Text('저장'),
|
|
),
|
|
],
|
|
);
|
|
});
|
|
},
|
|
);
|
|
if (result == null) return;
|
|
try {
|
|
final gameNumber = result['game']!;
|
|
final score = result['score']!;
|
|
await _service.updateScore(widget.publicHash, participantId: participantId, gameNumber: gameNumber, score: score);
|
|
if (!mounted) return;
|
|
// 로컬 모델 즉시 갱신
|
|
final idx = _participants.indexWhere((pp) => pp.participantId == participantId);
|
|
if (idx >= 0) {
|
|
final p = _participants[idx];
|
|
final newScores = List<int?>.from(p.rawScores);
|
|
if (gameNumber >= 1 && gameNumber <= newScores.length) {
|
|
newScores[gameNumber - 1] = score;
|
|
_participants[idx] = PublicParticipant(
|
|
participantId: p.participantId,
|
|
memberId: p.memberId,
|
|
name: p.name,
|
|
gender: p.gender,
|
|
memberType: p.memberType,
|
|
average: p.average,
|
|
handicap: p.handicap,
|
|
status: p.status,
|
|
paymentStatus: p.paymentStatus,
|
|
comment: p.comment,
|
|
isGuest: p.isGuest,
|
|
rawScores: newScores,
|
|
handicaps: p.handicaps,
|
|
);
|
|
setState(() {});
|
|
}
|
|
}
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('점수가 저장되었습니다')));
|
|
} on DioException catch (e) {
|
|
if (!mounted) return;
|
|
final status = e.response?.statusCode;
|
|
if (status == 401 || status == 403) {
|
|
Navigator.of(context).pushReplacement(
|
|
MaterialPageRoute(builder: (_) => PublicEventEntryScreen(publicHash: widget.publicHash)),
|
|
);
|
|
return;
|
|
}
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('저장 실패: ' + e.toString())));
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('저장 실패: ' + e.toString())));
|
|
}
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
setState(() {
|
|
_loading = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
final d = await _service.fetchEvent(widget.publicHash);
|
|
if (!mounted) return;
|
|
final eventJson = (d['event'] as Map?)?.cast<String, dynamic>() ?? <String, dynamic>{};
|
|
final eventModel = PublicEvent.fromJson(eventJson);
|
|
final gc = eventModel.gameCount;
|
|
final parts = (d['participants'] is List)
|
|
? (d['participants'] as List)
|
|
.whereType<Map>()
|
|
.map((e) => PublicParticipant.fromJson(e.cast<String, dynamic>(), gameCount: gc))
|
|
.toList()
|
|
: <PublicParticipant>[];
|
|
final teams = (d['teams'] is List)
|
|
? (d['teams'] as List)
|
|
.whereType<Map>()
|
|
.map((e) => PublicTeam.fromJson(e.cast<String, dynamic>(), gameCount: gc))
|
|
.toList()
|
|
: <PublicTeam>[];
|
|
|
|
setState(() {
|
|
_eventModel = eventModel;
|
|
_participants = parts;
|
|
_teams = teams;
|
|
});
|
|
} on DioException catch (e) {
|
|
if (!mounted) return;
|
|
final status = e.response?.statusCode;
|
|
if (status == 401 || status == 403) {
|
|
// 인증 만료: 엔트리 화면으로 복귀
|
|
Navigator.of(context).pushReplacement(
|
|
MaterialPageRoute(builder: (_) => PublicEventEntryScreen(publicHash: widget.publicHash)),
|
|
);
|
|
return;
|
|
} else if (status == 404) {
|
|
setState(() {
|
|
_error = '이벤트를 찾을 수 없습니다';
|
|
});
|
|
return;
|
|
}
|
|
setState(() {
|
|
_error = e.toString();
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_error = e.toString();
|
|
});
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_loading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final event = _eventModel;
|
|
final participants = _participants;
|
|
final teams = _teams;
|
|
final partRankMap = PublicRankingUtils.buildParticipantRankMap(participants);
|
|
final teamRankMap = PublicRankingUtils.buildTeamRankMap(teams);
|
|
final unassigned = PublicRankingUtils.unassignedParticipants(participants, teams);
|
|
final now = DateTime.now();
|
|
final registrationClosed = (event?.registrationDeadline != null) && now.isAfter(event!.registrationDeadline!);
|
|
final canJoin = (event?.isReady == true || event?.isActive == true) && !registrationClosed;
|
|
final canInputScore = (event?.isActive == true);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('공개 이벤트')),
|
|
body: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: _loading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: _error != null
|
|
? Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(_error!, style: const TextStyle(color: Colors.red)),
|
|
const SizedBox(height: 12),
|
|
ElevatedButton(
|
|
key: const Key('go_entry_button'),
|
|
onPressed: () {
|
|
Navigator.of(context).pushReplacement(
|
|
MaterialPageRoute(builder: (_) => PublicEventEntryScreen(publicHash: widget.publicHash)),
|
|
);
|
|
},
|
|
child: const Text('입장 화면으로 돌아가기'),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: ListView(
|
|
children: [
|
|
Text('hash: ' + widget.publicHash, key: const Key('public_hash_label')),
|
|
const SizedBox(height: 8),
|
|
if (event != null) ...[
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Expanded(child: Text(event.title, key: const Key('event_title'))),
|
|
if (canJoin)
|
|
ElevatedButton(
|
|
key: const Key('join_button'),
|
|
onPressed: () async {
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (_) => JoinDialog(publicHash: widget.publicHash, service: _service),
|
|
);
|
|
if (ok == true) {
|
|
await _load();
|
|
}
|
|
},
|
|
child: const Text('참가 신청/수정'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
if (event.status != null)
|
|
Chip(key: const Key('status_chip'), label: Text(event.status!)),
|
|
if (event.eventType != null && event.eventType!.isNotEmpty)
|
|
Chip(key: const Key('type_chip'), label: Text(event.eventType!)),
|
|
if (event.startDate != null || event.endDate != null)
|
|
Chip(
|
|
key: const Key('period_chip'),
|
|
label: Text(
|
|
((event.startDate != null) ? event.startDate!.toLocal().toString().split('.').first : '') +
|
|
(event.endDate != null ? ' ~ ' + event.endDate!.toLocal().toString().split('.').first : ''),
|
|
),
|
|
),
|
|
Chip(key: const Key('game_chip'), label: Text('게임 ' + event.gameCount.toString())),
|
|
if (event.registrationDeadline != null)
|
|
Chip(key: const Key('deadline_chip'), label: Text('신청마감 ' + event.registrationDeadline!.toLocal().toString().split('.').first)),
|
|
if (event.participantFee != null)
|
|
Chip(key: const Key('fee_chip'), label: Text('참가비 ₩' + event.participantFee!.toString())),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
const SizedBox(height: 8),
|
|
const Text('참가자', style: TextStyle(fontWeight: FontWeight.bold), key: Key('section_participants')),
|
|
Text('총 ' + participants.length.toString() + '명', key: const Key('participants_count')),
|
|
const SizedBox(height: 8),
|
|
Card(
|
|
child: participants.isEmpty
|
|
? const ListTile(
|
|
key: Key('empty_participants'),
|
|
title: Text('참가자가 없습니다'),
|
|
)
|
|
: Column(
|
|
children: [
|
|
for (final p in participants)
|
|
ListTile(
|
|
key: Key('participant_tile_' + p.participantId),
|
|
title: Text(p.name),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
(p.status ?? '-') + ' · avg:' + (p.average?.toString() ?? '-') + ' · hc:' + p.handicap.toString(),
|
|
key: Key('participant_info_' + p.participantId),
|
|
),
|
|
if (canInputScore && event != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Row(
|
|
children: [
|
|
for (int g = 1; g <= event.gameCount; g++)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 6),
|
|
child: SizedBox(
|
|
width: 54,
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Container(
|
|
key: Key('color_' + p.participantId + '_' + g.toString()),
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(
|
|
color: _scoreColor(p, g),
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
_stateIcon(_keyFor(p.participantId, g)),
|
|
],
|
|
),
|
|
TextFormField(
|
|
key: Key('cell_' + p.participantId + '_' + g.toString()),
|
|
initialValue: (g - 1 < p.rawScores.length && p.rawScores[g - 1] != null)
|
|
? p.rawScores[g - 1].toString()
|
|
: '',
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(
|
|
isDense: true,
|
|
hintText: '---',
|
|
counterText: '',
|
|
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 6),
|
|
),
|
|
maxLength: 3,
|
|
onChanged: (v) => _onScoreChanged(p, g, v),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
trailing: Text(
|
|
'R ' + (partRankMap[p.participantId]?['rank']?.toString() ?? '-') +
|
|
' · 총 ' + PublicRankingUtils.participantTotalWithHc(p).toString(),
|
|
key: Key('participant_total_' + p.participantId),
|
|
),
|
|
onTap: () {
|
|
if (!canInputScore) {
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('완료 상태에서는 점수 입력이 불가합니다')));
|
|
return;
|
|
}
|
|
_openScoreDialog(p.participantId, initial: p.rawScores.isNotEmpty ? p.rawScores[0] : null);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
const Text('팀', style: TextStyle(fontWeight: FontWeight.bold), key: Key('section_teams')),
|
|
Text('총 ' + teams.length.toString() + '팀', key: const Key('teams_count')),
|
|
const SizedBox(height: 8),
|
|
Card(
|
|
child: teams.isEmpty
|
|
? const ListTile(
|
|
key: Key('empty_teams'),
|
|
title: Text('팀이 없습니다'),
|
|
)
|
|
: Column(
|
|
children: [
|
|
for (final t in teams)
|
|
ListTile(
|
|
title: Row(
|
|
children: [
|
|
Text('팀 ' + t.teamNumber.toString()),
|
|
const SizedBox(width: 6),
|
|
Container(
|
|
key: Key('team_rank_' + t.teamNumber.toString()),
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue.shade50,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Text('R ' + (teamRankMap[t.teamNumber]?['rank']?.toString() ?? '-'), style: const TextStyle(fontSize: 12)),
|
|
),
|
|
],
|
|
),
|
|
subtitle: Builder(builder: (context) {
|
|
int membersTotal = 0;
|
|
for (final m in t.members) {
|
|
int mt = 0;
|
|
for (int i = 0; i < m.rawScores.length; i++) {
|
|
final raw = m.rawScores[i];
|
|
final hc = (i < m.handicaps.length) ? m.handicaps[i] : 0;
|
|
if (raw == null) continue;
|
|
mt += PublicRankingUtils.capScore(raw + hc);
|
|
}
|
|
membersTotal += mt;
|
|
}
|
|
final total = membersTotal + t.handicap;
|
|
final avg = t.members.isNotEmpty ? (total / t.members.length).toStringAsFixed(1) : '-';
|
|
return Text(
|
|
'총 ' + total.toString() + ' · 평균 ' + avg + ' (팀 HC ' + t.handicap.toString() + ')',
|
|
key: Key('team_summary_' + t.teamNumber.toString()),
|
|
);
|
|
}),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
const Text('미배정', style: TextStyle(fontWeight: FontWeight.bold), key: Key('section_unassigned')),
|
|
Text('총 ' + unassigned.length.toString() + '명', key: const Key('unassigned_count')),
|
|
const SizedBox(height: 8),
|
|
Card(
|
|
child: unassigned.isEmpty
|
|
? const ListTile(
|
|
key: Key('empty_unassigned'),
|
|
title: Text('미배정 참가자가 없습니다'),
|
|
)
|
|
: Column(
|
|
children: [
|
|
for (final p in unassigned)
|
|
ListTile(
|
|
key: Key('unassigned_' + p.participantId),
|
|
title: Text(p.name),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (event?.isFinished == true) ...[
|
|
const SizedBox(height: 24),
|
|
const Text('최종 결과', style: TextStyle(fontWeight: FontWeight.bold), key: Key('section_final_results')),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
ChoiceChip(
|
|
key: const Key('sort_total_chip'),
|
|
label: const Row(children: [Icon(Icons.summarize, size: 16), SizedBox(width: 4), Text('총점')]),
|
|
selected: _resultSort == _ResultSort.total,
|
|
onSelected: (_) => setState(() => _resultSort = _ResultSort.total),
|
|
),
|
|
const SizedBox(width: 8),
|
|
ChoiceChip(
|
|
key: const Key('sort_avg_chip'),
|
|
label: const Row(children: [Icon(Icons.show_chart, size: 16), SizedBox(width: 4), Text('평균')]),
|
|
selected: _resultSort == _ResultSort.avg,
|
|
onSelected: (_) => setState(() => _resultSort = _ResultSort.avg),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Card(
|
|
child: Column(
|
|
children: [
|
|
for (final item in (_resultSort == _ResultSort.total
|
|
? PublicRankingUtils.individualRanking(participants)
|
|
: PublicRankingUtils.individualRankingByAvg(participants)))
|
|
Builder(builder: (_) {
|
|
final p = item['p'] as PublicParticipant;
|
|
final rank = item['rank'] as int;
|
|
final total = item['total'] as int;
|
|
final avg = PublicRankingUtils.participantAverageWithHc(p).toStringAsFixed(1);
|
|
return ListTile(
|
|
key: Key('result_row_' + p.participantId),
|
|
leading: Text(rank.toString(), key: Key('result_rank_' + p.participantId)),
|
|
title: Text(p.name),
|
|
trailing: Text('총 ' + total.toString() + ' · 평균 ' + avg),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|