import 'package:flutter/material.dart'; import 'dart:async'; import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; 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/utils/enum_mappings.dart'; import 'package:lanebow/theme/badges.dart'; import 'package:lanebow/screens/public_event/join_dialog.dart'; import 'package:lanebow/screens/public_event/widgets/personal_views.dart'; import 'package:lanebow/widgets/public_user_widgets.dart'; 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 []; String? _clubName; final Map _scoreStates = {}; // key: pid-g -> idle|saving|saved|error final Map _debouncers = {}; final Map _postTimers = {}; final Map _teamExpanded = {}; Timer? _refreshDebounce; int _savedTabIndex = 0; bool _tabListenerAttached = false; bool _personalTableView = false; // 개인별 보기 전환 상태 @override void initState() { super.initState(); // 최초 진입 시 공개 이벤트 데이터 로드 _load(); _loadUiPrefs(); } Future _savePersonalViewPref(bool table) async { try { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_prefsKey('personalTableView'), table); } catch (_) {} } String _fmt2(int n) => n.toString().padLeft(2, '0'); String _fmtLocal(DateTime dt) { final l = dt.toLocal(); return '${l.year}-${_fmt2(l.month)}-${_fmt2(l.day)} ${_fmt2(l.hour)}:${_fmt2(l.minute)}'; } String _prefsKey(String name) => 'publicEvent/${widget.publicHash}/$name'; Future _loadUiPrefs() async { try { final prefs = await SharedPreferences.getInstance(); final rawIndex = prefs.getInt(_prefsKey('tabIndex')); if (rawIndex == null || (rawIndex != 0 && rawIndex != 1)) { _savedTabIndex = 0; await prefs.setInt(_prefsKey('tabIndex'), 0); } else { _savedTabIndex = rawIndex; } final teamJson = prefs.getString(_prefsKey('teamExpanded')); if (teamJson != null && teamJson.isNotEmpty) { final decoded = jsonDecode(teamJson); if (decoded is Map) { _teamExpanded.clear(); decoded.forEach((k, v) { final key = int.tryParse(k.toString()); if (key != null) { _teamExpanded[key] = v == true; } }); } } // 개인별 보기 전환 상태 복원 (기본값: false -> 카드형) final pv = prefs.getBool(_prefsKey('personalTableView')); _personalTableView = pv == true; if (mounted) setState(() {}); } catch (_) {} } Future _saveTabIndex(int index) async { try { final prefs = await SharedPreferences.getInstance(); await prefs.setInt(_prefsKey('tabIndex'), index); } catch (_) {} } Future _saveTeamExpandedPrefs() async { try { final prefs = await SharedPreferences.getInstance(); final map = {}; _teamExpanded.forEach((k, v) => map[k.toString()] = v); await prefs.setString(_prefsKey('teamExpanded'), jsonEncode(map)); } catch (_) {} } int? _rankForParticipant(String id, Map rankMap) { final data = rankMap[id]; if (data == null) return null; final val = data['rank']; if (val is int) return val; return int.tryParse((val ?? '').toString()); } Future _saveInlineHandicap( PublicParticipant p, int gameNumber, int handicap, ) async { final key = _keyFor(p.participantId, gameNumber); _scoreStates[key] = 'saving'; setState(() {}); try { // Use the latest score from current state to avoid sending stale value int currentScore = 0; final idxNow = _participants.indexWhere((pp) => pp.participantId == p.participantId); if (idxNow >= 0) { final now = _participants[idxNow]; if (gameNumber - 1 >= 0 && gameNumber - 1 < now.rawScores.length) { currentScore = now.rawScores[gameNumber - 1] ?? 0; } } else { currentScore = p.rawScores[gameNumber - 1] ?? 0; } await _service.updateScore( widget.publicHash, participantId: p.participantId, gameNumber: gameNumber, score: currentScore, handicap: handicap, ); final idx = _participants.indexWhere( (pp) => pp.participantId == p.participantId, ); if (idx >= 0) { final cur = _participants[idx]; final newHc = List.from(cur.handicaps); if (gameNumber >= 1 && gameNumber <= newHc.length) { newHc[gameNumber - 1] = handicap; } final updated = 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: cur.rawScores, handicaps: newHc, ); final newList = List.from(_participants); newList[idx] = updated; _participants = newList; } _scoreStates[key] = 'saved'; setState(() {}); _postTimers[key]?.cancel(); _postTimers[key] = Timer(const Duration(milliseconds: 1200), () { if (!mounted) return; _scoreStates[key] = 'idle'; setState(() {}); }); _refreshDebounce?.cancel(); _refreshDebounce = Timer(const Duration(milliseconds: 400), () { if (!mounted) return; _load(); }); } catch (_) { _scoreStates[key] = 'error'; setState(() {}); } } Future _saveTeamMemberHandicap( int teamNumber, PublicTeamMemberSummary m, int gameNumber, int handicap, ) async { final key = _keyFor(m.participantId ?? '0', gameNumber); _scoreStates[key] = 'saving'; setState(() {}); try { // Use the latest score from current state to avoid sending stale value int currentScore = 0; final tIdxNow = _teams.indexWhere((tt) => tt.teamNumber == teamNumber); if (tIdxNow >= 0) { final teamNow = _teams[tIdxNow]; final mIdxNow = teamNow.members.indexWhere((mm) => mm.participantId == m.participantId); if (mIdxNow >= 0) { final mm = teamNow.members[mIdxNow]; if (gameNumber - 1 >= 0 && gameNumber - 1 < mm.rawScores.length) { currentScore = mm.rawScores[gameNumber - 1] ?? 0; } } } else { currentScore = m.rawScores[gameNumber - 1] ?? 0; } await _service.updateScore( widget.publicHash, participantId: m.participantId!, gameNumber: gameNumber, score: currentScore, handicap: handicap, teamNumber: teamNumber, ); final tIdx = _teams.indexWhere((tt) => tt.teamNumber == teamNumber); if (tIdx >= 0) { final team = _teams[tIdx]; final members = List.from(team.members); final mIdx = members.indexWhere( (mm) => mm.participantId == m.participantId, ); if (mIdx >= 0) { final cur = members[mIdx]; final newHc = List.from(cur.handicaps); if (gameNumber >= 1 && gameNumber <= newHc.length) { newHc[gameNumber - 1] = handicap; } members[mIdx] = PublicTeamMemberSummary( participantId: cur.participantId, memberId: cur.memberId, name: cur.name, gender: cur.gender, memberType: cur.memberType, status: cur.status, paymentStatus: cur.paymentStatus, comment: cur.comment, isGuest: cur.isGuest, rawScores: cur.rawScores, handicaps: newHc, average: cur.average, memberHandicap: cur.memberHandicap, ); final updatedTeam = PublicTeam( teamId: team.teamId, teamNumber: team.teamNumber, handicap: team.handicap, members: members, ); final newTeams = List.from(_teams); newTeams[tIdx] = updatedTeam; _teams = newTeams; } } _scoreStates[key] = 'saved'; setState(() {}); _postTimers[key]?.cancel(); _postTimers[key] = Timer(const Duration(milliseconds: 1200), () { if (!mounted) return; _scoreStates[key] = 'idle'; setState(() {}); }); _refreshDebounce?.cancel(); _refreshDebounce = Timer(const Duration(milliseconds: 400), () { if (!mounted) return; _load(); }); } catch (_) { _scoreStates[key] = 'error'; setState(() {}); } } Widget _buildTeamSection( PublicEvent? event, List teams, Map teamRankMap, bool canInputScore, ) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 24), Text('총 ${teams.length}팀', key: const Key('teams_count')), const SizedBox(height: 8), teams.isEmpty ? const ListTile(key: Key('empty_teams'), title: Text('팀이 없습니다')) : Column( children: [ for (final t in teams) Padding( padding: const EdgeInsets.symmetric( vertical: 6, horizontal: 8, ), child: Stack( clipBehavior: Clip.none, children: [ Container( decoration: BoxDecoration( color: Colors.grey.shade100, borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.grey.shade300), ), padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 4, ), decoration: BoxDecoration( color: Colors.blueGrey.withOpacity( 0.08, ), borderRadius: BorderRadius.circular(12), border: Border.all( color: Colors.blueGrey.withOpacity( 0.25, ), ), ), child: Text( '팀 ${t.teamNumber}', style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: Colors.black87, ), ), ), const SizedBox(width: 6), Text( '${t.members.length}명', style: TextStyle( fontSize: 12, color: Colors.black.withOpacity(0.55), ), ), const SizedBox(width: 4), // Team total & average on header row Builder( builder: (context) { int membersTotal = 0; int validGames = 0; // 유효 게임 수(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, ); if (raw > 0) validGames++; } membersTotal += mt; } final total = membersTotal + t.handicap; final avg = validGames > 0 ? (total / validGames) .toStringAsFixed(1) : '-'; return Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), decoration: BoxDecoration( color: Colors.blueGrey.withOpacity( 0.08, ), borderRadius: BorderRadius.circular( 12, ), border: Border.all( color: Colors.blueGrey .withOpacity(0.25), ), ), child: Text( '총 $total · 평균 $avg', style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: Colors.blueGrey, ), ), ); }, ), if (canInputScore) Container( width: 65, margin: const EdgeInsets.only( left: 6, ), // 원하는 값 child: Stack( clipBehavior: Clip.none, children: [ TextFormField( key: Key( 'team_hc_input_${t.teamNumber}', ), initialValue: t.handicap .toString(), keyboardType: TextInputType.number, decoration: const InputDecoration( isDense: true, labelText: '팀 핸디캡', floatingLabelBehavior: FloatingLabelBehavior.auto, labelStyle: TextStyle( fontSize: 10, ), floatingLabelStyle: TextStyle( fontSize: 10, ), counterText: '', contentPadding: EdgeInsets.symmetric( horizontal: 8, vertical: 8, ), ), maxLength: 3, style: TextStyle(fontSize: 14), onChanged: (v) async { final k = _teamKey( t.teamNumber, ); final val = int.tryParse(v); if (val == null || val < 0 || val > 300) return; _scoreStates[k] = 'saving'; setState(() {}); _debouncers[k]?.cancel(); _debouncers[k] = Timer( const Duration( milliseconds: 500, ), () async { try { await _service .updateTeamHandicap( widget.publicHash, teamNumber: t.teamNumber, handicap: val, ); final idx = _teams .indexWhere( (tt) => tt.teamNumber == t.teamNumber, ); if (idx >= 0) { _teams[idx] = PublicTeam( teamId: _teams[idx] .teamId, teamNumber: _teams[idx] .teamNumber, handicap: val, members: _teams[idx] .members, ); } _scoreStates[k] = 'saved'; setState(() {}); _postTimers[k]?.cancel(); _postTimers[k] = Timer( const Duration( milliseconds: 1200, ), () { if (!mounted) return; _scoreStates[k] = '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[k] = 'error'; setState(() {}); } catch (_) { _scoreStates[k] = 'error'; setState(() {}); } }, ); }, ), ], ), ), if (!canInputScore && t.handicap != 0) Flexible( child: Text( '팀 핸디캡 ${t.handicap}', key: Key( 'team_hc_text_${t.teamNumber}', ), overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 6), const Spacer(), IconButton( iconSize: 18, padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 24, minHeight: 28, ), icon: Icon( (_teamExpanded[t.teamNumber] ?? true) ? Icons.expand_less : Icons.expand_more, ), onPressed: () { final v = _teamExpanded[t.teamNumber] ?? true; _teamExpanded[t.teamNumber] = !v; setState(() {}); _saveTeamExpandedPrefs(); }, ), ], ), const SizedBox(height: 6), const SizedBox(height: 6), if ((_teamExpanded[t.teamNumber] ?? true)) Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for ( int i = 0; i < t.members.length; i++ ) ...[ Padding( padding: const EdgeInsets.symmetric( vertical: 6, ), child: InkWell( // Popup removed: tapping team member no longer opens a dialog. child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ PublicUserNameRow( name: t.members[i].name, gender: t.members[i].gender, memberType: t.members[i].memberType, userHandicap: t .members[i] .memberHandicap == 0 ? null : t .members[i] .memberHandicap, userAverage: t.members[i].average ?? _calcAvg( t.members[i].rawScores, )?.toDouble(), ), const SizedBox(height: 6), PublicUserChipsRow( status: t.members[i].status, paymentStatus: t .members[i] .paymentStatus, memberType: t.members[i].memberType, ), const SizedBox(height: 6), if (event != null) PublicUserGameEditors( gameCount: event.gameCount, participantId: t .members[i] .participantId ?? '0', rawScores: t.members[i].rawScores, handicaps: t.members[i].handicaps, enabled: canInputScore, keyFor: _keyFor, stateIcon: _stateIcon, onScoreChanged: (g, val) => _saveTeamMemberScoreDebounced( t.teamNumber, t.members[i], g, val, ), onHandicapChanged: (g, val) => _saveTeamMemberHandicapDebounced( t.teamNumber, t.members[i], g, val, ), eventAverage: _calcAvg( t.members[i].rawScores, )?.toDouble(), memberHandicap: t .members[i] .memberHandicap, ), ], ), ), ), if (i < t.members.length - 1) const Divider( height: 12, thickness: 0.6, ), ], ], ), ], ), ), Positioned( top: -8, left: -8, child: KeyedSubtree( key: Key('team_rank_${t.teamNumber}'), child: _rankBadge( teamRankMap[t.teamNumber]?['rank'] as int?, ), ), ), ], ), ), ], ), ], ); } Future _saveTeamMemberScore( int teamNumber, PublicTeamMemberSummary m, int gameNumber, int score, ) async { if (m.participantId == null || m.participantId!.isEmpty) return; final key = _keyFor(m.participantId!, gameNumber); _scoreStates[key] = 'saving'; setState(() {}); try { await _service.updateScore( widget.publicHash, participantId: m.participantId!, gameNumber: gameNumber, score: score, teamNumber: teamNumber, ); final tIdx = _teams.indexWhere((tt) => tt.teamNumber == teamNumber); if (tIdx >= 0) { final team = _teams[tIdx]; final members = List.from(team.members); final mIdx = members.indexWhere( (mm) => mm.participantId == m.participantId, ); if (mIdx >= 0) { final cur = members[mIdx]; final newScores = List.from(cur.rawScores); if (gameNumber >= 1 && gameNumber <= newScores.length) { newScores[gameNumber - 1] = score; } members[mIdx] = PublicTeamMemberSummary( participantId: cur.participantId, memberId: cur.memberId, name: cur.name, gender: cur.gender, memberType: cur.memberType, status: cur.status, paymentStatus: cur.paymentStatus, comment: cur.comment, isGuest: cur.isGuest, rawScores: newScores, handicaps: cur.handicaps, average: cur.average, memberHandicap: cur.memberHandicap, ); final updatedTeam = PublicTeam( teamId: team.teamId, teamNumber: team.teamNumber, handicap: team.handicap, members: members, ); final newTeams = List.from(_teams); newTeams[tIdx] = updatedTeam; _teams = newTeams; } } _scoreStates[key] = 'saved'; setState(() {}); _postTimers[key]?.cancel(); _postTimers[key] = Timer(const Duration(milliseconds: 1200), () { if (!mounted) return; _scoreStates[key] = 'idle'; setState(() {}); }); _refreshDebounce?.cancel(); _refreshDebounce = Timer(const Duration(milliseconds: 400), () { if (!mounted) return; _load(); }); } catch (_) { _scoreStates[key] = 'error'; setState(() {}); } } double? _calcAvg(List raw) { final vals = raw.whereType().toList(); if (vals.isEmpty) return null; final sum = vals.fold(0, (a, b) => a + b); return sum / vals.length; } 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 SizedBox.shrink(); } Widget _rankBadge(int? rank) { final r = rank ?? 0; Color bg; Color fg; String label; if (r == 1) { bg = Colors.amber.shade100; fg = Colors.amber.shade900; label = '1위'; } else if (r == 2) { bg = Colors.blueGrey.shade100; fg = Colors.blueGrey.shade900; label = '2위'; } else if (r == 3) { bg = Colors.brown.shade100; fg = Colors.brown.shade900; label = '3위'; } else { bg = Colors.blue.shade50; fg = Colors.blueGrey.shade700; label = (r > 0) ? ('R $r') : 'R -'; } return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(10), ), child: Text( label, style: TextStyle(fontSize: 12, color: fg, fontWeight: FontWeight.w600), ), ); } @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'; String _teamKey(int teamNumber) => 'team-$teamNumber'; void _saveInlineScoreDebounced( PublicParticipant p, int gameNumber, int score, ) { final key = _keyFor(p.participantId, gameNumber); _scoreStates[key] = 'saving'; setState(() {}); _debouncers[key]?.cancel(); _debouncers[key] = Timer(const Duration(milliseconds: 500), () { _saveInlineScore(p, gameNumber, score); }); } void _saveInlineHandicapDebounced( PublicParticipant p, int gameNumber, int handicap, ) { final key = _keyFor(p.participantId, gameNumber); _scoreStates[key] = 'saving'; setState(() {}); _debouncers[key]?.cancel(); _debouncers[key] = Timer(const Duration(milliseconds: 500), () { _saveInlineHandicap(p, gameNumber, handicap); }); } void _saveTeamMemberScoreDebounced( int teamNumber, PublicTeamMemberSummary m, int gameNumber, int score, ) { final pid = m.participantId ?? '0'; final key = _keyFor(pid, gameNumber); _scoreStates[key] = 'saving'; setState(() {}); _debouncers[key]?.cancel(); _debouncers[key] = Timer(const Duration(milliseconds: 500), () { _saveTeamMemberScore(teamNumber, m, gameNumber, score); }); } void _saveTeamMemberHandicapDebounced( int teamNumber, PublicTeamMemberSummary m, int gameNumber, int handicap, ) { final pid = m.participantId ?? '0'; final key = _keyFor(pid, gameNumber); _scoreStates[key] = 'saving'; setState(() {}); _debouncers[key]?.cancel(); _debouncers[key] = Timer(const Duration(milliseconds: 500), () { _saveTeamMemberHandicap(teamNumber, m, gameNumber, handicap); }); } 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; } final updated = 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, ); final newList = List.from(_participants); newList[idx] = updated; _participants = newList; } _scoreStates[key] = 'saved'; setState(() {}); _postTimers[key]?.cancel(); _postTimers[key] = Timer(const Duration(milliseconds: 1200), () { if (!mounted) return; _scoreStates[key] = 'idle'; setState(() {}); }); // Debounced full sync load _refreshDebounce?.cancel(); _refreshDebounce = Timer(const Duration(milliseconds: 400), () { if (!mounted) return; _load(); }); } 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 _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; _clubName = (d['clubName']?.toString().isNotEmpty == true) ? d['clubName'].toString() : null; }); } 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: Text( (_clubName != null && _clubName!.trim().isNotEmpty) ? _clubName!.trim() : '공개 이벤트', ), ), body: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), child: Builder( builder: (context) { if (_loading) { return const Center(child: CircularProgressIndicator()); } if (_error != null) { return 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('입장 화면으로 돌아가기'), ), ], ), ); } return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ 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 (_clubName != null && _clubName!.trim().isNotEmpty) if (!(_clubName!.trim().toLowerCase() == (event.location ?? '').trim().toLowerCase() || _clubName!.trim().toLowerCase() == event.title.trim().toLowerCase())) Chip( key: const Key('club_chip'), label: Text(_clubName!.trim()), ), // 이벤트 상태 배지 (공용 스타일) BadgeStyles.eventStatus( getEventStatusLabel(event.status ?? ''), ), if (event.eventType != null && event.eventType!.isNotEmpty) Chip( key: const Key('type_chip'), label: Text(getEventTypeLabel(event.eventType)), ), if (event.startDate != null || event.endDate != null) Chip( key: const Key('period_chip'), label: Text( ((event.startDate != null) ? _fmtLocal(event.startDate!) : '') + (event.endDate != null ? ' ~ ${_fmtLocal(event.endDate!)}' : ''), ), ), Chip( key: const Key('game_chip'), label: Text('게임 ${event.gameCount}'), ), Chip( key: const Key('participants_chip'), label: Text( '참가자 ${_participants.length}${event.maxParticipants != null ? '/${event.maxParticipants!}' : ''}', ), ), 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!}'), ), if (event.location != null && event.location!.trim().isNotEmpty) if (!(event.location!.trim().toLowerCase() == (_clubName ?? '').trim().toLowerCase() || event.location!.trim().toLowerCase() == event.title.trim().toLowerCase())) Chip( key: const Key('location_chip'), label: Text(event.location!.trim()), ), ], ), const SizedBox(height: 8), ], if (teams.isEmpty) ...[ const SizedBox(height: 24), ] else ...[ DefaultTabController( length: 2, initialIndex: _savedTabIndex < 0 ? 0 : (_savedTabIndex > 1 ? 1 : _savedTabIndex), child: Builder( builder: (context) { final controller = DefaultTabController.of(context); if (!_tabListenerAttached) { _tabListenerAttached = true; controller.addListener(() { if (!controller.indexIsChanging) { _saveTabIndex(controller.index); } }); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const TabBar( tabs: [ Tab(text: '팀별'), Tab(text: '개인별'), ], ), const SizedBox(height: 12), AnimatedBuilder( animation: controller, builder: (ctx, _) { final idx = controller.index; if (idx == 0) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildTeamSection( event, teams, teamRankMap, canInputScore, ), if (unassigned.isNotEmpty) ...[ const SizedBox(height: 24), const Text( '미배정', style: TextStyle( fontWeight: FontWeight.bold, ), key: Key('section_unassigned'), ), Text( '총 ${unassigned.length}명', key: const Key('unassigned_count'), ), const SizedBox(height: 8), Card( child: Column( children: [ for ( int i = 0; i < unassigned.length; i++ ) ...[ ListTile( key: Key( 'unassigned_${unassigned[i].participantId}', ), title: Text( unassigned[i].name, ), ), if (i < unassigned.length - 1) const Divider( height: 12, thickness: 0.6, ), ], ], ), ), ], ], ); } else { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 개인별 보기 전환 아이콘 Row( mainAxisAlignment: MainAxisAlignment.end, children: [ IconButton( tooltip: '카드형', onPressed: () { if (_personalTableView == true) { setState( () => _personalTableView = false, ); _savePersonalViewPref(false); } }, icon: Icon( Icons.view_module, color: (_personalTableView == true) ? Colors.blueGrey : Colors.indigo, ), ), IconButton( tooltip: '표형', onPressed: () { if (_personalTableView != true) { setState( () => _personalTableView = true, ); _savePersonalViewPref(true); } }, icon: Icon( Icons.table_chart, color: (_personalTableView == true) ? Colors.indigo : Colors.blueGrey, ), ), ], ), const SizedBox(height: 8), LayoutBuilder( builder: (ctx, _) { final size = MediaQuery.of(ctx).size; final padding = MediaQuery.of(ctx).padding; final available = size.height - padding.top - padding.bottom - 180; final boxHeight = available < 360 ? 360.0 : available; return SizedBox( height: boxHeight, child: _personalTableView == true ? PersonalTableView( event: event!, participants: participants, canInputScore: canInputScore, teams: teams, saveInlineScore: (p, g, v) => _saveInlineScoreDebounced(p, g, v), saveInlineHandicap: (p, g, v) => _saveInlineHandicapDebounced(p, g, v), partRankMap: partRankMap, rankForParticipant: (pid, map) => _rankForParticipant(pid, map), ) : PersonalCardView( event: event, participants: participants, partRankMap: partRankMap, canInputScore: canInputScore, teams: teams, keyFor: _keyFor, stateIcon: _stateIcon, saveInlineScore: (p, g, v) => _saveInlineScoreDebounced(p, g, v), saveInlineHandicap: (p, g, v) => _saveInlineHandicapDebounced(p, g, v), calcAvg: _calcAvg, rankForParticipant: (pid, map) => _rankForParticipant(pid, map), ), ); }, ), ], ); } }, ), ], ); }, ), ), ], ], ), ); }, ), ), ); } }