Files
2025-11-10 14:14:08 +09:00

1431 lines
62 KiB
Dart

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<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 [];
String? _clubName;
final Map<String, String> _scoreStates =
{}; // key: pid-g -> idle|saving|saved|error
final Map<String, Timer> _debouncers = {};
final Map<String, Timer> _postTimers = {};
final Map<int, bool> _teamExpanded = {};
Timer? _refreshDebounce;
int _savedTabIndex = 0;
bool _tabListenerAttached = false;
bool _personalTableView = false; // 개인별 보기 전환 상태
@override
void initState() {
super.initState();
// 최초 진입 시 공개 이벤트 데이터 로드
_load();
_loadUiPrefs();
}
Future<void> _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<void> _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<void> _saveTabIndex(int index) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_prefsKey('tabIndex'), index);
} catch (_) {}
}
Future<void> _saveTeamExpandedPrefs() async {
try {
final prefs = await SharedPreferences.getInstance();
final map = <String, bool>{};
_teamExpanded.forEach((k, v) => map[k.toString()] = v);
await prefs.setString(_prefsKey('teamExpanded'), jsonEncode(map));
} catch (_) {}
}
int? _rankForParticipant(String id, Map<String, 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<void> _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<int>.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<PublicParticipant>.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<void> _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<PublicTeamMemberSummary>.from(team.members);
final mIdx = members.indexWhere(
(mm) => mm.participantId == m.participantId,
);
if (mIdx >= 0) {
final cur = members[mIdx];
final newHc = List<int>.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<PublicTeam>.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<PublicTeam> teams,
Map<int, 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<void> _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<PublicTeamMemberSummary>.from(team.members);
final mIdx = members.indexWhere(
(mm) => mm.participantId == m.participantId,
);
if (mIdx >= 0) {
final cur = members[mIdx];
final newScores = List<int?>.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<PublicTeam>.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<int?> raw) {
final vals = raw.whereType<int>().toList();
if (vals.isEmpty) return null;
final sum = vals.fold<int>(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<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;
}
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<PublicParticipant>.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<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;
_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<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 (_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),
),
);
},
),
],
);
}
},
),
],
);
},
),
),
],
],
),
);
},
),
),
);
}
}