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 createState() => _SubscriptionScreenState(); +} + +class _SubscriptionScreenState extends State { + bool _isYearly = true; // 기본값은 연간 구독 + + @override + void initState() { + super.initState(); + // 화면 로드 시 구독 정보 조회 + WidgetsBinding.instance.addPostFrameCallback((_) { + final subscriptionService = Provider.of( + context, + listen: false, + ); + subscriptionService.fetchCurrentSubscription(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('구독 관리')), + body: Consumer( + builder: (context, subscriptionService, child) { + if (subscriptionService.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (subscriptionService.error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('오류가 발생했습니다', style: TextStyle(color: Colors.red[700])), + const SizedBox(height: 8), + Text(subscriptionService.error!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => + subscriptionService.fetchCurrentSubscription(), + child: const Text('다시 시도'), + ), + ], + ), + ); + } + + // 현재 구독 정보가 있는 경우 + if (subscriptionService.hasSubscription) { + return _buildCurrentSubscription(subscriptionService); + } + + // 구독 정보가 없는 경우 플랜 선택 화면 + return _buildSubscriptionPlans(subscriptionService); + }, + ), + ); + } + + Widget _buildCurrentSubscription(SubscriptionService subscriptionService) { + final subscription = subscriptionService.currentSubscription!; + final plan = subscriptionService.availablePlans.firstWhere( + (plan) => plan.type == subscription.planType, + orElse: () => SubscriptionPlan.getDefaultPlans().first, + ); + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 현재 구독 정보 카드 + Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: subscription.isActive ? Colors.green : Colors.grey, + width: 2, + ), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.verified, + color: subscription.isActive + ? Colors.green + : Colors.grey, + size: 28, + ), + const SizedBox(width: 8), + Text( + '${plan.name} 플랜', + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: subscription.isActive + ? Colors.green.withOpacity(0.1) + : Colors.grey.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: subscription.isActive + ? Colors.green + : Colors.grey, + ), + ), + child: Text( + subscription.isActive ? '활성' : '만료됨', + style: TextStyle( + color: subscription.isActive + ? Colors.green + : Colors.grey, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 16), + _buildInfoRow('시작일', _formatDate(subscription.startDate)), + const SizedBox(height: 8), + _buildInfoRow('만료일', _formatDate(subscription.endDate)), + const SizedBox(height: 8), + _buildInfoRow( + '자동 갱신', + subscription.autoRenew ? '활성화' : '비활성화', + ), + const SizedBox(height: 8), + _buildInfoRow('남은 기간', '${subscription.daysLeft}일'), + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 16), + const Text( + '플랜 혜택', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + ...plan.features.map( + (feature) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + const Icon( + Icons.check_circle, + color: Colors.green, + size: 16, + ), + const SizedBox(width: 8), + Text(feature), + ], + ), + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 24), + + // 구독 관리 버튼 + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: subscription.isActive + ? () => _showCancelDialog(subscriptionService) + : null, + icon: const Icon(Icons.cancel), + label: const Text('구독 취소'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade100, + foregroundColor: Colors.red.shade700, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton.icon( + onPressed: () => _toggleAutoRenew(subscriptionService), + icon: Icon( + subscription.autoRenew ? Icons.toggle_on : Icons.toggle_off, + ), + label: Text(subscription.autoRenew ? '자동 갱신 끄기' : '자동 갱신 켜기'), + ), + ), + ], + ), + + const SizedBox(height: 16), + + // 플랜 변경 버튼 + ElevatedButton.icon( + onPressed: () { + setState(() { + // 플랜 선택 화면으로 전환 + subscriptionService.fetchCurrentSubscription(); + }); + }, + icon: const Icon(Icons.upgrade), + label: const Text('플랜 변경하기'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 50), + ), + ), + ], + ), + ); + } + + Widget _buildSubscriptionPlans(SubscriptionService subscriptionService) { + final plans = subscriptionService.availablePlans; + + return Column( + children: [ + // 연간/월간 선택 토글 + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('월간 구독'), + const SizedBox(width: 8), + Switch( + value: _isYearly, + onChanged: (value) { + setState(() { + _isYearly = value; + }); + }, + activeColor: Colors.blue, + ), + const SizedBox(width: 8), + Row( + children: [ + const Text('연간 구독'), + Container( + margin: const EdgeInsets.only(left: 8), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.green.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.green), + ), + child: const Text( + '20% 할인', + style: TextStyle( + color: Colors.green, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ], + ), + ), + + // 플랜 목록 + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: plans.length, + itemBuilder: (context, index) { + final plan = plans[index]; + final price = _isYearly ? plan.yearlyPrice : plan.monthlyPrice; + final period = _isYearly ? '년' : '월'; + + return Card( + margin: const EdgeInsets.only(bottom: 16), + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: InkWell( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => SubscriptionDetailsScreen( + plan: plan, + isYearly: _isYearly, + ), + ), + ); + }, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + plan.name, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + if (plan.type == SubscriptionPlanType.premium) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.amber.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.amber), + ), + child: const Text( + '인기', + style: TextStyle( + color: Colors.amber, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text(plan.description), + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '₩${_formatPrice(price)}', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 4), + Text('/$period'), + const Spacer(), + const Icon(Icons.arrow_forward), + ], + ), + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + ...plan.features + .take(3) + .map( + (feature) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + const Icon( + Icons.check_circle, + color: Colors.green, + size: 16, + ), + const SizedBox(width: 8), + Text(feature), + ], + ), + ), + ), + if (plan.features.length > 3) + Text( + '외 ${plan.features.length - 3}개 혜택', + style: TextStyle( + color: Colors.grey[600], + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ); + } + + Widget _buildInfoRow(String label, String value) { + return Row( + children: [ + Text('$label: ', style: const TextStyle(fontWeight: FontWeight.bold)), + Text(value), + ], + ); + } + + String _formatDate(DateTime date) { + return '${date.year}년 ${date.month}월 ${date.day}일'; + } + + 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]},', + ); + } + + void _showCancelDialog(SubscriptionService subscriptionService) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('구독 취소'), + content: const Text( + '정말로 구독을 취소하시겠습니까?\n' + '취소하면 현재 구독 기간이 끝날 때까지만 서비스를 이용할 수 있습니다.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('아니오'), + ), + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + final success = await subscriptionService.cancelSubscription(); + if (success && mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('구독이 취소되었습니다'))); + } + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('예, 취소합니다'), + ), + ], + ), + ); + } + + void _toggleAutoRenew(SubscriptionService subscriptionService) async { + final success = await subscriptionService.toggleAutoRenew(); + if (success && mounted) { + final isAutoRenew = subscriptionService.currentSubscription!.autoRenew; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(isAutoRenew ? '자동 갱신이 활성화되었습니다' : '자동 갱신이 비활성화되었습니다'), + ), + ); + } + } +} diff --git a/mobile/lib/services/admin_service.dart b/mobile/lib/services/admin_service.dart new file mode 100644 index 0000000..7bd0ba0 --- /dev/null +++ b/mobile/lib/services/admin_service.dart @@ -0,0 +1,358 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; + +import '../config/api_config.dart'; +import '../models/admin_model.dart'; + +/// 관리자 기능 관련 서비스 +class AdminService extends ChangeNotifier { + final String _baseUrl = ApiConfig.baseUrl; + final String _token; + final String _clubId; + + List _memberRoles = []; + ClubSettings? _clubSettings; + UserRole _currentUserRole = UserRole.member; + AdminPermission _currentUserPermissions = AdminPermission(); + bool _isLoading = false; + String? _error; + + AdminService(this._token, this._clubId) { + _initialize(); + } + + // 게터 + List get memberRoles => _memberRoles; + ClubSettings? get clubSettings => _clubSettings; + UserRole get currentUserRole => _currentUserRole; + AdminPermission get currentUserPermissions => _currentUserPermissions; + bool get isLoading => _isLoading; + String? get error => _error; + + // 권한 확인 게터 + bool get canManageMembers => _currentUserPermissions.canManageMembers; + bool get canManageEvents => _currentUserPermissions.canManageEvents; + bool get canManageScores => _currentUserPermissions.canManageScores; + bool get canManageSettings => _currentUserPermissions.canManageSettings; + bool get canManageSubscription => _currentUserPermissions.canManageSubscription; + bool get canManageRoles => _currentUserPermissions.canManageRoles; + bool get canViewFinancials => _currentUserPermissions.canViewFinancials; + bool get canExportData => _currentUserPermissions.canExportData; + + /// 초기화 (현재 사용자 권한 및 클럽 설정 로드) + Future _initialize() async { + await Future.wait([ + fetchCurrentUserRole(), + fetchClubSettings(), + ]); + } + + /// 현재 사용자의 역할 및 권한 조회 + Future fetchCurrentUserRole() async { + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final response = await http.get( + Uri.parse('$_baseUrl/api/clubs/$_clubId/my-role'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $_token', + }, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final memberRole = MemberRole.fromJson(data); + _currentUserRole = memberRole.role; + _currentUserPermissions = memberRole.permissions; + } else { + // 기본값: 일반 회원 + _currentUserRole = UserRole.member; + _currentUserPermissions = AdminPermission.fromRole(UserRole.member); + _error = '사용자 역할 정보를 불러오는데 실패했습니다. (${response.statusCode})'; + } + } catch (e) { + _error = '사용자 역할 정보를 불러오는데 실패했습니다: $e'; + // 기본값: 일반 회원 + _currentUserRole = UserRole.member; + _currentUserPermissions = AdminPermission.fromRole(UserRole.member); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// 클럽 내 모든 회원의 역할 정보 조회 + Future fetchMemberRoles() async { + if (!canManageRoles) { + _error = '역할 관리 권한이 없습니다.'; + notifyListeners(); + return; + } + + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final response = await http.get( + Uri.parse('$_baseUrl/api/clubs/$_clubId/roles'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $_token', + }, + ); + + if (response.statusCode == 200) { + final List data = jsonDecode(response.body); + _memberRoles = data.map((role) => MemberRole.fromJson(role)).toList(); + } else { + _error = '회원 역할 정보를 불러오는데 실패했습니다. (${response.statusCode})'; + } + } catch (e) { + _error = '회원 역할 정보를 불러오는데 실패했습니다: $e'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// 회원 역할 변경 + Future updateMemberRole(String memberId, UserRole newRole) async { + if (!canManageRoles) { + _error = '역할 관리 권한이 없습니다.'; + notifyListeners(); + return false; + } + + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final response = await http.put( + Uri.parse('$_baseUrl/api/clubs/$_clubId/members/$memberId/role'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $_token', + }, + body: jsonEncode({ + 'role': newRole.toString().split('.').last, + }), + ); + + if (response.statusCode == 200) { + // 역할 목록 갱신 + await fetchMemberRoles(); + return true; + } else { + _error = '회원 역할 변경에 실패했습니다. (${response.statusCode})'; + return false; + } + } catch (e) { + _error = '회원 역할 변경에 실패했습니다: $e'; + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// 클럽 설정 조회 + Future fetchClubSettings() async { + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final response = await http.get( + Uri.parse('$_baseUrl/api/clubs/$_clubId/settings'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $_token', + }, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + _clubSettings = ClubSettings.fromJson(data); + } else { + _error = '클럽 설정을 불러오는데 실패했습니다. (${response.statusCode})'; + } + } catch (e) { + _error = '클럽 설정을 불러오는데 실패했습니다: $e'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// 클럽 설정 업데이트 + Future updateClubSettings(ClubSettings updatedSettings) async { + if (!canManageSettings) { + _error = '설정 관리 권한이 없습니다.'; + notifyListeners(); + return false; + } + + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final response = await http.put( + Uri.parse('$_baseUrl/api/clubs/$_clubId/settings'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $_token', + }, + body: jsonEncode(updatedSettings.toJson()), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + _clubSettings = ClubSettings.fromJson(data); + notifyListeners(); + return true; + } else { + _error = '클럽 설정 업데이트에 실패했습니다. (${response.statusCode})'; + return false; + } + } catch (e) { + _error = '클럽 설정 업데이트에 실패했습니다: $e'; + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// 클럽 로고 업로드 + Future uploadClubLogo(String filePath) async { + if (!canManageSettings) { + _error = '설정 관리 권한이 없습니다.'; + notifyListeners(); + return false; + } + + _isLoading = true; + _error = null; + notifyListeners(); + + try { + // 파일 업로드 로직 구현 (multipart/form-data) + // 실제 구현 시 http.MultipartRequest 사용 + + // 성공 시 설정 업데이트 + await fetchClubSettings(); + return true; + } catch (e) { + _error = '클럽 로고 업로드에 실패했습니다: $e'; + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// 클럽 데이터 내보내기 + Future exportClubData(String dataType) async { + if (!canExportData) { + _error = '데이터 내보내기 권한이 없습니다.'; + notifyListeners(); + return null; + } + + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final response = await http.get( + Uri.parse('$_baseUrl/api/clubs/$_clubId/export/$dataType'), + headers: { + 'Authorization': 'Bearer $_token', + }, + ); + + if (response.statusCode == 200) { + // 다운로드 URL 또는 데이터 반환 + final data = jsonDecode(response.body); + return data['downloadUrl'] ?? data['data']; + } else { + _error = '데이터 내보내기에 실패했습니다. (${response.statusCode})'; + return null; + } + } catch (e) { + _error = '데이터 내보내기에 실패했습니다: $e'; + return null; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// 클럽 통계 및 분석 데이터 조회 + Future?> fetchClubAnalytics() async { + if (!canViewFinancials) { + _error = '통계 조회 권한이 없습니다.'; + notifyListeners(); + return null; + } + + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final response = await http.get( + Uri.parse('$_baseUrl/api/clubs/$_clubId/analytics'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $_token', + }, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data; + } else { + _error = '클럽 통계 데이터를 불러오는데 실패했습니다. (${response.statusCode})'; + return null; + } + } catch (e) { + _error = '클럽 통계 데이터를 불러오는데 실패했습니다: $e'; + return null; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// 특정 권한이 있는지 확인 + bool hasPermission(String permissionName) { + switch (permissionName) { + case 'manageMembers': + return _currentUserPermissions.canManageMembers; + case 'manageEvents': + return _currentUserPermissions.canManageEvents; + case 'manageScores': + return _currentUserPermissions.canManageScores; + case 'manageSettings': + return _currentUserPermissions.canManageSettings; + case 'manageSubscription': + return _currentUserPermissions.canManageSubscription; + case 'manageRoles': + return _currentUserPermissions.canManageRoles; + case 'viewFinancials': + return _currentUserPermissions.canViewFinancials; + case 'exportData': + return _currentUserPermissions.canExportData; + default: + return false; + } + } +} diff --git a/mobile/lib/services/api_service.dart b/mobile/lib/services/api_service.dart new file mode 100644 index 0000000..3b22f29 --- /dev/null +++ b/mobile/lib/services/api_service.dart @@ -0,0 +1,133 @@ +import 'package:dio/dio.dart'; +import 'package:cookie_jar/cookie_jar.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:flutter/material.dart'; +import '../config/api_config.dart'; +import '../services/auth_service.dart'; +import '../screens/auth/login_screen.dart'; +import '../main.dart'; // navigatorKey를 사용하기 위한 import + +class ApiService { + static final ApiService _instance = ApiService._internal(); + factory ApiService() => _instance; + + late Dio _dio; + final CookieJar _cookieJar = CookieJar(); + String? _token; + + ApiService._internal() { + _dio = Dio(BaseOptions( + baseUrl: ApiConfig.baseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + contentType: 'application/json', + )); + + // 쿠키 관리자 설정 + _dio.interceptors.add(CookieManager(_cookieJar)); + + // 로그 인터셉터 (디버깅용) + _dio.interceptors.add(LogInterceptor( + requestBody: true, + responseBody: true, + )); + } + + // 토큰 설정 + void setToken(String token) { + _token = token; + _dio.options.headers['Authorization'] = 'Bearer $token'; + } + + // GET 요청 + Future get(String path, {Map? queryParameters}) async { + try { + final response = await _dio.get( + path, + queryParameters: queryParameters, + ); + return response.data; + } on DioException catch (e) { + _handleError(e); + rethrow; + } + } + + // POST 요청 + Future post(String path, {dynamic data}) async { + try { + final response = await _dio.post( + path, + data: data ?? {}, + ); + return response.data; + } on DioException catch (e) { + _handleError(e); + rethrow; + } + } + + // PUT 요청 + Future put(String path, {dynamic data}) async { + try { + final response = await _dio.put( + path, + data: data, + ); + return response.data; + } on DioException catch (e) { + _handleError(e); + rethrow; + } + } + + // DELETE 요청 + Future delete(String path) async { + try { + final response = await _dio.delete(path); + return response.data; + } on DioException catch (e) { + _handleError(e); + rethrow; + } + } + + // 에러 처리 + void _handleError(DioException e) { + if (e.response != null) { + print('API 에러: ${e.response?.statusCode} - ${e.response?.data}'); + + // 401 오류 (인증 실패) 처리 + if (e.response?.statusCode == 401) { + print('인증 오류: 토큰이 유효하지 않습니다. 로그아웃 처리합니다.'); + _handleUnauthorized(); + } + } else { + print('API 요청 에러: ${e.message}'); + } + } + + // 인증 오류 처리 (401) + void _handleUnauthorized() { + // 다음 프레임에서 실행하여 현재 API 호출 스택이 완료되도록 함 + Future.microtask(() async { + try { + // AuthService 인스턴스 가져오기 + final authService = AuthService(); + + // 로그아웃 처리 + await authService.logout(); + + // 전역 네비게이터 키를 사용하여 로그인 화면으로 이동 + if (navigatorKey.currentContext != null) { + Navigator.of(navigatorKey.currentContext!).pushAndRemoveUntil( + MaterialPageRoute(builder: (context) => const LoginScreen()), + (route) => false, + ); + } + } catch (e) { + print('로그아웃 처리 중 오류 발생: $e'); + } + }); + } +} diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart new file mode 100644 index 0000000..a83ea01 --- /dev/null +++ b/mobile/lib/services/auth_service.dart @@ -0,0 +1,166 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../config/api_config.dart'; +import '../models/user_model.dart'; +import './api_service.dart'; + +class AuthService with ChangeNotifier { + final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); + User? _currentUser; + String? _token; + bool _isLoading = false; + bool _isInitialized = false; + final ApiService _apiService = ApiService(); + + User? get currentUser => _currentUser; + String? get token => _token; + bool get isLoading => _isLoading; + bool get isAuthenticated => _token != null; + bool get isInitialized => _isInitialized; + + // 초기화 함수 + Future initialize() async { + _token = await _secureStorage.read(key: ApiConfig.tokenKey); + if (_token != null) { + await _fetchUserProfile(); + } + _isInitialized = true; + notifyListeners(); + } + + // 로그인 함수 + Future login(String username, String password) async { + _isLoading = true; + notifyListeners(); + + try { + final data = await _apiService.post( + ApiConfig.login, + data: {'username': username, 'password': password}, + ); + + _token = data['token']; + _currentUser = User.fromJson(data['user']); + + // 토큰 저장 + await _secureStorage.write(key: ApiConfig.tokenKey, value: _token); + + // API 서비스에 토큰 설정 + _apiService.setToken(_token!); + + // 사용자 ID와 역할 저장 + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(ApiConfig.userIdKey, _currentUser!.id); + await prefs.setString(ApiConfig.userRoleKey, _currentUser!.role); + + // 클럽 ID가 있으면 저장 + if (_currentUser!.clubId != null) { + await prefs.setString(ApiConfig.clubIdKey, _currentUser!.clubId!); + } + + _isLoading = false; + notifyListeners(); + return true; + } catch (e) { + _isLoading = false; + notifyListeners(); + return false; + } + } + + // 로그아웃 함수 + Future logout() async { + _token = null; + _currentUser = null; + + // 저장된 데이터 삭제 + await _secureStorage.delete(key: ApiConfig.tokenKey); + + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(ApiConfig.userIdKey); + await prefs.remove(ApiConfig.userRoleKey); + await prefs.remove(ApiConfig.clubIdKey); + + notifyListeners(); + } + + // 사용자 프로필 정보 가져오기 + Future _fetchUserProfile() async { + if (_token == null) { + print('프로필 정보 가져오기 실패: 토큰이 없음'); + return; + } + + try { + print('프로필 정보 요청 시작: ${ApiConfig.profile}'); + _apiService.setToken(_token!); + final data = await _apiService.get(ApiConfig.profile); + print('프로필 API 응답: $data'); + + if (data != null) { + try { + _currentUser = User.fromJson(data); + print('프로필 정보 파싱 성공: ${_currentUser?.name}'); + notifyListeners(); + } catch (e) { + print('프로필 정보 파싱 오류: $e'); + print('파싱 실패한 데이터: $data'); + } + } else { + print('프로필 정보 가져오기 실패: 응답 데이터가 null'); + } + } catch (e) { + print('프로필 정보 가져오기 오류: $e'); + } + } + + // 회원가입 함수 + Future register(String name, String email, String password) async { + _isLoading = true; + notifyListeners(); + + try { + final data = await _apiService.post( + ApiConfig.register, + data: {'name': name, 'email': email, 'password': password}, + ); + + _isLoading = false; + notifyListeners(); + + if (data != null) { + return true; + } else { + return false; + } + } catch (e) { + _isLoading = false; + notifyListeners(); + return false; + } + } + + // 비밀번호 재설정 이메일 전송 함수 + Future sendPasswordResetEmail(String email) async { + _isLoading = true; + notifyListeners(); + + try { + final data = await _apiService.post( + ApiConfig.forgotPassword, + data: {'email': email}, + ); + + _isLoading = false; + notifyListeners(); + + return data != null; + } catch (e) { + _isLoading = false; + notifyListeners(); + return false; + } + } +} diff --git a/mobile/lib/services/club_service.dart b/mobile/lib/services/club_service.dart new file mode 100644 index 0000000..375b8ec --- /dev/null +++ b/mobile/lib/services/club_service.dart @@ -0,0 +1,208 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../config/api_config.dart'; +import '../models/club_model.dart'; +import './api_service.dart'; +import './event_bus.dart'; + +class ClubService with ChangeNotifier { + List _clubs = []; + Club? _currentClub; + bool _isLoading = false; + String? _token; + final ApiService _apiService = ApiService(); + + List get clubs => [..._clubs]; + Club? get currentClub => _currentClub; + bool get isLoading => _isLoading; + + // 초기화 함수 + Future initialize(String token) async { + _token = token; + _apiService.setToken(token); + + // 저장된 현재 클럽 ID 가져오기 + final prefs = await SharedPreferences.getInstance(); + final clubId = prefs.getString(ApiConfig.clubIdKey); + + if (clubId != null) { + await fetchClubById(clubId); + } + } + + // 사용자의 모든 클럽 가져오기 + Future fetchUserClubs() async { + if (_token == null) return; + + _isLoading = true; + notifyListeners(); + + try { + final data = await _apiService.post('${ApiConfig.clubs}/user'); + + final List clubsData = data; + + _clubs = clubsData.map((clubData) => Club.fromJson(clubData)).toList(); + + _isLoading = false; + notifyListeners(); + } catch (e) { + _isLoading = false; + notifyListeners(); + throw Exception('클럽 목록을 불러오는데 실패했습니다: $e'); + } + } + + // ID로 클럽 정보 가져오기 + Future fetchClubById(String clubId) async { + if (_token == null) return; + + _isLoading = true; + notifyListeners(); + + try { + final data = await _apiService.post( + '${ApiConfig.clubs}', + data: {'clubId': clubId}, + ); + + // 백엔드에서는 club 키로 감싸지 않고 직접 클럽 객체를 반환합니다 + _currentClub = Club.fromJson(data); + + // 현재 클럽 ID 저장 + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(ApiConfig.clubIdKey, clubId); + + _isLoading = false; + notifyListeners(); + } catch (e) { + _isLoading = false; + notifyListeners(); + throw Exception('클럽 정보를 불러오는데 실패했습니다: $e'); + } + } + + // 현재 클럽 설정 + Future setCurrentClub(String clubId) async { + await fetchClubById(clubId); + } + + // 백엔드에 클럽 선택 요청 (세션에 clubId 저장) + Future selectClub(String clubId) async { + if (_token == null) return; + + _isLoading = true; + notifyListeners(); + + try { + await _apiService.post( + '${ApiConfig.clubs}/select-club', + data: {'clubId': clubId}, + ); + + // 클럽 선택 성공 후 클럽 정보 가져오기 + await fetchClubById(clubId); + + // 클럽 ID를 로컬에 저장 + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(ApiConfig.clubIdKey, clubId); + + // 클럽 데이터도 함께 저장 + if (_currentClub != null) { + await prefs.setString('${ApiConfig.clubIdKey}_data', jsonEncode(_currentClub!.toJson())); + } + + // 클럽 변경 이벤트 발생 - 다른 서비스들에게 알림 + EventBus().fire(ClubChangedEvent(clubId)); + + _isLoading = false; + notifyListeners(); + } catch (e) { + _isLoading = false; + notifyListeners(); + throw Exception('클럽 선택에 실패했습니다: $e'); + } + } + + // 클럽 생성 + Future createClub(Club club) async { + if (_token == null) { + throw Exception('인증이 필요합니다'); + } + + _isLoading = true; + notifyListeners(); + + try { + final data = await _apiService.post( + '${ApiConfig.clubs}', + data: club.toJson(), + ); + + final newClub = Club.fromJson(data['club']); + + _clubs.add(newClub); + _currentClub = newClub; + + // 현재 클럽 ID 저장 + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(ApiConfig.clubIdKey, newClub.id); + + _isLoading = false; + notifyListeners(); + + return newClub; + } catch (e) { + _isLoading = false; + notifyListeners(); + throw Exception('클럽 생성에 실패했습니다: $e'); + } + } + + // 클럽 정보 업데이트 + Future updateClub(String clubId, Map updates) async { + if (_token == null) { + throw Exception('인증이 필요합니다'); + } + + _isLoading = true; + notifyListeners(); + + try { + final data = await _apiService.put( + ApiConfig.clubs, + data: { + 'clubId': clubId, + ...updates, + }, + ); + + // 백엔드 응답 처리: 직접 클럽 객체가 반환되거나 {club: {...}} 형태로 반환될 수 있음 + final updatedClub = data is Map && data.containsKey('club') + ? Club.fromJson(data['club']) + : Club.fromJson(data); + + // 클럽 목록 업데이트 + final index = _clubs.indexWhere((club) => club.id == clubId); + if (index >= 0) { + _clubs[index] = updatedClub; + } + + // 현재 클럽이 업데이트된 클럽인 경우 업데이트 + if (_currentClub?.id == clubId) { + _currentClub = updatedClub; + } + + _isLoading = false; + notifyListeners(); + + return updatedClub; + } catch (e) { + _isLoading = false; + notifyListeners(); + throw Exception('클럽 정보 업데이트에 실패했습니다: $e'); + } + } +} diff --git a/mobile/lib/services/event_bus.dart b/mobile/lib/services/event_bus.dart new file mode 100644 index 0000000..ad01848 --- /dev/null +++ b/mobile/lib/services/event_bus.dart @@ -0,0 +1,28 @@ +import 'dart:async'; + +class EventBus { + static final EventBus _instance = EventBus._internal(); + + factory EventBus() => _instance; + + EventBus._internal(); + + final _streamController = StreamController.broadcast(); + + Stream get stream => _streamController.stream; + + void fire(dynamic event) { + _streamController.add(event); + } + + void dispose() { + _streamController.close(); + } +} + +// 이벤트 클래스들 +class ClubChangedEvent { + final String clubId; + + ClubChangedEvent(this.clubId); +} diff --git a/mobile/lib/services/event_service.dart b/mobile/lib/services/event_service.dart new file mode 100644 index 0000000..14b4ce3 --- /dev/null +++ b/mobile/lib/services/event_service.dart @@ -0,0 +1,185 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../config/api_config.dart'; +import '../models/event_model.dart'; +import './api_service.dart'; + +class EventService with ChangeNotifier { + List _events = []; + Event? _currentEvent; + bool _isLoading = false; + String? _token; + final ApiService _apiService = ApiService(); + String? _clubId; + + List get events => [..._events]; + bool get isLoading => _isLoading; + + // 초기화 함수 + Future initialize(String token) async { + _token = token; + _apiService.setToken(token); + } + + // 클럽 ID 설정 + void setClubId(String clubId) { + _clubId = clubId; + notifyListeners(); + } + + // 클럽의 모든 이벤트 가져오기 + Future fetchClubEvents() async { + print('fetchClubEvents 호출됨: token=$_token'); + + _isLoading = true; + notifyListeners(); + + try { + // 클럽 ID 가져오기 + final prefs = await SharedPreferences.getInstance(); + final clubId = prefs.getString(ApiConfig.clubIdKey); + + if (clubId == null) { + throw Exception('선택된 클럽이 없습니다'); + } + + final data = await _apiService.post( + '${ApiConfig.clubs}/events', + data: {'clubId': clubId}, + ); + + // 응답 데이터가 리스트 형태로 직접 왔는지 확인 + if (data is List) { + final List eventsData = data; + _events = eventsData + .map((eventData) => Event.fromJson(eventData)) + .toList(); + } else { + // 응답 데이터가 객체 형태로 왔을 경우 events 키를 통해 접근 + final List eventsData = data['events'] ?? []; + _events = eventsData + .map((eventData) => Event.fromJson(eventData)) + .toList(); + } + + _isLoading = false; + notifyListeners(); + } catch (e) { + _isLoading = false; + notifyListeners(); + throw Exception('이벤트 목록을 불러오는데 실패했습니다: $e'); + } + } + + // ID로 이벤트 정보 가져오기 + Future fetchEventById(String eventId) async { + if (_token == null || _clubId == null) { + throw Exception('인증 또는 클럽 정보가 필요합니다'); + } + + try { + final data = await _apiService.post( + '${ApiConfig.clubs}/events/detail', + data: {'eventId': eventId}, + ); + + return Event.fromJson(data['event']); + } catch (e) { + throw Exception('이벤트 정보를 불러오는데 실패했습니다: $e'); + } + } + + // 이벤트 생성 + Future createEvent(Map eventData) async { + if (_token == null || _clubId == null) { + throw Exception('인증 또는 클럽 정보가 필요합니다'); + } + + _isLoading = true; + notifyListeners(); + + try { + final data = await _apiService.post( + '${ApiConfig.clubs}/events', + data: eventData, + ); + + final newEvent = Event.fromJson(data['event']); + + _events.add(newEvent); + + _isLoading = false; + notifyListeners(); + + return newEvent; + } catch (e) { + _isLoading = false; + notifyListeners(); + throw Exception('이벤트 생성에 실패했습니다: $e'); + } + } + + // 이벤트 정보 업데이트 + Future updateEvent( + String eventId, + Map updates, + ) async { + if (_token == null || _clubId == null) { + throw Exception('인증 또는 클럽 정보가 필요합니다'); + } + + _isLoading = true; + notifyListeners(); + + try { + final data = await _apiService.put( + '${ApiConfig.clubs}/events/$eventId', + data: updates, + ); + + final updatedEvent = Event.fromJson(data['event']); + + // 이벤트 목록 업데이트 + final index = _events.indexWhere((event) => event.id == eventId); + if (index >= 0) { + _events[index] = updatedEvent; + } + + _isLoading = false; + notifyListeners(); + + return updatedEvent; + } catch (e) { + _isLoading = false; + notifyListeners(); + throw Exception('이벤트 정보 업데이트에 실패했습니다: $e'); + } + } + + // 이벤트 삭제 + Future deleteEvent(String eventId) async { + if (_token == null || _clubId == null) { + throw Exception('인증 또는 클럽 정보가 필요합니다'); + } + + _isLoading = true; + notifyListeners(); + + try { + await _apiService.delete('${ApiConfig.clubs}/events/$eventId'); + + // 이벤트 목록에서 삭제 + _events.removeWhere((event) => event.id == eventId); + + _isLoading = false; + notifyListeners(); + + return true; + } catch (e) { + _isLoading = false; + notifyListeners(); + throw Exception('이벤트 삭제에 실패했습니다: $e'); + } + } +} diff --git a/mobile/lib/services/in_app_purchase_service.dart b/mobile/lib/services/in_app_purchase_service.dart new file mode 100644 index 0000000..bfc7c64 --- /dev/null +++ b/mobile/lib/services/in_app_purchase_service.dart @@ -0,0 +1,284 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:in_app_purchase_android/in_app_purchase_android.dart'; +import 'package:in_app_purchase_storekit/in_app_purchase_storekit.dart'; + +import '../config/api_config.dart'; +import '../models/subscription_model.dart'; + +/// 인앱 결제 서비스 +class InAppPurchaseService extends ChangeNotifier { + final InAppPurchase _inAppPurchase = InAppPurchase.instance; + StreamSubscription>? _subscription; + + List _products = []; + List _purchases = []; + bool _isAvailable = false; + bool _isLoading = false; + String? _error; + + // 구매 검증 관련 변수 + bool _purchaseVerified = false; + Map? _verifiedPurchase; + + // 구독 상품 ID (스토어에 등록된 ID와 일치해야 함) + final Map _subscriptionIds = { + SubscriptionPlanType.basic: Platform.isAndroid + ? 'com.bowlingmanager.subscription.basic' + : 'com.bowlingmanager.subscription.basic', + SubscriptionPlanType.premium: Platform.isAndroid + ? 'com.bowlingmanager.subscription.premium' + : 'com.bowlingmanager.subscription.premium', + SubscriptionPlanType.enterprise: Platform.isAndroid + ? 'com.bowlingmanager.subscription.enterprise' + : 'com.bowlingmanager.subscription.enterprise', + }; + + // 게터 + List get products => _products; + List get purchases => _purchases; + bool get isAvailable => _isAvailable; + bool get isLoading => _isLoading; + String? get error => _error; + + InAppPurchaseService() { + _initialize(); + } + + /// 초기화 및 결제 상태 리스너 설정 + Future _initialize() async { + _isLoading = true; + notifyListeners(); + + // 인앱 결제 가능 여부 확인 + _isAvailable = await _inAppPurchase.isAvailable(); + + if (!_isAvailable) { + _error = '인앱 결제를 사용할 수 없습니다.'; + _isLoading = false; + notifyListeners(); + return; + } + + // 결제 상태 변경 리스너 설정 + final purchaseUpdated = _inAppPurchase.purchaseStream; + _subscription = purchaseUpdated.listen( + _onPurchaseUpdate, + onDone: _updateStreamOnDone, + onError: _updateStreamOnError, + ); + + // 상품 정보 로드 + await _loadProducts(); + + _isLoading = false; + notifyListeners(); + } + + /// 상품 정보 로드 + Future _loadProducts() async { + try { + final Set ids = _subscriptionIds.values.toSet(); + final ProductDetailsResponse response = + await _inAppPurchase.queryProductDetails(ids); + + if (response.notFoundIDs.isNotEmpty) { + _error = '일부 상품을 찾을 수 없습니다: ${response.notFoundIDs.join(", ")}'; + } + + _products = response.productDetails; + notifyListeners(); + } catch (e) { + _error = '상품 정보를 불러오는데 실패했습니다: $e'; + } + } + + /// 구독 플랜 타입에 해당하는 상품 정보 가져오기 + ProductDetails? getProductByPlanType(SubscriptionPlanType planType) { + final String? id = _subscriptionIds[planType]; + if (id == null) return null; + + try { + return _products.firstWhere((product) => product.id == id); + } catch (e) { + return null; + } + } + + /// 구독 구매 시작 + Future purchaseSubscription(SubscriptionPlanType planType) async { + final ProductDetails? product = getProductByPlanType(planType); + + if (product == null) { + _error = '해당 구독 상품을 찾을 수 없습니다.'; + notifyListeners(); + return false; + } + + try { + // 구매 요청 + final PurchaseParam purchaseParam = PurchaseParam( + productDetails: product, + applicationUserName: null, + ); + + // 구독 상품 구매 요청 + await _inAppPurchase.buyNonConsumable(purchaseParam: purchaseParam); + return true; + } catch (e) { + _error = '구독 구매 요청 중 오류가 발생했습니다: $e'; + notifyListeners(); + return false; + } + } + + /// 구매 상태 업데이트 처리 + void _onPurchaseUpdate(List purchaseDetailsList) async { + _purchases = purchaseDetailsList; + + for (var purchaseDetails in purchaseDetailsList) { + if (purchaseDetails.status == PurchaseStatus.pending) { + // 결제 대기 중 + _handlePendingPurchase(purchaseDetails); + } else if (purchaseDetails.status == PurchaseStatus.purchased || + purchaseDetails.status == PurchaseStatus.restored) { + // 결제 완료 또는 복원됨 + await _handleSuccessfulPurchase(purchaseDetails); + } else if (purchaseDetails.status == PurchaseStatus.error) { + // 결제 오류 + _handleFailedPurchase(purchaseDetails); + } else if (purchaseDetails.status == PurchaseStatus.canceled) { + // 결제 취소 + _handleCanceledPurchase(purchaseDetails); + } + + // 구매 완료 처리 (iOS에서는 명시적으로 완료 처리 필요) + if (purchaseDetails.pendingCompletePurchase) { + await _inAppPurchase.completePurchase(purchaseDetails); + } + } + + notifyListeners(); + } + + /// 결제 대기 중 처리 + void _handlePendingPurchase(PurchaseDetails purchaseDetails) { + // 결제 대기 중 UI 업데이트 등 필요한 처리 + } + + /// 결제 성공 처리 + Future _handleSuccessfulPurchase(PurchaseDetails purchaseDetails) async { + // 서버에 구독 정보 업데이트 요청 + await _verifyAndSavePurchase(purchaseDetails); + } + + /// 결제 실패 처리 + void _handleFailedPurchase(PurchaseDetails purchaseDetails) { + _error = '결제에 실패했습니다: ${purchaseDetails.error?.message ?? "알 수 없는 오류"}'; + } + + /// 결제 취소 처리 + void _handleCanceledPurchase(PurchaseDetails purchaseDetails) { + _error = '결제가 취소되었습니다.'; + } + + /// 구매 검증 및 서버 저장 + Future _verifyAndSavePurchase(PurchaseDetails purchaseDetails) async { + // 구매 영수증 정보 추출 + String? receiptData; + String? transactionId = purchaseDetails.purchaseID; + String? productId = purchaseDetails.productID; + String platform = Platform.isIOS ? 'ios' : 'android'; + + if (Platform.isIOS) { + final InAppPurchaseStoreKitPlatformAddition iosPlatformAddition = + _inAppPurchase.getPlatformAddition(); + receiptData = await iosPlatformAddition.retrieveReceiptData(); + } else if (Platform.isAndroid) { + if (purchaseDetails is GooglePlayPurchaseDetails) { + receiptData = purchaseDetails.billingClientPurchase.originalJson; + } + } + + if (receiptData == null) { + _error = '영수증 데이터를 추출할 수 없습니다.'; + notifyListeners(); + return false; + } + + try { + // API 설정 가져오기 + final String baseUrl = ApiConfig.baseUrl; + final String verifyEndpoint = ApiConfig.verifyReceipt; + + // 서버에 영수증 검증 요청 + final response = await http.post( + Uri.parse('$baseUrl$verifyEndpoint'), + headers: { + 'Content-Type': 'application/json', + // 인증 토큰은 SubscriptionService에서 처리 + }, + body: jsonEncode({ + 'receipt': receiptData, + 'transactionId': transactionId, + 'productId': productId, + 'platform': platform, + }), + ); + + if (response.statusCode == 200) { + // 검증 성공 + final data = jsonDecode(response.body); + // 성공 이벤트 발생 (SubscriptionService에서 구독 정보 업데이트) + _purchaseVerified = true; + _verifiedPurchase = { + 'transactionId': transactionId, + 'productId': productId, + 'verificationData': data, + }; + notifyListeners(); + return true; + } else { + // 검증 실패 + _error = '영수증 검증에 실패했습니다. (${response.statusCode})'; + notifyListeners(); + return false; + } + } catch (e) { + _error = '영수증 검증 중 오류가 발생했습니다: $e'; + notifyListeners(); + return false; + } + } + + /// 구독 복원 + Future restorePurchases() async { + try { + await _inAppPurchase.restorePurchases(); + return true; + } catch (e) { + _error = '구독 복원 중 오류가 발생했습니다: $e'; + notifyListeners(); + return false; + } + } + + void _updateStreamOnDone() { + _subscription?.cancel(); + } + + void _updateStreamOnError(dynamic error) { + _error = '결제 스트림 오류: $error'; + notifyListeners(); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } +} diff --git a/mobile/lib/services/member_service.dart b/mobile/lib/services/member_service.dart new file mode 100644 index 0000000..9a8a76f --- /dev/null +++ b/mobile/lib/services/member_service.dart @@ -0,0 +1,229 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../config/api_config.dart'; +import '../models/member_model.dart'; +import './api_service.dart'; +import './event_bus.dart'; + +class MemberService with ChangeNotifier { + List _members = []; + Member? _currentMember; + bool _isLoading = false; + String? _token; + String? _clubId; + final ApiService _apiService = ApiService(); + StreamSubscription? _eventSubscription; + + List get members => [..._members]; + bool get isLoading => _isLoading; + + // 초기화 함수 + void initialize(String token) { + _token = token; + _apiService.setToken(token); + + // 이벤트 구독 + _eventSubscription = EventBus().stream.listen((event) { + if (event is ClubChangedEvent) { + setClubId(event.clubId); + } + }); + } + + @override + void dispose() { + _eventSubscription?.cancel(); + super.dispose(); + } + + // 클럽 ID 설정 + Future setClubId(String clubId) async { + // 클럽 ID가 변경된 경우에만 처리 + if (_clubId != clubId) { + _clubId = clubId; + notifyListeners(); + + // 새 클럽의 회원 목록 가져오기 + if (_token != null) { + await fetchClubMembers(); + } + } + } + + // 클럽의 모든 회원 가져오기 + Future fetchClubMembers() async { + print('fetchClubMembers 호출됨: token=${_token}'); + + _isLoading = true; + notifyListeners(); + + try { + // 클럽 ID 가져오기 + final prefs = await SharedPreferences.getInstance(); + final clubId = prefs.getString(ApiConfig.clubIdKey); + + if (clubId == null) { + throw Exception('선택된 클럽이 없습니다'); + } + + final data = await _apiService.post( + '${ApiConfig.clubs}/members', + data: {'clubId': clubId}, + ); + + print('회원 응답 데이터: $data'); + + // 응답 데이터가 리스트 형태로 직접 왔으므로 그대로 사용 + if (data is List) { + final List membersData = data; + _members = membersData + .map((memberData) => Member.fromJson(memberData)) + .toList(); + print('변환된 회원 리스트 길이: ${_members.length}'); + } else { + // 응답 데이터가 객체 형태로 왔을 경우 members 키를 통해 접근 + final List membersData = data['members'] ?? []; + _members = membersData + .map((memberData) => Member.fromJson(memberData)) + .toList(); + print('변환된 회원 리스트 길이: ${_members.length}'); + } + + _isLoading = false; + notifyListeners(); + } catch (e) { + _isLoading = false; + notifyListeners(); + throw Exception('회원 목록을 불러오는데 실패했습니다: $e'); + } + } + + // ID로 회원 정보 가져오기 + Future fetchMemberById(String memberId) async { + if (_token == null || _clubId == null) { + throw Exception('인증 또는 클럽 정보가 필요합니다'); + } + + try { + final data = await _apiService.post( + '${ApiConfig.clubs}/members/detail', + data: {'memberId': memberId}, + ); + + if (data['statusCode'] == 200) { + final memberData = data['member']; + return Member.fromJson(memberData); + } else { + throw Exception('회원 정보를 불러오는데 실패했습니다'); + } + } catch (e) { + throw Exception('회원 정보를 불러오는데 실패했습니다: $e'); + } + } + + // 회원 추가 + Future addMember(Map memberData) async { + if (_token == null || _clubId == null) { + throw Exception('인증 또는 클럽 정보가 필요합니다'); + } + + _isLoading = true; + notifyListeners(); + + try { + final data = await _apiService.post( + '${ApiConfig.clubs}/members/add', + data: memberData, + ); + + // 백엔드 응답 처리: 직접 회원 객체가 반환되거나 {member: {...}} 형태로 반환될 수 있음 + final newMember = data is Map && data.containsKey('member') + ? Member.fromJson(data['member']) + : Member.fromJson(data); + + _members.add(newMember); + + _isLoading = false; + notifyListeners(); + + return newMember; + } catch (e) { + _isLoading = false; + notifyListeners(); + throw Exception('회원 추가에 실패했습니다: $e'); + } + } + + // 회원 정보 업데이트 + Future updateMember( + String memberId, + Map updates, + ) async { + if (_token == null || _clubId == null) { + throw Exception('인증 또는 클럽 정보가 필요합니다'); + } + + _isLoading = true; + notifyListeners(); + + try { + final data = await _apiService.post( + '${ApiConfig.clubs}/members/update', + data: {'clubId': _clubId, 'memberId': memberId, ...updates}, + ); + + // 응답 데이터가 { member: {...} } 형태인지 또는 직접 회원 객체인지 확인 + final Map memberData = data['member'] != null + ? data['member'] as Map + : data as Map; + + final updatedMember = Member.fromJson(memberData); + + // 회원 목록 업데이트 + final index = _members.indexWhere((member) => member.id == memberId); + if (index >= 0) { + _members[index] = updatedMember; + } + + _isLoading = false; + notifyListeners(); + + return updatedMember; + } catch (e) { + _isLoading = false; + notifyListeners(); + throw Exception('회원 정보 업데이트에 실패했습니다: $e'); + } + } + + // 회원 삭제 (또는 비활성화) + Future deleteMember(String memberId) async { + if (_token == null || _clubId == null) { + throw Exception('인증 또는 클럽 정보가 필요합니다'); + } + + _isLoading = true; + notifyListeners(); + + try { + await _apiService.post( + '${ApiConfig.clubs}/members/delete', + data: {'memberId': memberId, 'clubId': _clubId}, + ); + + // 회원 목록에서 제거 + _members.removeWhere((member) => member.id == memberId); + + _isLoading = false; + notifyListeners(); + + return true; + } catch (e) { + _isLoading = false; + notifyListeners(); + throw Exception('회원 삭제에 실패했습니다: $e'); + } + } +} diff --git a/mobile/lib/services/score_service.dart b/mobile/lib/services/score_service.dart new file mode 100644 index 0000000..87e42ff --- /dev/null +++ b/mobile/lib/services/score_service.dart @@ -0,0 +1,296 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../config/api_config.dart'; +import '../models/score_model.dart'; +import './api_service.dart'; + +class ScoreService with ChangeNotifier { + List _scores = []; + bool _isLoading = false; + String? _token; + String? _clubId; + final ApiService _apiService = ApiService(); + String? _memberId; + String? _eventId; + + List get scores => [..._scores]; + bool get isLoading => _isLoading; + + // 초기화 함수 + void initialize(String token) { + _token = token; + _apiService.setToken(token); + } + + // 회원 ID 설정 + void setMemberId(String memberId) { + _memberId = memberId; + notifyListeners(); + } + + // 이벤트 ID 설정 + void setEventId(String eventId) { + _eventId = eventId; + notifyListeners(); + } + + // 클럽의 모든 점수 가져오기 + Future fetchClubScores() async { + print('fetchClubScores 호출됨: token=$_token'); + + _isLoading = true; + notifyListeners(); + + try { + // 클럽 ID 가져오기 + final prefs = await SharedPreferences.getInstance(); + final clubId = prefs.getString(ApiConfig.clubIdKey); + + if (clubId == null) { + throw Exception('선택된 클럽이 없습니다'); + } + + final data = await _apiService.post( + '${ApiConfig.clubs}/scores', + data: {'clubId': clubId}, + ); + + // 응답 데이터가 리스트 형태로 직접 왔는지 확인 + if (data is List) { + final List scoresData = data; + _scores = scoresData + .map((scoreData) => Score.fromJson(scoreData)) + .toList(); + } else { + // 응답 데이터가 객체 형태로 왔을 경우 scores 키를 통해 접근 + final List scoresData = data['scores'] ?? []; + _scores = scoresData + .map((scoreData) => Score.fromJson(scoreData)) + .toList(); + } + + _isLoading = false; + notifyListeners(); + } catch (e) { + _isLoading = false; + notifyListeners(); + throw Exception('점수 목록을 불러오는데 실패했습니다: $e'); + } + } + + // 회원의 점수 가져오기 + Future> fetchMemberScores(String memberId) async { + if (_token == null || _clubId == null) { + throw Exception('인증 또는 클럽 정보가 필요합니다'); + } + + _isLoading = true; + notifyListeners(); + + try { + final data = await _apiService.post( + '${ApiConfig.clubs}/members/scores', + data: {'memberId': memberId}, + ); + + final List scoresData = data['scores']; + + final memberScores = scoresData + .map((scoreData) => Score.fromJson(scoreData)) + .toList(); + + _isLoading = false; + notifyListeners(); + + return memberScores; + } catch (e) { + _isLoading = false; + notifyListeners(); + throw Exception('회원 점수를 불러오는데 실패했습니다: $e'); + } + } + + // 이벤트의 점수 가져오기 + Future> fetchEventScores(String eventId) async { + if (_token == null || _clubId == null) { + throw Exception('인증 또는 클럽 정보가 필요합니다'); + } + + _isLoading = true; + notifyListeners(); + + try { + final data = await _apiService.post( + '${ApiConfig.clubs}/events/scores', + data: {'eventId': eventId}, + ); + + final List scoresData = data['scores']; + + final eventScores = scoresData + .map((scoreData) => Score.fromJson(scoreData)) + .toList(); + + _isLoading = false; + notifyListeners(); + + return eventScores; + } catch (e) { + _isLoading = false; + notifyListeners(); + throw Exception('이벤트 점수를 불러오는데 실패했습니다: $e'); + } + } + + // 점수 추가 + Future addScore(Map scoreData) async { + if (_token == null || _clubId == null) { + throw Exception('인증 또는 클럽 정보가 필요합니다'); + } + + _isLoading = true; + notifyListeners(); + + try { + final data = await _apiService.post(ApiConfig.scores, data: scoreData); + + final newScore = Score.fromJson(data['score']); + + _scores.add(newScore); + + _isLoading = false; + notifyListeners(); + + return newScore; + } catch (e) { + _isLoading = false; + notifyListeners(); + throw Exception('점수 추가에 실패했습니다: $e'); + } + } + + // 점수 수정 + Future updateScore( + String scoreId, + Map updates, + ) async { + if (_token == null || _clubId == null) { + throw Exception('인증 또는 클럽 정보가 필요합니다'); + } + + _isLoading = true; + notifyListeners(); + + try { + final data = await _apiService.put( + '${ApiConfig.scores}/$scoreId', + data: updates, + ); + + final updatedScore = Score.fromJson(data['score']); + + // 점수 목록 업데이트 + final index = _scores.indexWhere((score) => score.id == scoreId); + if (index >= 0) { + _scores[index] = updatedScore; + } + + _isLoading = false; + notifyListeners(); + + return updatedScore; + } catch (e) { + _isLoading = false; + notifyListeners(); + throw Exception('점수 수정에 실패했습니다: $e'); + } + } + + // 점수 삭제 + Future deleteScore(String scoreId) async { + if (_token == null || _clubId == null) { + throw Exception('인증 또는 클럽 정보가 필요합니다'); + } + + _isLoading = true; + notifyListeners(); + + try { + await _apiService.delete('${ApiConfig.scores}/$scoreId'); + + // 점수 목록에서 삭제 + _scores.removeWhere((score) => score.id == scoreId); + + _isLoading = false; + notifyListeners(); + + return true; + } catch (e) { + _isLoading = false; + notifyListeners(); + throw Exception('점수 삭제에 실패했습니다: $e'); + } + } + + // 회원 통계 가져오기 + Future fetchMemberStatistics(String memberId) async { + if (_token == null || _clubId == null) { + throw Exception('인증 또는 클럽 정보가 필요합니다'); + } + + try { + final data = await _apiService.post( + '${ApiConfig.clubs}/members/stats', + data: {'memberId': memberId}, + ); + + return ScoreStatistics.fromJson(data['statistics']); + } catch (e) { + throw Exception('회원 통계를 불러오는데 실패했습니다: $e'); + } + } + + // 클럽 통계 가져오기 + Future> fetchClubStatistics() async { + if (_token == null) { + throw Exception('인증 정보가 필요합니다'); + } + + try { + // 클럽 ID 가져오기 + final prefs = await SharedPreferences.getInstance(); + final clubId = prefs.getString(ApiConfig.clubIdKey); + + if (clubId == null) { + throw Exception('선택된 클럽이 없습니다'); + } + + final data = await _apiService.post( + '${ApiConfig.clubs}/scores/stats', + data: {'clubId': clubId}, + ); + + final Map statistics = {}; + + if (data['statistics'] != null) { + final Map statsData = + data['statistics'] as Map; + + statsData.forEach((key, value) { + if (value != null) { + try { + statistics[key] = ScoreStatistics.fromJson(value); + } catch (e) { + print('통계 변환 오류: $key - $e'); + } + } + }); + } + + return statistics; + } catch (e) { + throw Exception('클럽 통계를 불러오는데 실패했습니다: $e'); + } + } +} diff --git a/mobile/lib/services/subscription_notification_service.dart b/mobile/lib/services/subscription_notification_service.dart new file mode 100644 index 0000000..648588f --- /dev/null +++ b/mobile/lib/services/subscription_notification_service.dart @@ -0,0 +1,90 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../models/subscription_model.dart'; + +/// 구독 알림 서비스 +class SubscriptionNotificationService extends ChangeNotifier { + // 구독 만료 임박 기준 (일) + static const int _expirationWarningDays = 7; + + // 알림 상태 + bool _hasExpirationWarning = false; + bool _hasExpired = false; + DateTime? _expirationDate; + Timer? _checkTimer; + + // 게터 + bool get hasExpirationWarning => _hasExpirationWarning; + bool get hasExpired => _hasExpired; + DateTime? get expirationDate => _expirationDate; + + // 만료까지 남은 일수 + int get daysUntilExpiration { + if (_expirationDate == null) return 0; + return _expirationDate!.difference(DateTime.now()).inDays; + } + + // 만료일 포맷팅 (YYYY-MM-DD) + String get formattedExpirationDate { + if (_expirationDate == null) return ''; + return DateFormat('yyyy-MM-dd').format(_expirationDate!); + } + + SubscriptionNotificationService() { + // 주기적으로 구독 상태 확인 (1시간마다) + _checkTimer = Timer.periodic(const Duration(hours: 1), (_) { + _checkExpirationStatus(); + }); + } + + /// 구독 정보 업데이트 + void updateSubscription(Subscription? subscription) { + if (subscription == null || !subscription.isActive) { + _hasExpired = true; + _hasExpirationWarning = false; + _expirationDate = null; + } else { + _expirationDate = subscription.endDate; + _checkExpirationStatus(); + } + notifyListeners(); + } + + /// 만료 상태 확인 + void _checkExpirationStatus() { + if (_expirationDate == null) { + _hasExpired = true; + _hasExpirationWarning = false; + return; + } + + final now = DateTime.now(); + final daysLeft = _expirationDate!.difference(now).inDays; + + // 만료 여부 확인 + _hasExpired = now.isAfter(_expirationDate!); + + // 만료 임박 여부 확인 (7일 이내) + _hasExpirationWarning = !_hasExpired && daysLeft <= _expirationWarningDays; + + notifyListeners(); + } + + /// 알림 메시지 생성 + String? getNotificationMessage() { + if (_hasExpired) { + return '구독이 만료되었습니다. 서비스 이용을 위해 구독을 갱신해 주세요.'; + } else if (_hasExpirationWarning && _expirationDate != null) { + return '구독이 $daysUntilExpiration일 후 만료됩니다. 서비스 이용을 위해 구독을 갱신해 주세요.'; + } + return null; + } + + @override + void dispose() { + _checkTimer?.cancel(); + super.dispose(); + } +} diff --git a/mobile/lib/services/subscription_service.dart b/mobile/lib/services/subscription_service.dart new file mode 100644 index 0000000..9ee9d73 --- /dev/null +++ b/mobile/lib/services/subscription_service.dart @@ -0,0 +1,445 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; + +import '../config/api_config.dart'; +import '../models/subscription_model.dart'; +import 'in_app_purchase_service.dart'; + +/// 구독 관련 서비스 +class SubscriptionService extends ChangeNotifier { + final String _baseUrl = ApiConfig.baseUrl; + final String _token; + + Subscription? _currentSubscription; + List _availablePlans = []; + bool _isLoading = false; + String? _error; + + // 인앱 결제 서비스 + final InAppPurchaseService _purchaseService = InAppPurchaseService(); + + SubscriptionService(this._token) { + _loadAvailablePlans(); + _initializeInAppPurchase(); + } + + // 게터 + Subscription? get currentSubscription => _currentSubscription; + List get availablePlans => _availablePlans; + bool get isLoading => _isLoading || _purchaseService.isLoading; + String? get error => _error ?? _purchaseService.error; + bool get hasSubscription => _currentSubscription != null; + bool get isActive => _currentSubscription?.isActive ?? false; + + // 인앱 결제 관련 게터 + InAppPurchaseService get purchaseService => _purchaseService; + List get products => _purchaseService.products; + + /// 현재 구독 정보 로드 + Future loadCurrentSubscription() async { + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final response = await http.post( + Uri.parse('$_baseUrl${ApiConfig.currentSubscription}'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $_token', + }, + body: jsonEncode({ + 'clubId': _clubId, + }), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + _currentSubscription = Subscription.fromJson(data); + } else if (response.statusCode == 404) { + // 구독 정보가 없는 경우 (Free 플랜으로 간주) + _currentSubscription = Subscription( + id: 'free', + userId: _userId, + planType: SubscriptionPlanType.free, + status: SubscriptionStatus.active, + startDate: DateTime.now(), + endDate: DateTime.now().add(const Duration(days: 36500)), // 100년 + autoRenew: false, + lastPaymentDate: null, + nextPaymentDate: null, + ); + } else { + _error = '구독 정보를 불러오는데 실패했습니다. (${response.statusCode})'; + } + } catch (e) { + _error = '구독 정보를 불러오는데 실패했습니다: $e'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// 사용 가능한 구독 플랜 목록 로드 + Future _loadAvailablePlans() async { + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final response = await http.get( + Uri.parse('$_baseUrl${ApiConfig.subscriptionPlans}'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $_token', + }, + ); + + if (response.statusCode == 200) { + final List data = jsonDecode(response.body); + _availablePlans = data.map((plan) => SubscriptionPlan.fromJson(plan)).toList(); + } else { + _error = '구독 플랜 정보를 불러오는데 실패했습니다. (${response.statusCode})'; + // 기본 플랜 정보 사용 + _availablePlans = SubscriptionPlan.getDefaultPlans(); + } + } catch (e) { + _error = '구독 플랜 정보를 불러오는데 실패했습니다: $e'; + // 기본 플랜 정보 사용 + _availablePlans = SubscriptionPlan.getDefaultPlans(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// 인앱 결제 서비스 초기화 + Future _initializeInAppPurchase() async { + // 인앱 결제 서비스는 자체적으로 초기화됨 + // 필요한 경우 추가 설정 + } + + /// 구독 복원 + Future restoreSubscriptions() async { + _isLoading = true; + _error = null; + notifyListeners(); + + try { + // 1. 인앱 결제 복원 요청 + final restoreSuccess = await _purchaseService.restorePurchases(); + if (!restoreSuccess) { + _error = '구독 복원에 실패했습니다.'; + return false; + } + + // 2. 서버에서 최신 구독 정보 조회 + await fetchCurrentSubscription(); + return true; + } catch (e) { + _error = '구독 복원에 실패했습니다: $e'; + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// 구독 상태 검증 + Future verifySubscription() async { + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final response = await http.get( + Uri.parse('$_baseUrl/api/subscriptions/verify'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $_token', + }, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + _currentSubscription = Subscription.fromJson(data); + notifyListeners(); + return true; + } else { + _error = '구독 검증에 실패했습니다. (${response.statusCode})'; + return false; + } + } catch (e) { + _error = '구독 검증에 실패했습니다: $e'; + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// 새 구독 생성 (인앱 결제 통합) + Future createSubscription(SubscriptionPlanType planType, bool isYearly) async { + _isLoading = true; + _error = null; + notifyListeners(); + + try { + // 인앱 결제 시작 + final success = await _purchaseService.purchaseSubscription(planType); + + if (!success) { + _error = '구독 구매 요청에 실패했습니다.'; + _isLoading = false; + notifyListeners(); + return false; + } + + // 구매 검증 결과 대기 + bool verified = false; + int attempts = 0; + const maxAttempts = 10; + + // 구매 검증이 완료될 때까지 최대 10회 확인 + while (!verified && attempts < maxAttempts) { + await Future.delayed(const Duration(seconds: 1)); + verified = _purchaseService._purchaseVerified; + attempts++; + + // 오류가 발생한 경우 + if (_purchaseService.error != null) { + _error = _purchaseService.error; + _isLoading = false; + notifyListeners(); + return false; + } + } + + if (!verified) { + _error = '구독 검증 시간이 초과되었습니다.'; + _isLoading = false; + notifyListeners(); + return false; + } + + // 검증된 구매 정보 가져오기 + final verifiedPurchase = _purchaseService._verifiedPurchase; + if (verifiedPurchase == null) { + _error = '검증된 구매 정보를 찾을 수 없습니다.'; + _isLoading = false; + notifyListeners(); + return false; + } + + // 서버에 구독 생성 요청 + final response = await http.post( + Uri.parse('$_baseUrl${ApiConfig.subscribeClub}'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $_token', + }, + body: jsonEncode({ + 'planType': planType.toString().split('.').last, + 'isYearly': isYearly, + 'clubId': _clubId, + 'transactionId': verifiedPurchase['transactionId'], + 'productId': verifiedPurchase['productId'], + 'verificationData': verifiedPurchase['verificationData'], + }), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + _currentSubscription = Subscription.fromJson(data); + + // 구매 검증 상태 초기화 + _purchaseService._purchaseVerified = false; + _purchaseService._verifiedPurchase = null; + + notifyListeners(); + return true; + } else { + _error = '구독 생성에 실패했습니다. (${response.statusCode})'; + return false; + } + } catch (e) { + _error = '구독 생성 중 오류가 발생했습니다: $e'; + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// 구독 취소 + Future cancelSubscription() async { + if (_currentSubscription == null) { + _error = '취소할 구독이 없습니다.'; + notifyListeners(); + return false; + } + + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final response = await http.post( + Uri.parse('$_baseUrl/api/subscriptions/${_currentSubscription!.id}/cancel'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $_token', + }, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + _currentSubscription = Subscription.fromJson(data); + notifyListeners(); + return true; + } else { + _error = '구독 취소에 실패했습니다. (${response.statusCode})'; + return false; + } + } catch (e) { + _error = '구독 취소에 실패했습니다: $e'; + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// 구독 플랜 변경 + Future changePlan(SubscriptionPlanType newPlanType, bool isYearly) async { + if (_currentSubscription == null) { + _error = '변경할 구독이 없습니다.'; + notifyListeners(); + return false; + } + + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final response = await http.post( + Uri.parse('$_baseUrl${ApiConfig.changePlan}'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $_token', + }, + body: jsonEncode({ + 'subscriptionId': _currentSubscription!.id, + 'planType': newPlanType.toString().split('.').last, + 'isYearly': isYearly, + 'clubId': _clubId, + }), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + _currentSubscription = Subscription.fromJson(data); + notifyListeners(); + return true; + } else { + _error = '구독 플랜 변경에 실패했습니다. (${response.statusCode})'; + return false; + } + } catch (e) { + _error = '구독 플랜 변경에 실패했습니다: $e'; + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// 자동 갱신 설정 변경 + Future toggleAutoRenew() async { + if (_currentSubscription == null) { + _error = '변경할 구독이 없습니다.'; + notifyListeners(); + return false; + } + + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final response = await http.post( + Uri.parse('$_baseUrl${ApiConfig.subscriptions}/autorenew'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $_token', + }, + body: jsonEncode({ + 'subscriptionId': _currentSubscription!.id, + 'autoRenew': !_currentSubscription!.autoRenew, + 'clubId': _clubId, + }), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + _currentSubscription = Subscription.fromJson(data); + notifyListeners(); + return true; + } else { + _error = '자동 갱신 설정 변경에 실패했습니다. (${response.statusCode})'; + return false; + } + } catch (e) { + _error = '자동 갱신 설정 변경에 실패했습니다: $e'; + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// 특정 기능이 현재 구독에서 사용 가능한지 확인 + bool canUseFeature(String featureName) { + if (_currentSubscription == null || !_currentSubscription!.isActive) { + return false; + } + + // 기능별 접근 권한 확인 로직 + switch (featureName) { + case 'advancedStats': + return _getSubscriptionPlan(_currentSubscription!.planType).hasAdvancedStats; + case 'customization': + return _getSubscriptionPlan(_currentSubscription!.planType).hasCustomization; + case 'prioritySupport': + return _getSubscriptionPlan(_currentSubscription!.planType).hasPriority; + default: + return false; + } + } + + /// 현재 구독으로 추가 가능한 최대 회원 수 확인 + int getMaxMembers() { + if (_currentSubscription == null || !_currentSubscription!.isActive) { + return SubscriptionPlan.getDefaultPlans().first.maxMembers; // 무료 플랜 + } + return _getSubscriptionPlan(_currentSubscription!.planType).maxMembers; + } + + /// 현재 구독으로 추가 가능한 최대 이벤트 수 확인 + int getMaxEvents() { + if (_currentSubscription == null || !_currentSubscription!.isActive) { + return SubscriptionPlan.getDefaultPlans().first.maxEvents; // 무료 플랜 + } + return _getSubscriptionPlan(_currentSubscription!.planType).maxEvents; + } + + /// 구독 플랜 타입에 해당하는 플랜 정보 가져오기 + SubscriptionPlan _getSubscriptionPlan(SubscriptionPlanType planType) { + return _availablePlans.firstWhere( + (plan) => plan.type == planType, + orElse: () => SubscriptionPlan.getDefaultPlans().first, + ); + } +} diff --git a/mobile/lib/utils/enum_mappings.dart b/mobile/lib/utils/enum_mappings.dart new file mode 100644 index 0000000..7fd23ca --- /dev/null +++ b/mobile/lib/utils/enum_mappings.dart @@ -0,0 +1,110 @@ +// 역할 옵션 +const Map roleOptions = { + 'admin': '관리자', + 'clubadmin': '클럽 관리자', + 'user': '일반 사용자', +}; + +// 성별 옵션 +const Map genderOptions = { + 'male': '남성', + 'female': '여성', +}; + +// 회원 유형 옵션 +const Map memberTypeOptions = { + 'regular': '정회원', + 'associate': '준회원', + 'guest': '게스트', + 'manager': '운영진', + 'owner': '모임장', +}; + +// 상태 옵션 +const Map statusOptions = { + 'active': '활성', + 'inactive': '비활성', + 'suspended': '정지', + 'deleted': '삭제', +}; + +// 참가자 상태 옵션 +const Map participantStatusOptions = { + 'pending': '참가예정', + 'confirmed': '참가확정', + 'canceled': '취소', +}; + +// 이벤트 상태 옵션 +const Map eventStatusOptions = { + 'ready': '준비', + 'active': '활성', + 'completed': '완료', + 'canceled': '취소', + 'deleted': '삭제', +}; + +// 이벤트 유형 옵션 +const Map eventTypeOptions = { + 'regular': '정기전', + 'exchange': '교류전', + 'tournament': '토너먼트', + 'lightning': '번개', + 'friendly': '친선전', + 'other': '기타', +}; + +// 결제 상태 옵션 +const Map paymentStatusOptions = { + 'unpaid': '미납', + 'paid': '납부완료', +}; + +// 평균 산정 기준 옵션 +const Map averageCalculationPeriodOptions = { + 'total': '전체', + '1year': '최근 1년', + '6month': '최근 6개월', + '3month': '최근 3개월', + 'firsthalf': '상반기', + 'secondhalf': '하반기', + 'quarterly': '분기별', +}; + +// 회원 유형 레이블 가져오기 +String getMemberTypeLabel(String? memberType) { + if (memberType == null) return '일반 회원'; + return memberTypeOptions[memberType] ?? memberType; +} + +// 역할 레이블 가져오기 +String getRoleLabel(String? role) { + if (role == null) return '일반 사용자'; + return roleOptions[role] ?? role; +} + +// 성별 레이블 가져오기 +String getGenderLabel(String? gender) { + if (gender == null) return '정보 없음'; + return genderOptions[gender] ?? gender; +} + +// 상태 레이블 가져오기 +String getStatusLabel(String? status) { + if (status == null) return '정보 없음'; + return statusOptions[status] ?? status; +} + +// 이벤트 상태 레이블 가져오기 +String getEventStatusLabel(String? status) { + if (status == null) return '정보 없음'; + return eventStatusOptions[status] ?? status; +} + +// 이벤트 유형 레이블 가져오기 +String getEventTypeLabel(String? type) { + if (type == null) return '기타'; + return eventTypeOptions[type] ?? type; +} + + diff --git a/mobile/lib/widgets/subscription_alert_widget.dart b/mobile/lib/widgets/subscription_alert_widget.dart new file mode 100644 index 0000000..915e0dd --- /dev/null +++ b/mobile/lib/widgets/subscription_alert_widget.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../services/subscription_notification_service.dart'; +import '../services/subscription_service.dart'; + +/// 구독 알림 위젯 +class SubscriptionAlertWidget extends StatelessWidget { + const SubscriptionAlertWidget({super.key}); + + @override + Widget build(BuildContext context) { + // 구독 알림 서비스 가져오기 + final notificationService = Provider.of( + context, + listen: true, + ); + + // 구독 서비스 가져오기 + final subscriptionService = Provider.of( + context, + listen: false, + ); + + // 알림 메시지 가져오기 + final message = notificationService.getNotificationMessage(); + + // 알림이 없으면 빈 컨테이너 반환 + if (message == null) { + return const SizedBox.shrink(); + } + + // 알림 배너 표시 + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + color: notificationService.hasExpired + ? Colors.red.shade100 + : Colors.orange.shade100, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + notificationService.hasExpired + ? Icons.error_outline + : Icons.warning_amber_outlined, + color: notificationService.hasExpired + ? Colors.red.shade700 + : Colors.orange.shade700, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: TextStyle( + color: notificationService.hasExpired + ? Colors.red.shade700 + : Colors.orange.shade700, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () { + // 구독 화면으로 이동 + Navigator.of(context).pushNamed('/subscription'); + }, + style: TextButton.styleFrom( + backgroundColor: notificationService.hasExpired + ? Colors.red.shade700 + : Colors.orange.shade700, + foregroundColor: Colors.white, + ), + child: const Text('구독 갱신하기'), + ), + ], + ), + ], + ), + ); + } +} diff --git a/mobile/linux/.gitignore b/mobile/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/mobile/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/mobile/linux/CMakeLists.txt b/mobile/linux/CMakeLists.txt new file mode 100644 index 0000000..9a39eb1 --- /dev/null +++ b/mobile/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "mobile") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.mobile") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/mobile/linux/flutter/CMakeLists.txt b/mobile/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/mobile/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/mobile/linux/flutter/generated_plugin_registrant.cc b/mobile/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..d0e7f79 --- /dev/null +++ b/mobile/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); +} diff --git a/mobile/linux/flutter/generated_plugin_registrant.h b/mobile/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/mobile/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/mobile/linux/flutter/generated_plugins.cmake b/mobile/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..b29e9ba --- /dev/null +++ b/mobile/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/mobile/linux/runner/CMakeLists.txt b/mobile/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/mobile/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/mobile/linux/runner/main.cc b/mobile/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/mobile/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/mobile/linux/runner/my_application.cc b/mobile/linux/runner/my_application.cc new file mode 100644 index 0000000..8c77818 --- /dev/null +++ b/mobile/linux/runner/my_application.cc @@ -0,0 +1,130 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "mobile"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "mobile"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/mobile/linux/runner/my_application.h b/mobile/linux/runner/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/mobile/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/mobile/macos/.gitignore b/mobile/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/mobile/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/mobile/macos/Flutter/Flutter-Debug.xcconfig b/mobile/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/mobile/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/mobile/macos/Flutter/Flutter-Release.xcconfig b/mobile/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/mobile/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/mobile/macos/Flutter/GeneratedPluginRegistrant.swift b/mobile/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..edb8097 --- /dev/null +++ b/mobile/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,18 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import flutter_secure_storage_macos +import in_app_purchase_storekit +import path_provider_foundation +import shared_preferences_foundation + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) +} diff --git a/mobile/macos/Podfile b/mobile/macos/Podfile new file mode 100644 index 0000000..29c8eb3 --- /dev/null +++ b/mobile/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.14' + +# 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', 'ephemeral', '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 Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_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_macos_build_settings(target) + end +end diff --git a/mobile/macos/Runner.xcodeproj/project.pbxproj b/mobile/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..25a9d2c --- /dev/null +++ b/mobile/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* mobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "mobile.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* mobile.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* mobile.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + 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)/mobile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mobile"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + 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)/mobile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mobile"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + 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)/mobile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mobile"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/mobile/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/mobile/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..09d4458 --- /dev/null +++ b/mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/macos/Runner.xcworkspace/contents.xcworkspacedata b/mobile/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/mobile/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/mobile/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/mobile/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/mobile/macos/Runner/AppDelegate.swift b/mobile/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/mobile/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/mobile/macos/Runner/Base.lproj/MainMenu.xib b/mobile/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/mobile/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/macos/Runner/Configs/AppInfo.xcconfig b/mobile/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..ebe2895 --- /dev/null +++ b/mobile/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = mobile + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.mobile + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. diff --git a/mobile/macos/Runner/Configs/Debug.xcconfig b/mobile/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/mobile/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/mobile/macos/Runner/Configs/Release.xcconfig b/mobile/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/mobile/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/mobile/macos/Runner/Configs/Warnings.xcconfig b/mobile/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/mobile/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/mobile/macos/Runner/DebugProfile.entitlements b/mobile/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/mobile/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/mobile/macos/Runner/Info.plist b/mobile/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/mobile/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/mobile/macos/Runner/MainFlutterWindow.swift b/mobile/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/mobile/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/mobile/macos/Runner/Release.entitlements b/mobile/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/mobile/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/mobile/macos/RunnerTests/RunnerTests.swift b/mobile/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/mobile/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +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/pubspec.lock b/mobile/pubspec.lock new file mode 100644 index 0000000..e988b03 --- /dev/null +++ b/mobile/pubspec.lock @@ -0,0 +1,671 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cookie_jar: + dependency: "direct main" + description: + name: cookie_jar + sha256: a6ac027d3ed6ed756bfce8f3ff60cb479e266f3b0fdabd6242b804b6765e52de + url: "https://pub.dev" + source: hosted + version: "4.0.8" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dio: + dependency: "direct main" + description: + name: dio + sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9" + url: "https://pub.dev" + source: hosted + version: "5.8.0+1" + dio_cookie_manager: + dependency: "direct main" + description: + name: dio_cookie_manager + sha256: "47cacbf6a783c263bfa7cd7d08101e93127d87760ddb003ba289162f7be0f679" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + equatable: + dependency: transitive + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "577aeac8ca414c25333334d7c4bb246775234c0e44b38b10a82b559dd4d764e7" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + url: "https://pub.dev" + source: hosted + version: "0.13.1" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + url: "https://pub.dev" + source: hosted + version: "4.5.4" + in_app_purchase: + dependency: "direct main" + description: + name: in_app_purchase + sha256: "960f26a08d9351fb8f89f08901f8a829d41b04d45a694b8f776121d9e41dcad6" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + in_app_purchase_android: + dependency: "direct main" + description: + name: in_app_purchase_android + sha256: b7cc194f183a97d71e6da1edb4a4095466ca0c22533615ecad021bdf95a5ecdc + url: "https://pub.dev" + source: hosted + version: "0.3.6+13" + in_app_purchase_platform_interface: + dependency: transitive + description: + name: in_app_purchase_platform_interface + sha256: "1d353d38251da5b9fea6635c0ebfc6bb17a2d28d0e86ea5e083bf64244f1fb4c" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + in_app_purchase_storekit: + dependency: "direct main" + description: + name: in_app_purchase_storekit + sha256: "6ce1361278cacc0481508989ba419b2c9f46a2b0dc54b3fe54f5ee63c2718fef" + url: "https://pub.dev" + source: hosted + version: "0.3.22+1" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + url: "https://pub.dev" + source: hosted + version: "10.0.9" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + url: "https://pub.dev" + source: hosted + version: "3.0.9" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + url: "https://pub.dev" + source: hosted + version: "2.2.17" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" + url: "https://pub.dev" + source: hosted + version: "6.1.5" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" + url: "https://pub.dev" + source: hosted + version: "2.4.10" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.dev" + source: hosted + version: "0.7.4" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + url: "https://pub.dev" + source: hosted + version: "15.0.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + url: "https://pub.dev" + source: hosted + version: "5.14.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.8.1 <4.0.0" + flutter: ">=3.27.4" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml new file mode 100644 index 0000000..388461a --- /dev/null +++ b/mobile/pubspec.yaml @@ -0,0 +1,123 @@ +name: lanebow +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.8.1 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + + # HTTP 요청을 위한 패키지 + http: ^1.2.0 + + # 로컬 저장소 관련 패키지 + shared_preferences: ^2.2.2 + flutter_secure_storage: ^9.0.0 + + # 상태 관리 패키지 + provider: ^6.1.1 + + # 다국어 및 날짜 형식 지원 + intl: ^0.20.2 + fl_chart: ^1.0.0 + + # 인앱 결제 관련 패키지 + in_app_purchase: ^3.1.11 + in_app_purchase_storekit: ^0.3.7+1 + in_app_purchase_android: ^0.3.0+15 + dio: ^5.8.0+1 + dio_cookie_manager: ^3.2.0 + cookie_jar: ^4.0.8 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^5.0.0 + + # 앱 아이콘 생성 패키지 + flutter_launcher_icons: ^0.13.1 + +# 앱 아이콘 설정 +flutter_launcher_icons: + android: true + ios: true + image_path: "assets/icons/app_icon.png" + adaptive_icon_background: "#FFFFFF" + adaptive_icon_foreground: "assets/icons/app_icon.png" + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + assets: + - assets/images/ + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/mobile/test/widget_test.dart b/mobile/test/widget_test.dart new file mode 100644 index 0000000..a6b7d51 --- /dev/null +++ b/mobile/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mobile/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/mobile/web/favicon.png b/mobile/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/mobile/web/favicon.png differ diff --git a/mobile/web/icons/Icon-192.png b/mobile/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/mobile/web/icons/Icon-192.png differ diff --git a/mobile/web/icons/Icon-512.png b/mobile/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/mobile/web/icons/Icon-512.png differ diff --git a/mobile/web/icons/Icon-maskable-192.png b/mobile/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/mobile/web/icons/Icon-maskable-192.png differ diff --git a/mobile/web/icons/Icon-maskable-512.png b/mobile/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/mobile/web/icons/Icon-maskable-512.png differ diff --git a/mobile/web/index.html b/mobile/web/index.html new file mode 100644 index 0000000..44689e6 --- /dev/null +++ b/mobile/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + mobile + + + + + + diff --git a/mobile/web/manifest.json b/mobile/web/manifest.json new file mode 100644 index 0000000..9dc3ff2 --- /dev/null +++ b/mobile/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "mobile", + "short_name": "mobile", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/mobile/windows/.gitignore b/mobile/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/mobile/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/mobile/windows/CMakeLists.txt b/mobile/windows/CMakeLists.txt new file mode 100644 index 0000000..eb19942 --- /dev/null +++ b/mobile/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(mobile LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "mobile") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/mobile/windows/flutter/CMakeLists.txt b/mobile/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/mobile/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/mobile/windows/flutter/generated_plugin_registrant.cc b/mobile/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..0c50753 --- /dev/null +++ b/mobile/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); +} diff --git a/mobile/windows/flutter/generated_plugin_registrant.h b/mobile/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/mobile/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/mobile/windows/flutter/generated_plugins.cmake b/mobile/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..4fc759c --- /dev/null +++ b/mobile/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/mobile/windows/runner/CMakeLists.txt b/mobile/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/mobile/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/mobile/windows/runner/Runner.rc b/mobile/windows/runner/Runner.rc new file mode 100644 index 0000000..388ee67 --- /dev/null +++ b/mobile/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "mobile" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "mobile" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "mobile.exe" "\0" + VALUE "ProductName", "mobile" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/mobile/windows/runner/flutter_window.cpp b/mobile/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/mobile/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/mobile/windows/runner/flutter_window.h b/mobile/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/mobile/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/mobile/windows/runner/main.cpp b/mobile/windows/runner/main.cpp new file mode 100644 index 0000000..dbb4717 --- /dev/null +++ b/mobile/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"mobile", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/mobile/windows/runner/resource.h b/mobile/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/mobile/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/mobile/windows/runner/resources/app_icon.ico b/mobile/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/mobile/windows/runner/resources/app_icon.ico differ diff --git a/mobile/windows/runner/runner.exe.manifest b/mobile/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/mobile/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/mobile/windows/runner/utils.cpp b/mobile/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/mobile/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/mobile/windows/runner/utils.h b/mobile/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/mobile/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/mobile/windows/runner/win32_window.cpp b/mobile/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/mobile/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/mobile/windows/runner/win32_window.h b/mobile/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/mobile/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_