tdd 진행

This commit is contained in:
2025-08-09 03:09:17 +09:00
parent ed8c7eacba
commit 6033d59590
74 changed files with 17804 additions and 395 deletions
@@ -0,0 +1,202 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:table_calendar/table_calendar.dart';
import 'package:intl/intl.dart';
import '../../models/event_model.dart';
import '../../services/event_service.dart';
import '../../services/auth_service.dart';
import '../../services/club_service.dart';
import 'event_details_screen.dart';
class EventCalendarScreen extends StatefulWidget {
const EventCalendarScreen({super.key});
@override
State<EventCalendarScreen> createState() => _EventCalendarScreenState();
}
class _EventCalendarScreenState extends State<EventCalendarScreen> {
CalendarFormat _calendarFormat = CalendarFormat.month;
DateTime _focusedDay = DateTime.now();
DateTime? _selectedDay;
Map<DateTime, List<Event>> _events = {};
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadEvents();
}
Future<void> _loadEvents() async {
setState(() {
_isLoading = true;
});
try {
final authService = Provider.of<AuthService>(context, listen: false);
final clubService = Provider.of<ClubService>(context, listen: false);
final eventService = Provider.of<EventService>(context, listen: false);
if (clubService.currentClub != null) {
eventService.initialize(authService.token!);
await eventService.fetchClubEvents();
// 이벤트를 날짜별로 그룹화
final Map<DateTime, List<Event>> eventMap = {};
for (final event in eventService.events) {
final date = DateTime(
event.startDate.year,
event.startDate.month,
event.startDate.day,
);
if (eventMap[date] == null) {
eventMap[date] = [];
}
eventMap[date]!.add(event);
}
setState(() {
_events = eventMap;
_isLoading = false;
});
}
} catch (e) {
setState(() {
_isLoading = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('이벤트를 불러오는데 실패했습니다: $e')),
);
}
}
}
List<Event> _getEventsForDay(DateTime day) {
final normalizedDay = DateTime(day.year, day.month, day.day);
return _events[normalizedDay] ?? [];
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('이벤트 캘린더'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadEvents,
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: Column(
children: [
TableCalendar(
firstDay: DateTime.now().subtract(const Duration(days: 365)),
lastDay: DateTime.now().add(const Duration(days: 365)),
focusedDay: _focusedDay,
calendarFormat: _calendarFormat,
selectedDayPredicate: (day) {
return isSameDay(_selectedDay, day);
},
onDaySelected: (selectedDay, focusedDay) {
setState(() {
_selectedDay = selectedDay;
_focusedDay = focusedDay;
});
},
onFormatChanged: (format) {
setState(() {
_calendarFormat = format;
});
},
onPageChanged: (focusedDay) {
_focusedDay = focusedDay;
},
eventLoader: _getEventsForDay,
calendarStyle: const CalendarStyle(
markersMaxCount: 3,
markerDecoration: BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
),
),
headerStyle: const HeaderStyle(
formatButtonShowsNext: false,
titleCentered: true,
),
),
const SizedBox(height: 8),
Expanded(
child: _selectedDay == null
? const Center(child: Text('날짜를 선택하세요'))
: _buildEventList(),
),
],
),
);
}
Widget _buildEventList() {
final events = _getEventsForDay(_selectedDay!);
final dateFormat = DateFormat('HH:mm');
if (events.isEmpty) {
return const Center(child: Text('선택한 날짜에 이벤트가 없습니다'));
}
return ListView.builder(
itemCount: events.length,
itemBuilder: (context, index) {
final event = events[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Colors.blue[100],
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.event, color: Colors.blue),
),
title: Text(
event.title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(dateFormat.format(event.startDate)),
if (event.location != null) Text(event.location!),
],
),
onTap: () => _navigateToEventDetails(event),
),
);
},
);
}
void _navigateToEventDetails(Event event) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => EventDetailsScreen(event: event),
),
).then((result) {
if (result == true) {
// 이벤트가 수정되었으면 목록 새로고침
_loadEvents();
}
});
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,719 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import 'dart:math';
import '../../models/event_model.dart';
import '../../services/auth_service.dart';
import '../../services/event_service.dart';
import '../../widgets/loading_indicator.dart';
class EventFormScreen extends StatefulWidget {
final Event? event; // null이면 새 이벤트 생성, 아니면 이벤트 수정
const EventFormScreen({
Key? key,
this.event,
}) : super(key: key);
@override
State<EventFormScreen> createState() => _EventFormScreenState();
}
class _EventFormScreenState extends State<EventFormScreen> {
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _isInit = false;
final _currencyFormat = NumberFormat.currency(locale: 'ko_KR', symbol: '', decimalDigits: 0);
// 폼 필드 컨트롤러
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
final _locationController = TextEditingController();
final _maxParticipantsController = TextEditingController();
final _gameCountController = TextEditingController();
final _participantFeeController = TextEditingController();
final _publicHashController = TextEditingController();
final _accessPasswordController = TextEditingController();
// 이벤트 데이터
String? _type;
String? _status;
DateTime _startDate = DateTime.now();
DateTime? _endDate;
DateTime? _registrationDeadline;
// 이벤트 유형 및 상태 옵션
final List<String> _typeOptions = ['개인전', '팀전', '리그', '토너먼트', '연습', '기타'];
final List<String> _statusOptions = ['활성', '대기', '취소', '완료'];
// 이벤트 유형 및 상태별 색상
final Map<String, Color> _typeColors = {
'개인전': Colors.blue,
'팀전': Colors.green,
'리그': Colors.purple,
'토너먼트': Colors.orange,
'연습': Colors.teal,
'기타': Colors.grey,
};
final Map<String, Color> _statusColors = {
'활성': Colors.green,
'대기': Colors.amber,
'취소': Colors.red,
'완료': Colors.blue,
};
@override
void initState() {
super.initState();
// 수정 모드인 경우 기존 데이터 로드
if (widget.event != null) {
_titleController.text = widget.event!.title;
_descriptionController.text = widget.event!.description ?? '';
_locationController.text = widget.event!.location ?? '';
_maxParticipantsController.text = widget.event!.maxParticipants?.toString() ?? '';
_gameCountController.text = widget.event!.gameCount?.toString() ?? '';
_participantFeeController.text = widget.event!.participantFee?.toString() ?? '';
_publicHashController.text = widget.event!.publicHash ?? '';
_accessPasswordController.text = widget.event!.accessPassword ?? '';
_type = widget.event!.type;
_status = widget.event!.status;
_startDate = widget.event!.startDate;
_endDate = widget.event!.endDate;
_registrationDeadline = widget.event!.registrationDeadline;
} else {
// 새 이벤트 생성 시 기본값 설정
_type = _typeOptions[0];
_status = _statusOptions[0];
_generatePublicHash(); // 새 이벤트는 공개 해시 자동 생성
}
}
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
_locationController.dispose();
_maxParticipantsController.dispose();
_gameCountController.dispose();
_participantFeeController.dispose();
_publicHashController.dispose();
_accessPasswordController.dispose();
super.dispose();
}
// 공개 URL 해시 생성
void _generatePublicHash() {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
final random = Random();
final hash = String.fromCharCodes(
Iterable.generate(
8,
(_) => chars.codeUnitAt(random.nextInt(chars.length)),
),
);
_publicHashController.text = hash;
}
// 이벤트 저장
Future<void> _saveEvent() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isLoading = true;
});
try {
final eventService = Provider.of<EventService>(context, listen: false);
final authService = Provider.of<AuthService>(context, listen: false);
// 이벤트 데이터 준비
final eventData = {
'title': _titleController.text.trim(),
'description': _descriptionController.text.trim().isEmpty ? null : _descriptionController.text.trim(),
'location': _locationController.text.trim().isEmpty ? null : _locationController.text.trim(),
'type': _type,
'status': _status,
'startDate': _startDate.toIso8601String(),
'endDate': _endDate?.toIso8601String(),
'maxParticipants': _maxParticipantsController.text.isEmpty ? null : int.parse(_maxParticipantsController.text),
'gameCount': _gameCountController.text.isEmpty ? null : int.parse(_gameCountController.text),
'participantFee': _participantFeeController.text.isEmpty ? null : double.parse(_participantFeeController.text),
'registrationDeadline': _registrationDeadline?.toIso8601String(),
'publicHash': _publicHashController.text.trim().isEmpty ? null : _publicHashController.text.trim(),
'accessPassword': _accessPasswordController.text.trim().isEmpty ? null : _accessPasswordController.text.trim(),
};
if (widget.event == null) {
// 새 이벤트 생성
await eventService.createEvent(eventData);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('이벤트가 생성되었습니다')),
);
}
} else {
// 기존 이벤트 수정
await eventService.updateEvent(widget.event!.id, eventData);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('이벤트가 수정되었습니다')),
);
}
}
if (mounted) {
Navigator.of(context).pop(true); // 성공 결과와 함께 이전 화면으로 돌아가기
}
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('이벤트 저장에 실패했습니다: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.event == null ? '새 이벤트 생성' : '이벤트 수정'),
actions: [
IconButton(
icon: const Icon(Icons.save),
onPressed: _saveEvent,
),
],
),
body: _isLoading
? const LoadingIndicator()
: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 기본 정보 섹션
_buildSectionTitle('기본 정보'),
// 제목
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: '이벤트 제목 *',
hintText: '이벤트 제목을 입력하세요',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '이벤트 제목을 입력해주세요';
}
return null;
},
),
const SizedBox(height: 16),
// 설명
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: '이벤트 설명',
hintText: '이벤트 설명을 입력하세요',
),
maxLines: 3,
),
const SizedBox(height: 16),
// 이벤트 유형
DropdownButtonFormField<String>(
value: _type,
decoration: const InputDecoration(
labelText: '이벤트 유형',
),
items: _typeOptions
.map((type) => DropdownMenuItem<String>(
value: type,
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: _typeColors[type]?.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _typeColors[type] ?? Colors.grey),
),
child: Text(
type,
style: TextStyle(color: _typeColors[type]),
),
),
],
),
))
.toList(),
onChanged: (value) {
setState(() {
_type = value;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return '이벤트 유형을 선택해주세요';
}
return null;
},
),
const SizedBox(height: 8),
if (_type != null)
Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(left: 12),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: _typeColors[_type]?.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _typeColors[_type] ?? Colors.grey),
),
child: Text(
_type!,
style: TextStyle(color: _typeColors[_type], fontSize: 12),
),
),
),
const SizedBox(height: 16),
// 상태
DropdownButtonFormField<String>(
value: _status,
decoration: const InputDecoration(
labelText: '상태',
),
items: _statusOptions
.map((status) => DropdownMenuItem<String>(
value: status,
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: _statusColors[status]?.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _statusColors[status] ?? Colors.grey),
),
child: Text(
status,
style: TextStyle(color: _statusColors[status]),
),
),
],
),
))
.toList(),
onChanged: (value) {
setState(() {
_status = value;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return '상태를 선택해주세요';
}
return null;
},
),
const SizedBox(height: 8),
if (_status != null)
Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(left: 12),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: _statusColors[_status]?.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _statusColors[_status] ?? Colors.grey),
),
child: Text(
_status!,
style: TextStyle(color: _statusColors[_status], fontSize: 12),
),
),
),
const SizedBox(height: 24),
// 날짜 및 시간 섹션
_buildSectionTitle('날짜 및 시간'),
// 시작 날짜
ListTile(
title: const Text('시작 날짜 및 시간 *'),
subtitle: Text(
DateFormat('yyyy년 MM월 dd일 HH:mm').format(_startDate),
),
trailing: const Icon(Icons.calendar_today),
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: _startDate,
firstDate: DateTime(2020),
lastDate: DateTime(2030),
);
if (date != null) {
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_startDate),
);
if (time != null && mounted) {
setState(() {
_startDate = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
});
}
}
},
),
const SizedBox(height: 8),
// 종료 날짜
ListTile(
title: const Text('종료 날짜 및 시간 (선택)'),
subtitle: _endDate != null
? Text(
DateFormat('yyyy년 MM월 dd일 HH:mm').format(_endDate!),
)
: const Text('설정되지 않음'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_endDate != null)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
setState(() {
_endDate = null;
});
},
),
const Icon(Icons.calendar_today),
],
),
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: _endDate ?? _startDate.add(const Duration(hours: 2)),
firstDate: DateTime(2020),
lastDate: DateTime(2030),
);
if (date != null) {
final time = await showTimePicker(
context: context,
initialTime: _endDate != null
? TimeOfDay.fromDateTime(_endDate!)
: TimeOfDay.fromDateTime(_startDate.add(const Duration(hours: 2))),
);
if (time != null && mounted) {
setState(() {
_endDate = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
});
}
}
},
),
const SizedBox(height: 8),
// 등록 마감일
ListTile(
title: const Text('등록 마감일 (선택)'),
subtitle: _registrationDeadline != null
? Text(
DateFormat('yyyy년 MM월 dd일 HH:mm').format(_registrationDeadline!),
)
: const Text('설정되지 않음'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_registrationDeadline != null)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
setState(() {
_registrationDeadline = null;
});
},
),
const Icon(Icons.calendar_today),
],
),
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: _registrationDeadline ?? _startDate.subtract(const Duration(days: 1)),
firstDate: DateTime(2020),
lastDate: DateTime(2030),
);
if (date != null) {
final time = await showTimePicker(
context: context,
initialTime: _registrationDeadline != null
? TimeOfDay.fromDateTime(_registrationDeadline!)
: TimeOfDay.fromDateTime(_startDate.subtract(const Duration(days: 1))),
);
if (time != null && mounted) {
setState(() {
_registrationDeadline = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
});
}
}
},
),
const SizedBox(height: 24),
// 장소 및 참가자 섹션
_buildSectionTitle('장소 및 참가자'),
// 장소
TextFormField(
controller: _locationController,
decoration: const InputDecoration(
labelText: '장소',
hintText: '이벤트 장소를 입력하세요',
),
),
const SizedBox(height: 16),
// 최대 참가자 수
TextFormField(
controller: _maxParticipantsController,
decoration: const InputDecoration(
labelText: '최대 참가자 수',
hintText: '최대 참가자 수를 입력하세요',
suffixText: '',
),
keyboardType: TextInputType.number,
validator: (value) {
if (value != null && value.isNotEmpty) {
if (int.tryParse(value) == null) {
return '숫자를 입력해주세요';
}
}
return null;
},
),
const SizedBox(height: 16),
// 게임 수
TextFormField(
controller: _gameCountController,
decoration: const InputDecoration(
labelText: '게임 수',
hintText: '게임 수를 입력하세요',
suffixText: '게임',
),
keyboardType: TextInputType.number,
validator: (value) {
if (value != null && value.isNotEmpty) {
final numValue = int.tryParse(value);
if (numValue == null) {
return '숫자를 입력해주세요';
}
if (numValue <= 0) {
return '1 이상의 숫자를 입력해주세요';
}
}
return null;
},
),
const SizedBox(height: 16),
// 게임 수
TextFormField(
controller: _gameCountController,
decoration: const InputDecoration(
labelText: '게임 수',
hintText: '게임 수를 입력하세요',
),
keyboardType: TextInputType.number,
validator: (value) {
if (value != null && value.isNotEmpty) {
if (int.tryParse(value) == null) {
return '숫자를 입력해주세요';
}
}
return null;
},
),
const SizedBox(height: 16),
// 참가비
TextFormField(
controller: _participantFeeController,
decoration: const InputDecoration(
labelText: '참가비',
hintText: '참가비를 입력하세요',
prefixText: '',
),
keyboardType: TextInputType.number,
validator: (value) {
if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) {
return '숫자를 입력해주세요';
}
}
return null;
},
onChanged: (value) {
if (value.isNotEmpty) {
final numericValue = double.tryParse(value);
if (numericValue != null) {
// 입력 중에는 포맷을 적용하지 않음
}
}
},
),
const SizedBox(height: 4),
if (_participantFeeController.text.isNotEmpty && double.tryParse(_participantFeeController.text) != null)
Align(
alignment: Alignment.centerRight,
child: Text(
'표시: ${_currencyFormat.format(double.parse(_participantFeeController.text))}',
style: TextStyle(color: Colors.grey[600], fontSize: 12),
),
),
const SizedBox(height: 24),
// 공개 접근 섹션
_buildSectionTitle('공개 접근'),
// 공개 URL 해시
Row(
children: [
Expanded(
child: TextFormField(
controller: _publicHashController,
decoration: const InputDecoration(
labelText: '공개 URL 해시',
hintText: '공개 URL 해시를 입력하세요',
),
),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
setState(() {
_generatePublicHash();
});
},
tooltip: '새 해시 생성',
),
IconButton(
icon: const Icon(Icons.copy),
onPressed: () {
final publicUrl = 'https://bowling.example.com/events/${_publicHashController.text}';
Clipboard.setData(ClipboardData(text: publicUrl));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('공개 URL이 클립보드에 복사되었습니다')),
);
},
tooltip: 'URL 복사',
),
],
),
const SizedBox(height: 4),
if (_publicHashController.text.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 12),
child: Text(
'https://bowling.example.com/events/${_publicHashController.text}',
style: TextStyle(color: Colors.blue[700], fontSize: 12),
),
),
const SizedBox(height: 16),
// 접근 비밀번호
TextFormField(
controller: _accessPasswordController,
decoration: const InputDecoration(
labelText: '접근 비밀번호 (선택)',
hintText: '접근 비밀번호를 입력하세요',
),
obscureText: true,
),
const SizedBox(height: 32),
// 저장 버튼
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _saveEvent,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: Text(
widget.event == null ? '이벤트 생성' : '이벤트 수정',
style: const TextStyle(fontSize: 16),
),
),
),
],
),
),
),
);
}
// 섹션 제목 위젯
Widget _buildSectionTitle(String title) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Divider(),
const SizedBox(height: 8),
],
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,308 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../models/participant_model.dart';
import '../../services/event_service.dart';
import '../../widgets/loading_indicator.dart';
class ParticipantFormScreen extends StatefulWidget {
final String eventId;
final Participant? participant; // null이면 새 참가자 추가, 아니면 참가자 수정
const ParticipantFormScreen({
Key? key,
required this.eventId,
this.participant,
}) : super(key: key);
@override
State<ParticipantFormScreen> createState() => _ParticipantFormScreenState();
}
class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
// 폼 필드 컨트롤러
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _phoneNumberController = TextEditingController();
final _notesController = TextEditingController();
final _paidAmountController = TextEditingController();
// 참가자 데이터
String? _status;
bool _isPaid = false;
// 참가자 상태 옵션
final List<String> _statusOptions = ['등록됨', '확인됨', '취소됨', '참석함'];
@override
void initState() {
super.initState();
// 수정 모드인 경우 기존 데이터 로드
if (widget.participant != null) {
_nameController.text = widget.participant!.name ?? '';
_emailController.text = widget.participant!.email ?? '';
_phoneNumberController.text = widget.participant!.phoneNumber ?? '';
_notesController.text = widget.participant!.notes ?? '';
_paidAmountController.text = widget.participant!.paidAmount?.toString() ?? '';
_status = widget.participant!.status;
_isPaid = widget.participant!.isPaid;
} else {
// 새 참가자 추가 시 기본값 설정
_status = _statusOptions[0];
_isPaid = false;
}
}
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_phoneNumberController.dispose();
_notesController.dispose();
_paidAmountController.dispose();
super.dispose();
}
// 참가자 저장
Future<void> _saveParticipant() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isLoading = true;
});
try {
final eventService = Provider.of<EventService>(context, listen: false);
// 참가자 데이터 준비
final participantData = {
'eventId': widget.eventId,
'name': _nameController.text.trim(),
'email': _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
'phoneNumber': _phoneNumberController.text.trim().isEmpty ? null : _phoneNumberController.text.trim(),
'status': _status,
'isPaid': _isPaid,
'paidAmount': _paidAmountController.text.isEmpty ? null : double.parse(_paidAmountController.text),
'notes': _notesController.text.trim().isEmpty ? null : _notesController.text.trim(),
};
if (widget.participant == null) {
// 새 참가자 추가
await eventService.addParticipant(widget.eventId, participantData);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('참가자가 추가되었습니다')),
);
}
} else {
// 기존 참가자 수정
await eventService.updateParticipant(widget.eventId, widget.participant!.id, participantData);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('참가자 정보가 수정되었습니다')),
);
}
}
if (mounted) {
Navigator.of(context).pop(true); // 성공 결과와 함께 이전 화면으로 돌아가기
}
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('참가자 저장에 실패했습니다: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.participant == null ? '참가자 추가' : '참가자 수정'),
actions: [
IconButton(
icon: const Icon(Icons.save),
onPressed: _saveParticipant,
),
],
),
body: _isLoading
? const LoadingIndicator()
: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 기본 정보 섹션
_buildSectionTitle('기본 정보'),
// 이름
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: '이름 *',
hintText: '참가자 이름을 입력하세요',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '참가자 이름을 입력해주세요';
}
return null;
},
),
const SizedBox(height: 16),
// 이메일
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: '이메일',
hintText: '참가자 이메일을 입력하세요',
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value != null && value.isNotEmpty) {
// 간단한 이메일 형식 검증
if (!value.contains('@') || !value.contains('.')) {
return '유효한 이메일 주소를 입력해주세요';
}
}
return null;
},
),
const SizedBox(height: 16),
// 전화번호
TextFormField(
controller: _phoneNumberController,
decoration: const InputDecoration(
labelText: '전화번호',
hintText: '참가자 전화번호를 입력하세요',
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 24),
// 상태 섹션
_buildSectionTitle('상태 정보'),
// 참가자 상태
DropdownButtonFormField<String>(
value: _status,
decoration: const InputDecoration(
labelText: '참가자 상태',
),
items: _statusOptions.map((status) {
return DropdownMenuItem(
value: status,
child: Text(status),
);
}).toList(),
onChanged: (value) {
setState(() {
_status = value;
});
},
),
const SizedBox(height: 16),
// 결제 여부
SwitchListTile(
title: const Text('결제 완료'),
value: _isPaid,
onChanged: (value) {
setState(() {
_isPaid = value;
});
},
),
// 결제 금액
TextFormField(
controller: _paidAmountController,
decoration: const InputDecoration(
labelText: '결제 금액',
hintText: '결제 금액을 입력하세요',
suffixText: '',
),
keyboardType: TextInputType.number,
validator: (value) {
if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) {
return '숫자를 입력해주세요';
}
}
return null;
},
),
const SizedBox(height: 24),
// 메모 섹션
_buildSectionTitle('추가 정보'),
// 메모
TextFormField(
controller: _notesController,
decoration: const InputDecoration(
labelText: '메모',
hintText: '참가자에 대한 메모를 입력하세요',
),
maxLines: 3,
),
const SizedBox(height: 32),
// 저장 버튼
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _saveParticipant,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: Text(
widget.participant == null ? '참가자 추가' : '참가자 정보 수정',
style: const TextStyle(fontSize: 16),
),
),
),
],
),
),
),
);
}
// 섹션 제목 위젯
Widget _buildSectionTitle(String title) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Divider(),
const SizedBox(height: 8),
],
);
}
}
@@ -0,0 +1,363 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../models/score_model.dart';
import '../../models/participant_model.dart';
import '../../services/event_service.dart';
import '../../widgets/loading_indicator.dart';
class ScoreFormScreen extends StatefulWidget {
final String eventId;
final Score? score; // null이면 새 점수 추가, 아니면 점수 수정
final List<Participant> participants;
const ScoreFormScreen({
Key? key,
required this.eventId,
this.score,
required this.participants,
}) : super(key: key);
@override
State<ScoreFormScreen> createState() => _ScoreFormScreenState();
}
class _ScoreFormScreenState extends State<ScoreFormScreen> {
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
// 폼 필드 컨트롤러
final _notesController = TextEditingController();
final List<TextEditingController> _frameControllers = List.generate(
10,
(_) => TextEditingController(),
);
// 점수 데이터
String? _selectedParticipantId;
DateTime _scoreDate = DateTime.now();
int _totalScore = 0;
@override
void initState() {
super.initState();
// 수정 모드인 경우 기존 데이터 로드
if (widget.score != null) {
_notesController.text = widget.score!.notes ?? '';
_selectedParticipantId = widget.score!.memberId;
_scoreDate = widget.score!.date;
// 프레임별 점수 설정
for (int i = 0; i < widget.score!.frames.length && i < 10; i++) {
_frameControllers[i].text = widget.score!.frames[i].toString();
}
_calculateTotalScore();
} else if (widget.participants.isNotEmpty) {
// 새 점수 추가 시 첫 번째 참가자 선택
_selectedParticipantId = widget.participants.first.memberId;
}
}
@override
void dispose() {
_notesController.dispose();
for (var controller in _frameControllers) {
controller.dispose();
}
super.dispose();
}
// 총점 계산
void _calculateTotalScore() {
int total = 0;
for (var controller in _frameControllers) {
if (controller.text.isNotEmpty) {
total += int.tryParse(controller.text) ?? 0;
}
}
setState(() {
_totalScore = total;
});
}
// 점수 저장
Future<void> _saveScore() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isLoading = true;
});
try {
final eventService = Provider.of<EventService>(context, listen: false);
// 프레임 점수 리스트 생성
final frames = _frameControllers
.map((controller) => controller.text.isEmpty ? 0 : int.parse(controller.text))
.toList();
// 점수 데이터 준비
final scoreData = {
'eventId': widget.eventId,
'memberId': _selectedParticipantId,
'date': _scoreDate.toIso8601String(),
'frames': frames,
'totalScore': _totalScore,
'notes': _notesController.text.trim().isEmpty ? null : _notesController.text.trim(),
};
if (widget.score == null) {
// 새 점수 추가
await eventService.addScore(widget.eventId, scoreData);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('점수가 추가되었습니다')),
);
}
} else {
// 기존 점수 수정
await eventService.updateScore(widget.eventId, widget.score!.id, scoreData);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('점수가 수정되었습니다')),
);
}
}
if (mounted) {
Navigator.of(context).pop(true); // 성공 결과와 함께 이전 화면으로 돌아가기
}
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('점수 저장에 실패했습니다: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.score == null ? '점수 추가' : '점수 수정'),
actions: [
IconButton(
icon: const Icon(Icons.save),
onPressed: _saveScore,
),
],
),
body: _isLoading
? const LoadingIndicator()
: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 기본 정보 섹션
_buildSectionTitle('기본 정보'),
// 참가자 선택
DropdownButtonFormField<String>(
value: _selectedParticipantId,
decoration: const InputDecoration(
labelText: '참가자 *',
),
items: widget.participants.map((participant) {
return DropdownMenuItem(
value: participant.memberId,
child: Text(participant.name ?? '이름 없음'),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedParticipantId = value;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return '참가자를 선택해주세요';
}
return null;
},
),
const SizedBox(height: 16),
// 날짜 선택
ListTile(
title: const Text('점수 기록 날짜 *'),
subtitle: Text(
DateFormat('yyyy년 MM월 dd일').format(_scoreDate),
),
trailing: const Icon(Icons.calendar_today),
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: _scoreDate,
firstDate: DateTime(2020),
lastDate: DateTime(2030),
);
if (date != null && mounted) {
setState(() {
_scoreDate = date;
});
}
},
),
const SizedBox(height: 24),
// 점수 입력 섹션
_buildSectionTitle('프레임별 점수'),
// 프레임별 점수 입력
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
childAspectRatio: 1.5,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
),
itemCount: 10,
itemBuilder: (context, index) {
return _buildFrameInput(index);
},
),
const SizedBox(height: 16),
// 총점 표시
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'총점:',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
_totalScore.toString(),
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
],
),
),
const SizedBox(height: 24),
// 메모 섹션
_buildSectionTitle('추가 정보'),
// 메모
TextFormField(
controller: _notesController,
decoration: const InputDecoration(
labelText: '메모',
hintText: '점수에 대한 메모를 입력하세요',
),
maxLines: 3,
),
const SizedBox(height: 32),
// 저장 버튼
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _saveScore,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: Text(
widget.score == null ? '점수 추가' : '점수 수정',
style: const TextStyle(fontSize: 16),
),
),
),
],
),
),
),
);
}
// 프레임 입력 위젯
Widget _buildFrameInput(int frameIndex) {
return Column(
children: [
Text(
'${frameIndex + 1}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
Expanded(
child: TextFormField(
controller: _frameControllers[frameIndex],
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
),
validator: (value) {
if (value != null && value.isNotEmpty) {
final score = int.tryParse(value);
if (score == null) {
return '숫자만';
}
if (score < 0 || score > 300) {
return '0-300';
}
}
return null;
},
onChanged: (_) => _calculateTotalScore(),
),
),
],
);
}
// 섹션 제목 위젯
Widget _buildSectionTitle(String title) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Divider(),
const SizedBox(height: 8),
],
);
}
}
@@ -0,0 +1,511 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'dart:math';
import '../../models/participant_model.dart';
import '../../models/team_model.dart';
import '../../services/event_service.dart';
import '../../widgets/loading_indicator.dart';
class TeamGeneratorScreen extends StatefulWidget {
final String eventId;
final List<Participant> participants;
final List<Team>? existingTeams;
const TeamGeneratorScreen({
Key? key,
required this.eventId,
required this.participants,
this.existingTeams,
}) : super(key: key);
@override
State<TeamGeneratorScreen> createState() => _TeamGeneratorScreenState();
}
class _TeamGeneratorScreenState extends State<TeamGeneratorScreen> {
bool _isLoading = false;
int _teamCount = 2;
bool _balanceTeams = true;
List<Team> _generatedTeams = [];
final _formKey = GlobalKey<FormState>();
// 참가자 선택 상태 관리
final Map<String, bool> _selectedParticipants = {};
@override
void initState() {
super.initState();
// 모든 참가자 기본 선택
for (var participant in widget.participants) {
if (participant.status == '참석함' || participant.status == '확인됨') {
_selectedParticipants[participant.memberId ?? ''] = true;
} else {
_selectedParticipants[participant.memberId ?? ''] = false;
}
}
// 기존 팀이 있으면 팀 수 설정
if (widget.existingTeams != null && widget.existingTeams!.isNotEmpty) {
_teamCount = widget.existingTeams!.length;
_generatedTeams = List.from(widget.existingTeams!);
}
}
// 팀 생성
void _generateTeams() {
if (!_formKey.currentState!.validate()) {
return;
}
// 선택된 참가자 목록 생성
final selectedParticipants = widget.participants
.where((p) => _selectedParticipants[p.memberId] == true)
.toList();
if (selectedParticipants.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('최소 한 명 이상의 참가자를 선택해주세요')),
);
return;
}
if (_teamCount <= 0 || _teamCount > selectedParticipants.length) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('유효한 팀 수를 입력해주세요')),
);
return;
}
setState(() {
_isLoading = true;
});
try {
// 참가자 섞기
final shuffledParticipants = List.from(selectedParticipants)..shuffle();
// 팀 생성
final teams = List.generate(_teamCount, (index) => <Participant>[]);
if (_balanceTeams) {
// 균등하게 팀 배분
for (int i = 0; i < shuffledParticipants.length; i++) {
teams[i % _teamCount].add(shuffledParticipants[i]);
}
} else {
// 랜덤 배분
final random = Random();
for (var participant in shuffledParticipants) {
teams[random.nextInt(_teamCount)].add(participant);
}
}
// Team 객체로 변환
_generatedTeams = List.generate(
_teamCount,
(index) => Team(
id: widget.existingTeams != null && index < widget.existingTeams!.length
? widget.existingTeams![index].id
: '',
eventId: widget.eventId,
name: '${index + 1}',
memberIds: teams[index].map((p) => p.memberId ?? '').toList(),
description: '자동 생성된 팀',
),
);
setState(() {
_isLoading = false;
});
} catch (e) {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('팀 생성 중 오류가 발생했습니다: $e')),
);
}
}
// 팀 저장
Future<void> _saveTeams() async {
if (_generatedTeams.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('먼저 팀을 생성해주세요')),
);
return;
}
setState(() {
_isLoading = true;
});
try {
final eventService = Provider.of<EventService>(context, listen: false);
// 팀 데이터 준비
final teamsData = _generatedTeams.map((team) => {
'eventId': widget.eventId,
'name': team.name,
'memberIds': team.memberIds,
'description': team.description,
}).toList();
// 팀 저장
await eventService.saveTeams(widget.eventId, teamsData);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('팀이 저장되었습니다')),
);
Navigator.of(context).pop(true); // 성공 결과와 함께 이전 화면으로 돌아가기
}
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('팀 저장에 실패했습니다: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('팀 생성'),
actions: [
if (_generatedTeams.isNotEmpty)
IconButton(
icon: const Icon(Icons.save),
onPressed: _saveTeams,
),
],
),
body: _isLoading
? const LoadingIndicator()
: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 팀 생성 설정 섹션
_buildSectionTitle('팀 생성 설정'),
// 팀 수 설정
TextFormField(
initialValue: _teamCount.toString(),
decoration: const InputDecoration(
labelText: '팀 수',
hintText: '생성할 팀 수를 입력하세요',
),
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.isEmpty) {
return '팀 수를 입력해주세요';
}
final teamCount = int.tryParse(value);
if (teamCount == null || teamCount <= 0) {
return '유효한 팀 수를 입력해주세요';
}
return null;
},
onChanged: (value) {
setState(() {
_teamCount = int.tryParse(value) ?? 2;
});
},
),
const SizedBox(height: 16),
// 균등 배분 설정
SwitchListTile(
title: const Text('균등하게 팀 배분'),
subtitle: const Text('참가자를 팀에 균등하게 배분합니다'),
value: _balanceTeams,
onChanged: (value) {
setState(() {
_balanceTeams = value;
});
},
),
const SizedBox(height: 16),
// 팀 생성 버튼
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _generateTeams,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text(
'팀 생성하기',
style: TextStyle(fontSize: 16),
),
),
),
const SizedBox(height: 24),
// 참가자 선택 섹션
_buildSectionTitle('참가자 선택'),
// 전체 선택/해제 버튼
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {
setState(() {
for (var key in _selectedParticipants.keys) {
_selectedParticipants[key] = true;
}
});
},
child: const Text('전체 선택'),
),
const SizedBox(width: 8),
TextButton(
onPressed: () {
setState(() {
for (var key in _selectedParticipants.keys) {
_selectedParticipants[key] = false;
}
});
},
child: const Text('전체 해제'),
),
],
),
const SizedBox(height: 8),
// 참가자 목록
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: widget.participants.length,
itemBuilder: (context, index) {
final participant = widget.participants[index];
return CheckboxListTile(
title: Text(participant.name ?? '이름 없음'),
subtitle: Text(participant.status ?? '상태 없음'),
value: _selectedParticipants[participant.memberId] ?? false,
onChanged: (value) {
setState(() {
_selectedParticipants[participant.memberId ?? ''] = value ?? false;
});
},
);
},
),
if (_generatedTeams.isNotEmpty) ...[
const SizedBox(height: 24),
// 생성된 팀 섹션
_buildSectionTitle('생성된 팀'),
// 팀 목록
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _generatedTeams.length,
itemBuilder: (context, teamIndex) {
final team = _generatedTeams[teamIndex];
// 팀원 목록 생성
final teamMembers = widget.participants
.where((p) => team.memberIds.contains(p.memberId))
.toList();
return Card(
margin: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
title: Text(
'${team.name}',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
subtitle: Text('${teamMembers.length}'),
trailing: IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
// 팀 이름 수정 다이얼로그
showDialog(
context: context,
builder: (context) {
String newName = team.name;
return AlertDialog(
title: const Text('팀 이름 수정'),
content: TextField(
autofocus: true,
decoration: const InputDecoration(
labelText: '팀 이름',
),
onChanged: (value) {
newName = value;
},
controller: TextEditingController(text: team.name),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('취소'),
),
TextButton(
onPressed: () {
setState(() {
_generatedTeams[teamIndex] = Team(
id: team.id,
eventId: team.eventId,
name: newName,
memberIds: team.memberIds,
description: team.description,
);
});
Navigator.of(context).pop();
},
child: const Text('저장'),
),
],
);
},
);
},
),
),
const Divider(),
...teamMembers.map((member) => ListTile(
leading: const Icon(Icons.person),
title: Text(member.name ?? '이름 없음'),
trailing: IconButton(
icon: const Icon(Icons.swap_horiz),
onPressed: () {
// 팀원 이동 다이얼로그
showDialog(
context: context,
builder: (context) {
int targetTeamIndex = teamIndex;
return AlertDialog(
title: const Text('팀원 이동'),
content: DropdownButton<int>(
value: targetTeamIndex,
items: List.generate(
_generatedTeams.length,
(index) => DropdownMenuItem(
value: index,
child: Text('${_generatedTeams[index].name}'),
),
),
onChanged: (value) {
if (value != null) {
targetTeamIndex = value;
}
},
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('취소'),
),
TextButton(
onPressed: () {
if (targetTeamIndex != teamIndex) {
setState(() {
// 현재 팀에서 제거
_generatedTeams[teamIndex] = Team(
id: team.id,
eventId: team.eventId,
name: team.name,
memberIds: team.memberIds
.where((id) => id != member.memberId)
.toList(),
description: team.description,
);
// 대상 팀에 추가
final targetTeam = _generatedTeams[targetTeamIndex];
_generatedTeams[targetTeamIndex] = Team(
id: targetTeam.id,
eventId: targetTeam.eventId,
name: targetTeam.name,
memberIds: [
...targetTeam.memberIds,
member.memberId ?? '',
],
description: targetTeam.description,
);
});
}
Navigator.of(context).pop();
},
child: const Text('이동'),
),
],
);
},
);
},
),
)),
],
),
);
},
),
// 팀 저장 버튼
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _saveTeams,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Colors.green,
),
child: const Text(
'팀 저장하기',
style: TextStyle(fontSize: 16),
),
),
),
],
],
),
),
),
);
}
// 섹션 제목 위젯
Widget _buildSectionTitle(String title) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Divider(),
const SizedBox(height: 8),
],
);
}
}