공개페이지 완료

This commit is contained in:
2025-11-10 14:14:08 +09:00
parent 2ce0b81483
commit ac43ddb55e
67 changed files with 3220 additions and 224696 deletions
+26 -22
View File
@@ -54,6 +54,7 @@ class MyApp extends StatelessWidget {
}
});
final initial = WidgetsBinding.instance.platformDispatcher.defaultRouteName;
return MaterialApp(
navigatorKey: navigatorKey, // 인증 오류 처리를 위한 전역 네비게이터 키
title: '볼링 매니저',
@@ -150,29 +151,12 @@ class MyApp extends StatelessWidget {
Locale('en', 'US'),
],
locale: const Locale('ko', 'KR'),
// 전역 레이아웃 래퍼: 큰 화면에서 최대 폭 제한 및 좌우 패딩 적용
builder: (context, child) {
const double maxWidth = 1200;
return Container(
alignment: Alignment.topCenter,
color: Theme.of(context).colorScheme.background,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: maxWidth),
child: child,
),
),
);
},
home: authService.isAuthenticated ? const HomeScreen() : const LoginScreen(),
routes: {
'/login': (context) => const LoginScreen(),
'/profile': (context) => const ProfileScreen(),
ClubSettingsScreen.routeName: (context) => const ClubSettingsScreen(),
},
// NOTE: 웹 딥링크 초기 빌드 시 child가 null일 수 있어 단순 패스 처리
builder: (context, child) => child ?? const SizedBox.shrink(),
initialRoute: initial,
onGenerateRoute: (settings) {
final name = settings.name ?? '';
// 공용 이벤트 딥링크 우선 처리
if (name.startsWith('/p/')) {
final hash = name.substring(3);
return MaterialPageRoute(
@@ -187,7 +171,27 @@ class MyApp extends StatelessWidget {
settings: settings,
);
}
return null;
if (name == '/login') {
return MaterialPageRoute(builder: (_) => const LoginScreen(), settings: settings);
}
if (name == '/profile') {
return MaterialPageRoute(builder: (_) => const ProfileScreen(), settings: settings);
}
if (name == ClubSettingsScreen.routeName) {
return MaterialPageRoute(builder: (_) => const ClubSettingsScreen(), settings: settings);
}
// 루트('/') 진입 시에만 홈/로그인 분기
if (name == '/' || name.isEmpty) {
return MaterialPageRoute(
builder: (_) => authService.isAuthenticated ? const HomeScreen() : const LoginScreen(),
settings: settings,
);
}
// 알 수 없는 경로는 루트로 포워딩
return MaterialPageRoute(
builder: (_) => authService.isAuthenticated ? const HomeScreen() : const LoginScreen(),
settings: settings,
);
},
);
},
+40 -1
View File
@@ -3,22 +3,53 @@ class PublicTeamMemberSummary {
final String? participantId;
final String? memberId;
final String name;
final String? gender;
final String? memberType;
final String? status;
final String? paymentStatus;
final String? comment;
final bool isGuest;
final List<int?> rawScores;
final List<int> handicaps;
final num? average;
final int memberHandicap;
PublicTeamMemberSummary({
required this.participantId,
required this.memberId,
required this.name,
required this.gender,
required this.memberType,
required this.status,
required this.paymentStatus,
required this.comment,
required this.isGuest,
required this.rawScores,
required this.handicaps,
required this.average,
required this.memberHandicap,
});
factory PublicTeamMemberSummary.fromJson(Map<String, dynamic> json, {int gameCount = 3}) {
num? parseNum(dynamic v) {
if (v == null) return null;
if (v is num) return v;
return num.tryParse(v.toString());
}
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 participantId = json['participantId']?.toString();
final memberId = json['memberId']?.toString();
final name = (json['name'] ?? '이름없음').toString();
// 점수 포맷은 PublicParticipant와 동일 규칙
final gender = json['gender']?.toString();
final memberType = json['memberType']?.toString();
final status = json['status']?.toString();
final paymentStatus = json['paymentStatus']?.toString();
final comment = json['comment']?.toString();
final isGuest = json['isGuest'] == true;
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);
@@ -44,8 +75,16 @@ class PublicTeamMemberSummary {
participantId: participantId,
memberId: memberId,
name: name,
gender: gender,
memberType: memberType,
status: status,
paymentStatus: paymentStatus,
comment: comment,
isGuest: isGuest,
rawScores: raw,
handicaps: hcs,
average: parseNum(json['average']),
memberHandicap: parseInt(json['handicap'], def: 0),
);
}
}
@@ -315,7 +315,7 @@ class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
labelText: '평균 산정 기준',
border: OutlineInputBorder(),
),
value: _selectedAverageCalculationPeriod,
initialValue: _selectedAverageCalculationPeriod,
isExpanded: true,
items: averageCalculationPeriodOptions.entries
.map(
@@ -383,7 +383,7 @@ class _EventDetailsScreenState extends State<EventDetailsScreen>
Expanded(
child: DropdownButtonFormField<String>(
key: const Key('event_details_guest_gender_dropdown'),
value: gender,
initialValue: gender,
decoration: const InputDecoration(
labelText: '성별',
border: OutlineInputBorder(),
@@ -469,7 +469,7 @@ class _EventDetailsScreenState extends State<EventDetailsScreen>
key: const Key(
'event_details_participant_status_dropdown',
),
value:
initialValue:
(participantStatusOptions.keys.contains(
participantStatus,
))
@@ -500,7 +500,7 @@ class _EventDetailsScreenState extends State<EventDetailsScreen>
Expanded(
child: DropdownButtonFormField<String>(
key: const Key('event_details_payment_status_dropdown'),
value:
initialValue:
(paymentStatusOptions.keys.contains(paymentStatus))
? paymentStatus
: null,
@@ -1421,7 +1421,7 @@ ${widget.event.accessPassword != null && widget.event.accessPassword!.isNotEmpty
else
DropdownButtonFormField<String>(
key: const Key('quick_add_member_dropdown'),
value: selectedMemberId,
initialValue: selectedMemberId,
isExpanded: true,
decoration: const InputDecoration(
labelText: '회원 선택',
@@ -1447,7 +1447,7 @@ ${widget.event.accessPassword != null && widget.event.accessPassword!.isNotEmpty
Expanded(
child: DropdownButtonFormField<String>(
key: const Key('quick_add_status_dropdown'),
value: status,
initialValue: status,
isExpanded: true,
decoration: const InputDecoration(
labelText: '상태',
@@ -1468,7 +1468,7 @@ ${widget.event.accessPassword != null && widget.event.accessPassword!.isNotEmpty
Expanded(
child: DropdownButtonFormField<String>(
key: const Key('quick_add_payment_dropdown'),
value: paymentStatus,
initialValue: paymentStatus,
isExpanded: true,
decoration: const InputDecoration(
labelText: '결제',
@@ -1578,7 +1578,7 @@ ${widget.event.accessPassword != null && widget.event.accessPassword!.isNotEmpty
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<String>(
value: status,
initialValue: status,
isExpanded: true,
decoration: const InputDecoration(labelText: '상태'),
items: participantStatusOptions.entries
@@ -1593,7 +1593,7 @@ ${widget.event.accessPassword != null && widget.event.accessPassword!.isNotEmpty
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: paymentStatus,
initialValue: paymentStatus,
isExpanded: true,
decoration: const InputDecoration(labelText: '결제'),
items: paymentStatusOptions.entries
@@ -480,7 +480,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
// 이벤트 유형
DropdownButtonFormField<String>(
key: const Key('event_form_type_dropdown'),
value: _type,
initialValue: _type,
decoration: InputDecoration(
labelText: '이벤트 유형 *',
labelStyle: TextStyle(
@@ -560,7 +560,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
// 상태
DropdownButtonFormField<String>(
key: const Key('event_form_status_dropdown'),
value: _status,
initialValue: _status,
decoration: InputDecoration(
labelText: '상태 *',
labelStyle: TextStyle(
@@ -1172,10 +1172,10 @@ class _EventFormScreenState extends State<EventFormScreen> {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.blue.withValues(alpha: 0.3),
color: Colors.blue.withOpacity(0.3),
),
),
child: Column(
@@ -549,8 +549,9 @@ class _EventImportWizardState extends State<EventImportWizard> {
final totalWarnings = _participants
.map(_rowWarnings)
.fold<int>(0, (a, b) => a + b.length);
if (totalWarnings == 0)
if (totalWarnings == 0) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(
top: 4.0,
@@ -565,7 +566,7 @@ class _EventImportWizardState extends State<EventImportWizard> {
),
const SizedBox(width: 6),
Text(
'경고 ${totalWarnings}: 이름 누락 또는 점수 개수 불일치',
'경고 $totalWarnings건: 이름 누락 또는 점수 개수 불일치',
),
],
),
@@ -877,7 +878,7 @@ class _EventImportWizardState extends State<EventImportWizard> {
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('이벤트: ${_titleCtrl.text} (${_gameCount}게임)'),
Text('이벤트: ${_titleCtrl.text} ($_gameCount게임)'),
if (_startDate != null)
Text('시작일: ${dateFmt.format(_startDate!)}'),
if (_endDate != null) Text('종료일: ${dateFmt.format(_endDate!)}'),
+4 -3
View File
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:intl/intl.dart';
@@ -45,11 +44,13 @@ class _EventsScreenState extends State<EventsScreen> {
s == 'draft' ||
s == 'scheduled' ||
s == 'waiting' ||
s == '대기')
s == '대기') {
return '대기';
}
if (s == 'canceled' || s == 'cancelled' || s == '취소') return '취소';
if (s == 'completed' || s == 'done' || s == 'finished' || s == '완료')
if (s == 'completed' || s == 'done' || s == 'finished' || s == '완료') {
return '완료';
}
return status; // 알 수 없는 값은 원문 유지
}
+6 -6
View File
@@ -411,7 +411,7 @@ class _MembersScreenState extends State<MembersScreen> {
const SizedBox(height: 16),
DropdownButtonFormField<String>(
key: const Key('members_add_gender_dropdown'),
value: selectedGender,
initialValue: selectedGender,
decoration: const InputDecoration(
labelText: '성별',
border: OutlineInputBorder(),
@@ -432,7 +432,7 @@ class _MembersScreenState extends State<MembersScreen> {
const SizedBox(height: 16),
DropdownButtonFormField<String>(
key: const Key('members_add_member_type_dropdown'),
value: selectedMemberType,
initialValue: selectedMemberType,
decoration: const InputDecoration(
labelText: '회원 유형',
border: OutlineInputBorder(),
@@ -454,7 +454,7 @@ class _MembersScreenState extends State<MembersScreen> {
const SizedBox(height: 16),
DropdownButtonFormField<String>(
key: const Key('members_add_status_dropdown'),
value: selectedStatus,
initialValue: selectedStatus,
decoration: const InputDecoration(
labelText: '상태',
border: OutlineInputBorder(),
@@ -727,7 +727,7 @@ class _MembersScreenState extends State<MembersScreen> {
buildFormField('성별', isEditing ?
DropdownButtonFormField<String>(
key: const Key('members_edit_gender_dropdown'),
value: selectedGender,
initialValue: selectedGender,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@@ -756,7 +756,7 @@ class _MembersScreenState extends State<MembersScreen> {
if (isEditing)
buildFormField('회원 유형', DropdownButtonFormField<String>(
key: const Key('members_edit_member_type_dropdown'),
value: selectedMemberType,
initialValue: selectedMemberType,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@@ -830,7 +830,7 @@ class _MembersScreenState extends State<MembersScreen> {
if (isEditing)
buildFormField('상태', DropdownButtonFormField<String>(
key: const Key('members_edit_status_dropdown'),
value: selectedStatus,
initialValue: selectedStatus,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@@ -214,7 +214,7 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
//
DropdownButtonFormField<String>(
key: const Key('score_form_participant_dropdown'),
value: _selectedParticipantId,
initialValue: _selectedParticipantId,
decoration: const InputDecoration(labelText: '참가자 *'),
isExpanded: true,
items: widget.participants.map((participant) {
@@ -164,9 +164,9 @@ class BasicInfoTab extends StatelessWidget {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withValues(alpha: 0.3)),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -34,10 +34,12 @@ class _ParticipantsTabState extends State<ParticipantsTab> {
String _buildFilterSummary() {
final parts = <String>[];
if (_filterStatus != null && _filterStatus!.isNotEmpty)
if (_filterStatus != null && _filterStatus!.isNotEmpty) {
parts.add('상태: ${_filterStatus!}');
if (_filterMemberType != null && _filterMemberType!.isNotEmpty)
}
if (_filterMemberType != null && _filterMemberType!.isNotEmpty) {
parts.add('유형: ${_filterMemberType!}');
}
if (_filterPaid != null) parts.add('결제: ${_filterPaid! ? '완료' : '미결제'}');
final q = _searchController.text.trim();
if (q.isNotEmpty) parts.add("검색: '$q'");
@@ -237,7 +239,7 @@ class _ParticipantsTabState extends State<ParticipantsTab> {
final icon = isFemale ? Icons.female : Icons.male;
final color = isFemale ? Colors.pink : Colors.blue;
return CircleAvatar(
backgroundColor: color.withValues(alpha: 0.1),
backgroundColor: color.withOpacity(0.1),
child: Icon(icon, size: 18, color: color),
);
},
@@ -266,10 +268,10 @@ class _ParticipantsTabState extends State<ParticipantsTab> {
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.deepPurple.withValues(alpha: 0.10),
color: Colors.deepPurple.withOpacity(0.10),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.deepPurple.withValues(alpha: 0.25),
color: Colors.deepPurple.withOpacity(0.25),
),
),
child: Text(
@@ -290,10 +292,10 @@ class _ParticipantsTabState extends State<ParticipantsTab> {
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.indigo.withValues(alpha: 0.10),
color: Colors.indigo.withOpacity(0.10),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.indigo.withValues(alpha: 0.25),
color: Colors.indigo.withOpacity(0.25),
),
),
child: Text(
+34 -34
View File
@@ -93,8 +93,9 @@ class TeamsTab extends StatelessWidget {
value: 1,
groupValue: teamGenerationMethod,
onChanged: (value) {
if (value != null)
if (value != null) {
onChangeTeamGenerationMethod(value);
}
},
),
const Text('팀 인원수로 나누기'),
@@ -131,8 +132,9 @@ class TeamsTab extends StatelessWidget {
value: 2,
groupValue: teamGenerationMethod,
onChanged: (value) {
if (value != null)
if (value != null) {
onChangeTeamGenerationMethod(value);
}
},
),
const Text('팀 개수로 나누기'),
@@ -463,11 +465,12 @@ class TeamsTab extends StatelessWidget {
final memberCount = team.memberIds.length;
return DragTarget<Map<String, dynamic>>(
onWillAccept: (data) =>
data != null && data['participant'] is Participant,
onAccept: (data) {
final Participant p = data['participant'] as Participant;
final Team from = data['fromTeam'] as Team;
onWillAcceptWithDetails: (details) =>
details.data['participant'] is Participant,
onAcceptWithDetails: (details) {
final Participant p =
details.data['participant'] as Participant;
final Team from = details.data['fromTeam'] as Team;
if (onMoveParticipant != null) {
onMoveParticipant!(p, from, team, team.memberIds.length);
}
@@ -520,12 +523,12 @@ class TeamsTab extends StatelessWidget {
children: [
// : ( 0)
DragTarget<Map<String, dynamic>>(
onWillAccept: (data) =>
data != null && data['participant'] is Participant,
onAccept: (data) {
onWillAcceptWithDetails: (details) =>
details.data['participant'] is Participant,
onAcceptWithDetails: (details) {
final Participant dp =
data['participant'] as Participant;
final Team from = data['fromTeam'] as Team;
details.data['participant'] as Participant;
final Team from = details.data['fromTeam'] as Team;
if (onMoveParticipant != null) {
onMoveParticipant!(dp, from, team, 0);
}
@@ -558,13 +561,12 @@ class TeamsTab extends StatelessWidget {
// ( )
return DragTarget<Map<String, dynamic>>(
onWillAccept: (data) =>
data != null &&
data['participant'] is Participant,
onAccept: (data) {
onWillAcceptWithDetails: (details) =>
details.data['participant'] is Participant,
onAcceptWithDetails: (details) {
final Participant dp =
data['participant'] as Participant;
final Team from = data['fromTeam'] as Team;
details.data['participant'] as Participant;
final Team from = details.data['fromTeam'] as Team;
if (onMoveParticipant != null) {
onMoveParticipant!(dp, from, team, memberIndex);
}
@@ -624,9 +626,7 @@ class TeamsTab extends StatelessWidget {
? Colors.pink
: Colors.blue;
return CircleAvatar(
backgroundColor: color.withValues(
alpha: 0.1,
),
backgroundColor: color.withOpacity(0.1),
child: Icon(
icon,
size: 18,
@@ -687,12 +687,12 @@ class TeamsTab extends StatelessWidget {
),
decoration: BoxDecoration(
color: Colors.deepPurple
.withValues(alpha: 0.10),
.withOpacity(0.10),
borderRadius:
BorderRadius.circular(12),
border: Border.all(
color: Colors.deepPurple
.withValues(alpha: 0.25),
.withOpacity(0.25),
),
),
child: Text(
@@ -780,7 +780,7 @@ class TeamsTab extends StatelessWidget {
: Colors.blue;
return CircleAvatar(
backgroundColor: color
.withValues(alpha: 0.1),
.withOpacity(0.1),
child: Icon(
icon,
size: 18,
@@ -1054,12 +1054,12 @@ class TeamsTab extends StatelessWidget {
}),
// : ( length)
DragTarget<Map<String, dynamic>>(
onWillAccept: (data) =>
data != null && data['participant'] is Participant,
onAccept: (data) {
onWillAcceptWithDetails: (details) =>
details.data['participant'] is Participant,
onAcceptWithDetails: (details) {
final Participant dp =
data['participant'] as Participant;
final Team from = data['fromTeam'] as Team;
details.data['participant'] as Participant;
final Team from = details.data['fromTeam'] as Team;
if (onMoveParticipant != null) {
onMoveParticipant!(
dp,
@@ -1130,7 +1130,7 @@ class _UnassignedChip extends StatelessWidget {
return Chip(
avatar: CircleAvatar(
backgroundColor: color.withValues(alpha: 0.1),
backgroundColor: color.withOpacity(0.1),
child: Icon(icon, size: 16, color: color),
),
label: Row(
@@ -1158,10 +1158,10 @@ class _UnassignedChip extends StatelessWidget {
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.deepPurple.withValues(alpha: 0.10),
color: Colors.deepPurple.withOpacity(0.10),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.deepPurple.withValues(alpha: 0.25),
color: Colors.deepPurple.withOpacity(0.25),
),
),
child: Text(
@@ -1182,10 +1182,10 @@ class _UnassignedChip extends StatelessWidget {
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.indigo.withValues(alpha: 0.10),
color: Colors.indigo.withOpacity(0.10),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.indigo.withValues(alpha: 0.25),
color: Colors.indigo.withOpacity(0.25),
),
),
child: Text(
@@ -41,7 +41,7 @@ class _JoinDialogState extends State<JoinDialog> {
Navigator.of(context).pop(true);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('신청 실패: ' + e.toString())));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('신청 실패: $e')));
} finally {
if (mounted) setState(() => _saving = false);
}
@@ -5,14 +5,19 @@ 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});
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();
late final PublicEventService _service =
widget.service ?? PublicEventService();
final TextEditingController _pw = TextEditingController();
bool _loading = false;
String? _error;
@@ -37,7 +42,9 @@ class _PublicEventEntryScreenState extends State<PublicEventEntryScreen> {
// .
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => PublicEventScreen(publicHash: widget.publicHash)),
MaterialPageRoute(
builder: (_) => PublicEventScreen(publicHash: widget.publicHash),
),
);
} catch (e) {
// 401/needPassword :
@@ -63,8 +70,6 @@ class _PublicEventEntryScreenState extends State<PublicEventEntryScreen> {
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)),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -614,7 +614,7 @@ class _MemberStatisticsScreenState extends State<MemberStatisticsScreen>
dotData: const FlDotData(show: true),
belowBarData: BarAreaData(
show: true,
color: Colors.blue.withValues(alpha: 0.2),
color: Colors.blue.withOpacity(0.2),
),
),
],
@@ -198,7 +198,7 @@ class _ScoreEntryScreenState extends State<ScoreEntryScreen> {
labelText: '회원 선택',
border: OutlineInputBorder(),
),
value: _selectedMemberId,
initialValue: _selectedMemberId,
isExpanded: true,
items: members.map((member) {
return DropdownMenuItem<String>(
@@ -237,7 +237,7 @@ class _ScoreEntryScreenState extends State<ScoreEntryScreen> {
labelText: '이벤트 선택 (선택사항)',
border: OutlineInputBorder(),
),
value: _selectedEventId,
initialValue: _selectedEventId,
isExpanded: true,
items: [
const DropdownMenuItem<String>(
@@ -136,7 +136,7 @@ class _SubscriptionDetailsScreenState extends State<SubscriptionDetailsScreen> {
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -5),
),
@@ -207,7 +207,7 @@ class _SubscriptionDetailsScreenState extends State<SubscriptionDetailsScreen> {
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.1),
color: Colors.amber.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.amber),
),
@@ -122,8 +122,8 @@ class _SubscriptionScreenState extends State<SubscriptionScreen> {
),
decoration: BoxDecoration(
color: subscription.isActive
? Colors.green.withValues(alpha: 0.1)
: Colors.grey.withValues(alpha: 0.1),
? Colors.green.withOpacity(0.1)
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: subscription.isActive
@@ -259,7 +259,7 @@ class _SubscriptionScreenState extends State<SubscriptionScreen> {
_isYearly = value;
});
},
activeColor: Colors.blue,
activeThumbColor: Colors.blue,
),
const SizedBox(width: 8),
Row(
@@ -272,7 +272,7 @@ class _SubscriptionScreenState extends State<SubscriptionScreen> {
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.green),
),
@@ -341,7 +341,7 @@ class _SubscriptionScreenState extends State<SubscriptionScreen> {
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.1),
color: Colors.amber.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.amber),
),
+9 -7
View File
@@ -851,7 +851,7 @@ class EventService with ChangeNotifier {
// /404
try {
final prefs = await SharedPreferences.getInstance();
final key = 'local_teams_' + eventId;
final key = 'local_teams_$eventId';
final raw = prefs.getString(key);
if (raw != null && raw.isNotEmpty) {
final List<dynamic> jsonList = jsonDecode(raw);
@@ -945,7 +945,7 @@ class EventService with ChangeNotifier {
// /404
try {
final prefs = await SharedPreferences.getInstance();
final key = 'local_teams_' + eventId;
final key = 'local_teams_$eventId';
final raw = jsonEncode(teams.map((t) => t.toJson()).toList());
await prefs.setString(key, raw);
_teams = teams;
@@ -1091,7 +1091,7 @@ class EventService with ChangeNotifier {
data: formData,
);
if (data is Map) {
final map = Map<String, dynamic>.from(data as Map);
final map = Map<String, dynamic>.from(data);
// : { message, file: { filename, originalname, ... } }
if (map['file'] is Map) {
final fm = Map<String, dynamic>.from(map['file'] as Map);
@@ -1201,8 +1201,9 @@ class EventService with ChangeNotifier {
String? rawStatus = payloadEvent['status']?.toString();
if (rawStatus != null && rawStatus.isNotEmpty) {
final s = rawStatus.toLowerCase();
if (['active', '활성'].contains(s)) payloadEvent['status'] = 'active';
else if (['ready', '대기', '준비', 'draft'].contains(s)) payloadEvent['status'] = 'ready';
if (['active', '활성'].contains(s)) {
payloadEvent['status'] = 'active';
} else if (['ready', '대기', '준비', 'draft'].contains(s)) payloadEvent['status'] = 'ready';
else if (['completed', '완료', 'done', 'finished'].contains(s)) payloadEvent['status'] = 'completed';
else if (['canceled', 'cancelled', '취소'].contains(s)) payloadEvent['status'] = 'canceled';
else if (['deleted', '삭제'].contains(s)) payloadEvent['status'] = 'deleted';
@@ -1212,8 +1213,9 @@ class EventService with ChangeNotifier {
String? rawType = payloadEvent['eventType']?.toString();
if (rawType != null && rawType.isNotEmpty) {
final t = rawType.toLowerCase();
if (['regular','일반'].contains(t)) payloadEvent['eventType'] = 'regular';
else if (['lightning','번개'].contains(t)) payloadEvent['eventType'] = 'lightning';
if (['regular','일반'].contains(t)) {
payloadEvent['eventType'] = 'regular';
} else if (['lightning','번개'].contains(t)) payloadEvent['eventType'] = 'lightning';
else if (['practice','연습'].contains(t)) payloadEvent['eventType'] = 'practice';
else if (['exchange','교류전'].contains(t)) payloadEvent['eventType'] = 'exchange';
else if (['event','행사'].contains(t)) payloadEvent['eventType'] = 'event';
@@ -42,7 +42,7 @@ class PublicEventService {
@visibleForTesting
PublicEventService.forTest(this._http);
static String tokenKey(String publicHash) => 'event_token_' + publicHash;
static String tokenKey(String publicHash) => 'event_token_$publicHash';
String? getSavedToken(String publicHash) {
return WebStorage.getItem(tokenKey(publicHash));
@@ -58,14 +58,14 @@ class PublicEventService {
Map<String, dynamic>? _authHeader(String? token) {
if (token == null || token.isEmpty) return null;
return { 'Authorization': 'Bearer ' + token };
return { 'Authorization': 'Bearer $token' };
}
Future<Map<String, dynamic>> fetchEvent(String publicHash) async {
final saved = getSavedToken(publicHash);
// GET이 POST('/:publicHash') /
final res = await _http.post(
'/public/' + publicHash,
'/public/$publicHash',
data: {},
headers: _authHeader(saved),
);
@@ -83,7 +83,7 @@ class PublicEventService {
try {
// ,
final res = await _http.post(
'/public/' + publicHash,
'/public/$publicHash',
data: password != null ? { 'password': password } : {},
headers: _authHeader(saved),
);
@@ -119,7 +119,7 @@ class PublicEventService {
if (comment != null) 'comment': comment,
};
await _http.post(
'/public/' + publicHash + '/participant',
'/public/$publicHash/participant',
data: payload,
headers: _authHeader(token),
);
@@ -139,7 +139,7 @@ class PublicEventService {
if (average != null) 'average': average,
};
await _http.post(
'/public/' + publicHash + '/guest',
'/public/$publicHash/guest',
data: payload,
headers: _authHeader(token),
);
@@ -161,7 +161,7 @@ class PublicEventService {
if (teamNumber != null) 'teamNumber': teamNumber,
};
await _http.put(
'/public/' + publicHash + '/score',
'/public/$publicHash/score',
data: payload,
headers: _authHeader(token),
);
@@ -173,7 +173,7 @@ class PublicEventService {
}) async {
final token = getSavedToken(publicHash);
await _http.put(
'/public/' + publicHash + '/team/' + teamNumber.toString() + '/handicap',
'/public/$publicHash/team/$teamNumber/handicap',
data: { 'teamNumber': teamNumber, 'handicap': handicap },
headers: _authHeader(token),
);
+26 -8
View File
@@ -142,7 +142,11 @@ class BadgeStyles {
label = '활성';
bg = successBg;
icon = Icons.check_circle;
} else if (s == 'pending' || s == 'draft' || s == 'scheduled' || s == 'waiting' || s == '대기') {
} else if (s == 'pending' ||
s == 'draft' ||
s == 'scheduled' ||
s == 'waiting' ||
s == '대기') {
label = '대기';
bg = Colors.amber.shade100;
icon = Icons.hourglass_top;
@@ -150,7 +154,10 @@ class BadgeStyles {
label = '취소';
bg = dangerBg;
icon = Icons.cancel;
} else if (s == 'completed' || s == 'done' || s == 'finished' || s == '완료') {
} else if (s == 'completed' ||
s == 'done' ||
s == 'finished' ||
s == '완료') {
label = '완료';
bg = Colors.grey.shade300;
icon = Icons.done_all;
@@ -172,15 +179,26 @@ class BadgeStyles {
final double l2 = lum1 > lum2 ? lum2 : lum1;
return (l1 + 0.05) / (l2 + 0.05);
}
final double contrastWithWhite = contrast(lumBg, Colors.white.computeLuminance());
final double contrastWithBlack = contrast(lumBg, Colors.black.computeLuminance());
final Color fg = contrastWithWhite >= contrastWithBlack ? Colors.white : Colors.black;
final double contrastWithWhite = contrast(
lumBg,
Colors.white.computeLuminance(),
);
final double contrastWithBlack = contrast(
lumBg,
Colors.black.computeLuminance(),
);
final Color fg = contrastWithWhite >= contrastWithBlack
? Colors.white
: Colors.black;
return Chip(
avatar: Icon(icon, size: 16, color: fg),
label: Text(text, style: TextStyle(color: fg)),
avatar: Icon(icon, size: 14, color: fg),
label: Text(text, style: TextStyle(color: fg, fontSize: 11)),
backgroundColor: bg,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 0),
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 0),
labelPadding: const EdgeInsets.only(left: -2, right: 5),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: const VisualDensity(horizontal: -3, vertical: -3),
);
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ class OwnerDropdown extends StatelessWidget {
border: OutlineInputBorder(),
),
isExpanded: true,
value: value,
initialValue: value,
items: items,
onChanged: enabled ? onChanged : null,
);
+744
View File
@@ -0,0 +1,744 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lanebow/theme/badges.dart';
import 'package:lanebow/utils/enum_mappings.dart';
class PublicUserNameRow extends StatelessWidget {
final String name;
final String? gender;
final String? memberType;
final int? userHandicap;
final num? userAverage;
final int? rank; // 1: , 2: , 3:
const PublicUserNameRow({
super.key,
required this.name,
this.gender,
this.memberType,
this.userHandicap,
this.userAverage,
this.rank,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
if (rank != null) ...[_rankBadge(rank!), const SizedBox(width: 6)],
Expanded(
child: RichText(
text: TextSpan(
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors
.black, // DefaultTextStyle.of(context).style.color
),
children: [
TextSpan(text: name),
const WidgetSpan(child: SizedBox(width: 4)), // -
WidgetSpan(
alignment: PlaceholderAlignment.middle, //
child: _genderIcon(gender),
),
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
_hcBadge(userHandicap),
if (userAverage != null) ...[
const SizedBox(width: 4),
_avgBadge(userAverage),
],
],
);
}
Widget _genderIcon(String? gender) {
final g = (gender ?? '').toLowerCase();
final isFemale = g == 'female' || g == 'f' || g == '' || g == '여성';
final icon = isFemale ? Icons.female : Icons.male;
final color = isFemale ? Colors.pink : Colors.blue;
return CircleAvatar(
radius: 10,
backgroundColor: color.withOpacity(0.1),
child: Icon(icon, size: 14, color: color),
);
}
Widget _avgBadge(num? avg) {
if (avg == null) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.indigo.withOpacity(0.10),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.indigo.withOpacity(0.25)),
),
child: Text(
(avg is double ? avg : avg.toDouble()).toStringAsFixed(1),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.indigo,
),
),
);
}
Widget _hcBadge(int? hc) {
if (hc == null || hc == 0) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.deepPurple.withOpacity(0.10),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.deepPurple.withOpacity(0.25)),
),
child: Text(
hc > 0 ? '+$hc' : '$hc',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.deepPurple,
),
),
);
}
Widget _rankBadge(int r) {
String label;
Color bg;
Color fg = Colors.black87;
if (r == 1) {
label = r.toString();
bg = Colors.amber.shade200;
} else if (r == 2) {
label = r.toString();
bg = Colors.blueGrey.shade200;
} else if (r == 3) {
label = r.toString();
bg = Colors.brown.shade200;
} else {
label = r.toString();
bg = Colors.grey.shade300;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(12),
),
child: Text(
label,
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: fg),
),
);
}
}
class PublicUserGameEditors extends StatelessWidget {
final int gameCount;
final String participantId;
final List<int?> rawScores;
final List<int> handicaps;
final bool enabled;
final String Function(String pid, int gameNumber) keyFor;
final Widget Function(String key) stateIcon;
final void Function(int gameNumber, int value) onScoreChanged;
final void Function(int gameNumber, int value) onHandicapChanged;
final double? eventAverage; // optional, to show at right
final int? memberHandicap; // optional default per-game handicap
const PublicUserGameEditors({
super.key,
required this.gameCount,
required this.participantId,
required this.rawScores,
required this.handicaps,
required this.enabled,
required this.keyFor,
required this.stateIcon,
required this.onScoreChanged,
required this.onHandicapChanged,
this.eventAverage,
this.memberHandicap,
});
bool _hasRaw(int g) =>
(g - 1 >= 0 && g - 1 < rawScores.length && rawScores[g - 1] != null);
int _rawAt(int g) => _hasRaw(g) ? rawScores[g - 1]! : 0;
int _hcAt(int g) {
int hv = (g - 1 >= 0 && g - 1 < handicaps.length) ? handicaps[g - 1] : 0;
if (hv == 0 && (memberHandicap ?? 0) != 0) return memberHandicap!;
return hv;
}
int _overallRawTotal() {
int s = 0;
for (int g = 1; g <= gameCount; g++) {
if (_hasRaw(g)) s += _rawAt(g);
}
return s;
}
double? _avgRaw() {
int count = 0;
int sum = 0;
for (int g = 1; g <= gameCount; g++) {
if (_hasRaw(g)) {
sum += _rawAt(g);
count++;
}
}
if (count == 0) return null;
return sum / count;
}
bool _hasAnyPerGameHandicap() {
// Consider effective handicap (per-game or member fallback)
for (int g = 1; g <= gameCount; g++) {
if (_hcAt(g) != 0) return true;
}
return false;
}
int _totalWithPerGameHcAt(int g) {
if (!_hasRaw(g)) return 0;
// Use effective handicap (includes memberHandicap fallback)
return _rawAt(g) + _hcAt(g);
}
int _overallWithPerGameHcTotal() {
int s = 0;
for (int g = 1; g <= gameCount; g++) {
if (_hasRaw(g)) s += _totalWithPerGameHcAt(g);
}
return s;
}
double? _avgWithPerGameHc() {
int count = 0;
int sum = 0;
for (int g = 1; g <= gameCount; g++) {
if (_hasRaw(g)) {
sum += _totalWithPerGameHcAt(g);
count++;
}
}
if (count == 0) return null;
return sum / count;
}
Color _valueColor(num v) => v >= 200 ? Colors.red : Colors.black87;
int _playedGames() {
int c = 0;
for (int g = 1; g <= gameCount; g++) {
if (_hasRaw(g)) c++;
}
return c;
}
Color _totalColor(int total) {
final n = _playedGames();
if (n <= 0) return Colors.blueGrey;
final thresh = 200 * n;
return total >= thresh ? Colors.red : Colors.blueGrey;
}
@override
Widget build(BuildContext context) {
if (!enabled) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (int g = 1; g <= gameCount; g++)
Padding(
padding: const EdgeInsets.only(right: 6),
child: SizedBox(
width: 60,
child: Column(
children: [
Container(
padding: const EdgeInsets.symmetric(
vertical: 2,
),
alignment: Alignment.center,
child: Text(
_hasRaw(g) ? _rawAt(g).toString() : '',
style: TextStyle(
fontSize: _hcAt(g) != 0 ? 11 : 17,
fontWeight: _hcAt(g) != 0
? FontWeight.w300
: FontWeight.w700,
color: _valueColor(_rawAt(g)),
),
),
),
// tighter spacing between raw and handicap
if (_hcAt(g) != 0)
Text(
(_hcAt(g) > 0
? '+${_hcAt(g)}'
: _hcAt(g).toString()),
style: const TextStyle(
fontSize: 11,
color: Colors.black54,
height: 1.0,
),
),
if (_hcAt(g) != 0) ...[
Align(
alignment: Alignment.centerRight,
child: Transform.rotate(
angle: -0.2,
child: Container(
width: 40,
height: 1,
color: Colors.black26,
),
),
),
Align(
alignment: Alignment.centerRight,
child: Text(
_totalWithPerGameHcAt(g).toString(),
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: _valueColor(
_totalWithPerGameHcAt(g),
),
),
),
),
],
],
),
),
),
],
),
),
),
const SizedBox(width: 4),
Align(
alignment: Alignment.topRight,
child: IntrinsicWidth(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.blueGrey.withOpacity(0.10),
border: Border.all(
color: Colors.blueGrey.withOpacity(0.25),
),
borderRadius: BorderRadius.circular(12),
),
child: Builder(
builder: (context) {
final hasGameHc = _hasAnyPerGameHandicap();
final raw = _overallRawTotal();
if (hasGameHc) {
final withHc = _overallWithPerGameHcTotal();
if (withHc != raw) {
return Text(
'$raw ($withHc)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: _totalColor(withHc),
),
);
}
}
return Text(
'$raw',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _totalColor(raw),
),
);
},
),
),
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.indigo.withOpacity(0.10),
border: Border.all(
color: Colors.indigo.withOpacity(0.25),
),
borderRadius: BorderRadius.circular(12),
),
child: Builder(
builder: (context) {
final hasGameHc = _hasAnyPerGameHandicap();
final rawAvg = (eventAverage ?? _avgRaw() ?? 0)
.toStringAsFixed(1);
if (hasGameHc) {
final withHcAvg = (_avgWithPerGameHc() ?? 0)
.toStringAsFixed(1);
if (withHcAvg != rawAvg) {
return Text(
'$rawAvg ($withHcAvg)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: _valueColor(
double.tryParse(withHcAvg) ?? 0,
),
),
);
}
}
return Text(
rawAvg,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _valueColor(
double.tryParse(rawAvg) ?? 0,
),
),
);
},
),
),
],
),
),
),
],
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (int g = 1; g <= gameCount; g++)
Padding(
padding: const EdgeInsets.only(right: 6),
child: SizedBox(
width: 60,
child: Column(
children: [
Stack(
clipBehavior: Clip.none,
children: [
TextFormField(
key: Key(
'cell_${participantId}_${g}_${_rawAt(g)}',
),
initialValue:
(g - 1 < rawScores.length &&
rawScores[g - 1] != null)
? rawScores[g - 1].toString()
: '',
keyboardType: TextInputType.number,
style: TextStyle(
fontSize: _hcAt(g) != 0 ? 12 : 14,
fontWeight: FontWeight.w600,
color: _valueColor(_rawAt(g)),
),
decoration: InputDecoration(
isDense: true,
labelText: '${g}G 점수',
floatingLabelBehavior:
FloatingLabelBehavior.auto,
labelStyle: const TextStyle(fontSize: 10),
floatingLabelStyle: const TextStyle(
fontSize: 10,
),
hintText: '${g}G 점수',
hintStyle: const TextStyle(fontSize: 9),
counterText: '',
contentPadding:
const EdgeInsets.symmetric(
vertical: 6,
horizontal: 8,
),
),
maxLength: 3,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
enabled: enabled,
onChanged: (v) {
if (v.isEmpty) {
onScoreChanged(g, 0);
return;
}
final val = int.tryParse(v);
if (val != null &&
val >= 0 &&
val <= 300) {
onScoreChanged(g, val);
}
},
),
Positioned(
top: -8,
right: -8,
child: IgnorePointer(
child: stateIcon(
keyFor(participantId, g),
),
),
),
],
),
const SizedBox(height: 4),
TextFormField(
key: Key(
'cell_hc_${participantId}_${g}_${_hcAt(g)}',
),
initialValue: (() {
int hv = (g - 1 < handicaps.length)
? handicaps[g - 1]
: 0;
if (hv == 0 && (memberHandicap ?? 0) != 0) {
return memberHandicap!.toString();
}
return hv.toString();
})(),
keyboardType: TextInputType.number,
decoration: InputDecoration(
isDense: true,
labelText: '${g}G 핸디캡',
floatingLabelBehavior:
FloatingLabelBehavior.auto,
labelStyle: const TextStyle(fontSize: 10),
floatingLabelStyle: const TextStyle(
fontSize: 10,
),
hintText: '${g}G 핸디캡',
hintStyle: const TextStyle(fontSize: 9),
counterText: '',
contentPadding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 8,
),
),
maxLength: 3,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
enabled: enabled,
onChanged: (v) {
if (v.isEmpty) {
onHandicapChanged(g, 0);
return;
}
final val = int.tryParse(v);
if (val != null && val >= 0 && val <= 100) {
onHandicapChanged(g, val);
}
},
),
const SizedBox(height: 2),
if (_hcAt(g) != 0) ...[
Align(
alignment: Alignment.centerRight,
child: Transform.rotate(
angle: -0.2,
child: Container(
width: 40,
height: 1,
color: Colors.black26,
),
),
),
Align(
alignment: Alignment.centerRight,
child: Text(
_totalWithPerGameHcAt(g).toString(),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: _valueColor(
_totalWithPerGameHcAt(g),
),
),
),
),
],
],
),
),
),
],
),
),
),
const SizedBox(width: 4),
Align(
alignment: Alignment.topRight,
child: IntrinsicWidth(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.blueGrey.withOpacity(0.10),
border: Border.all(
color: Colors.blueGrey.withOpacity(0.25),
),
borderRadius: BorderRadius.circular(12),
),
child: Builder(
builder: (context) {
final hasGameHc = _hasAnyPerGameHandicap();
final raw = _overallRawTotal();
if (hasGameHc) {
final withHc = _overallWithPerGameHcTotal();
if (withHc != raw) {
return Text(
'$raw ($withHc)',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _totalColor(withHc),
),
);
}
}
return Text(
'$raw',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _totalColor(raw),
),
);
},
),
),
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.indigo.withOpacity(0.10),
border: Border.all(
color: Colors.indigo.withOpacity(0.25),
),
borderRadius: BorderRadius.circular(12),
),
child: Builder(
builder: (context) {
final hasGameHc = _hasAnyPerGameHandicap();
final rawAvg = (eventAverage ?? _avgRaw() ?? 0)
.toStringAsFixed(1);
if (hasGameHc) {
final withHcAvg = (_avgWithPerGameHc() ?? 0)
.toStringAsFixed(1);
if (withHcAvg != rawAvg) {
return Text(
'$rawAvg ($withHcAvg)',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _valueColor(
double.tryParse(withHcAvg) ?? 0,
),
),
);
}
}
return Text(
rawAvg,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _valueColor(double.tryParse(rawAvg) ?? 0),
),
);
},
),
),
],
),
),
),
],
),
],
);
}
}
class PublicUserChipsRow extends StatelessWidget {
final String? status;
final String? paymentStatus;
final String? memberType;
const PublicUserChipsRow({
super.key,
this.status,
this.paymentStatus,
this.memberType,
});
bool _isPaid(String? paymentStatus) {
final s = (paymentStatus ?? '').toLowerCase();
return s == 'paid' || s == '납부' || s == '납부완료' || s == '완납';
}
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 6,
runSpacing: 6,
children: [
BadgeStyles.participantStatus(toKoreanParticipantStatus(status)),
if ((paymentStatus ?? '').isNotEmpty)
BadgeStyles.payment(_isPaid(paymentStatus)),
if ((memberType ?? '').isNotEmpty)
BadgeStyles.memberType(getMemberTypeLabel(memberType)),
],
);
}
}