공개페이지

This commit is contained in:
2025-10-29 00:25:03 +09:00
parent 19f99989b8
commit 63d0d383b0
28 changed files with 2691 additions and 0 deletions
+12
View File
@@ -17,6 +17,7 @@ import 'screens/club/events_screen.dart';
import 'screens/club/club_settings_screen.dart';
// import 'screens/score/club_statistics_screen.dart';
import 'widgets/dialog_actions.dart';
import 'screens/public_event/public_event_entry_screen.dart';
// 전역 네비게이터 키 (인증 오류 처리용)
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
@@ -170,6 +171,17 @@ class MyApp extends StatelessWidget {
'/profile': (context) => const ProfileScreen(),
ClubSettingsScreen.routeName: (context) => const ClubSettingsScreen(),
},
onGenerateRoute: (settings) {
final name = settings.name ?? '';
if (name.startsWith('/p/')) {
final hash = name.substring(3);
return MaterialPageRoute(
builder: (_) => PublicEventEntryScreen(publicHash: hash),
settings: settings,
);
}
return null;
},
);
},
),
+69
View File
@@ -0,0 +1,69 @@
class PublicEvent {
final String id;
final String title;
final String? description;
final String? eventType;
final DateTime? startDate;
final DateTime? endDate;
final String? location;
final int gameCount;
final DateTime? registrationDeadline;
final int? maxParticipants;
final num? participantFee;
final String? status;
PublicEvent({
required this.id,
required this.title,
this.description,
this.eventType,
this.startDate,
this.endDate,
this.location,
required this.gameCount,
this.registrationDeadline,
this.maxParticipants,
this.participantFee,
this.status,
});
factory PublicEvent.fromJson(Map<String, dynamic> json) {
String parseId(dynamic v) => v?.toString() ?? '';
DateTime? parseDate(dynamic v) {
if (v == null) return null;
final s = v.toString();
return DateTime.tryParse(s);
}
int parseInt(dynamic v, {int def = 0}) {
if (v == null) return def;
if (v is int) return v;
return int.tryParse(v.toString()) ?? def;
}
num? parseNum(dynamic v) {
if (v == null) return null;
if (v is num) return v;
return num.tryParse(v.toString());
}
return PublicEvent(
id: parseId(json['id']),
title: (json['title'] ?? '').toString(),
description: json['description']?.toString(),
eventType: json['eventType']?.toString(),
startDate: parseDate(json['startDate']),
endDate: parseDate(json['endDate']),
location: json['location']?.toString(),
gameCount: parseInt(json['gameCount'], def: 3),
registrationDeadline: parseDate(json['registrationDeadline']),
maxParticipants: json['maxParticipants'] == null ? null : parseInt(json['maxParticipants']),
participantFee: parseNum(json['participantFee']),
status: json['status']?.toString(),
);
}
bool get isReady => (status == 'ready');
bool get isActive => (status == 'active');
bool get isFinished => (status == 'completed');
}
+90
View File
@@ -0,0 +1,90 @@
class PublicParticipant {
final String participantId;
final String? memberId;
final String name;
final String? gender;
final String? memberType;
final num? average;
final int handicap;
final String? status;
final String? paymentStatus;
final String? comment;
final bool isGuest;
// 점수는 서버에서 number 또는 {score, handicap} 객체로 올 수 있음
// rawScores: 입력 점수(핸디캡 제외)
// handicaps: 각 게임 핸디캡(없으면 0)
final List<int?> rawScores;
final List<int> handicaps;
PublicParticipant({
required this.participantId,
required this.memberId,
required this.name,
required this.gender,
required this.memberType,
required this.average,
required this.handicap,
required this.status,
required this.paymentStatus,
required this.comment,
required this.isGuest,
required this.rawScores,
required this.handicaps,
});
factory PublicParticipant.fromJson(Map<String, dynamic> json, {int gameCount = 3}) {
int parseInt(dynamic v, {int def = 0}) {
if (v == null) return def;
if (v is int) return v;
return int.tryParse(v.toString()) ?? def;
}
num? parseNum(dynamic v) {
if (v == null) return null;
if (v is num) return v;
return num.tryParse(v.toString());
}
final List<dynamic> src = (json['scores'] is List) ? (json['scores'] as List) : const [];
final List<int?> raw = List<int?>.filled(gameCount, null);
final List<int> hcs = List<int>.filled(gameCount, 0);
for (int i = 0; i < src.length && i < gameCount; i++) {
final el = src[i];
if (el == null) {
raw[i] = null;
hcs[i] = 0;
} else if (el is num) {
raw[i] = el.toInt();
hcs[i] = 0;
} else if (el is Map) {
final score = el['score'];
final hc = el['handicap'];
raw[i] = (score is num) ? score.toInt() : int.tryParse(score?.toString() ?? '');
hcs[i] = (hc is num) ? hc.toInt() : (int.tryParse(hc?.toString() ?? '') ?? 0);
} else {
raw[i] = int.tryParse(el.toString());
hcs[i] = 0;
}
}
final dynamic pidRaw = json['participantId'];
final String pid = (pidRaw != null && pidRaw.toString().isNotEmpty)
? pidRaw.toString()
: (json['id']?.toString() ?? '');
return PublicParticipant(
participantId: pid,
memberId: json['memberId']?.toString(),
name: (json['name'] ?? '이름없음').toString(),
gender: json['gender']?.toString(),
memberType: json['memberType']?.toString(),
average: parseNum(json['average']),
handicap: parseInt(json['handicap'], def: 0),
status: json['status']?.toString(),
paymentStatus: json['paymentStatus']?.toString(),
comment: json['comment']?.toString(),
isGuest: json['isGuest'] == true,
rawScores: raw,
handicaps: hcs,
);
}
}
+80
View File
@@ -0,0 +1,80 @@
class PublicTeamMemberSummary {
final String? participantId;
final String? memberId;
final String name;
final List<int?> rawScores;
final List<int> handicaps;
PublicTeamMemberSummary({
required this.participantId,
required this.memberId,
required this.name,
required this.rawScores,
required this.handicaps,
});
factory PublicTeamMemberSummary.fromJson(Map<String, dynamic> json, {int gameCount = 3}) {
final participantId = json['participantId']?.toString();
final memberId = json['memberId']?.toString();
final name = (json['name'] ?? '이름없음').toString();
// 점수 포맷은 PublicParticipant와 동일 규칙
final List<dynamic> src = (json['scores'] is List) ? (json['scores'] as List) : const [];
final List<int?> raw = List<int?>.filled(gameCount, null);
final List<int> hcs = List<int>.filled(gameCount, 0);
for (int i = 0; i < src.length && i < gameCount; i++) {
final el = src[i];
if (el == null) {
raw[i] = null;
hcs[i] = 0;
} else if (el is num) {
raw[i] = el.toInt();
hcs[i] = 0;
} else if (el is Map) {
final score = el['score'];
final hc = el['handicap'];
raw[i] = (score is num) ? score.toInt() : int.tryParse(score?.toString() ?? '');
hcs[i] = (hc is num) ? hc.toInt() : (int.tryParse(hc?.toString() ?? '') ?? 0);
} else {
raw[i] = int.tryParse(el.toString());
hcs[i] = 0;
}
}
return PublicTeamMemberSummary(
participantId: participantId,
memberId: memberId,
name: name,
rawScores: raw,
handicaps: hcs,
);
}
}
class PublicTeam {
final String teamId;
final int teamNumber;
final int handicap; // 팀 핸디캡
final List<PublicTeamMemberSummary> members;
PublicTeam({
required this.teamId,
required this.teamNumber,
required this.handicap,
required this.members,
});
factory PublicTeam.fromJson(Map<String, dynamic> json, {int gameCount = 3}) {
int parseInt(dynamic v, {int def = 0}) {
if (v == null) return def;
if (v is int) return v;
return int.tryParse(v.toString()) ?? def;
}
final List<dynamic> mem = (json['members'] is List) ? (json['members'] as List) : const [];
return PublicTeam(
teamId: (json['teamId'] ?? json['id'] ?? '').toString(),
teamNumber: parseInt(json['teamNumber'], def: 0),
handicap: parseInt(json['handicap'], def: 0),
members: mem.map((e) => PublicTeamMemberSummary.fromJson((e as Map).cast<String, dynamic>(), gameCount: gameCount)).toList(),
);
}
}
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:lanebow/services/public_event_service.dart';
class JoinDialog extends StatefulWidget {
final String publicHash;
final PublicEventService service;
const JoinDialog({super.key, required this.publicHash, required this.service});
@override
State<JoinDialog> createState() => _JoinDialogState();
}
class _JoinDialogState extends State<JoinDialog> {
final _formKey = GlobalKey<FormState>();
final _nameCtrl = TextEditingController();
final _phoneCtrl = TextEditingController();
final _avgCtrl = TextEditingController();
bool _saving = false;
@override
void dispose() {
_nameCtrl.dispose();
_phoneCtrl.dispose();
_avgCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
try {
final avg = int.tryParse(_avgCtrl.text);
await widget.service.addGuest(
widget.publicHash,
name: _nameCtrl.text.trim(),
phone: _phoneCtrl.text.trim().isEmpty ? null : _phoneCtrl.text.trim(),
gender: null,
average: avg,
);
if (!mounted) return;
Navigator.of(context).pop(true);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('신청 실패: ' + e.toString())));
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('참가 신청(게스트)'),
content: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
key: const Key('join_name_input'),
controller: _nameCtrl,
decoration: const InputDecoration(labelText: '이름*'),
validator: (v) => (v == null || v.trim().isEmpty) ? '이름을 입력하세요' : null,
),
TextFormField(
key: const Key('join_phone_input'),
controller: _phoneCtrl,
decoration: const InputDecoration(labelText: '연락처(선택)'),
),
TextFormField(
key: const Key('join_avg_input'),
controller: _avgCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '에버리지(선택)')
),
],
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(false),
child: const Text('취소'),
),
ElevatedButton(
key: const Key('join_save_button'),
onPressed: _saving ? null : _save,
child: _saving ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('신청'),
)
],
);
}
}
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:lanebow/screens/public_event/public_event_screen.dart';
import 'package:lanebow/services/public_event_service.dart';
class PublicEventEntryScreen extends StatefulWidget {
final String publicHash;
final PublicEventService? service;
const PublicEventEntryScreen({super.key, required this.publicHash, this.service});
@override
State<PublicEventEntryScreen> createState() => _PublicEventEntryScreenState();
}
class _PublicEventEntryScreenState extends State<PublicEventEntryScreen> {
late final PublicEventService _service = widget.service ?? PublicEventService();
final TextEditingController _pw = TextEditingController();
bool _loading = false;
String? _error;
bool _needPassword = false;
@override
void initState() {
super.initState();
// 자동 토큰 인증 시도
WidgetsBinding.instance.addPostFrameCallback((_) {
_tryEnter();
});
}
Future<void> _tryEnter({String? password}) async {
setState(() {
_loading = true;
_error = null;
});
try {
final data = await _service.enter(widget.publicHash, password: password);
// 성공 시 토큰 저장은 서비스가 처리. 공개 화면으로 이동
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => PublicEventScreen(publicHash: widget.publicHash)),
);
} catch (e) {
// 401/needPassword 케이스 포함: 간단히 비번 입력 노출
setState(() {
_needPassword = true;
_error = e.toString();
});
} finally {
if (mounted) {
setState(() {
_loading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('공개 이벤트 입장')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('hash: ' + widget.publicHash),
const SizedBox(height: 16),
if (_loading) const LinearProgressIndicator(),
if (_error != null) ...[
Text(_error!, style: const TextStyle(color: Colors.red)),
const SizedBox(height: 8),
],
if (_needPassword) ...[
TextField(
key: const Key('public_password_field'),
controller: _pw,
decoration: const InputDecoration(labelText: '비밀번호'),
obscureText: true,
),
const SizedBox(height: 12),
ElevatedButton(
key: const Key('public_enter_submit'),
onPressed: _loading
? null
: () {
_tryEnter(password: _pw.text);
},
child: const Text('입장'),
),
] else ...[
const Text('자동 인증 시도 중...'),
],
],
),
),
);
}
}
@@ -0,0 +1,620 @@
import 'package:flutter/material.dart';
import 'dart:async';
import 'package:lanebow/services/public_event_service.dart';
import 'package:dio/dio.dart';
import 'package:lanebow/screens/public_event/public_event_entry_screen.dart';
import 'package:lanebow/models/public_event.dart';
import 'package:lanebow/models/public_participant.dart';
import 'package:lanebow/models/public_team.dart';
import 'package:lanebow/utils/ranking_utils_public.dart';
import 'package:lanebow/screens/public_event/join_dialog.dart';
enum _ResultSort { total, avg }
class PublicEventScreen extends StatefulWidget {
final String publicHash;
final PublicEventService? service;
const PublicEventScreen({super.key, required this.publicHash, this.service});
@override
State<PublicEventScreen> createState() => _PublicEventScreenState();
}
class _PublicEventScreenState extends State<PublicEventScreen> {
late final PublicEventService _service = widget.service ?? PublicEventService();
bool _loading = true;
String? _error;
PublicEvent? _eventModel;
List<PublicParticipant> _participants = const [];
List<PublicTeam> _teams = const [];
final Map<String, String> _scoreStates = {}; // key: pid-g -> idle|saving|saved|error
final Map<String, Timer> _debouncers = {};
final Map<String, Timer> _postTimers = {};
_ResultSort _resultSort = _ResultSort.total;
@override
void initState() {
super.initState();
_load();
}
Color _scoreColor(PublicParticipant p, int g) {
final idx = g - 1;
int base = 0;
if (idx >= 0 && idx < p.rawScores.length && p.rawScores[idx] != null) {
base = p.rawScores[idx]!;
}
int hc = 0;
if (idx >= 0 && idx < p.handicaps.length) {
hc = p.handicaps[idx];
}
final total = base + hc;
if (base == 0) return Colors.grey;
return total >= 200 ? Colors.red : Colors.black87;
}
Widget _stateIcon(String key) {
final st = _scoreStates[key];
if (st == 'saving') return const Icon(Icons.autorenew, size: 14, color: Colors.grey);
if (st == 'saved') return const Icon(Icons.check_circle, size: 14, color: Colors.green);
if (st == 'error') return const Icon(Icons.error_outline, size: 14, color: Colors.orange);
return const Icon(Icons.circle, size: 10, color: Colors.grey);
}
@override
void dispose() {
for (final t in _debouncers.values) {
t.cancel();
}
_debouncers.clear();
for (final t in _postTimers.values) {
t.cancel();
}
_postTimers.clear();
super.dispose();
}
String _keyFor(String pid, int g) => pid + '-' + g.toString();
void _onScoreChanged(PublicParticipant p, int gameNumber, String value) {
final key = _keyFor(p.participantId, gameNumber);
_scoreStates[key] = 'idle';
// 3자리 입력 시 즉시 저장, 아니면 디바운스
_debouncers[key]?.cancel();
if (value.length == 3) {
final v = int.tryParse(value);
if (v != null && v >= 0 && v <= 300) {
_saveInlineScore(p, gameNumber, v);
return;
}
}
_debouncers[key] = Timer(const Duration(milliseconds: 200), () {
final v = int.tryParse(value);
if (v != null && v >= 0 && v <= 300) {
_saveInlineScore(p, gameNumber, v);
}
});
}
Future<void> _saveInlineScore(PublicParticipant p, int gameNumber, int score) async {
final key = _keyFor(p.participantId, gameNumber);
_scoreStates[key] = 'saving';
setState(() {});
try {
await _service.updateScore(widget.publicHash, participantId: p.participantId, gameNumber: gameNumber, score: score);
// 로컬 모델 즉시 갱신
final idx = _participants.indexWhere((pp) => pp.participantId == p.participantId);
if (idx >= 0) {
final cur = _participants[idx];
final newScores = List<int?>.from(cur.rawScores);
if (gameNumber >= 1 && gameNumber <= newScores.length) newScores[gameNumber - 1] = score;
_participants[idx] = PublicParticipant(
participantId: cur.participantId,
memberId: cur.memberId,
name: cur.name,
gender: cur.gender,
memberType: cur.memberType,
average: cur.average,
handicap: cur.handicap,
status: cur.status,
paymentStatus: cur.paymentStatus,
comment: cur.comment,
isGuest: cur.isGuest,
rawScores: newScores,
handicaps: cur.handicaps,
);
}
_scoreStates[key] = 'saved';
setState(() {});
_postTimers[key]?.cancel();
_postTimers[key] = Timer(const Duration(milliseconds: 1200), () {
if (!mounted) return;
_scoreStates[key] = 'idle';
setState(() {});
});
} on DioException catch (e) {
final status = e.response?.statusCode;
if (status == 401 || status == 403) {
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => PublicEventEntryScreen(publicHash: widget.publicHash)),
);
return;
}
_scoreStates[key] = 'error';
setState(() {});
} catch (_) {
_scoreStates[key] = 'error';
setState(() {});
}
}
Future<void> _openScoreDialog(String participantId, {int? initial}) async {
final controller = TextEditingController(text: initial?.toString() ?? '');
int selectedGame = 1;
String? errorText;
final gameCount = _eventModel?.gameCount ?? 3;
final result = await showDialog<Map<String, int>?>(
context: context,
builder: (ctx) {
return StatefulBuilder(builder: (ctx2, setState) {
void validate() {
final v = int.tryParse(controller.text);
if (v == null || v < 0 || v > 300) {
setState(() => errorText = '0~300 사이의 정수를 입력하세요');
} else {
setState(() => errorText = null);
}
}
return AlertDialog(
title: const Text('점수 입력'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButton<int>(
key: const Key('game_select'),
value: selectedGame,
items: [for (int i = 1; i <= gameCount; i++) DropdownMenuItem(value: i, child: Text('게임 ' + i.toString()))],
onChanged: (v) => setState(() => selectedGame = v ?? 1),
),
TextField(
key: const Key('score_input'),
controller: controller,
keyboardType: TextInputType.number,
decoration: InputDecoration(labelText: '점수(0-300)', errorText: errorText),
onChanged: (_) => validate(),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(null),
child: const Text('취소'),
),
ElevatedButton(
key: const Key('score_save_button'),
onPressed: () {
final v = int.tryParse(controller.text);
if (v == null || v < 0 || v > 300) {
validate();
return;
}
Navigator.of(ctx).pop({'game': selectedGame, 'score': v});
},
child: const Text('저장'),
),
],
);
});
},
);
if (result == null) return;
try {
final gameNumber = result['game']!;
final score = result['score']!;
await _service.updateScore(widget.publicHash, participantId: participantId, gameNumber: gameNumber, score: score);
if (!mounted) return;
// 로컬 모델 즉시 갱신
final idx = _participants.indexWhere((pp) => pp.participantId == participantId);
if (idx >= 0) {
final p = _participants[idx];
final newScores = List<int?>.from(p.rawScores);
if (gameNumber >= 1 && gameNumber <= newScores.length) {
newScores[gameNumber - 1] = score;
_participants[idx] = PublicParticipant(
participantId: p.participantId,
memberId: p.memberId,
name: p.name,
gender: p.gender,
memberType: p.memberType,
average: p.average,
handicap: p.handicap,
status: p.status,
paymentStatus: p.paymentStatus,
comment: p.comment,
isGuest: p.isGuest,
rawScores: newScores,
handicaps: p.handicaps,
);
setState(() {});
}
}
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('점수가 저장되었습니다')));
} on DioException catch (e) {
if (!mounted) return;
final status = e.response?.statusCode;
if (status == 401 || status == 403) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => PublicEventEntryScreen(publicHash: widget.publicHash)),
);
return;
}
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('저장 실패: ' + e.toString())));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('저장 실패: ' + e.toString())));
}
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final d = await _service.fetchEvent(widget.publicHash);
if (!mounted) return;
final eventJson = (d['event'] as Map?)?.cast<String, dynamic>() ?? <String, dynamic>{};
final eventModel = PublicEvent.fromJson(eventJson);
final gc = eventModel.gameCount;
final parts = (d['participants'] is List)
? (d['participants'] as List)
.whereType<Map>()
.map((e) => PublicParticipant.fromJson(e.cast<String, dynamic>(), gameCount: gc))
.toList()
: <PublicParticipant>[];
final teams = (d['teams'] is List)
? (d['teams'] as List)
.whereType<Map>()
.map((e) => PublicTeam.fromJson(e.cast<String, dynamic>(), gameCount: gc))
.toList()
: <PublicTeam>[];
setState(() {
_eventModel = eventModel;
_participants = parts;
_teams = teams;
});
} on DioException catch (e) {
if (!mounted) return;
final status = e.response?.statusCode;
if (status == 401 || status == 403) {
// 인증 만료: 엔트리 화면으로 복귀
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => PublicEventEntryScreen(publicHash: widget.publicHash)),
);
return;
} else if (status == 404) {
setState(() {
_error = '이벤트를 찾을 수 없습니다';
});
return;
}
setState(() {
_error = e.toString();
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
});
} finally {
if (mounted) {
setState(() {
_loading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final event = _eventModel;
final participants = _participants;
final teams = _teams;
final partRankMap = PublicRankingUtils.buildParticipantRankMap(participants);
final teamRankMap = PublicRankingUtils.buildTeamRankMap(teams);
final unassigned = PublicRankingUtils.unassignedParticipants(participants, teams);
final now = DateTime.now();
final registrationClosed = (event?.registrationDeadline != null) && now.isAfter(event!.registrationDeadline!);
final canJoin = (event?.isReady == true || event?.isActive == true) && !registrationClosed;
final canInputScore = (event?.isActive == true);
return Scaffold(
appBar: AppBar(title: const Text('공개 이벤트')),
body: Padding(
padding: const EdgeInsets.all(16),
child: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!, style: const TextStyle(color: Colors.red)),
const SizedBox(height: 12),
ElevatedButton(
key: const Key('go_entry_button'),
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => PublicEventEntryScreen(publicHash: widget.publicHash)),
);
},
child: const Text('입장 화면으로 돌아가기'),
),
],
),
)
: ListView(
children: [
Text('hash: ' + widget.publicHash, key: const Key('public_hash_label')),
const SizedBox(height: 8),
if (event != null) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: Text(event.title, key: const Key('event_title'))),
if (canJoin)
ElevatedButton(
key: const Key('join_button'),
onPressed: () async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => JoinDialog(publicHash: widget.publicHash, service: _service),
);
if (ok == true) {
await _load();
}
},
child: const Text('참가 신청/수정'),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (event.status != null)
Chip(key: const Key('status_chip'), label: Text(event.status!)),
if (event.eventType != null && event.eventType!.isNotEmpty)
Chip(key: const Key('type_chip'), label: Text(event.eventType!)),
if (event.startDate != null || event.endDate != null)
Chip(
key: const Key('period_chip'),
label: Text(
((event.startDate != null) ? event.startDate!.toLocal().toString().split('.').first : '') +
(event.endDate != null ? ' ~ ' + event.endDate!.toLocal().toString().split('.').first : ''),
),
),
Chip(key: const Key('game_chip'), label: Text('게임 ' + event.gameCount.toString())),
if (event.registrationDeadline != null)
Chip(key: const Key('deadline_chip'), label: Text('신청마감 ' + event.registrationDeadline!.toLocal().toString().split('.').first)),
if (event.participantFee != null)
Chip(key: const Key('fee_chip'), label: Text('참가비 ₩' + event.participantFee!.toString())),
],
),
const SizedBox(height: 8),
],
const SizedBox(height: 8),
const Text('참가자', style: TextStyle(fontWeight: FontWeight.bold), key: Key('section_participants')),
Text('' + participants.length.toString() + '', key: const Key('participants_count')),
const SizedBox(height: 8),
Card(
child: participants.isEmpty
? const ListTile(
key: Key('empty_participants'),
title: Text('참가자가 없습니다'),
)
: Column(
children: [
for (final p in participants)
ListTile(
key: Key('participant_tile_' + p.participantId),
title: Text(p.name),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
(p.status ?? '-') + ' · avg:' + (p.average?.toString() ?? '-') + ' · hc:' + p.handicap.toString(),
key: Key('participant_info_' + p.participantId),
),
if (canInputScore && event != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
for (int g = 1; g <= event.gameCount; g++)
Padding(
padding: const EdgeInsets.only(right: 6),
child: SizedBox(
width: 54,
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
key: Key('color_' + p.participantId + '_' + g.toString()),
width: 8,
height: 8,
decoration: BoxDecoration(
color: _scoreColor(p, g),
shape: BoxShape.circle,
),
),
_stateIcon(_keyFor(p.participantId, g)),
],
),
TextFormField(
key: Key('cell_' + p.participantId + '_' + g.toString()),
initialValue: (g - 1 < p.rawScores.length && p.rawScores[g - 1] != null)
? p.rawScores[g - 1].toString()
: '',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
isDense: true,
hintText: '---',
counterText: '',
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 6),
),
maxLength: 3,
onChanged: (v) => _onScoreChanged(p, g, v),
),
],
),
),
),
],
),
),
],
),
trailing: Text(
'R ' + (partRankMap[p.participantId]?['rank']?.toString() ?? '-') +
' · 총 ' + PublicRankingUtils.participantTotalWithHc(p).toString(),
key: Key('participant_total_' + p.participantId),
),
onTap: () {
if (!canInputScore) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('완료 상태에서는 점수 입력이 불가합니다')));
return;
}
_openScoreDialog(p.participantId, initial: p.rawScores.isNotEmpty ? p.rawScores[0] : null);
},
),
],
),
),
const SizedBox(height: 24),
const Text('', style: TextStyle(fontWeight: FontWeight.bold), key: Key('section_teams')),
Text('' + teams.length.toString() + '', key: const Key('teams_count')),
const SizedBox(height: 8),
Card(
child: teams.isEmpty
? const ListTile(
key: Key('empty_teams'),
title: Text('팀이 없습니다'),
)
: Column(
children: [
for (final t in teams)
ListTile(
title: Row(
children: [
Text('' + t.teamNumber.toString()),
const SizedBox(width: 6),
Container(
key: Key('team_rank_' + t.teamNumber.toString()),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(10),
),
child: Text('R ' + (teamRankMap[t.teamNumber]?['rank']?.toString() ?? '-'), style: const TextStyle(fontSize: 12)),
),
],
),
subtitle: Builder(builder: (context) {
int membersTotal = 0;
for (final m in t.members) {
int mt = 0;
for (int i = 0; i < m.rawScores.length; i++) {
final raw = m.rawScores[i];
final hc = (i < m.handicaps.length) ? m.handicaps[i] : 0;
if (raw == null) continue;
mt += PublicRankingUtils.capScore(raw + hc);
}
membersTotal += mt;
}
final total = membersTotal + t.handicap;
final avg = t.members.isNotEmpty ? (total / t.members.length).toStringAsFixed(1) : '-';
return Text(
'' + total.toString() + ' · 평균 ' + avg + ' (팀 HC ' + t.handicap.toString() + ')',
key: Key('team_summary_' + t.teamNumber.toString()),
);
}),
),
],
),
),
const SizedBox(height: 24),
const Text('미배정', style: TextStyle(fontWeight: FontWeight.bold), key: Key('section_unassigned')),
Text('' + unassigned.length.toString() + '', key: const Key('unassigned_count')),
const SizedBox(height: 8),
Card(
child: unassigned.isEmpty
? const ListTile(
key: Key('empty_unassigned'),
title: Text('미배정 참가자가 없습니다'),
)
: Column(
children: [
for (final p in unassigned)
ListTile(
key: Key('unassigned_' + p.participantId),
title: Text(p.name),
),
],
),
),
if (event?.isFinished == true) ...[
const SizedBox(height: 24),
const Text('최종 결과', style: TextStyle(fontWeight: FontWeight.bold), key: Key('section_final_results')),
const SizedBox(height: 8),
Row(
children: [
ChoiceChip(
key: const Key('sort_total_chip'),
label: const Row(children: [Icon(Icons.summarize, size: 16), SizedBox(width: 4), Text('총점')]),
selected: _resultSort == _ResultSort.total,
onSelected: (_) => setState(() => _resultSort = _ResultSort.total),
),
const SizedBox(width: 8),
ChoiceChip(
key: const Key('sort_avg_chip'),
label: const Row(children: [Icon(Icons.show_chart, size: 16), SizedBox(width: 4), Text('평균')]),
selected: _resultSort == _ResultSort.avg,
onSelected: (_) => setState(() => _resultSort = _ResultSort.avg),
),
],
),
const SizedBox(height: 8),
Card(
child: Column(
children: [
for (final item in (_resultSort == _ResultSort.total
? PublicRankingUtils.individualRanking(participants)
: PublicRankingUtils.individualRankingByAvg(participants)))
Builder(builder: (_) {
final p = item['p'] as PublicParticipant;
final rank = item['rank'] as int;
final total = item['total'] as int;
final avg = PublicRankingUtils.participantAverageWithHc(p).toStringAsFixed(1);
return ListTile(
key: Key('result_row_' + p.participantId),
leading: Text(rank.toString(), key: Key('result_rank_' + p.participantId)),
title: Text(p.name),
trailing: Text('' + total.toString() + ' · 평균 ' + avg),
);
}),
],
),
),
],
],
),
),
);
}
}
@@ -0,0 +1,162 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../config/api_config.dart';
import '../utils/web_storage.dart';
abstract class PublicHttpClient {
Future<Response<dynamic>> get(String path, {Map<String, dynamic>? headers});
Future<Response<dynamic>> post(String path, {dynamic data, Map<String, dynamic>? headers});
Future<Response<dynamic>> put(String path, {dynamic data, Map<String, dynamic>? headers});
}
class DioPublicHttpClient implements PublicHttpClient {
final Dio _dio;
DioPublicHttpClient({Dio? dio}) : _dio = dio ?? Dio(BaseOptions(
baseUrl: ApiConfig.baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
contentType: 'application/json',
));
@override
Future<Response> get(String path, {Map<String, dynamic>? headers}) {
return _dio.get(path, options: Options(headers: headers));
}
@override
Future<Response> post(String path, {data, Map<String, dynamic>? headers}) {
return _dio.post(path, data: data ?? {}, options: Options(headers: headers));
}
@override
Future<Response> put(String path, {data, Map<String, dynamic>? headers}) {
return _dio.put(path, data: data ?? {}, options: Options(headers: headers));
}
}
class PublicEventService {
final PublicHttpClient _http;
PublicEventService() : _http = DioPublicHttpClient();
@visibleForTesting
PublicEventService.forTest(this._http);
static String tokenKey(String publicHash) => 'event_token_' + publicHash;
String? getSavedToken(String publicHash) {
return WebStorage.getItem(tokenKey(publicHash));
}
void saveToken(String publicHash, String token) {
WebStorage.setItem(tokenKey(publicHash), token);
}
void removeToken(String publicHash) {
WebStorage.removeItem(tokenKey(publicHash));
}
Map<String, dynamic>? _authHeader(String? token) {
if (token == null || token.isEmpty) return null;
return { 'Authorization': 'Bearer ' + token };
}
Future<Map<String, dynamic>> fetchEvent(String publicHash) async {
final saved = getSavedToken(publicHash);
final res = await _http.get(
'/api/public/' + publicHash,
headers: _authHeader(saved),
);
final data = res.data as Map<String, dynamic>;
return data;
}
Future<Map<String, dynamic>> enter(String publicHash, {String? password}) async {
final saved = getSavedToken(publicHash);
try {
// 우선 저장된 토큰으로 시도, 실패 시 비밀번호로 재시도는 서버가 처리
final res = await _http.post(
'/api/public/' + publicHash,
data: password != null ? { 'password': password } : {},
headers: _authHeader(saved),
);
final data = res.data as Map<String, dynamic>;
// 응답에 token이 있으면 저장
final token = data['token']?.toString();
if (token != null && token.isNotEmpty) {
saveToken(publicHash, token);
}
return data;
} on DioException catch (e) {
final status = e.response?.statusCode;
if (status == 401 || status == 403) {
removeToken(publicHash);
}
rethrow;
}
}
Future<void> addOrUpdateParticipant(String publicHash, {
String? participantId,
required String memberId,
required String status,
required String paymentStatus,
String? comment,
}) async {
final token = getSavedToken(publicHash);
final payload = {
if (participantId != null) 'participantId': participantId,
'memberId': memberId,
'status': status,
'paymentStatus': paymentStatus,
if (comment != null) 'comment': comment,
};
await _http.post(
'/api/public/' + publicHash + '/participant',
data: payload,
headers: _authHeader(token),
);
}
Future<void> addGuest(String publicHash, {
required String name,
String? phone,
String? gender,
num? average,
}) async {
final token = getSavedToken(publicHash);
final payload = {
'name': name,
if (phone != null) 'phone': phone,
if (gender != null) 'gender': gender,
if (average != null) 'average': average,
};
await _http.post(
'/api/public/' + publicHash + '/guest',
data: payload,
headers: _authHeader(token),
);
}
Future<void> updateScore(String publicHash, {
required String participantId,
required int gameNumber,
required int score,
int? handicap,
dynamic teamNumber,
}) async {
final token = getSavedToken(publicHash);
final payload = {
'participantId': participantId,
'gameNumber': gameNumber,
'score': score,
if (handicap != null) 'handicap': handicap,
if (teamNumber != null) 'teamNumber': teamNumber,
};
await _http.put(
'/api/public/' + publicHash + '/score',
data: payload,
headers: _authHeader(token),
);
}
}
+180
View File
@@ -0,0 +1,180 @@
import '../models/public_participant.dart';
import '../models/public_team.dart';
class PublicRankingUtils {
static int capScore(int scoreWithHandicap) {
if (scoreWithHandicap > 300) return 300;
if (scoreWithHandicap < 0) return 0;
return scoreWithHandicap;
}
// 평균(핸디 포함), 소수 1자리. 입력된 게임 수 기준으로 계산
static double participantAverageWithHc(PublicParticipant p) {
int total = 0;
int cnt = 0;
for (int i = 0; i < p.rawScores.length; i++) {
final raw = p.rawScores[i];
int hc = (i < p.handicaps.length) ? p.handicaps[i] : 0;
if (hc == 0) {
hc = p.handicap;
}
if (raw == null) continue;
total += capScore(raw + hc);
cnt += 1;
}
if (cnt == 0) return 0.0;
return total / cnt;
}
// 개인별 총점(핸디캡 포함) 계산
static int participantTotalWithHc(PublicParticipant p) {
int total = 0;
for (int i = 0; i < p.rawScores.length; i++) {
final raw = p.rawScores[i];
int hc = (i < p.handicaps.length) ? p.handicaps[i] : 0;
if (hc == 0) {
// per-game 핸디캡이 없으면 참가자 기본 핸디캡 사용
hc = p.handicap;
}
if (raw == null) continue;
total += capScore(raw + hc);
}
return total;
}
// 동점자 순위 처리(1,1,3 방식)
static List<int> ranksFor(List<int> valuesDesc) {
if (valuesDesc.isEmpty) return const [];
final ranks = List<int>.filled(valuesDesc.length, 0);
int rank = 1;
for (int i = 0; i < valuesDesc.length; i++) {
if (i > 0 && valuesDesc[i] == valuesDesc[i - 1]) {
ranks[i] = ranks[i - 1];
} else {
ranks[i] = rank;
}
rank = i + 2; // 다음 베이스 랭크
}
return ranks;
}
static List<Map<String, dynamic>> individualRanking(List<PublicParticipant> list) {
// 내림차순 정렬
final items = list
.map((p) => {
'p': p,
'total': participantTotalWithHc(p),
})
.toList();
items.sort((a, b) => (b['total'] as int).compareTo(a['total'] as int));
final totals = items.map<int>((e) => e['total'] as int).toList();
final rs = ranksFor(totals);
for (int i = 0; i < items.length; i++) {
items[i]['rank'] = rs[i];
}
return items;
}
// 동점자 순위 처리 for double 리스트 (1,1,3 방식)
static List<int> ranksForDoubles(List<double> valuesDesc) {
if (valuesDesc.isEmpty) return const [];
final ranks = List<int>.filled(valuesDesc.length, 0);
int rank = 1;
const eps = 1e-9;
for (int i = 0; i < valuesDesc.length; i++) {
if (i > 0 && (valuesDesc[i] - valuesDesc[i - 1]).abs() < eps) {
ranks[i] = ranks[i - 1];
} else {
ranks[i] = rank;
}
rank = i + 2;
}
return ranks;
}
// 평균(핸디 포함) 기준 개인 랭킹 (내림차순). item: {p, total, avg, rank}
static List<Map<String, dynamic>> individualRankingByAvg(List<PublicParticipant> list) {
final items = list
.map((p) => {
'p': p,
'total': participantTotalWithHc(p),
'avg': participantAverageWithHc(p),
})
.toList();
items.sort((a, b) => (b['avg'] as double).compareTo(a['avg'] as double));
final avgs = items.map<double>((e) => e['avg'] as double).toList();
final rs = ranksForDoubles(avgs);
for (int i = 0; i < items.length; i++) {
items[i]['rank'] = rs[i];
}
return items;
}
static List<Map<String, dynamic>> teamRanking(List<PublicTeam> teams) {
// 팀 총점 = 구성원 총점 합 + 팀 핸디캡 합(여기서는 팀 handicap만 단일 값으로 가정)
final items = teams
.map((t) {
int membersTotal = 0;
for (final m in t.members) {
int mt = 0;
for (int i = 0; i < m.rawScores.length; i++) {
final raw = m.rawScores[i];
final hc = (i < m.handicaps.length) ? m.handicaps[i] : 0;
if (raw == null) continue;
mt += capScore(raw + hc);
}
membersTotal += mt;
}
final total = membersTotal + t.handicap;
return {'team': t, 'total': total};
})
.toList();
items.sort((a, b) => (b['total'] as int).compareTo(a['total'] as int));
final totals = items.map<int>((e) => e['total'] as int).toList();
final rs = ranksFor(totals);
for (int i = 0; i < items.length; i++) {
items[i]['rank'] = rs[i];
}
return items;
}
// 참가자별 총점과 랭크 맵 생성 (participantId -> {total, rank})
static Map<String, Map<String, int>> buildParticipantRankMap(List<PublicParticipant> list) {
final ranked = individualRanking(list);
final map = <String, Map<String, int>>{};
for (final item in ranked) {
final p = item['p'] as PublicParticipant;
map[p.participantId] = {
'total': item['total'] as int,
'rank': item['rank'] as int,
};
}
return map;
}
// 팀별 총점 맵 (teamNumber -> {total, rank})
static Map<int, Map<String, int>> buildTeamRankMap(List<PublicTeam> teams) {
final ranked = teamRanking(teams);
final map = <int, Map<String, int>>{};
for (final item in ranked) {
final t = item['team'] as PublicTeam;
map[t.teamNumber] = {
'total': item['total'] as int,
'rank': item['rank'] as int,
};
}
return map;
}
// 미배정 참가자 목록 계산
static List<PublicParticipant> unassignedParticipants(List<PublicParticipant> participants, List<PublicTeam> teams) {
final assigned = <String>{};
for (final t in teams) {
for (final m in t.members) {
final pid = m.participantId;
if (pid != null && pid.isNotEmpty) assigned.add(pid);
}
}
return participants.where((p) => !assigned.contains(p.participantId)).toList();
}
}
+19
View File
@@ -0,0 +1,19 @@
// Intentionally minimal; no platform imports to keep tests stable
// Minimal web storage wrapper. On web uses window.localStorage; otherwise in-memory.
// Avoids flutter_secure_storage_web to keep WebAssembly warnings out.
class WebStorage {
static final Map<String, String> _memory = <String, String>{};
static String? getItem(String key) {
return _memory[key];
}
static void setItem(String key, String value) {
_memory[key] = value;
}
static void removeItem(String key) {
_memory.remove(key);
}
}