공개페이지
This commit is contained in:
@@ -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;
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:lanebow/models/public_event.dart';
|
||||
import 'package:lanebow/models/public_participant.dart';
|
||||
|
||||
void main() {
|
||||
group('PublicEvent.fromJson', () {
|
||||
test('parses minimal and coerces types', () {
|
||||
final e = PublicEvent.fromJson({
|
||||
'id': 10,
|
||||
'title': 'Open',
|
||||
'gameCount': '5',
|
||||
});
|
||||
expect(e.id, '10');
|
||||
expect(e.title, 'Open');
|
||||
expect(e.gameCount, 5);
|
||||
expect(e.description, isNull);
|
||||
});
|
||||
|
||||
test('parses dates and optionals', () {
|
||||
final e = PublicEvent.fromJson({
|
||||
'id': 'A1',
|
||||
'title': 'E',
|
||||
'startDate': '2024-01-01T10:00:00Z',
|
||||
'registrationDeadline': '2024-01-02T10:00:00Z',
|
||||
'participantFee': '10000',
|
||||
'maxParticipants': '30',
|
||||
});
|
||||
expect(e.id, 'A1');
|
||||
expect(e.startDate, isNotNull);
|
||||
expect(e.registrationDeadline, isNotNull);
|
||||
expect(e.participantFee, 10000);
|
||||
expect(e.maxParticipants, 30);
|
||||
});
|
||||
});
|
||||
|
||||
group('PublicParticipant.fromJson', () {
|
||||
test('handles numeric scores and fills length to gameCount', () {
|
||||
final p = PublicParticipant.fromJson({
|
||||
'participantId': 7,
|
||||
'name': 'Kim',
|
||||
'scores': [120, 130],
|
||||
'handicap': 10,
|
||||
}, gameCount: 3);
|
||||
expect(p.participantId, '7');
|
||||
expect(p.name, 'Kim');
|
||||
expect(p.rawScores, [120, 130, null]);
|
||||
expect(p.handicaps, [0, 0, 0]);
|
||||
});
|
||||
|
||||
test('handles object scores with handicap', () {
|
||||
final p = PublicParticipant.fromJson({
|
||||
'participantId': '9',
|
||||
'name': 'Lee',
|
||||
'scores': [
|
||||
{'score': 200, 'handicap': 0},
|
||||
{'score': '150', 'handicap': '5'},
|
||||
null
|
||||
],
|
||||
}, gameCount: 3);
|
||||
expect(p.rawScores, [200, 150, null]);
|
||||
expect(p.handicaps, [0, 5, 0]);
|
||||
});
|
||||
|
||||
test('coerces fields and defaults', () {
|
||||
final p = PublicParticipant.fromJson({
|
||||
'id': '11',
|
||||
'name': null,
|
||||
'average': '192.5',
|
||||
'memberType': 'guest',
|
||||
'status': 'pending',
|
||||
'paymentStatus': 'unpaid',
|
||||
'scores': [],
|
||||
}, gameCount: 2);
|
||||
expect(p.participantId, '11');
|
||||
expect(p.name, '이름없음');
|
||||
expect(p.average, 192.5);
|
||||
expect(p.isGuest, false); // isGuest는 명시 true일 때만 처리
|
||||
expect(p.rawScores, [null, null]);
|
||||
expect(p.handicaps, [0, 0]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'package:lanebow/services/public_event_service.dart';
|
||||
import 'package:lanebow/utils/web_storage.dart';
|
||||
|
||||
class FakeHttp implements PublicHttpClient {
|
||||
Response<dynamic>? lastResponse;
|
||||
Map<String, dynamic>? lastHeaders;
|
||||
String? lastPath;
|
||||
dynamic lastData;
|
||||
|
||||
int statusCode = 200;
|
||||
Map<String, dynamic> responseData = const {'ok': true};
|
||||
|
||||
@override
|
||||
Future<Response<dynamic>> get(String path, {Map<String, dynamic>? headers}) async {
|
||||
lastPath = path;
|
||||
lastData = null;
|
||||
lastHeaders = headers;
|
||||
if (statusCode >= 400) {
|
||||
throw DioException(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
response: Response(requestOptions: RequestOptions(path: path), statusCode: statusCode, data: responseData),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
}
|
||||
lastResponse = Response(requestOptions: RequestOptions(path: path), statusCode: statusCode, data: responseData);
|
||||
return lastResponse!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<dynamic>> post(String path, {data, Map<String, dynamic>? headers}) async {
|
||||
lastPath = path;
|
||||
lastData = data;
|
||||
lastHeaders = headers;
|
||||
if (statusCode >= 400) {
|
||||
throw DioException(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
response: Response(requestOptions: RequestOptions(path: path), statusCode: statusCode, data: responseData),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
}
|
||||
lastResponse = Response(requestOptions: RequestOptions(path: path), statusCode: statusCode, data: responseData);
|
||||
return lastResponse!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<dynamic>> put(String path, {data, Map<String, dynamic>? headers}) async {
|
||||
lastPath = path;
|
||||
lastData = data;
|
||||
lastHeaders = headers;
|
||||
if (statusCode >= 400) {
|
||||
throw DioException(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
response: Response(requestOptions: RequestOptions(path: path), statusCode: statusCode, data: responseData),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
}
|
||||
lastResponse = Response(requestOptions: RequestOptions(path: path), statusCode: statusCode, data: responseData);
|
||||
return lastResponse!;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('PublicEventService.enter', () {
|
||||
test('saves token when response includes token', () async {
|
||||
final fake = FakeHttp()
|
||||
..statusCode = 200
|
||||
..responseData = {'token': 'TKN', 'event': {'id': 1}};
|
||||
final svc = PublicEventService.forTest(fake);
|
||||
final hash = 'abc123';
|
||||
|
||||
// ensure empty
|
||||
WebStorage.removeItem(PublicEventService.tokenKey(hash));
|
||||
|
||||
final data = await svc.enter(hash, password: 'pw');
|
||||
expect(data['token'], 'TKN');
|
||||
expect(WebStorage.getItem(PublicEventService.tokenKey(hash)), 'TKN');
|
||||
expect(fake.lastPath, '/api/public/' + hash);
|
||||
});
|
||||
|
||||
test('removes saved token on 401/403', () async {
|
||||
final fake = FakeHttp()
|
||||
..statusCode = 401
|
||||
..responseData = {'message': 'unauthorized'};
|
||||
final svc = PublicEventService.forTest(fake);
|
||||
final hash = 'abc401';
|
||||
|
||||
// preset token
|
||||
WebStorage.setItem(PublicEventService.tokenKey(hash), 'OLD');
|
||||
|
||||
await expectLater(
|
||||
svc.enter(hash, password: 'wrong'),
|
||||
throwsA(isA<DioException>()),
|
||||
);
|
||||
expect(WebStorage.getItem(PublicEventService.tokenKey(hash)), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('PublicEventService.updateScore', () {
|
||||
test('sends Authorization header when token saved', () async {
|
||||
final fake = FakeHttp()
|
||||
..statusCode = 200
|
||||
..responseData = {'ok': true};
|
||||
final svc = PublicEventService.forTest(fake);
|
||||
final hash = 'hhh';
|
||||
WebStorage.setItem(PublicEventService.tokenKey(hash), 'TKN2');
|
||||
|
||||
await svc.updateScore(
|
||||
hash,
|
||||
participantId: '10',
|
||||
gameNumber: 1,
|
||||
score: 200,
|
||||
handicap: 0,
|
||||
);
|
||||
|
||||
expect(fake.lastPath, '/api/public/' + hash + '/score');
|
||||
expect(fake.lastHeaders?['Authorization'], 'Bearer TKN2');
|
||||
expect(fake.lastData['participantId'], '10');
|
||||
expect(fake.lastData['gameNumber'], 1);
|
||||
expect(fake.lastData['score'], 200);
|
||||
});
|
||||
});
|
||||
|
||||
group('PublicEventService.fetchEvent', () {
|
||||
test('uses GET and Authorization header when token saved', () async {
|
||||
final fake = FakeHttp()
|
||||
..statusCode = 200
|
||||
..responseData = {'event': {'id': 'E1'}};
|
||||
final svc = PublicEventService.forTest(fake);
|
||||
final hash = 'fetch1';
|
||||
WebStorage.setItem(PublicEventService.tokenKey(hash), 'TKF');
|
||||
|
||||
final data = await svc.fetchEvent(hash);
|
||||
expect(data['event']['id'], 'E1');
|
||||
expect(fake.lastPath, '/api/public/' + hash);
|
||||
expect(fake.lastHeaders?['Authorization'], 'Bearer TKF');
|
||||
});
|
||||
});
|
||||
|
||||
group('PublicEventService.enter edge cases', () {
|
||||
test('404 does not remove saved token', () async {
|
||||
final fake = FakeHttp()
|
||||
..statusCode = 404
|
||||
..responseData = {'message': 'not found'};
|
||||
final svc = PublicEventService.forTest(fake);
|
||||
final hash = 'nf404';
|
||||
WebStorage.setItem(PublicEventService.tokenKey(hash), 'KEEP');
|
||||
await expectLater(svc.enter(hash), throwsA(isA<DioException>()));
|
||||
expect(WebStorage.getItem(PublicEventService.tokenKey(hash)), 'KEEP');
|
||||
});
|
||||
});
|
||||
|
||||
group('PublicEventService.addGuest', () {
|
||||
test('sends payload and header', () async {
|
||||
final fake = FakeHttp()
|
||||
..statusCode = 200
|
||||
..responseData = {'ok': true};
|
||||
final svc = PublicEventService.forTest(fake);
|
||||
final hash = 'guest';
|
||||
WebStorage.setItem(PublicEventService.tokenKey(hash), 'TG');
|
||||
|
||||
await svc.addGuest(hash, name: '홍길동', phone: '010', gender: 'M', average: 190);
|
||||
expect(fake.lastPath, '/api/public/' + hash + '/guest');
|
||||
expect(fake.lastHeaders?['Authorization'], 'Bearer TG');
|
||||
expect(fake.lastData['name'], '홍길동');
|
||||
expect(fake.lastData['phone'], '010');
|
||||
expect(fake.lastData['gender'], 'M');
|
||||
expect(fake.lastData['average'], 190);
|
||||
});
|
||||
});
|
||||
|
||||
group('PublicEventService.updateScore errors', () {
|
||||
test('propagates server error 500', () async {
|
||||
final fake = FakeHttp()
|
||||
..statusCode = 500
|
||||
..responseData = {'message': 'oops'};
|
||||
final svc = PublicEventService.forTest(fake);
|
||||
final hash = 'err';
|
||||
WebStorage.setItem(PublicEventService.tokenKey(hash), 'TE');
|
||||
|
||||
await expectLater(
|
||||
svc.updateScore(hash, participantId: 'p1', gameNumber: 1, score: 100),
|
||||
throwsA(isA<DioException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:lanebow/models/public_participant.dart';
|
||||
import 'package:lanebow/models/public_team.dart';
|
||||
import 'package:lanebow/utils/ranking_utils_public.dart';
|
||||
|
||||
void main() {
|
||||
group('PublicRankingUtils', () {
|
||||
test('caps score with handicap at 300 per game and ties rank', () {
|
||||
final p1 = PublicParticipant.fromJson({
|
||||
'participantId': '1',
|
||||
'name': 'A',
|
||||
'scores': [250, 280],
|
||||
'handicap': 0,
|
||||
}, gameCount: 2);
|
||||
final p2 = PublicParticipant.fromJson({
|
||||
'participantId': '2',
|
||||
'name': 'B',
|
||||
'scores': [295, 290],
|
||||
'handicap': 20, // 각 게임 합이 300 넘는 케이스 -> 캡
|
||||
}, gameCount: 2);
|
||||
|
||||
final ranks = PublicRankingUtils.individualRanking([p1, p2]);
|
||||
// p1 totals: 250 + 280 = 530
|
||||
// p2 totals with cap: min(295+20,300)=300 + min(290+20,300)=300 => 600
|
||||
expect(ranks[0]['p'].participantId, '2');
|
||||
expect(ranks[0]['total'], 600);
|
||||
expect(ranks[0]['rank'], 1);
|
||||
expect(ranks[1]['p'].participantId, '1');
|
||||
expect(ranks[1]['total'], 530);
|
||||
expect(ranks[1]['rank'], 2);
|
||||
});
|
||||
|
||||
test('tie produces 1,1,3 ranks', () {
|
||||
final p1 = PublicParticipant.fromJson({
|
||||
'participantId': '1', 'name': 'A', 'scores': [200], 'handicap': 0
|
||||
}, gameCount: 1);
|
||||
final p2 = PublicParticipant.fromJson({
|
||||
'participantId': '2', 'name': 'B', 'scores': [200], 'handicap': 0
|
||||
}, gameCount: 1);
|
||||
final p3 = PublicParticipant.fromJson({
|
||||
'participantId': '3', 'name': 'C', 'scores': [150], 'handicap': 0
|
||||
}, gameCount: 1);
|
||||
final ranks = PublicRankingUtils.individualRanking([p1, p2, p3]);
|
||||
expect(ranks[0]['total'], 200);
|
||||
expect(ranks[1]['total'], 200);
|
||||
expect(ranks[2]['total'], 150);
|
||||
expect(ranks[0]['rank'], 1);
|
||||
expect(ranks[1]['rank'], 1);
|
||||
expect(ranks[2]['rank'], 3);
|
||||
});
|
||||
|
||||
test('team ranking sums member totals and team handicap', () {
|
||||
final team = PublicTeam.fromJson({
|
||||
'teamId': 'T1',
|
||||
'teamNumber': 1,
|
||||
'handicap': 5,
|
||||
'members': [
|
||||
{
|
||||
'participantId': '1',
|
||||
'name': 'A',
|
||||
'scores': [200, {'score': 150, 'handicap': 10}],
|
||||
},
|
||||
{
|
||||
'participantId': '2',
|
||||
'name': 'B',
|
||||
'scores': [100, 120],
|
||||
},
|
||||
]
|
||||
}, gameCount: 2);
|
||||
final ranked = PublicRankingUtils.teamRanking([team]);
|
||||
// member1: 200 + min(150+10,300)=160 => 360
|
||||
// member2: 100 + 120 = 220
|
||||
// team total = 360 + 220 + teamHandicap(5) = 585
|
||||
expect(ranked[0]['total'], 585);
|
||||
expect(ranked[0]['rank'], 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lanebow/screens/public_event/public_event_entry_screen.dart';
|
||||
import 'package:lanebow/screens/public_event/public_event_screen.dart';
|
||||
import 'package:lanebow/services/public_event_service.dart';
|
||||
|
||||
DioException dio404(String path) => DioException(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
response: Response(requestOptions: RequestOptions(path: path), statusCode: 404),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
|
||||
class Stub404Service extends PublicEventService {
|
||||
@override
|
||||
Future<Map<String, dynamic>> fetchEvent(String publicHash) async {
|
||||
throw dio404('/api/public/' + publicHash);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('shows 404 message and go to entry button navigates back', (tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: PublicEventScreen(publicHash: 'nope', service: Stub404Service()),
|
||||
));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('이벤트를 찾을 수 없습니다'), findsOneWidget);
|
||||
expect(find.byKey(const Key('go_entry_button')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('go_entry_button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PublicEventEntryScreen), findsOneWidget);
|
||||
expect(find.text('공개 이벤트 입장'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lanebow/screens/public_event/public_event_entry_screen.dart';
|
||||
import 'package:lanebow/screens/public_event/public_event_screen.dart';
|
||||
import 'package:lanebow/services/public_event_service.dart';
|
||||
|
||||
DioException dio401(String path) => DioException(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
response: Response(requestOptions: RequestOptions(path: path), statusCode: 401),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
|
||||
class StubFetch401Service extends PublicEventService {
|
||||
@override
|
||||
Future<Map<String, dynamic>> fetchEvent(String publicHash) async {
|
||||
throw dio401('/api/public/' + publicHash);
|
||||
}
|
||||
}
|
||||
|
||||
class StubUpdate401Service extends PublicEventService {
|
||||
@override
|
||||
Future<Map<String, dynamic>> fetchEvent(String publicHash) async {
|
||||
return {
|
||||
'event': {'id': 'E1', 'title': '테스트 이벤트', 'gameCount': 3, 'status': 'active'},
|
||||
'participants': [
|
||||
{
|
||||
'participantId': 'p1',
|
||||
'name': 'Kim',
|
||||
'scores': [100, null, null],
|
||||
'handicap': 0,
|
||||
},
|
||||
],
|
||||
'teams': [],
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateScore(String publicHash, {required String participantId, required int gameNumber, required int score, int? handicap, dynamic teamNumber}) async {
|
||||
throw dio401('/api/public/' + publicHash + '/score');
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('401 on fetchEvent navigates to entry screen', (tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: PublicEventScreen(publicHash: 'abc', service: StubFetch401Service()),
|
||||
));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(PublicEventEntryScreen), findsOneWidget);
|
||||
expect(find.text('공개 이벤트 입장'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('401 on updateScore navigates to entry screen', (tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: PublicEventScreen(publicHash: 'abc', service: StubUpdate401Service()),
|
||||
));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('participant_tile_p1')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('participant_tile_p1')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byKey(const Key('score_input')), '150');
|
||||
await tester.tap(find.byKey(const Key('score_save_button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PublicEventEntryScreen), findsOneWidget);
|
||||
expect(find.text('공개 이벤트 입장'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lanebow/screens/public_event/public_event_entry_screen.dart';
|
||||
import 'package:lanebow/services/public_event_service.dart';
|
||||
|
||||
class StubSuccessService extends PublicEventService {
|
||||
@override
|
||||
Future<Map<String, dynamic>> enter(String publicHash, {String? password}) async {
|
||||
// 즉시 성공(토큰 포함)
|
||||
return {'token': 'TKN', 'event': {'id': 'E1'}};
|
||||
}
|
||||
}
|
||||
|
||||
class StubNeedPasswordThenSuccessService extends PublicEventService {
|
||||
bool first = true;
|
||||
@override
|
||||
Future<Map<String, dynamic>> enter(String publicHash, {String? password}) async {
|
||||
if (first) {
|
||||
first = false;
|
||||
throw Exception('401 need password');
|
||||
}
|
||||
if (password == 'pw') {
|
||||
return {'token': 'TKN2', 'event': {'id': 'E2'}};
|
||||
}
|
||||
throw Exception('wrong password');
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('auto enter success navigates to PublicEventScreen', (tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: PublicEventEntryScreen(publicHash: 'auto', service: StubSuccessService()),
|
||||
));
|
||||
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('공개 이벤트'), findsOneWidget);
|
||||
expect(find.text('공개 이벤트 입장'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('needs password then success on submit', (tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: PublicEventEntryScreen(publicHash: 'needpw', service: StubNeedPasswordThenSuccessService()),
|
||||
));
|
||||
|
||||
// 초기 자동 진입 실패 → 비밀번호 UI 노출
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('public_password_field')), findsOneWidget);
|
||||
|
||||
// 비밀번호 입력 후 제출
|
||||
await tester.enterText(find.byKey(const Key('public_password_field')), 'pw');
|
||||
await tester.tap(find.byKey(const Key('public_enter_submit')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('공개 이벤트'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lanebow/screens/public_event/public_event_screen.dart';
|
||||
import 'package:lanebow/services/public_event_service.dart';
|
||||
|
||||
class StubFinalService extends PublicEventService {
|
||||
@override
|
||||
Future<Map<String, dynamic>> fetchEvent(String publicHash) async {
|
||||
return {
|
||||
'event': {'id': 'E1', 'title': '최종 결과 테스트', 'gameCount': 3, 'status': 'completed'},
|
||||
'participants': [
|
||||
{
|
||||
'participantId': 'p1',
|
||||
'name': 'Kim',
|
||||
'scores': [140, 150, 0],
|
||||
'handicap': 0,
|
||||
},
|
||||
{
|
||||
'participantId': 'p2',
|
||||
'name': 'Lee',
|
||||
'scores': [200, 100, 0],
|
||||
'handicap': 0,
|
||||
},
|
||||
{
|
||||
'participantId': 'p3',
|
||||
'name': 'Park',
|
||||
'scores': [200, 100, 0],
|
||||
'handicap': 0,
|
||||
},
|
||||
],
|
||||
'teams': [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('완료 상태에서 최종 결과 섹션 렌더와 동순위 처리, 평균 1자리', (tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: PublicEventScreen(publicHash: 'H', service: StubFinalService()),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 최종 결과 섹션 존재 (스크롤 필요 시 스크롤)
|
||||
final scrollable = find.byType(Scrollable).first;
|
||||
await tester.scrollUntilVisible(find.byKey(const Key('section_final_results')), 300, scrollable: scrollable);
|
||||
expect(find.byKey(const Key('section_final_results')), findsOneWidget);
|
||||
|
||||
// 각 행 존재
|
||||
expect(find.byKey(const Key('result_row_p1')), findsOneWidget);
|
||||
expect(find.byKey(const Key('result_row_p2')), findsOneWidget);
|
||||
expect(find.byKey(const Key('result_row_p3')), findsOneWidget);
|
||||
|
||||
// 총점: p2/p3=300, p1=300 (동률), 동순위 1,1,3
|
||||
expect(find.byKey(const Key('result_rank_p2')), findsOneWidget);
|
||||
expect(find.byKey(const Key('result_rank_p3')), findsOneWidget);
|
||||
expect(find.byKey(const Key('result_rank_p1')), findsOneWidget);
|
||||
|
||||
final r2 = tester.widget<Text>(find.byKey(const Key('result_rank_p2'))).data;
|
||||
final r3 = tester.widget<Text>(find.byKey(const Key('result_rank_p3'))).data;
|
||||
final r1 = tester.widget<Text>(find.byKey(const Key('result_rank_p1'))).data;
|
||||
|
||||
expect(r2, '1');
|
||||
expect(r3, '1');
|
||||
expect(r1, '3');
|
||||
|
||||
// 평균 표기 1자리 확인: 0점도 입력으로 카운트됨 → p2: (200+100+0)/3 = 100.0
|
||||
expect(find.textContaining('평균 100.0'), findsWidgets);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lanebow/screens/public_event/public_event_screen.dart';
|
||||
import 'package:lanebow/services/public_event_service.dart';
|
||||
|
||||
class StubInlineService extends PublicEventService {
|
||||
int fetchCount = 0;
|
||||
int updateCount = 0;
|
||||
final List<Map<String, dynamic>> calls = [];
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> fetchEvent(String publicHash) async {
|
||||
fetchCount++;
|
||||
return {
|
||||
'event': {'id': 'E1', 'title': '인라인 저장 테스트', 'gameCount': 3, 'status': 'active'},
|
||||
'participants': [
|
||||
{
|
||||
'participantId': 'p1',
|
||||
'name': 'Kim',
|
||||
'scores': [null, null, null],
|
||||
'handicap': 0,
|
||||
},
|
||||
],
|
||||
'teams': [],
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateScore(String publicHash, {required String participantId, required int gameNumber, required int score, int? handicap, teamNumber}) async {
|
||||
updateCount++;
|
||||
calls.add({'pid': participantId, 'g': gameNumber, 's': score});
|
||||
// simulate small delay
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1));
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('3자리 입력 즉시 저장, 디바운스 저장, 범위 밖 값은 저장 안함', (tester) async {
|
||||
final stub = StubInlineService();
|
||||
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: PublicEventScreen(publicHash: 'H', service: stub),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// cell p1 game2 존재 확인
|
||||
final cellG2 = find.byKey(const Key('cell_p1_2'));
|
||||
expect(cellG2, findsOneWidget);
|
||||
|
||||
// 3자리 입력 즉시 저장
|
||||
await tester.enterText(cellG2, '150');
|
||||
// allow microtasks
|
||||
await tester.pump(const Duration(milliseconds: 10));
|
||||
expect(stub.updateCount, 1);
|
||||
expect(stub.calls.last['s'], 150);
|
||||
|
||||
// 1자리 입력 후 디바운스 200ms 후 저장
|
||||
await tester.enterText(cellG2, '2');
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
expect(stub.updateCount, 2);
|
||||
expect(stub.calls.last['s'], 2);
|
||||
|
||||
// 범위 밖 값은 저장 안함 (350)
|
||||
await tester.enterText(cellG2, '350');
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
expect(stub.updateCount, 2);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lanebow/screens/public_event/public_event_screen.dart';
|
||||
import 'package:lanebow/services/public_event_service.dart';
|
||||
|
||||
class StubJoinService extends PublicEventService {
|
||||
int fetchCount = 0;
|
||||
bool addGuestCalled = false;
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> fetchEvent(String publicHash) async {
|
||||
fetchCount++;
|
||||
return {
|
||||
'event': {'id': 'E1', 'title': 'Join 테스트', 'gameCount': 3, 'status': 'ready'},
|
||||
'participants': [
|
||||
{
|
||||
'participantId': 'p1',
|
||||
'name': 'Kim',
|
||||
'scores': [null, null, null],
|
||||
'handicap': 0,
|
||||
},
|
||||
],
|
||||
'teams': [],
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addGuest(String publicHash, {required String name, String? phone, String? gender, num? average}) async {
|
||||
addGuestCalled = true;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('join button opens dialog, saves, and reloads (fetch called twice)', (tester) async {
|
||||
final stub = StubJoinService();
|
||||
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: PublicEventScreen(publicHash: 'HASH', service: stub),
|
||||
));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(stub.fetchCount, 1);
|
||||
expect(find.byKey(const Key('join_button')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('join_button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('참가 신청(게스트)'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byKey(const Key('join_name_input')), 'New Guest');
|
||||
await tester.tap(find.byKey(const Key('join_save_button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(stub.addGuestCalled, isTrue);
|
||||
// reload triggered
|
||||
expect(stub.fetchCount, 2);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lanebow/main.dart' as app;
|
||||
|
||||
void main() {
|
||||
testWidgets('navigates to PublicEventEntryScreen with /p/:hash', (tester) async {
|
||||
// MyApp은 내부에서 routes/onGenerateRoute를 정의하므로 직접 runApp
|
||||
await tester.pumpWidget(const app.MyApp());
|
||||
|
||||
// 초기 홈이 뜬 후, 라우트 푸시
|
||||
final nav = app.navigatorKey.currentState!;
|
||||
nav.pushNamed('/p/abc123');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('공개 이벤트 입장'), findsOneWidget);
|
||||
expect(find.textContaining('abc123'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lanebow/screens/public_event/public_event_screen.dart';
|
||||
import 'package:lanebow/services/public_event_service.dart';
|
||||
|
||||
class StubColorService extends PublicEventService {
|
||||
@override
|
||||
Future<Map<String, dynamic>> fetchEvent(String publicHash) async {
|
||||
return {
|
||||
'event': {'id': 'E1', 'title': '색상/상태 테스트', 'gameCount': 3, 'status': 'active'},
|
||||
'participants': [
|
||||
{
|
||||
'participantId': 'p1',
|
||||
'name': 'Kim',
|
||||
'scores': [null, null, null],
|
||||
'handicap': 0,
|
||||
},
|
||||
],
|
||||
'teams': [],
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateScore(String publicHash, {required String participantId, required int gameNumber, required int score, int? handicap, teamNumber}) async {
|
||||
// no-op
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1));
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('200 입력 시 색상 빨강으로 표시', (tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: PublicEventScreen(publicHash: 'H', service: StubColorService()),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 초기에는 회색 점
|
||||
final colorDot = find.byKey(const Key('color_p1_1'));
|
||||
expect(colorDot, findsOneWidget);
|
||||
|
||||
// 200 입력
|
||||
final cell = find.byKey(const Key('cell_p1_1'));
|
||||
await tester.enterText(cell, '200');
|
||||
await tester.pump(const Duration(milliseconds: 20));
|
||||
|
||||
// 색상 컨테이너를 가져와서 색상을 확인
|
||||
final container = tester.widget<Container>(colorDot);
|
||||
final deco = container.decoration as BoxDecoration;
|
||||
expect((deco.color as Color), equals(Colors.red));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lanebow/screens/public_event/public_event_screen.dart';
|
||||
import 'package:lanebow/services/public_event_service.dart';
|
||||
|
||||
class StubDialogService extends PublicEventService {
|
||||
String? lastHash;
|
||||
String? lastParticipantId;
|
||||
int? lastGameNumber;
|
||||
int? lastScore;
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> fetchEvent(String publicHash) async {
|
||||
return {
|
||||
'event': {'id': 'E1', 'title': '다이얼로그 테스트', 'gameCount': 3, 'status': 'active'},
|
||||
'participants': [
|
||||
{
|
||||
'participantId': 'p1',
|
||||
'name': 'Kim',
|
||||
'scores': [null, null, null],
|
||||
'handicap': 0,
|
||||
},
|
||||
],
|
||||
'teams': [],
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateScore(String publicHash, {required String participantId, required int gameNumber, required int score, int? handicap, dynamic teamNumber}) async {
|
||||
lastHash = publicHash;
|
||||
lastParticipantId = participantId;
|
||||
lastGameNumber = gameNumber;
|
||||
lastScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('game select and validation works in score dialog', (tester) async {
|
||||
final stub = StubDialogService();
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: PublicEventScreen(publicHash: 'hash-validate', service: stub),
|
||||
));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('participant_tile_p1')), findsOneWidget);
|
||||
|
||||
// Open dialog
|
||||
await tester.tap(find.byKey(const Key('participant_tile_p1')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Select game 2
|
||||
await tester.tap(find.byKey(const Key('game_select')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('게임 2').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Enter invalid value
|
||||
await tester.enterText(find.byKey(const Key('score_input')), '350');
|
||||
await tester.tap(find.byKey(const Key('score_save_button')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('0~300'), findsOneWidget);
|
||||
|
||||
// Enter valid value
|
||||
await tester.enterText(find.byKey(const Key('score_input')), '180');
|
||||
await tester.tap(find.byKey(const Key('score_save_button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Saved and called with game 2
|
||||
expect(stub.lastHash, 'hash-validate');
|
||||
expect(stub.lastParticipantId, 'p1');
|
||||
expect(stub.lastGameNumber, 2);
|
||||
expect(stub.lastScore, 180);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lanebow/screens/public_event/public_event_screen.dart';
|
||||
import 'package:lanebow/services/public_event_service.dart';
|
||||
|
||||
class StubScoreService extends PublicEventService {
|
||||
String? lastHash;
|
||||
String? lastParticipantId;
|
||||
int? lastGameNumber;
|
||||
int? lastScore;
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> fetchEvent(String publicHash) async {
|
||||
return {
|
||||
'event': {'id': 'E1', 'title': '테스트 이벤트', 'gameCount': 3, 'status': 'active'},
|
||||
'participants': [
|
||||
{
|
||||
'participantId': 'p1',
|
||||
'name': 'Kim',
|
||||
'scores': [100, null, null],
|
||||
'handicap': 0,
|
||||
},
|
||||
],
|
||||
'teams': [],
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateScore(String publicHash, {required String participantId, required int gameNumber, required int score, int? handicap, dynamic teamNumber}) async {
|
||||
lastHash = publicHash;
|
||||
lastParticipantId = participantId;
|
||||
lastGameNumber = gameNumber;
|
||||
lastScore = score;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('tap participant -> edit game1 score -> calls updateScore and shows snackbar', (tester) async {
|
||||
final stub = StubScoreService();
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: PublicEventScreen(publicHash: 'hash123', service: stub),
|
||||
));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('participant_tile_p1')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('participant_tile_p1')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 입력 후 저장
|
||||
await tester.enterText(find.byKey(const Key('score_input')), '199');
|
||||
await tester.tap(find.byKey(const Key('score_save_button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 스낵바 문구 확인
|
||||
expect(find.text('점수가 저장되었습니다'), findsOneWidget);
|
||||
|
||||
// 서비스 호출 확인
|
||||
expect(stub.lastHash, 'hash123');
|
||||
expect(stub.lastParticipantId, 'p1');
|
||||
expect(stub.lastGameNumber, 1);
|
||||
expect(stub.lastScore, 199);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lanebow/screens/public_event/public_event_screen.dart';
|
||||
import 'package:lanebow/services/public_event_service.dart';
|
||||
|
||||
class StubFetchService extends PublicEventService {
|
||||
@override
|
||||
Future<Map<String, dynamic>> fetchEvent(String publicHash) async {
|
||||
return {
|
||||
'event': {'id': 'E100', 'title': '오픈 리그'},
|
||||
'participants': [
|
||||
{'participantId': '1', 'name': 'Kim'},
|
||||
{'participantId': '2', 'name': 'Lee'},
|
||||
],
|
||||
'teams': [
|
||||
{'teamId': 'T1'},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('renders title and counts from fetchEvent', (tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: PublicEventScreen(publicHash: 'abc', service: StubFetchService()),
|
||||
));
|
||||
|
||||
// initial loading
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('event_title')), findsOneWidget);
|
||||
expect(find.text('오픈 리그'), findsOneWidget);
|
||||
expect(find.byKey(const Key('participants_count')), findsOneWidget);
|
||||
final pc = tester.widget<Text>(find.byKey(const Key('participants_count')));
|
||||
expect(pc.data, '총 2명');
|
||||
expect(find.byKey(const Key('teams_count')), findsOneWidget);
|
||||
expect(find.text('총 1팀'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lanebow/screens/public_event/public_event_screen.dart';
|
||||
import 'package:lanebow/services/public_event_service.dart';
|
||||
|
||||
class StubStateService extends PublicEventService {
|
||||
final String status;
|
||||
StubStateService(this.status);
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> fetchEvent(String publicHash) async {
|
||||
return {
|
||||
'event': {'id': 'E1', 'title': '상태 테스트', 'gameCount': 3, 'status': status},
|
||||
'participants': [
|
||||
{
|
||||
'participantId': 'p1',
|
||||
'name': 'Kim',
|
||||
'scores': [null, null, null],
|
||||
'handicap': 0,
|
||||
},
|
||||
],
|
||||
'teams': [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('ready: shows join button, blocks score dialog', (tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: PublicEventScreen(publicHash: 'H', service: StubStateService('ready')),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('join_button')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('participant_tile_p1')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// dialog should NOT appear, snackbar appears
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
expect(find.textContaining('불가'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('active: shows join button, allows score dialog', (tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: PublicEventScreen(publicHash: 'H', service: StubStateService('active')),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('join_button')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('participant_tile_p1')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AlertDialog), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('completed: hides join button, blocks score dialog', (tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: PublicEventScreen(publicHash: 'H', service: StubStateService('completed')),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('join_button')), findsNothing);
|
||||
|
||||
await tester.tap(find.byKey(const Key('participant_tile_p1')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
expect(find.textContaining('불가'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lanebow/screens/public_event/public_event_screen.dart';
|
||||
import 'package:lanebow/services/public_event_service.dart';
|
||||
|
||||
class StubTeamsService extends PublicEventService {
|
||||
@override
|
||||
Future<Map<String, dynamic>> fetchEvent(String publicHash) async {
|
||||
return {
|
||||
'event': {'id': 'E1', 'title': '팀/미배정 테스트', 'gameCount': 3, 'status': 'active'},
|
||||
'participants': [
|
||||
{
|
||||
'participantId': 'p1',
|
||||
'name': 'Kim',
|
||||
'scores': [100, 100, 100],
|
||||
'handicap': 0,
|
||||
},
|
||||
{
|
||||
'participantId': 'p2',
|
||||
'name': 'Lee',
|
||||
'scores': [120, 110, 90],
|
||||
'handicap': 0,
|
||||
},
|
||||
],
|
||||
'teams': [
|
||||
{
|
||||
'teamId': 'T1',
|
||||
'teamNumber': 1,
|
||||
'handicap': 0,
|
||||
'members': [
|
||||
{
|
||||
'participantId': 'p1',
|
||||
'name': 'Kim',
|
||||
'scores': [100, 100, 100],
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('팀 순위 배지와 미배정 목록 렌더', (tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: PublicEventScreen(publicHash: 'H', service: StubTeamsService()),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 팀 섹션 (스크롤 필요 시 스크롤)
|
||||
final scrollable = find.byType(Scrollable).first;
|
||||
await tester.scrollUntilVisible(find.byKey(const Key('section_teams')), 300, scrollable: scrollable);
|
||||
expect(find.byKey(const Key('section_teams')), findsOneWidget);
|
||||
expect(find.byKey(const Key('team_rank_1')), findsOneWidget);
|
||||
|
||||
// 미배정: p2가 표시되어야 함 (필요 시 더 스크롤)
|
||||
await tester.scrollUntilVisible(find.byKey(const Key('section_unassigned')), 300, scrollable: scrollable);
|
||||
expect(find.byKey(const Key('section_unassigned')), findsOneWidget);
|
||||
expect(find.byKey(const Key('unassigned_count')), findsOneWidget);
|
||||
await tester.scrollUntilVisible(find.byKey(const Key('unassigned_p2')), 300, scrollable: scrollable);
|
||||
expect(find.byKey(const Key('unassigned_p2')), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lanebow/screens/public_event/public_event_screen.dart';
|
||||
import 'package:lanebow/services/public_event_service.dart';
|
||||
|
||||
class StubTotalsService extends PublicEventService {
|
||||
@override
|
||||
Future<Map<String, dynamic>> fetchEvent(String publicHash) async {
|
||||
return {
|
||||
'event': {'id': 'E1', 'title': '합계/평균 테스트', 'gameCount': 3},
|
||||
'participants': [
|
||||
{
|
||||
'participantId': 'p1',
|
||||
'name': 'Kim',
|
||||
'handicap': 10,
|
||||
'scores': [100, 120, 130], // per-game hc 없음 → 기본 hc=10 적용
|
||||
},
|
||||
{
|
||||
'participantId': 'p2',
|
||||
'name': 'Lee',
|
||||
'handicap': 0,
|
||||
'scores': [200, null, 50], // null은 미집계
|
||||
}
|
||||
],
|
||||
'teams': [
|
||||
{
|
||||
'teamId': 'T1',
|
||||
'teamNumber': 1,
|
||||
'handicap': 0,
|
||||
'members': [
|
||||
{
|
||||
'participantId': 'm1',
|
||||
'name': 'A',
|
||||
'scores': [100, 100, 100]
|
||||
},
|
||||
{
|
||||
'participantId': 'm2',
|
||||
'name': 'B',
|
||||
'scores': [50, null, 50]
|
||||
},
|
||||
]
|
||||
}
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('renders participant totals and team total/avg', (tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: PublicEventScreen(publicHash: 'hash-totals', service: StubTotalsService()),
|
||||
));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 참가자 합계 검증
|
||||
// Kim: (100+10) + (120+10) + (130+10) = 380 → 300 cap 없음 → 380
|
||||
expect(find.textContaining('총 380'), findsOneWidget);
|
||||
// Lee: (200+0) + (50+0) = 250
|
||||
expect(find.textContaining('총 250'), findsOneWidget);
|
||||
|
||||
// 팀 합계/평균 검증
|
||||
// team members total: A:300, B:100 → 합 400, 팀 hc 0 → 총 400, 인원2 → 평균 200.0
|
||||
expect(find.textContaining('총 400'), findsOneWidget);
|
||||
expect(find.textContaining('평균 200.0'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -672,3 +672,104 @@ void main() {
|
||||
### 진행 현황 업데이트 (2025-08-25T08:37:56+09:00)
|
||||
- __플랜 갱신__: 오늘 작업 계획 수립 및 체크리스트/Current Goal 날짜 갱신.
|
||||
- __테스트 전략 유지__: 전체 스위트 반복 실행으로 플래키 재발 방지 계획 확정.
|
||||
|
||||
{{ ... }}
|
||||
- [ ] 품질: 전체 테스트/린트 실행 및 그린 유지
|
||||
- [ ] 품질: 전체 스위트 2~3회 반복 실행으로 플래키 재발 여부 점검(`flutter test -r expanded` 비교 실행 포함)
|
||||
- [ ] 품질: 남은 린트 경고 정리 PR 분리 제출
|
||||
|
||||
### 진행 현황 업데이트 (2025-08-25T08:37:56+09:00)
|
||||
- __플랜 갱신__: 오늘 작업 계획 수립 및 체크리스트/Current Goal 날짜 673→- __테스트 전략 유지__: 전체 스위트 반복 실행으로 플래키 재발 방지 계획 확정.
|
||||
674→
|
||||
675→
|
||||
### 진행 현황 업데이트 (2025-10-27T13:49:00+09:00)
|
||||
- __결정__: Vue 공개 이벤트 페이지(EventPublicPage.vue)를 Flutter Web로 이전한다.
|
||||
- __백엔드 확인__: 공개 API 엔드포인트 정리 완료(`backend/routes/publicEventRoutes.js` 기준)
|
||||
- POST `/api/public/:publicHash` (최초 진입/비밀번호 또는 저장 토큰 인증)
|
||||
- POST `/api/public/:publicHash/participant` (공개 참가자 등록/수정)
|
||||
- POST `/api/public/:publicHash/guest` (공개 게스트 추가)
|
||||
- PUT `/api/public/:publicHash/score` (공개 점수 수정)
|
||||
- __웹 전역 레이아웃__: `mobile/lib/main.dart`에 전역 maxWidth(1200) 제한 및 ThemeMode.light 고정 적용 완료.
|
||||
|
||||
## Current Goal (as of 2025-10-27)
|
||||
공개 이벤트 페이지 Flutter 이전(웹 우선) — 인증/참가자/게스트/점수 기능을 MVP로 이전하고, 반응형 레이아웃과 접근성, 테스트를 포함해 안정적인 배포가 가능하도록 한다.
|
||||
|
||||
## 통합 작업 체크리스트 (as of 2025-10-27T13:49:00+09:00)
|
||||
- [ ] 라우팅 구성: `/p/:publicHash` 경로 처리(딥링크/새로고침 대응)
|
||||
- [ ] 서비스 신설: `PublicEventService`(enter/addOrUpdateParticipant/addGuest/updateScore)
|
||||
- [ ] 모델 정의: `PublicEvent`/`PublicParticipant`/`PublicScore`
|
||||
- [ ] 진입 화면: `PublicEventEntryScreen` (비번 입력/토큰 저장/자동 인증)
|
||||
- [ ] 상세 화면: `PublicEventScreen` (헤더/참가자/미배정/점수 그리드/저장)
|
||||
- [ ] 참가자 등록/수정 모달 구현(필수값/유효성/저장 후 갱신)
|
||||
- [ ] 게스트 추가 모달 구현(기본값: status=registered, payment=unpaid)
|
||||
- [ ] 점수 입력/검증/저장(0~300, gameCount 동기화)
|
||||
- [ ] 로딩/빈/에러 상태 분기(스낵바/리트라이)
|
||||
- [ ] 토큰 스토리지 래퍼(web: localStorage) 도입 및 보안 가이드 적용
|
||||
- [ ] 반응형 레이아웃(테이블 가로 스크롤/카드 전환) 점검
|
||||
- [ ] 접근성/로컬라이징(ko_KR) 적용 및 날짜 포맷 통일
|
||||
- [ ] 위젯 테스트(Entry/Screen/모달/점수) 추가 및 그린 확보
|
||||
- [ ] 서비스 테스트(각 API 성공/실패/예외) 추가 및 그린 확보
|
||||
- [ ] 통합 시나리오(인증→로드→참가자/게스트→점수 저장) 수동/E2E 검증
|
||||
|
||||
## 공개 이벤트 페이지 Flutter 이전: 기능명세서
|
||||
- **[페이지 진입/인증]**
|
||||
- 비밀번호 입력 → POST `/api/public/:publicHash` → 토큰 저장 후 이벤트 데이터 로드
|
||||
- 저장된 토큰 자동 인증, 실패(401/403) 시 토큰 삭제 및 재시도 유도
|
||||
- **[이벤트 정보]**
|
||||
- 제목/설명/유형/상태/일정/장소/게임 수/정원 등 표시, 날짜 포맷 `YYYY-MM-DD 오전/오후 hh:mm`
|
||||
- **[참가자/미배정]**
|
||||
- 참가자 목록(이름/성별/유형/상태/결제/핸디캡/평균/게임)
|
||||
- 미배정 인원 목록 표시(응답 그대로)
|
||||
- 참가자 등록/수정, 게스트 추가 폼 및 저장 후 갱신
|
||||
- **[점수 관리]**
|
||||
- `gameCount` 기반 총점 입력 그리드, 0~300 검증, 저장(put `/score`)
|
||||
- 저장 중 로딩/성공/오류 피드백
|
||||
- **[상태/오류]**
|
||||
- 로딩/빈/에러 분기, 스낵바/리트라이, 인증 만료 시 재인증 유도
|
||||
- **[보안/네트워크]**
|
||||
- HTTPS, 동일 오리진(`/api`) 호출, 민감정보 미노출
|
||||
|
||||
## 공개 이벤트 페이지 Flutter 이전: 화면설계서
|
||||
- **[PublicEventEntryScreen]**
|
||||
- 요소: 비밀번호 입력, 접속 버튼, 로딩/오류 메시지
|
||||
- 동작: 저장 토큰 자동 인증 → 성공 시 상세 이동
|
||||
- **[PublicEventScreen]**
|
||||
- 헤더: 제목, 일정, 장소, `gameCount` 배지
|
||||
- 섹션: 참가자 리스트, 미배정 리스트, 점수 입력(그리드) + 저장 버튼
|
||||
- 모달: 참가자 등록/수정, 게스트 추가
|
||||
- 반응형: 최대폭 1200, 모바일 카드형 전환/가로 스크롤 허용
|
||||
|
||||
## 공개 이벤트 페이지 Flutter 이전: 테스트 계획
|
||||
- **[서비스 테스트]** `PublicEventService`
|
||||
- 진입 API: 200(토큰 저장)/401·403(토큰 삭제)·타임아웃
|
||||
- 참가자/게스트/점수: 성공/실패/예외 전파, 상태 복구 검증
|
||||
- **[위젯 테스트]**
|
||||
- Entry: 비번 입력→제출→성공 라우팅, 실패 메시지
|
||||
- Screen: 헤더 렌더, 참가자/미배정 렌더, 모달(열기/저장/유효성), 점수 입력(범위/저장 성공/실패)
|
||||
- **[통합/회귀]**
|
||||
- 인증→로드→참가자 추가→점수 저장→재로딩 시 반영 확인, 새로고침/딥링크 동작
|
||||
|
||||
## 공개 이벤트 페이지 Flutter 이전: 누락 기능 보강 목록 (EventPublicPage.vue 기준 → Flutter 매핑)
|
||||
- **[인증/스토리지]** 저장 토큰(localStorage) 래퍼 구현, 401/403 시 토큰 삭제·재인증 유도.
|
||||
- **[권한 로직]** `isReady/isActive/isFinished` 및 `canJoin/canEditParticipant/canInputScore` 계산자 도입.
|
||||
- **[참가 신청]** `availableMembers` 활용한 참가 신청/수정 UI(JoinDialog 동등) 및 성공 후 `reload()` 처리.
|
||||
- **[점수 입력 UX]** 200ms 디바운스 저장, 입력 3자리 시 자동 저장(onScoreInput), blur/enter 저장, 상태 아이콘(`idle/saving/saved/error`).
|
||||
- **[점수 색상 규칙]** 핸디캡 포함 점수 기준 200이상 빨강, 그 외 검정/회색.
|
||||
- **[점수 한도]** 핸디캡 포함 점수 300 상한 적용.
|
||||
- **[순위 계산]** 개인 총점/게임별/팀별 순위(공동 순위 처리) 유틸 포팅 및 `memberRankMap/gameRanks` 반영.
|
||||
- **[팀/미배정]** `getAllTeamsWithUnassigned()` 로직 반영, 팀 카드 요약(게임별/총점/등수) 포함.
|
||||
- **[최종 순위표]** 완료 상태에서 테이블 렌더(정렬/페이지네이션 유사 UX), 평균 소수 1자리.
|
||||
- **[데이터 보정]** `sortedParticipants`에서 점수 배열 길이 `gameCount`로 보정, 이름 정렬(ko).
|
||||
- **[상태 분기]** 로딩/에러/빈 상태 UI와 404 문구, 재시도 버튼.
|
||||
- **[날짜/통화]** `YYYY-MM-DD 오전/오후 hh:mm` 포맷 및 참가비 통화 포맷 ko-KR.
|
||||
- **[반응형]** 큰 화면 DataTable은 가로 스크롤, 모바일은 카드 전환. 전역 `maxWidth=1200` 유지.
|
||||
- **[메타/분석]** GA pageview(웹 한정), SEO 메타는 서버 템플릿에서 대응(Flutter Web 한계 안내).
|
||||
|
||||
### 다음 작업 (TDD 우선)
|
||||
- **[서비스]** `PublicEventService` 스텁/테스트 생성 → 진입(200/401/403/404)·참가자·게스트·점수 저장 성공/실패/예외.
|
||||
- **[모델]** `PublicEvent/PublicParticipant/Team/Score` 파서 테스트(누락 키/타입 견고성).
|
||||
- **[유틸]** 랭킹/합산 유틸 테스트(동점자 처리/300 상한/게임별·팀별 순위).
|
||||
- **[스토리지]** web localStorage 래퍼 테스트(세트/겟/삭제, 네임스페이스 event_token_<hash>).
|
||||
- **[라우팅]** `/p/:publicHash` 경로 파라미터 테스트(새로고침/딥링크).
|
||||
- **[위젯]** EntryScreen(비번 제출 성공/실패), EventScreen(헤더/참가자/팀/미배정/점수/저장/에러), 모달(유효성/저장/갱신) 테스트.
|
||||
- **[회귀]** 저장 후 순위/색상/상태 아이콘 업데이트, `reload()` 재로딩 반영.
|
||||
|
||||
Reference in New Issue
Block a user