diff --git a/README.md b/README.md
index e69de29..41578b0 100644
--- a/README.md
+++ b/README.md
@@ -0,0 +1,113 @@
+# 볼링 매니저 (Bowling Manager)
+
+볼링 매니저는 볼링장 운영자와 클럽 관리자를 위한 종합 관리 시스템입니다. 회원 관리, 이벤트 일정 관리, 점수 기록 및 통계, 구독 기반 프리미엄 기능을 제공합니다.
+
+## 프로젝트 구조
+
+이 프로젝트는 세 가지 주요 구성 요소로 이루어져 있습니다:
+
+### 1. 백엔드 (Node.js/Express)
+
+`/backend` 디렉토리에 위치한 서버 측 코드입니다.
+
+- **기술 스택**: Node.js, Express, Sequelize ORM, MySQL
+- **주요 기능**:
+ - RESTful API 제공
+ - 데이터베이스 관리
+ - 인증 및 권한 관리
+ - 구독 및 결제 처리
+ - 영수증 검증
+
+### 2. 프론트엔드 (Vue.js)
+
+`/frontend` 디렉토리에 위치한 웹 클라이언트 코드입니다.
+
+- **기술 스택**: Vue 3, Vite, Vuex, Vue Router
+- **주요 기능**:
+ - 관리자 대시보드
+ - 회원 및 이벤트 관리
+ - 통계 및 보고서
+ - 구독 관리
+
+### 3. 모바일 앱 (Flutter)
+
+`/mobile` 디렉토리에 위치한 크로스 플랫폼 모바일 앱 코드입니다.
+
+- **기술 스택**: Flutter, Dart, Provider 상태 관리
+- **주요 기능**:
+ - 회원 관리
+ - 이벤트 및 일정 관리
+ - 점수 기록 및 통계
+ - 인앱 결제 및 구독
+ - 오프라인 지원
+
+## 설치 및 실행
+
+### 백엔드
+
+```bash
+cd backend
+npm install
+npm start
+```
+
+### 프론트엔드
+
+```bash
+cd frontend
+npm install
+npm run dev
+```
+
+### 모바일 앱
+
+```bash
+cd mobile
+flutter pub get
+flutter run
+```
+
+## 구독 및 인앱 결제 기능
+
+볼링 매니저는 다양한 구독 플랜을 제공합니다:
+
+- **무료 플랜**: 기본 기능, 제한된 회원 및 이벤트 관리
+- **기본 플랜**: 확장된 회원 및 이벤트 관리, 기본 통계
+- **프리미엄 플랜**: 고급 통계, 커스터마이징 옵션
+- **프로 플랜**: 모든 기능 무제한 이용, 우선 지원
+
+구독 관련 문서:
+- [구독 및 인앱 결제 요구사항 및 사용자 플로우](/docs/subscription_requirements_flow.md)
+- [구독 및 인앱 결제 QA 체크리스트](/docs/subscription_qa_checklist.md)
+- [배포 체크리스트](/docs/deployment_checklist.md)
+
+## 개발 가이드
+
+### 백엔드 API
+
+백엔드 API는 RESTful 원칙을 따르며, JWT 기반 인증을 사용합니다. 주요 엔드포인트:
+
+- `/api/auth`: 인증 관련 엔드포인트
+- `/api/clubs`: 클럽 관리 엔드포인트
+- `/api/members`: 회원 관리 엔드포인트
+- `/api/events`: 이벤트 관리 엔드포인트
+- `/api/scores`: 점수 관리 엔드포인트
+- `/api/subscriptions`: 구독 관리 엔드포인트
+
+### 모바일 앱 구조
+
+모바일 앱은 다음과 같은 구조로 구성되어 있습니다:
+
+- `lib/models`: 데이터 모델 클래스
+- `lib/services`: API 통신 및 비즈니스 로직
+- `lib/screens`: UI 화면
+- `lib/widgets`: 재사용 가능한 UI 구성 요소
+- `lib/utils`: 유틸리티 함수 및 상수
+
+## 라이선스
+
+이 프로젝트는 MIT 라이선스 하에 배포됩니다. 자세한 내용은 [LICENSE](LICENSE) 파일을 참조하세요.
+
+## 기여
+
+버그 신고, 기능 요청 및 풀 리퀘스트는 환영합니다. 기여하기 전에 프로젝트 관리자에게 문의하세요.
\ No newline at end of file
diff --git a/backend/controllers/subscriptionController.js b/backend/controllers/subscriptionController.js
index c5a4f9e..1709892 100644
--- a/backend/controllers/subscriptionController.js
+++ b/backend/controllers/subscriptionController.js
@@ -685,3 +685,141 @@ exports.getPlanSubscriptions = async (req, res) => {
res.status(500).json({ message: '플랜별 구독 목록 조회 중 오류가 발생했습니다.' });
}
};
+
+// 인앱 결제 영수증 검증
+exports.verifyReceipt = async (req, res) => {
+ try {
+ const { receipt, transactionId, productId, platform } = req.body;
+ const userId = req.user.id;
+ const clubId = req.body.clubId;
+
+ if (!receipt) {
+ return res.status(400).json({ message: '영수증 데이터가 없습니다.' });
+ }
+
+ // 플랫폼별 검증 로직
+ let verificationResult = false;
+ let purchaseData = {};
+
+ if (platform === 'ios') {
+ // iOS 영수증 검증
+ verificationResult = await verifyIosReceipt(receipt);
+ purchaseData = {
+ platform: 'ios',
+ transactionId,
+ productId,
+ receipt
+ };
+ } else if (platform === 'android') {
+ // Android 영수증 검증
+ verificationResult = await verifyAndroidReceipt(receipt, productId);
+ purchaseData = {
+ platform: 'android',
+ transactionId,
+ productId,
+ receipt
+ };
+ } else {
+ return res.status(400).json({ message: '지원하지 않는 플랫폼입니다.' });
+ }
+
+ if (!verificationResult) {
+ return res.status(400).json({ message: '영수증 검증에 실패했습니다.' });
+ }
+
+ // 구독 플랜 타입 확인
+ const planType = getPlanTypeFromProductId(productId);
+ if (!planType) {
+ return res.status(400).json({ message: '유효하지 않은 상품 ID입니다.' });
+ }
+
+ // 해당 플랜 정보 가져오기
+ const plan = await SubscriptionPlan.findOne({
+ where: { type: planType }
+ });
+
+ if (!plan) {
+ return res.status(404).json({ message: '해당 플랜을 찾을 수 없습니다.' });
+ }
+
+ // 기존 구독 정보 확인
+ let subscription = await ClubSubscription.findOne({
+ where: { clubId }
+ });
+
+ // 구독 생성 또는 업데이트
+ const startDate = new Date();
+ const endDate = new Date();
+ endDate.setMonth(endDate.getMonth() + 1); // 기본 1개월 구독
+
+ if (subscription) {
+ // 기존 구독 업데이트
+ await subscription.update({
+ planId: plan.id,
+ status: 'active',
+ startDate,
+ endDate,
+ lastPaymentDate: startDate,
+ transactionId,
+ receiptData: JSON.stringify(purchaseData)
+ });
+ } else {
+ // 새 구독 생성
+ subscription = await ClubSubscription.create({
+ clubId,
+ planId: plan.id,
+ status: 'active',
+ startDate,
+ endDate,
+ lastPaymentDate: startDate,
+ transactionId,
+ receiptData: JSON.stringify(purchaseData)
+ });
+ }
+
+ // 검증 결과 및 구독 정보 반환
+ res.json({
+ success: true,
+ message: '영수증 검증 및 구독 처리가 완료되었습니다.',
+ subscription: {
+ id: subscription.id,
+ planId: subscription.planId,
+ planType: planType,
+ status: subscription.status,
+ startDate: subscription.startDate,
+ endDate: subscription.endDate,
+ lastPaymentDate: subscription.lastPaymentDate
+ }
+ });
+
+ } catch (error) {
+ console.error('Error in verifyReceipt:', error);
+ res.status(500).json({ message: '영수증 검증 중 오류가 발생했습니다.' });
+ }
+};
+
+// iOS 영수증 검증 함수 (실제 구현에서는 Apple 서버와 통신 필요)
+async function verifyIosReceipt(receipt) {
+ // 실제 구현에서는 Apple 서버에 영수증 검증 요청
+ // 테스트를 위해 현재는 항상 true 반환
+ return true;
+}
+
+// Android 영수증 검증 함수 (실제 구현에서는 Google Play API와 통신 필요)
+async function verifyAndroidReceipt(receipt, productId) {
+ // 실제 구현에서는 Google Play API를 통해 영수증 검증
+ // 테스트를 위해 현재는 항상 true 반환
+ return true;
+}
+
+// 상품 ID에서 플랜 타입 추출
+function getPlanTypeFromProductId(productId) {
+ if (productId.includes('basic')) {
+ return 'basic';
+ } else if (productId.includes('premium')) {
+ return 'premium';
+ } else if (productId.includes('pro')) {
+ return 'pro';
+ }
+ return null;
+}
diff --git a/backend/routes/subscriptionRoutes.js b/backend/routes/subscriptionRoutes.js
index 13fc40a..983ef08 100644
--- a/backend/routes/subscriptionRoutes.js
+++ b/backend/routes/subscriptionRoutes.js
@@ -29,4 +29,7 @@ router.post('/payment/request', authenticateJWT, subscriptionController.requestP
router.post('/payment/success', subscriptionController.processPaymentSuccess);
router.post('/payment/fail', subscriptionController.processPaymentFail);
+// 인앱 결제 영수증 검증 엔드포인트
+router.post('/verify-receipt', authenticateJWT, subscriptionController.verifyReceipt);
+
module.exports = router;
\ No newline at end of file
diff --git a/docs/deployment_checklist.md b/docs/deployment_checklist.md
new file mode 100644
index 0000000..da38f1e
--- /dev/null
+++ b/docs/deployment_checklist.md
@@ -0,0 +1,127 @@
+# 볼링 매니저 앱 배포 체크리스트
+
+## 1. 앱 준비
+
+### 1.1 코드 준비
+- [ ] 모든 디버그 코드 및 로그 제거
+- [ ] 릴리스 모드로 빌드 설정 확인
+- [ ] 앱 버전 및 빌드 번호 업데이트
+- [ ] 프로덕션 API 엔드포인트 설정 확인
+- [ ] 코드 최적화 및 성능 검토 완료
+
+### 1.2 자산 및 리소스
+- [ ] 모든 이미지 및 아이콘 최적화
+- [ ] 앱 아이콘 모든 크기 준비 완료
+- [ ] 스플래시 화면 준비 완료
+- [ ] 다국어 지원 텍스트 검토 완료
+
+### 1.3 인앱 결제 설정
+- [ ] App Store Connect에 인앱 결제 상품 등록 완료
+- [ ] Google Play Console에 구독 상품 등록 완료
+- [ ] 샌드박스 테스트 완료
+- [ ] 실제 결제 테스트 완료 (필요시)
+
+## 2. iOS 배포 준비
+
+### 2.1 App Store Connect 설정
+- [ ] 앱 정보 입력 완료 (이름, 설명, 키워드 등)
+- [ ] 스크린샷 및 프리뷰 비디오 업로드
+- [ ] 개인정보 처리방침 URL 등록
+- [ ] 앱 리뷰 정보 준비 (테스트 계정 등)
+- [ ] 연령 등급 설정
+
+### 2.2 인증서 및 프로비저닝
+- [ ] 배포용 인증서 유효성 확인
+- [ ] 프로비저닝 프로파일 설정 확인
+- [ ] 앱 ID 및 기능 활성화 확인
+
+### 2.3 빌드 및 제출
+- [ ] Archive 빌드 생성
+- [ ] TestFlight 업로드 및 테스트
+- [ ] 앱 심사 제출 준비
+
+## 3. Android 배포 준비
+
+### 3.1 Google Play Console 설정
+- [ ] 스토어 등록정보 입력 완료 (이름, 설명, 카테고리 등)
+- [ ] 스크린샷 및 그래픽 자산 업로드
+- [ ] 개인정보 처리방침 URL 등록
+- [ ] 콘텐츠 등급 설정
+- [ ] 가격 및 배포 국가 설정
+
+### 3.2 서명 및 보안
+- [ ] 앱 서명 키 확인
+- [ ] Play App Signing 설정 확인
+- [ ] ProGuard 설정 확인 (코드 난독화)
+
+### 3.3 빌드 및 제출
+- [ ] 릴리스 APK/AAB 생성
+- [ ] 내부/외부 테스트 트랙 업로드 및 테스트
+- [ ] 프로덕션 출시 준비
+
+## 4. 백엔드 배포 준비
+
+### 4.1 서버 환경 설정
+- [ ] 프로덕션 서버 환경 구성 완료
+- [ ] 데이터베이스 마이그레이션 계획 수립
+- [ ] 백업 및 복구 전략 확인
+
+### 4.2 API 및 보안
+- [ ] 모든 API 엔드포인트 프로덕션 환경에서 테스트 완료
+- [ ] HTTPS 설정 확인
+- [ ] API 키 및 인증 설정 검토
+- [ ] 속도 제한 및 보안 설정 확인
+
+### 4.3 모니터링 및 로깅
+- [ ] 서버 모니터링 도구 설정
+- [ ] 오류 로깅 및 알림 설정
+- [ ] 성능 모니터링 설정
+
+## 5. 법적 요구사항
+
+### 5.1 개인정보 보호
+- [ ] 개인정보 처리방침 최신화
+- [ ] 데이터 수집 및 사용에 대한 동의 절차 확인
+- [ ] GDPR/CCPA 등 관련 법규 준수 확인
+
+### 5.2 이용 약관
+- [ ] 이용 약관 최신화
+- [ ] 구독 및 결제 관련 조항 포함 확인
+- [ ] 환불 정책 명시 확인
+
+### 5.3 지적 재산권
+- [ ] 사용된 모든 라이브러리 및 자산의 라이선스 확인
+- [ ] 제3자 콘텐츠 사용 권한 확인
+
+## 6. 마케팅 및 출시 준비
+
+### 6.1 마케팅 자료
+- [ ] 보도 자료 준비
+- [ ] 소셜 미디어 콘텐츠 준비
+- [ ] 프로모션 계획 수립
+
+### 6.2 사용자 지원
+- [ ] 고객 지원 채널 설정
+- [ ] FAQ 및 도움말 문서 준비
+- [ ] 사용자 피드백 수집 방법 설정
+
+### 6.3 출시 계획
+- [ ] 출시 일정 확정
+- [ ] 단계적 출시 전략 수립 (필요시)
+- [ ] 출시 후 모니터링 계획 수립
+
+## 7. 최종 검토
+
+### 7.1 품질 보증
+- [ ] 모든 주요 기능 최종 테스트 완료
+- [ ] 다양한 기기 및 OS 버전에서 테스트 완료
+- [ ] 성능 및 배터리 사용량 테스트 완료
+
+### 7.2 사용자 경험
+- [ ] 온보딩 프로세스 검토
+- [ ] 오류 처리 및 피드백 검토
+- [ ] 접근성 기능 검토
+
+### 7.3 최종 승인
+- [ ] 모든 이해관계자의 최종 승인 획득
+- [ ] 출시 결정 확인
diff --git a/docs/subscription_qa_checklist.md b/docs/subscription_qa_checklist.md
new file mode 100644
index 0000000..89de852
--- /dev/null
+++ b/docs/subscription_qa_checklist.md
@@ -0,0 +1,133 @@
+# 볼링 매니저 구독 및 인앱 결제 QA 체크리스트
+
+## 1. 구독 기능 테스트
+
+### 1.1 구독 정보 조회
+- [ ] 앱 시작 시 현재 구독 정보 로드 확인
+- [ ] 구독이 없는 경우 기본(무료) 플랜 표시 확인
+- [ ] 구독 상세 정보 표시 확인 (플랜 유형, 시작일, 만료일, 자동 갱신 상태 등)
+
+### 1.2 구독 플랜 목록
+- [ ] 사용 가능한 모든 구독 플랜 표시 확인
+- [ ] 각 플랜의 가격, 기능, 제한 사항 등 정확히 표시 확인
+- [ ] 월간/연간 요금제 전환 기능 확인
+
+### 1.3 구독 상태별 기능 제한
+- [ ] 무료 플랜 사용자의 기능 제한 확인
+- [ ] 기본 플랜 사용자의 기능 접근 확인
+- [ ] 프리미엄 플랜 사용자의 기능 접근 확인
+- [ ] 프로 플랜 사용자의 기능 접근 확인
+- [ ] 제한된 기능 접근 시 업그레이드 안내 대화상자 표시 확인
+
+### 1.4 회원 및 이벤트 제한
+- [ ] 플랜별 최대 회원 수 제한 확인
+- [ ] 플랜별 최대 이벤트 수 제한 확인
+- [ ] 제한 초과 시 적절한 안내 메시지 표시 확인
+
+## 2. 인앱 결제 테스트
+
+### 2.1 구독 구매
+- [ ] 각 플랜별 구독 구매 버튼 작동 확인
+- [ ] 인앱 결제 다이얼로그 표시 확인
+- [ ] 결제 진행 중 로딩 표시 확인
+- [ ] 결제 성공 시 구독 정보 업데이트 확인
+- [ ] 결제 실패 시 적절한 오류 메시지 표시 확인
+
+### 2.2 구독 복원
+- [ ] 구독 복원 버튼 작동 확인
+- [ ] 복원 진행 중 로딩 표시 확인
+- [ ] 복원 성공 시 구독 정보 업데이트 확인
+- [ ] 복원할 구독이 없는 경우 적절한 메시지 표시 확인
+
+### 2.3 플랜 변경
+- [ ] 현재 구독에서 다른 플랜으로 변경 기능 확인
+- [ ] 업그레이드/다운그레이드 시 적절한 안내 메시지 표시 확인
+- [ ] 플랜 변경 후 구독 정보 업데이트 확인
+
+### 2.4 자동 갱신 설정
+- [ ] 자동 갱신 설정 변경 기능 확인
+- [ ] 설정 변경 후 상태 업데이트 확인
+
+## 3. 백엔드 API 테스트
+
+### 3.1 구독 관련 API
+- [ ] `/subscriptions/current` - 현재 구독 정보 조회 API 테스트
+- [ ] `/subscriptions/plans` - 구독 플랜 목록 조회 API 테스트
+- [ ] `/subscriptions/subscribe` - 구독 생성 API 테스트
+- [ ] `/subscriptions/change-with-features` - 플랜 변경 API 테스트
+
+### 3.2 인앱 결제 검증 API
+- [ ] `/subscriptions/verify-receipt` - 영수증 검증 API 테스트
+ - [ ] iOS 영수증 검증 테스트
+ - [ ] Android 영수증 검증 테스트
+ - [ ] 잘못된 영수증 데이터 처리 테스트
+
+## 4. 샌드박스 테스트
+
+### 4.1 iOS 샌드박스 테스트
+- [ ] App Store Connect에서 샌드박스 테스터 계정 설정
+- [ ] 샌드박스 환경에서 구독 구매 테스트
+- [ ] 샌드박스 환경에서 구독 복원 테스트
+- [ ] 샌드박스 환경에서 플랜 변경 테스트
+
+### 4.2 Android 샌드박스 테스트
+- [ ] Google Play Console에서 테스트 트랙 설정
+- [ ] 테스트 환경에서 구독 구매 테스트
+- [ ] 테스트 환경에서 구독 복원 테스트
+- [ ] 테스트 환경에서 플랜 변경 테스트
+
+## 5. 오류 처리 테스트
+
+### 5.1 네트워크 오류
+- [ ] 오프라인 상태에서 구독 관련 기능 동작 확인
+- [ ] 네트워크 재연결 시 상태 복구 확인
+
+### 5.2 서버 오류
+- [ ] 서버 응답 오류 시 적절한 메시지 표시 확인
+- [ ] 재시도 메커니즘 확인
+
+### 5.3 결제 오류
+- [ ] 결제 취소 시 적절한 처리 확인
+- [ ] 잘못된 결제 정보 입력 시 오류 처리 확인
+
+## 6. UI/UX 테스트
+
+### 6.1 구독 화면 UI
+- [ ] 구독 화면의 레이아웃 및 디자인 확인
+- [ ] 다양한 화면 크기에서의 반응형 디자인 확인
+- [ ] 다크 모드 지원 확인
+
+### 6.2 사용자 피드백
+- [ ] 로딩 상태 표시 확인
+- [ ] 오류 메시지 명확성 확인
+- [ ] 성공 메시지 표시 확인
+
+## 7. 보안 테스트
+
+### 7.1 영수증 검증
+- [ ] 위조된 영수증 거부 확인
+- [ ] 만료된 영수증 처리 확인
+
+### 7.2 인증 및 권한
+- [ ] 인증되지 않은 사용자의 구독 API 접근 차단 확인
+- [ ] 권한이 없는 사용자의 관리자 기능 접근 차단 확인
+
+## 8. 통합 테스트
+
+### 8.1 전체 구독 흐름
+- [ ] 구독 생성 → 플랜 변경 → 자동 갱신 설정 → 구독 취소의 전체 흐름 테스트
+
+### 8.2 다중 기기 동기화
+- [ ] 여러 기기에서 동일한 계정으로 로그인 시 구독 정보 동기화 확인
+
+## 9. 배포 준비
+
+### 9.1 스토어 설정
+- [ ] App Store 인앱 결제 상품 설정 확인
+- [ ] Google Play 구독 상품 설정 확인
+- [ ] 스토어 스크린샷 및 설명에 구독 정보 포함 확인
+
+### 9.2 법적 요구사항
+- [ ] 개인정보 처리방침에 구독 관련 내용 포함 확인
+- [ ] 이용 약관에 구독 조건 포함 확인
+- [ ] 환불 정책 명시 확인
diff --git a/docs/subscription_requirements_flow.md b/docs/subscription_requirements_flow.md
new file mode 100644
index 0000000..8322712
--- /dev/null
+++ b/docs/subscription_requirements_flow.md
@@ -0,0 +1,204 @@
+# 볼링 매니저 구독 및 인앱 결제 요구사항 및 사용자 플로우
+
+## 1. 요구사항
+
+### 1.1 기능적 요구사항
+
+#### 구독 관리
+- 사용자는 다양한 구독 플랜(기본, 프리미엄, 프로) 중에서 선택할 수 있어야 함
+- 사용자는 월간 또는 연간 구독 옵션을 선택할 수 있어야 함
+- 사용자는 현재 구독 상태를 확인할 수 있어야 함
+- 사용자는 구독을 업그레이드/다운그레이드할 수 있어야 함
+- 사용자는 자동 갱신 설정을 변경할 수 있어야 함
+- 사용자는 이전에 구매한 구독을 복원할 수 있어야 함
+
+#### 인앱 결제
+- 앱은 플랫폼별(iOS, Android) 인앱 결제를 지원해야 함
+- 결제 과정은 안전하고 사용자 친화적이어야 함
+- 결제 오류 발생 시 적절한 피드백을 제공해야 함
+- 결제 완료 후 즉시 구독 상태가 업데이트되어야 함
+
+#### 기능 제한
+- 구독 플랜에 따라 사용 가능한 기능이 차등 적용되어야 함
+- 무료 사용자는 기본 기능만 사용할 수 있어야 함
+- 구독 만료 시 접근 가능한 기능이 제한되어야 함
+- 제한된 기능 접근 시 업그레이드 안내가 표시되어야 함
+
+#### 백엔드 연동
+- 구독 정보는 서버에 안전하게 저장되어야 함
+- 인앱 결제 영수증은 서버에서 검증되어야 함
+- 구독 상태는 여러 기기에서 동기화되어야 함
+- 구독 만료 및 갱신은 서버에서 처리되어야 함
+
+### 1.2 비기능적 요구사항
+
+#### 보안
+- 결제 정보는 안전하게 처리되어야 함
+- 영수증 검증은 서버 측에서 수행되어야 함
+- 사용자 인증 및 권한 관리가 적절히 구현되어야 함
+
+#### 성능
+- 구독 상태 확인은 빠르게 이루어져야 함
+- 결제 처리는 최소한의 지연으로 완료되어야 함
+- 오프라인 상태에서도 기본적인 기능은 사용 가능해야 함
+
+#### 사용성
+- 구독 및 결제 UI는 직관적이고 사용하기 쉬워야 함
+- 오류 메시지는 명확하고 이해하기 쉬워야 함
+- 구독 혜택 및 제한 사항이 명확하게 표시되어야 함
+
+#### 확장성
+- 새로운 구독 플랜 추가가 용이해야 함
+- 새로운 기능 및 제한 사항 추가가 용이해야 함
+- 다양한 결제 방식 추가가 가능해야 함
+
+## 2. 사용자 플로우
+
+### 2.1 구독 구매 플로우
+
+1. **구독 화면 접근**
+ - 사용자가 앱에서 '구독' 또는 '프리미엄' 메뉴를 탭함
+ - 또는 제한된 기능 접근 시 표시되는 업그레이드 안내에서 '구독하기' 버튼을 탭함
+
+2. **구독 플랜 선택**
+ - 사용 가능한 구독 플랜(기본, 프리미엄, 프로) 목록이 표시됨
+ - 각 플랜의 가격, 기능, 제한 사항 등이 표시됨
+ - 사용자가 원하는 플랜을 선택함
+
+3. **월간/연간 선택**
+ - 선택한 플랜의 월간 또는 연간 구독 옵션이 표시됨
+ - 사용자가 원하는 구독 기간을 선택함
+
+4. **결제 진행**
+ - 플랫폼별 인앱 결제 다이얼로그가 표시됨
+ - 사용자가 결제 정보를 입력하고 결제를 확인함
+ - 결제 진행 중 로딩 표시가 나타남
+
+5. **결제 완료**
+ - 결제가 성공적으로 완료되면 성공 메시지가 표시됨
+ - 구독 정보가 업데이트되고 구독 상세 화면으로 이동함
+ - 결제 실패 시 오류 메시지가 표시되고 재시도 옵션이 제공됨
+
+### 2.2 구독 관리 플로우
+
+1. **구독 정보 확인**
+ - 사용자가 '내 구독' 또는 '계정' 메뉴를 탭함
+ - 현재 구독 상태, 플랜, 시작일, 만료일, 자동 갱신 상태 등이 표시됨
+
+2. **플랜 변경**
+ - 사용자가 '플랜 변경' 버튼을 탭함
+ - 사용 가능한 다른 플랜 목록이 표시됨
+ - 사용자가 새 플랜을 선택하고 확인함
+ - 플랜 변경이 처리되고 구독 정보가 업데이트됨
+
+3. **자동 갱신 설정**
+ - 사용자가 '자동 갱신' 토글 버튼을 탭함
+ - 설정 변경 확인 다이얼로그가 표시됨
+ - 사용자가 확인하면 설정이 변경되고 구독 정보가 업데이트됨
+
+4. **구독 취소**
+ - 사용자가 '구독 취소' 버튼을 탭함
+ - 취소 확인 다이얼로그가 표시됨
+ - 사용자가 확인하면 구독이 취소되고 만료일까지 사용 가능함을 안내함
+
+### 2.3 구독 복원 플로우
+
+1. **복원 시작**
+ - 사용자가 '구독 복원' 버튼을 탭함
+ - 복원 진행 중 로딩 표시가 나타남
+
+2. **복원 결과**
+ - 복원 가능한 구독이 있으면 구독이 복원되고 성공 메시지가 표시됨
+ - 구독 정보가 업데이트되고 구독 상세 화면으로 이동함
+ - 복원할 구독이 없으면 안내 메시지가 표시됨
+
+### 2.4 제한된 기능 접근 플로우
+
+1. **기능 접근 시도**
+ - 사용자가 현재 구독 플랜에서 제한된 기능에 접근을 시도함
+
+2. **업그레이드 안내**
+ - 해당 기능에 접근하기 위해 필요한 구독 플랜 정보가 포함된 대화상자가 표시됨
+ - '구독하기' 버튼과 '취소' 버튼이 제공됨
+
+3. **사용자 선택**
+ - 사용자가 '구독하기'를 선택하면 구독 화면으로 이동함
+ - 사용자가 '취소'를 선택하면 이전 화면으로 돌아감
+
+## 3. 구독 플랜 및 기능
+
+### 3.1 무료 플랜
+- **기능**:
+ - 기본 볼링 점수 추적
+ - 최대 10명의 회원 관리
+ - 최대 5개의 이벤트 관리
+
+### 3.2 기본 플랜
+- **기능**:
+ - 모든 무료 기능
+ - 최대 30명의 회원 관리
+ - 최대 10개의 이벤트 관리
+ - 기본 통계 분석
+
+### 3.3 프리미엄 플랜
+- **기능**:
+ - 모든 기본 기능
+ - 최대 100명의 회원 관리
+ - 최대 50개의 이벤트 관리
+ - 고급 통계 분석
+ - 커스터마이징 옵션
+
+### 3.4 프로 플랜
+- **기능**:
+ - 모든 프리미엄 기능
+ - 최대 500명의 회원 관리
+ - 최대 200개의 이벤트 관리
+ - 우선 지원
+ - 모든 고급 기능 접근
+
+## 4. 오류 처리 시나리오
+
+### 4.1 결제 오류
+- **시나리오**: 사용자가 결제를 시도했으나 결제가 실패함
+- **처리**:
+ - 오류 원인에 따른 명확한 메시지 표시
+ - 재시도 옵션 제공
+ - 문제 해결을 위한 안내 제공
+
+### 4.2 네트워크 오류
+- **시나리오**: 구독 정보 로드 또는 결제 중 네트워크 연결이 끊김
+- **처리**:
+ - 네트워크 오류 메시지 표시
+ - 자동 재연결 시도
+ - 오프라인 모드로 전환 (가능한 경우)
+
+### 4.3 서버 오류
+- **시나리오**: 서버에서 구독 정보 처리 중 오류 발생
+- **처리**:
+ - 서버 오류 메시지 표시
+ - 관리자에게 오류 보고
+ - 재시도 옵션 제공
+
+### 4.4 영수증 검증 오류
+- **시나리오**: 인앱 결제는 성공했으나 영수증 검증에 실패함
+- **처리**:
+ - 검증 오류 메시지 표시
+ - 자동 재검증 시도
+ - 수동 복원 옵션 제공
+
+## 5. 배포 및 유지보수 계획
+
+### 5.1 배포 준비
+- App Store 및 Google Play에 인앱 결제 상품 등록
+- 스토어 설명 및 스크린샷에 구독 정보 포함
+- 개인정보 처리방침 및 이용 약관 업데이트
+
+### 5.2 모니터링 및 분석
+- 구독 전환율 모니터링
+- 구독 취소율 및 원인 분석
+- 플랜별 사용자 행동 분석
+
+### 5.3 향후 개선 계획
+- 사용자 피드백에 기반한 구독 플랜 조정
+- 새로운 프리미엄 기능 추가
+- 추가 결제 방식 통합 검토
diff --git a/mobile/.gitignore b/mobile/.gitignore
new file mode 100644
index 0000000..79c113f
--- /dev/null
+++ b/mobile/.gitignore
@@ -0,0 +1,45 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.build/
+.buildlog/
+.history
+.svn/
+.swiftpm/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins
+.flutter-plugins-dependencies
+.pub-cache/
+.pub/
+/build/
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+# Android Studio will place build artifacts here
+/android/app/debug
+/android/app/profile
+/android/app/release
diff --git a/mobile/.metadata b/mobile/.metadata
new file mode 100644
index 0000000..fdb4416
--- /dev/null
+++ b/mobile/.metadata
@@ -0,0 +1,45 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "fcf2c11572af6f390246c056bc905eca609533a0"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: fcf2c11572af6f390246c056bc905eca609533a0
+ base_revision: fcf2c11572af6f390246c056bc905eca609533a0
+ - platform: android
+ create_revision: fcf2c11572af6f390246c056bc905eca609533a0
+ base_revision: fcf2c11572af6f390246c056bc905eca609533a0
+ - platform: ios
+ create_revision: fcf2c11572af6f390246c056bc905eca609533a0
+ base_revision: fcf2c11572af6f390246c056bc905eca609533a0
+ - platform: linux
+ create_revision: fcf2c11572af6f390246c056bc905eca609533a0
+ base_revision: fcf2c11572af6f390246c056bc905eca609533a0
+ - platform: macos
+ create_revision: fcf2c11572af6f390246c056bc905eca609533a0
+ base_revision: fcf2c11572af6f390246c056bc905eca609533a0
+ - platform: web
+ create_revision: fcf2c11572af6f390246c056bc905eca609533a0
+ base_revision: fcf2c11572af6f390246c056bc905eca609533a0
+ - platform: windows
+ create_revision: fcf2c11572af6f390246c056bc905eca609533a0
+ base_revision: fcf2c11572af6f390246c056bc905eca609533a0
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/mobile/README.md b/mobile/README.md
new file mode 100644
index 0000000..7b01285
--- /dev/null
+++ b/mobile/README.md
@@ -0,0 +1,16 @@
+# mobile
+
+A new Flutter project.
+
+## Getting Started
+
+This project is a starting point for a Flutter application.
+
+A few resources to get you started if this is your first Flutter project:
+
+- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
+- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
+
+For help getting started with Flutter development, view the
+[online documentation](https://docs.flutter.dev/), which offers tutorials,
+samples, guidance on mobile development, and a full API reference.
diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml
new file mode 100644
index 0000000..0d29021
--- /dev/null
+++ b/mobile/analysis_options.yaml
@@ -0,0 +1,28 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+linter:
+ # The lint rules applied to this project can be customized in the
+ # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+ # included above or to enable additional rules. A list of all available lints
+ # and their documentation is published at https://dart.dev/lints.
+ #
+ # Instead of disabling a lint rule for the entire project in the
+ # section below, it can also be suppressed for a single line of code
+ # or a specific dart file by using the `// ignore: name_of_lint` and
+ # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+ # producing the lint.
+ rules:
+ # avoid_print: false # Uncomment to disable the `avoid_print` rule
+ # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore
new file mode 100644
index 0000000..be3943c
--- /dev/null
+++ b/mobile/android/.gitignore
@@ -0,0 +1,14 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+key.properties
+**/*.keystore
+**/*.jks
diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts
new file mode 100644
index 0000000..ee96cd7
--- /dev/null
+++ b/mobile/android/app/build.gradle.kts
@@ -0,0 +1,44 @@
+plugins {
+ id("com.android.application")
+ id("kotlin-android")
+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+ id("dev.flutter.flutter-gradle-plugin")
+}
+
+android {
+ namespace = "com.example.mobile"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_11.toString()
+ }
+
+ defaultConfig {
+ // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+ applicationId = "com.example.mobile"
+ // You can update the following values to match your application needs.
+ // For more information, see: https://flutter.dev/to/review-gradle-config.
+ minSdk = flutter.minSdkVersion
+ targetSdk = flutter.targetSdkVersion
+ versionCode = flutter.versionCode
+ versionName = flutter.versionName
+ }
+
+ buildTypes {
+ release {
+ // TODO: Add your own signing config for the release build.
+ // Signing with the debug keys for now, so `flutter run --release` works.
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+}
+
+flutter {
+ source = "../.."
+}
diff --git a/mobile/android/app/src/debug/AndroidManifest.xml b/mobile/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/mobile/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..77be3b5
--- /dev/null
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/MainActivity.kt
new file mode 100644
index 0000000..b5dc9d0
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/com/example/mobile/MainActivity.kt
@@ -0,0 +1,5 @@
+package com.example.mobile
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity : FlutterActivity()
diff --git a/mobile/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..cb58f08
Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ
diff --git a/mobile/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..8887669
Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ
diff --git a/mobile/android/app/src/main/res/drawable-v21/launch_background.xml b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000..f74085f
--- /dev/null
+++ b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/mobile/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..97a66ec
Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ
diff --git a/mobile/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..cb84d7b
Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ
diff --git a/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..668ba5f
Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/mobile/android/app/src/main/res/drawable/launch_background.xml b/mobile/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..304732f
--- /dev/null
+++ b/mobile/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..5f349f7
--- /dev/null
+++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..d3fdf59
Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..fe229e9
Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..b6749e7
Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..330c0f1
Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..99302fd
Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/mobile/android/app/src/main/res/values-night/styles.xml b/mobile/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..06952be
--- /dev/null
+++ b/mobile/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/mobile/android/app/src/main/res/values/colors.xml b/mobile/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..c5d5899
--- /dev/null
+++ b/mobile/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #FFFFFF
+
\ No newline at end of file
diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..cb1ef88
--- /dev/null
+++ b/mobile/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/mobile/android/app/src/profile/AndroidManifest.xml b/mobile/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/mobile/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/mobile/android/build.gradle.kts b/mobile/android/build.gradle.kts
new file mode 100644
index 0000000..89176ef
--- /dev/null
+++ b/mobile/android/build.gradle.kts
@@ -0,0 +1,21 @@
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+ project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties
new file mode 100644
index 0000000..f018a61
--- /dev/null
+++ b/mobile/android/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
+android.enableJetifier=true
diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..ac3b479
--- /dev/null
+++ b/mobile/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
diff --git a/mobile/android/settings.gradle.kts b/mobile/android/settings.gradle.kts
new file mode 100644
index 0000000..ab39a10
--- /dev/null
+++ b/mobile/android/settings.gradle.kts
@@ -0,0 +1,25 @@
+pluginManagement {
+ val flutterSdkPath = run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "8.7.3" apply false
+ id("org.jetbrains.kotlin.android") version "2.1.0" apply false
+}
+
+include(":app")
diff --git a/mobile/assets/icons/app_icon.png b/mobile/assets/icons/app_icon.png
new file mode 100644
index 0000000..dee2921
Binary files /dev/null and b/mobile/assets/icons/app_icon.png differ
diff --git a/mobile/assets/images/logo.png b/mobile/assets/images/logo.png
new file mode 100644
index 0000000..d470569
Binary files /dev/null and b/mobile/assets/images/logo.png differ
diff --git a/mobile/devtools_options.yaml b/mobile/devtools_options.yaml
new file mode 100644
index 0000000..fa0b357
--- /dev/null
+++ b/mobile/devtools_options.yaml
@@ -0,0 +1,3 @@
+description: This file stores settings for Dart & Flutter DevTools.
+documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
+extensions:
diff --git a/mobile/docs/subscription_integration.md b/mobile/docs/subscription_integration.md
new file mode 100644
index 0000000..9f1e137
--- /dev/null
+++ b/mobile/docs/subscription_integration.md
@@ -0,0 +1,160 @@
+# 볼링 매니저 구독 및 인앱 결제 통합 문서
+
+## 개요
+
+볼링 매니저 앱은 구독 기반 비즈니스 모델을 채택하여 다양한 기능을 제공합니다. 이 문서는 Flutter 모바일 앱에서 구독 및 인앱 결제 기능의 구현과 통합에 대한 상세 정보를 제공합니다.
+
+## 아키텍처 개요
+
+구독 및 인앱 결제 시스템은 다음 주요 구성 요소로 이루어져 있습니다:
+
+1. **모델 (Models)**
+ - `Subscription`: 사용자의 구독 정보를 나타냅니다.
+ - `SubscriptionPlan`: 사용 가능한 구독 플랜의 세부 정보를 포함합니다.
+
+2. **서비스 (Services)**
+ - `SubscriptionService`: 구독 상태 관리 및 백엔드 API와의 통신을 담당합니다.
+ - `InAppPurchaseService`: 플랫폼별 인앱 결제 처리를 담당합니다.
+
+3. **백엔드 API**
+ - 구독 검증, 상태 관리, 영수증 검증 등의 서버 측 로직을 제공합니다.
+
+## 데이터 흐름
+
+### 구독 구매 프로세스
+
+1. 사용자가 구독 구매 버튼을 클릭합니다.
+2. `SubscriptionService.createSubscription()`이 호출됩니다.
+3. `InAppPurchaseService.purchaseSubscription()`이 호출되어 플랫폼별 결제 프로세스를 시작합니다.
+4. 결제가 완료되면 `InAppPurchaseService`의 `purchaseStream`이 업데이트됩니다.
+5. 결제 상태가 `PurchaseStatus.purchased`로 변경되면 `_verifyAndSavePurchase()` 메서드가 호출됩니다.
+6. `_verifyAndSavePurchase()`는 플랫폼별 영수증 데이터를 추출하고 서버에 전송하여 검증합니다.
+7. 서버는 영수증을 검증하고 구독 정보를 데이터베이스에 저장합니다.
+8. 검증이 성공하면 `SubscriptionService`는 서버에서 최신 구독 정보를 가져와 로컬 상태를 업데이트합니다.
+
+### 구독 상태 확인 및 기능 접근 제어
+
+1. 앱이 시작되면 `SubscriptionService.loadCurrentSubscription()`이 호출되어 현재 구독 상태를 로드합니다.
+2. 사용자가 특정 기능에 접근하려고 할 때 `SubscriptionService.canAccessFeature()`가 호출됩니다.
+3. 현재 구독 상태에 따라 기능 접근 권한이 결정됩니다.
+4. 접근 권한이 없는 경우 업그레이드 안내 대화상자가 표시됩니다.
+
+## 주요 클래스 및 메서드
+
+### SubscriptionService
+
+```dart
+class SubscriptionService extends ChangeNotifier {
+ // 주요 속성
+ Subscription? _currentSubscription;
+ List _availablePlans;
+ InAppPurchaseService _purchaseService;
+
+ // 주요 메서드
+ Future loadCurrentSubscription();
+ Future createSubscription(SubscriptionPlanType planType, bool isYearly);
+ Future restoreSubscriptions();
+ Future changePlan(SubscriptionPlanType newPlanType, bool isYearly);
+ Future toggleAutoRenew();
+ bool canAccessFeature(SubscriptionFeature feature);
+ Future checkFeatureAccess(SubscriptionFeature feature, BuildContext context);
+ int getMaxMembersAllowed();
+ int getMaxEventsAllowed();
+}
+```
+
+### InAppPurchaseService
+
+```dart
+class InAppPurchaseService extends ChangeNotifier {
+ // 주요 속성
+ List _products;
+ List _purchases;
+ bool _purchaseVerified;
+ Map? _verifiedPurchase;
+
+ // 주요 메서드
+ Future initialize();
+ Future purchaseSubscription(SubscriptionPlanType planType);
+ Future restorePurchases();
+ Future _verifyAndSavePurchase(PurchaseDetails purchaseDetails);
+}
+```
+
+## 구독 플랜 및 기능
+
+볼링 매니저는 다음과 같은 구독 플랜을 제공합니다:
+
+1. **무료 (Free)**
+ - 기본 볼링 점수 추적
+ - 최대 10명의 회원 관리
+ - 최대 5개의 이벤트 관리
+
+2. **기본 (Basic)**
+ - 모든 무료 기능
+ - 최대 30명의 회원 관리
+ - 최대 10개의 이벤트 관리
+ - 기본 통계 분석
+
+3. **프리미엄 (Premium)**
+ - 모든 기본 기능
+ - 최대 100명의 회원 관리
+ - 최대 50개의 이벤트 관리
+ - 고급 통계 분석
+ - 커스터마이징 옵션
+
+4. **프로 (Pro)**
+ - 모든 프리미엄 기능
+ - 최대 500명의 회원 관리
+ - 최대 200개의 이벤트 관리
+ - 우선 지원
+ - 모든 고급 기능 접근
+
+## 영수증 검증 프로세스
+
+1. **iOS 영수증 검증**
+ - App Store 영수증 데이터 추출
+ - 서버에 영수증 데이터 전송
+ - 서버는 Apple 서버에 영수증 검증 요청
+ - 검증 결과에 따라 구독 상태 업데이트
+
+2. **Android 영수증 검증**
+ - Google Play 영수증 데이터(JSON) 추출
+ - 서버에 영수증 데이터 전송
+ - 서버는 Google Play API를 통해 영수증 검증
+ - 검증 결과에 따라 구독 상태 업데이트
+
+## 오류 처리
+
+- 결제 프로세스 중 발생하는 오류는 `_error` 속성에 저장되고 UI에 표시됩니다.
+- 네트워크 오류, 서버 오류, 검증 오류 등 다양한 오류 상황에 대한 처리가 구현되어 있습니다.
+- 오류 발생 시 사용자에게 적절한 피드백을 제공하고 필요한 경우 재시도 옵션을 제공합니다.
+
+## 테스트 및 QA
+
+구독 및 인앱 결제 기능을 테스트하려면:
+
+1. **샌드박스 환경 설정**
+ - iOS: App Store Connect에서 샌드박스 테스터 계정 설정
+ - Android: Google Play Console에서 테스트 트랙 설정
+
+2. **테스트 시나리오**
+ - 구독 구매
+ - 구독 복원
+ - 플랜 변경
+ - 자동 갱신 설정 변경
+ - 구독 만료 처리
+ - 다양한 오류 상황 테스트
+
+## 백엔드 API 엔드포인트
+
+- `/subscriptions/current`: 현재 구독 정보 조회
+- `/subscriptions/plans`: 사용 가능한 구독 플랜 목록 조회
+- `/subscriptions/subscribe`: 새 구독 생성
+- `/subscriptions/verify-receipt`: 인앱 결제 영수증 검증
+- `/subscriptions/change-with-features`: 구독 플랜 변경
+- `/subscriptions/autorenew`: 자동 갱신 설정 변경
+
+## 결론
+
+볼링 매니저의 구독 및 인앱 결제 시스템은 Flutter의 `in_app_purchase` 패키지와 커스텀 백엔드 API를 통합하여 안전하고 사용자 친화적인 결제 경험을 제공합니다. 이 문서는 시스템의 주요 구성 요소와 데이터 흐름을 설명하여 개발자들이 시스템을 이해하고 유지보수할 수 있도록 돕습니다.
diff --git a/mobile/ios/.gitignore b/mobile/ios/.gitignore
new file mode 100644
index 0000000..7a7f987
--- /dev/null
+++ b/mobile/ios/.gitignore
@@ -0,0 +1,34 @@
+**/dgph
+*.mode1v3
+*.mode2v3
+*.moved-aside
+*.pbxuser
+*.perspectivev3
+**/*sync/
+.sconsign.dblite
+.tags*
+**/.vagrant/
+**/DerivedData/
+Icon?
+**/Pods/
+**/.symlinks/
+profile
+xcuserdata
+**/.generated/
+Flutter/App.framework
+Flutter/Flutter.framework
+Flutter/Flutter.podspec
+Flutter/Generated.xcconfig
+Flutter/ephemeral/
+Flutter/app.flx
+Flutter/app.zip
+Flutter/flutter_assets/
+Flutter/flutter_export_environment.sh
+ServiceDefinitions.json
+Runner/GeneratedPluginRegistrant.*
+
+# Exceptions to above rules.
+!default.mode1v3
+!default.mode2v3
+!default.pbxuser
+!default.perspectivev3
diff --git a/mobile/ios/Flutter/AppFrameworkInfo.plist b/mobile/ios/Flutter/AppFrameworkInfo.plist
new file mode 100644
index 0000000..7c56964
--- /dev/null
+++ b/mobile/ios/Flutter/AppFrameworkInfo.plist
@@ -0,0 +1,26 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ App
+ CFBundleIdentifier
+ io.flutter.flutter.app
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ App
+ CFBundlePackageType
+ FMWK
+ CFBundleShortVersionString
+ 1.0
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ 1.0
+ MinimumOSVersion
+ 12.0
+
+
diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig
new file mode 100644
index 0000000..ec97fc6
--- /dev/null
+++ b/mobile/ios/Flutter/Debug.xcconfig
@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
+#include "Generated.xcconfig"
diff --git a/mobile/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig
new file mode 100644
index 0000000..c4855bf
--- /dev/null
+++ b/mobile/ios/Flutter/Release.xcconfig
@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
+#include "Generated.xcconfig"
diff --git a/mobile/ios/Podfile b/mobile/ios/Podfile
new file mode 100644
index 0000000..e549ee2
--- /dev/null
+++ b/mobile/ios/Podfile
@@ -0,0 +1,43 @@
+# Uncomment this line to define a global platform for your project
+# platform :ios, '12.0'
+
+# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
+ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+
+project 'Runner', {
+ 'Debug' => :debug,
+ 'Profile' => :release,
+ 'Release' => :release,
+}
+
+def flutter_root
+ generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
+ unless File.exist?(generated_xcode_build_settings_path)
+ raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
+ end
+
+ File.foreach(generated_xcode_build_settings_path) do |line|
+ matches = line.match(/FLUTTER_ROOT\=(.*)/)
+ return matches[1].strip if matches
+ end
+ raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
+end
+
+require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
+
+flutter_ios_podfile_setup
+
+target 'Runner' do
+ use_frameworks!
+
+ flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
+ target 'RunnerTests' do
+ inherit! :search_paths
+ end
+end
+
+post_install do |installer|
+ installer.pods_project.targets.each do |target|
+ flutter_additional_ios_build_settings(target)
+ end
+end
diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock
new file mode 100644
index 0000000..9d540c3
--- /dev/null
+++ b/mobile/ios/Podfile.lock
@@ -0,0 +1,43 @@
+PODS:
+ - Flutter (1.0.0)
+ - flutter_secure_storage (6.0.0):
+ - Flutter
+ - in_app_purchase_storekit (0.0.1):
+ - Flutter
+ - FlutterMacOS
+ - path_provider_foundation (0.0.1):
+ - Flutter
+ - FlutterMacOS
+ - shared_preferences_foundation (0.0.1):
+ - Flutter
+ - FlutterMacOS
+
+DEPENDENCIES:
+ - Flutter (from `Flutter`)
+ - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
+ - in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`)
+ - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
+ - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
+
+EXTERNAL SOURCES:
+ Flutter:
+ :path: Flutter
+ flutter_secure_storage:
+ :path: ".symlinks/plugins/flutter_secure_storage/ios"
+ in_app_purchase_storekit:
+ :path: ".symlinks/plugins/in_app_purchase_storekit/darwin"
+ path_provider_foundation:
+ :path: ".symlinks/plugins/path_provider_foundation/darwin"
+ shared_preferences_foundation:
+ :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
+
+SPEC CHECKSUMS:
+ Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
+ flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
+ in_app_purchase_storekit: d1a48cb0f8b29dbf5f85f782f5dd79b21b90a5e6
+ path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
+ shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
+
+PODFILE CHECKSUM: 4305caec6b40dde0ae97be1573c53de1882a07e5
+
+COCOAPODS: 1.16.2
diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..5fcef8a
--- /dev/null
+++ b/mobile/ios/Runner.xcodeproj/project.pbxproj
@@ -0,0 +1,728 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 54;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
+ A2B54DBFF2070F0147141B2E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2AB1FA8761A510A1AB0E22A6 /* Pods_Runner.framework */; };
+ BED0EB1D643B5C8960491578 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D87907F099B026F68EDEAB39 /* Pods_RunnerTests.framework */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 97C146E61CF9000F007C117D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 97C146ED1CF9000F007C117D;
+ remoteInfo = Runner;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 9705A1C41CF9048500538489 /* Embed Frameworks */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ );
+ name = "Embed Frameworks";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; };
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; };
+ 2AB1FA8761A510A1AB0E22A6 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; };
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 355A374A8D2A72F499E2A232 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; };
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; };
+ 3F6039EC571A40CA5A3421CE /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; };
+ 53D946C2A42D4F1B1B4DBE9C /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; };
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
+ 8154F9AE74B580244D51C084 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; };
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; };
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; };
+ 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
+ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ BCE312C5D51857E9B078DC0B /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; };
+ D87907F099B026F68EDEAB39 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ F9A79F7167432F25A9176E3F /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 97C146EB1CF9000F007C117D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A2B54DBFF2070F0147141B2E /* Pods_Runner.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A3C195AAAC5367AC911F04A6 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ BED0EB1D643B5C8960491578 /* Pods_RunnerTests.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 1EEF9CC0167CE714191E3BC4 /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ 2AB1FA8761A510A1AB0E22A6 /* Pods_Runner.framework */,
+ D87907F099B026F68EDEAB39 /* Pods_RunnerTests.framework */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+ 331C8082294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXGroup;
+ children = (
+ 331C807B294A618700263BE5 /* RunnerTests.swift */,
+ );
+ path = RunnerTests;
+ sourceTree = "";
+ };
+ 9740EEB11CF90186004384FC /* Flutter */ = {
+ isa = PBXGroup;
+ children = (
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */,
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */,
+ );
+ name = Flutter;
+ sourceTree = "";
+ };
+ 97C146E51CF9000F007C117D = {
+ isa = PBXGroup;
+ children = (
+ 9740EEB11CF90186004384FC /* Flutter */,
+ 97C146F01CF9000F007C117D /* Runner */,
+ 97C146EF1CF9000F007C117D /* Products */,
+ 331C8082294A63A400263BE5 /* RunnerTests */,
+ F8390BE3267FB56413CCA22F /* Pods */,
+ 1EEF9CC0167CE714191E3BC4 /* Frameworks */,
+ );
+ sourceTree = "";
+ };
+ 97C146EF1CF9000F007C117D /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146EE1CF9000F007C117D /* Runner.app */,
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 97C146F01CF9000F007C117D /* Runner */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146FA1CF9000F007C117D /* Main.storyboard */,
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */,
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
+ 97C147021CF9000F007C117D /* Info.plist */,
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
+ );
+ path = Runner;
+ sourceTree = "";
+ };
+ F8390BE3267FB56413CCA22F /* Pods */ = {
+ isa = PBXGroup;
+ children = (
+ 8154F9AE74B580244D51C084 /* Pods-Runner.debug.xcconfig */,
+ 355A374A8D2A72F499E2A232 /* Pods-Runner.release.xcconfig */,
+ F9A79F7167432F25A9176E3F /* Pods-Runner.profile.xcconfig */,
+ 53D946C2A42D4F1B1B4DBE9C /* Pods-RunnerTests.debug.xcconfig */,
+ 3F6039EC571A40CA5A3421CE /* Pods-RunnerTests.release.xcconfig */,
+ BCE312C5D51857E9B078DC0B /* Pods-RunnerTests.profile.xcconfig */,
+ );
+ name = Pods;
+ path = Pods;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 331C8080294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
+ buildPhases = (
+ 8CE422EB9696F2D9AC24A2BF /* [CP] Check Pods Manifest.lock */,
+ 331C807D294A63A400263BE5 /* Sources */,
+ 331C807F294A63A400263BE5 /* Resources */,
+ A3C195AAAC5367AC911F04A6 /* Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */,
+ );
+ name = RunnerTests;
+ productName = RunnerTests;
+ productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ 97C146ED1CF9000F007C117D /* Runner */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
+ buildPhases = (
+ F39579041B6959A7AE1A2D5D /* [CP] Check Pods Manifest.lock */,
+ 9740EEB61CF901F6004384FC /* Run Script */,
+ 97C146EA1CF9000F007C117D /* Sources */,
+ 97C146EB1CF9000F007C117D /* Frameworks */,
+ 97C146EC1CF9000F007C117D /* Resources */,
+ 9705A1C41CF9048500538489 /* Embed Frameworks */,
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+ 8A77A54775D4EC0D63AC9681 /* [CP] Embed Pods Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Runner;
+ productName = Runner;
+ productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 97C146E61CF9000F007C117D /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 1510;
+ ORGANIZATIONNAME = "";
+ TargetAttributes = {
+ 331C8080294A63A400263BE5 = {
+ CreatedOnToolsVersion = 14.0;
+ TestTargetID = 97C146ED1CF9000F007C117D;
+ };
+ 97C146ED1CF9000F007C117D = {
+ CreatedOnToolsVersion = 7.3.1;
+ LastSwiftMigration = 1100;
+ };
+ };
+ };
+ buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
+ compatibilityVersion = "Xcode 9.3";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 97C146E51CF9000F007C117D;
+ productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 97C146ED1CF9000F007C117D /* Runner */,
+ 331C8080294A63A400263BE5 /* RunnerTests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 331C807F294A63A400263BE5 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EC1CF9000F007C117D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
+ );
+ name = "Thin Binary";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
+ };
+ 8A77A54775D4EC0D63AC9681 /* [CP] Embed Pods Frameworks */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ name = "[CP] Embed Pods Frameworks";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 8CE422EB9696F2D9AC24A2BF /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 9740EEB61CF901F6004384FC /* Run Script */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "Run Script";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
+ };
+ F39579041B6959A7AE1A2D5D /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ showEnvVarsInLog = 0;
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 331C807D294A63A400263BE5 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EA1CF9000F007C117D /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 97C146ED1CF9000F007C117D /* Runner */;
+ targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+ 97C146FA1CF9000F007C117D /* Main.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C146FB1CF9000F007C117D /* Base */,
+ );
+ name = Main.storyboard;
+ sourceTree = "";
+ };
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C147001CF9000F007C117D /* Base */,
+ );
+ name = LaunchScreen.storyboard;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 249021D3217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Profile;
+ };
+ 249021D4217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.mobile;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Profile;
+ };
+ 331C8088294A63A400263BE5 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 53D946C2A42D4F1B1B4DBE9C /* Pods-RunnerTests.debug.xcconfig */;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.mobile.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Debug;
+ };
+ 331C8089294A63A400263BE5 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 3F6039EC571A40CA5A3421CE /* Pods-RunnerTests.release.xcconfig */;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.mobile.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Release;
+ };
+ 331C808A294A63A400263BE5 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = BCE312C5D51857E9B078DC0B /* Pods-RunnerTests.profile.xcconfig */;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.mobile.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Profile;
+ };
+ 97C147031CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 97C147041CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 97C147061CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.mobile;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Debug;
+ };
+ 97C147071CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.mobile;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 331C8088294A63A400263BE5 /* Debug */,
+ 331C8089294A63A400263BE5 /* Release */,
+ 331C808A294A63A400263BE5 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147031CF9000F007C117D /* Debug */,
+ 97C147041CF9000F007C117D /* Release */,
+ 249021D3217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147061CF9000F007C117D /* Debug */,
+ 97C147071CF9000F007C117D /* Release */,
+ 249021D4217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 97C146E61CF9000F007C117D /* Project object */;
+}
diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..f9b0d7c
--- /dev/null
+++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
new file mode 100644
index 0000000..e3773d4
--- /dev/null
+++ b/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata b/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..21a3cc1
--- /dev/null
+++ b/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..f9b0d7c
--- /dev/null
+++ b/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift
new file mode 100644
index 0000000..6266644
--- /dev/null
+++ b/mobile/ios/Runner/AppDelegate.swift
@@ -0,0 +1,13 @@
+import Flutter
+import UIKit
+
+@main
+@objc class AppDelegate: FlutterAppDelegate {
+ override func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
+ ) -> Bool {
+ GeneratedPluginRegistrant.register(with: self)
+ return super.application(application, didFinishLaunchingWithOptions: launchOptions)
+ }
+}
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..d36b1fa
--- /dev/null
+++ b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,122 @@
+{
+ "images" : [
+ {
+ "size" : "20x20",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-20x20@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-20x20@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-40x40@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-40x40@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "60x60",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-60x60@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "60x60",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-60x60@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-20x20@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-20x20@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-29x29@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-29x29@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-40x40@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-40x40@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "76x76",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-76x76@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "76x76",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-76x76@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "83.5x83.5",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-83.5x83.5@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "1024x1024",
+ "idiom" : "ios-marketing",
+ "filename" : "Icon-App-1024x1024@1x.png",
+ "scale" : "1x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
new file mode 100644
index 0000000..a8d54f8
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
new file mode 100644
index 0000000..11110f3
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
new file mode 100644
index 0000000..6b277f0
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
new file mode 100644
index 0000000..60127e9
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
new file mode 100644
index 0000000..1d2bd23
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
new file mode 100644
index 0000000..5da2f8b
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
new file mode 100644
index 0000000..e56544f
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
new file mode 100644
index 0000000..6b277f0
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
new file mode 100644
index 0000000..48bd6d7
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
new file mode 100644
index 0000000..4b918c0
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png
new file mode 100644
index 0000000..7fa32d2
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png
new file mode 100644
index 0000000..239bf8b
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png
new file mode 100644
index 0000000..86bfb9f
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png
new file mode 100644
index 0000000..f765865
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
new file mode 100644
index 0000000..4b918c0
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
new file mode 100644
index 0000000..f93a31e
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png
new file mode 100644
index 0000000..d3fdf59
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png
new file mode 100644
index 0000000..330c0f1
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
new file mode 100644
index 0000000..15b87e4
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
new file mode 100644
index 0000000..1ca410c
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
new file mode 100644
index 0000000..c8536ce
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
new file mode 100644
index 0000000..0bedcf2
--- /dev/null
+++ b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@3x.png",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
new file mode 100644
index 0000000..9da19ea
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
new file mode 100644
index 0000000..9da19ea
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
new file mode 100644
index 0000000..9da19ea
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
new file mode 100644
index 0000000..89c2725
--- /dev/null
+++ b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
@@ -0,0 +1,5 @@
+# Launch Screen Assets
+
+You can customize the launch screen with your own desired assets by replacing the image files in this directory.
+
+You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
\ No newline at end of file
diff --git a/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard b/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 0000000..f2e259c
--- /dev/null
+++ b/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/ios/Runner/Base.lproj/Main.storyboard b/mobile/ios/Runner/Base.lproj/Main.storyboard
new file mode 100644
index 0000000..f3c2851
--- /dev/null
+++ b/mobile/ios/Runner/Base.lproj/Main.storyboard
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist
new file mode 100644
index 0000000..dacb531
--- /dev/null
+++ b/mobile/ios/Runner/Info.plist
@@ -0,0 +1,49 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Lanebow
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ lanebow
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSRequiresIPhoneOS
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ CADisableMinimumFrameDurationOnPhone
+
+ UIApplicationSupportsIndirectInputEvents
+
+
+
diff --git a/mobile/ios/Runner/Runner-Bridging-Header.h b/mobile/ios/Runner/Runner-Bridging-Header.h
new file mode 100644
index 0000000..308a2a5
--- /dev/null
+++ b/mobile/ios/Runner/Runner-Bridging-Header.h
@@ -0,0 +1 @@
+#import "GeneratedPluginRegistrant.h"
diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift
new file mode 100644
index 0000000..86a7c3b
--- /dev/null
+++ b/mobile/ios/RunnerTests/RunnerTests.swift
@@ -0,0 +1,12 @@
+import Flutter
+import UIKit
+import XCTest
+
+class RunnerTests: XCTestCase {
+
+ func testExample() {
+ // If you add code to the Runner application, consider adding tests here.
+ // See https://developer.apple.com/documentation/xctest for more information about using XCTest.
+ }
+
+}
diff --git a/mobile/lib/config/api_config.dart b/mobile/lib/config/api_config.dart
new file mode 100644
index 0000000..c2c2f8b
--- /dev/null
+++ b/mobile/lib/config/api_config.dart
@@ -0,0 +1,38 @@
+class ApiConfig {
+ // 백엔드 API 기본 URL
+ static const String baseUrl = 'https://lanebow.com/api';
+
+ // API 엔드포인트
+ static const String login = '/auth/login';
+ static const String register = '/auth/register';
+ static const String forgotPassword = '/auth/forgot-password';
+ static const String profile = '/users/profile';
+
+ // 클럽 관련 엔드포인트
+ static const String clubs = '/club';
+ static const String members = '/members';
+ static const String events = '/events';
+ static const String scores = '/scores';
+
+ // 구독 관련 엔드포인트
+ static const String subscriptions = '/subscriptions';
+ static const String subscriptionPlans = '/subscriptions/plans';
+ static const String currentSubscription = '/subscriptions/current';
+ static const String subscribeClub = '/subscriptions/subscribe';
+ static const String subscriptionPayments = '/subscriptions/payments';
+ static const String changePlan = '/subscriptions/change-with-features';
+
+ // 결제 관련 엔드포인트
+ static const String paymentRequest = '/subscriptions/payment/request';
+ static const String paymentSuccess = '/subscriptions/payment/success';
+ static const String paymentFail = '/subscriptions/payment/fail';
+
+ // 인앱 결제 검증 엔드포인트
+ static const String verifyReceipt = '/subscriptions/verify-receipt';
+
+ // 토큰 저장 키
+ static const String tokenKey = 'auth_token';
+ static const String userIdKey = 'user_id';
+ static const String userRoleKey = 'user_role';
+ static const String clubIdKey = 'club_id';
+}
diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart
new file mode 100644
index 0000000..f8df5be
--- /dev/null
+++ b/mobile/lib/main.dart
@@ -0,0 +1,344 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:flutter_localizations/flutter_localizations.dart';
+
+import 'services/auth_service.dart';
+import 'services/club_service.dart';
+import 'services/member_service.dart';
+import 'services/event_service.dart';
+import 'services/score_service.dart';
+import 'screens/auth/login_screen.dart';
+import 'screens/auth/profile_screen.dart';
+import 'screens/club/dashboard_screen.dart';
+import 'screens/club/members_screen.dart';
+import 'screens/club/events_screen.dart';
+import 'screens/club/club_settings_screen.dart';
+import 'screens/score/club_statistics_screen.dart';
+
+// 전역 네비게이터 키 (인증 오류 처리용)
+final GlobalKey navigatorKey = GlobalKey();
+
+void main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+ runApp(const MyApp());
+}
+
+class MyApp extends StatelessWidget {
+ const MyApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return MultiProvider(
+ providers: [
+ ChangeNotifierProvider(create: (_) => AuthService()),
+ ChangeNotifierProvider(create: (_) => ClubService()),
+ ChangeNotifierProvider(create: (_) => MemberService()),
+ ChangeNotifierProvider(create: (_) => EventService()),
+ ChangeNotifierProvider(create: (_) => ScoreService()),
+ ],
+ child: Consumer(
+ builder: (context, authService, _) {
+ // 앱 시작 시 인증 서비스 초기화
+ Future.delayed(Duration.zero, () {
+ if (!authService.isInitialized) {
+ authService.initialize();
+ }
+ });
+
+ return MaterialApp(
+ navigatorKey: navigatorKey, // 인증 오류 처리를 위한 전역 네비게이터 키
+ title: '볼링 매니저',
+ theme: ThemeData(
+ colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
+ useMaterial3: true,
+ appBarTheme: const AppBarTheme(
+ backgroundColor: Colors.blue,
+ foregroundColor: Colors.white,
+ ),
+ ),
+ // 로케일 설정 추가
+ localizationsDelegates: const [
+ GlobalMaterialLocalizations.delegate,
+ GlobalWidgetsLocalizations.delegate,
+ GlobalCupertinoLocalizations.delegate,
+ ],
+ supportedLocales: const [
+ Locale('ko', 'KR'),
+ Locale('en', 'US'),
+ ],
+ locale: const Locale('ko', 'KR'),
+ home: authService.isAuthenticated ? const HomeScreen() : const LoginScreen(),
+ routes: {
+ '/login': (context) => const LoginScreen(),
+ '/profile': (context) => const ProfileScreen(),
+ ClubSettingsScreen.routeName: (context) => const ClubSettingsScreen(),
+ },
+ );
+ },
+ ),
+ );
+ }
+}
+
+class HomeScreen extends StatefulWidget {
+ const HomeScreen({super.key});
+
+ @override
+ State createState() => _HomeScreenState();
+}
+
+class _HomeScreenState extends State {
+ int _selectedIndex = 0;
+ bool _isInit = false;
+
+ static final List _widgetOptions = [
+ const DashboardScreen(),
+ const MembersScreen(),
+ const EventsScreen(),
+ const ClubStatisticsScreen(),
+ const ProfileScreen(),
+ ];
+
+ void _onItemTapped(int index) {
+ setState(() {
+ _selectedIndex = index;
+ });
+ }
+
+ // 클럽 선택 다이얼로그 표시
+ Future _showClubSelectionDialog(BuildContext context) async {
+ final clubService = Provider.of(context, listen: false);
+ final authService = Provider.of(context, listen: false);
+
+ // 사용자의 모든 클럽 가져오기
+ if (clubService.clubs.isEmpty) {
+ try {
+ await clubService.fetchUserClubs();
+ } catch (e) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('클럽 목록을 불러오는데 실패했습니다: $e')),
+ );
+ return;
+ }
+ }
+
+ if (clubService.clubs.isEmpty) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('소속된 클럽이 없습니다')),
+ );
+ return;
+ }
+
+ if (!context.mounted) return;
+
+ // 다이얼로그 표시
+ showDialog(
+ context: context,
+ builder: (BuildContext context) {
+ return AlertDialog(
+ title: const Text('클럽 선택'),
+ content: SizedBox(
+ width: double.maxFinite,
+ child: ListView.builder(
+ shrinkWrap: true,
+ itemCount: clubService.clubs.length,
+ itemBuilder: (context, index) {
+ final club = clubService.clubs[index];
+ final isSelected = clubService.currentClub?.id == club.id;
+
+ return ListTile(
+ title: Text(club.name),
+ subtitle: Text(club.description ?? ''),
+ trailing: isSelected ? const Icon(Icons.check, color: Colors.blue) : null,
+ onTap: () async {
+ Navigator.of(context).pop();
+
+ if (!isSelected) {
+ try {
+ // 백엔드 세션에 clubId 저장하고 클럽 정보 가져오기
+ await clubService.selectClub(club.id);
+
+ // 관련 서비스 초기화
+ final memberService = Provider.of(context, listen: false);
+ final eventService = Provider.of(context, listen: false);
+ final scoreService = Provider.of(context, listen: false);
+
+ memberService.initialize(authService.token!);
+ eventService.initialize(authService.token!);
+ scoreService.initialize(authService.token!);
+
+ // 데이터 다시 로드
+ await Future.wait([
+ memberService.fetchClubMembers(),
+ eventService.fetchClubEvents(),
+ ]);
+
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('${club.name} 클럽으로 변경되었습니다')),
+ );
+ }
+ } catch (e) {
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('클럽 변경 중 오류가 발생했습니다: $e')),
+ );
+ }
+ }
+ }
+ },
+ );
+ },
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('취소'),
+ ),
+ ],
+ );
+ },
+ );
+ }
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ if (!_isInit) {
+ _initializeServices();
+ _isInit = true;
+ }
+ }
+
+ Future _initializeServices() async {
+ try {
+ final authService = Provider.of(context, listen: false);
+ final clubService = Provider.of(context, listen: false);
+ final scoreService = Provider.of(context, listen: false);
+
+ if (authService.isAuthenticated && authService.token != null) {
+ // 클럽 서비스 초기화
+ await clubService.initialize(authService.token!);
+
+ // 클럽이 선택된 경우 점수 서비스 초기화
+ if (clubService.currentClub != null) {
+ scoreService.initialize(authService.token!);
+ } else {
+ // 현재 선택된 클럽이 없는 경우
+ // 사용자의 클럽 목록 가져오기
+ await clubService.fetchUserClubs();
+
+ // 클럽이 하나만 있으면 자동으로 선택
+ if (clubService.clubs.length == 1) {
+ try {
+ await clubService.selectClub(clubService.clubs[0].id);
+ scoreService.initialize(authService.token!);
+
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('${clubService.clubs[0].name} 클럽이 자동으로 선택되었습니다')),
+ );
+ }
+ } catch (e) {
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('클럽 자동 선택 중 오류가 발생했습니다: $e')),
+ );
+ }
+ }
+ } else if (clubService.clubs.isNotEmpty) {
+ // 클럽이 여러 개 있으면 선택 다이얼로그 표시
+ Future.delayed(Duration.zero, () {
+ if (context.mounted) {
+ _showClubSelectionDialog(context);
+ }
+ });
+ } else {
+ // 클럽이 없는 경우 메시지 표시
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('소속된 클럽이 없습니다. 클럽을 생성하거나 초대를 요청하세요.')),
+ );
+ }
+ }
+ }
+ }
+ } catch (e) {
+ // 오류 처리
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('서비스 초기화 중 오류가 발생했습니다: $e')),
+ );
+ }
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: Consumer(
+ builder: (context, clubService, child) {
+ return InkWell(
+ onTap: () => _showClubSelectionDialog(context),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ clubService.currentClub?.name ?? '볼링 매니저',
+ style: const TextStyle(fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(width: 4),
+ const Icon(Icons.arrow_drop_down, size: 20),
+ ],
+ ),
+ );
+ },
+ ),
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.notifications),
+ onPressed: () {
+ // 알림 기능 (추후 구현)
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('알림 기능은 아직 개발 중입니다')),
+ );
+ },
+ ),
+ ],
+ ),
+ body: _widgetOptions.elementAt(_selectedIndex),
+ bottomNavigationBar: BottomNavigationBar(
+ type: BottomNavigationBarType.fixed,
+ items: const [
+ BottomNavigationBarItem(
+ icon: Icon(Icons.dashboard),
+ label: '대시보드',
+ ),
+ BottomNavigationBarItem(
+ icon: Icon(Icons.people),
+ label: '회원',
+ ),
+ BottomNavigationBarItem(
+ icon: Icon(Icons.event),
+ label: '이벤트',
+ ),
+ BottomNavigationBarItem(
+ icon: Icon(Icons.score),
+ label: '통계',
+ ),
+ BottomNavigationBarItem(
+ icon: Icon(Icons.person),
+ label: '프로필',
+ ),
+ ],
+ currentIndex: _selectedIndex,
+ selectedItemColor: Colors.blue,
+ onTap: _onItemTapped,
+ ),
+ );
+ }
+}
+
+
diff --git a/mobile/lib/models/admin_model.dart b/mobile/lib/models/admin_model.dart
new file mode 100644
index 0000000..7dec95b
--- /dev/null
+++ b/mobile/lib/models/admin_model.dart
@@ -0,0 +1,265 @@
+
+/// 사용자 역할 정의
+enum UserRole {
+ member, // 일반 회원
+ manager, // 매니저 (일부 관리 권한)
+ admin, // 관리자 (대부분의 관리 권한)
+ owner // 소유자 (모든 권한)
+}
+
+/// 관리 권한 정의
+class AdminPermission {
+ final bool canManageMembers; // 회원 관리 권한
+ final bool canManageEvents; // 이벤트 관리 권한
+ final bool canManageScores; // 점수 관리 권한
+ final bool canManageSettings; // 설정 관리 권한
+ final bool canManageSubscription; // 구독 관리 권한
+ final bool canManageRoles; // 역할 관리 권한
+ final bool canViewFinancials; // 재정 정보 조회 권한
+ final bool canExportData; // 데이터 내보내기 권한
+
+ const AdminPermission({
+ this.canManageMembers = false,
+ this.canManageEvents = false,
+ this.canManageScores = false,
+ this.canManageSettings = false,
+ this.canManageSubscription = false,
+ this.canManageRoles = false,
+ this.canViewFinancials = false,
+ this.canExportData = false,
+ });
+
+ /// 역할에 따른 기본 권한 생성
+ factory AdminPermission.fromRole(UserRole role) {
+ switch (role) {
+ case UserRole.member:
+ return const AdminPermission();
+ case UserRole.manager:
+ return const AdminPermission(
+ canManageMembers: true,
+ canManageEvents: true,
+ canManageScores: true,
+ canExportData: true,
+ );
+ case UserRole.admin:
+ return const AdminPermission(
+ canManageMembers: true,
+ canManageEvents: true,
+ canManageScores: true,
+ canManageSettings: true,
+ canViewFinancials: true,
+ canExportData: true,
+ );
+ case UserRole.owner:
+ return const AdminPermission(
+ canManageMembers: true,
+ canManageEvents: true,
+ canManageScores: true,
+ canManageSettings: true,
+ canManageSubscription: true,
+ canManageRoles: true,
+ canViewFinancials: true,
+ canExportData: true,
+ );
+ }
+ }
+
+ /// JSON에서 권한 객체 생성
+ factory AdminPermission.fromJson(Map json) {
+ return AdminPermission(
+ canManageMembers: json['canManageMembers'] ?? false,
+ canManageEvents: json['canManageEvents'] ?? false,
+ canManageScores: json['canManageScores'] ?? false,
+ canManageSettings: json['canManageSettings'] ?? false,
+ canManageSubscription: json['canManageSubscription'] ?? false,
+ canManageRoles: json['canManageRoles'] ?? false,
+ canViewFinancials: json['canViewFinancials'] ?? false,
+ canExportData: json['canExportData'] ?? false,
+ );
+ }
+
+ /// 권한 객체를 JSON으로 변환
+ Map toJson() {
+ return {
+ 'canManageMembers': canManageMembers,
+ 'canManageEvents': canManageEvents,
+ 'canManageScores': canManageScores,
+ 'canManageSettings': canManageSettings,
+ 'canManageSubscription': canManageSubscription,
+ 'canManageRoles': canManageRoles,
+ 'canViewFinancials': canViewFinancials,
+ 'canExportData': canExportData,
+ };
+ }
+}
+
+/// 클럽 회원 역할 정보
+class MemberRole {
+ final String userId;
+ final String memberId;
+ final String clubId;
+ final UserRole role;
+ final AdminPermission permissions;
+ final DateTime assignedAt;
+ final String? assignedBy;
+
+ MemberRole({
+ required this.userId,
+ required this.memberId,
+ required this.clubId,
+ required this.role,
+ required this.permissions,
+ required this.assignedAt,
+ this.assignedBy,
+ });
+
+ /// JSON에서 회원 역할 객체 생성
+ factory MemberRole.fromJson(Map json) {
+ return MemberRole(
+ userId: json['userId'],
+ memberId: json['memberId'],
+ clubId: json['clubId'],
+ role: UserRole.values.firstWhere(
+ (e) => e.toString().split('.').last == json['role'],
+ orElse: () => UserRole.member,
+ ),
+ permissions: AdminPermission.fromJson(json['permissions'] ?? {}),
+ assignedAt: DateTime.parse(json['assignedAt']),
+ assignedBy: json['assignedBy'],
+ );
+ }
+
+ /// 회원 역할 객체를 JSON으로 변환
+ Map toJson() {
+ return {
+ 'userId': userId,
+ 'memberId': memberId,
+ 'clubId': clubId,
+ 'role': role.toString().split('.').last,
+ 'permissions': permissions.toJson(),
+ 'assignedAt': assignedAt.toIso8601String(),
+ 'assignedBy': assignedBy,
+ };
+ }
+
+ /// 역할 이름 (한글)
+ String get roleName {
+ switch (role) {
+ case UserRole.member:
+ return '일반 회원';
+ case UserRole.manager:
+ return '매니저';
+ case UserRole.admin:
+ return '관리자';
+ case UserRole.owner:
+ return '소유자';
+ }
+ }
+}
+
+/// 클럽 설정 모델
+class ClubSettings {
+ final String clubId;
+ final String clubName;
+ final String? logoUrl;
+ final String? bannerUrl;
+ final String? description;
+ final String? contactEmail;
+ final String? contactPhone;
+ final String? address;
+ final String? website;
+ final Map? customFields;
+ final Map? notificationSettings;
+ final Map? privacySettings;
+ final DateTime createdAt;
+ final DateTime updatedAt;
+
+ ClubSettings({
+ required this.clubId,
+ required this.clubName,
+ this.logoUrl,
+ this.bannerUrl,
+ this.description,
+ this.contactEmail,
+ this.contactPhone,
+ this.address,
+ this.website,
+ this.customFields,
+ this.notificationSettings,
+ this.privacySettings,
+ required this.createdAt,
+ required this.updatedAt,
+ });
+
+ /// JSON에서 클럽 설정 객체 생성
+ factory ClubSettings.fromJson(Map json) {
+ return ClubSettings(
+ clubId: json['clubId'],
+ clubName: json['clubName'],
+ logoUrl: json['logoUrl'],
+ bannerUrl: json['bannerUrl'],
+ description: json['description'],
+ contactEmail: json['contactEmail'],
+ contactPhone: json['contactPhone'],
+ address: json['address'],
+ website: json['website'],
+ customFields: json['customFields'],
+ notificationSettings: json['notificationSettings'],
+ privacySettings: json['privacySettings'],
+ createdAt: DateTime.parse(json['createdAt']),
+ updatedAt: DateTime.parse(json['updatedAt']),
+ );
+ }
+
+ /// 클럽 설정 객체를 JSON으로 변환
+ Map toJson() {
+ return {
+ 'clubId': clubId,
+ 'clubName': clubName,
+ 'logoUrl': logoUrl,
+ 'bannerUrl': bannerUrl,
+ 'description': description,
+ 'contactEmail': contactEmail,
+ 'contactPhone': contactPhone,
+ 'address': address,
+ 'website': website,
+ 'customFields': customFields,
+ 'notificationSettings': notificationSettings,
+ 'privacySettings': privacySettings,
+ 'createdAt': createdAt.toIso8601String(),
+ 'updatedAt': updatedAt.toIso8601String(),
+ };
+ }
+
+ /// 복사본 생성 (일부 필드 업데이트)
+ ClubSettings copyWith({
+ String? clubName,
+ String? logoUrl,
+ String? bannerUrl,
+ String? description,
+ String? contactEmail,
+ String? contactPhone,
+ String? address,
+ String? website,
+ Map? customFields,
+ Map? notificationSettings,
+ Map? privacySettings,
+ }) {
+ return ClubSettings(
+ clubId: clubId,
+ clubName: clubName ?? this.clubName,
+ logoUrl: logoUrl ?? this.logoUrl,
+ bannerUrl: bannerUrl ?? this.bannerUrl,
+ description: description ?? this.description,
+ contactEmail: contactEmail ?? this.contactEmail,
+ contactPhone: contactPhone ?? this.contactPhone,
+ address: address ?? this.address,
+ website: website ?? this.website,
+ customFields: customFields ?? this.customFields,
+ notificationSettings: notificationSettings ?? this.notificationSettings,
+ privacySettings: privacySettings ?? this.privacySettings,
+ createdAt: createdAt,
+ updatedAt: DateTime.now(),
+ );
+ }
+}
diff --git a/mobile/lib/models/club_model.dart b/mobile/lib/models/club_model.dart
new file mode 100644
index 0000000..e23b6fa
--- /dev/null
+++ b/mobile/lib/models/club_model.dart
@@ -0,0 +1,77 @@
+class Club {
+ final String id;
+ final String name;
+ final String? description;
+ final String? logo;
+ final String? address;
+ final String? location;
+ final String? phone;
+ final String? email;
+ final String? website;
+ final int? memberCount;
+ final String? ownerId;
+ final int? femaleHandicap;
+ final String? averageCalculationPeriod;
+ final DateTime? createdAt;
+ final DateTime? updatedAt;
+
+ Club({
+ required this.id,
+ required this.name,
+ this.description,
+ this.logo,
+ this.address,
+ this.location,
+ this.phone,
+ this.email,
+ this.website,
+ this.memberCount,
+ this.ownerId,
+ this.femaleHandicap,
+ this.averageCalculationPeriod,
+ this.createdAt,
+ this.updatedAt,
+ });
+
+ // JSON 데이터로부터 Club 객체 생성
+ factory Club.fromJson(Map json) {
+ return Club(
+ id: json['id'] != null ? json['id'].toString() : '',
+ name: json['name'] ?? '',
+ description: json['description'],
+ logo: json['logo'],
+ address: json['address'],
+ location: json['location'],
+ phone: json['phone'] ?? json['contact'], // contact 필드도 확인
+ email: json['email'],
+ website: json['website'],
+ memberCount: json['memberCount'],
+ ownerId: json['ownerId']?.toString(),
+ femaleHandicap: json['femaleHandicap'] != null ? int.tryParse(json['femaleHandicap'].toString()) : null,
+ averageCalculationPeriod: json['averageCalculationPeriod'],
+ createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
+ updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : null,
+ );
+ }
+
+ // Club 객체를 JSON으로 변환
+ Map toJson() {
+ return {
+ 'id': id,
+ 'name': name,
+ 'description': description,
+ 'logo': logo,
+ 'address': address,
+ 'location': location,
+ 'phone': phone,
+ 'email': email,
+ 'website': website,
+ 'memberCount': memberCount,
+ 'ownerId': ownerId,
+ 'femaleHandicap': femaleHandicap,
+ 'averageCalculationPeriod': averageCalculationPeriod,
+ 'createdAt': createdAt?.toIso8601String(),
+ 'updatedAt': updatedAt?.toIso8601String(),
+ };
+ }
+}
diff --git a/mobile/lib/models/event_model.dart b/mobile/lib/models/event_model.dart
new file mode 100644
index 0000000..c7596f5
--- /dev/null
+++ b/mobile/lib/models/event_model.dart
@@ -0,0 +1,73 @@
+class Event {
+ final String id;
+ final String clubId;
+ final String title;
+ final String? description;
+ final DateTime startDate;
+ final DateTime? endDate;
+ final String? location;
+ final String? type;
+ final int? maxParticipants;
+ final int? currentParticipants;
+ final bool isActive;
+ final String? createdBy;
+ final DateTime? createdAt;
+ final DateTime? updatedAt;
+
+ Event({
+ required this.id,
+ required this.clubId,
+ required this.title,
+ this.description,
+ required this.startDate,
+ this.endDate,
+ this.location,
+ this.type,
+ this.maxParticipants,
+ this.currentParticipants,
+ required this.isActive,
+ this.createdBy,
+ this.createdAt,
+ this.updatedAt,
+ });
+
+ // JSON 데이터로부터 Event 객체 생성
+ factory Event.fromJson(Map json) {
+ return Event(
+ id: json['id']?.toString() ?? '',
+ clubId: json['clubId']?.toString() ?? '',
+ title: json['title'] ?? '',
+ description: json['description']?.toString(),
+ startDate: json['startDate'] != null ? DateTime.parse(json['startDate'].toString()) : DateTime.now(),
+ endDate: json['endDate'] != null ? DateTime.parse(json['endDate'].toString()) : null,
+ location: json['location']?.toString(),
+ type: json['type']?.toString(),
+ maxParticipants: json['maxParticipants'],
+ currentParticipants: json['currentParticipants'],
+ isActive: json['isActive'] ?? true,
+ createdBy: json['createdBy']?.toString(),
+ createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'].toString()) : null,
+ updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'].toString()) : null,
+ );
+ }
+
+ // Event 객체를 JSON으로 변환
+ Map toJson() {
+ return {
+ 'id': id,
+ 'clubId': clubId,
+ 'title': title,
+ 'description': description,
+ 'startDate': startDate.toIso8601String(),
+ 'endDate': endDate?.toIso8601String(),
+ 'location': location,
+ 'type': type,
+ 'maxParticipants': maxParticipants,
+ 'currentParticipants': currentParticipants,
+ 'isActive': isActive,
+ 'createdBy': createdBy,
+ 'createdAt': createdAt?.toIso8601String(),
+ 'updatedAt': updatedAt?.toIso8601String(),
+ };
+ }
+}
diff --git a/mobile/lib/models/member_model.dart b/mobile/lib/models/member_model.dart
new file mode 100644
index 0000000..23bd525
--- /dev/null
+++ b/mobile/lib/models/member_model.dart
@@ -0,0 +1,126 @@
+class Member {
+ final String id;
+ final String userId;
+ final String clubId;
+ final String name;
+ final String email;
+ final String? role;
+ final String? profileImage;
+ final String? phone;
+ final String? address;
+ final DateTime? joinDate;
+ final bool isActive;
+ final String? gender;
+ final String? memberType;
+ final int? handicap;
+ final String? status;
+ final DateTime? createdAt;
+ final DateTime? updatedAt;
+
+ Member({
+ required this.id,
+ required this.userId,
+ required this.clubId,
+ required this.name,
+ required this.email,
+ this.role,
+ this.profileImage,
+ this.phone,
+ this.address,
+ this.joinDate,
+ required this.isActive,
+ this.gender,
+ this.memberType,
+ this.handicap,
+ this.status,
+ this.createdAt,
+ this.updatedAt,
+ });
+
+ // JSON 데이터로부터 Member 객체 생성
+ factory Member.fromJson(Map json) {
+ return Member(
+ id: json['id']?.toString() ?? '',
+ userId: json['userId']?.toString() ?? '',
+ clubId: json['clubId']?.toString() ?? '',
+ name: json['name'] ?? '',
+ email: json['email'] ?? '',
+ role: json['role']?.toString(),
+ profileImage: json['profileImage']?.toString(),
+ phone: json['phone']?.toString(),
+ address: json['address']?.toString(),
+ joinDate: json['joinDate'] != null ? DateTime.parse(json['joinDate'].toString()) : null,
+ isActive: json['isActive'] ?? true,
+ gender: json['gender']?.toString(),
+ memberType: json['memberType']?.toString(),
+ handicap: json['handicap'] != null ? int.tryParse(json['handicap'].toString()) : null,
+ status: json['status']?.toString(),
+ createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'].toString()) : null,
+ updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'].toString()) : null,
+ );
+ }
+
+ // Member 객체를 JSON으로 변환
+ Map toJson() {
+ return {
+ 'id': id,
+ 'userId': userId,
+ 'clubId': clubId,
+ 'name': name,
+ 'email': email,
+ 'role': role,
+ 'profileImage': profileImage,
+ 'phone': phone,
+ 'address': address,
+ 'joinDate': joinDate?.toIso8601String(),
+ 'isActive': isActive,
+ 'gender': gender,
+ 'memberType': memberType,
+ 'handicap': handicap,
+ 'status': status,
+ 'createdAt': createdAt?.toIso8601String(),
+ 'updatedAt': updatedAt?.toIso8601String(),
+ };
+ }
+
+ // 객체 복사 및 특정 필드 업데이트를 위한 copyWith 메서드
+ Member copyWith({
+ String? id,
+ String? userId,
+ String? clubId,
+ String? name,
+ String? email,
+ String? role,
+ String? profileImage,
+ String? phone,
+ String? address,
+ DateTime? joinDate,
+ bool? isActive,
+ String? gender,
+ String? memberType,
+ int? handicap,
+ String? status,
+ DateTime? createdAt,
+ DateTime? updatedAt,
+ }) {
+ return Member(
+ id: id ?? this.id,
+ userId: userId ?? this.userId,
+ clubId: clubId ?? this.clubId,
+ name: name ?? this.name,
+ email: email ?? this.email,
+ role: role ?? this.role,
+ profileImage: profileImage ?? this.profileImage,
+ phone: phone ?? this.phone,
+ address: address ?? this.address,
+ joinDate: joinDate ?? this.joinDate,
+ isActive: isActive ?? this.isActive,
+ gender: gender ?? this.gender,
+ memberType: memberType ?? this.memberType,
+ handicap: handicap ?? this.handicap,
+ status: status ?? this.status,
+ createdAt: createdAt ?? this.createdAt,
+ updatedAt: updatedAt ?? this.updatedAt,
+ );
+ }
+}
diff --git a/mobile/lib/models/score_model.dart b/mobile/lib/models/score_model.dart
new file mode 100644
index 0000000..d69f8c9
--- /dev/null
+++ b/mobile/lib/models/score_model.dart
@@ -0,0 +1,83 @@
+class Score {
+ final String id;
+ final String memberId;
+ final String eventId;
+ final String clubId;
+ final List frames;
+ final int totalScore;
+ final DateTime date;
+ final String? notes;
+
+ Score({
+ required this.id,
+ required this.memberId,
+ required this.eventId,
+ required this.clubId,
+ required this.frames,
+ required this.totalScore,
+ required this.date,
+ this.notes,
+ });
+
+ factory Score.fromJson(Map json) {
+ return Score(
+ id: json['id']?.toString() ?? '',
+ memberId: json['memberId']?.toString() ?? '',
+ eventId: json['eventId']?.toString() ?? '',
+ clubId: json['clubId']?.toString() ?? '',
+ frames: json['frames'] != null ? List.from(json['frames']) : [],
+ totalScore: json['totalScore'] ?? 0,
+ date: json['date'] != null ? DateTime.parse(json['date'].toString()) : DateTime.now(),
+ notes: json['notes']?.toString(),
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'id': id,
+ 'memberId': memberId,
+ 'eventId': eventId,
+ 'clubId': clubId,
+ 'frames': frames,
+ 'totalScore': totalScore,
+ 'date': date.toIso8601String(),
+ 'notes': notes,
+ };
+ }
+}
+
+class ScoreStatistics {
+ final double average;
+ final int highScore;
+ final int lowScore;
+ final int gamesPlayed;
+ final Map? additionalStats;
+
+ ScoreStatistics({
+ required this.average,
+ required this.highScore,
+ required this.lowScore,
+ required this.gamesPlayed,
+ this.additionalStats,
+ });
+
+ factory ScoreStatistics.fromJson(Map json) {
+ return ScoreStatistics(
+ average: json['average'] != null ? (json['average'] is int ? (json['average'] as int).toDouble() : json['average'].toDouble()) : 0.0,
+ highScore: json['highScore'] ?? 0,
+ lowScore: json['lowScore'] ?? 0,
+ gamesPlayed: json['gamesPlayed'] ?? 0,
+ additionalStats: json['additionalStats'],
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'average': average,
+ 'highScore': highScore,
+ 'lowScore': lowScore,
+ 'gamesPlayed': gamesPlayed,
+ 'additionalStats': additionalStats,
+ };
+ }
+}
diff --git a/mobile/lib/models/subscription_model.dart b/mobile/lib/models/subscription_model.dart
new file mode 100644
index 0000000..a336900
--- /dev/null
+++ b/mobile/lib/models/subscription_model.dart
@@ -0,0 +1,243 @@
+
+/// 구독 플랜 타입
+enum SubscriptionPlanType {
+ free, // 무료 플랜
+ basic, // 기본 유료 플랜
+ premium, // 프리미엄 플랜
+ enterprise // 기업용 플랜
+}
+
+/// 구독 상태
+enum SubscriptionStatus {
+ active, // 활성화 상태
+ canceled, // 취소됨 (아직 만료 안됨)
+ expired, // 만료됨
+ pending, // 결제 대기 중
+ failed // 결제 실패
+}
+
+/// 구독 모델
+class Subscription {
+ final String id;
+ final String userId;
+ final String? clubId;
+ final SubscriptionPlanType planType;
+ final SubscriptionStatus status;
+ final DateTime startDate;
+ final DateTime endDate;
+ final double price;
+ final String currency;
+ final bool autoRenew;
+ final Map? features;
+ final String? paymentMethod;
+ final String? transactionId;
+ final DateTime createdAt;
+ final DateTime updatedAt;
+
+ Subscription({
+ required this.id,
+ required this.userId,
+ this.clubId,
+ required this.planType,
+ required this.status,
+ required this.startDate,
+ required this.endDate,
+ required this.price,
+ required this.currency,
+ required this.autoRenew,
+ this.features,
+ this.paymentMethod,
+ this.transactionId,
+ required this.createdAt,
+ required this.updatedAt,
+ });
+
+ /// JSON에서 구독 객체 생성
+ factory Subscription.fromJson(Map json) {
+ return Subscription(
+ id: json['id'],
+ userId: json['userId'],
+ clubId: json['clubId'],
+ planType: SubscriptionPlanType.values.firstWhere(
+ (e) => e.toString().split('.').last == json['planType'],
+ orElse: () => SubscriptionPlanType.free,
+ ),
+ status: SubscriptionStatus.values.firstWhere(
+ (e) => e.toString().split('.').last == json['status'],
+ orElse: () => SubscriptionStatus.expired,
+ ),
+ startDate: DateTime.parse(json['startDate']),
+ endDate: DateTime.parse(json['endDate']),
+ price: json['price'].toDouble(),
+ currency: json['currency'],
+ autoRenew: json['autoRenew'] ?? false,
+ features: json['features'],
+ paymentMethod: json['paymentMethod'],
+ transactionId: json['transactionId'],
+ createdAt: DateTime.parse(json['createdAt']),
+ updatedAt: DateTime.parse(json['updatedAt']),
+ );
+ }
+
+ /// 구독 객체를 JSON으로 변환
+ Map toJson() {
+ return {
+ 'id': id,
+ 'userId': userId,
+ 'clubId': clubId,
+ 'planType': planType.toString().split('.').last,
+ 'status': status.toString().split('.').last,
+ 'startDate': startDate.toIso8601String(),
+ 'endDate': endDate.toIso8601String(),
+ 'price': price,
+ 'currency': currency,
+ 'autoRenew': autoRenew,
+ 'features': features,
+ 'paymentMethod': paymentMethod,
+ 'transactionId': transactionId,
+ 'createdAt': createdAt.toIso8601String(),
+ 'updatedAt': updatedAt.toIso8601String(),
+ };
+ }
+
+ /// 구독이 활성 상태인지 확인
+ bool get isActive => status == SubscriptionStatus.active;
+
+ /// 구독이 만료되었는지 확인
+ bool get isExpired => status == SubscriptionStatus.expired ||
+ DateTime.now().isAfter(endDate);
+
+ /// 구독 남은 일수 계산
+ int get daysLeft {
+ if (isExpired) return 0;
+ return endDate.difference(DateTime.now()).inDays;
+ }
+
+ /// 구독 플랜 이름 (한글)
+ String get planName {
+ switch (planType) {
+ case SubscriptionPlanType.free:
+ return '무료';
+ case SubscriptionPlanType.basic:
+ return '기본';
+ case SubscriptionPlanType.premium:
+ return '프리미엄';
+ case SubscriptionPlanType.enterprise:
+ return '기업용';
+ }
+ }
+}
+
+/// 구독 플랜 정보 모델
+class SubscriptionPlan {
+ final SubscriptionPlanType type;
+ final String name;
+ final String description;
+ final double monthlyPrice;
+ final double yearlyPrice;
+ final String currency;
+ final List features;
+ final int maxMembers;
+ final int maxEvents;
+ final bool hasAdvancedStats;
+ final bool hasCustomization;
+ final bool hasPriority;
+
+ SubscriptionPlan({
+ required this.type,
+ required this.name,
+ required this.description,
+ required this.monthlyPrice,
+ required this.yearlyPrice,
+ required this.currency,
+ required this.features,
+ required this.maxMembers,
+ required this.maxEvents,
+ required this.hasAdvancedStats,
+ required this.hasCustomization,
+ required this.hasPriority,
+ });
+
+ /// 기본 구독 플랜 목록 반환
+ static List getDefaultPlans() {
+ return [
+ SubscriptionPlan(
+ type: SubscriptionPlanType.free,
+ name: '무료',
+ description: '소규모 클럽을 위한 기본 기능',
+ monthlyPrice: 0,
+ yearlyPrice: 0,
+ currency: 'KRW',
+ features: [
+ '최대 10명 회원 관리',
+ '기본 점수 기록',
+ '간단한 통계',
+ ],
+ maxMembers: 10,
+ maxEvents: 5,
+ hasAdvancedStats: false,
+ hasCustomization: false,
+ hasPriority: false,
+ ),
+ SubscriptionPlan(
+ type: SubscriptionPlanType.basic,
+ name: '기본',
+ description: '중소규모 클럽을 위한 확장 기능',
+ monthlyPrice: 9900,
+ yearlyPrice: 99000,
+ currency: 'KRW',
+ features: [
+ '최대 30명 회원 관리',
+ '상세 점수 분석',
+ '이벤트 관리',
+ '회원 통계',
+ ],
+ maxMembers: 30,
+ maxEvents: 20,
+ hasAdvancedStats: true,
+ hasCustomization: false,
+ hasPriority: false,
+ ),
+ SubscriptionPlan(
+ type: SubscriptionPlanType.premium,
+ name: '프리미엄',
+ description: '대규모 클럽을 위한 고급 기능',
+ monthlyPrice: 19900,
+ yearlyPrice: 199000,
+ currency: 'KRW',
+ features: [
+ '무제한 회원 관리',
+ '고급 통계 및 분석',
+ '무제한 이벤트',
+ '맞춤형 보고서',
+ '우선 지원',
+ ],
+ maxMembers: 100,
+ maxEvents: 100,
+ hasAdvancedStats: true,
+ hasCustomization: true,
+ hasPriority: true,
+ ),
+ SubscriptionPlan(
+ type: SubscriptionPlanType.enterprise,
+ name: '기업용',
+ description: '프로 클럽 및 단체를 위한 맞춤형 솔루션',
+ monthlyPrice: 49900,
+ yearlyPrice: 499000,
+ currency: 'KRW',
+ features: [
+ '무제한 회원 및 이벤트',
+ '전문가 수준 분석',
+ '맞춤형 기능',
+ '전담 지원',
+ 'API 액세스',
+ ],
+ maxMembers: 1000,
+ maxEvents: 1000,
+ hasAdvancedStats: true,
+ hasCustomization: true,
+ hasPriority: true,
+ ),
+ ];
+ }
+}
diff --git a/mobile/lib/models/user_model.dart b/mobile/lib/models/user_model.dart
new file mode 100644
index 0000000..9556398
--- /dev/null
+++ b/mobile/lib/models/user_model.dart
@@ -0,0 +1,45 @@
+class User {
+ final String id;
+ final String email;
+ final String name;
+ final String role;
+ final String? profileImage;
+ final String? clubId;
+ final String? clubRole;
+
+ User({
+ required this.id,
+ required this.email,
+ required this.name,
+ required this.role,
+ this.profileImage,
+ this.clubId,
+ this.clubRole,
+ });
+
+ // JSON 데이터로부터 User 객체 생성
+ factory User.fromJson(Map json) {
+ return User(
+ id: json['id'] != null ? json['id'].toString() : '',
+ email: json['email'] ?? '',
+ name: json['name'] ?? '',
+ role: json['role'] ?? 'user',
+ profileImage: json['profileImage'],
+ clubId: json['clubId']?.toString(),
+ clubRole: json['clubRole'],
+ );
+ }
+
+ // User 객체를 JSON으로 변환
+ Map toJson() {
+ return {
+ 'id': id,
+ 'email': email,
+ 'name': name,
+ 'role': role,
+ 'profileImage': profileImage,
+ 'clubId': clubId,
+ 'clubRole': clubRole,
+ };
+ }
+}
diff --git a/mobile/lib/screens/admin/admin_dashboard_screen.dart b/mobile/lib/screens/admin/admin_dashboard_screen.dart
new file mode 100644
index 0000000..77544ec
--- /dev/null
+++ b/mobile/lib/screens/admin/admin_dashboard_screen.dart
@@ -0,0 +1,456 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../services/admin_service.dart';
+import '../../services/subscription_service.dart';
+import '../../models/subscription_model.dart';
+
+/// 관리자 대시보드 화면
+class AdminDashboardScreen extends StatefulWidget {
+ const AdminDashboardScreen({super.key});
+
+ @override
+ State createState() => _AdminDashboardScreenState();
+}
+
+class _AdminDashboardScreenState extends State {
+ Map? _analyticsData;
+ bool _isLoading = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _loadData();
+ }
+
+ /// 데이터 로드
+ Future _loadData() async {
+ setState(() {
+ _isLoading = true;
+ });
+
+ try {
+ final adminService = Provider.of(context, listen: false);
+ final analyticsData = await adminService.fetchClubAnalytics();
+
+ setState(() {
+ _analyticsData = analyticsData;
+ });
+ } catch (e) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')),
+ );
+ } finally {
+ setState(() {
+ _isLoading = false;
+ });
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final adminService = Provider.of(context);
+ final subscriptionService = Provider.of(context);
+
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('관리자 대시보드'),
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.refresh),
+ onPressed: _loadData,
+ ),
+ ],
+ ),
+ body: _isLoading
+ ? const Center(child: CircularProgressIndicator())
+ : RefreshIndicator(
+ onRefresh: _loadData,
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _buildPermissionsCard(adminService),
+ const SizedBox(height: 16),
+ _buildSubscriptionCard(subscriptionService),
+ const SizedBox(height: 16),
+ _buildAnalyticsCard(),
+ const SizedBox(height: 16),
+ _buildQuickActionsCard(context, adminService),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ /// 권한 정보 카드
+ Widget _buildPermissionsCard(AdminService adminService) {
+ return Card(
+ elevation: 2,
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ const Icon(Icons.admin_panel_settings),
+ const SizedBox(width: 8),
+ Text(
+ '관리자 권한',
+ style: Theme.of(context).textTheme.titleLarge,
+ ),
+ ],
+ ),
+ const Divider(),
+ const SizedBox(height: 8),
+ _buildPermissionItem(
+ '회원 관리',
+ adminService.canManageMembers,
+ Icons.people,
+ ),
+ _buildPermissionItem(
+ '이벤트 관리',
+ adminService.canManageEvents,
+ Icons.event,
+ ),
+ _buildPermissionItem(
+ '점수 관리',
+ adminService.canManageScores,
+ Icons.score,
+ ),
+ _buildPermissionItem(
+ '설정 관리',
+ adminService.canManageSettings,
+ Icons.settings,
+ ),
+ _buildPermissionItem(
+ '구독 관리',
+ adminService.canManageSubscription,
+ Icons.subscriptions,
+ ),
+ _buildPermissionItem(
+ '역할 관리',
+ adminService.canManageRoles,
+ Icons.admin_panel_settings,
+ ),
+ _buildPermissionItem(
+ '재무 정보 조회',
+ adminService.canViewFinancials,
+ Icons.attach_money,
+ ),
+ _buildPermissionItem(
+ '데이터 내보내기',
+ adminService.canExportData,
+ Icons.file_download,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ /// 권한 항목 위젯
+ Widget _buildPermissionItem(String title, bool hasPermission, IconData icon) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 4.0),
+ child: Row(
+ children: [
+ Icon(
+ icon,
+ size: 18,
+ color: hasPermission ? Colors.green : Colors.grey,
+ ),
+ const SizedBox(width: 8),
+ Text(title),
+ const Spacer(),
+ Icon(
+ hasPermission ? Icons.check_circle : Icons.cancel,
+ color: hasPermission ? Colors.green : Colors.red,
+ size: 18,
+ ),
+ ],
+ ),
+ );
+ }
+
+ /// 구독 정보 카드
+ Widget _buildSubscriptionCard(SubscriptionService subscriptionService) {
+ final subscription = subscriptionService.currentSubscription;
+
+ return Card(
+ elevation: 2,
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ const Icon(Icons.subscriptions),
+ const SizedBox(width: 8),
+ Text(
+ '구독 정보',
+ style: Theme.of(context).textTheme.titleLarge,
+ ),
+ ],
+ ),
+ const Divider(),
+ const SizedBox(height: 8),
+ if (subscription != null) ...[
+ _buildInfoRow('플랜', subscription.planName),
+ _buildInfoRow('상태', _getStatusText(subscription.status)),
+ _buildInfoRow('시작일', _formatDate(subscription.startDate)),
+ _buildInfoRow('만료일', _formatDate(subscription.endDate)),
+ _buildInfoRow('자동 갱신', subscription.autoRenew ? '활성화' : '비활성화'),
+ _buildInfoRow('남은 일수', '${subscription.daysLeft}일'),
+ ] else
+ const Text('활성화된 구독이 없습니다.'),
+ const SizedBox(height: 16),
+ ElevatedButton(
+ onPressed: () {
+ Navigator.of(context).pushNamed('/subscription/manage');
+ },
+ child: const Text('구독 관리'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ /// 분석 데이터 카드
+ Widget _buildAnalyticsCard() {
+ return Card(
+ elevation: 2,
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ const Icon(Icons.analytics),
+ const SizedBox(width: 8),
+ Text(
+ '클럽 통계',
+ style: Theme.of(context).textTheme.titleLarge,
+ ),
+ ],
+ ),
+ const Divider(),
+ const SizedBox(height: 8),
+ if (_analyticsData != null) ...[
+ _buildInfoRow('총 회원 수', '${_analyticsData!['totalMembers'] ?? 0}명'),
+ _buildInfoRow('활성 회원 수', '${_analyticsData!['activeMembers'] ?? 0}명'),
+ _buildInfoRow('이번 달 신규 회원', '${_analyticsData!['newMembersThisMonth'] ?? 0}명'),
+ _buildInfoRow('총 이벤트 수', '${_analyticsData!['totalEvents'] ?? 0}개'),
+ _buildInfoRow('이번 달 이벤트', '${_analyticsData!['eventsThisMonth'] ?? 0}개'),
+ _buildInfoRow('총 게임 수', '${_analyticsData!['totalGames'] ?? 0}게임'),
+ _buildInfoRow('평균 점수', '${_analyticsData!['averageScore'] ?? 0}점'),
+ ] else
+ const Text('통계 데이터를 불러올 수 없습니다.'),
+ ],
+ ),
+ ),
+ );
+ }
+
+ /// 빠른 작업 카드
+ Widget _buildQuickActionsCard(BuildContext context, AdminService adminService) {
+ return Card(
+ elevation: 2,
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ const Icon(Icons.flash_on),
+ const SizedBox(width: 8),
+ Text(
+ '빠른 작업',
+ style: Theme.of(context).textTheme.titleLarge,
+ ),
+ ],
+ ),
+ const Divider(),
+ const SizedBox(height: 8),
+ Wrap(
+ spacing: 8.0,
+ runSpacing: 8.0,
+ children: [
+ if (adminService.canManageMembers)
+ _buildActionButton(
+ context,
+ '회원 관리',
+ Icons.people,
+ () => Navigator.of(context).pushNamed('/admin/members'),
+ ),
+ if (adminService.canManageEvents)
+ _buildActionButton(
+ context,
+ '이벤트 관리',
+ Icons.event,
+ () => Navigator.of(context).pushNamed('/admin/events'),
+ ),
+ if (adminService.canManageSettings)
+ _buildActionButton(
+ context,
+ '클럽 설정',
+ Icons.settings,
+ () => Navigator.of(context).pushNamed('/admin/settings'),
+ ),
+ if (adminService.canManageRoles)
+ _buildActionButton(
+ context,
+ '역할 관리',
+ Icons.admin_panel_settings,
+ () => Navigator.of(context).pushNamed('/admin/roles'),
+ ),
+ if (adminService.canExportData)
+ _buildActionButton(
+ context,
+ '데이터 내보내기',
+ Icons.file_download,
+ () => _showExportDialog(context),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ /// 작업 버튼 위젯
+ Widget _buildActionButton(
+ BuildContext context,
+ String label,
+ IconData icon,
+ VoidCallback onPressed,
+ ) {
+ return ElevatedButton.icon(
+ onPressed: onPressed,
+ icon: Icon(icon, size: 18),
+ label: Text(label),
+ style: ElevatedButton.styleFrom(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ ),
+ );
+ }
+
+ /// 데이터 내보내기 다이얼로그
+ void _showExportDialog(BuildContext context) {
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('데이터 내보내기'),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ ListTile(
+ leading: const Icon(Icons.people),
+ title: const Text('회원 데이터'),
+ onTap: () => _exportData(context, 'members'),
+ ),
+ ListTile(
+ leading: const Icon(Icons.event),
+ title: const Text('이벤트 데이터'),
+ onTap: () => _exportData(context, 'events'),
+ ),
+ ListTile(
+ leading: const Icon(Icons.score),
+ title: const Text('점수 데이터'),
+ onTap: () => _exportData(context, 'scores'),
+ ),
+ ListTile(
+ leading: const Icon(Icons.analytics),
+ title: const Text('통계 데이터'),
+ onTap: () => _exportData(context, 'analytics'),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('취소'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ /// 데이터 내보내기 처리
+ Future _exportData(BuildContext context, String dataType) async {
+ Navigator.of(context).pop(); // 다이얼로그 닫기
+
+ setState(() {
+ _isLoading = true;
+ });
+
+ try {
+ final adminService = Provider.of(context, listen: false);
+ final downloadUrl = await adminService.exportClubData(dataType);
+
+ if (downloadUrl != null) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('데이터 내보내기 성공: $downloadUrl')),
+ );
+ } else {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('데이터 내보내기에 실패했습니다.')),
+ );
+ }
+ } catch (e) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('데이터 내보내기 중 오류가 발생했습니다: $e')),
+ );
+ } finally {
+ setState(() {
+ _isLoading = false;
+ });
+ }
+ }
+
+ /// 정보 행 위젯
+ Widget _buildInfoRow(String label, String value) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 4.0),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ '$label:',
+ style: const TextStyle(fontWeight: FontWeight.bold),
+ ),
+ Text(value),
+ ],
+ ),
+ );
+ }
+
+ /// 날짜 포맷팅
+ String _formatDate(DateTime date) {
+ return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
+ }
+
+ /// 구독 상태 텍스트
+ String _getStatusText(SubscriptionStatus status) {
+ switch (status) {
+ case SubscriptionStatus.active:
+ return '활성화';
+ case SubscriptionStatus.canceled:
+ return '취소됨';
+ case SubscriptionStatus.expired:
+ return '만료됨';
+ case SubscriptionStatus.pending:
+ return '대기 중';
+ case SubscriptionStatus.failed:
+ return '실패';
+ }
+ }
+}
diff --git a/mobile/lib/screens/auth/forgot_password_screen.dart b/mobile/lib/screens/auth/forgot_password_screen.dart
new file mode 100644
index 0000000..e3dfeab
--- /dev/null
+++ b/mobile/lib/screens/auth/forgot_password_screen.dart
@@ -0,0 +1,168 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import '../../services/auth_service.dart';
+
+class ForgotPasswordScreen extends StatefulWidget {
+ const ForgotPasswordScreen({super.key});
+
+ @override
+ State createState() => _ForgotPasswordScreenState();
+}
+
+class _ForgotPasswordScreenState extends State {
+ final _formKey = GlobalKey();
+ final _emailController = TextEditingController();
+ bool _isSubmitting = false;
+ bool _emailSent = false;
+
+ @override
+ void dispose() {
+ _emailController.dispose();
+ super.dispose();
+ }
+
+ // 비밀번호 재설정 이메일 전송 함수
+ Future _resetPassword() async {
+ if (_formKey.currentState!.validate()) {
+ setState(() {
+ _isSubmitting = true;
+ });
+
+ final authService = Provider.of(context, listen: false);
+ final success = await authService.sendPasswordResetEmail(
+ _emailController.text.trim(),
+ );
+
+ setState(() {
+ _isSubmitting = false;
+ _emailSent = success;
+ });
+
+ if (success && mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('비밀번호 재설정 이메일이 전송되었습니다. 이메일을 확인해주세요.'),
+ backgroundColor: Colors.green,
+ ),
+ );
+ } else if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('비밀번호 재설정 이메일 전송에 실패했습니다. 이메일 주소를 확인해주세요.'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('비밀번호 찾기'),
+ ),
+ body: SafeArea(
+ child: Center(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(24.0),
+ child: Form(
+ key: _formKey,
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const Icon(
+ Icons.lock_reset,
+ size: 80,
+ color: Colors.blue,
+ ),
+ const SizedBox(height: 24),
+
+ const Text(
+ '비밀번호를 잊으셨나요?',
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ fontSize: 24,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 16),
+
+ const Text(
+ '가입하신 이메일 주소를 입력하시면 비밀번호 재설정 링크를 보내드립니다.',
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ fontSize: 16,
+ color: Colors.grey,
+ ),
+ ),
+ const SizedBox(height: 32),
+
+ // 이메일 입력 필드
+ TextFormField(
+ controller: _emailController,
+ keyboardType: TextInputType.emailAddress,
+ decoration: const InputDecoration(
+ labelText: '이메일',
+ prefixIcon: Icon(Icons.email),
+ border: OutlineInputBorder(),
+ ),
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return '이메일을 입력해주세요';
+ }
+ if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
+ return '유효한 이메일 주소를 입력해주세요';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 24),
+
+ // 비밀번호 재설정 이메일 전송 버튼
+ ElevatedButton(
+ onPressed: _isSubmitting ? null : _resetPassword,
+ style: ElevatedButton.styleFrom(
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ backgroundColor: Colors.blue,
+ foregroundColor: Colors.white,
+ ),
+ child: _isSubmitting
+ ? const SizedBox(
+ width: 20,
+ height: 20,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ color: Colors.white,
+ ),
+ )
+ : const Text(
+ '비밀번호 재설정 이메일 전송',
+ style: TextStyle(fontSize: 16),
+ ),
+ ),
+ const SizedBox(height: 16),
+
+ // 로그인 화면으로 돌아가기
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Text('비밀번호가 기억나셨나요?'),
+ TextButton(
+ onPressed: () {
+ Navigator.pop(context); // 로그인 화면으로 돌아가기
+ },
+ child: const Text('로그인'),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/auth/login_screen.dart b/mobile/lib/screens/auth/login_screen.dart
new file mode 100644
index 0000000..ad7aa16
--- /dev/null
+++ b/mobile/lib/screens/auth/login_screen.dart
@@ -0,0 +1,212 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import '../../services/auth_service.dart';
+import 'register_screen.dart';
+import 'forgot_password_screen.dart';
+
+class LoginScreen extends StatefulWidget {
+ const LoginScreen({super.key});
+
+ @override
+ State createState() => _LoginScreenState();
+}
+
+class _LoginScreenState extends State {
+ final _formKey = GlobalKey();
+ final _usernameController = TextEditingController();
+ final _passwordController = TextEditingController();
+ bool _isPasswordVisible = false;
+ bool _rememberMe = false;
+
+ @override
+ void dispose() {
+ _usernameController.dispose();
+ _passwordController.dispose();
+ super.dispose();
+ }
+
+ // 로그인 처리 함수
+ Future _login() async {
+ if (_formKey.currentState!.validate()) {
+ final authService = Provider.of(context, listen: false);
+ final success = await authService.login(
+ _usernameController.text.trim(),
+ _passwordController.text,
+ );
+
+ if (success && mounted) {
+ // 로그인 성공 시 홈 화면으로 이동
+ Navigator.of(context).pushNamedAndRemoveUntil('/', (route) => false);
+ } else if (!success && mounted) {
+ // 로그인 실패 시 스낵바 표시
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('로그인에 실패했습니다. 아이디와 비밀번호를 확인해주세요.'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final authService = Provider.of(context);
+
+ return Scaffold(
+ body: SafeArea(
+ child: Center(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(24.0),
+ child: Form(
+ key: _formKey,
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ // 로고 이미지
+ Column(
+ children: [
+ Image.asset(
+ 'assets/images/logo.png',
+ width: 120,
+ height: 120,
+ ),
+ const SizedBox(height: 16),
+ const Text(
+ '레인보우 - 볼링 클럽 매니저',
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ fontSize: 24,
+ fontWeight: FontWeight.bold,
+ color: Colors.blue,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 48),
+
+ // 사용자명(아이디) 입력 필드
+ TextFormField(
+ controller: _usernameController,
+ decoration: const InputDecoration(
+ labelText: '아이디',
+ prefixIcon: Icon(Icons.person),
+ border: OutlineInputBorder(),
+ ),
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return '아이디를 입력해주세요';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 16),
+
+ // 비밀번호 입력 필드
+ TextFormField(
+ controller: _passwordController,
+ obscureText: !_isPasswordVisible,
+ decoration: InputDecoration(
+ labelText: '비밀번호',
+ prefixIcon: const Icon(Icons.lock),
+ border: const OutlineInputBorder(),
+ suffixIcon: IconButton(
+ icon: Icon(
+ _isPasswordVisible ? Icons.visibility : Icons.visibility_off,
+ ),
+ onPressed: () {
+ setState(() {
+ _isPasswordVisible = !_isPasswordVisible;
+ });
+ },
+ ),
+ ),
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return '비밀번호를 입력해주세요';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 8),
+
+ // 로그인 상태 유지 체크박스
+ Row(
+ children: [
+ Checkbox(
+ value: _rememberMe,
+ onChanged: (value) {
+ setState(() {
+ _rememberMe = value ?? false;
+ });
+ },
+ ),
+ const Text('로그인 상태 유지'),
+ const Spacer(),
+ TextButton(
+ onPressed: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => const ForgotPasswordScreen(),
+ ),
+ );
+ },
+ child: const Text('비밀번호 찾기'),
+ ),
+ ],
+ ),
+ const SizedBox(height: 24),
+
+ // 로그인 버튼
+ ElevatedButton(
+ onPressed: authService.isLoading ? null : _login,
+ style: ElevatedButton.styleFrom(
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ backgroundColor: Colors.blue,
+ foregroundColor: Colors.white,
+ ),
+ child: authService.isLoading
+ ? const SizedBox(
+ width: 20,
+ height: 20,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ color: Colors.white,
+ ),
+ )
+ : const Text(
+ '로그인',
+ style: TextStyle(fontSize: 16),
+ ),
+ ),
+ const SizedBox(height: 16),
+
+ // 회원가입 링크
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Text('계정이 없으신가요?'),
+ TextButton(
+ onPressed: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => const RegisterScreen(),
+ ),
+ );
+ },
+ child: const Text('회원가입'),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/auth/profile_screen.dart b/mobile/lib/screens/auth/profile_screen.dart
new file mode 100644
index 0000000..1425e29
--- /dev/null
+++ b/mobile/lib/screens/auth/profile_screen.dart
@@ -0,0 +1,186 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import '../../services/auth_service.dart';
+import '../../models/user_model.dart';
+
+class ProfileScreen extends StatefulWidget {
+ const ProfileScreen({super.key});
+
+ @override
+ State createState() => _ProfileScreenState();
+}
+
+class _ProfileScreenState extends State {
+ final _formKey = GlobalKey();
+ final _nameController = TextEditingController();
+ bool _isEditing = false;
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ final user = Provider.of(context, listen: false).currentUser;
+ if (user != null) {
+ _nameController.text = user.name;
+ }
+ });
+ }
+
+ @override
+ void dispose() {
+ _nameController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('내 프로필'),
+ actions: [
+ IconButton(
+ icon: Icon(_isEditing ? Icons.save : Icons.edit),
+ onPressed: () {
+ setState(() {
+ _isEditing = !_isEditing;
+ });
+ if (!_isEditing) {
+ // 저장 로직 구현 (추후 개발)
+ if (_formKey.currentState!.validate()) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('프로필이 업데이트되었습니다')),
+ );
+ }
+ }
+ },
+ ),
+ ],
+ ),
+ body: Consumer(
+ builder: (context, authService, child) {
+ final User? user = authService.currentUser;
+
+ if (user == null) {
+ return const Center(child: Text('사용자 정보를 불러올 수 없습니다'));
+ }
+
+ return SingleChildScrollView(
+ padding: const EdgeInsets.all(16.0),
+ child: Form(
+ key: _formKey,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ // 프로필 이미지
+ CircleAvatar(
+ radius: 50,
+ backgroundImage: user.profileImage != null
+ ? NetworkImage(user.profileImage!)
+ : null,
+ child: user.profileImage == null
+ ? Text(
+ user.name.isNotEmpty ? user.name[0].toUpperCase() : '?',
+ style: const TextStyle(fontSize: 40),
+ )
+ : null,
+ ),
+ const SizedBox(height: 16),
+
+ // 이름 필드
+ TextFormField(
+ controller: _nameController,
+ enabled: _isEditing,
+ decoration: const InputDecoration(
+ labelText: '이름',
+ border: OutlineInputBorder(),
+ ),
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return '이름을 입력해주세요';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 16),
+
+ // 이메일 (수정 불가)
+ TextFormField(
+ initialValue: user.email,
+ enabled: false,
+ decoration: const InputDecoration(
+ labelText: '이메일',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 16),
+
+ // 역할 표시
+ TextFormField(
+ initialValue: _getRoleDisplayName(user.role),
+ enabled: false,
+ decoration: const InputDecoration(
+ labelText: '역할',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 16),
+
+ // 클럽 역할 표시 (클럽이 있는 경우)
+ if (user.clubRole != null)
+ TextFormField(
+ initialValue: _getRoleDisplayName(user.clubRole!),
+ enabled: false,
+ decoration: const InputDecoration(
+ labelText: '클럽 내 역할',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 32),
+
+ // 로그아웃 버튼
+ ElevatedButton(
+ onPressed: () async {
+ await authService.logout();
+ if (context.mounted) {
+ Navigator.of(context).pushReplacementNamed('/login');
+ }
+ },
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.red,
+ foregroundColor: Colors.white,
+ padding: const EdgeInsets.symmetric(
+ horizontal: 32,
+ vertical: 12,
+ ),
+ ),
+ child: const Text('로그아웃'),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ // 역할 표시 이름 변환
+ String _getRoleDisplayName(String role) {
+ switch (role) {
+ case 'admin':
+ return '관리자';
+ case 'superadmin':
+ return '최고 관리자';
+ case 'user':
+ return '일반 사용자';
+ case 'owner':
+ return '클럽 소유자';
+ case 'manager':
+ return '클럽 매니저';
+ case 'member':
+ return '클럽 회원';
+ default:
+ return role;
+ }
+ }
+}
diff --git a/mobile/lib/screens/auth/register_screen.dart b/mobile/lib/screens/auth/register_screen.dart
new file mode 100644
index 0000000..cc791b3
--- /dev/null
+++ b/mobile/lib/screens/auth/register_screen.dart
@@ -0,0 +1,222 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import '../../services/auth_service.dart';
+
+class RegisterScreen extends StatefulWidget {
+ const RegisterScreen({super.key});
+
+ @override
+ State createState() => _RegisterScreenState();
+}
+
+class _RegisterScreenState extends State {
+ final _formKey = GlobalKey();
+ final _nameController = TextEditingController();
+ final _emailController = TextEditingController();
+ final _passwordController = TextEditingController();
+ final _confirmPasswordController = TextEditingController();
+ bool _isPasswordVisible = false;
+ bool _isConfirmPasswordVisible = false;
+
+ @override
+ void dispose() {
+ _nameController.dispose();
+ _emailController.dispose();
+ _passwordController.dispose();
+ _confirmPasswordController.dispose();
+ super.dispose();
+ }
+
+ // 회원가입 처리 함수
+ Future _register() async {
+ if (_formKey.currentState!.validate()) {
+ final authService = Provider.of(context, listen: false);
+ final success = await authService.register(
+ _nameController.text.trim(),
+ _emailController.text.trim(),
+ _passwordController.text,
+ );
+
+ if (success && mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('회원가입이 완료되었습니다. 로그인해주세요.'),
+ backgroundColor: Colors.green,
+ ),
+ );
+ Navigator.pop(context); // 로그인 화면으로 돌아가기
+ } else if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('회원가입에 실패했습니다. 다시 시도해주세요.'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final authService = Provider.of(context);
+
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('회원가입'),
+ ),
+ body: SafeArea(
+ child: Center(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(24.0),
+ child: Form(
+ key: _formKey,
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ // 이름 입력 필드
+ TextFormField(
+ controller: _nameController,
+ decoration: const InputDecoration(
+ labelText: '이름',
+ prefixIcon: Icon(Icons.person),
+ border: OutlineInputBorder(),
+ ),
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return '이름을 입력해주세요';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 16),
+
+ // 이메일 입력 필드
+ TextFormField(
+ controller: _emailController,
+ keyboardType: TextInputType.emailAddress,
+ decoration: const InputDecoration(
+ labelText: '이메일',
+ prefixIcon: Icon(Icons.email),
+ border: OutlineInputBorder(),
+ ),
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return '이메일을 입력해주세요';
+ }
+ if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
+ return '유효한 이메일 주소를 입력해주세요';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 16),
+
+ // 비밀번호 입력 필드
+ TextFormField(
+ controller: _passwordController,
+ obscureText: !_isPasswordVisible,
+ decoration: InputDecoration(
+ labelText: '비밀번호',
+ prefixIcon: const Icon(Icons.lock),
+ border: const OutlineInputBorder(),
+ suffixIcon: IconButton(
+ icon: Icon(
+ _isPasswordVisible ? Icons.visibility : Icons.visibility_off,
+ ),
+ onPressed: () {
+ setState(() {
+ _isPasswordVisible = !_isPasswordVisible;
+ });
+ },
+ ),
+ ),
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return '비밀번호를 입력해주세요';
+ }
+ if (value.length < 6) {
+ return '비밀번호는 6자 이상이어야 합니다';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 16),
+
+ // 비밀번호 확인 입력 필드
+ TextFormField(
+ controller: _confirmPasswordController,
+ obscureText: !_isConfirmPasswordVisible,
+ decoration: InputDecoration(
+ labelText: '비밀번호 확인',
+ prefixIcon: const Icon(Icons.lock_outline),
+ border: const OutlineInputBorder(),
+ suffixIcon: IconButton(
+ icon: Icon(
+ _isConfirmPasswordVisible ? Icons.visibility : Icons.visibility_off,
+ ),
+ onPressed: () {
+ setState(() {
+ _isConfirmPasswordVisible = !_isConfirmPasswordVisible;
+ });
+ },
+ ),
+ ),
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return '비밀번호를 다시 입력해주세요';
+ }
+ if (value != _passwordController.text) {
+ return '비밀번호가 일치하지 않습니다';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 24),
+
+ // 회원가입 버튼
+ ElevatedButton(
+ onPressed: authService.isLoading ? null : _register,
+ style: ElevatedButton.styleFrom(
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ backgroundColor: Colors.blue,
+ foregroundColor: Colors.white,
+ ),
+ child: authService.isLoading
+ ? const SizedBox(
+ width: 20,
+ height: 20,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ color: Colors.white,
+ ),
+ )
+ : const Text(
+ '회원가입',
+ style: TextStyle(fontSize: 16),
+ ),
+ ),
+ const SizedBox(height: 16),
+
+ // 로그인 화면으로 돌아가기
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Text('이미 계정이 있으신가요?'),
+ TextButton(
+ onPressed: () {
+ Navigator.pop(context); // 로그인 화면으로 돌아가기
+ },
+ child: const Text('로그인'),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/club/club_settings_screen.dart b/mobile/lib/screens/club/club_settings_screen.dart
new file mode 100644
index 0000000..a765bdd
--- /dev/null
+++ b/mobile/lib/screens/club/club_settings_screen.dart
@@ -0,0 +1,367 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../models/member_model.dart';
+import '../../services/auth_service.dart';
+import '../../services/club_service.dart';
+import '../../services/member_service.dart';
+import '../../utils/enum_mappings.dart';
+
+class ClubSettingsScreen extends StatefulWidget {
+ static const routeName = '/club-settings';
+
+ const ClubSettingsScreen({super.key});
+
+ @override
+ _ClubSettingsScreenState createState() => _ClubSettingsScreenState();
+}
+
+class _ClubSettingsScreenState extends State {
+ final _formKey = GlobalKey();
+ final _nameController = TextEditingController();
+ final _descriptionController = TextEditingController();
+ final _locationController = TextEditingController();
+ final _femaleHandicapController = TextEditingController();
+
+ String? _selectedAverageCalculationPeriod;
+ String? _selectedOwnerId;
+ List _members = [];
+ bool _isLoading = true;
+ bool _hasEditPermission = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _loadData();
+ }
+
+ @override
+ void dispose() {
+ _nameController.dispose();
+ _descriptionController.dispose();
+ _locationController.dispose();
+ _femaleHandicapController.dispose();
+ super.dispose();
+ }
+
+ Future _loadData() async {
+ setState(() {
+ _isLoading = true;
+ });
+
+ try {
+ // 클럽 정보 로드
+ final clubService = Provider.of(context, listen: false);
+ final club = clubService.currentClub;
+
+ if (club != null) {
+ _nameController.text = club.name;
+ _descriptionController.text = club.description ?? '';
+ _locationController.text = club.location ?? '';
+ _femaleHandicapController.text = club.femaleHandicap?.toString() ?? '';
+ _selectedAverageCalculationPeriod =
+ club.averageCalculationPeriod ?? 'total';
+ _selectedOwnerId = club.ownerId;
+ }
+
+ // 회원 목록 로드
+ final memberService = Provider.of(context, listen: false);
+ await memberService.fetchClubMembers();
+ setState(() {
+ _members = memberService.members;
+ });
+
+ // 권한 확인
+ final authService = Provider.of(context, listen: false);
+ final currentUserId = authService.currentUser?.id;
+
+ // 현재 사용자가 클럽 소유자이거나 관리자인지 확인
+ if (currentUserId != null) {
+ final currentMember = _members.firstWhere(
+ (member) => member.userId == currentUserId,
+ orElse: () => Member(
+ id: '',
+ userId: '',
+ name: '',
+ memberType: '',
+ clubId: club?.id ?? '',
+ email: '',
+ isActive: true,
+ ),
+ );
+
+ setState(() {
+ _hasEditPermission =
+ club?.ownerId == currentUserId || // 클럽 소유자
+ currentMember.memberType == 'manager' || // 클럽 매니저
+ authService.currentUser?.role == 'admin'; // 시스템 관리자
+ });
+ }
+ } catch (error) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $error')));
+ } finally {
+ if (mounted) {
+ setState(() {
+ _isLoading = false;
+ });
+ }
+ }
+ }
+
+ Future _saveClubInfo() async {
+ if (!_formKey.currentState!.validate()) {
+ return;
+ }
+
+ setState(() {
+ _isLoading = true;
+ });
+
+ try {
+ final clubService = Provider.of(context, listen: false);
+ final club = clubService.currentClub;
+
+ if (club != null) {
+ // 기본 업데이트 데이터 준비
+ final Map updates = {
+ 'clubId': club.id,
+ 'name': _nameController.text.trim(),
+ };
+
+ // 빈 문자열이 아닌 경우에만 추가 (null 값 명시적 처리)
+ if (_descriptionController.text.trim().isNotEmpty) {
+ updates['description'] = _descriptionController.text.trim();
+ } else if (_descriptionController.text.trim().isEmpty &&
+ club.description != null) {
+ // 기존에 값이 있었는데 지금 비어있으면 명시적으로 null 설정
+ updates['description'] = null;
+ }
+
+ if (_locationController.text.trim().isNotEmpty) {
+ updates['location'] = _locationController.text.trim();
+ } else if (_locationController.text.trim().isEmpty &&
+ club.location != null) {
+ updates['location'] = null;
+ }
+
+ if (_femaleHandicapController.text.trim().isNotEmpty) {
+ updates['femaleHandicap'] = int.parse(
+ _femaleHandicapController.text.trim(),
+ );
+ }
+
+ if (_selectedAverageCalculationPeriod != null) {
+ updates['averageCalculationPeriod'] =
+ _selectedAverageCalculationPeriod;
+ }
+
+ if (_selectedOwnerId != null) {
+ updates['ownerId'] = _selectedOwnerId;
+ }
+
+ // 모임장 변경 시에만 이메일 정보 추가
+ if (_selectedOwnerId != club.ownerId) {
+ // 모임장이 변경된 경우 해당 회원의 이메일 정보 찾기
+ final selectedMember = _members.firstWhere(
+ (member) => member.userId == _selectedOwnerId,
+ orElse: () => Member(
+ id: '',
+ userId: '',
+ name: '',
+ memberType: '',
+ clubId: club.id,
+ email: '',
+ isActive: true,
+ ),
+ );
+
+ // 이메일이 있는 경우에만 추가 (빈 문자열이나 null은 전송하지 않음)
+ if (selectedMember.email.isNotEmpty) {
+ updates['ownerEmail'] = selectedMember.email;
+ }
+ }
+
+ await clubService.updateClub(club.id, updates);
+
+ if (context.mounted) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(const SnackBar(content: Text('클럽 정보가 성공적으로 저장되었습니다')));
+ }
+ }
+ } catch (error) {
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('클럽 정보 저장 중 오류가 발생했습니다: $error')),
+ );
+ }
+ } finally {
+ if (mounted) {
+ setState(() {
+ _isLoading = false;
+ });
+ }
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(title: const Text('클럽 설정')),
+ body: _isLoading
+ ? const Center(child: CircularProgressIndicator())
+ : Column(
+ children: [
+ if (!_hasEditPermission)
+ Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(16.0),
+ color: Colors.amber.shade100,
+ child: const Text(
+ '권한 알림: 클럽 설정을 변경하려면 클럽 소유자 또는 관리자 권한이 필요합니다.',
+ style: TextStyle(fontWeight: FontWeight.bold),
+ ),
+ ),
+ Expanded(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(16.0),
+ child: Form(
+ key: _formKey,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // 클럽 이름
+ TextFormField(
+ controller: _nameController,
+ decoration: const InputDecoration(
+ labelText: '클럽 이름',
+ border: OutlineInputBorder(),
+ ),
+ validator: (value) {
+ if (value == null || value.trim().isEmpty) {
+ return '클럽 이름을 입력해주세요';
+ }
+ return null;
+ },
+ enabled: _hasEditPermission,
+ ),
+ // 설명
+ const SizedBox(height: 16),
+ TextFormField(
+ controller: _descriptionController,
+ decoration: const InputDecoration(
+ labelText: '설명',
+ border: OutlineInputBorder(),
+ ),
+ maxLines: 3,
+ enabled: _hasEditPermission,
+ ),
+ // 위치
+ const SizedBox(height: 16),
+ TextFormField(
+ controller: _locationController,
+ decoration: const InputDecoration(
+ labelText: '위치',
+ border: OutlineInputBorder(),
+ ),
+ enabled: _hasEditPermission,
+ ),
+ // 여성 기본 핸디캡
+ const SizedBox(height: 16),
+ TextFormField(
+ controller: _femaleHandicapController,
+ decoration: const InputDecoration(
+ labelText: '여성 기본 핸디캡',
+ border: OutlineInputBorder(),
+ ),
+ keyboardType: TextInputType.number,
+ validator: (value) {
+ if (value != null && value.isNotEmpty) {
+ final handicap = int.tryParse(value);
+ if (handicap == null) {
+ return '숫자를 입력해주세요';
+ }
+ if (handicap < 0) {
+ return '0 이상의 값을 입력해주세요';
+ }
+ }
+ return null;
+ },
+ enabled: _hasEditPermission,
+ ),
+ // 평균 산정 기준
+ const SizedBox(height: 16),
+ DropdownButtonFormField(
+ decoration: const InputDecoration(
+ labelText: '평균 산정 기준',
+ border: OutlineInputBorder(),
+ ),
+ value: _selectedAverageCalculationPeriod,
+ items: averageCalculationPeriodOptions.entries
+ .map(
+ (entry) => DropdownMenuItem(
+ value: entry.key,
+ child: Text(entry.value),
+ ),
+ )
+ .toList(),
+ onChanged: _hasEditPermission
+ ? (value) {
+ setState(() {
+ _selectedAverageCalculationPeriod = value;
+ });
+ }
+ : null,
+ ),
+ // 모임장 설정
+ const SizedBox(height: 16),
+ DropdownButtonFormField(
+ decoration: const InputDecoration(
+ labelText: '모임장 설정',
+ border: OutlineInputBorder(),
+ ),
+ value: _selectedOwnerId,
+ items: _members.map((member) {
+ return DropdownMenuItem(
+ value: member.userId,
+ child: Text(
+ '${member.name} (${getMemberTypeLabel(member.memberType)})',
+ ),
+ );
+ }).toList(),
+ onChanged: _hasEditPermission
+ ? (value) {
+ setState(() {
+ _selectedOwnerId = value;
+ });
+ }
+ : null,
+ ),
+ // 저장 버튼
+ const SizedBox(height: 24),
+ SizedBox(
+ width: double.infinity,
+ child: ElevatedButton(
+ onPressed: _hasEditPermission
+ ? _saveClubInfo
+ : null,
+ style: ElevatedButton.styleFrom(
+ padding: const EdgeInsets.symmetric(
+ vertical: 16.0,
+ ),
+ ),
+ child: const Text('저장'),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/club/dashboard_screen.dart b/mobile/lib/screens/club/dashboard_screen.dart
new file mode 100644
index 0000000..36db936
--- /dev/null
+++ b/mobile/lib/screens/club/dashboard_screen.dart
@@ -0,0 +1,394 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:intl/intl.dart';
+
+import '../../services/auth_service.dart';
+import '../../services/club_service.dart';
+import '../../services/member_service.dart';
+import '../../services/event_service.dart';
+import '../../models/club_model.dart';
+import 'club_settings_screen.dart';
+
+class DashboardScreen extends StatefulWidget {
+ const DashboardScreen({super.key});
+
+ @override
+ State createState() => _DashboardScreenState();
+}
+
+class _DashboardScreenState extends State {
+ bool _isLoading = false;
+ bool _isInit = false;
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ if (!_isInit) {
+ _loadDashboardData();
+ _isInit = true;
+ }
+ }
+
+ Future _loadDashboardData() async {
+ setState(() {
+ _isLoading = true;
+ });
+
+ try {
+ final authService = Provider.of(context, listen: false);
+ final clubService = Provider.of(context, listen: false);
+
+ // 클럽 정보 로드
+ if (clubService.currentClub == null) {
+ await clubService.initialize(authService.token!);
+ }
+
+ // 회원 및 이벤트 서비스 초기화
+ if (clubService.currentClub != null) {
+ final memberService = Provider.of(context, listen: false);
+ final eventService = Provider.of(context, listen: false);
+
+ memberService.initialize(authService.token!);
+ eventService.initialize(authService.token!);
+
+ // 회원 및 이벤트 데이터 로드
+ await Future.wait([
+ memberService.fetchClubMembers(),
+ eventService.fetchClubEvents(),
+ ]);
+ }
+ } catch (e) {
+ // 오류 처리
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')),
+ );
+ } finally {
+ setState(() {
+ _isLoading = false;
+ });
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: _isLoading
+ ? const Center(child: CircularProgressIndicator())
+ : RefreshIndicator(
+ onRefresh: _loadDashboardData,
+ child: Consumer3(
+ builder: (context, clubService, memberService, eventService, _) {
+ final club = clubService.currentClub;
+
+ if (club == null) {
+ return const Center(
+ child: Text('클럽 정보를 불러올 수 없습니다'),
+ );
+ }
+
+ return SingleChildScrollView(
+ physics: const AlwaysScrollableScrollPhysics(),
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // 클럽 정보 카드
+ _buildClubInfoCard(club),
+ const SizedBox(height: 16),
+
+ // 통계 카드들
+ _buildStatisticsCards(
+ memberCount: memberService.members.length,
+ eventCount: eventService.events.length,
+ ),
+ const SizedBox(height: 16),
+
+ // 최근 이벤트 목록
+ _buildRecentEventsList(eventService),
+ ],
+ ),
+ );
+ },
+ ),
+ ),
+ );
+ }
+
+ Widget _buildClubInfoCard(Club club) {
+ return Card(
+ elevation: 2,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Expanded(
+ child: Row(
+ children: [
+ if (club.logo != null)
+ ClipRRect(
+ borderRadius: BorderRadius.circular(8),
+ child: Image.network(
+ club.logo!,
+ width: 60,
+ height: 60,
+ fit: BoxFit.cover,
+ errorBuilder: (context, error, stackTrace) {
+ return Container(
+ width: 60,
+ height: 60,
+ color: Colors.grey[300],
+ child: const Icon(Icons.sports_golf),
+ );
+ },
+ ),
+ )
+ else
+ Container(
+ width: 60,
+ height: 60,
+ decoration: BoxDecoration(
+ color: Colors.blue[100],
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: const Icon(
+ Icons.sports_golf,
+ size: 30,
+ color: Colors.blue,
+ ),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ club.name,
+ style: const TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ if (club.description != null)
+ Text(
+ club.description!,
+ style: TextStyle(
+ fontSize: 14,
+ color: Colors.grey[600],
+ ),
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.settings),
+ tooltip: '클럽 설정',
+ onPressed: () {
+ Navigator.of(context).pushNamed(ClubSettingsScreen.routeName);
+ },
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ if (club.address != null)
+ _buildInfoRow(Icons.location_on, club.address!),
+ if (club.phone != null)
+ _buildInfoRow(Icons.phone, club.phone!),
+ if (club.email != null)
+ _buildInfoRow(Icons.email, club.email!),
+ if (club.website != null)
+ _buildInfoRow(Icons.language, club.website!),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildInfoRow(IconData icon, String text) {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 8.0),
+ child: Row(
+ children: [
+ Icon(icon, size: 16, color: Colors.grey[600]),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ text,
+ style: TextStyle(fontSize: 14, color: Colors.grey[800]),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildStatisticsCards({required int memberCount, required int eventCount}) {
+ return Row(
+ children: [
+ Expanded(
+ child: _buildStatCard(
+ title: '회원',
+ value: memberCount.toString(),
+ icon: Icons.people,
+ color: Colors.blue,
+ ),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: _buildStatCard(
+ title: '이벤트',
+ value: eventCount.toString(),
+ icon: Icons.event,
+ color: Colors.orange,
+ ),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildStatCard({
+ required String title,
+ required String value,
+ required IconData icon,
+ required Color color,
+ }) {
+ return Card(
+ elevation: 2,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ children: [
+ Icon(icon, size: 32, color: color),
+ const SizedBox(height: 8),
+ Text(
+ value,
+ style: TextStyle(
+ fontSize: 24,
+ fontWeight: FontWeight.bold,
+ color: color,
+ ),
+ ),
+ Text(
+ title,
+ style: TextStyle(
+ fontSize: 14,
+ color: Colors.grey[600],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildRecentEventsList(EventService eventService) {
+ final events = eventService.events;
+
+ if (events.isEmpty) {
+ return const Card(
+ child: Padding(
+ padding: EdgeInsets.all(16.0),
+ child: Center(
+ child: Text('예정된 이벤트가 없습니다'),
+ ),
+ ),
+ );
+ }
+
+ // 날짜 기준으로 정렬 (가까운 날짜순)
+ final sortedEvents = [...events];
+ sortedEvents.sort((a, b) => a.startDate.compareTo(b.startDate));
+
+ // 앞으로 진행될 이벤트만 필터링 (최대 3개)
+ final upcomingEvents = sortedEvents
+ .where((event) => event.startDate.isAfter(DateTime.now()))
+ .take(3)
+ .toList();
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ const Text(
+ '다가오는 이벤트',
+ style: TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ TextButton(
+ onPressed: () {
+ // 이벤트 목록 화면으로 이동 (추후 구현)
+ },
+ child: const Text('모두 보기'),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ ...upcomingEvents.map((event) => _buildEventCard(event)),
+ ],
+ );
+ }
+
+ Widget _buildEventCard(event) {
+ final dateFormat = DateFormat('yyyy년 MM월 dd일 HH:mm');
+
+ return Card(
+ margin: const EdgeInsets.only(bottom: 12),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: ListTile(
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: 16,
+ vertical: 8,
+ ),
+ title: Text(
+ event.title,
+ style: const TextStyle(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ subtitle: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const SizedBox(height: 4),
+ Text(
+ dateFormat.format(event.startDate),
+ style: TextStyle(
+ color: Colors.grey[700],
+ ),
+ ),
+ if (event.location != null)
+ Text(
+ event.location!,
+ style: TextStyle(
+ color: Colors.grey[600],
+ ),
+ ),
+ ],
+ ),
+ trailing: const Icon(Icons.arrow_forward_ios, size: 16),
+ onTap: () {
+ // 이벤트 상세 화면으로 이동 (추후 구현)
+ },
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/club/events_screen.dart b/mobile/lib/screens/club/events_screen.dart
new file mode 100644
index 0000000..9be6964
--- /dev/null
+++ b/mobile/lib/screens/club/events_screen.dart
@@ -0,0 +1,671 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:intl/intl.dart';
+
+import '../../services/auth_service.dart';
+import '../../services/club_service.dart';
+import '../../services/event_service.dart';
+import '../../models/event_model.dart';
+
+class EventsScreen extends StatefulWidget {
+ const EventsScreen({super.key});
+
+ @override
+ State createState() => _EventsScreenState();
+}
+
+class _EventsScreenState extends State {
+ bool _isInit = false;
+ String _searchQuery = '';
+ final TextEditingController _searchController = TextEditingController();
+ String _filterType = '전체'; // '전체', '예정', '지난'
+
+ @override
+ void dispose() {
+ _searchController.dispose();
+ super.dispose();
+ }
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ if (!_isInit) {
+ _loadEvents();
+ _isInit = true;
+ }
+ }
+
+ Future _loadEvents() async {
+ try {
+ final authService = Provider.of(context, listen: false);
+ final clubService = Provider.of(context, listen: false);
+ final eventService = Provider.of(context, listen: false);
+
+ if (clubService.currentClub != null) {
+ eventService.initialize(authService.token!);
+ await eventService.fetchClubEvents();
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('이벤트 목록을 불러오는데 실패했습니다: $e')),
+ );
+ }
+ }
+ }
+
+ List _getFilteredEvents(List events) {
+ // 검색어 필터링
+ List filteredEvents = events;
+ if (_searchQuery.isNotEmpty) {
+ final query = _searchQuery.toLowerCase();
+ filteredEvents = filteredEvents.where((event) {
+ return event.title.toLowerCase().contains(query) ||
+ (event.description?.toLowerCase().contains(query) ?? false) ||
+ (event.location?.toLowerCase().contains(query) ?? false);
+ }).toList();
+ }
+
+ // 날짜 필터링
+ final now = DateTime.now();
+ if (_filterType == '예정') {
+ filteredEvents = filteredEvents.where((event) => event.startDate.isAfter(now)).toList();
+ } else if (_filterType == '지난') {
+ filteredEvents = filteredEvents.where((event) => event.startDate.isBefore(now)).toList();
+ }
+
+ // 날짜순 정렬
+ filteredEvents.sort((a, b) => a.startDate.compareTo(b.startDate));
+
+ return filteredEvents;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: Column(
+ children: [
+ // 검색 바
+ Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ children: [
+ TextField(
+ controller: _searchController,
+ decoration: InputDecoration(
+ hintText: '이벤트 검색',
+ prefixIcon: const Icon(Icons.search),
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ contentPadding: const EdgeInsets.symmetric(vertical: 0),
+ suffixIcon: _searchQuery.isNotEmpty
+ ? IconButton(
+ icon: const Icon(Icons.clear),
+ onPressed: () {
+ _searchController.clear();
+ setState(() {
+ _searchQuery = '';
+ });
+ },
+ )
+ : null,
+ ),
+ onChanged: (value) {
+ setState(() {
+ _searchQuery = value;
+ });
+ },
+ ),
+ const SizedBox(height: 8),
+
+ // 필터 버튼들
+ SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: Row(
+ children: [
+ _buildFilterChip('전체'),
+ const SizedBox(width: 8),
+ _buildFilterChip('예정'),
+ const SizedBox(width: 8),
+ _buildFilterChip('지난'),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ // 이벤트 목록
+ Expanded(
+ child: Consumer(
+ builder: (context, eventService, _) {
+ if (eventService.isLoading) {
+ return const Center(child: CircularProgressIndicator());
+ }
+
+ final filteredEvents = _getFilteredEvents(eventService.events);
+
+ if (filteredEvents.isEmpty) {
+ return Center(
+ child: Text(
+ _searchQuery.isEmpty
+ ? '등록된 이벤트가 없습니다'
+ : '검색 결과가 없습니다',
+ ),
+ );
+ }
+
+ return RefreshIndicator(
+ onRefresh: _loadEvents,
+ child: ListView.builder(
+ padding: const EdgeInsets.only(bottom: 16),
+ itemCount: filteredEvents.length,
+ itemBuilder: (context, index) {
+ final event = filteredEvents[index];
+ return _buildEventCard(event);
+ },
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ floatingActionButton: FloatingActionButton(
+ onPressed: () {
+ // 이벤트 추가 화면으로 이동 (추후 구현)
+ _showAddEventDialog();
+ },
+ backgroundColor: Colors.blue,
+ child: const Icon(Icons.add, color: Colors.white),
+ ),
+ );
+ }
+
+ Widget _buildFilterChip(String label) {
+ final isSelected = _filterType == label;
+
+ return FilterChip(
+ label: Text(label),
+ selected: isSelected,
+ onSelected: (selected) {
+ setState(() {
+ _filterType = selected ? label : '전체';
+ });
+ },
+ backgroundColor: Colors.grey[200],
+ selectedColor: Colors.blue[100],
+ checkmarkColor: Colors.blue,
+ labelStyle: TextStyle(
+ color: isSelected ? Colors.blue[800] : Colors.black87,
+ fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
+ ),
+ );
+ }
+
+ Widget _buildEventCard(Event event) {
+ final dateFormat = DateFormat('yyyy년 MM월 dd일 HH:mm');
+ final now = DateTime.now();
+ final isPast = event.startDate.isBefore(now);
+
+ return Card(
+ margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: ListTile(
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: 16,
+ vertical: 8,
+ ),
+ leading: Container(
+ width: 48,
+ height: 48,
+ decoration: BoxDecoration(
+ color: isPast ? Colors.grey[200] : Colors.blue[100],
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Icon(
+ Icons.event,
+ color: isPast ? Colors.grey[600] : Colors.blue,
+ ),
+ ),
+ title: Text(
+ event.title,
+ style: TextStyle(
+ fontWeight: FontWeight.bold,
+ color: isPast ? Colors.grey[600] : Colors.black,
+ ),
+ ),
+ subtitle: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const SizedBox(height: 4),
+ Text(
+ dateFormat.format(event.startDate),
+ style: TextStyle(
+ color: isPast ? Colors.grey[500] : Colors.grey[700],
+ ),
+ ),
+ if (event.location != null)
+ Text(
+ event.location!,
+ style: TextStyle(
+ color: isPast ? Colors.grey[500] : Colors.grey[600],
+ ),
+ ),
+ ],
+ ),
+ trailing: PopupMenuButton(
+ icon: const Icon(Icons.more_vert),
+ onSelected: (value) {
+ if (value == 'edit') {
+ _showEditEventDialog(event);
+ } else if (value == 'delete') {
+ _showDeleteConfirmationDialog(event);
+ }
+ },
+ itemBuilder: (context) => [
+ const PopupMenuItem(
+ value: 'edit',
+ child: Row(
+ children: [
+ Icon(Icons.edit, size: 18),
+ SizedBox(width: 8),
+ Text('수정'),
+ ],
+ ),
+ ),
+ const PopupMenuItem(
+ value: 'delete',
+ child: Row(
+ children: [
+ Icon(Icons.delete, size: 18, color: Colors.red),
+ SizedBox(width: 8),
+ Text('삭제', style: TextStyle(color: Colors.red)),
+ ],
+ ),
+ ),
+ ],
+ ),
+ onTap: () {
+ // 이벤트 상세 화면으로 이동 (추후 구현)
+ _showEventDetailsDialog(event);
+ },
+ ),
+ );
+ }
+
+ // 이벤트 추가 다이얼로그
+ void _showAddEventDialog() {
+ final titleController = TextEditingController();
+ final descriptionController = TextEditingController();
+ final locationController = TextEditingController();
+
+ DateTime selectedDate = DateTime.now().add(const Duration(days: 1));
+ TimeOfDay selectedTime = TimeOfDay.now();
+
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('이벤트 추가'),
+ content: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ TextField(
+ controller: titleController,
+ decoration: const InputDecoration(
+ labelText: '제목',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: descriptionController,
+ maxLines: 3,
+ decoration: const InputDecoration(
+ labelText: '설명',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: locationController,
+ decoration: const InputDecoration(
+ labelText: '장소',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 16),
+ ListTile(
+ title: const Text('날짜'),
+ subtitle: Text(
+ DateFormat('yyyy년 MM월 dd일').format(selectedDate),
+ ),
+ trailing: const Icon(Icons.calendar_today),
+ onTap: () async {
+ final pickedDate = await showDatePicker(
+ context: context,
+ initialDate: selectedDate,
+ firstDate: DateTime.now(),
+ lastDate: DateTime.now().add(const Duration(days: 365)),
+ );
+ if (pickedDate != null && context.mounted) {
+ setState(() {
+ selectedDate = pickedDate;
+ });
+ }
+ },
+ ),
+ ListTile(
+ title: const Text('시간'),
+ subtitle: Text(selectedTime.format(context)),
+ trailing: const Icon(Icons.access_time),
+ onTap: () async {
+ final pickedTime = await showTimePicker(
+ context: context,
+ initialTime: selectedTime,
+ );
+ if (pickedTime != null && context.mounted) {
+ setState(() {
+ selectedTime = pickedTime;
+ });
+ }
+ },
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('취소'),
+ ),
+ ElevatedButton(
+ onPressed: () async {
+ if (titleController.text.isEmpty) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('제목은 필수 입력 항목입니다')),
+ );
+ return;
+ }
+
+ try {
+ final eventService = Provider.of(context, listen: false);
+ final clubService = Provider.of(context, listen: false);
+
+ if (clubService.currentClub != null) {
+ // 날짜와 시간 결합
+ final eventDateTime = DateTime(
+ selectedDate.year,
+ selectedDate.month,
+ selectedDate.day,
+ selectedTime.hour,
+ selectedTime.minute,
+ );
+
+ await eventService.createEvent({
+ 'title': titleController.text.trim(),
+ 'description': descriptionController.text.trim(),
+ 'location': locationController.text.trim(),
+ 'startDate': eventDateTime.toIso8601String(),
+ 'clubId': clubService.currentClub!.id,
+ 'isActive': true,
+ });
+
+ if (mounted) {
+ Navigator.of(context).pop();
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('이벤트가 추가되었습니다')),
+ );
+ }
+ }
+ } catch (e) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('이벤트 추가에 실패했습니다: $e')),
+ );
+ }
+ },
+ child: const Text('추가'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ // 이벤트 수정 다이얼로그
+ void _showEditEventDialog(Event event) {
+ final titleController = TextEditingController(text: event.title);
+ final descriptionController = TextEditingController(text: event.description ?? '');
+ final locationController = TextEditingController(text: event.location ?? '');
+
+ DateTime selectedDate = event.startDate;
+ TimeOfDay selectedTime = TimeOfDay(
+ hour: event.startDate.hour,
+ minute: event.startDate.minute,
+ );
+
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('이벤트 수정'),
+ content: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ TextField(
+ controller: titleController,
+ decoration: const InputDecoration(
+ labelText: '제목',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: descriptionController,
+ maxLines: 3,
+ decoration: const InputDecoration(
+ labelText: '설명',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: locationController,
+ decoration: const InputDecoration(
+ labelText: '장소',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 16),
+ ListTile(
+ title: const Text('날짜'),
+ subtitle: Text(
+ DateFormat('yyyy년 MM월 dd일').format(selectedDate),
+ ),
+ trailing: const Icon(Icons.calendar_today),
+ onTap: () async {
+ final pickedDate = await showDatePicker(
+ context: context,
+ initialDate: selectedDate,
+ firstDate: DateTime.now().subtract(const Duration(days: 365)),
+ lastDate: DateTime.now().add(const Duration(days: 365)),
+ );
+ if (pickedDate != null && context.mounted) {
+ setState(() {
+ selectedDate = pickedDate;
+ });
+ }
+ },
+ ),
+ ListTile(
+ title: const Text('시간'),
+ subtitle: Text(selectedTime.format(context)),
+ trailing: const Icon(Icons.access_time),
+ onTap: () async {
+ final pickedTime = await showTimePicker(
+ context: context,
+ initialTime: selectedTime,
+ );
+ if (pickedTime != null && context.mounted) {
+ setState(() {
+ selectedTime = pickedTime;
+ });
+ }
+ },
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('취소'),
+ ),
+ ElevatedButton(
+ onPressed: () async {
+ if (titleController.text.isEmpty) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('제목은 필수 입력 항목입니다')),
+ );
+ return;
+ }
+
+ try {
+ final eventService = Provider.of(context, listen: false);
+
+ // 날짜와 시간 결합
+ final eventDateTime = DateTime(
+ selectedDate.year,
+ selectedDate.month,
+ selectedDate.day,
+ selectedTime.hour,
+ selectedTime.minute,
+ );
+
+ await eventService.updateEvent(event.id, {
+ 'title': titleController.text.trim(),
+ 'description': descriptionController.text.trim(),
+ 'location': locationController.text.trim(),
+ 'startDate': eventDateTime.toIso8601String(),
+ });
+
+ if (mounted) {
+ Navigator.of(context).pop();
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('이벤트가 업데이트되었습니다')),
+ );
+ }
+ } catch (e) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('이벤트 업데이트에 실패했습니다: $e')),
+ );
+ }
+ },
+ child: const Text('저장'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ // 이벤트 상세 정보 다이얼로그
+ void _showEventDetailsDialog(Event event) {
+ final dateFormat = DateFormat('yyyy년 MM월 dd일 HH:mm');
+
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: Text(event.title),
+ content: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ const Icon(Icons.calendar_today, size: 16, color: Colors.blue),
+ const SizedBox(width: 8),
+ Text(dateFormat.format(event.startDate)),
+ ],
+ ),
+ const SizedBox(height: 8),
+ if (event.location != null) ...[
+ Row(
+ children: [
+ const Icon(Icons.location_on, size: 16, color: Colors.blue),
+ const SizedBox(width: 8),
+ Text(event.location!),
+ ],
+ ),
+ const SizedBox(height: 8),
+ ],
+ if (event.description != null) ...[
+ const Divider(),
+ const SizedBox(height: 8),
+ Text(
+ event.description!,
+ style: const TextStyle(fontSize: 16),
+ ),
+ ],
+ ],
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('닫기'),
+ ),
+ TextButton(
+ onPressed: () {
+ Navigator.of(context).pop();
+ _showEditEventDialog(event);
+ },
+ child: const Text('수정'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ // 이벤트 삭제 확인 다이얼로그
+ void _showDeleteConfirmationDialog(Event event) {
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('이벤트 삭제'),
+ content: Text('${event.title} 이벤트를 정말 삭제하시겠습니까?'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('취소'),
+ ),
+ TextButton(
+ onPressed: () async {
+ try {
+ final eventService = Provider.of(context, listen: false);
+
+ await eventService.deleteEvent(event.id);
+
+ if (mounted) {
+ Navigator.of(context).pop();
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('이벤트가 삭제되었습니다')),
+ );
+ }
+ } catch (e) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('이벤트 삭제에 실패했습니다: $e')),
+ );
+ }
+ },
+ style: TextButton.styleFrom(foregroundColor: Colors.red),
+ child: const Text('삭제'),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/club/members_screen.dart b/mobile/lib/screens/club/members_screen.dart
new file mode 100644
index 0000000..976ed68
--- /dev/null
+++ b/mobile/lib/screens/club/members_screen.dart
@@ -0,0 +1,1018 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:intl/intl.dart';
+
+import '../../services/auth_service.dart';
+import '../../services/club_service.dart';
+import '../../services/member_service.dart';
+import '../../models/member_model.dart';
+import '../../utils/enum_mappings.dart';
+
+class MembersScreen extends StatefulWidget {
+ const MembersScreen({super.key});
+
+ @override
+ State createState() => _MembersScreenState();
+}
+
+class _MembersScreenState extends State {
+ bool _isInit = false;
+ String _searchQuery = '';
+ final TextEditingController _searchController = TextEditingController();
+
+ @override
+ void dispose() {
+ _searchController.dispose();
+ super.dispose();
+ }
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ if (!_isInit) {
+ _loadMembers();
+ _isInit = true;
+ }
+ }
+
+ Future _loadMembers() async {
+ try {
+ print('_loadMembers 호출됨');
+ final authService = Provider.of(context, listen: false);
+ final clubService = Provider.of(context, listen: false);
+ final memberService = Provider.of(context, listen: false);
+
+ print('현재 클럽: ${clubService.currentClub?.name}');
+ print('토큰: ${authService.token}');
+
+ if (clubService.currentClub != null && authService.token != null) {
+ print('멤버 서비스 초기화 시작');
+ memberService.initialize(authService.token!);
+ memberService.setClubId(clubService.currentClub!.id); // 클럽 ID 설정 추가
+ print('멤버 서비스 초기화 완료');
+
+ print('회원 목록 가져오기 시작');
+ await memberService.fetchClubMembers();
+ print('회원 목록 가져오기 완료: ${memberService.members.length}개');
+ } else {
+ print('클럽 또는 토큰이 없어서 회원 목록을 가져올 수 없습니다.');
+ }
+ } catch (e) {
+ print('회원 목록 가져오기 오류: $e');
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('회원 목록을 불러오는데 실패했습니다: $e')),
+ );
+ }
+ }
+ }
+
+ List _getFilteredMembers(List members) {
+ if (_searchQuery.isEmpty) {
+ return members;
+ }
+
+ final query = _searchQuery.toLowerCase();
+ return members.where((member) {
+ return member.name.toLowerCase().contains(query) ||
+ member.email.toLowerCase().contains(query);
+ }).toList();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: Column(
+ children: [
+ // 검색 바
+ Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: TextField(
+ controller: _searchController,
+ decoration: InputDecoration(
+ hintText: '회원 검색',
+ prefixIcon: const Icon(Icons.search),
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ contentPadding: const EdgeInsets.symmetric(vertical: 0),
+ suffixIcon: _searchQuery.isNotEmpty
+ ? IconButton(
+ icon: const Icon(Icons.clear),
+ onPressed: () {
+ _searchController.clear();
+ setState(() {
+ _searchQuery = '';
+ });
+ },
+ )
+ : null,
+ ),
+ onChanged: (value) {
+ setState(() {
+ _searchQuery = value;
+ });
+ },
+ ),
+ ),
+
+ // 회원 목록
+ Expanded(
+ child: Consumer(
+ builder: (context, memberService, _) {
+ if (memberService.isLoading) {
+ return const Center(child: CircularProgressIndicator());
+ }
+
+ final filteredMembers = _getFilteredMembers(memberService.members);
+
+ if (filteredMembers.isEmpty) {
+ return Center(
+ child: Text(
+ _searchQuery.isEmpty
+ ? '등록된 회원이 없습니다'
+ : '검색 결과가 없습니다',
+ ),
+ );
+ }
+
+ return RefreshIndicator(
+ onRefresh: _loadMembers,
+ child: ListView.builder(
+ padding: const EdgeInsets.only(bottom: 16),
+ itemCount: filteredMembers.length,
+ itemBuilder: (context, index) {
+ final member = filteredMembers[index];
+ return _buildMemberCard(member);
+ },
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ floatingActionButton: FloatingActionButton(
+ onPressed: () {
+ // 회원 추가 화면으로 이동 (추후 구현)
+ _showAddMemberDialog();
+ },
+ backgroundColor: Colors.blue,
+ child: const Icon(Icons.add, color: Colors.white),
+ ),
+ );
+ }
+
+ Widget _buildMemberCard(Member member) {
+ final memberTypeText = _getMemberTypeDisplayName(member.memberType);
+
+ return Card(
+ margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ elevation: 2,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ child: InkWell(
+ onTap: () {
+ // 회원 상세 정보 다이얼로그 표시
+ _showMemberDetailDialog(member);
+ },
+ borderRadius: BorderRadius.circular(12),
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Row(
+ children: [
+ // 회원 정보
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Text(
+ member.name,
+ style: const TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(width: 8),
+ // 성별 아이콘
+ Icon(
+ member.gender == 'female' ? Icons.female : Icons.male,
+ size: 18,
+ color: member.gender == 'female' ? Colors.pink : Colors.blue,
+ ),
+ // 핸디캡
+ if (member.handicap != null && member.handicap != 0)
+ Container(
+ margin: const EdgeInsets.only(left: 8),
+ padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
+ decoration: BoxDecoration(
+ color: Colors.orange.shade100,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Text(
+ (member.handicap! > 0) ? "+${member.handicap}" : member.handicap.toString(),
+ style: TextStyle(
+ fontSize: 12,
+ color: Colors.orange.shade800,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ ],
+ ),
+ if (member.email.isNotEmpty || member.phone != null)
+ const SizedBox(height: 4),
+ if (member.phone != null)
+ Text(
+ member.phone!,
+ style: TextStyle(
+ fontSize: 14,
+ color: Colors.grey.shade700,
+ ),
+ ),
+ const SizedBox(width: 8),
+ if (member.email.isNotEmpty)
+ Text(
+ member.email,
+ style: TextStyle(
+ fontSize: 14,
+ color: Colors.grey.shade700,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Row(
+ children: [
+ Text(
+ memberTypeText, // 회원 유형으로 변경
+ style: TextStyle(
+ fontSize: 14,
+ color: Colors.blue.shade700,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ const SizedBox(width: 8),
+ // 가입일 표시
+ if (member.joinDate != null)
+ Text(
+ '가입: ${formatDate(member.joinDate!)}',
+ style: TextStyle(
+ fontSize: 12,
+ color: Colors.grey.shade600,
+ ),
+ ),
+ const SizedBox(width: 8),
+ // 상태 표시
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
+ decoration: BoxDecoration(
+ color: getStatusColor(member.status ?? 'inactive'),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Text(
+ getStatusLabel(member.status),
+ style: TextStyle(
+ color: getStatusTextColor(member.status ?? 'inactive'),
+ fontWeight: FontWeight.bold,
+ fontSize: 12,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ // 액션 버튼
+ PopupMenuButton(
+ icon: const Icon(Icons.more_vert),
+ onSelected: (value) {
+ if (value == 'edit') {
+ _showMemberDetailDialog(member);
+ } else if (value == 'delete') {
+ _showDeleteConfirmationDialog(member);
+ }
+ },
+ itemBuilder: (context) => [
+ const PopupMenuItem(
+ value: 'delete',
+ child: Row(
+ children: [
+ Icon(Icons.delete, size: 20, color: Colors.red),
+ SizedBox(width: 8),
+ Text('삭제', style: TextStyle(color: Colors.red)),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ // 회원 추가 다이얼로그
+ void _showAddMemberDialog() {
+ final nameController = TextEditingController();
+ final emailController = TextEditingController();
+ final phoneController = TextEditingController();
+ final handicapController = TextEditingController(text: '0');
+ final joinDateController = TextEditingController(text: formatDate(DateTime.now()));
+
+ // 선택형 필드를 위한 값 초기화
+ String selectedGender = 'male';
+ String selectedMemberType = 'regular';
+ String selectedStatus = 'active';
+
+ showDialog(
+ context: context,
+ builder: (context) => StatefulBuilder(
+ builder: (context, setState) {
+ return AlertDialog(
+ title: const Text('회원 추가'),
+ content: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ TextField(
+ controller: nameController,
+ decoration: const InputDecoration(
+ labelText: '이름',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: emailController,
+ keyboardType: TextInputType.emailAddress,
+ decoration: const InputDecoration(
+ labelText: '이메일',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: phoneController,
+ keyboardType: TextInputType.phone,
+ decoration: const InputDecoration(
+ labelText: '전화번호 (선택사항)',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: handicapController,
+ keyboardType: TextInputType.number,
+ decoration: const InputDecoration(
+ labelText: '핸디캡',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: joinDateController,
+ readOnly: true,
+ decoration: const InputDecoration(
+ labelText: '가입일',
+ border: OutlineInputBorder(),
+ suffixIcon: Icon(Icons.calendar_today),
+ ),
+ onTap: () async {
+ final DateTime? picked = await showDatePicker(
+ context: context,
+ initialDate: DateTime.now(),
+ firstDate: DateTime(2000),
+ lastDate: DateTime(2101),
+ );
+ if (picked != null) {
+ setState(() {
+ joinDateController.text = formatDate(picked);
+ });
+ }
+ },
+ ),
+ const SizedBox(height: 16),
+ DropdownButtonFormField(
+ value: selectedGender,
+ decoration: const InputDecoration(
+ labelText: '성별',
+ border: OutlineInputBorder(),
+ ),
+ items: const [
+ DropdownMenuItem(value: 'male', child: Text('남성')),
+ DropdownMenuItem(value: 'female', child: Text('여성')),
+ ],
+ onChanged: (value) {
+ if (value != null) {
+ setState(() {
+ selectedGender = value;
+ });
+ }
+ },
+ ),
+ const SizedBox(height: 16),
+ DropdownButtonFormField(
+ value: selectedMemberType,
+ decoration: const InputDecoration(
+ labelText: '회원 유형',
+ border: OutlineInputBorder(),
+ ),
+ items: const [
+ DropdownMenuItem(value: 'regular', child: Text('정회원')),
+ DropdownMenuItem(value: 'associate', child: Text('준회원')),
+ DropdownMenuItem(value: 'guest', child: Text('게스트')),
+ ],
+ onChanged: (value) {
+ if (value != null) {
+ setState(() {
+ selectedMemberType = value;
+ });
+ }
+ },
+ ),
+ const SizedBox(height: 16),
+ DropdownButtonFormField(
+ value: selectedStatus,
+ decoration: const InputDecoration(
+ labelText: '상태',
+ border: OutlineInputBorder(),
+ ),
+ items: const [
+ DropdownMenuItem(value: 'active', child: Text('활성')),
+ DropdownMenuItem(value: 'inactive', child: Text('비활성')),
+ ],
+ onChanged: (value) {
+ if (value != null) {
+ setState(() {
+ selectedStatus = value;
+ });
+ }
+ },
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('취소'),
+ ),
+ ElevatedButton(
+ onPressed: () async {
+ if (nameController.text.isEmpty) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('이름은 필수 입력 항목입니다')),
+ );
+ return;
+ }
+
+ try {
+ final memberService = Provider.of(context, listen: false);
+ final clubService = Provider.of(context, listen: false);
+
+ if (clubService.currentClub != null) {
+ // 회원 추가 데이터 준비
+ final memberData = {
+ 'name': nameController.text.trim(),
+ 'clubId': clubService.currentClub!.id,
+ 'gender': selectedGender,
+ 'memberType': selectedMemberType,
+ 'status': selectedStatus,
+ 'handicap': int.tryParse(handicapController.text) ?? 0,
+ 'isActive': selectedStatus == 'active',
+ };
+
+ // 가입일 추가
+ try {
+ final joinDate = parseDate(joinDateController.text);
+ if (joinDate != null) {
+ memberData['joinDate'] = joinDate.toIso8601String();
+ }
+ } catch (e) {
+ // 가입일 파싱 오류 무시
+ }
+
+ // 이메일이 비어있으면 null로 설정, 그렇지 않으면 트림한 값 사용
+ if (emailController.text.trim().isEmpty) {
+ memberData['email'] = null;
+ } else {
+ memberData['email'] = emailController.text.trim();
+ }
+
+ // 전화번호가 비어있으면 null로 설정, 그렇지 않으면 트림한 값 사용
+ if (phoneController.text.trim().isEmpty) {
+ memberData['phone'] = null;
+ } else {
+ memberData['phone'] = phoneController.text.trim();
+ }
+
+ await memberService.addMember(memberData);
+
+ if (mounted) {
+ Navigator.of(context).pop();
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('회원이 추가되었습니다')),
+ );
+ }
+ }
+ } catch (e) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('회원 추가에 실패했습니다: $e')),
+ );
+ }
+ },
+ child: const Text('추가'),
+ ),
+ ],
+ );
+ },
+ ),
+ );
+ }
+
+ // 날짜 포맷팅 함수
+ String formatDate(DateTime? date) {
+ if (date == null) return '정보 없음';
+ return DateFormat('yyyy-MM-dd').format(date);
+ }
+
+ // 날짜 파싱 함수
+ DateTime? parseDate(String? dateStr) {
+ if (dateStr == null) return null;
+ if (dateStr.isEmpty) return null;
+ try {
+ // 다양한 형식 지원
+ if (dateStr.contains('T')) {
+ // ISO 형식 (예: 2023-01-01T00:00:00.000Z)
+ return DateTime.parse(dateStr);
+ } else if (dateStr.contains('-')) {
+ // yyyy-MM-dd 형식
+ return DateFormat('yyyy-MM-dd').parse(dateStr);
+ }
+ return null;
+ } catch (e) {
+ return null;
+ }
+ }
+
+ // 폼 필드 생성 헬퍼 메서드
+ // 폼 필드 빌더 헬퍼 함수
+ Widget buildFormField(String label, Widget field) {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ label,
+ style: const TextStyle(
+ fontSize: 14,
+ fontWeight: FontWeight.w500,
+ color: Colors.grey,
+ ),
+ ),
+ const SizedBox(height: 4),
+ field,
+ ],
+ ),
+ );
+ }
+
+ // 성별 표시 이름 변환
+ String _getGenderDisplayName(String? gender) {
+ return getGenderLabel(gender);
+ }
+
+ // 회원 유형 표시 이름 변환
+ String _getMemberTypeDisplayName(String? memberType) {
+ return getMemberTypeLabel(memberType);
+ }
+
+ // 회원 상태에 따른 배경색 가져오기
+ Color getStatusColor(String status) {
+ switch (status) {
+ case 'active': return Colors.green.shade100;
+ case 'inactive': return Colors.grey.shade200;
+ case 'suspended': return Colors.red.shade100;
+ case 'deleted': return Colors.grey.shade300;
+ default: return Colors.grey.shade200;
+ }
+ }
+
+ // 회원 상태에 따른 텍스트 색상 가져오기
+ Color getStatusTextColor(String status) {
+ switch (status) {
+ case 'active': return Colors.green.shade800;
+ case 'inactive': return Colors.grey.shade700;
+ case 'suspended': return Colors.red.shade800;
+ case 'deleted': return Colors.grey.shade800;
+ default: return Colors.grey.shade700;
+ }
+ }
+
+ // 회원 상세 정보 다이얼로그
+ void _showMemberDetailDialog(Member member) {
+ bool isEditing = false;
+
+ // 폼 컨트롤러 초기화
+ final nameController = TextEditingController(text: member.name);
+ final phoneController = TextEditingController(text: member.phone ?? '');
+ final emailController = TextEditingController(text: member.email ?? '');
+ final handicapController = TextEditingController(text: member.handicap?.toString() ?? '0');
+ final joinDateController = TextEditingController(text: formatDate(member.joinDate));
+
+ // 선택형 필드를 위한 값 초기화
+ String selectedGender = member.gender ?? 'male';
+ String selectedMemberType = member.memberType ?? 'regular';
+ String selectedStatus = member.status ?? (member.isActive ? 'active' : 'inactive');
+
+ showDialog(
+ context: context,
+ builder: (context) => StatefulBuilder(
+ builder: (context, setState) {
+ return AlertDialog(
+ title: Text(isEditing ? '회원 정보 수정' : '회원 정보'),
+ content: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // 회원 정보 헤더 (보기 모드에서만 표시)
+ if (!isEditing) ...[
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ // 현재 회원 상태
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Text(
+ member.name,
+ style: const TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 4),
+ Text(
+ _getMemberTypeDisplayName(member.memberType),
+ style: TextStyle(
+ fontSize: 14,
+ color: Colors.blue.shade700,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ if (member.phone != null && member.phone!.isNotEmpty) ...[
+ const SizedBox(height: 4),
+ Text(
+ member.phone!,
+ style: TextStyle(
+ fontSize: 14,
+ color: Colors.grey.shade700,
+ ),
+ ),
+ ],
+ if (member.email.isNotEmpty) ...[
+ const SizedBox(height: 2),
+ Text(
+ member.email,
+ style: TextStyle(
+ fontSize: 14,
+ color: Colors.grey.shade700,
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ const Divider(),
+ ],
+
+ // 폼 필드
+ Container(
+ width: double.infinity,
+ padding: const EdgeInsets.symmetric(vertical: 8),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // 이름 필드 - 수정 모드에서만 표시 (상단에 이미 표시됨)
+ if (isEditing)
+ buildFormField('이름', TextField(
+ controller: nameController,
+ decoration: const InputDecoration(
+ border: OutlineInputBorder(),
+ contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ ),
+ )),
+
+ // 성별 필드 - 상단에 없으므로 항상 표시
+ buildFormField('성별', isEditing ?
+ DropdownButtonFormField(
+ value: selectedGender,
+ decoration: const InputDecoration(
+ border: OutlineInputBorder(),
+ contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ ),
+ items: [
+ DropdownMenuItem(
+ value: 'male',
+ child: Text('남성'),
+ ),
+ DropdownMenuItem(
+ value: 'female',
+ child: Text('여성'),
+ ),
+ ],
+ onChanged: (value) {
+ if (value != null) {
+ selectedGender = value;
+ }
+ },
+ ) :
+ Text(_getGenderDisplayName(member.gender), style: const TextStyle(fontSize: 16))
+ ),
+
+ // 회원 유형 필드 - 수정 모드에서만 표시 (상단에 이미 표시됨)
+ if (isEditing)
+ buildFormField('회원 유형', DropdownButtonFormField(
+ value: selectedMemberType,
+ decoration: const InputDecoration(
+ border: OutlineInputBorder(),
+ contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ ),
+ items: [
+ DropdownMenuItem(
+ value: 'regular',
+ child: Text('정회원'),
+ ),
+ DropdownMenuItem(
+ value: 'associate',
+ child: Text('준회원'),
+ ),
+ DropdownMenuItem(
+ value: 'guest',
+ child: Text('게스트'),
+ ),
+ DropdownMenuItem(
+ value: 'manager',
+ child: Text('운영진'),
+ ),
+ DropdownMenuItem(
+ value: 'owner',
+ child: Text('모임장'),
+ ),
+ ],
+ onChanged: (value) {
+ if (value != null) {
+ selectedMemberType = value;
+ }
+ },
+ )),
+
+ // 전화번호 필드 - 수정 모드에서만 표시 (상단에 이미 표시됨)
+ if (isEditing)
+ buildFormField('전화번호', TextField(
+ controller: phoneController,
+ keyboardType: TextInputType.phone,
+ decoration: const InputDecoration(
+ border: OutlineInputBorder(),
+ contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ ),
+ )),
+
+ // 이메일 필드 - 수정 모드에서만 표시 (상단에 이미 표시됨)
+ if (isEditing)
+ buildFormField('이메일', TextField(
+ controller: emailController,
+ keyboardType: TextInputType.emailAddress,
+ decoration: const InputDecoration(
+ border: OutlineInputBorder(),
+ contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ ),
+ )),
+
+ // 핸디캡 필드 - 상단에 없으므로 항상 표시
+ buildFormField('핸디캡', isEditing ?
+ TextField(
+ controller: handicapController,
+ keyboardType: TextInputType.number,
+ decoration: const InputDecoration(
+ border: OutlineInputBorder(),
+ contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ ),
+ ) :
+ Text(member.handicap?.toString() ?? '0', style: const TextStyle(fontSize: 16))
+ ),
+
+ // 상태 필드 - 수정 모드에서만 표시 (상단에 이미 표시됨)
+ if (isEditing)
+ buildFormField('상태', DropdownButtonFormField(
+ value: selectedStatus,
+ decoration: const InputDecoration(
+ border: OutlineInputBorder(),
+ contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ ),
+ items: [
+ DropdownMenuItem(
+ value: 'active',
+ child: Text('활성'),
+ ),
+ DropdownMenuItem(
+ value: 'inactive',
+ child: Text('비활성'),
+ ),
+ DropdownMenuItem(
+ value: 'suspended',
+ child: Text('정지'),
+ ),
+ ],
+ onChanged: (value) {
+ if (value != null) {
+ selectedStatus = value;
+ }
+ },
+ )),
+
+ // 가입일 필드
+ buildFormField('가입일', isEditing ?
+ InkWell(
+ onTap: () async {
+ final DateTime? picked = await showDatePicker(
+ context: context,
+ initialDate: member.joinDate ?? DateTime.now(),
+ firstDate: DateTime(2000),
+ lastDate: DateTime.now(),
+ // locale은 앱 전체 설정을 사용합니다
+ );
+ if (picked != null) {
+ setState(() {
+ // 새로운 날짜로 업데이트
+ joinDateController.text = formatDate(picked);
+
+ // copyWith 메서드를 사용하여 효율적으로 업데이트
+ member = member.copyWith(
+ joinDate: picked, // 새로 선택한 날짜
+ );
+ });
+ }
+ },
+ child: InputDecorator(
+ decoration: const InputDecoration(
+ border: OutlineInputBorder(),
+ contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ suffixIcon: Icon(Icons.calendar_today),
+ ),
+ child: Text(formatDate(member.joinDate), style: const TextStyle(fontSize: 16)),
+ ),
+ ) :
+ Text(formatDate(member.joinDate), style: const TextStyle(fontSize: 16))
+ ),
+
+ // 시스템 정보 (수정불가)
+ if (!isEditing) ...[
+ const SizedBox(height: 16),
+ const Divider(),
+ const SizedBox(height: 8),
+ const Text('시스템 정보', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
+ const SizedBox(height: 8),
+ buildFormField('회원 ID', Text(member.id, style: const TextStyle(fontSize: 14, fontFamily: 'monospace'))),
+ if (member.userId.isNotEmpty)
+ buildFormField('유저 ID', Text(member.userId, style: const TextStyle(fontSize: 14, fontFamily: 'monospace'))),
+ if (member.clubId.isNotEmpty)
+ buildFormField('클럽 ID', Text(member.clubId, style: const TextStyle(fontSize: 14, fontFamily: 'monospace'))),
+ ],
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ // 취소 버튼
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('취소'),
+ ),
+ // 수정/저장 버튼
+ TextButton(
+ onPressed: () {
+ if (isEditing) {
+ // 수정 모드에서 저장 버튼 클릭 시
+ if (nameController.text.trim().isEmpty) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('이름은 필수 입력 항목입니다.')),
+ );
+ return;
+ }
+
+ // copyWith 메서드를 사용하여 회원 정보 업데이트
+ final updatedMember = member.copyWith(
+ name: nameController.text.trim(),
+ email: emailController.text.trim().isEmpty ? null : emailController.text.trim(),
+ phone: phoneController.text.trim().isEmpty ? null : phoneController.text.trim(),
+ memberType: selectedMemberType,
+ gender: selectedGender,
+ status: selectedStatus,
+ handicap: int.tryParse(handicapController.text) ?? member.handicap,
+ isActive: selectedStatus == 'active',
+ updatedAt: DateTime.now(),
+ // joinDate는 이미 날짜 선택기에서 업데이트되었으므로 여기서 다시 설정할 필요 없음
+ );
+
+ // 회원 정보 저장
+ // toJson 결과에서 clubId 제거하여 MemberService에서 설정한 clubId만 사용
+ final memberData = updatedMember.toJson();
+ memberData.remove('clubId'); // clubId 필드 제거
+
+ // 전화번호와 이메일 필드가 비어있을 때 null로 명시적 설정
+ if (phoneController.text.trim().isEmpty) {
+ memberData['phone'] = null;
+ }
+
+ if (emailController.text.trim().isEmpty) {
+ memberData['email'] = null;
+ }
+
+ // 가입일 필드 명시적으로 추가 (ISO 형식으로 변환)
+ if (member.joinDate != null) {
+ memberData['joinDate'] = member.joinDate!.toIso8601String();
+ }
+
+ context.read().updateMember(
+ updatedMember.id,
+ memberData,
+ ).then((_) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('회원 정보가 업데이트되었습니다.')),
+ );
+ Navigator.of(context).pop();
+ }).catchError((error) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('오류: ${error.toString()}')),
+ );
+ });
+ } else {
+ // 보기 모드에서 수정 버튼 클릭 시
+ setState(() {
+ isEditing = true;
+ });
+ }
+ },
+ child: Text(isEditing ? '저장' : '수정'),
+ ),
+ ],
+ );
+ },
+ ),
+ );
+ }
+
+ // 회원 삭제 확인 다이얼로그
+ void _showDeleteConfirmationDialog(Member member) {
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('회원 삭제'),
+ content: Text('${member.name} 회원을 정말 삭제하시겠습니까?'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('취소'),
+ ),
+ TextButton(
+ onPressed: () async {
+ try {
+ final memberService = Provider.of(context, listen: false);
+
+ await memberService.deleteMember(member.id);
+
+ if (mounted) {
+ Navigator.of(context).pop();
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('회원이 삭제되었습니다')),
+ );
+ }
+ } catch (e) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('회원 삭제에 실패했습니다: $e')),
+ );
+ }
+ },
+ style: TextButton.styleFrom(foregroundColor: Colors.red),
+ child: const Text('삭제'),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/club/members_screen.dart.bak b/mobile/lib/screens/club/members_screen.dart.bak
new file mode 100644
index 0000000..a173b5e
--- /dev/null
+++ b/mobile/lib/screens/club/members_screen.dart.bak
@@ -0,0 +1,1006 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:intl/intl.dart';
+
+import '../../services/auth_service.dart';
+import '../../services/club_service.dart';
+import '../../services/member_service.dart';
+import '../../models/member_model.dart';
+
+class MembersScreen extends StatefulWidget {
+ const MembersScreen({super.key});
+
+ @override
+ State createState() => _MembersScreenState();
+}
+
+class _MembersScreenState extends State {
+ bool _isInit = false;
+ String _searchQuery = '';
+ final TextEditingController _searchController = TextEditingController();
+
+ @override
+ void dispose() {
+ _searchController.dispose();
+ super.dispose();
+ }
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ if (!_isInit) {
+ _loadMembers();
+ _isInit = true;
+ }
+ }
+
+ Future _loadMembers() async {
+ try {
+ print('_loadMembers 호출됨');
+ final authService = Provider.of(context, listen: false);
+ final clubService = Provider.of(context, listen: false);
+ final memberService = Provider.of(context, listen: false);
+
+ print('현재 클럽: ${clubService.currentClub?.name}');
+ print('토큰: ${authService.token}');
+
+ if (clubService.currentClub != null && authService.token != null) {
+ print('멤버 서비스 초기화 시작');
+ memberService.initialize(authService.token!);
+ print('멤버 서비스 초기화 완료');
+
+ print('회원 목록 가져오기 시작');
+ await memberService.fetchClubMembers();
+ print('회원 목록 가져오기 완료: ${memberService.members.length}개');
+ } else {
+ print('클럽 또는 토큰이 없어서 회원 목록을 가져올 수 없습니다.');
+ }
+ } catch (e) {
+ print('회원 목록 가져오기 오류: $e');
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('회원 목록을 불러오는데 실패했습니다: $e')),
+ );
+ }
+ }
+ }
+
+ List _getFilteredMembers(List members) {
+ if (_searchQuery.isEmpty) {
+ return members;
+ }
+
+ final query = _searchQuery.toLowerCase();
+ return members.where((member) {
+ return member.name.toLowerCase().contains(query) ||
+ member.email.toLowerCase().contains(query);
+ }).toList();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: Column(
+ children: [
+ // 검색 바
+ Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: TextField(
+ controller: _searchController,
+ decoration: InputDecoration(
+ hintText: '회원 검색',
+ prefixIcon: const Icon(Icons.search),
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ contentPadding: const EdgeInsets.symmetric(vertical: 0),
+ suffixIcon: _searchQuery.isNotEmpty
+ ? IconButton(
+ icon: const Icon(Icons.clear),
+ onPressed: () {
+ _searchController.clear();
+ setState(() {
+ _searchQuery = '';
+ });
+ },
+ )
+ : null,
+ ),
+ onChanged: (value) {
+ setState(() {
+ _searchQuery = value;
+ });
+ },
+ ),
+ ),
+
+ // 회원 목록
+ Expanded(
+ child: Consumer(
+ builder: (context, memberService, _) {
+ if (memberService.isLoading) {
+ return const Center(child: CircularProgressIndicator());
+ }
+
+ final filteredMembers = _getFilteredMembers(memberService.members);
+
+ if (filteredMembers.isEmpty) {
+ return Center(
+ child: Text(
+ _searchQuery.isEmpty
+ ? '등록된 회원이 없습니다'
+ : '검색 결과가 없습니다',
+ ),
+ );
+ }
+
+ return RefreshIndicator(
+ onRefresh: _loadMembers,
+ child: ListView.builder(
+ padding: const EdgeInsets.only(bottom: 16),
+ itemCount: filteredMembers.length,
+ itemBuilder: (context, index) {
+ final member = filteredMembers[index];
+ return _buildMemberCard(member);
+ },
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ floatingActionButton: FloatingActionButton(
+ onPressed: () {
+ // 회원 추가 화면으로 이동 (추후 구현)
+ _showAddMemberDialog();
+ },
+ backgroundColor: Colors.blue,
+ child: const Icon(Icons.add, color: Colors.white),
+ ),
+ );
+ }
+
+ Widget _buildMemberCard(Member member) {
+ final roleText = _getRoleDisplayName(member.role ?? 'member');
+
+ return Card(
+ margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ elevation: 2,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ child: InkWell(
+ onTap: () {
+ // 회원 상세 정보 다이얼로그 표시
+ _showMemberDetailDialog(member);
+ },
+ borderRadius: BorderRadius.circular(12),
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Row(
+ children: [
+ // 프로필 이미지 또는 이니셜
+ Container(
+ width: 60,
+ height: 60,
+ decoration: BoxDecoration(
+ color: Colors.blue.shade100,
+ shape: BoxShape.circle,
+ ),
+ child: Center(
+ child: member.profileImage != null
+ ? ClipOval(
+ child: Image.network(
+ member.profileImage!,
+ width: 60,
+ height: 60,
+ fit: BoxFit.cover,
+ errorBuilder: (context, error, stackTrace) {
+ return Text(
+ member.name.isNotEmpty ? member.name[0].toUpperCase() : '?',
+ style: const TextStyle(
+ fontSize: 24,
+ fontWeight: FontWeight.bold,
+ color: Colors.blue,
+ ),
+ );
+ },
+ ),
+ )
+ : Text(
+ member.name.isNotEmpty ? member.name[0].toUpperCase() : '?',
+ style: const TextStyle(
+ fontSize: 24,
+ fontWeight: FontWeight.bold,
+ color: Colors.blue,
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(width: 16),
+ // 회원 정보
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ member.name,
+ style: const TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ member.email,
+ style: TextStyle(
+ fontSize: 14,
+ color: Colors.grey.shade700,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ roleText,
+ style: TextStyle(
+ fontSize: 14,
+ color: Colors.blue.shade700,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ],
+ ),
+ ),
+ // 액션 버튼
+ PopupMenuButton(
+ icon: const Icon(Icons.more_vert),
+ onSelected: (value) {
+ if (value == 'edit') {
+ _showEditMemberDialog(member);
+ } else if (value == 'delete') {
+ _showDeleteConfirmationDialog(member);
+ }
+ },
+ itemBuilder: (context) => [
+ const PopupMenuItem(
+ value: 'edit',
+ child: Row(
+ children: [
+ Icon(Icons.edit, size: 20),
+ SizedBox(width: 8),
+ Text('수정'),
+ ],
+ ),
+ ),
+ const PopupMenuItem(
+ value: 'delete',
+ child: Row(
+ children: [
+ Icon(Icons.delete, size: 20, color: Colors.red),
+ SizedBox(width: 8),
+ Text('삭제', style: TextStyle(color: Colors.red)),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ // 회원 추가 다이얼로그
+ void _showAddMemberDialog() {
+ final nameController = TextEditingController();
+ final emailController = TextEditingController();
+ final phoneController = TextEditingController();
+
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('회원 추가'),
+ content: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ TextField(
+ controller: nameController,
+ decoration: const InputDecoration(
+ labelText: '이름',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: emailController,
+ keyboardType: TextInputType.emailAddress,
+ decoration: const InputDecoration(
+ labelText: '이메일',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: phoneController,
+ keyboardType: TextInputType.phone,
+ decoration: const InputDecoration(
+ labelText: '전화번호 (선택사항)',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('취소'),
+ ),
+ ElevatedButton(
+ onPressed: () async {
+ if (nameController.text.isEmpty || emailController.text.isEmpty) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('이름과 이메일은 필수 입력 항목입니다')),
+ );
+ return;
+ }
+
+ try {
+ final memberService = Provider.of(context, listen: false);
+ final clubService = Provider.of(context, listen: false);
+
+ if (clubService.currentClub != null) {
+ await memberService.addMember({
+ 'name': nameController.text.trim(),
+ 'email': emailController.text.trim(),
+ 'phone': phoneController.text.trim(),
+ 'clubId': clubService.currentClub!.id,
+ });
+
+ if (mounted) {
+ Navigator.of(context).pop();
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('회원이 추가되었습니다')),
+ );
+ }
+ }
+ } catch (e) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('회원 추가에 실패했습니다: $e')),
+ );
+ }
+ },
+ child: const Text('추가'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ // 회원 수정 다이얼로그
+ void _showEditMemberDialog(Member member) {
+ final nameController = TextEditingController(text: member.name);
+ final phoneController = TextEditingController(text: member.phone ?? '');
+
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('회원 정보 수정'),
+ content: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ TextField(
+ controller: nameController,
+ decoration: const InputDecoration(
+ labelText: '이름',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ enabled: false, // 이메일은 수정 불가
+ controller: TextEditingController(text: member.email),
+ decoration: const InputDecoration(
+ labelText: '이메일',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: phoneController,
+ keyboardType: TextInputType.phone,
+ decoration: const InputDecoration(
+ labelText: '전화번호',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('취소'),
+ ),
+ ElevatedButton(
+ onPressed: () async {
+ if (nameController.text.isEmpty) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('이름은 필수 입력 항목입니다')),
+ );
+ return;
+ }
+
+ try {
+ final memberService = Provider.of(context, listen: false);
+
+ await memberService.updateMember(member.id, {
+ 'name': nameController.text.trim(),
+ 'phone': phoneController.text.trim(),
+ });
+
+ if (mounted) {
+ Navigator.of(context).pop();
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('회원 정보가 업데이트되었습니다')),
+ );
+ }
+ } catch (e) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('회원 정보 업데이트에 실패했습니다: $e')),
+ );
+ }
+ },
+ child: const Text('저장'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ // 회원 상세 정보 다이얼로그
+ void _showMemberDetailDialog(Member member) {
+ bool isEditing = false;
+
+ // 폼 컨트롤러 초기화
+ final nameController = TextEditingController(text: member.name);
+ final phoneController = TextEditingController(text: member.phone ?? '');
+ final emailController = TextEditingController(text: member.email);
+ final handicapController = TextEditingController(text: member.handicap?.toString() ?? '0');
+
+ // 선택형 필드를 위한 값 초기화
+ String selectedGender = member.gender ?? 'male';
+ String selectedMemberType = member.memberType ?? 'regular';
+ String selectedStatus = member.status ?? 'active';
+
+ showDialog(
+ context: context,
+ builder: (context) => StatefulBuilder(
+ builder: (context, setState) {
+ // 날짜 포맷팅
+ String formatDate(DateTime? date) {
+ if (date == null) return '정보 없음';
+ return DateFormat('yyyy년 MM월 dd일').format(date);
+ }
+
+ // 역할 표시 이름 변환
+ String getRoleDisplayName(String? role) {
+ if (role == null) return '일반 회원';
+
+ switch (role) {
+ case 'owner':
+ return '클럽 소유자';
+ case 'manager':
+ return '클럽 매니저';
+ case 'member':
+ return '클럽 회원';
+ default:
+ return role;
+ }
+ }
+
+ // 회원 유형 표시 이름 변환
+ String getMemberTypeDisplayName(String? memberType) {
+ if (memberType == null) return '일반 회원';
+
+ switch (memberType) {
+ case 'regular':
+ return '정회원';
+ case 'associate':
+ return '준회원';
+ case 'guest':
+ return '게스트';
+ case 'manager':
+ return '매니저';
+ case 'owner':
+ return '소유자';
+ default:
+ return memberType;
+ }
+ }
+
+ // 성별 표시 이름 변환
+ String getGenderDisplayName(String? gender) {
+ if (gender == null) return '정보 없음';
+
+ switch (gender) {
+ case 'male':
+ return '남성';
+ case 'female':
+ return '여성';
+ default:
+ return gender;
+ }
+ }
+
+ // 상태 표시 이름 변환
+ String getStatusDisplayName(String? status) {
+ if (status == null) return '활성';
+
+ switch (status) {
+ case 'active':
+ return '활성';
+ case 'inactive':
+ return '비활성';
+ case 'suspended':
+ return '정지';
+ case 'deleted':
+ return '삭제됨';
+ default:
+ return status;
+ }
+ }
+
+ // 정보 항목 표시 위젯
+ Widget buildInfoRow(String label, String value) {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 8.0),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ SizedBox(
+ width: 100,
+ child: Text(
+ label,
+ style: const TextStyle(
+ fontWeight: FontWeight.w500,
+ color: Colors.grey,
+ ),
+ ),
+ ),
+ Expanded(
+ child: Text(
+ value,
+ style: const TextStyle(
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ // 입력 필드 위젯
+ Widget buildEditField(String label, TextEditingController controller, {bool enabled = true}) {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ label,
+ style: const TextStyle(
+ fontWeight: FontWeight.w500,
+ fontSize: 14,
+ color: Colors.grey,
+ ),
+ ),
+ const SizedBox(height: 8),
+ TextField(
+ controller: controller,
+ enabled: enabled,
+ decoration: InputDecoration(
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(8),
+ ),
+ contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ // Vue.js 스타일의 폼 필드 메서드
+ Widget buildFormField(String label, Widget field) {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ label,
+ style: const TextStyle(
+ fontSize: 14,
+ fontWeight: FontWeight.w500,
+ color: Colors.grey,
+ ),
+ ),
+ const SizedBox(height: 8),
+ field,
+ ],
+ ),
+ );
+ }
+
+ return AlertDialog(
+ title: Text(isEditing ? '회원 정보 수정' : '회원 정보'),
+ content: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // 프로필 이미지와 현재 상태
+ if (!isEditing) ...[
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ // 프로필 이미지
+ Container(
+ width: 70,
+ height: 70,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: Colors.grey.shade200,
+ image: member.profileImage != null
+ ? DecorationImage(
+ image: NetworkImage(member.profileImage!),
+ fit: BoxFit.cover,
+ )
+ : null,
+ ),
+ child: member.profileImage == null
+ ? Center(child: Text(
+ member.name.isNotEmpty ? member.name.substring(0, 1) : '?',
+ style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.blue.shade700),
+ ))
+ : null,
+ ),
+ const SizedBox(width: 16),
+ // 현재 회원 상태
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Text(
+ member.name,
+ style: const TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(width: 8),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+ decoration: BoxDecoration(
+ color: member.isActive ? Colors.green.shade100 : Colors.red.shade100,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Text(
+ getStatusDisplayName(member.status),
+ style: TextStyle(
+ fontSize: 12,
+ color: member.isActive ? Colors.green.shade800 : Colors.red.shade800,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 4),
+ Text(
+ getMemberTypeDisplayName(member.memberType),
+ style: TextStyle(
+ fontSize: 14,
+ color: Colors.blue.shade700,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ if (member.phone != null && member.phone!.isNotEmpty) ...[
+ const SizedBox(height: 4),
+ Text(
+ member.phone!,
+ style: TextStyle(
+ fontSize: 14,
+ color: Colors.grey.shade700,
+ ),
+ ),
+ ],
+ if (member.email != null && member.email.isNotEmpty) ...[
+ const SizedBox(height: 2),
+ Text(
+ member.email,
+ style: TextStyle(
+ fontSize: 14,
+ color: Colors.grey.shade700,
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ const Divider(),
+ ],
+
+ // 폼 필드
+ Container(
+ width: double.infinity,
+ padding: const EdgeInsets.symmetric(vertical: 8),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // 이름 필드
+ buildFormField('이름', isEditing ?
+ TextField(
+ controller: nameController,
+ decoration: const InputDecoration(
+ border: OutlineInputBorder(),
+ contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ ),
+ ) :
+ Text(member.name, style: const TextStyle(fontSize: 16))
+ ),
+
+ // 성별 필드
+ buildFormField('성별', isEditing ?
+ DropdownButtonFormField(
+ value: selectedGender,
+ decoration: const InputDecoration(
+ border: OutlineInputBorder(),
+ contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ ),
+ items: [
+ DropdownMenuItem(value: 'male', child: Text('남성')),
+ DropdownMenuItem(value: 'female', child: Text('여성')),
+ ],
+ onChanged: isEditing ? (value) {
+ if (value != null) {
+ selectedGender = value;
+ }
+ } : null,
+ ) :
+ Text(getGenderDisplayName(member.gender), style: const TextStyle(fontSize: 16))
+ ),
+
+ // 회원 유형 필드
+ buildFormField('회원 유형', isEditing ?
+ DropdownButtonFormField(
+ value: selectedMemberType,
+ decoration: const InputDecoration(
+ border: OutlineInputBorder(),
+ contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ ),
+ items: [
+ DropdownMenuItem(value: 'regular', child: Text('정회원')),
+ DropdownMenuItem(value: 'associate', child: Text('준회원')),
+ DropdownMenuItem(value: 'guest', child: Text('게스트')),
+ DropdownMenuItem(value: 'manager', child: Text('매니저')),
+ DropdownMenuItem(value: 'owner', child: Text('소유자')),
+ ],
+ onChanged: isEditing ? (value) {
+ if (value != null) {
+ selectedMemberType = value;
+ }
+ } : null,
+ ) :
+ Text(getMemberTypeDisplayName(member.memberType), style: const TextStyle(fontSize: 16))
+ ),
+
+ // 전화번호 필드
+ buildFormField('전화번호', isEditing ?
+ TextField(
+ controller: phoneController,
+ keyboardType: TextInputType.phone,
+ decoration: const InputDecoration(
+ border: OutlineInputBorder(),
+ contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ ),
+ ) :
+ Text(member.phone ?? '정보 없음', style: const TextStyle(fontSize: 16))
+ ),
+
+ // 이메일 필드
+ buildFormField('이메일', isEditing ?
+ TextField(
+ controller: emailController,
+ keyboardType: TextInputType.emailAddress,
+ decoration: const InputDecoration(
+ border: OutlineInputBorder(),
+ contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ ),
+ ) :
+ Text(member.email, style: const TextStyle(fontSize: 16))
+ ),
+
+ // 핸디캡 필드
+ buildFormField('핸디캡', isEditing ?
+ TextField(
+ controller: handicapController,
+ keyboardType: TextInputType.number,
+ decoration: const InputDecoration(
+ border: OutlineInputBorder(),
+ contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ ),
+ ) :
+ Text(member.handicap?.toString() ?? '0', style: const TextStyle(fontSize: 16))
+ ),
+
+ // 상태 필드
+ buildFormField('상태', isEditing ?
+ DropdownButtonFormField(
+ value: selectedStatus,
+ decoration: const InputDecoration(
+ border: OutlineInputBorder(),
+ contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ ),
+ items: [
+ DropdownMenuItem(value: 'active', child: Text('활성')),
+ DropdownMenuItem(value: 'inactive', child: Text('비활성')),
+ DropdownMenuItem(value: 'suspended', child: Text('정지')),
+ ],
+ onChanged: isEditing ? (value) {
+ if (value != null) {
+ selectedStatus = value;
+ }
+ } : null,
+ ) :
+ Text(getStatusDisplayName(member.status), style: const TextStyle(fontSize: 16))
+ ),
+
+ // 가입일 필드 (수정불가)
+ if (!isEditing)
+ buildFormField('가입일', Text(formatDate(member.joinedAt), style: const TextStyle(fontSize: 16))),
+
+ // 시스템 정보 (수정불가)
+ if (!isEditing) ...[
+ const SizedBox(height: 16),
+ const Divider(),
+ const SizedBox(height: 8),
+ const Text('시스템 정보', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
+ const SizedBox(height: 8),
+ buildFormField('회원 ID', Text(member.id, style: const TextStyle(fontSize: 14, fontFamily: 'monospace'))),
+ if (member.userId.isNotEmpty)
+ buildFormField('유저 ID', Text(member.userId, style: const TextStyle(fontSize: 14, fontFamily: 'monospace'))),
+ buildFormField('클럽 ID', Text(member.clubId, style: const TextStyle(fontSize: 14, fontFamily: 'monospace'))),
+ ],
+ ],
+ ),
+ ),
+ ],
+ ),
+ actions: [
+ // 취소 버튼
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('취소'),
+ ),
+ // 수정/저장 버튼
+ TextButton(
+ onPressed: () {
+ if (isEditing) {
+ // 수정 모드에서 저장 버튼 클릭 시
+ if (nameController.text.trim().isEmpty) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('이름은 필수 입력 항목입니다.')),
+ );
+ return;
+ }
+
+ // 회원 정보 업데이트
+ final updatedMember = Member(
+ id: member.id,
+ clubId: member.clubId,
+ userId: member.userId,
+ name: nameController.text.trim(),
+ email: emailController.text.trim(),
+ phone: phoneController.text.trim(),
+ address: member.address, // 주소는 유지
+ profileImage: member.profileImage,
+ role: member.role,
+ memberType: selectedMemberType,
+ gender: selectedGender,
+ status: selectedStatus,
+ handicap: int.tryParse(handicapController.text) ?? member.handicap,
+ isActive: selectedStatus == 'active',
+ joinedAt: member.joinedAt,
+ createdAt: member.createdAt,
+ updatedAt: DateTime.now().toIso8601String(),
+ );
+
+ // 회원 정보 저장
+ context.read().updateMember(updatedMember).then((_) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('회원 정보가 업데이트되었습니다.')),
+ );
+ Navigator.of(context).pop();
+ }).catchError((error) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('오류: ${error.toString()}')),
+ );
+ });
+ } else {
+ // 보기 모드에서 수정 버튼 클릭 시
+ setState(() {
+ isEditing = true;
+ });
+ }
+ },
+ child: Text(isEditing ? '저장' : '수정'),
+ ),
+ ],
+ );
+ },
+ ),
+ );
+ }
+
+ // 회원 삭제 확인 다이얼로그
+ void _showDeleteConfirmationDialog(Member member) {
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('회원 삭제'),
+ content: Text('${member.name} 회원을 정말 삭제하시겠습니까?'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('취소'),
+ ),
+ TextButton(
+ onPressed: () async {
+ try {
+ final memberService = Provider.of(context, listen: false);
+
+ await memberService.deleteMember(member.id);
+
+ if (mounted) {
+ Navigator.of(context).pop();
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('회원이 삭제되었습니다')),
+ );
+ }
+ } catch (e) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('회원 삭제에 실패했습니다: $e')),
+ );
+ }
+ },
+ style: TextButton.styleFrom(foregroundColor: Colors.red),
+ child: const Text('삭제'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ // 역할 표시 이름 변환
+ String _getRoleDisplayName(String role) {
+ switch (role) {
+ case 'owner':
+ return '클럽 소유자';
+ case 'manager':
+ return '클럽 매니저';
+ case 'member':
+ return '클럽 회원';
+ default:
+ return role;
+ }
+ }
+}
diff --git a/mobile/lib/screens/score/club_statistics_screen.dart b/mobile/lib/screens/score/club_statistics_screen.dart
new file mode 100644
index 0000000..f984a8e
--- /dev/null
+++ b/mobile/lib/screens/score/club_statistics_screen.dart
@@ -0,0 +1,501 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:intl/intl.dart';
+import 'package:fl_chart/fl_chart.dart';
+
+import '../../services/auth_service.dart';
+import '../../services/club_service.dart';
+import '../../services/member_service.dart';
+import '../../services/score_service.dart';
+import '../../models/member_model.dart';
+import '../../models/score_model.dart';
+import 'member_statistics_screen.dart';
+import 'score_entry_screen.dart';
+
+class ClubStatisticsScreen extends StatefulWidget {
+ const ClubStatisticsScreen({super.key});
+
+ @override
+ State createState() => _ClubStatisticsScreenState();
+}
+
+class _ClubStatisticsScreenState extends State {
+ bool _isLoading = false;
+ bool _isInit = false;
+
+ Map _clubStatistics = {};
+ List _members = [];
+ Map> _recentScores = {};
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ if (!_isInit) {
+ _loadClubStatistics();
+ _isInit = true;
+ }
+ }
+
+ Future _loadClubStatistics() async {
+ setState(() {
+ _isLoading = true;
+ });
+
+ try {
+ final authService = Provider.of(context, listen: false);
+ final clubService = Provider.of(context, listen: false);
+ final memberService = Provider.of(context, listen: false);
+ final scoreService = Provider.of(context, listen: false);
+
+ if (clubService.currentClub != null) {
+ // 점수 서비스 초기화
+ scoreService.initialize(authService.token!);
+
+ // 회원 목록 로드
+ await memberService.fetchClubMembers();
+ _members = memberService.members;
+
+ // 클럽 통계 로드
+ _clubStatistics = await scoreService.fetchClubStatistics();
+
+ // 각 회원별 최근 점수 로드 (최대 3개)
+ _recentScores = {};
+ for (final member in _members) {
+ try {
+ final scores = await scoreService.fetchMemberScores(member.id);
+ // 날짜 기준 정렬 (최신순)
+ scores.sort((a, b) => b.date.compareTo(a.date));
+ _recentScores[member.id] = scores.take(3).toList();
+ } catch (e) {
+ // 개별 회원 점수 로드 실패 시 빈 배열로 처리
+ _recentScores[member.id] = [];
+ }
+ }
+ }
+ } catch (e) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')));
+ } finally {
+ setState(() {
+ _isLoading = false;
+ });
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(title: const Text('클럽 통계')),
+ body: _isLoading
+ ? const Center(child: CircularProgressIndicator())
+ : RefreshIndicator(
+ onRefresh: _loadClubStatistics,
+ child: SingleChildScrollView(
+ physics: const AlwaysScrollableScrollPhysics(),
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // 클럽 전체 통계
+ _buildClubOverallStats(),
+ const SizedBox(height: 24),
+
+ // 회원별 통계
+ const Text(
+ '회원별 통계',
+ style: TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 16),
+
+ // 회원 목록
+ ..._members.map((member) => _buildMemberStatsCard(member)),
+ ],
+ ),
+ ),
+ ),
+ floatingActionButton: FloatingActionButton(
+ onPressed: () async {
+ await Navigator.of(context).push(
+ MaterialPageRoute(builder: (context) => const ScoreEntryScreen()),
+ );
+ // 화면으로 돌아왔을 때 데이터 새로고침
+ _loadClubStatistics();
+ },
+ backgroundColor: Colors.blue,
+ child: const Icon(Icons.add, color: Colors.white),
+ ),
+ );
+ }
+
+ Widget _buildClubOverallStats() {
+ // 클럽 전체 통계가 없는 경우
+ if (!_clubStatistics.containsKey('overall')) {
+ return const Card(
+ child: Padding(
+ padding: EdgeInsets.all(16.0),
+ child: Center(child: Text('클럽 전체 통계 데이터가 없습니다')),
+ ),
+ );
+ }
+
+ final overallStats = _clubStatistics['overall']!;
+
+ return Card(
+ elevation: 2,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ '클럽 전체 통계',
+ style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 16),
+ Row(
+ children: [
+ Expanded(
+ child: _buildStatItem(
+ label: '클럽 평균',
+ value: overallStats.average.toStringAsFixed(1),
+ icon: Icons.score,
+ color: Colors.blue,
+ ),
+ ),
+ Expanded(
+ child: _buildStatItem(
+ label: '최고 점수',
+ value: overallStats.highScore.toString(),
+ icon: Icons.emoji_events,
+ color: Colors.amber,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ Row(
+ children: [
+ Expanded(
+ child: _buildStatItem(
+ label: '최저 점수',
+ value: overallStats.lowScore.toString(),
+ icon: Icons.arrow_downward,
+ color: Colors.red,
+ ),
+ ),
+ Expanded(
+ child: _buildStatItem(
+ label: '총 게임 수',
+ value: overallStats.gamesPlayed.toString(),
+ icon: Icons.sports,
+ color: Colors.green,
+ ),
+ ),
+ ],
+ ),
+
+ // 클럽 평균 점수 추이 그래프
+ const SizedBox(height: 24),
+ const Text(
+ '클럽 평균 점수 추이',
+ style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 8),
+ SizedBox(height: 180, child: _buildClubAverageChart()),
+
+ // 추가 통계 정보
+ if (overallStats.additionalStats != null) ...[
+ const SizedBox(height: 16),
+ const Divider(),
+ const SizedBox(height: 8),
+ ...overallStats.additionalStats!.entries.map((entry) {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 8.0),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(entry.key, style: const TextStyle(fontSize: 14)),
+ Text(
+ entry.value.toString(),
+ style: const TextStyle(
+ fontSize: 14,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ],
+ ),
+ );
+ }),
+ ],
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildStatItem({
+ required String label,
+ required String value,
+ required IconData icon,
+ required Color color,
+ }) {
+ return Column(
+ children: [
+ Icon(icon, size: 28, color: color),
+ const SizedBox(height: 8),
+ Text(
+ value,
+ style: TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.bold,
+ color: color,
+ ),
+ ),
+ Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
+ ],
+ );
+ }
+
+ Widget _buildMemberStatsCard(Member member) {
+ // 회원 통계 정보
+ final memberStats = _clubStatistics[member.id];
+ // 회원 최근 점수
+ final recentScores = _recentScores[member.id] ?? [];
+
+ return Card(
+ margin: const EdgeInsets.only(bottom: 12),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ child: InkWell(
+ onTap: () {
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (context) => MemberStatisticsScreen(memberId: member.id),
+ ),
+ );
+ },
+ borderRadius: BorderRadius.circular(12),
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // 회원 정보
+ Row(
+ children: [
+ CircleAvatar(
+ radius: 24,
+ backgroundColor: Colors.blue.shade100,
+ backgroundImage: member.profileImage != null
+ ? NetworkImage(member.profileImage!)
+ : null,
+ child: member.profileImage == null
+ ? Text(
+ member.name.isNotEmpty
+ ? member.name[0].toUpperCase()
+ : '?',
+ style: const TextStyle(
+ fontSize: 20,
+ color: Colors.blue,
+ fontWeight: FontWeight.bold,
+ ),
+ )
+ : null,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ member.name,
+ style: const TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ if (memberStats != null)
+ Text(
+ '평균: ${memberStats.average.toStringAsFixed(1)} | 최고: ${memberStats.highScore} | 게임: ${memberStats.gamesPlayed}',
+ style: TextStyle(
+ fontSize: 12,
+ color: Colors.grey[600],
+ ),
+ ),
+ ],
+ ),
+ ),
+ const Icon(Icons.chevron_right),
+ ],
+ ),
+
+ // 최근 점수 표시
+ if (recentScores.isNotEmpty) ...[
+ const SizedBox(height: 12),
+ const Divider(),
+ const SizedBox(height: 8),
+ const Text(
+ '최근 점수',
+ style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 8),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceAround,
+ children: recentScores.map((score) {
+ return Column(
+ children: [
+ Text(
+ score.totalScore.toString(),
+ style: const TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.bold,
+ color: Colors.blue,
+ ),
+ ),
+ Text(
+ DateFormat('MM/dd').format(score.date),
+ style: TextStyle(
+ fontSize: 10,
+ color: Colors.grey[600],
+ ),
+ ),
+ ],
+ );
+ }).toList(),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildClubAverageChart() {
+ // 클럽 통계가 없는 경우
+ if (!_clubStatistics.containsKey('overall')) {
+ return const Center(child: Text('통계 데이터가 없습니다'));
+ }
+
+ // 회원별 평균 점수 데이터 추출
+ final memberAverages = {};
+
+ // 회원별 통계 정보에서 평균 점수 추출
+ _clubStatistics.forEach((key, stats) {
+ if (key != 'overall') {
+ // 회원 ID에 해당하는 회원 찾기
+ final member = _members.firstWhere(
+ (m) => m.id == key,
+ orElse: () => Member(
+ id: key,
+ userId: key,
+ name: 'Unknown',
+ email: '',
+ clubId: '',
+ isActive: true,
+ ),
+ );
+ memberAverages[member.name] = stats.average;
+ }
+ });
+
+ // 평균 점수 기준으로 정렬 (내림차순)
+ final sortedEntries = memberAverages.entries.toList()
+ ..sort((a, b) => b.value.compareTo(a.value));
+
+ // 최대 8명만 표시
+ final displayEntries = sortedEntries.length > 8
+ ? sortedEntries.sublist(0, 8)
+ : sortedEntries;
+
+ // 그래프 데이터 생성
+ final barGroups = [];
+ for (int i = 0; i < displayEntries.length; i++) {
+ barGroups.add(
+ BarChartGroupData(
+ x: i,
+ barRods: [
+ BarChartRodData(
+ toY: displayEntries[i].value,
+ color: Colors.blue,
+ width: 16,
+ borderRadius: const BorderRadius.only(
+ topLeft: Radius.circular(4),
+ topRight: Radius.circular(4),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ // 최소/최대 점수 계산 (Y축 범위 설정용)
+ final minScore = displayEntries.isEmpty
+ ? 0.0
+ : displayEntries.map((e) => e.value).reduce((a, b) => a < b ? a : b) -
+ 10;
+ final maxScore = displayEntries.isEmpty
+ ? 300.0
+ : displayEntries.map((e) => e.value).reduce((a, b) => a > b ? a : b) +
+ 10;
+
+ return BarChart(
+ BarChartData(
+ alignment: BarChartAlignment.spaceAround,
+ maxY: maxScore,
+ minY: minScore < 0 ? 0 : minScore,
+ gridData: const FlGridData(show: true, horizontalInterval: 50),
+ borderData: FlBorderData(
+ show: true,
+ border: Border.all(color: Colors.grey.shade300),
+ ),
+ titlesData: FlTitlesData(
+ show: true,
+ rightTitles: const AxisTitles(
+ sideTitles: SideTitles(showTitles: false),
+ ),
+ topTitles: const AxisTitles(
+ sideTitles: SideTitles(showTitles: false),
+ ),
+ bottomTitles: AxisTitles(
+ sideTitles: SideTitles(
+ showTitles: true,
+ getTitlesWidget: (value, meta) {
+ final index = value.toInt();
+ if (index >= 0 && index < displayEntries.length) {
+ // 회원 이름 표시 (최대 5글자)
+ final name = displayEntries[index].key;
+ final shortName = name.length > 5
+ ? '${name.substring(0, 4)}...'
+ : name;
+
+ return Text(shortName, style: const TextStyle(fontSize: 10));
+ }
+ return const SizedBox();
+ },
+ reservedSize: 30,
+ ),
+ ),
+ leftTitles: AxisTitles(
+ sideTitles: SideTitles(
+ showTitles: true,
+ interval: 50,
+ getTitlesWidget: (value, meta) {
+ return Text(
+ value.toInt().toString(),
+ style: const TextStyle(fontSize: 10),
+ );
+ },
+ reservedSize: 40,
+ ),
+ ),
+ ),
+ barGroups: barGroups,
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/score/member_statistics_screen.dart b/mobile/lib/screens/score/member_statistics_screen.dart
new file mode 100644
index 0000000..edefc07
--- /dev/null
+++ b/mobile/lib/screens/score/member_statistics_screen.dart
@@ -0,0 +1,662 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:intl/intl.dart';
+import 'package:fl_chart/fl_chart.dart';
+
+import '../../services/auth_service.dart';
+import '../../services/club_service.dart';
+import '../../services/member_service.dart';
+import '../../services/score_service.dart';
+import '../../models/member_model.dart';
+import '../../models/score_model.dart';
+import 'score_entry_screen.dart';
+
+class MemberStatisticsScreen extends StatefulWidget {
+ final String memberId;
+
+ const MemberStatisticsScreen({super.key, required this.memberId});
+
+ @override
+ State createState() => _MemberStatisticsScreenState();
+}
+
+class _MemberStatisticsScreenState extends State
+ with SingleTickerProviderStateMixin {
+ bool _isLoading = false;
+ bool _isInit = false;
+ late TabController _tabController;
+
+ Member? _member;
+ List _scores = [];
+ ScoreStatistics? _statistics;
+
+ @override
+ void initState() {
+ super.initState();
+ _tabController = TabController(length: 2, vsync: this);
+ }
+
+ @override
+ void dispose() {
+ _tabController.dispose();
+ super.dispose();
+ }
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ if (!_isInit) {
+ _loadMemberData();
+ _isInit = true;
+ }
+ }
+
+ Future _loadMemberData() async {
+ setState(() {
+ _isLoading = true;
+ });
+
+ try {
+ final authService = Provider.of(context, listen: false);
+ final clubService = Provider.of(context, listen: false);
+ final memberService = Provider.of(context, listen: false);
+ final scoreService = Provider.of(context, listen: false);
+
+ if (clubService.currentClub != null) {
+ // 회원 정보 로드
+ _member = await memberService.fetchMemberById(widget.memberId);
+
+ // 점수 서비스 초기화
+ scoreService.initialize(authService.token!);
+
+ // 회원 점수 및 통계 로드
+ await Future.wait([
+ scoreService.fetchMemberScores(widget.memberId).then((scores) {
+ _scores = scores;
+ }),
+ scoreService.fetchMemberStatistics(widget.memberId).then((stats) {
+ _statistics = stats;
+ }),
+ ]);
+ }
+ } catch (e) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')));
+ } finally {
+ setState(() {
+ _isLoading = false;
+ });
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: Text(_member?.name ?? '회원 통계'),
+ bottom: TabBar(
+ controller: _tabController,
+ tabs: const [
+ Tab(text: '통계'),
+ Tab(text: '점수 기록'),
+ ],
+ ),
+ ),
+ body: _isLoading
+ ? const Center(child: CircularProgressIndicator())
+ : TabBarView(
+ controller: _tabController,
+ children: [_buildStatisticsTab(), _buildScoresTab()],
+ ),
+ floatingActionButton: FloatingActionButton(
+ onPressed: () async {
+ await Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (context) => ScoreEntryScreen(memberId: widget.memberId),
+ ),
+ );
+ // 화면으로 돌아왔을 때 데이터 새로고침
+ _loadMemberData();
+ },
+ backgroundColor: Colors.blue,
+ child: const Icon(Icons.add, color: Colors.white),
+ ),
+ );
+ }
+
+ Widget _buildStatisticsTab() {
+ if (_statistics == null) {
+ return const Center(child: Text('통계 데이터가 없습니다'));
+ }
+
+ return RefreshIndicator(
+ onRefresh: _loadMemberData,
+ child: SingleChildScrollView(
+ physics: const AlwaysScrollableScrollPhysics(),
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // 회원 정보 카드
+ if (_member != null) _buildMemberInfoCard(),
+ const SizedBox(height: 24),
+
+ // 주요 통계 카드
+ _buildMainStatsCard(),
+ const SizedBox(height: 24),
+
+ // 추가 통계 정보
+ if (_statistics!.additionalStats != null)
+ _buildAdditionalStatsCard(),
+
+ // 통계 그래프
+ const SizedBox(height: 24),
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ '점수 추이',
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 16),
+ SizedBox(height: 200, child: _buildScoreChart()),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildMemberInfoCard() {
+ return Card(
+ elevation: 2,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Row(
+ children: [
+ CircleAvatar(
+ radius: 30,
+ backgroundColor: Colors.blue.shade100,
+ backgroundImage: _member!.profileImage != null
+ ? NetworkImage(_member!.profileImage!)
+ : null,
+ child: _member!.profileImage == null
+ ? Text(
+ _member!.name.isNotEmpty
+ ? _member!.name[0].toUpperCase()
+ : '?',
+ style: const TextStyle(
+ fontSize: 24,
+ color: Colors.blue,
+ fontWeight: FontWeight.bold,
+ ),
+ )
+ : null,
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ _member!.name,
+ style: const TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ _member!.email,
+ style: TextStyle(color: Colors.grey[600]),
+ ),
+ if (_member!.phone != null)
+ Text(
+ _member!.phone!,
+ style: TextStyle(color: Colors.grey[600]),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildMainStatsCard() {
+ return Card(
+ elevation: 2,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ '주요 통계',
+ style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 16),
+ Row(
+ children: [
+ Expanded(
+ child: _buildStatItem(
+ label: '평균 점수',
+ value: _statistics!.average.toStringAsFixed(1),
+ icon: Icons.score,
+ color: Colors.blue,
+ ),
+ ),
+ Expanded(
+ child: _buildStatItem(
+ label: '최고 점수',
+ value: _statistics!.highScore.toString(),
+ icon: Icons.emoji_events,
+ color: Colors.amber,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ Row(
+ children: [
+ Expanded(
+ child: _buildStatItem(
+ label: '최저 점수',
+ value: _statistics!.lowScore.toString(),
+ icon: Icons.arrow_downward,
+ color: Colors.red,
+ ),
+ ),
+ Expanded(
+ child: _buildStatItem(
+ label: '게임 수',
+ value: _statistics!.gamesPlayed.toString(),
+ icon: Icons.sports,
+ color: Colors.green,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildStatItem({
+ required String label,
+ required String value,
+ required IconData icon,
+ required Color color,
+ }) {
+ return Column(
+ children: [
+ Icon(icon, size: 32, color: color),
+ const SizedBox(height: 8),
+ Text(
+ value,
+ style: TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.bold,
+ color: color,
+ ),
+ ),
+ Text(label, style: TextStyle(fontSize: 14, color: Colors.grey[600])),
+ ],
+ );
+ }
+
+ Widget _buildAdditionalStatsCard() {
+ final additionalStats = _statistics!.additionalStats!;
+
+ return Card(
+ elevation: 2,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ '추가 통계',
+ style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 16),
+ ...additionalStats.entries.map((entry) {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 8.0),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(entry.key, style: const TextStyle(fontSize: 16)),
+ Text(
+ entry.value.toString(),
+ style: const TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ],
+ ),
+ );
+ }),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildScoresTab() {
+ if (_scores.isEmpty) {
+ return const Center(child: Text('기록된 점수가 없습니다'));
+ }
+
+ // 날짜 기준으로 정렬 (최신순)
+ final sortedScores = [..._scores];
+ sortedScores.sort((a, b) => b.date.compareTo(a.date));
+
+ return RefreshIndicator(
+ onRefresh: _loadMemberData,
+ child: ListView.builder(
+ padding: const EdgeInsets.all(8.0),
+ itemCount: sortedScores.length,
+ itemBuilder: (context, index) {
+ final score = sortedScores[index];
+ return _buildScoreCard(score);
+ },
+ ),
+ );
+ }
+
+ Widget _buildScoreCard(Score score) {
+ final dateFormat = DateFormat('yyyy년 MM월 dd일');
+
+ return Card(
+ margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ child: ExpansionTile(
+ title: Row(
+ children: [
+ Text(
+ score.totalScore.toString(),
+ style: const TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.bold,
+ color: Colors.blue,
+ ),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ dateFormat.format(score.date),
+ style: const TextStyle(fontSize: 14),
+ ),
+ if (score.notes != null && score.notes!.isNotEmpty)
+ Text(
+ score.notes!,
+ style: TextStyle(fontSize: 12, color: Colors.grey[600]),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ children: [
+ Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ '프레임 점수',
+ style: TextStyle(fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 8),
+ _buildFrameScores(score.frames),
+ if (score.notes != null && score.notes!.isNotEmpty) ...[
+ const SizedBox(height: 16),
+ const Text(
+ '메모',
+ style: TextStyle(fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 4),
+ Text(score.notes!),
+ ],
+ const SizedBox(height: 16),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ TextButton.icon(
+ icon: const Icon(Icons.edit, size: 16),
+ label: const Text('수정'),
+ onPressed: () {
+ // 점수 수정 기능 (추후 구현)
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('점수 수정 기능은 아직 개발 중입니다')),
+ );
+ },
+ ),
+ const SizedBox(width: 8),
+ TextButton.icon(
+ icon: const Icon(
+ Icons.delete,
+ size: 16,
+ color: Colors.red,
+ ),
+ label: const Text(
+ '삭제',
+ style: TextStyle(color: Colors.red),
+ ),
+ onPressed: () {
+ _showDeleteConfirmationDialog(score);
+ },
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildFrameScores(List frames) {
+ return Container(
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: Colors.grey[100],
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Row(
+ children: List.generate(10, (index) {
+ return Expanded(
+ child: Container(
+ padding: const EdgeInsets.symmetric(vertical: 8),
+ decoration: BoxDecoration(
+ border: Border.all(color: Colors.grey[300]!),
+ ),
+ child: Column(
+ children: [
+ Text(
+ '${index + 1}',
+ style: TextStyle(fontSize: 10, color: Colors.grey[600]),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ index < frames.length ? frames[index].toString() : '-',
+ style: const TextStyle(fontWeight: FontWeight.bold),
+ textAlign: TextAlign.center,
+ ),
+ ],
+ ),
+ ),
+ );
+ }),
+ ),
+ );
+ }
+
+ Widget _buildScoreChart() {
+ if (_scores.isEmpty) {
+ return const Center(child: Text('점수 데이터가 없습니다'));
+ }
+
+ // 날짜 기준으로 정렬 (오래된 순)
+ final sortedScores = [..._scores];
+ sortedScores.sort((a, b) => a.date.compareTo(b.date));
+
+ // 최대 10개의 최근 점수만 표시
+ final displayScores = sortedScores.length > 10
+ ? sortedScores.sublist(sortedScores.length - 10)
+ : sortedScores;
+
+ // 최소/최대 점수 계산 (Y축 범위 설정용)
+ final minScore =
+ displayScores.map((s) => s.totalScore).reduce((a, b) => a < b ? a : b) -
+ 10;
+ final maxScore =
+ displayScores.map((s) => s.totalScore).reduce((a, b) => a > b ? a : b) +
+ 10;
+
+ // 그래프 데이터 포인트 생성
+ final spots = [];
+ for (int i = 0; i < displayScores.length; i++) {
+ spots.add(FlSpot(i.toDouble(), displayScores[i].totalScore.toDouble()));
+ }
+
+ return LineChart(
+ LineChartData(
+ gridData: FlGridData(
+ show: true,
+ drawVerticalLine: true,
+ horizontalInterval: 20,
+ verticalInterval: 1,
+ ),
+ titlesData: FlTitlesData(
+ show: true,
+ rightTitles: const AxisTitles(
+ sideTitles: SideTitles(showTitles: false),
+ ),
+ topTitles: const AxisTitles(
+ sideTitles: SideTitles(showTitles: false),
+ ),
+ bottomTitles: AxisTitles(
+ sideTitles: SideTitles(
+ showTitles: true,
+ reservedSize: 30,
+ interval: 1,
+ getTitlesWidget: (value, meta) {
+ final index = value.toInt();
+ if (index >= 0 && index < displayScores.length) {
+ return Text(
+ DateFormat('MM/dd').format(displayScores[index].date),
+ style: const TextStyle(fontSize: 10),
+ );
+ }
+ return const SizedBox();
+ },
+ ),
+ ),
+ leftTitles: AxisTitles(
+ sideTitles: SideTitles(
+ showTitles: true,
+ interval: 50,
+ getTitlesWidget: (value, meta) {
+ return Text(
+ value.toInt().toString(),
+ style: const TextStyle(fontSize: 10),
+ );
+ },
+ reservedSize: 40,
+ ),
+ ),
+ ),
+ borderData: FlBorderData(
+ show: true,
+ border: Border.all(color: Colors.grey.shade300),
+ ),
+ minX: 0,
+ maxX: displayScores.length - 1.0,
+ minY: minScore.toDouble(),
+ maxY: maxScore.toDouble(),
+ lineBarsData: [
+ LineChartBarData(
+ spots: spots,
+ isCurved: false,
+ color: Colors.blue,
+ barWidth: 3,
+ isStrokeCapRound: true,
+ dotData: const FlDotData(show: true),
+ belowBarData: BarAreaData(
+ show: true,
+ color: Colors.blue.withOpacity(0.2),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ void _showDeleteConfirmationDialog(Score score) {
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('점수 삭제'),
+ content: const Text('이 점수 기록을 정말 삭제하시겠습니까?'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('취소'),
+ ),
+ TextButton(
+ onPressed: () async {
+ Navigator.of(context).pop();
+
+ try {
+ final scoreService = Provider.of(
+ context,
+ listen: false,
+ );
+ await scoreService.deleteScore(score.id);
+
+ // 데이터 새로고침
+ _loadMemberData();
+
+ if (mounted) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(const SnackBar(content: Text('점수가 삭제되었습니다')));
+ }
+ } catch (e) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text('점수 삭제에 실패했습니다: $e')));
+ }
+ },
+ style: TextButton.styleFrom(foregroundColor: Colors.red),
+ child: const Text('삭제'),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/score/score_entry_screen.dart b/mobile/lib/screens/score/score_entry_screen.dart
new file mode 100644
index 0000000..5ebbceb
--- /dev/null
+++ b/mobile/lib/screens/score/score_entry_screen.dart
@@ -0,0 +1,378 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:intl/intl.dart';
+
+import '../../services/club_service.dart';
+import '../../services/member_service.dart';
+import '../../services/event_service.dart';
+import '../../services/score_service.dart';
+
+class ScoreEntryScreen extends StatefulWidget {
+ final String? memberId;
+ final String? eventId;
+
+ const ScoreEntryScreen({
+ super.key,
+ this.memberId,
+ this.eventId,
+ });
+
+ @override
+ State createState() => _ScoreEntryScreenState();
+}
+
+class _ScoreEntryScreenState extends State {
+ final _formKey = GlobalKey();
+ bool _isLoading = false;
+
+ // 점수 입력 관련 상태
+ final List _frameControllers = List.generate(
+ 10,
+ (_) => TextEditingController(),
+ );
+ final TextEditingController _notesController = TextEditingController();
+
+ // 선택된 회원 및 이벤트
+ String? _selectedMemberId;
+ String? _selectedEventId;
+ DateTime _selectedDate = DateTime.now();
+
+ @override
+ void initState() {
+ super.initState();
+ _selectedMemberId = widget.memberId;
+ _selectedEventId = widget.eventId;
+ }
+
+ @override
+ void dispose() {
+ for (var controller in _frameControllers) {
+ controller.dispose();
+ }
+ _notesController.dispose();
+ super.dispose();
+ }
+
+ // 총점 계산
+ int _calculateTotalScore() {
+ int total = 0;
+ for (var controller in _frameControllers) {
+ final value = int.tryParse(controller.text) ?? 0;
+ total += value;
+ }
+ return total;
+ }
+
+ // 점수 저장
+ Future _saveScore() async {
+ if (!_formKey.currentState!.validate()) {
+ return;
+ }
+
+ if (_selectedMemberId == null) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('회원을 선택해주세요')),
+ );
+ return;
+ }
+
+ setState(() {
+ _isLoading = true;
+ });
+
+ try {
+ final scoreService = Provider.of(context, listen: false);
+ final clubService = Provider.of(context, listen: false);
+
+ if (clubService.currentClub != null) {
+ // 프레임 점수 배열 생성
+ final frames = _frameControllers
+ .map((controller) => int.tryParse(controller.text) ?? 0)
+ .toList();
+
+ // 총점 계산
+ final totalScore = _calculateTotalScore();
+
+ // 점수 데이터 생성
+ final scoreData = {
+ 'memberId': _selectedMemberId,
+ 'eventId': _selectedEventId,
+ 'clubId': clubService.currentClub!.id,
+ 'frames': frames,
+ 'totalScore': totalScore,
+ 'date': _selectedDate.toIso8601String(),
+ 'notes': _notesController.text.isNotEmpty ? _notesController.text : null,
+ };
+
+ // 점수 저장
+ await scoreService.addScore(scoreData);
+
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('점수가 저장되었습니다')),
+ );
+ Navigator.of(context).pop();
+ }
+ }
+ } catch (e) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('점수 저장에 실패했습니다: $e')),
+ );
+ } finally {
+ setState(() {
+ _isLoading = false;
+ });
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('점수 입력'),
+ ),
+ body: _isLoading
+ ? const Center(child: CircularProgressIndicator())
+ : Form(
+ key: _formKey,
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // 회원 선택
+ _buildMemberSelector(),
+ const SizedBox(height: 16),
+
+ // 이벤트 선택
+ _buildEventSelector(),
+ const SizedBox(height: 16),
+
+ // 날짜 선택
+ _buildDateSelector(),
+ const SizedBox(height: 24),
+
+ // 프레임 점수 입력
+ _buildFrameInputs(),
+ const SizedBox(height: 24),
+
+ // 총점 표시
+ _buildTotalScore(),
+ const SizedBox(height: 16),
+
+ // 메모 입력
+ TextFormField(
+ controller: _notesController,
+ decoration: const InputDecoration(
+ labelText: '메모',
+ border: OutlineInputBorder(),
+ ),
+ maxLines: 3,
+ ),
+ const SizedBox(height: 24),
+
+ // 저장 버튼
+ SizedBox(
+ width: double.infinity,
+ height: 50,
+ child: ElevatedButton(
+ onPressed: _saveScore,
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.blue,
+ foregroundColor: Colors.white,
+ ),
+ child: const Text('점수 저장'),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildMemberSelector() {
+ return Consumer(
+ builder: (context, memberService, _) {
+ final members = memberService.members;
+
+ return DropdownButtonFormField(
+ decoration: const InputDecoration(
+ labelText: '회원 선택',
+ border: OutlineInputBorder(),
+ ),
+ value: _selectedMemberId,
+ items: members.map((member) {
+ return DropdownMenuItem(
+ value: member.id,
+ child: Text(member.name),
+ );
+ }).toList(),
+ onChanged: (value) {
+ setState(() {
+ _selectedMemberId = value;
+ });
+ },
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return '회원을 선택해주세요';
+ }
+ return null;
+ },
+ );
+ },
+ );
+ }
+
+ Widget _buildEventSelector() {
+ return Consumer(
+ builder: (context, eventService, _) {
+ final events = eventService.events;
+
+ return DropdownButtonFormField(
+ decoration: const InputDecoration(
+ labelText: '이벤트 선택 (선택사항)',
+ border: OutlineInputBorder(),
+ ),
+ value: _selectedEventId,
+ items: [
+ const DropdownMenuItem(
+ value: null,
+ child: Text('이벤트 없음'),
+ ),
+ ...events.map((event) {
+ return DropdownMenuItem(
+ value: event.id,
+ child: Text(event.title),
+ );
+ }),
+ ],
+ onChanged: (value) {
+ setState(() {
+ _selectedEventId = value;
+ });
+ },
+ );
+ },
+ );
+ }
+
+ Widget _buildDateSelector() {
+ return InkWell(
+ onTap: () async {
+ final pickedDate = await showDatePicker(
+ context: context,
+ initialDate: _selectedDate,
+ firstDate: DateTime.now().subtract(const Duration(days: 365)),
+ lastDate: DateTime.now(),
+ );
+
+ if (pickedDate != null) {
+ setState(() {
+ _selectedDate = pickedDate;
+ });
+ }
+ },
+ child: InputDecorator(
+ decoration: const InputDecoration(
+ labelText: '날짜',
+ border: OutlineInputBorder(),
+ ),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(DateFormat('yyyy년 MM월 dd일').format(_selectedDate)),
+ const Icon(Icons.calendar_today),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildFrameInputs() {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ '프레임 점수',
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 8),
+ GridView.builder(
+ shrinkWrap: true,
+ physics: const NeverScrollableScrollPhysics(),
+ gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: 5,
+ childAspectRatio: 1.5,
+ crossAxisSpacing: 8,
+ mainAxisSpacing: 8,
+ ),
+ itemCount: 10,
+ itemBuilder: (context, index) {
+ return TextFormField(
+ controller: _frameControllers[index],
+ keyboardType: TextInputType.number,
+ textAlign: TextAlign.center,
+ decoration: InputDecoration(
+ labelText: '${index + 1}프레임',
+ border: const OutlineInputBorder(),
+ ),
+ onChanged: (_) {
+ setState(() {});
+ },
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return '입력';
+ }
+ final score = int.tryParse(value);
+ if (score == null) {
+ return '숫자';
+ }
+ if (score < 0 || score > 30) {
+ return '0-30';
+ }
+ return null;
+ },
+ );
+ },
+ ),
+ ],
+ );
+ }
+
+ Widget _buildTotalScore() {
+ final totalScore = _calculateTotalScore();
+
+ return Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: Colors.blue.shade50,
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(color: Colors.blue.shade200),
+ ),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ const Text(
+ '총점',
+ style: TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ Text(
+ '$totalScore',
+ style: const TextStyle(
+ fontSize: 24,
+ fontWeight: FontWeight.bold,
+ color: Colors.blue,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/subscription/subscription_details_screen.dart b/mobile/lib/screens/subscription/subscription_details_screen.dart
new file mode 100644
index 0000000..b274b77
--- /dev/null
+++ b/mobile/lib/screens/subscription/subscription_details_screen.dart
@@ -0,0 +1,393 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../models/subscription_model.dart';
+import '../../services/subscription_service.dart';
+
+class SubscriptionDetailsScreen extends StatefulWidget {
+ final SubscriptionPlan plan;
+ final bool isYearly;
+
+ const SubscriptionDetailsScreen({
+ super.key,
+ required this.plan,
+ required this.isYearly,
+ });
+
+ @override
+ State createState() => _SubscriptionDetailsScreenState();
+}
+
+class _SubscriptionDetailsScreenState extends State {
+ bool _isLoading = false;
+
+ @override
+ Widget build(BuildContext context) {
+ final price = widget.isYearly ? widget.plan.yearlyPrice : widget.plan.monthlyPrice;
+ final period = widget.isYearly ? '년' : '월';
+
+ return Scaffold(
+ appBar: AppBar(
+ title: Text('${widget.plan.name} 플랜'),
+ ),
+ body: Stack(
+ children: [
+ SingleChildScrollView(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // 플랜 헤더
+ _buildPlanHeader(price, period),
+
+ const SizedBox(height: 24),
+
+ // 플랜 설명
+ Text(
+ widget.plan.description,
+ style: const TextStyle(fontSize: 16),
+ ),
+
+ const SizedBox(height: 24),
+
+ // 플랜 혜택
+ const Text(
+ '플랜 혜택',
+ style: TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 16),
+ ...widget.plan.features.map((feature) => _buildFeatureItem(feature)),
+
+ const SizedBox(height: 24),
+
+ // 플랜 세부 정보
+ const Text(
+ '세부 정보',
+ style: TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 16),
+ _buildDetailItem('최대 회원 수', '${widget.plan.maxMembers}명'),
+ _buildDetailItem('최대 이벤트 수', '${widget.plan.maxEvents}개'),
+ _buildDetailItem(
+ '고급 통계 기능',
+ widget.plan.hasAdvancedStats ? '제공' : '미제공',
+ isAvailable: widget.plan.hasAdvancedStats,
+ ),
+ _buildDetailItem(
+ '맞춤형 기능',
+ widget.plan.hasCustomization ? '제공' : '미제공',
+ isAvailable: widget.plan.hasCustomization,
+ ),
+ _buildDetailItem(
+ '우선 지원',
+ widget.plan.hasPriority ? '제공' : '미제공',
+ isAvailable: widget.plan.hasPriority,
+ ),
+
+ const SizedBox(height: 24),
+
+ // 결제 정보
+ const Text(
+ '결제 정보',
+ style: TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 16),
+ _buildDetailItem(
+ '결제 주기',
+ widget.isYearly ? '연간' : '월간',
+ ),
+ _buildDetailItem(
+ '결제 금액',
+ '₩${_formatPrice(price)}/${widget.isYearly ? '년' : '월'}',
+ ),
+ if (widget.isYearly)
+ _buildDetailItem(
+ '월 환산 금액',
+ '₩${_formatPrice(widget.plan.yearlyPrice / 12)}/월',
+ ),
+ _buildDetailItem(
+ '자동 갱신',
+ '활성화 (설정에서 변경 가능)',
+ ),
+
+ // 하단 여백 (버튼 높이만큼)
+ const SizedBox(height: 80),
+ ],
+ ),
+ ),
+
+ // 하단 구독 버튼
+ Positioned(
+ left: 0,
+ right: 0,
+ bottom: 0,
+ child: Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.1),
+ blurRadius: 10,
+ offset: const Offset(0, -5),
+ ),
+ ],
+ ),
+ child: ElevatedButton(
+ onPressed: _isLoading ? null : () => _subscribe(context),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.blue,
+ foregroundColor: Colors.white,
+ minimumSize: const Size(double.infinity, 50),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ ),
+ child: _isLoading
+ ? const CircularProgressIndicator(color: Colors.white)
+ : Text(
+ widget.plan.type == SubscriptionPlanType.free
+ ? '무료 플랜 시작하기'
+ : '구독하기',
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildPlanHeader(double price, String period) {
+ return Card(
+ elevation: 4,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(16),
+ side: BorderSide(
+ color: _getPlanColor(),
+ width: 2,
+ ),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ children: [
+ Row(
+ children: [
+ Icon(
+ _getPlanIcon(),
+ color: _getPlanColor(),
+ size: 32,
+ ),
+ const SizedBox(width: 16),
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ widget.plan.name,
+ style: const TextStyle(
+ fontSize: 24,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ if (widget.plan.type == SubscriptionPlanType.premium)
+ Container(
+ margin: const EdgeInsets.only(top: 4),
+ padding: const EdgeInsets.symmetric(
+ horizontal: 8,
+ vertical: 2,
+ ),
+ decoration: BoxDecoration(
+ color: Colors.amber.withOpacity(0.1),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: Colors.amber),
+ ),
+ child: const Text(
+ '인기 플랜',
+ style: TextStyle(
+ color: Colors.amber,
+ fontSize: 12,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ const Divider(),
+ const SizedBox(height: 16),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ Text(
+ '₩${_formatPrice(price)}',
+ style: const TextStyle(
+ fontSize: 32,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(width: 4),
+ Padding(
+ padding: const EdgeInsets.only(bottom: 4),
+ child: Text(
+ '/$period',
+ style: const TextStyle(fontSize: 16),
+ ),
+ ),
+ ],
+ ),
+ if (widget.isYearly)
+ Padding(
+ padding: const EdgeInsets.only(top: 8),
+ child: Text(
+ '월 ₩${_formatPrice(widget.plan.yearlyPrice / 12)} (20% 할인)',
+ style: TextStyle(
+ color: Colors.green[700],
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildFeatureItem(String feature) {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 12),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Icon(Icons.check_circle, color: Colors.green, size: 20),
+ const SizedBox(width: 12),
+ Expanded(child: Text(feature, style: const TextStyle(fontSize: 16))),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildDetailItem(String label, String value, {bool isAvailable = true}) {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 12),
+ child: Row(
+ children: [
+ Expanded(
+ flex: 2,
+ child: Text(
+ label,
+ style: const TextStyle(
+ fontWeight: FontWeight.bold,
+ fontSize: 16,
+ ),
+ ),
+ ),
+ Expanded(
+ flex: 3,
+ child: Text(
+ value,
+ style: TextStyle(
+ fontSize: 16,
+ color: isAvailable ? null : Colors.grey,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Color _getPlanColor() {
+ switch (widget.plan.type) {
+ case SubscriptionPlanType.free:
+ return Colors.grey;
+ case SubscriptionPlanType.basic:
+ return Colors.blue;
+ case SubscriptionPlanType.premium:
+ return Colors.amber;
+ case SubscriptionPlanType.enterprise:
+ return Colors.purple;
+ }
+ }
+
+ IconData _getPlanIcon() {
+ switch (widget.plan.type) {
+ case SubscriptionPlanType.free:
+ return Icons.star_border;
+ case SubscriptionPlanType.basic:
+ return Icons.star_half;
+ case SubscriptionPlanType.premium:
+ return Icons.star;
+ case SubscriptionPlanType.enterprise:
+ return Icons.workspace_premium;
+ }
+ }
+
+ String _formatPrice(double price) {
+ if (price == 0) {
+ return '0';
+ }
+
+ // 천 단위 콤마 추가
+ final priceInt = price.toInt();
+ return priceInt.toString().replaceAllMapped(
+ RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
+ (Match m) => '${m[1]},',
+ );
+ }
+
+ Future _subscribe(BuildContext context) async {
+ setState(() {
+ _isLoading = true;
+ });
+
+ try {
+ final subscriptionService = Provider.of(context, listen: false);
+ final success = await subscriptionService.createSubscription(
+ widget.plan.type,
+ widget.isYearly,
+ );
+
+ if (success && mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('구독이 성공적으로 완료되었습니다')),
+ );
+ Navigator.of(context).pop(); // 상세 화면 닫기
+ } else if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(subscriptionService.error ?? '구독 처리 중 오류가 발생했습니다'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('오류: $e'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ } finally {
+ if (mounted) {
+ setState(() {
+ _isLoading = false;
+ });
+ }
+ }
+ }
+}
diff --git a/mobile/lib/screens/subscription/subscription_screen.dart b/mobile/lib/screens/subscription/subscription_screen.dart
new file mode 100644
index 0000000..433d923
--- /dev/null
+++ b/mobile/lib/screens/subscription/subscription_screen.dart
@@ -0,0 +1,486 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../models/subscription_model.dart';
+import '../../services/subscription_service.dart';
+import 'subscription_details_screen.dart';
+
+class SubscriptionScreen extends StatefulWidget {
+ const SubscriptionScreen({super.key});
+
+ @override
+ State