1431 lines
62 KiB
Dart
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),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|