회원목록

This commit is contained in:
2025-08-01 16:45:26 +09:00
parent a996def67a
commit ed8c7eacba
191 changed files with 17491 additions and 0 deletions
+113
View File
@@ -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) 파일을 참조하세요.
## 기여
버그 신고, 기능 요청 및 풀 리퀘스트는 환영합니다. 기여하기 전에 프로젝트 관리자에게 문의하세요.
@@ -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;
}
+3
View File
@@ -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;
+127
View File
@@ -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 최종 승인
- [ ] 모든 이해관계자의 최종 승인 획득
- [ ] 출시 결정 확인
+133
View File
@@ -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 법적 요구사항
- [ ] 개인정보 처리방침에 구독 관련 내용 포함 확인
- [ ] 이용 약관에 구독 조건 포함 확인
- [ ] 환불 정책 명시 확인
+204
View File
@@ -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 향후 개선 계획
- 사용자 피드백에 기반한 구독 플랜 조정
- 새로운 프리미엄 기능 추가
- 추가 결제 방식 통합 검토
+45
View File
@@ -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
+45
View File
@@ -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'
+16
View File
@@ -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.
+28
View File
@@ -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
+14
View File
@@ -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
+44
View File
@@ -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 = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="Lanebow"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.example.mobile
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+21
View File
@@ -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<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
@@ -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
+25
View File
@@ -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")
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+3
View File
@@ -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:
+160
View File
@@ -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<SubscriptionPlan> _availablePlans;
InAppPurchaseService _purchaseService;
// 주요 메서드
Future<void> loadCurrentSubscription();
Future<bool> createSubscription(SubscriptionPlanType planType, bool isYearly);
Future<bool> restoreSubscriptions();
Future<bool> changePlan(SubscriptionPlanType newPlanType, bool isYearly);
Future<bool> toggleAutoRenew();
bool canAccessFeature(SubscriptionFeature feature);
Future<bool> checkFeatureAccess(SubscriptionFeature feature, BuildContext context);
int getMaxMembersAllowed();
int getMaxEventsAllowed();
}
```
### InAppPurchaseService
```dart
class InAppPurchaseService extends ChangeNotifier {
// 주요 속성
List<ProductDetails> _products;
List<PurchaseDetails> _purchases;
bool _purchaseVerified;
Map<String, dynamic>? _verifiedPurchase;
// 주요 메서드
Future<void> initialize();
Future<bool> purchaseSubscription(SubscriptionPlanType planType);
Future<bool> restorePurchases();
Future<bool> _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를 통합하여 안전하고 사용자 친화적인 결제 경험을 제공합니다. 이 문서는 시스템의 주요 구성 요소와 데이터 흐름을 설명하여 개발자들이 시스템을 이해하고 유지보수할 수 있도록 돕습니다.
+34
View File
@@ -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
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
</dict>
</plist>
+2
View File
@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
+2
View File
@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
+43
View File
@@ -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
+43
View File
@@ -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
+728
View File
@@ -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 = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
/* 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 = "<group>";
};
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
F8390BE3267FB56413CCA22F /* Pods */,
1EEF9CC0167CE714191E3BC4 /* Frameworks */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
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 = "<group>";
};
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 = "<group>";
};
/* 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 = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* 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 */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
+13
View File
@@ -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)
}
}
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

@@ -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.
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+49
View File
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Lanebow</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>lanebow</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>
@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"
+12
View File
@@ -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.
}
}
+38
View File
@@ -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';
}
+344
View File
@@ -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<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
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<AuthService>(
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<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int _selectedIndex = 0;
bool _isInit = false;
static final List<Widget> _widgetOptions = <Widget>[
const DashboardScreen(),
const MembersScreen(),
const EventsScreen(),
const ClubStatisticsScreen(),
const ProfileScreen(),
];
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
// 클럽 선택 다이얼로그 표시
Future<void> _showClubSelectionDialog(BuildContext context) async {
final clubService = Provider.of<ClubService>(context, listen: false);
final authService = Provider.of<AuthService>(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<MemberService>(context, listen: false);
final eventService = Provider.of<EventService>(context, listen: false);
final scoreService = Provider.of<ScoreService>(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<void> _initializeServices() async {
try {
final authService = Provider.of<AuthService>(context, listen: false);
final clubService = Provider.of<ClubService>(context, listen: false);
final scoreService = Provider.of<ScoreService>(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<ClubService>(
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>[
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,
),
);
}
}
+265
View File
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic>? customFields;
final Map<String, dynamic>? notificationSettings;
final Map<String, dynamic>? 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<String, dynamic> 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<String, dynamic> 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<String, dynamic>? customFields,
Map<String, dynamic>? notificationSettings,
Map<String, dynamic>? 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(),
);
}
}
+77
View File
@@ -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<String, dynamic> 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<String, dynamic> 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(),
};
}
}
+73
View File
@@ -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<String, dynamic> 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<String, dynamic> 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(),
};
}
}
+126
View File
@@ -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<String, dynamic> 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<String, dynamic> 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,
);
}
}
+83
View File
@@ -0,0 +1,83 @@
class Score {
final String id;
final String memberId;
final String eventId;
final String clubId;
final List<int> 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<String, dynamic> json) {
return Score(
id: json['id']?.toString() ?? '',
memberId: json['memberId']?.toString() ?? '',
eventId: json['eventId']?.toString() ?? '',
clubId: json['clubId']?.toString() ?? '',
frames: json['frames'] != null ? List<int>.from(json['frames']) : [],
totalScore: json['totalScore'] ?? 0,
date: json['date'] != null ? DateTime.parse(json['date'].toString()) : DateTime.now(),
notes: json['notes']?.toString(),
);
}
Map<String, dynamic> 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<String, dynamic>? additionalStats;
ScoreStatistics({
required this.average,
required this.highScore,
required this.lowScore,
required this.gamesPlayed,
this.additionalStats,
});
factory ScoreStatistics.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() {
return {
'average': average,
'highScore': highScore,
'lowScore': lowScore,
'gamesPlayed': gamesPlayed,
'additionalStats': additionalStats,
};
}
}
+243
View File
@@ -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<String, dynamic>? 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<String, dynamic> 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<String, dynamic> 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<String> 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<SubscriptionPlan> 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,
),
];
}
}
+45
View File
@@ -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<String, dynamic> 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<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'name': name,
'role': role,
'profileImage': profileImage,
'clubId': clubId,
'clubRole': clubRole,
};
}
}
@@ -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<AdminDashboardScreen> createState() => _AdminDashboardScreenState();
}
class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
Map<String, dynamic>? _analyticsData;
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadData();
}
/// 데이터 로드
Future<void> _loadData() async {
setState(() {
_isLoading = true;
});
try {
final adminService = Provider.of<AdminService>(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<AdminService>(context);
final subscriptionService = Provider.of<SubscriptionService>(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<void> _exportData(BuildContext context, String dataType) async {
Navigator.of(context).pop(); // 다이얼로그 닫기
setState(() {
_isLoading = true;
});
try {
final adminService = Provider.of<AdminService>(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 '실패';
}
}
}
@@ -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<ForgotPasswordScreen> createState() => _ForgotPasswordScreenState();
}
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
bool _isSubmitting = false;
bool _emailSent = false;
@override
void dispose() {
_emailController.dispose();
super.dispose();
}
// 비밀번호 재설정 이메일 전송 함수
Future<void> _resetPassword() async {
if (_formKey.currentState!.validate()) {
setState(() {
_isSubmitting = true;
});
final authService = Provider.of<AuthService>(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('로그인'),
),
],
),
],
),
),
),
),
),
);
}
}
+212
View File
@@ -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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _isPasswordVisible = false;
bool _rememberMe = false;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
// 로그인 처리 함수
Future<void> _login() async {
if (_formKey.currentState!.validate()) {
final authService = Provider.of<AuthService>(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<AuthService>(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('회원가입'),
),
],
),
],
),
),
),
),
),
);
}
}
+186
View File
@@ -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<ProfileScreen> createState() => _ProfileScreenState();
}
class _ProfileScreenState extends State<ProfileScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
bool _isEditing = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final user = Provider.of<AuthService>(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<AuthService>(
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;
}
}
}

Some files were not shown because too many files have changed in this diff Show More