tdd 진행
This commit is contained in:
+22
-5
@@ -7,6 +7,7 @@ import 'services/club_service.dart';
|
||||
import 'services/member_service.dart';
|
||||
import 'services/event_service.dart';
|
||||
import 'services/score_service.dart';
|
||||
import 'services/notification_service.dart';
|
||||
import 'screens/auth/login_screen.dart';
|
||||
import 'screens/auth/profile_screen.dart';
|
||||
import 'screens/club/dashboard_screen.dart';
|
||||
@@ -20,6 +21,11 @@ final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// 알림 서비스 초기화
|
||||
final notificationService = NotificationService();
|
||||
await notificationService.initialize();
|
||||
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
@@ -299,11 +305,22 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.notifications),
|
||||
onPressed: () {
|
||||
// 알림 기능 (추후 구현)
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('알림 기능은 아직 개발 중입니다')),
|
||||
);
|
||||
onPressed: () async {
|
||||
// 알림 권한 요청
|
||||
final notificationService = NotificationService();
|
||||
final hasPermission = await notificationService.requestPermission();
|
||||
|
||||
if (context.mounted) {
|
||||
if (hasPermission) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('알림 권한이 허용되었습니다')),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('알림 권한이 거부되었습니다. 설정에서 권한을 허용해주세요')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -16,7 +16,7 @@ class Club {
|
||||
final DateTime? updatedAt;
|
||||
|
||||
Club({
|
||||
required this.id,
|
||||
this.id = '', // 기본값 빈 문자열로 설정
|
||||
required this.name,
|
||||
this.description,
|
||||
this.logo,
|
||||
|
||||
@@ -7,8 +7,14 @@ class Event {
|
||||
final DateTime? endDate;
|
||||
final String? location;
|
||||
final String? type;
|
||||
final String? status;
|
||||
final int? maxParticipants;
|
||||
final int? currentParticipants;
|
||||
final int? gameCount;
|
||||
final double? participantFee;
|
||||
final DateTime? registrationDeadline;
|
||||
final String? publicHash;
|
||||
final String? accessPassword;
|
||||
final bool isActive;
|
||||
final String? createdBy;
|
||||
final DateTime? createdAt;
|
||||
@@ -23,8 +29,14 @@ class Event {
|
||||
this.endDate,
|
||||
this.location,
|
||||
this.type,
|
||||
this.status,
|
||||
this.maxParticipants,
|
||||
this.currentParticipants,
|
||||
this.gameCount,
|
||||
this.participantFee,
|
||||
this.registrationDeadline,
|
||||
this.publicHash,
|
||||
this.accessPassword,
|
||||
required this.isActive,
|
||||
this.createdBy,
|
||||
this.createdAt,
|
||||
@@ -42,8 +54,14 @@ class Event {
|
||||
endDate: json['endDate'] != null ? DateTime.parse(json['endDate'].toString()) : null,
|
||||
location: json['location']?.toString(),
|
||||
type: json['type']?.toString(),
|
||||
status: json['status']?.toString(),
|
||||
maxParticipants: json['maxParticipants'],
|
||||
currentParticipants: json['currentParticipants'],
|
||||
gameCount: json['gameCount'],
|
||||
participantFee: json['participantFee'] != null ? double.tryParse(json['participantFee'].toString()) : null,
|
||||
registrationDeadline: json['registrationDeadline'] != null ? DateTime.parse(json['registrationDeadline'].toString()) : null,
|
||||
publicHash: json['publicHash']?.toString(),
|
||||
accessPassword: json['accessPassword']?.toString(),
|
||||
isActive: json['isActive'] ?? true,
|
||||
createdBy: json['createdBy']?.toString(),
|
||||
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'].toString()) : null,
|
||||
@@ -62,8 +80,14 @@ class Event {
|
||||
'endDate': endDate?.toIso8601String(),
|
||||
'location': location,
|
||||
'type': type,
|
||||
'status': status,
|
||||
'maxParticipants': maxParticipants,
|
||||
'currentParticipants': currentParticipants,
|
||||
'gameCount': gameCount,
|
||||
'participantFee': participantFee,
|
||||
'registrationDeadline': registrationDeadline?.toIso8601String(),
|
||||
'publicHash': publicHash,
|
||||
'accessPassword': accessPassword,
|
||||
'isActive': isActive,
|
||||
'createdBy': createdBy,
|
||||
'createdAt': createdAt?.toIso8601String(),
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
class Participant {
|
||||
final String id;
|
||||
final String eventId;
|
||||
final String memberId;
|
||||
final String? name;
|
||||
final String? email;
|
||||
final String? phoneNumber;
|
||||
final String? status; // 'registered', 'confirmed', 'cancelled', 'attended'
|
||||
final DateTime? registeredAt;
|
||||
final DateTime? confirmedAt;
|
||||
final DateTime? cancelledAt;
|
||||
final DateTime? attendedAt;
|
||||
final bool isPaid;
|
||||
final double? paidAmount;
|
||||
final String? notes;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
Participant({
|
||||
required this.id,
|
||||
required this.eventId,
|
||||
required this.memberId,
|
||||
this.name,
|
||||
this.email,
|
||||
this.phoneNumber,
|
||||
this.status,
|
||||
this.registeredAt,
|
||||
this.confirmedAt,
|
||||
this.cancelledAt,
|
||||
this.attendedAt,
|
||||
this.isPaid = false,
|
||||
this.paidAmount,
|
||||
this.notes,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
// JSON 데이터로부터 Participant 객체 생성
|
||||
factory Participant.fromJson(Map<String, dynamic> json) {
|
||||
return Participant(
|
||||
id: json['id']?.toString() ?? '',
|
||||
eventId: json['eventId']?.toString() ?? '',
|
||||
memberId: json['memberId']?.toString() ?? '',
|
||||
name: json['name']?.toString(),
|
||||
email: json['email']?.toString(),
|
||||
phoneNumber: json['phoneNumber']?.toString(),
|
||||
status: json['status']?.toString(),
|
||||
registeredAt: json['registeredAt'] != null ? DateTime.parse(json['registeredAt'].toString()) : null,
|
||||
confirmedAt: json['confirmedAt'] != null ? DateTime.parse(json['confirmedAt'].toString()) : null,
|
||||
cancelledAt: json['cancelledAt'] != null ? DateTime.parse(json['cancelledAt'].toString()) : null,
|
||||
attendedAt: json['attendedAt'] != null ? DateTime.parse(json['attendedAt'].toString()) : null,
|
||||
isPaid: json['isPaid'] ?? false,
|
||||
paidAmount: json['paidAmount'] != null ? double.tryParse(json['paidAmount'].toString()) : null,
|
||||
notes: json['notes']?.toString(),
|
||||
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'].toString()) : null,
|
||||
updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'].toString()) : null,
|
||||
);
|
||||
}
|
||||
|
||||
// Participant 객체를 JSON으로 변환
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'eventId': eventId,
|
||||
'memberId': memberId,
|
||||
'name': name,
|
||||
'email': email,
|
||||
'phoneNumber': phoneNumber,
|
||||
'status': status,
|
||||
'registeredAt': registeredAt?.toIso8601String(),
|
||||
'confirmedAt': confirmedAt?.toIso8601String(),
|
||||
'cancelledAt': cancelledAt?.toIso8601String(),
|
||||
'attendedAt': attendedAt?.toIso8601String(),
|
||||
'isPaid': isPaid,
|
||||
'paidAmount': paidAmount,
|
||||
'notes': notes,
|
||||
'createdAt': createdAt?.toIso8601String(),
|
||||
'updatedAt': updatedAt?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ class Score {
|
||||
final int totalScore;
|
||||
final DateTime date;
|
||||
final String? notes;
|
||||
final String? participantName;
|
||||
|
||||
Score({
|
||||
required this.id,
|
||||
@@ -17,6 +18,7 @@ class Score {
|
||||
required this.totalScore,
|
||||
required this.date,
|
||||
this.notes,
|
||||
this.participantName,
|
||||
});
|
||||
|
||||
factory Score.fromJson(Map<String, dynamic> json) {
|
||||
@@ -29,6 +31,7 @@ class Score {
|
||||
totalScore: json['totalScore'] ?? 0,
|
||||
date: json['date'] != null ? DateTime.parse(json['date'].toString()) : DateTime.now(),
|
||||
notes: json['notes']?.toString(),
|
||||
participantName: json['participantName']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +45,7 @@ class Score {
|
||||
'totalScore': totalScore,
|
||||
'date': date.toIso8601String(),
|
||||
'notes': notes,
|
||||
'participantName': participantName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,45 @@ class SubscriptionPlan {
|
||||
required this.hasPriority,
|
||||
});
|
||||
|
||||
/// JSON에서 구독 플랜 객체 생성
|
||||
factory SubscriptionPlan.fromJson(Map<String, dynamic> json) {
|
||||
return SubscriptionPlan(
|
||||
type: SubscriptionPlanType.values.firstWhere(
|
||||
(e) => e.toString().split('.').last == json['type'],
|
||||
orElse: () => SubscriptionPlanType.free,
|
||||
),
|
||||
name: json['name'],
|
||||
description: json['description'],
|
||||
monthlyPrice: json['monthlyPrice'].toDouble(),
|
||||
yearlyPrice: json['yearlyPrice'].toDouble(),
|
||||
currency: json['currency'],
|
||||
features: List<String>.from(json['features']),
|
||||
maxMembers: json['maxMembers'],
|
||||
maxEvents: json['maxEvents'],
|
||||
hasAdvancedStats: json['hasAdvancedStats'] ?? false,
|
||||
hasCustomization: json['hasCustomization'] ?? false,
|
||||
hasPriority: json['hasPriority'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/// 구독 플랜 객체를 JSON으로 변환
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.toString().split('.').last,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'monthlyPrice': monthlyPrice,
|
||||
'yearlyPrice': yearlyPrice,
|
||||
'currency': currency,
|
||||
'features': features,
|
||||
'maxMembers': maxMembers,
|
||||
'maxEvents': maxEvents,
|
||||
'hasAdvancedStats': hasAdvancedStats,
|
||||
'hasCustomization': hasCustomization,
|
||||
'hasPriority': hasPriority,
|
||||
};
|
||||
}
|
||||
|
||||
/// 기본 구독 플랜 목록 반환
|
||||
static List<SubscriptionPlan> getDefaultPlans() {
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
class Team {
|
||||
final String id;
|
||||
final String eventId;
|
||||
final String name;
|
||||
final List<String> memberIds;
|
||||
final String? description;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
Team({
|
||||
required this.id,
|
||||
required this.eventId,
|
||||
required this.name,
|
||||
required this.memberIds,
|
||||
this.description,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
// JSON 데이터로부터 Team 객체 생성
|
||||
factory Team.fromJson(Map<String, dynamic> json) {
|
||||
return Team(
|
||||
id: json['id']?.toString() ?? '',
|
||||
eventId: json['eventId']?.toString() ?? '',
|
||||
name: json['name']?.toString() ?? '',
|
||||
memberIds: json['memberIds'] != null ? List<String>.from(json['memberIds']) : [],
|
||||
description: json['description']?.toString(),
|
||||
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'].toString()) : null,
|
||||
updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'].toString()) : null,
|
||||
);
|
||||
}
|
||||
|
||||
// Team 객체를 JSON으로 변환
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'eventId': eventId,
|
||||
'name': name,
|
||||
'memberIds': memberIds,
|
||||
'description': description,
|
||||
'createdAt': createdAt?.toIso8601String(),
|
||||
'updatedAt': updatedAt?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:cookie_jar/cookie_jar.dart';
|
||||
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../config/api_config.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../screens/auth/login_screen.dart';
|
||||
@@ -23,8 +24,10 @@ class ApiService {
|
||||
contentType: 'application/json',
|
||||
));
|
||||
|
||||
// 쿠키 관리자 설정
|
||||
_dio.interceptors.add(CookieManager(_cookieJar));
|
||||
// 쿠키 관리자 설정 (웹 환경에서는 사용하지 않음)
|
||||
if (!kIsWeb) {
|
||||
_dio.interceptors.add(CookieManager(_cookieJar));
|
||||
}
|
||||
|
||||
// 로그 인터셉터 (디버깅용)
|
||||
_dio.interceptors.add(LogInterceptor(
|
||||
|
||||
@@ -12,7 +12,13 @@ class ClubService with ChangeNotifier {
|
||||
Club? _currentClub;
|
||||
bool _isLoading = false;
|
||||
String? _token;
|
||||
final ApiService _apiService = ApiService();
|
||||
ApiService _apiService;
|
||||
|
||||
// 기본 생성자
|
||||
ClubService() : _apiService = ApiService();
|
||||
|
||||
// 테스트용 생성자
|
||||
ClubService.forTest(this._apiService);
|
||||
|
||||
List<Club> get clubs => [..._clubs];
|
||||
Club? get currentClub => _currentClub;
|
||||
|
||||
@@ -15,6 +15,11 @@ class EventBus {
|
||||
_streamController.add(event);
|
||||
}
|
||||
|
||||
// 특정 타입의 이벤트를 구독하는 메서드
|
||||
Stream<T> on<T>() {
|
||||
return stream.where((event) => event is T).cast<T>();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_streamController.close();
|
||||
}
|
||||
|
||||
@@ -3,17 +3,33 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../config/api_config.dart';
|
||||
import '../models/event_model.dart';
|
||||
import '../models/participant_model.dart';
|
||||
import '../models/score_model.dart';
|
||||
import '../models/team_model.dart';
|
||||
import './api_service.dart';
|
||||
|
||||
class EventService with ChangeNotifier {
|
||||
List<Event> _events = [];
|
||||
Event? _currentEvent;
|
||||
List<Participant> _participants = [];
|
||||
List<Score> _scores = [];
|
||||
List<Team> _teams = [];
|
||||
bool _isLoading = false;
|
||||
String? _token;
|
||||
final ApiService _apiService = ApiService();
|
||||
ApiService _apiService;
|
||||
String? _clubId;
|
||||
|
||||
// 기본 생성자
|
||||
EventService() : _apiService = ApiService();
|
||||
|
||||
// 테스트용 생성자
|
||||
EventService.forTest(this._apiService);
|
||||
|
||||
List<Event> get events => [..._events];
|
||||
Event? get currentEvent => _currentEvent;
|
||||
List<Participant> get participants => [..._participants];
|
||||
List<Score> get scores => [..._scores];
|
||||
List<Team> get teams => [..._teams];
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
// 초기화 함수
|
||||
@@ -182,4 +198,464 @@ class EventService with ChangeNotifier {
|
||||
throw Exception('이벤트 삭제에 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 참가자 관리 기능
|
||||
// 이벤트 참가자 목록 가져오기
|
||||
Future<List<Participant>> fetchEventParticipants(String eventId) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
throw Exception('인증 또는 클럽 정보가 필요합니다');
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}/events/participants',
|
||||
data: {'eventId': eventId},
|
||||
);
|
||||
|
||||
final List<dynamic> participantsData = data['participants'] ?? [];
|
||||
_participants = participantsData
|
||||
.map((participantData) => Participant.fromJson(participantData))
|
||||
.toList();
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
return _participants;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
throw Exception('참가자 목록을 불러오는데 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 참가자 추가
|
||||
Future<Participant> addParticipant(
|
||||
String eventId,
|
||||
Map<String, dynamic> participantData,
|
||||
) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
throw Exception('인증 또는 클럽 정보가 필요합니다');
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}/events/$eventId/participants',
|
||||
data: participantData,
|
||||
);
|
||||
|
||||
final newParticipant = Participant.fromJson(data['participant']);
|
||||
|
||||
_participants.add(newParticipant);
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
return newParticipant;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
throw Exception('참가자 추가에 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 참가자 정보 업데이트
|
||||
Future<Participant> updateParticipant(
|
||||
String eventId,
|
||||
String participantId,
|
||||
Map<String, dynamic> updates,
|
||||
) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
throw Exception('인증 또는 클럽 정보가 필요합니다');
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final data = await _apiService.put(
|
||||
'${ApiConfig.clubs}/events/$eventId/participants/$participantId',
|
||||
data: updates,
|
||||
);
|
||||
|
||||
final updatedParticipant = Participant.fromJson(data['participant']);
|
||||
|
||||
// 참가자 목록 업데이트
|
||||
final index = _participants.indexWhere(
|
||||
(participant) => participant.id == participantId,
|
||||
);
|
||||
if (index >= 0) {
|
||||
_participants[index] = updatedParticipant;
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
return updatedParticipant;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
throw Exception('참가자 정보 업데이트에 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 참가자 삭제
|
||||
Future<bool> removeParticipant(String eventId, String participantId) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
throw Exception('인증 또는 클럽 정보가 필요합니다');
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await _apiService.delete(
|
||||
'${ApiConfig.clubs}/events/$eventId/participants/$participantId',
|
||||
);
|
||||
|
||||
// 참가자 목록에서 삭제
|
||||
_participants.removeWhere(
|
||||
(participant) => participant.id == participantId,
|
||||
);
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
throw Exception('참가자 삭제에 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 참가자 상태 업데이트
|
||||
Future<Participant> updateParticipantStatus(
|
||||
String eventId,
|
||||
String participantId,
|
||||
String status,
|
||||
) async {
|
||||
return updateParticipant(eventId, participantId, {'status': status});
|
||||
}
|
||||
|
||||
// 점수 관리 기능
|
||||
// 이벤트 점수 목록 가져오기
|
||||
Future<List<Score>> fetchEventScores(String eventId) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
throw Exception('인증 또는 클럽 정보가 필요합니다');
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}/events/scores',
|
||||
data: {'eventId': eventId},
|
||||
);
|
||||
|
||||
final List<dynamic> scoresData = data['scores'] ?? [];
|
||||
_scores = scoresData
|
||||
.map((scoreData) => Score.fromJson(scoreData))
|
||||
.toList();
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
return _scores;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
throw Exception('점수 목록을 불러오는데 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 점수 추가
|
||||
Future<Score> addScore(String eventId, Map<String, dynamic> scoreData) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
throw Exception('인증 또는 클럽 정보가 필요합니다');
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}/events/$eventId/scores',
|
||||
data: scoreData,
|
||||
);
|
||||
|
||||
final newScore = Score.fromJson(data['score']);
|
||||
|
||||
_scores.add(newScore);
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
return newScore;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
throw Exception('점수 추가에 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 점수 업데이트
|
||||
Future<Score> updateScore(
|
||||
String eventId,
|
||||
String scoreId,
|
||||
Map<String, dynamic> updates,
|
||||
) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
throw Exception('인증 또는 클럽 정보가 필요합니다');
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final data = await _apiService.put(
|
||||
'${ApiConfig.clubs}/events/$eventId/scores/$scoreId',
|
||||
data: updates,
|
||||
);
|
||||
|
||||
final updatedScore = Score.fromJson(data['score']);
|
||||
|
||||
// 점수 목록 업데이트
|
||||
final index = _scores.indexWhere((score) => score.id == scoreId);
|
||||
if (index >= 0) {
|
||||
_scores[index] = updatedScore;
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
return updatedScore;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
throw Exception('점수 업데이트에 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 점수 삭제
|
||||
Future<bool> deleteScore(String eventId, String scoreId) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
throw Exception('인증 또는 클럽 정보가 필요합니다');
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await _apiService.delete(
|
||||
'${ApiConfig.clubs}/events/$eventId/scores/$scoreId',
|
||||
);
|
||||
|
||||
// 점수 목록에서 삭제
|
||||
_scores.removeWhere((score) => score.id == scoreId);
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
throw Exception('점수 삭제에 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 팀 관리 기능
|
||||
// 이벤트 팀 목록 가져오기
|
||||
Future<List<Team>> fetchEventTeams(String eventId) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
throw Exception('인증 또는 클럽 정보가 필요합니다');
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}/events/teams',
|
||||
data: {'eventId': eventId},
|
||||
);
|
||||
|
||||
final List<dynamic> teamsData = data['teams'] ?? [];
|
||||
_teams = teamsData.map((teamData) => Team.fromJson(teamData)).toList();
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
return _teams;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
throw Exception('팀 목록을 불러오는데 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 팀 생성
|
||||
Future<List<Team>> generateTeams(
|
||||
String eventId,
|
||||
Map<String, dynamic> teamConfig,
|
||||
) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
throw Exception('인증 또는 클럽 정보가 필요합니다');
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}/events/$eventId/teams/generate',
|
||||
data: teamConfig,
|
||||
);
|
||||
|
||||
final List<dynamic> teamsData = data['teams'] ?? [];
|
||||
_teams = teamsData.map((teamData) => Team.fromJson(teamData)).toList();
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
return _teams;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
throw Exception('팀 생성에 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 팀 저장
|
||||
Future<bool> saveTeams(String eventId, List<Team> teams) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
throw Exception('인증 또는 클럽 정보가 필요합니다');
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await _apiService.post(
|
||||
'${ApiConfig.clubs}/events/$eventId/teams/save',
|
||||
data: {'teams': teams.map((team) => team.toJson()).toList()},
|
||||
);
|
||||
|
||||
_teams = teams;
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
throw Exception('팀 저장에 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 파일에서 이벤트 일괄 생성
|
||||
Future<List<Event>> createEventsFromFile(
|
||||
List<Map<String, dynamic>> eventsData,
|
||||
) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
throw Exception('인증 또는 클럽 정보가 필요합니다');
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final List<Event> createdEvents = [];
|
||||
|
||||
// 각 이벤트 데이터에 클럽 ID 추가
|
||||
for (var eventData in eventsData) {
|
||||
eventData['clubId'] = _clubId;
|
||||
|
||||
// null 값 처리 - 빈 문자열을 명시적 null로 변환
|
||||
eventData.forEach((key, value) {
|
||||
if (value is String && value.isEmpty) {
|
||||
eventData[key] = null;
|
||||
}
|
||||
});
|
||||
|
||||
// 이벤트 생성 API 호출
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}/events',
|
||||
data: eventData,
|
||||
);
|
||||
|
||||
final newEvent = Event.fromJson(data['event']);
|
||||
createdEvents.add(newEvent);
|
||||
_events.add(newEvent);
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
return createdEvents;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
throw Exception('파일에서 이벤트 생성에 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 이벤트 복제
|
||||
Future<Event> cloneEvent(String eventId, {String? newName}) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
throw Exception('인증 또는 클럽 정보가 필요합니다');
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// 원본 이벤트 정보 가져오기
|
||||
final originalEvent = await fetchEventById(eventId);
|
||||
|
||||
// 복제할 이벤트 데이터 준비
|
||||
final Map<String, dynamic> cloneData = {
|
||||
'clubId': _clubId,
|
||||
'title': newName ?? '${originalEvent.title} (복사본)',
|
||||
'description': originalEvent.description,
|
||||
'location': originalEvent.location,
|
||||
'type': originalEvent.type,
|
||||
'status': 'draft', // 복제된 이벤트는 초안 상태로 시작
|
||||
'startDate': originalEvent.startDate.toIso8601String(),
|
||||
'endDate': originalEvent.endDate?.toIso8601String(),
|
||||
'maxParticipants': originalEvent.maxParticipants,
|
||||
'participantFee': originalEvent.participantFee,
|
||||
'gameCount': originalEvent.gameCount,
|
||||
'publicHash': originalEvent.publicHash,
|
||||
'accessPassword': originalEvent.accessPassword,
|
||||
'isActive': true,
|
||||
};
|
||||
|
||||
// 이벤트 생성 API 호출
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}/events',
|
||||
data: cloneData,
|
||||
);
|
||||
|
||||
final newEvent = Event.fromJson(data['event']);
|
||||
_events.add(newEvent);
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
return newEvent;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
throw Exception('이벤트 복제에 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,16 @@ class InAppPurchaseService extends ChangeNotifier {
|
||||
bool get isLoading => _isLoading;
|
||||
String? get error => _error;
|
||||
|
||||
// 구매 검증 관련 게터/세터
|
||||
bool get isPurchaseVerified => _purchaseVerified;
|
||||
set isPurchaseVerified(bool value) => _purchaseVerified = value;
|
||||
PurchaseDetails? get lastVerifiedPurchase => _purchases.isNotEmpty ? _purchases.last : null;
|
||||
set lastVerifiedPurchase(PurchaseDetails? value) {
|
||||
if (value != null && !_purchases.contains(value)) {
|
||||
_purchases.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
InAppPurchaseService() {
|
||||
_initialize();
|
||||
}
|
||||
@@ -195,9 +205,8 @@ class InAppPurchaseService extends ChangeNotifier {
|
||||
String platform = Platform.isIOS ? 'ios' : 'android';
|
||||
|
||||
if (Platform.isIOS) {
|
||||
final InAppPurchaseStoreKitPlatformAddition iosPlatformAddition =
|
||||
_inAppPurchase.getPlatformAddition<InAppPurchaseStoreKitPlatformAddition>();
|
||||
receiptData = await iosPlatformAddition.retrieveReceiptData();
|
||||
// iOS의 경우 구매 정보에서 직접 가져오기
|
||||
receiptData = purchaseDetails.verificationData.serverVerificationData;
|
||||
} else if (Platform.isAndroid) {
|
||||
if (purchaseDetails is GooglePlayPurchaseDetails) {
|
||||
receiptData = purchaseDetails.billingClientPurchase.originalJson;
|
||||
|
||||
@@ -13,8 +13,14 @@ class MemberService with ChangeNotifier {
|
||||
bool _isLoading = false;
|
||||
String? _token;
|
||||
String? _clubId;
|
||||
final ApiService _apiService = ApiService();
|
||||
final ApiService _apiService;
|
||||
StreamSubscription? _eventSubscription;
|
||||
|
||||
// 기본 생성자
|
||||
MemberService() : _apiService = ApiService();
|
||||
|
||||
// 테스트용 생성자
|
||||
MemberService.forTest(this._apiService);
|
||||
|
||||
List<Member> get members => [..._members];
|
||||
bool get isLoading => _isLoading;
|
||||
@@ -60,9 +66,14 @@ class MemberService with ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// 클럽 ID 가져오기
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final clubId = prefs.getString(ApiConfig.clubIdKey);
|
||||
// 클럽 ID 가져오기 - 메모리에 저장된 값을 우선 사용
|
||||
String? clubId = _clubId;
|
||||
|
||||
// 메모리에 없으면 SharedPreferences에서 가져오기
|
||||
if (clubId == null) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
clubId = prefs.getString(ApiConfig.clubIdKey);
|
||||
}
|
||||
|
||||
if (clubId == null) {
|
||||
throw Exception('선택된 클럽이 없습니다');
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
import 'package:timezone/data/latest.dart' as tz_data;
|
||||
|
||||
import '../models/event_model.dart';
|
||||
|
||||
class NotificationService {
|
||||
static NotificationService _instance = NotificationService._internal();
|
||||
factory NotificationService() => _instance;
|
||||
NotificationService._internal();
|
||||
|
||||
// 테스트용 인스턴스 설정 메서드
|
||||
static void setTestInstance(NotificationService instance) {
|
||||
_instance = instance;
|
||||
}
|
||||
|
||||
final FlutterLocalNotificationsPlugin _notificationsPlugin = FlutterLocalNotificationsPlugin();
|
||||
bool _isInitialized = false;
|
||||
|
||||
// 알림 서비스 초기화
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
// 타임존 초기화
|
||||
tz_data.initializeTimeZones();
|
||||
|
||||
// 안드로이드 설정
|
||||
const AndroidInitializationSettings androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
// iOS 설정
|
||||
const DarwinInitializationSettings iOSSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
);
|
||||
|
||||
// 초기화 설정
|
||||
const InitializationSettings initSettings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iOSSettings,
|
||||
);
|
||||
|
||||
// 알림 플러그인 초기화
|
||||
await _notificationsPlugin.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: (NotificationResponse response) {
|
||||
// 알림 클릭 시 처리
|
||||
debugPrint('알림 클릭: ${response.payload}');
|
||||
},
|
||||
);
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('알림 서비스 초기화 완료');
|
||||
}
|
||||
|
||||
// 권한 요청
|
||||
Future<bool> requestPermission() async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
// iOS에서 권한 요청
|
||||
final bool? result = await _notificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()
|
||||
?.requestPermissions(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
// 즉시 알림 표시
|
||||
Future<void> showNotification({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
String? payload,
|
||||
}) async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
const AndroidNotificationDetails androidDetails = AndroidNotificationDetails(
|
||||
'event_channel',
|
||||
'이벤트 알림',
|
||||
channelDescription: '이벤트 관련 알림을 표시합니다',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
showWhen: true,
|
||||
);
|
||||
|
||||
const DarwinNotificationDetails iOSDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const NotificationDetails notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iOSDetails,
|
||||
);
|
||||
|
||||
await _notificationsPlugin.show(
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
notificationDetails,
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
|
||||
// 예약 알림 설정
|
||||
Future<void> scheduleNotification({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
required DateTime scheduledDate,
|
||||
String? payload,
|
||||
}) async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
final tz.TZDateTime scheduledTZDate = tz.TZDateTime.from(
|
||||
scheduledDate,
|
||||
tz.local,
|
||||
);
|
||||
|
||||
const AndroidNotificationDetails androidDetails = AndroidNotificationDetails(
|
||||
'event_reminder_channel',
|
||||
'이벤트 리마인더',
|
||||
channelDescription: '예정된 이벤트에 대한 알림을 표시합니다',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
);
|
||||
|
||||
const DarwinNotificationDetails iOSDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const NotificationDetails notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iOSDetails,
|
||||
);
|
||||
|
||||
await _notificationsPlugin.zonedSchedule(
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
scheduledTZDate,
|
||||
notificationDetails,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
matchDateTimeComponents: DateTimeComponents.time,
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
|
||||
// 이벤트 시작 알림 설정
|
||||
Future<void> scheduleEventStartReminder(Event event) async {
|
||||
if (event.id.isEmpty) return;
|
||||
|
||||
// 이벤트 시작 1시간 전 알림
|
||||
final DateTime reminderTime = event.startDate.subtract(const Duration(hours: 1));
|
||||
|
||||
// 현재 시간이 알림 시간보다 이후라면 알림을 설정하지 않음
|
||||
if (reminderTime.isBefore(DateTime.now())) return;
|
||||
|
||||
await scheduleNotification(
|
||||
id: event.id.hashCode,
|
||||
title: '이벤트 시작 알림',
|
||||
body: '${event.title} 이벤트가 1시간 후에 시작됩니다.',
|
||||
scheduledDate: reminderTime,
|
||||
payload: event.id,
|
||||
);
|
||||
}
|
||||
|
||||
// 이벤트 등록 마감 알림 설정
|
||||
Future<void> scheduleRegistrationDeadlineReminder(Event event) async {
|
||||
if (event.id.isEmpty || event.registrationDeadline == null) return;
|
||||
|
||||
// 등록 마감 1일 전 알림
|
||||
final DateTime reminderTime = event.registrationDeadline!.subtract(const Duration(days: 1));
|
||||
|
||||
// 현재 시간이 알림 시간보다 이후라면 알림을 설정하지 않음
|
||||
if (reminderTime.isBefore(DateTime.now())) return;
|
||||
|
||||
await scheduleNotification(
|
||||
id: event.id.hashCode + 1, // 이벤트 시작 알림과 ID가 겹치지 않도록 함
|
||||
title: '이벤트 등록 마감 알림',
|
||||
body: '${event.title} 이벤트의 등록이 내일 마감됩니다.',
|
||||
scheduledDate: reminderTime,
|
||||
payload: event.id,
|
||||
);
|
||||
}
|
||||
|
||||
// 특정 이벤트의 모든 알림 취소
|
||||
Future<void> cancelEventNotifications(String eventId) async {
|
||||
await _notificationsPlugin.cancel(eventId.hashCode);
|
||||
await _notificationsPlugin.cancel(eventId.hashCode + 1);
|
||||
}
|
||||
|
||||
// 모든 알림 취소
|
||||
Future<void> cancelAllNotifications() async {
|
||||
await _notificationsPlugin.cancelAll();
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,30 @@ class ScoreService with ChangeNotifier {
|
||||
bool _isLoading = false;
|
||||
String? _token;
|
||||
String? _clubId;
|
||||
final ApiService _apiService = ApiService();
|
||||
ApiService _apiService = ApiService();
|
||||
String? _memberId;
|
||||
String? _eventId;
|
||||
|
||||
// 테스트용 생성자
|
||||
ScoreService.forTest(ApiService apiService, {String? clubId, String? memberId, String? eventId}) {
|
||||
_apiService = apiService;
|
||||
if (clubId != null) {
|
||||
_clubId = clubId;
|
||||
}
|
||||
if (memberId != null) {
|
||||
_memberId = memberId;
|
||||
}
|
||||
if (eventId != null) {
|
||||
_eventId = eventId;
|
||||
}
|
||||
}
|
||||
|
||||
// 테스트용 getter
|
||||
String? get testMemberId => _memberId;
|
||||
String? get testEventId => _eventId;
|
||||
|
||||
// 기본 생성자
|
||||
ScoreService();
|
||||
|
||||
List<Score> get scores => [..._scores];
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
@@ -10,6 +10,8 @@ import 'in_app_purchase_service.dart';
|
||||
class SubscriptionService extends ChangeNotifier {
|
||||
final String _baseUrl = ApiConfig.baseUrl;
|
||||
final String _token;
|
||||
final String _clubId;
|
||||
final String _userId;
|
||||
|
||||
Subscription? _currentSubscription;
|
||||
List<SubscriptionPlan> _availablePlans = [];
|
||||
@@ -17,9 +19,42 @@ class SubscriptionService extends ChangeNotifier {
|
||||
String? _error;
|
||||
|
||||
// 인앱 결제 서비스
|
||||
final InAppPurchaseService _purchaseService = InAppPurchaseService();
|
||||
final InAppPurchaseService _purchaseService;
|
||||
final http.Client _httpClient;
|
||||
|
||||
SubscriptionService(this._token) {
|
||||
// 기본 생성자
|
||||
SubscriptionService(this._token, [this._clubId = '', this._userId = '']) :
|
||||
_purchaseService = InAppPurchaseService(),
|
||||
_httpClient = http.Client() {
|
||||
_loadAvailablePlans();
|
||||
_initializeInAppPurchase();
|
||||
}
|
||||
|
||||
// 테스트용 생성자
|
||||
factory SubscriptionService.forTest({
|
||||
required http.Client client,
|
||||
required InAppPurchaseService purchaseService,
|
||||
required String userId,
|
||||
required String token,
|
||||
required String clubId,
|
||||
}) {
|
||||
return SubscriptionService._internal(
|
||||
token,
|
||||
clubId,
|
||||
userId,
|
||||
client,
|
||||
purchaseService,
|
||||
);
|
||||
}
|
||||
|
||||
// 내부 생성자
|
||||
SubscriptionService._internal(
|
||||
this._token,
|
||||
this._clubId,
|
||||
this._userId,
|
||||
this._httpClient,
|
||||
this._purchaseService,
|
||||
) {
|
||||
_loadAvailablePlans();
|
||||
_initializeInAppPurchase();
|
||||
}
|
||||
@@ -34,7 +69,7 @@ class SubscriptionService extends ChangeNotifier {
|
||||
|
||||
// 인앱 결제 관련 게터
|
||||
InAppPurchaseService get purchaseService => _purchaseService;
|
||||
List<ProductDetails> get products => _purchaseService.products;
|
||||
List<dynamic> get products => _purchaseService.products;
|
||||
|
||||
/// 현재 구독 정보 로드
|
||||
Future<void> loadCurrentSubscription() async {
|
||||
@@ -43,7 +78,7 @@ class SubscriptionService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final response = await http.post(
|
||||
final response = await _httpClient.post(
|
||||
Uri.parse('$_baseUrl${ApiConfig.currentSubscription}'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -66,9 +101,11 @@ class SubscriptionService extends ChangeNotifier {
|
||||
status: SubscriptionStatus.active,
|
||||
startDate: DateTime.now(),
|
||||
endDate: DateTime.now().add(const Duration(days: 36500)), // 100년
|
||||
price: 0,
|
||||
currency: 'KRW',
|
||||
autoRenew: false,
|
||||
lastPaymentDate: null,
|
||||
nextPaymentDate: null,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
} else {
|
||||
_error = '구독 정보를 불러오는데 실패했습니다. (${response.statusCode})';
|
||||
@@ -88,7 +125,7 @@ class SubscriptionService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final response = await http.get(
|
||||
final response = await _httpClient.get(
|
||||
Uri.parse('$_baseUrl${ApiConfig.subscriptionPlans}'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -125,20 +162,21 @@ class SubscriptionService extends ChangeNotifier {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
|
||||
try {
|
||||
// 1. 인앱 결제 복원 요청
|
||||
final restoreSuccess = await _purchaseService.restorePurchases();
|
||||
if (!restoreSuccess) {
|
||||
_error = '구독 복원에 실패했습니다.';
|
||||
// 인앱 구매 복원 시도
|
||||
final restored = await _purchaseService.restorePurchases();
|
||||
|
||||
if (restored) {
|
||||
// 서버에서 현재 구독 정보 가져오기
|
||||
await loadCurrentSubscription();
|
||||
return true;
|
||||
} else {
|
||||
_error = '구독을 복원할 수 없습니다.';
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 서버에서 최신 구독 정보 조회
|
||||
await fetchCurrentSubscription();
|
||||
return true;
|
||||
} catch (e) {
|
||||
_error = '구독 복원에 실패했습니다: $e';
|
||||
_error = '구독 복원 중 오류가 발생했습니다: $e';
|
||||
return false;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
@@ -153,8 +191,8 @@ class SubscriptionService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/api/subscriptions/verify'),
|
||||
final response = await _httpClient.get(
|
||||
Uri.parse('$_baseUrl${ApiConfig.currentSubscription}'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $_token',
|
||||
@@ -204,7 +242,7 @@ class SubscriptionService extends ChangeNotifier {
|
||||
// 구매 검증이 완료될 때까지 최대 10회 확인
|
||||
while (!verified && attempts < maxAttempts) {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
verified = _purchaseService._purchaseVerified;
|
||||
verified = _purchaseService.isPurchaseVerified;
|
||||
attempts++;
|
||||
|
||||
// 오류가 발생한 경우
|
||||
@@ -224,7 +262,7 @@ class SubscriptionService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// 검증된 구매 정보 가져오기
|
||||
final verifiedPurchase = _purchaseService._verifiedPurchase;
|
||||
final verifiedPurchase = _purchaseService.lastVerifiedPurchase;
|
||||
if (verifiedPurchase == null) {
|
||||
_error = '검증된 구매 정보를 찾을 수 없습니다.';
|
||||
_isLoading = false;
|
||||
@@ -233,7 +271,7 @@ class SubscriptionService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// 서버에 구독 생성 요청
|
||||
final response = await http.post(
|
||||
final response = await _httpClient.post(
|
||||
Uri.parse('$_baseUrl${ApiConfig.subscribeClub}'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -243,9 +281,9 @@ class SubscriptionService extends ChangeNotifier {
|
||||
'planType': planType.toString().split('.').last,
|
||||
'isYearly': isYearly,
|
||||
'clubId': _clubId,
|
||||
'transactionId': verifiedPurchase['transactionId'],
|
||||
'productId': verifiedPurchase['productId'],
|
||||
'verificationData': verifiedPurchase['verificationData'],
|
||||
'transactionId': verifiedPurchase.purchaseID,
|
||||
'productId': verifiedPurchase.productID,
|
||||
'verificationData': verifiedPurchase.verificationData.serverVerificationData,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -254,8 +292,8 @@ class SubscriptionService extends ChangeNotifier {
|
||||
_currentSubscription = Subscription.fromJson(data);
|
||||
|
||||
// 구매 검증 상태 초기화
|
||||
_purchaseService._purchaseVerified = false;
|
||||
_purchaseService._verifiedPurchase = null;
|
||||
_purchaseService.isPurchaseVerified = false;
|
||||
_purchaseService.lastVerifiedPurchase = null;
|
||||
|
||||
notifyListeners();
|
||||
return true;
|
||||
@@ -285,8 +323,8 @@ class SubscriptionService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$_baseUrl/api/subscriptions/${_currentSubscription!.id}/cancel'),
|
||||
final response = await _httpClient.post(
|
||||
Uri.parse('$_baseUrl${ApiConfig.subscriptions}/${_currentSubscription!.id}/cancel'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $_token',
|
||||
@@ -324,7 +362,7 @@ class SubscriptionService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final response = await http.post(
|
||||
final response = await _httpClient.post(
|
||||
Uri.parse('$_baseUrl${ApiConfig.changePlan}'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -369,7 +407,7 @@ class SubscriptionService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final response = await http.post(
|
||||
final response = await _httpClient.post(
|
||||
Uri.parse('$_baseUrl${ApiConfig.subscriptions}/autorenew'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import 'package:excel/excel.dart';
|
||||
|
||||
/// 엑셀 파일에서 이벤트 데이터를 파싱하는 유틸리티 클래스
|
||||
class EventExcelParser {
|
||||
/// 엑셀 파일 바이트 데이터에서 이벤트 데이터 목록을 추출합니다.
|
||||
///
|
||||
/// [bytes] 엑셀 파일의 바이트 데이터
|
||||
///
|
||||
/// 반환값:
|
||||
/// - processedRows: 처리된 총 행 수
|
||||
/// - validEvents: 유효한 이벤트 데이터 목록
|
||||
/// - invalidRows: 유효하지 않은 행 수
|
||||
static Map<String, dynamic> parseExcelBytes(List<int> bytes) {
|
||||
final excel = Excel.decodeBytes(bytes);
|
||||
final events = <Map<String, dynamic>>[];
|
||||
int invalidRows = 0;
|
||||
int processedRows = 0;
|
||||
|
||||
// 첫 번째 시트 사용
|
||||
final sheet = excel.tables.keys.first;
|
||||
final rows = excel.tables[sheet]?.rows;
|
||||
|
||||
if (rows == null || rows.isEmpty) {
|
||||
return {
|
||||
'processedRows': 0,
|
||||
'validEvents': [],
|
||||
'invalidRows': 0,
|
||||
'error': '엑셀 파일에 데이터가 없습니다.'
|
||||
};
|
||||
}
|
||||
|
||||
// 헤더 행 추출 (첫 번째 행)
|
||||
final headers = <String>[];
|
||||
for (final cell in rows[0]) {
|
||||
if (cell?.value != null) {
|
||||
headers.add(cell!.value.toString().trim().toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
// 필수 필드 확인
|
||||
if (!headers.contains('title') || !headers.contains('startdate')) {
|
||||
return {
|
||||
'processedRows': 0,
|
||||
'validEvents': [],
|
||||
'invalidRows': 0,
|
||||
'error': '필수 필드(title, startDate)가 없습니다.'
|
||||
};
|
||||
}
|
||||
|
||||
// 데이터 행 처리 (헤더 제외)
|
||||
for (var i = 1; i < rows.length; i++) {
|
||||
processedRows++;
|
||||
final row = rows[i];
|
||||
final event = <String, dynamic>{};
|
||||
|
||||
// 각 셀 처리
|
||||
for (var j = 0; j < headers.length && j < row.length; j++) {
|
||||
final header = headers[j];
|
||||
final value = row[j]?.value;
|
||||
|
||||
// 날짜 필드 특별 처리
|
||||
if (header == 'startdate' || header == 'enddate') {
|
||||
DateTime? date = _parseDate(value);
|
||||
|
||||
// 날짜 값 설정
|
||||
if (date != null) {
|
||||
// 헤더 이름 정규화 (startdate -> startDate)
|
||||
final normalizedHeader = header == 'startdate' ? 'startDate' : 'endDate';
|
||||
event[normalizedHeader] = date.toIso8601String();
|
||||
} else {
|
||||
// 날짜 파싱 실패 시 startDate는 필수이므로 현재 날짜 사용
|
||||
if (header == 'startdate') {
|
||||
event['startDate'] = DateTime.now().toIso8601String();
|
||||
} else {
|
||||
// endDate는 선택적이므로 null 설정
|
||||
event['endDate'] = null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 문자열 필드의 경우 빈 문자열은 null로 처리
|
||||
final stringValue = value?.toString().trim() ?? '';
|
||||
|
||||
// 헤더 이름 정규화 (특별한 경우)
|
||||
String normalizedHeader = header;
|
||||
if (header == 'title' || header == 'description' || header == 'location' ||
|
||||
header == 'status' || header == 'type' || header == 'maxparticipants') {
|
||||
// 첫 글자를 소문자로 유지하고 나머지는 원래 형식 유지
|
||||
normalizedHeader = header == 'maxparticipants' ? 'maxParticipants' : header;
|
||||
}
|
||||
|
||||
// 빈 문자열은 null로 처리
|
||||
event[normalizedHeader] = stringValue.isEmpty ? null : stringValue;
|
||||
}
|
||||
}
|
||||
|
||||
// 필수 필드가 있는 경우만 추가
|
||||
if (event.containsKey('title') && event.containsKey('startDate')) {
|
||||
events.add(event);
|
||||
} else {
|
||||
invalidRows++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'processedRows': processedRows,
|
||||
'validEvents': events,
|
||||
'invalidRows': invalidRows,
|
||||
'error': events.isEmpty ? '유효한 이벤트 데이터가 없습니다.' : null
|
||||
};
|
||||
}
|
||||
|
||||
/// 다양한 형식의 날짜 값을 DateTime 객체로 파싱합니다.
|
||||
static DateTime? _parseDate(dynamic value) {
|
||||
DateTime? date;
|
||||
|
||||
// DateTime 타입인 경우
|
||||
if (value is DateTime) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// 문자열인 경우
|
||||
if (value != null) {
|
||||
final strValue = value.toString();
|
||||
|
||||
if (strValue.isNotEmpty) {
|
||||
// YYYY-MM-DD 형식 시도
|
||||
try {
|
||||
date = DateTime.parse(strValue);
|
||||
} catch (_) {
|
||||
// 실패 시 다른 형식 시도
|
||||
}
|
||||
|
||||
// YYYY/MM/DD 형식 시도
|
||||
if (date == null && strValue.contains('/')) {
|
||||
try {
|
||||
final parts = strValue.split('/');
|
||||
if (parts.length == 3) {
|
||||
date = DateTime(
|
||||
int.parse(parts[0]),
|
||||
int.parse(parts[1]),
|
||||
int.parse(parts[2]),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// 실패 시 무시
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class LoadingIndicator extends StatelessWidget {
|
||||
const LoadingIndicator({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('로딩 중...', style: TextStyle(fontSize: 16)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user