diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 2bdd26a..3985993 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -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 navigatorKey = GlobalKey(); @@ -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; + }, ); }, ), diff --git a/mobile/lib/models/public_event.dart b/mobile/lib/models/public_event.dart new file mode 100644 index 0000000..ef452aa --- /dev/null +++ b/mobile/lib/models/public_event.dart @@ -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 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'); +} diff --git a/mobile/lib/models/public_participant.dart b/mobile/lib/models/public_participant.dart new file mode 100644 index 0000000..0990587 --- /dev/null +++ b/mobile/lib/models/public_participant.dart @@ -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 rawScores; + final List 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 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 src = (json['scores'] is List) ? (json['scores'] as List) : const []; + final List raw = List.filled(gameCount, null); + final List hcs = List.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, + ); + } +} diff --git a/mobile/lib/models/public_team.dart b/mobile/lib/models/public_team.dart new file mode 100644 index 0000000..447d737 --- /dev/null +++ b/mobile/lib/models/public_team.dart @@ -0,0 +1,80 @@ + +class PublicTeamMemberSummary { + final String? participantId; + final String? memberId; + final String name; + final List rawScores; + final List handicaps; + + PublicTeamMemberSummary({ + required this.participantId, + required this.memberId, + required this.name, + required this.rawScores, + required this.handicaps, + }); + + factory PublicTeamMemberSummary.fromJson(Map json, {int gameCount = 3}) { + final participantId = json['participantId']?.toString(); + final memberId = json['memberId']?.toString(); + final name = (json['name'] ?? '이름없음').toString(); + // 점수 포맷은 PublicParticipant와 동일 규칙 + final List src = (json['scores'] is List) ? (json['scores'] as List) : const []; + final List raw = List.filled(gameCount, null); + final List hcs = List.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 members; + + PublicTeam({ + required this.teamId, + required this.teamNumber, + required this.handicap, + required this.members, + }); + + factory PublicTeam.fromJson(Map 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 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(), gameCount: gameCount)).toList(), + ); + } +} diff --git a/mobile/lib/screens/public_event/join_dialog.dart b/mobile/lib/screens/public_event/join_dialog.dart new file mode 100644 index 0000000..c670a24 --- /dev/null +++ b/mobile/lib/screens/public_event/join_dialog.dart @@ -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 createState() => _JoinDialogState(); +} + +class _JoinDialogState extends State { + final _formKey = GlobalKey(); + final _nameCtrl = TextEditingController(); + final _phoneCtrl = TextEditingController(); + final _avgCtrl = TextEditingController(); + bool _saving = false; + + @override + void dispose() { + _nameCtrl.dispose(); + _phoneCtrl.dispose(); + _avgCtrl.dispose(); + super.dispose(); + } + + Future _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('신청'), + ) + ], + ); + } +} diff --git a/mobile/lib/screens/public_event/public_event_entry_screen.dart b/mobile/lib/screens/public_event/public_event_entry_screen.dart new file mode 100644 index 0000000..8547c9a --- /dev/null +++ b/mobile/lib/screens/public_event/public_event_entry_screen.dart @@ -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 createState() => _PublicEventEntryScreenState(); +} + +class _PublicEventEntryScreenState extends State { + 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 _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('자동 인증 시도 중...'), + ], + ], + ), + ), + ); + } +} diff --git a/mobile/lib/screens/public_event/public_event_screen.dart b/mobile/lib/screens/public_event/public_event_screen.dart new file mode 100644 index 0000000..0afd6ea --- /dev/null +++ b/mobile/lib/screens/public_event/public_event_screen.dart @@ -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 createState() => _PublicEventScreenState(); +} + +class _PublicEventScreenState extends State { + late final PublicEventService _service = widget.service ?? PublicEventService(); + bool _loading = true; + String? _error; + PublicEvent? _eventModel; + List _participants = const []; + List _teams = const []; + final Map _scoreStates = {}; // key: pid-g -> idle|saving|saved|error + final Map _debouncers = {}; + final Map _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 _saveInlineScore(PublicParticipant p, int gameNumber, int score) async { + final key = _keyFor(p.participantId, gameNumber); + _scoreStates[key] = 'saving'; + setState(() {}); + try { + await _service.updateScore(widget.publicHash, participantId: p.participantId, gameNumber: gameNumber, score: score); + // 로컬 모델 즉시 갱신 + final idx = _participants.indexWhere((pp) => pp.participantId == p.participantId); + if (idx >= 0) { + final cur = _participants[idx]; + final newScores = List.from(cur.rawScores); + if (gameNumber >= 1 && gameNumber <= newScores.length) newScores[gameNumber - 1] = score; + _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 _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?>( + 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( + 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.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 _load() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final d = await _service.fetchEvent(widget.publicHash); + if (!mounted) return; + final eventJson = (d['event'] as Map?)?.cast() ?? {}; + final eventModel = PublicEvent.fromJson(eventJson); + final gc = eventModel.gameCount; + final parts = (d['participants'] is List) + ? (d['participants'] as List) + .whereType() + .map((e) => PublicParticipant.fromJson(e.cast(), gameCount: gc)) + .toList() + : []; + final teams = (d['teams'] is List) + ? (d['teams'] as List) + .whereType() + .map((e) => PublicTeam.fromJson(e.cast(), gameCount: gc)) + .toList() + : []; + + setState(() { + _eventModel = eventModel; + _participants = parts; + _teams = teams; + }); + } 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( + 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), + ); + }), + ], + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/mobile/lib/services/public_event_service.dart b/mobile/lib/services/public_event_service.dart new file mode 100644 index 0000000..fd2a52f --- /dev/null +++ b/mobile/lib/services/public_event_service.dart @@ -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> get(String path, {Map? headers}); + Future> post(String path, {dynamic data, Map? headers}); + Future> put(String path, {dynamic data, Map? 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 get(String path, {Map? headers}) { + return _dio.get(path, options: Options(headers: headers)); + } + + @override + Future post(String path, {data, Map? headers}) { + return _dio.post(path, data: data ?? {}, options: Options(headers: headers)); + } + + @override + Future put(String path, {data, Map? 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? _authHeader(String? token) { + if (token == null || token.isEmpty) return null; + return { 'Authorization': 'Bearer ' + token }; + } + + Future> 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; + return data; + } + + Future> 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; + // 응답에 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 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 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 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), + ); + } +} diff --git a/mobile/lib/utils/ranking_utils_public.dart b/mobile/lib/utils/ranking_utils_public.dart new file mode 100644 index 0000000..812a21a --- /dev/null +++ b/mobile/lib/utils/ranking_utils_public.dart @@ -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 ranksFor(List valuesDesc) { + if (valuesDesc.isEmpty) return const []; + final ranks = List.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> individualRanking(List 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((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 ranksForDoubles(List valuesDesc) { + if (valuesDesc.isEmpty) return const []; + final ranks = List.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> individualRankingByAvg(List 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((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> teamRanking(List 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((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> buildParticipantRankMap(List list) { + final ranked = individualRanking(list); + final map = >{}; + 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> buildTeamRankMap(List teams) { + final ranked = teamRanking(teams); + final map = >{}; + 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 unassignedParticipants(List participants, List teams) { + final assigned = {}; + 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(); + } +} diff --git a/mobile/lib/utils/web_storage.dart b/mobile/lib/utils/web_storage.dart new file mode 100644 index 0000000..55ae7b5 --- /dev/null +++ b/mobile/lib/utils/web_storage.dart @@ -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 _memory = {}; + + 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); + } +} diff --git a/mobile/test/models/public_models_test.dart b/mobile/test/models/public_models_test.dart new file mode 100644 index 0000000..f85fefd --- /dev/null +++ b/mobile/test/models/public_models_test.dart @@ -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]); + }); + }); +} diff --git a/mobile/test/services/public_event_service_test.dart b/mobile/test/services/public_event_service_test.dart new file mode 100644 index 0000000..829d7ca --- /dev/null +++ b/mobile/test/services/public_event_service_test.dart @@ -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? lastResponse; + Map? lastHeaders; + String? lastPath; + dynamic lastData; + + int statusCode = 200; + Map responseData = const {'ok': true}; + + @override + Future> get(String path, {Map? 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> post(String path, {data, Map? 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> put(String path, {data, Map? 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()), + ); + 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())); + 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()), + ); + }); + }); +} diff --git a/mobile/test/utils/public_ranking_utils_test.dart b/mobile/test/utils/public_ranking_utils_test.dart new file mode 100644 index 0000000..a75264c --- /dev/null +++ b/mobile/test/utils/public_ranking_utils_test.dart @@ -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); + }); + }); +} diff --git a/mobile/test/widgets/public_event_404_guard_test.dart b/mobile/test/widgets/public_event_404_guard_test.dart new file mode 100644 index 0000000..9e3fbf1 --- /dev/null +++ b/mobile/test/widgets/public_event_404_guard_test.dart @@ -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> 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); + }); +} diff --git a/mobile/test/widgets/public_event_auth_expire_test.dart b/mobile/test/widgets/public_event_auth_expire_test.dart new file mode 100644 index 0000000..b74391d --- /dev/null +++ b/mobile/test/widgets/public_event_auth_expire_test.dart @@ -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> fetchEvent(String publicHash) async { + throw dio401('/api/public/' + publicHash); + } +} + +class StubUpdate401Service extends PublicEventService { + @override + Future> 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 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); + }); +} diff --git a/mobile/test/widgets/public_event_entry_screen_test.dart b/mobile/test/widgets/public_event_entry_screen_test.dart new file mode 100644 index 0000000..fbdbe0a --- /dev/null +++ b/mobile/test/widgets/public_event_entry_screen_test.dart @@ -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> enter(String publicHash, {String? password}) async { + // 즉시 성공(토큰 포함) + return {'token': 'TKN', 'event': {'id': 'E1'}}; + } +} + +class StubNeedPasswordThenSuccessService extends PublicEventService { + bool first = true; + @override + Future> 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); + }); +} diff --git a/mobile/test/widgets/public_event_final_results_test.dart b/mobile/test/widgets/public_event_final_results_test.dart new file mode 100644 index 0000000..34d73b2 --- /dev/null +++ b/mobile/test/widgets/public_event_final_results_test.dart @@ -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> 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(find.byKey(const Key('result_rank_p2'))).data; + final r3 = tester.widget(find.byKey(const Key('result_rank_p3'))).data; + final r1 = tester.widget(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); + }); +} diff --git a/mobile/test/widgets/public_event_inline_scores_test.dart b/mobile/test/widgets/public_event_inline_scores_test.dart new file mode 100644 index 0000000..eae84f2 --- /dev/null +++ b/mobile/test/widgets/public_event_inline_scores_test.dart @@ -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> calls = []; + + @override + Future> 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 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.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); + }); +} diff --git a/mobile/test/widgets/public_event_join_flow_test.dart b/mobile/test/widgets/public_event_join_flow_test.dart new file mode 100644 index 0000000..f8ddcee --- /dev/null +++ b/mobile/test/widgets/public_event_join_flow_test.dart @@ -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> 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 addGuest(String publicHash, {required String name, String? phone, String? gender, num? average}) async { + addGuestCalled = true; + await Future.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); + }); +} diff --git a/mobile/test/widgets/public_event_routing_test.dart b/mobile/test/widgets/public_event_routing_test.dart new file mode 100644 index 0000000..4155b24 --- /dev/null +++ b/mobile/test/widgets/public_event_routing_test.dart @@ -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); + }); +} diff --git a/mobile/test/widgets/public_event_score_color_and_state_test.dart b/mobile/test/widgets/public_event_score_color_and_state_test.dart new file mode 100644 index 0000000..2fef3bb --- /dev/null +++ b/mobile/test/widgets/public_event_score_color_and_state_test.dart @@ -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> 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 updateScore(String publicHash, {required String participantId, required int gameNumber, required int score, int? handicap, teamNumber}) async { + // no-op + await Future.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(colorDot); + final deco = container.decoration as BoxDecoration; + expect((deco.color as Color), equals(Colors.red)); + }); +} diff --git a/mobile/test/widgets/public_event_score_dialog_validation_test.dart b/mobile/test/widgets/public_event_score_dialog_validation_test.dart new file mode 100644 index 0000000..5529a30 --- /dev/null +++ b/mobile/test/widgets/public_event_score_dialog_validation_test.dart @@ -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> 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 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); + }); +} diff --git a/mobile/test/widgets/public_event_score_edit_test.dart b/mobile/test/widgets/public_event_score_edit_test.dart new file mode 100644 index 0000000..0e5bfd7 --- /dev/null +++ b/mobile/test/widgets/public_event_score_edit_test.dart @@ -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> 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 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.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); + }); +} diff --git a/mobile/test/widgets/public_event_screen_test.dart b/mobile/test/widgets/public_event_screen_test.dart new file mode 100644 index 0000000..1edcc63 --- /dev/null +++ b/mobile/test/widgets/public_event_screen_test.dart @@ -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> 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(find.byKey(const Key('participants_count'))); + expect(pc.data, '총 2명'); + expect(find.byKey(const Key('teams_count')), findsOneWidget); + expect(find.text('총 1팀'), findsOneWidget); + }); +} diff --git a/mobile/test/widgets/public_event_state_guards_test.dart b/mobile/test/widgets/public_event_state_guards_test.dart new file mode 100644 index 0000000..f303795 --- /dev/null +++ b/mobile/test/widgets/public_event_state_guards_test.dart @@ -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> 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); + }); +} diff --git a/mobile/test/widgets/public_event_teams_unassigned_test.dart b/mobile/test/widgets/public_event_teams_unassigned_test.dart new file mode 100644 index 0000000..741cbaa --- /dev/null +++ b/mobile/test/widgets/public_event_teams_unassigned_test.dart @@ -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> 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); + }); +} diff --git a/mobile/test/widgets/public_event_totals_test.dart b/mobile/test/widgets/public_event_totals_test.dart new file mode 100644 index 0000000..79e66d8 --- /dev/null +++ b/mobile/test/widgets/public_event_totals_test.dart @@ -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> 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); + }); +} diff --git a/plan.md b/plan.md index 19f1dee..1349571 100644 --- a/plan.md +++ b/plan.md @@ -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_). +- **[라우팅]** `/p/:publicHash` 경로 파라미터 테스트(새로고침/딥링크). +- **[위젯]** EntryScreen(비번 제출 성공/실패), EventScreen(헤더/참가자/팀/미배정/점수/저장/에러), 모달(유효성/저장/갱신) 테스트. +- **[회귀]** 저장 후 순위/색상/상태 아이콘 업데이트, `reload()` 재로딩 반영.