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 createState() => _PublicEventScreenState(); } class _PublicEventScreenState extends State { late final PublicEventService _service = widget.service ?? PublicEventService(); bool _loading = true; String? _error; PublicEvent? _eventModel; List _participants = const []; List _teams = const []; final Map _scoreStates = {}; // key: pid-g -> idle|saving|saved|error final Map _debouncers = {}; final Map _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 _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.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 _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?>( 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( 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.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 _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() ?? {}; final eventModel = PublicEvent.fromJson(eventJson); final gc = eventModel.gameCount; final parts = (d['participants'] is List) ? (d['participants'] as List) .whereType() .map((e) => PublicParticipant.fromJson(e.cast(), gameCount: gc)) .toList() : []; final teams = (d['teams'] is List) ? (d['teams'] as List) .whereType() .map((e) => PublicTeam.fromJson(e.cast(), gameCount: gc)) .toList() : []; 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( 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), ); }), ], ), ), ], ], ), ), ); } }