Compare commits

58 Commits

Author SHA1 Message Date
Yu Sung
afca24c221 docs(plan): 라이브 이미지 관련 작업 계획 문서를 추가한다 2026-03-17 17:05:57 +09:00
Yu Sung
606470ae04 feat(image): 이미지 선택 후 크롭 편집 흐름을 안정화한다 2026-03-17 17:05:22 +09:00
Yu Sung
039355c088 fix(profile): 프로필 후원 랭킹 전체보기 왕관 UI 위치와 크기를 통일한다 2026-03-17 16:11:55 +09:00
Yu Sung
5e0f6fd3e3 fix(profile): 프로필 후원 랭킹 왕관 UI를 홈과 동일하게 조정한다 2026-03-17 15:57:59 +09:00
Yu Sung
99fcf3a94c feat(image): 이미지 선택 후 크롭 편집 흐름을 적용한다 2026-03-17 14:42:31 +09:00
Yu Sung
408c3b7619 fix(community): 게시글 고정 표시인 pin 크기를 20x20으로 수정 2026-03-17 11:58:40 +09:00
Yu Sung
37e361b1e9 fix(community): 유료 미구매 게시물 롱프레스 메뉴 노출을 차단한다 2026-03-17 11:29:05 +09:00
Yu Sung
2b20e7a9a3 chore(docs): 커밋 검증 로그를 기록한다 2026-03-17 10:42:17 +09:00
Yu Sung
5e08711b29 feat(community): 크리에이터 커뮤니티 게시물 고정 기능을 추가한다 2026-03-17 10:41:28 +09:00
Yu Sung
de627e1700 fix(deeplink): 커뮤니티 댓글 딥링크를 보강한다 2026-03-13 21:39:45 +09:00
Yu Sung
3d4f67dbd5 fix(ui): 탭 상단 로고 영역 간격을 통일한다 2026-03-13 17:55:15 +09:00
Yu Sung
82889f405a fix(image) - 메시지 페이지 이동 아이콘 변경 2026-03-13 17:37:28 +09:00
Yu Sung
4d39a07fbd fix(live): 라이브 종료 시 참여자 블랙 스크린을 방지한다 2026-03-13 17:23:45 +09:00
Yu Sung
026f855bc5 fix(live): 종료 라이브 토스트를 노출한다 2026-03-13 16:49:44 +09:00
Yu Sung
fb85f3e90c refactor(toast): 공통 토스트 모디파이어를 적용한다 2026-03-13 16:32:57 +09:00
Yu Sung
19f5cc8ad6 fix(notification): 알림 리스트 라이브 이동 분기를 보정한다 2026-03-13 15:45:39 +09:00
Yu Sung
abe939e768 feat(notification): 알림 수신 설정 페이지와 이동 경로를 추가한다 2026-03-13 13:56:59 +09:00
Yu Sung
d5d5d97c2a fix(notification): 푸시 딥링크 우선 실행 분기를 보정한다 2026-03-13 11:34:10 +09:00
Yu Sung
af8813685e feat(notification): 알림함 진입 및 딥링크 라우팅을 추가한다 2026-03-12 18:35:43 +09:00
Yu Sung
2b58a0147b fix(community): 유료 잠금 배경색을 조정한다 2026-03-09 10:20:25 +09:00
Yu Sung
4fc7f6a39a fix(community): 세로 여백 조정 2026-03-06 19:21:59 +09:00
Yu Sung
cab9795557 fix(navigation): 라이브 재생 중 외부 이동을 확인 후 처리한다 2026-03-06 18:56:49 +09:00
Yu Sung
33f9ddfd12 fix(live): 프로필 라이브 상세를 프로필 화면에서 표시한다 2026-03-06 18:13:09 +09:00
Yu Sung
298c02b83f feat(live): 라이브 상세를 전역 바텀시트로 표시한다 2026-03-06 17:46:26 +09:00
Yu Sung
42ce09d927 refactor(navigation): 전역 경로 기반 단일 내비게이션 흐름으로 전환한다 2026-03-06 16:34:44 +09:00
Yu Sung
f145de87aa feat(community): 커뮤니티 보기 전환 탭과 아이콘을 추가한다 2026-03-06 14:25:04 +09:00
Yu Sung
d29e23b9cf fix(live): 라이브룸 팔로우 버튼 스타일을 정렬한다 2026-03-05 15:41:37 +09:00
Yu Sung
ca565a2b5f feat(live): 라이브룸 게스트 상단에 팔로우 버튼과 알림 옵션을 추가한다 2026-03-05 10:55:55 +09:00
Yu Sung
f0763d75c2 fix(community): 커뮤니티 전체 아이템 말줄임과 폰트를 정렬, 텍스트 확장 동작을 개선한다 2026-03-04 17:32:56 +09:00
Yu Sung
9d6f0c648b fix(profile): 프로필 소셜 URL 필드를 신규 명세로 정리한다 2026-02-27 13:45:49 +09:00
Yu Sung
38fb818f4b fix(detail): 콘텐츠/시리즈 상세 로딩 실패 시 자동 복귀를 적용한다 2026-02-26 01:20:37 +09:00
Yu Sung
db68aa90d2 fix(profile): 채널 후원 비밀 문구를 분리하고 자기 프로필 후원 버튼을 숨긴다 2026-02-26 00:44:37 +09:00
Yu Sung
b84b996059 fix(profile): 차단 유저 프로필 진입 실패 시 자동 복귀를 추가한다 2026-02-25 22:51:06 +09:00
Yu Sung
a4d6de83db fix(report): 사용자 차단 다이얼로그 문구를 국제화한다 2026-02-25 22:30:22 +09:00
Yu Sung
c7ec9045ff feat(profile): 크리에이터 상세정보에서 닉네임의 크기 32, SNS 아이콘 margin 16 2026-02-25 21:33:58 +09:00
Yu Sung
32d1d970e4 feat(explorer): 채널 후원 목록/등록 기능을 추가한다 2026-02-25 20:57:23 +09:00
Yu Sung
e9bd1e7396 feat(explorer): 크리에이터 상세정보 다이얼로그와 SNS 링크를 추가한다 2026-02-25 16:28:48 +09:00
Yu Sung
7ff9360b1e fix(live-room): 유료 라이브 최소 30캔 검증을 추가한다 2026-02-25 15:30:38 +09:00
Yu Sung
aaffd08cb5 docs(commit-policy): 커밋 정책 스킬과 검증 절차를 정비한다 2026-02-25 14:58:12 +09:00
Yu Sung
b796f6d9c5 라이브룸 V2V 번역 자막 기능을 추가한다
라이브룸에서 진행자 언어와 기기 언어가 다를 때 자막 토글을 제공한다.
룸 정보 응답에 V2V 워커 토큰과 진행자 언어 코드를 포함한다.
Agora V2V 에이전트 참여와 종료 API 연동을 추가한다
2026-02-09 21:11:17 +09:00
Yu Sung
7f703024d8 룰렛 설정 입력 가시성과 프로필 메뉴 레이아웃 개선
룰렛 설정에서 입력 필드 포커스 시 항목을 중앙으로 이동한다.
키보드에 가려지지 않도록 입력 가시성을 높인다.
프로필 메뉴 오버레이의 하단 안전영역 패딩을 제거한다.
2026-02-09 17:43:45 +09:00
Yu Sung
7cba6de2fc 프로필 상단 탐색 사용성을 개선한다 2026-02-09 17:24:54 +09:00
Yu Sung
5e0899419e 응원 항목 너비가 화면 폭에 맞게 표시된다
프로필 팬톡의 응원 항목이 기기 화면 폭에 맞춰 표시되어
콘텐츠 정렬이 안정적으로 보인다
2026-02-09 11:02:54 +09:00
Yu Sung
68976e221c 라이브 방이 19금일 때 제목 앞에 🔞 대신 방패(ic_shield)가 표시되도록 수정 2026-02-04 17:41:55 +09:00
Yu Sung
3590db82be 라이브 룸 - 라이브 크리에이터 프로필 영역에 팔로우 버튼 제거 2026-02-04 16:58:14 +09:00
Yu Sung
3456510eec 팬톡 TextField가 Focus 되었을 때 자판이 해당 영역을 가리는 버그 수정 2026-02-04 16:12:37 +09:00
Yu Sung
8d3aed41c2 크리 채널 커뮤니티 등록 - 썸네일 선택시 보이지 않던 버그 수정 2026-02-04 15:53:30 +09:00
Yu Sung
c0288b5eb8 크리에이터 채널
- 후원랭킹이 없어도 내 채널에서는 후원랭킹 영역이 보이도록 수정
- 채널 주인이 아닌 다른 유저는 커뮤니티 게시물이 없으면 커뮤니티 영역이 보이지 않도록 수정
2026-02-04 11:26:04 +09:00
Yu Sung
13f8d924c0 기부 랭킹 기간 선택 추가
프로필 기부 랭킹 조회와 프로필 갱신 요청에\n기간 값을 전달한다.
2026-02-03 18:38:36 +09:00
Yu Sung
d686223362 연령 제한에 따른 성별 제한 전송 조정 2026-02-03 14:09:59 +09:00
Yu Sung
652fe3dc13 라이브 전체보기 - 성인 라이브 입장에 본인인증 흐름 추가 2026-02-03 11:28:13 +09:00
Yu Sung
36bf533269 라이브 상세, 라이브 룸 - 19금 표시를 이모지로 변경 2026-02-02 19:00:00 +09:00
Yu Sung
5159debf7f 라이브 성별 제한 옵션 추가
라이브 생성과 수정 요청에 성별 제한 값을 포함한다.
라이브 정보 조회 응답에 성별 제한 값을 제공한다.
2026-02-02 18:20:26 +09:00
Yu Sung
b985af4497 성인 라이브 입장에 본인인증 흐름 추가
라이브 지금 항목 탭을 상위에서 처리 가능하도록 노출
2026-02-02 11:48:12 +09:00
Yu Sung
9e97c301b8 지금 라이브 중 19금 방송 방패 표시 2026-01-30 18:03:00 +09:00
Yu Sung
f9d84efbe1 라이브 프로필 이미지 크기 비율 조정 2026-01-30 17:48:07 +09:00
Yu Sung
5352d28fe3 지금 라이브 중 아이템 하단에 크리에이터가 설정한 언어와 관심사 1개 랜덤 표시 2026-01-30 17:43:33 +09:00
Yu Sung
26f67028cf 라이브 전체보기 그리드 레이아웃 조정 2026-01-30 16:42:00 +09:00
255 changed files with 9513 additions and 3415 deletions

1
.gitignore vendored
View File

@@ -279,6 +279,5 @@ xcuserdata
.kiro/
.junie/
docs/
# End of https://www.toptal.com/developers/gitignore/api/macos,xcode,appcode,swift,swiftpackagemanager,swiftpm,fastlane,cocoapods

View File

@@ -0,0 +1,21 @@
---
description: commit-policy 스킬을 로드해 커밋 메시지 생성과 전후 검증을 수행한다
agent: build
subtask: true
---
작업 목표:
현재 변경사항을 안전하게 커밋한다.
필수 시작 단계:
1. `skill` 도구로 `commit-policy` 스킬을 먼저 로드한다.
- `skill({ name: "commit-policy" })`
실행 단계:
1. 로드한 `commit-policy` 스킬의 Hard Requirements와 Execution Flow를 그대로 수행한다.
2. `AGENTS.md`의 최소 정책(형식/한글 description/검증 스크립트)을 항상 만족한다.
3. `$ARGUMENTS`가 있으면 scope 또는 description 의도에 반영하되, 스킬 규칙과 형식을 깨지 않는다.
4. 마지막에 실행 명령과 pre-check/post-check PASS/FAIL 핵심 결과를 간단히 보고한다.
추가 사용자 의도:
$ARGUMENTS

View File

@@ -0,0 +1,46 @@
---
name: commit-policy
description: Apply this skill for any git commit task in this repository. It enforces commit message format and validation flow defined in AGENTS.md and work/scripts/check-commit-message-rules.sh, including pre-commit and post-commit verification.
---
# Commit Policy Skill
Use this workflow whenever the task includes creating a commit.
## Required References
- `@AGENTS.md`
- `@work/scripts/check-commit-message-rules.sh`
## Hard Requirements
1. Use commit subject format: `<type>(scope): <description>`.
2. `type` must be lowercase (for example `feat`, `fix`, `chore`, `docs`, `refactor`, `test`).
3. `description` must include Korean text and stay concise in imperative present tone.
4. Optional footer must use `Refs: #123` or `Refs: #123, #456` format.
5. Never commit secret files (`.env`, key/token/secret credential files).
6. Never bypass hooks with `--no-verify`.
## Execution Flow
1. Inspect context with:
- `git status`
- `git diff --cached`
- `git diff`
- `git log -5 --oneline`
2. Stage commit target files only. Exclude suspicious secret-bearing files.
3. Draft commit message from the change intent (focus on why, not only what).
4. Run pre-commit validation with the full draft message:
- `./work/scripts/check-commit-message-rules.sh --message "<full message>"`
5. If validation fails, revise message and re-run until PASS.
6. Commit using the validated message.
7. Run post-commit validation:
- `./work/scripts/check-commit-message-rules.sh`
8. Report executed commands and PASS/FAIL summary.
## Output Checklist
- Final commit subject.
- Whether pre-check passed.
- Whether post-check passed.
- Any excluded files and reason.

174
AGENTS.md
View File

@@ -1,18 +1,160 @@
질문에 대한 답변과 설명은 한국어로 한다.
# AGENTS.md
`SodaLive` 저장소에서 작업하는 에이전트 실행 가이드다.
## Quality Assurance Guidelines
## 커뮤니케이션 규칙
- **"질문에 대한 답변과 설명은 한국어로 한다."**
- 사용자에게 전달하는 설명, 진행 상황, 결과 보고는 한국어로 작성한다.
- 코드 식별자, 파일 경로, 명령어는 원문(영문) 그대로 유지한다.
### Commit Standards
1. Write in Korean.
2. Use the present tense in the subject line (e.g., "Add feature" not "Added feature").
3. Keep the subject line to 50 characters or less.
4. Add a blank line between the subject and body.
5. Keep the body to 72 characters or less per line.
6. Within a paragraph, only break lines when the text exceeds 72 characters.
7. Describe changes to public API features and do not include implementation details such as package-private code.
8. Do not mention test code in commit messages.
9. Do not use any prefix (such as "fix:", "update:", "docs:", "feat:", etc.) in the subject line.
10. Do not start the subject line with a lowercase letter unless the first word is a function name or another identifier that is conventionally lowercase and there is a clear, justifiable reason for the exception. Otherwise, always start with an uppercase letter.
11. Do not include tool advertisements, branding, or promotional content in commit messages.
12. Use separate git commands to stage files before committing.
13. Always validate commits using `work/scripts/check-commit-message-rules.sh` and fix until validation passes.
## 저장소 범위
- 앱 소스: `SodaLive/Sources/**`
- 프로젝트/스킴: `SodaLive.xcodeproj`, `SodaLive.xcworkspace`
- 의존성 설정: `Podfile`, `Podfile.lock`
- 운영 스크립트: `work/scripts/**`
- 생성/외부 결과물: `Pods/**`, `generated/**`, `build/**`
- 작업 계획 문서: `docs/**`
### 수정 우선순위
- 기능 변경은 `SodaLive/Sources/**`에서 해결한다.
- 프로젝트 설정 변경은 필요한 경우에만 수행한다.
- `Pods/**`, `generated/**`는 직접 수정하지 않는다.
- `build/**`는 빌드 산출물로 간주하며 수정 대상이 아니다.
## 빌드/테스트/검증 명령
아래 명령은 현재 저장소에서 확인된 공식 진입점이다.
### 1) 의존성 설치
- `pod install`
- 근거: `Podfile`에 CocoaPods 타깃(`SodaLive`, `SodaLive-dev`) 정의.
### 2) 스킴/타깃 확인
- `xcodebuild -workspace "SodaLive.xcworkspace" -list`
- 근거: 공유 스킴 `SodaLive`, `SodaLive-dev` 존재.
### 3) 빌드
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
### 4) 테스트(전체)
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
### 5) 단일 테스트 실행
- 일반 형식(테스트 타깃이 있는 경우):
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -only-testing:"SodaLiveTests/TestClass/testMethod" test`
- **현재 주의사항**:
- `SodaLive.xcodeproj/project.pbxproj` 기준으로 앱 타깃 중심 구성이고 테스트 번들 타깃이 확인되지 않는다.
- 따라서 현재 상태에서는 단일 테스트 지정이 실질적으로 동작하지 않을 수 있다.
### 6) 린트/포맷
- 저장소에 공식 `swiftlint`/`swiftformat` 실행 스크립트는 확인되지 않았다.
- `generated/*.generated.swift``swiftlint:disable all` 주석은 존재하나, 이는 생성 코드 보호 목적이다.
- 린트 도구를 도입/추가하면 본 문서 명령 섹션을 즉시 갱신한다.
## 코드 스타일 가이드
### 아키텍처/레이어
- 기본 흐름은 `View -> ViewModel -> Repository -> Api(TargetType)`를 따른다.
- API는 `enum ...Api: TargetType`, 저장소는 `final class ...Repository` 형태를 우선 사용한다.
- 상태 모델은 `struct`/`enum` 중심으로 두고, 화면 상태는 `ObservableObject`에서 관리한다.
### 임포트 규칙
- 시스템 프레임워크(`Foundation`, `SwiftUI`, `Combine`)를 먼저 배치한다.
- 서드파티(`Moya`, `CombineMoya`, SDK들)는 이후 배치한다.
- import 그룹 사이에는 한 줄 공백으로 의미 단위를 분리한다.
### 포맷/구조
- 들여쓰기는 4칸 스페이스를 사용한다.
- 프로퍼티 선언, 비즈니스 로직, 헬퍼 메서드는 공백 줄로 구획한다.
- 클로저 체인은 줄바꿈해 가독성을 유지한다.
### 타입/상태 관리
- ViewModel은 `final class ...: ObservableObject` 패턴을 우선한다.
- View가 소유하는 객체는 `@StateObject`, 외부 주입 객체는 `@ObservedObject`를 사용한다.
- 네트워크 반환은 `AnyPublisher<Response, MoyaError>` 패턴을 기본으로 따른다.
### 네이밍 규칙
- 타입명은 PascalCase (`HomeViewModel`, `UserRepository`, `UserApi`).
- 변수/함수는 camelCase (`errorMessage`, `getMemberInfo`).
- 역할을 이름에 반영한다 (`*View`, `*ViewModel`, `*Repository`, `*Api`, `*Request`, `*Response`).
### 비동기/Combine 규칙
- 비동기 처리는 Combine의 `sink`, `receiveValue`, `.store(in: &subscription)` 패턴을 따른다.
- `sink` 완료 블록에서 `.failure`를 반드시 처리한다.
- 클로저 캡처는 상황에 맞게 `[weak self]` 또는 `[unowned self]`를 선택한다.
### 에러 처리 규칙
- 사용자 노출 오류는 `errorMessage`와 팝업 플래그(`isShowPopup`)로 일관되게 처리한다.
- JSON 파싱은 `do/catch + JSONDecoder` 패턴을 따른다.
- **신규 코드에서 빈 `catch`는 금지**하고, 최소한 로깅 또는 명시적 무시 사유를 남긴다.
### 로깅 규칙
- 디버그 로그는 `DEBUG_LOG`, 오류 로그는 `ERROR_LOG`를 사용한다.
- `print`는 임시 디버깅 목적 외 신규 코드에서 지양한다.
### 네트워크/헤더 규칙
- 공통 Moya 플러그인(`AuthPlugin`, `AcceptLanguagePlugin`) 흐름을 유지한다.
- 언어 헤더는 `LanguageHeaderProvider.current`를 기준으로 사용한다.
### 문자열/다국어
- 신규 사용자 노출 문자열은 가능하면 `I18n` 경로를 우선 사용한다.
- 다국어 기능 수정 시 `Settings/Language` 모듈과 `Accept-Language` 헤더 흐름을 함께 점검한다.
### 주석/문서화
- 자명한 코드에는 주석을 남기지 않는다.
- 복잡한 분기, 외부 제약, 부작용이 있는 로직에만 주석을 추가한다.
## Cursor/Copilot 규칙 반영
- 아래 파일 존재 여부를 확인해 AGENTS와 함께 유지한다.
- `.cursor/rules/**`
- `.cursorrules`
- `.github/copilot-instructions.md`
- 현재 저장소에서는 위 파일들이 확인되지 않았다.
- 추후 파일이 추가되면 AGENTS.md에 요약 규칙을 동기화한다.
- 충돌 우선순위 기본값:
- 범위가 더 구체적인 규칙이 우선한다(경로 특화 > 저장소 전역).
- Cursor: `.cursor/rules/**` > `.cursorrules` > `AGENTS.md`
- Copilot: `.github/instructions/**`(존재 시) > `.github/copilot-instructions.md` > `AGENTS.md`
## 커밋 메시지 규칙 (표준 Conventional Commits)
- 커밋 상세 가이드/절차는 `.opencode/skills/commit-policy/SKILL.md`를 단일 기준으로 사용한다.
- 커밋 작업 시작 시 `skill` 도구로 `commit-policy`를 먼저 로드한다.
- 기본 형식은 `<type>(scope): <description>`를 사용한다.
- `type`은 소문자(`feat`, `fix`, `chore`, `docs`, `refactor`, `test`)를 사용한다.
- 제목(description)은 한글로 작성하고, 명령형/간결한 현재형으로 작성한다.
- 이슈 참조 footer는 `Refs: #123` 또는 `Refs: #123, #456` 형식을 사용한다.
### 커밋 메시지 검증 절차
- `git commit` 직후 `work/scripts/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 확인한다.
- 스크립트 결과가 `[FAIL]`이면 커밋 메시지를 수정한 뒤 다시 검증한다.
## 작업 절차 체크리스트
- 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다.
- 변경 중: 공개 API 스키마를 임의 변경하지 말고 작은 단위로 안전하게 수정한다.
- 변경 후: 영향 범위 파일에 대해 빌드/테스트/로그/다국어 키를 점검한다.
- 커밋 직후: `commit-policy` 스킬을 로드하고 메시지 검증 스크립트를 실행한다.
## 작업 계획 문서 규칙 (docs)
- 모든 작업 시작 전에 `docs` 폴더 아래 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현한다.
- 계획 문서 파일명은 `[날짜]_구현할내용한글.md` 형식을 사용한다.
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
- 구현 항목은 기능/작업 단위 체크박스(`- [ ]`)로 작성하고 완료 즉시 `- [x]`로 갱신한다.
- 작업 도중 범위가 변경되면 계획 문서 체크리스트를 먼저 업데이트한 뒤 구현한다.
- 결과 보고 시 문서 하단에 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)을 한국어로 남긴다.
- 후속 수정이 발생해도 기존 검증 기록은 삭제/덮어쓰기 없이 누적한다.
## 문서 유지보수 규칙
- 불확실한 규칙은 추측으로 채우지 말고 근거 파일 경로를 먼저 확인한다.
- 에이전트 안내 문구는 한국어 중심으로 유지한다.
- 명령/경로/타깃명이 바뀌면 본 문서를 즉시 업데이트한다.
## 에이전트 동작 원칙
- 추측하지 말고 근거 파일을 읽고 결정한다.
- 기존 관례를 깨는 변경은 이유가 명확할 때만 수행한다.
- 불필요한 리팩터링 확장은 피하고 요청 범위를 우선 충족한다.
- 결과 보고 시 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 남긴다.
## 설정/보안 유의사항
- 토큰/키/개인정보를 코드/로그/문서에 하드코딩하지 않는다.
- 인증 관련 헤더/토큰 처리 로직(`AuthPlugin`, `UserDefaultsKey.token`) 수정 시 회귀 위험을 함께 점검한다.
- 외부 SDK 키 변경 시 빌드 설정과 런타임 초기화 지점을 함께 검토한다.

View File

@@ -1,5 +1,5 @@
{
"originHash" : "9f35428c4c178ca4a8bfa4b72544585a9e4d5b119825b423e1d2166cbe03fe37",
"originHash" : "cf552e0db687218f4a2207a39678af43731c56f6f8ea12b111a15ac39574aa38",
"pins" : [
{
"identity" : "abseil-cpp-binary",
@@ -199,15 +199,6 @@
"revision" : "28c3261c9836cd3f4d64ab6419a3628d2b167811"
}
},
{
"identity" : "popupview",
"kind" : "remoteSourceControl",
"location" : "https://github.com/exyte/PopupView.git",
"state" : {
"revision" : "1b99d6e9872ef91fd57aaef657661b5a00069638",
"version" : "1.3.1"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "ic_bell.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "ic_bell_settings.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "ic_community_grid.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "ic_community_grid_selected.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "ic_community_list.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "ic_community_list_selected.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "ic_live_creator_follow_alarm.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "ic_live_creator_follow_no_alarm.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "ic_live_creator_follow_plus.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,6 +1,7 @@
{
"images" : [
{
"filename" : "ic_message.png",
"idiom" : "universal",
"scale" : "1x"
},
@@ -9,7 +10,6 @@
"scale" : "2x"
},
{
"filename" : "ic_message.png",
"idiom" : "universal",
"scale" : "3x"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_shield.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "ic_sns_fancimm.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "ic_sns_instagram.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "ic_sns_kakao.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "ic_sns_x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "ic_sns_youtube.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -16,6 +16,9 @@
}
}
}
},
" · %@" : {
},
" (" : {
"localizations" : {
@@ -2503,6 +2506,9 @@
}
}
}
},
"고정 해제" : {
},
"공개 설정" : {
"localizations" : {
@@ -2967,6 +2973,9 @@
}
}
}
},
"누적" : {
},
"눌러서 잠금해제" : {
"localizations" : {
@@ -4120,22 +4129,6 @@
}
}
},
"모든 기기에서 로그아웃" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Log out from all devices"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "全端末からログアウト"
}
}
}
},
"모집완료" : {
"localizations" : {
"en" : {
@@ -4167,6 +4160,25 @@
}
}
}
},
"모든 기기에서 로그아웃" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Log out from all devices"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "全端末からログアウト"
}
}
}
},
"모서리 원을 드래그해서 크롭 영역 크기를 조정하세요" : {
},
"모집중" : {
"localizations" : {
@@ -5207,6 +5219,9 @@
}
}
}
},
"서비스 알림" : {
},
"설정" : {
"localizations" : {
@@ -5815,6 +5830,9 @@
}
}
}
},
"알림이 없습니다." : {
},
"앱 버전 정보" : {
"localizations" : {
@@ -6888,54 +6906,6 @@
}
}
},
"인기 캐릭터" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Popular"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "人気キャラ"
}
}
}
},
"인기 캐릭터 채팅" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Top character"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "人気キャラチャット"
}
}
}
},
"일" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sun"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "日"
}
}
}
},
"이메일" : {
"localizations" : {
"en" : {
@@ -7096,6 +7066,38 @@
}
}
},
"인기 캐릭터" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Popular"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "人気キャラ"
}
}
}
},
"인기 캐릭터 채팅" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Top character"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "人気キャラチャット"
}
}
}
},
"인기 콘텐츠" : {
"localizations" : {
"en" : {
@@ -7160,6 +7162,22 @@
}
}
},
"일" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sun"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "日"
}
}
}
},
"일간 랭킹" : {
"localizations" : {
"en" : {
@@ -7831,6 +7849,9 @@
}
}
}
},
"주간" : {
},
"중복확인" : {
"localizations" : {
@@ -8391,6 +8412,9 @@
}
}
}
},
"최상단에 고정" : {
},
"최신 콘텐츠" : {
"localizations" : {
@@ -8632,22 +8656,6 @@
}
}
},
"캔" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cans"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "CAN"
}
}
}
},
"캐릭터 정보" : {
"localizations" : {
"en" : {
@@ -8664,6 +8672,22 @@
}
}
},
"캔" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cans"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "CAN"
}
}
}
},
"캔 충전" : {
"localizations" : {
"en" : {
@@ -9608,7 +9632,18 @@
}
}
},
"함께 보낼 %@메시지 입력(최대 %lld자)" : {
"localizations" : {
"ko" : {
"stringUnit" : {
"state" : "new",
"value" : "함께 보낼 %1$@메시지 입력(최대 %2$lld자)"
}
}
}
},
"함께 보낼 %@메시지 입력(최대 1000자)" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {

View File

@@ -0,0 +1,234 @@
import Foundation
enum AppDeepLinkAction {
case live(roomId: Int)
case content(contentId: Int)
case series(seriesId: Int)
case community(creatorId: Int, postId: Int?)
case message
case audition
}
enum AppDeepLinkSource {
case external
case notificationList
}
enum AppDeepLinkHandler {
static func handle(url: URL, source: AppDeepLinkSource = .external) -> Bool {
guard isSupportedScheme(url) else {
return false
}
guard let action = parseAction(url: url) else {
return false
}
DispatchQueue.main.async {
if case .splash = AppState.shared.rootStep {
AppState.shared.pendingDeepLinkAction = action
return
}
apply(action: action, source: source)
}
return true
}
static func handle(urlString: String, source: AppDeepLinkSource = .external) -> Bool {
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: trimmed) else {
return false
}
return handle(url: url, source: source)
}
static func apply(action: AppDeepLinkAction) {
apply(action: action, source: .external)
}
private static func apply(action: AppDeepLinkAction, source: AppDeepLinkSource) {
switch action {
case .live(let roomId):
guard roomId > 0 else { return }
AppState.shared.isPushRoomFromDeepLink = source == .external
AppState.shared.pushRoomId = 0
AppState.shared.pushRoomId = roomId
case .content(let contentId):
guard contentId > 0 else { return }
AppState.shared.setAppStep(step: .contentDetail(contentId: contentId))
case .series(let seriesId):
guard seriesId > 0 else { return }
AppState.shared.setAppStep(step: .seriesDetail(seriesId: seriesId))
case .community(let creatorId, let postId):
guard creatorId > 0 else { return }
if let postId = postId, postId > 0 {
AppState.shared.setPendingCommunityCommentDeepLink(creatorId: creatorId, postId: postId)
} else {
AppState.shared.clearPendingCommunityCommentDeepLink()
}
AppState.shared.setAppStep(step: .creatorCommunityAll(creatorId: creatorId))
case .message:
AppState.shared.setAppStep(step: .message)
case .audition:
AppState.shared.setAppStep(step: .audition)
}
}
private static func isSupportedScheme(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased() else {
return false
}
let appScheme = APPSCHEME.lowercased()
return scheme == appScheme || scheme == "voiceon" || scheme == "voiceon-test"
}
private static func parseAction(url: URL) -> AppDeepLinkAction? {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
if let routeAction = parsePathStyle(url: url, components: components) {
return routeAction
}
return parseQueryStyle(components: components)
}
private static func parsePathStyle(url: URL, components: URLComponents?) -> AppDeepLinkAction? {
let pathComponents = url.pathComponents.filter { $0 != "/" && !$0.isEmpty }
let host = components?.host?.lowercased() ?? ""
if !host.isEmpty {
let identifier = pathComponents.first
return makeAction(route: host, identifier: identifier, components: components)
}
guard !pathComponents.isEmpty else {
return nil
}
let route = pathComponents[0].lowercased()
let identifier = pathComponents.count > 1 ? pathComponents[1] : nil
return makeAction(route: route, identifier: identifier, components: components)
}
private static func parseQueryStyle(components: URLComponents?) -> AppDeepLinkAction? {
guard let queryItems = components?.queryItems else {
return nil
}
var queryMap: [String: String] = [:]
for item in queryItems {
queryMap[item.name.lowercased()] = item.value
}
if let roomId = queryMap["room_id"], let value = Int(roomId), value > 0 {
return .live(roomId: value)
}
if let contentId = queryMap["content_id"], let value = Int(contentId), value > 0 {
return .content(contentId: value)
}
if let seriesId = queryMap["series_id"], let value = Int(seriesId), value > 0 {
return .series(seriesId: value)
}
if let communityId = queryMap["community_id"], let value = Int(communityId), value > 0 {
return .community(creatorId: value, postId: communityPostId(queryMap: queryMap))
}
if queryMap["message_id"] != nil {
return .message
}
if queryMap["audition_id"] != nil {
return .audition
}
return nil
}
private static func makeAction(route: String, identifier: String?, components: URLComponents?) -> AppDeepLinkAction? {
switch route {
case "live":
guard let identifier = identifier, let roomId = Int(identifier), roomId > 0 else {
return nil
}
return .live(roomId: roomId)
case "content":
guard let identifier = identifier, let contentId = Int(identifier), contentId > 0 else {
return nil
}
return .content(contentId: contentId)
case "series":
guard let identifier = identifier, let seriesId = Int(identifier), seriesId > 0 else {
return nil
}
return .series(seriesId: seriesId)
case "community":
let postId = communityPostId(queryItems: components?.queryItems)
if let identifier = identifier, let creatorId = Int(identifier), creatorId > 0 {
return .community(creatorId: creatorId, postId: postId)
}
guard let creatorId = fallbackCommunityCreatorId() else {
return nil
}
return .community(creatorId: creatorId, postId: postId)
case "message":
return .message
case "audition":
return .audition
default:
return nil
}
}
private static func communityPostId(queryItems: [URLQueryItem]?) -> Int? {
guard let queryItems = queryItems else {
return nil
}
var queryMap: [String: String] = [:]
for item in queryItems {
queryMap[item.name.lowercased()] = item.value
}
return communityPostId(queryMap: queryMap)
}
private static func communityPostId(queryMap: [String: String]) -> Int? {
if let postId = queryMap["postid"], let value = Int(postId), value > 0 {
return value
}
if let postId = queryMap["post_id"], let value = Int(postId), value > 0 {
return value
}
return nil
}
private static func fallbackCommunityCreatorId() -> Int? {
let userId = UserDefaults.int(forKey: .userId)
return userId > 0 ? userId : nil
}
}

View File

@@ -265,6 +265,15 @@ extension AppDelegate : UNUserNotificationCenterDelegate {
// With swizzling disabled you must let Messaging know about the message, for Analytics
Messaging.messaging().appDidReceiveMessage(userInfo)
Notifly.userNotificationCenter(center, didReceive: response)
let deepLinkString = (userInfo["deep_link"] as? String ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if !deepLinkString.isEmpty {
_ = AppDeepLinkHandler.handle(urlString: deepLinkString)
completionHandler()
return
}
let roomIdString = userInfo["room_id"] as? String
let contentIdString = userInfo["content_id"] as? String

View File

@@ -7,13 +7,31 @@
import Foundation
struct AppRoute: Hashable {
let id = UUID()
}
struct LiveDetailSheetState {
let roomId: Int
let onClickParticipant: () -> Void
let onClickReservation: () -> Void
let onClickStart: () -> Void
let onClickCancel: () -> Void
}
class AppState: ObservableObject {
static let shared = AppState()
private var appStepBackStack = [AppStep]()
private var routeStepMap: [AppRoute: AppStep] = [:]
@Published var alreadyUpdatedMarketingInfo = false
@Published private(set) var appStep: AppStep = .splash
@Published private(set) var rootStep: AppStep = .splash
@Published var navigationPath: [AppRoute] = [] {
didSet {
syncStepWithNavigationPath()
}
}
@Published var isShowPlayer = false {
didSet {
@@ -31,6 +49,10 @@ class AppState: ObservableObject {
@Published var pushMessageId = 0
@Published var pushAudioContentId = 0
@Published var pushSeriesId = 0
@Published var pendingDeepLinkAction: AppDeepLinkAction? = nil
@Published var pendingCommunityCommentCreatorId = 0
@Published var pendingCommunityCommentPostId = 0
@Published var isPushRoomFromDeepLink = false
@Published var roomId = 0 {
didSet {
if roomId <= 0 {
@@ -52,29 +74,113 @@ class AppState: ObservableObject {
@Published var isShowErrorPopup = false
@Published var errorMessage = ""
@Published var liveDetailSheet: LiveDetailSheetState? = nil
private func syncStepWithNavigationPath() {
let validRoutes = Set(navigationPath)
routeStepMap = routeStepMap.filter { validRoutes.contains($0.key) }
if let route = navigationPath.last,
let step = routeStepMap[route] {
appStep = step
} else {
appStep = rootStep
}
}
func appStep(for route: AppRoute) -> AppStep? {
routeStepMap[route]
}
func setAppStep(step: AppStep) {
switch step {
case .splash, .main:
appStepBackStack.removeAll()
default:
appStepBackStack.append(appStep)
}
DispatchQueue.main.async {
self.appStep = step
switch step {
case .splash, .main:
self.liveDetailSheet = nil
self.rootStep = step
self.routeStepMap.removeAll()
self.navigationPath.removeAll()
self.appStep = step
case .liveDetail(let roomId, let onClickParticipant, let onClickReservation, let onClickStart, let onClickCancel):
self.liveDetailSheet = LiveDetailSheetState(
roomId: roomId,
onClickParticipant: onClickParticipant,
onClickReservation: onClickReservation,
onClickStart: onClickStart,
onClickCancel: onClickCancel
)
self.appStep = step
default:
let route = AppRoute()
self.routeStepMap[route] = step
self.navigationPath.append(route)
self.appStep = step
}
}
}
func back() {
if let step = appStepBackStack.popLast() {
self.appStep = step
} else {
self.appStep = .main
DispatchQueue.main.async {
if self.liveDetailSheet != nil {
self.liveDetailSheet = nil
self.syncStepWithNavigationPath()
return
}
if self.navigationPath.isEmpty {
self.rootStep = .main
self.appStep = .main
return
}
_ = self.navigationPath.popLast()
}
}
func hideLiveDetailSheet() {
DispatchQueue.main.async {
self.liveDetailSheet = nil
self.syncStepWithNavigationPath()
}
}
func consumePendingDeepLinkAction() -> AppDeepLinkAction? {
let action = pendingDeepLinkAction
pendingDeepLinkAction = nil
return action
}
func setPendingCommunityCommentDeepLink(creatorId: Int, postId: Int) {
guard creatorId > 0, postId > 0 else {
return
}
pendingCommunityCommentCreatorId = creatorId
pendingCommunityCommentPostId = postId
}
func consumePendingCommunityCommentPostId(creatorId: Int) -> Int? {
guard creatorId > 0 else {
return nil
}
guard pendingCommunityCommentCreatorId == creatorId,
pendingCommunityCommentPostId > 0 else {
return nil
}
let postId = pendingCommunityCommentPostId
clearPendingCommunityCommentDeepLink()
return postId
}
func clearPendingCommunityCommentDeepLink() {
pendingCommunityCommentCreatorId = 0
pendingCommunityCommentPostId = 0
}
// ( -> ) UI
func softRestart() {
isRestartApp = true

View File

@@ -41,6 +41,8 @@ enum AppStep {
case privacy
case notificationSettings
case notificationReceiveSettings
case contentViewSettings
@@ -75,7 +77,9 @@ enum AppStep {
case followerList(userId: Int)
case userProfileDonationAll(userId: Int)
case channelDonationAll(creatorId: Int)
case userProfileFanTalkAll(userId: Int)
case createLive(
@@ -159,6 +163,8 @@ enum AppStep {
case introduceCreatorAll
case message
case notificationList
case pointStatus(refresh: () -> Void)

View File

@@ -84,6 +84,10 @@ struct SodaLiveApp: App {
comps.path.lowercased() == "/result" {
canPgPaymentViewModel.handleVerifyOpenURL(url)
} else {
if AppDeepLinkHandler.handle(url: url) {
return
}
_ = LoginManager.shared.application(UIApplication.shared, open: url, options: [:])
ApplicationDelegate.shared.application(UIApplication.shared, open: url, options: [:])
AppsFlyerLib.shared().handleOpen(url)

View File

@@ -170,29 +170,7 @@ struct AuditionApplicantRecordingView: View {
}
}
}
.popup(isPresented: $soundManager.isShowPopup, type: .toast, position: .top, autohideIn: 2) {
GeometryReader { geo in
HStack {
Spacer()
Text(soundManager.errorMessage)
.padding(.vertical, 13.3)
.padding(.horizontal, 6.7)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.cornerRadius(20)
.padding(.top, 66.7)
Spacer()
}
.onDisappear {
if soundManager.onClose {
isShowing = false
}
}
}
}
.sodaToast(isPresented: $soundManager.isShowPopup, message: soundManager.errorMessage, autohideIn: 2)
}
}

View File

@@ -130,23 +130,7 @@ struct AuditionApplyView: View {
}
.ignoresSafeArea()
}
.popup(isPresented: $isShowPopup, type: .toast, position: .top, autohideIn: 2) {
GeometryReader { geo in
HStack {
Spacer()
Text(errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.cornerRadius(20)
.padding(.top, 66.7)
Spacer()
}
}
}
.sodaToast(isPresented: $isShowPopup, message: errorMessage, autohideIn: 2)
.onAppear {
withAnimation {
isShow = true

View File

@@ -68,21 +68,7 @@ struct AuditionDetailView: View {
}
}
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
.onAppear {
viewModel.getAuditionDetail(auditionId: auditionId) {
AppState.shared.back()

View File

@@ -150,36 +150,8 @@ struct AuditionRoleDetailView: View {
.padding(.horizontal, 13.3)
}
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
.popup(isPresented: $soundManager.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(soundManager.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
.sodaToast(isPresented: $soundManager.isShowPopup, message: soundManager.errorMessage, autohideIn: 2)
.onAppear {
viewModel.onFailure = { AppState.shared.back() }
viewModel.auditionRoleId = roleId

View File

@@ -120,23 +120,7 @@ struct CharacterView: View {
}
}
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) {
GeometryReader { geo in
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: geo.size.width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.cornerRadius(20)
.padding(.top, 66.7)
Spacer()
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}

View File

@@ -132,25 +132,7 @@ struct CharacterDetailView: View {
}
.navigationTitle("")
.navigationBarBackButtonHidden()
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) {
GeometryReader { geo in
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(alignment: .center)
.frame(maxWidth: .infinity)
.padding(.horizontal, 33.3)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.cornerRadius(20)
.padding(.top, 66.7)
Spacer()
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
.onAppear {
viewModel.characterId = characterId

View File

@@ -45,25 +45,7 @@ struct CharacterDetailGalleryView: View {
Spacer()
}
.padding(.top, 24)
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) {
GeometryReader { geo in
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(alignment: .center)
.frame(maxWidth: .infinity)
.padding(.horizontal, 33.3)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.cornerRadius(20)
.padding(.top, 66.7)
Spacer()
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
.onAppear {
viewModel.characterId = characterId

View File

@@ -14,104 +14,83 @@ struct NewCharacterListView: View {
private let gridSpacing: CGFloat = 12
var body: some View {
NavigationStack {
BaseView(isLoading: $viewModel.isLoading) {
VStack(spacing: 8) {
// Toolbar
DetailNavigationBar(title: String(localized: "신규 캐릭터 전체보기"))
Group { BaseView(isLoading: $viewModel.isLoading) {
VStack(spacing: 8) {
// Toolbar
DetailNavigationBar(title: String(localized: "신규 캐릭터 전체보기"))
VStack(alignment: .leading, spacing: 12) {
// n
HStack(spacing: 0) {
Text("전체")
.appFont(size: 12, weight: .regular)
.foregroundColor(Color(hex: "e2e2e2"))
Text(" \(viewModel.totalCount)")
.appFont(size: 12, weight: .regular)
.foregroundColor(Color(hex: "ff5c49"))
Text("")
.appFont(size: 12, weight: .regular)
.foregroundColor(Color(hex: "e2e2e2"))
Spacer()
}
.padding(.horizontal, 24)
VStack(alignment: .leading, spacing: 12) {
// n
HStack(spacing: 0) {
Text("전체")
.appFont(size: 12, weight: .regular)
.foregroundColor(Color(hex: "e2e2e2"))
Text(" \(viewModel.totalCount)")
.appFont(size: 12, weight: .regular)
.foregroundColor(Color(hex: "ff5c49"))
Text("")
.appFont(size: 12, weight: .regular)
.foregroundColor(Color(hex: "e2e2e2"))
Spacer()
}
.padding(.horizontal, 24)
// Grid 3
GeometryReader { geo in
let width = (geo.size.width - (horizontalPadding * 2) - gridSpacing) / 2
// Grid 3
GeometryReader { geo in
let width = (geo.size.width - (horizontalPadding * 2) - gridSpacing) / 2
ScrollView(.vertical, showsIndicators: false) {
LazyVGrid(
columns: Array(
repeating: GridItem(
.flexible(),
spacing: gridSpacing,
alignment: .topLeading
),
count: 2
ScrollView(.vertical, showsIndicators: false) {
LazyVGrid(
columns: Array(
repeating: GridItem(
.flexible(),
spacing: gridSpacing,
alignment: .topLeading
),
alignment: .leading,
spacing: gridSpacing
) {
ForEach(viewModel.items.indices, id: \.self) { idx in
let item = viewModel.items[idx]
NavigationLink(value: item.characterId) {
CharacterItemView(
character: item,
size: width,
rank: 0,
isShowRank: false
)
.onAppear { viewModel.loadMoreIfNeeded(currentIndex: idx) }
}
}
count: 2
),
alignment: .leading,
spacing: gridSpacing
) {
ForEach(viewModel.items.indices, id: \.self) { idx in
let item = viewModel.items[idx]
CharacterItemView(
character: item,
size: width,
rank: 0,
isShowRank: false
)
.onAppear { viewModel.loadMoreIfNeeded(currentIndex: idx) }
.onTapGesture { AppState.shared.setAppStep(step: .characterDetail(characterId: item.characterId)) }
}
.padding(.horizontal, horizontalPadding)
if viewModel.isLoadingMore {
HStack {
Spacer()
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.padding(.vertical, 16)
Spacer()
}
}
.padding(.horizontal, horizontalPadding)
if viewModel.isLoadingMore {
HStack {
Spacer()
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.padding(.vertical, 16)
Spacer()
}
}
}
.frame(minHeight: 0, maxHeight: .infinity)
}
.padding(.vertical, 12)
.onAppear {
// 1
if viewModel.items.isEmpty {
viewModel.fetch()
}
}
.frame(minHeight: 0, maxHeight: .infinity)
}
.background(Color.black)
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) {
GeometryReader { geo in
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: geo.size.width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.cornerRadius(20)
.padding(.top, 66.7)
Spacer()
.padding(.vertical, 12)
.onAppear {
// 1
if viewModel.items.isEmpty {
viewModel.fetch()
}
}
}
.navigationDestination(for: Int.self) { characterId in
CharacterDetailView(characterId: characterId)
}
.background(Color.black)
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}
}

View File

@@ -8,8 +8,6 @@
import SwiftUI
import Bootpay
import BootpayUI
import PopupView
struct ChatTabView: View {
@AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token)
@AppStorage("auth") private var auth: Bool = UserDefaults.bool(forKey: UserDefaultsKey.auth)
@@ -75,7 +73,7 @@ struct ChatTabView: View {
ZStack {
VStack(alignment: .leading, spacing: 0) {
//
HStack(spacing: 24) {
HStack(spacing: 20) {
Image("img_text_logo")
Spacer()

View File

@@ -15,91 +15,87 @@ struct OriginalWorkDetailView: View {
let originalId: Int
var body: some View {
NavigationStack {
BaseView(isLoading: $viewModel.isLoading) {
ZStack(alignment: .top) {
if let imageUrl = viewModel.response?.imageUrl {
KFImage(URL(string: imageUrl))
.cancelOnDisappear(true)
Group { BaseView(isLoading: $viewModel.isLoading) {
ZStack(alignment: .top) {
if let imageUrl = viewModel.response?.imageUrl {
KFImage(URL(string: imageUrl))
.cancelOnDisappear(true)
.resizable()
.scaledToFill()
.frame(width: screenSize().width, height: (168 * 288 / 306) + 56)
.clipped()
.blur(radius: 25)
}
Color.black.opacity(0.5).ignoresSafeArea()
VStack(spacing: 0) {
HStack(spacing: 0) {
Image("ic_back")
.resizable()
.scaledToFill()
.frame(width: screenSize().width, height: (168 * 288 / 306) + 56)
.clipped()
.blur(radius: 25)
}
Color.black.opacity(0.5).ignoresSafeArea()
VStack(spacing: 0) {
HStack(spacing: 0) {
Image("ic_back")
.resizable()
.frame(width: 24, height: 24)
.onTapGesture {
AppState.shared.back()
}
Spacer()
}
.padding(.horizontal, 24)
.frame(height: 56)
.frame(width: 24, height: 24)
.onTapGesture {
AppState.shared.back()
}
if let response = viewModel.response {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
OriginalWorkDetailHeaderView(item: response)
.padding(.horizontal, 24)
.padding(.bottom, 24)
HStack(spacing: 0) {
SeriesDetailTabView(
title: I18n.Tab.character,
width: screenSize().width / 2,
isSelected: viewModel.currentTab == .character
) {
if viewModel.currentTab != .character {
viewModel.currentTab = .character
}
}
SeriesDetailTabView(
title: I18n.Tab.workInfo,
width: screenSize().width / 2,
isSelected: viewModel.currentTab == .info
) {
if viewModel.currentTab != .info {
viewModel.currentTab = .info
}
Spacer()
}
.padding(.horizontal, 24)
.frame(height: 56)
if let response = viewModel.response {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
OriginalWorkDetailHeaderView(item: response)
.padding(.horizontal, 24)
.padding(.bottom, 24)
HStack(spacing: 0) {
SeriesDetailTabView(
title: I18n.Tab.character,
width: screenSize().width / 2,
isSelected: viewModel.currentTab == .character
) {
if viewModel.currentTab != .character {
viewModel.currentTab = .character
}
}
.background(Color.black)
Rectangle()
.foregroundColor(Color.gray90.opacity(0.5))
.frame(height: 1)
.frame(maxWidth: .infinity)
switch(viewModel.currentTab) {
case .info:
OriginalWorkInfoView(response: response)
default:
OriginalWorkCharacterView(characters: viewModel.characters)
SeriesDetailTabView(
title: I18n.Tab.workInfo,
width: screenSize().width / 2,
isSelected: viewModel.currentTab == .info
) {
if viewModel.currentTab != .info {
viewModel.currentTab = .info
}
}
}
.background(Color.black)
Rectangle()
.foregroundColor(Color.gray90.opacity(0.5))
.frame(height: 1)
.frame(maxWidth: .infinity)
switch(viewModel.currentTab) {
case .info:
OriginalWorkInfoView(response: response)
default:
OriginalWorkCharacterView(characters: viewModel.characters)
}
}
}
}
}
}
.onAppear {
if viewModel.response == nil {
viewModel.originalId = originalId
}
}
.navigationDestination(for: Int.self) { characterId in
CharacterDetailView(characterId: characterId)
}
.onAppear {
if viewModel.response == nil {
viewModel.originalId = originalId
}
}
}
}
}
@@ -129,14 +125,13 @@ struct OriginalWorkCharacterView: View {
ForEach(characters.indices, id: \.self) { idx in
let item = characters[idx]
NavigationLink(value: item.characterId) {
CharacterItemView(
character: item,
size: width,
rank: 0,
isShowRank: false
)
}
CharacterItemView(
character: item,
size: width,
rank: 0,
isShowRank: false
)
.onTapGesture { AppState.shared.setAppStep(step: .characterDetail(characterId: item.characterId)) }
}
}
.padding(.horizontal, horizontalPadding)

View File

@@ -69,23 +69,7 @@ struct OriginalTabView: View {
}
}
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) {
GeometryReader { geo in
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: geo.size.width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.cornerRadius(20)
.padding(.top, 66.7)
Spacer()
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}

View File

@@ -308,23 +308,7 @@ struct ChatRoomView: View {
.onDisappear {
viewModel.stopTimer()
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) {
GeometryReader { geo in
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: geo.size.width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.cornerRadius(20)
.padding(.top, 66.7)
Spacer()
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}

View File

@@ -44,25 +44,7 @@ struct ChatBgSelectionView: View {
Spacer()
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) {
GeometryReader { geo in
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(alignment: .center)
.frame(maxWidth: .infinity)
.padding(.horizontal, 33.3)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.cornerRadius(20)
.padding(.top, 66.7)
Spacer()
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
.onAppear {
viewModel.characterId = characterId

View File

@@ -36,3 +36,99 @@ struct BaseView_Previews: PreviewProvider {
BaseView(isLoading: .constant(false)) {}
}
}
private struct SodaToastModifier: ViewModifier {
@Binding var isPresented: Bool
let message: String
let autohideIn: Double
private let toastBackgroundColor = Color(
red: 59.0 / 255.0,
green: 185.0 / 255.0,
blue: 241.0 / 255.0,
opacity: 0.92
)
@State private var dismissWorkItem: DispatchWorkItem?
func body(content: Content) -> some View {
content
.overlay(alignment: .top) {
GeometryReader { geo in
if isPresented, !message.isEmpty {
Text(message)
.appFont(size: 12, weight: .medium)
.foregroundColor(.white)
.multilineTextAlignment(.center)
.lineLimit(2)
.padding(.horizontal, 18)
.padding(.vertical, 12)
.background(
Capsule()
.fill(toastBackgroundColor)
)
.overlay(
Capsule()
.stroke(Color.white.opacity(0.15), lineWidth: 1)
)
.padding(.top, geo.safeAreaInsets.top + 8)
.padding(.horizontal, 24)
.frame(maxWidth: .infinity, alignment: .top)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.allowsHitTesting(false)
}
.animation(.easeInOut(duration: 0.2), value: isPresented)
.onAppear {
if isPresented {
scheduleDismiss()
}
}
.onChange(of: isPresented) { newValue in
if newValue {
scheduleDismiss()
} else {
cancelDismiss()
}
}
.onDisappear {
cancelDismiss()
}
}
private func scheduleDismiss() {
cancelDismiss()
guard autohideIn > 0 else {
return
}
let workItem = DispatchWorkItem {
withAnimation {
isPresented = false
}
}
dismissWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + autohideIn, execute: workItem)
}
private func cancelDismiss() {
dismissWorkItem?.cancel()
dismissWorkItem = nil
}
}
extension View {
func sodaToast(isPresented: Binding<Bool>, message: String, autohideIn: Double = 2) -> some View {
modifier(
SodaToastModifier(
isPresented: isPresented,
message: message,
autohideIn: autohideIn
)
)
}
}

View File

@@ -21,6 +21,7 @@ enum DateParser {
{ ISO8601.fractional.date(from: $0) },
{ ISO8601.basic.date(from: $0) },
{ DF.rfc3339.date(from: $0) },
{ DF.isoLocalDateTime.date(from: $0) },
{ DF.basic.date(from: $0) }
]
@@ -56,5 +57,13 @@ enum DateParser {
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
return f
}()
static let isoLocalDateTime: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(secondsFromGMT: 0)
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
return f
}()
}
}

View File

@@ -9,11 +9,12 @@ import SwiftUI
struct ContentAllByThemeView: View {
@StateObject var viewModel = ContentAllByThemeViewModel()
@State private var isInitialized = false
let themeId: Int
var body: some View {
NavigationView {
Group {
BaseView(isLoading: $viewModel.isLoading) {
VStack(alignment: .leading, spacing: 0) {
DetailNavigationBar(title: viewModel.theme)
@@ -111,8 +112,11 @@ struct ContentAllByThemeView: View {
}
}
.onAppear {
viewModel.themeId = themeId
viewModel.getContentList()
if !isInitialized || viewModel.themeId != themeId {
viewModel.themeId = themeId
viewModel.getContentList()
isInitialized = true
}
}
}
}

View File

@@ -10,12 +10,13 @@ import SwiftUI
struct ContentAllView: View {
@StateObject var viewModel = ContentAllViewModel()
@State private var isInitialized = false
var isFree: Bool = false
var isPointAvailableOnly: Bool = false
var body: some View {
NavigationView {
Group {
BaseView(isLoading: $viewModel.isLoading) {
VStack(spacing: 0) {
DetailNavigationBar(title: isFree ? String(localized: "무료 콘텐츠 전체") : isPointAvailableOnly ? String(localized: "포인트 대여 전체") : String(localized: "콘텐츠 전체"))
@@ -78,63 +79,63 @@ struct ContentAllView: View {
ForEach(viewModel.contentList.indices, id: \.self) { idx in
let item = viewModel.contentList[idx]
NavigationLink {
ContentDetailView(contentId: item.contentId)
} label: {
VStack(alignment: .leading, spacing: 0) {
ZStack(alignment: .top) {
DownsampledKFImage(
url: URL(string: item.coverImageUrl),
size: CGSize(width: itemSize, height: itemSize)
)
.cornerRadius(16)
VStack(alignment: .leading, spacing: 0) {
ZStack(alignment: .top) {
DownsampledKFImage(
url: URL(string: item.coverImageUrl),
size: CGSize(width: itemSize, height: itemSize)
)
.cornerRadius(16)
HStack(alignment: .top, spacing: 0) {
Spacer()
HStack(alignment: .top, spacing: 0) {
Spacer()
if item.isPointAvailable {
Image("ic_point")
.padding(.top, 6)
.padding(.trailing, 6)
}
if item.isPointAvailable {
Image("ic_point")
.padding(.top, 6)
.padding(.trailing, 6)
}
}
Text(item.title)
.appFont(size: 18, weight: .regular)
.foregroundColor(.white)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(1)
.padding(.horizontal, 6)
.padding(.top, 8)
Text(item.creatorNickname)
.appFont(size: 14, weight: .regular)
.foregroundColor(Color(hex: "78909C"))
.lineLimit(1)
.padding(.horizontal, 6)
.padding(.top, 4)
}
.frame(width: itemSize)
.contentShape(Rectangle())
.onAppear {
if idx == viewModel.contentList.count - 1 {
viewModel.fetchData()
}
Text(item.title)
.appFont(size: 18, weight: .regular)
.foregroundColor(.white)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(1)
.padding(.horizontal, 6)
.padding(.top, 8)
Text(item.creatorNickname)
.appFont(size: 14, weight: .regular)
.foregroundColor(Color(hex: "78909C"))
.lineLimit(1)
.padding(.horizontal, 6)
.padding(.top, 4)
}
.frame(width: itemSize)
.contentShape(Rectangle())
.onAppear {
if idx == viewModel.contentList.count - 1 {
viewModel.fetchData()
}
}
.onTapGesture { AppState.shared.setAppStep(step: .contentDetail(contentId: item.contentId)) }
}
}
.padding(horizontalPadding)
}
}
.onAppear {
viewModel.isFree = isFree
viewModel.isPointAvailableOnly = isPointAvailableOnly
viewModel.getThemeList()
viewModel.fetchData()
if !isInitialized || viewModel.isFree != isFree || viewModel.isPointAvailableOnly != isPointAvailableOnly {
viewModel.isFree = isFree
viewModel.isPointAvailableOnly = isPointAvailableOnly
viewModel.getThemeList()
viewModel.fetchData()
isInitialized = true
}
}
}
}

View File

@@ -14,95 +14,92 @@ struct ContentNewAllItemView: View {
let item: GetAudioContentMainItem
var body: some View {
NavigationLink {
ContentDetailView(contentId: item.contentId)
} label: {
VStack(alignment: .leading, spacing: 8) {
ZStack(alignment: .bottom) {
KFImage(URL(string: item.coverImageUrl))
.cancelOnDisappear(true)
.downsampling(
size: CGSize(
width: width,
height: width
)
VStack(alignment: .leading, spacing: 8) {
ZStack(alignment: .bottom) {
KFImage(URL(string: item.coverImageUrl))
.cancelOnDisappear(true)
.downsampling(
size: CGSize(
width: width,
height: width
)
.resizable()
.scaledToFill()
.frame(width: width, height: width, alignment: .top)
.cornerRadius(2.7)
)
.resizable()
.scaledToFill()
.frame(width: width, height: width, alignment: .top)
.cornerRadius(2.7)
VStack(spacing: 0) {
Spacer()
VStack(spacing: 0) {
Spacer()
HStack(spacing: 0) {
HStack(spacing: 2) {
if item.price > 0 {
Image("ic_card_can_gray")
Text("\(item.price)")
.appFont(size: 8.5, weight: .medium)
.foregroundColor(Color.white)
} else {
Text("무료")
.appFont(size: 8.5, weight: .medium)
.foregroundColor(Color.white)
}
}
.padding(3)
.background(Color(hex: "333333").opacity(0.7))
.cornerRadius(10)
.padding(.leading, 2.7)
.padding(.bottom, 2.7)
Spacer()
HStack(spacing: 2) {
Text(item.duration)
HStack(spacing: 0) {
HStack(spacing: 2) {
if item.price > 0 {
Image("ic_card_can_gray")
Text("\(item.price)")
.appFont(size: 8.5, weight: .medium)
.foregroundColor(Color.white)
} else {
Text("무료")
.appFont(size: 8.5, weight: .medium)
.foregroundColor(Color.white)
}
.padding(3)
.background(Color(hex: "333333").opacity(0.7))
.cornerRadius(10)
.padding(.trailing, 2.7)
.padding(.bottom, 2.7)
}
.padding(3)
.background(Color(hex: "333333").opacity(0.7))
.cornerRadius(10)
.padding(.leading, 2.7)
.padding(.bottom, 2.7)
Spacer()
HStack(spacing: 2) {
Text(item.duration)
.appFont(size: 8.5, weight: .medium)
.foregroundColor(Color.white)
}
.padding(3)
.background(Color(hex: "333333").opacity(0.7))
.cornerRadius(10)
.padding(.trailing, 2.7)
.padding(.bottom, 2.7)
}
}
.frame(width: width, height: width)
Text(item.title)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "d2d2d2"))
.frame(width: width, alignment: .leading)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(2)
HStack(spacing: 5.3) {
KFImage(URL(string: item.creatorProfileImageUrl))
.cancelOnDisappear(true)
.downsampling(
size: CGSize(
width: 21.3,
height: 21.3
)
)
.resizable()
.scaledToFill()
.frame(width: 21.3, height: 21.3)
.clipShape(Circle())
.onTapGesture { AppState.shared.setAppStep(step: .creatorDetail(userId: item.creatorId)) }
Text(item.creatorNickname)
.appFont(size: 12, weight: .medium)
.foregroundColor(Color(hex: "777777"))
.lineLimit(1)
}
.padding(.bottom, 10)
}
.frame(width: width)
.frame(width: width, height: width)
Text(item.title)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "d2d2d2"))
.frame(width: width, alignment: .leading)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(2)
HStack(spacing: 5.3) {
KFImage(URL(string: item.creatorProfileImageUrl))
.cancelOnDisappear(true)
.downsampling(
size: CGSize(
width: 21.3,
height: 21.3
)
)
.resizable()
.scaledToFill()
.frame(width: 21.3, height: 21.3)
.clipShape(Circle())
.onTapGesture { AppState.shared.setAppStep(step: .creatorDetail(userId: item.creatorId)) }
Text(item.creatorNickname)
.appFont(size: 12, weight: .medium)
.foregroundColor(Color(hex: "777777"))
.lineLimit(1)
}
.padding(.bottom, 10)
}
.frame(width: width)
.onTapGesture { AppState.shared.setAppStep(step: .contentDetail(contentId: item.contentId)) }
}
}

View File

@@ -10,11 +10,12 @@ import SwiftUI
struct ContentNewAllView: View {
@StateObject var viewModel = ContentNewAllViewModel()
@State private var isInitialized = false
let isFree: Bool
var body: some View {
NavigationView {
Group {
BaseView(isLoading: $viewModel.isLoading) {
VStack(alignment: .leading, spacing: 13.3) {
DetailNavigationBar(title: isFree ? "최신 무료 콘텐츠" : "최신 콘텐츠")
@@ -82,9 +83,12 @@ struct ContentNewAllView: View {
}
}
.onAppear {
viewModel.isFree = isFree
viewModel.getThemeList()
viewModel.getNewContentList()
if !isInitialized || viewModel.isFree != isFree {
viewModel.isFree = isFree
viewModel.getThemeList()
viewModel.getNewContentList()
isInitialized = true
}
}
}
.navigationBarHidden(true)

View File

@@ -11,9 +11,10 @@ import Kingfisher
struct ContentRankingAllView: View {
@StateObject var viewModel = ContentRankingAllViewModel()
@State private var isInitialized = false
var body: some View {
NavigationView {
Group {
BaseView(isLoading: $viewModel.isLoading) {
VStack(spacing: 0) {
DetailNavigationBar(title: "인기 콘텐츠")
@@ -44,97 +45,94 @@ struct ContentRankingAllView: View {
LazyVStack(spacing: 20) {
ForEach(0..<viewModel.contentRankingItemList.count, id: \.self) { index in
let item = viewModel.contentRankingItemList[index]
NavigationLink {
ContentDetailView(contentId: item.contentId)
} label: {
HStack(spacing: 0) {
KFImage(URL(string: item.coverImageUrl))
.cancelOnDisappear(true)
.downsampling(
size: CGSize(
width: 66.7,
height: 66.7
)
HStack(spacing: 0) {
KFImage(URL(string: item.coverImageUrl))
.cancelOnDisappear(true)
.downsampling(
size: CGSize(
width: 66.7,
height: 66.7
)
.resizable()
.scaledToFill()
.frame(width: 66.7, height: 66.7, alignment: .top)
.clipped()
.cornerRadius(5.3)
Text("\(index + 1)")
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color(hex: "3bb9f1"))
.padding(.horizontal, 12)
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 8) {
Text(item.themeStr)
.appFont(size: 8, weight: .medium)
.foregroundColor(Color(hex: "3bac6a"))
.padding(2.6)
.background(Color(hex: "28312b"))
.cornerRadius(2.6)
Text(item.duration)
.appFont(size: 8, weight: .medium)
.foregroundColor(Color(hex: "777777"))
.padding(2.6)
.background(Color(hex: "222222"))
.cornerRadius(2.6)
if item.isPointAvailable {
Text("포인트")
.appFont(size: 8, weight: .medium)
.foregroundColor(.white)
.padding(2.6)
.background(Color(hex: "7849bc"))
.cornerRadius(2.6)
}
}
Text(item.creatorNickname)
.appFont(size: 10.7, weight: .medium)
.foregroundColor(Color(hex: "777777"))
.padding(.vertical, 8)
Text(item.title)
.appFont(size: 12, weight: .medium)
.foregroundColor(Color(hex: "d2d2d2"))
.lineLimit(2)
.padding(.top, 2.7)
}
Spacer()
if item.price > 0 {
HStack(spacing: 8) {
Image("ic_can")
.resizable()
.frame(width: 17, height: 17)
Text("\(item.price)")
.appFont(size: 12, weight: .medium)
.foregroundColor(Color(hex: "909090"))
}
} else {
Text("무료")
.appFont(size: 12, weight: .medium)
.foregroundColor(Color(hex: "ffffff"))
.padding(.horizontal, 5.3)
.padding(.vertical, 2.7)
.background(Color(hex: "cf5c37"))
)
.resizable()
.scaledToFill()
.frame(width: 66.7, height: 66.7, alignment: .top)
.clipped()
.cornerRadius(5.3)
Text("\(index + 1)")
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color(hex: "3bb9f1"))
.padding(.horizontal, 12)
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 8) {
Text(item.themeStr)
.appFont(size: 8, weight: .medium)
.foregroundColor(Color(hex: "3bac6a"))
.padding(2.6)
.background(Color(hex: "28312b"))
.cornerRadius(2.6)
Text(item.duration)
.appFont(size: 8, weight: .medium)
.foregroundColor(Color(hex: "777777"))
.padding(2.6)
.background(Color(hex: "222222"))
.cornerRadius(2.6)
if item.isPointAvailable {
Text("포인트")
.appFont(size: 8, weight: .medium)
.foregroundColor(.white)
.padding(2.6)
.background(Color(hex: "7849bc"))
.cornerRadius(2.6)
}
}
Text(item.creatorNickname)
.appFont(size: 10.7, weight: .medium)
.foregroundColor(Color(hex: "777777"))
.padding(.vertical, 8)
Text(item.title)
.appFont(size: 12, weight: .medium)
.foregroundColor(Color(hex: "d2d2d2"))
.lineLimit(2)
.padding(.top, 2.7)
}
.frame(height: 66.7)
.contentShape(Rectangle())
.onAppear {
if index == viewModel.contentRankingItemList.count - 1 {
viewModel.getContentRanking()
Spacer()
if item.price > 0 {
HStack(spacing: 8) {
Image("ic_can")
.resizable()
.frame(width: 17, height: 17)
Text("\(item.price)")
.appFont(size: 12, weight: .medium)
.foregroundColor(Color(hex: "909090"))
}
} else {
Text("무료")
.appFont(size: 12, weight: .medium)
.foregroundColor(Color(hex: "ffffff"))
.padding(.horizontal, 5.3)
.padding(.vertical, 2.7)
.background(Color(hex: "cf5c37"))
.cornerRadius(2.6)
}
}
.frame(height: 66.7)
.contentShape(Rectangle())
.onAppear {
if index == viewModel.contentRankingItemList.count - 1 {
viewModel.getContentRanking()
}
}
.onTapGesture { AppState.shared.setAppStep(step: .contentDetail(contentId: item.contentId)) }
}
}
}
@@ -145,28 +143,13 @@ struct ContentRankingAllView: View {
LoadingView()
}
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) {
GeometryReader { geo in
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.padding(.horizontal, 6.7)
.frame(width: geo.size.width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color(hex: "9970ff"))
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(20)
.padding(.top, 66.7)
Spacer()
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
.onAppear {
viewModel.getContentRankingSortType()
viewModel.getContentRanking()
if !isInitialized {
viewModel.getContentRankingSortType()
viewModel.getContentRanking()
isInitialized = true
}
}
}
}

View File

@@ -21,7 +21,7 @@ struct ContentBoxView: View {
var body: some View {
ZStack {
NavigationView {
Group {
VStack(spacing: 13.3) {
DetailNavigationBar(title: I18n.ContentBox.title)

View File

@@ -11,9 +11,10 @@ struct ContentListView: View {
let userId: Int
@StateObject var viewModel = ContentListViewModel()
@State private var isInitialized = false
var body: some View {
NavigationView {
Group {
BaseView(isLoading: $viewModel.isLoading) {
VStack(spacing: 0) {
HStack(spacing: 0) {
@@ -128,17 +129,14 @@ struct ContentListView: View {
ForEach(0..<viewModel.audioContentList.count, id: \.self) { index in
let audioContent = viewModel.audioContentList[index]
NavigationLink {
ContentDetailView(contentId: audioContent.contentId)
} label: {
ContentListItemView(item: audioContent)
.contentShape(Rectangle())
.onAppear {
if index == viewModel.audioContentList.count - 1 {
viewModel.getAudioContentList()
}
ContentListItemView(item: audioContent)
.contentShape(Rectangle())
.onAppear {
if index == viewModel.audioContentList.count - 1 {
viewModel.getAudioContentList()
}
}
}
.onTapGesture { AppState.shared.setAppStep(step: .contentDetail(contentId: audioContent.contentId)) }
}
}
.padding(.horizontal, 13.3)
@@ -147,25 +145,14 @@ struct ContentListView: View {
.padding(.top, 13.3)
}
.onAppear {
viewModel.userId = userId
viewModel.getCategoryList()
viewModel.getAudioContentList()
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color(hex: "3bb9f1"))
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
if !isInitialized || viewModel.userId != userId {
viewModel.userId = userId
viewModel.getCategoryList()
viewModel.getAudioContentList()
isInitialized = true
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}
}

View File

@@ -14,6 +14,10 @@ struct ContentCreateView: View {
@StateObject private var viewModel = ContentCreateViewModel()
@State private var isShowPhotoPicker = false
@State private var selectedPickedImage: UIImage?
@State private var cropSourceImage: UIImage?
@State private var isShowImageCropper = false
@State private var isImageLoading = false
@State private var isShowSelectAudioView = false
@State private var isShowSelectThemeView = false
@State private var isShowSelectDateView = false
@@ -626,11 +630,11 @@ struct ContentCreateView: View {
if isShowSelectTimeView {
QuarterTimePickerView(selectedTime: $viewModel.releaseTime, isShowing: $isShowSelectTimeView)
}
if isShowPhotoPicker {
ImagePicker(
isShowing: $isShowPhotoPicker,
selectedImage: $viewModel.coverImage,
selectedImage: $selectedPickedImage,
sourceType: .photoLibrary
)
}
@@ -659,25 +663,56 @@ struct ContentCreateView: View {
}
}
.edgesIgnoringSafeArea(.bottom)
if isImageLoading {
ZStack {
Color.black.opacity(0.45)
.edgesIgnoringSafeArea(.all)
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
}
}
.onTapGesture { hideKeyboard() }
.edgesIgnoringSafeArea(.bottom)
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) {
GeometryReader { geo in
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.cornerRadius(20)
.padding(.top, 66.7)
Spacer()
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
.onChange(of: selectedPickedImage, perform: { newImage in
guard let newImage else {
return
}
isImageLoading = true
DispatchQueue.global(qos: .userInitiated).async {
let normalizedImage = newImage.normalizedForCrop()
DispatchQueue.main.async {
isImageLoading = false
selectedPickedImage = nil
cropSourceImage = normalizedImage
isShowImageCropper = true
}
}
})
.onDisappear {
isImageLoading = false
}
.sheet(isPresented: $isShowImageCropper, onDismiss: {
cropSourceImage = nil
}) {
if let cropSourceImage {
ImageCropEditorView(
image: cropSourceImage,
aspectPolicy: .square,
onCancel: {
isShowImageCropper = false
},
onComplete: { croppedImage in
viewModel.coverImage = croppedImage
isShowImageCropper = false
}
)
}
}
}
}

View File

@@ -10,6 +10,7 @@ import SwiftUI
struct ContentCurationView: View {
@StateObject var viewModel = ContentCurationViewModel()
@State private var isInitialized = false
let title: String
let curationId: Int
@@ -21,7 +22,7 @@ struct ContentCurationView: View {
]
var body: some View {
NavigationView {
Group {
BaseView(isLoading: $viewModel.isLoading) {
VStack(spacing: 0) {
DetailNavigationBar(title: title)
@@ -119,8 +120,11 @@ struct ContentCurationView: View {
}
}
.onAppear {
viewModel.curationId = curationId
viewModel.getContentList()
if !isInitialized || viewModel.curationId != curationId {
viewModel.curationId = curationId
viewModel.getContentList()
isInitialized = true
}
}
}
}

View File

@@ -25,7 +25,7 @@ struct AudioContentCommentListView: View {
@State private var isShowMemberProfilePopup: Bool = false
var body: some View {
NavigationView {
Group {
ZStack {
VStack(spacing: 0) {
HStack(spacing: 0) {
@@ -188,23 +188,7 @@ struct AudioContentCommentListView: View {
viewModel.audioContentId = audioContentId
viewModel.getCommentList()
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) {
GeometryReader { geo in
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color(hex: "9970ff"))
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.cornerRadius(20)
.padding(.top, 66.7)
Spacer()
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}
}

View File

@@ -21,6 +21,8 @@ struct ContentDetailView: View {
@State private var isShowCommentListView = false
@State private var isShowFollowNotifyDialog: Bool = false
@State private var creatorId: Int = 0
@State private var didTriggerAutoBackOnLoadFailure: Bool = false
@State private var isViewVisible: Bool = false
var body: some View {
GeometryReader { proxy in
@@ -28,11 +30,7 @@ struct ContentDetailView: View {
VStack(spacing: 0) {
HStack(spacing: 0) {
Button {
if presentationMode.wrappedValue.isPresented {
presentationMode.wrappedValue.dismiss()
} else {
AppState.shared.back()
}
goBack()
} label: {
Image("ic_back")
.resizable()
@@ -217,9 +215,26 @@ struct ContentDetailView: View {
.navigationTitle("")
.navigationBarBackButtonHidden()
.onAppear {
isViewVisible = true
didTriggerAutoBackOnLoadFailure = false
viewModel.contentId = contentId
AppState.shared.pushAudioContentId = 0
}
.onDisappear {
isViewVisible = false
}
.onChange(of: viewModel.isShowPopup) { isShowing in
guard isShowing else { return }
guard viewModel.audioContent == nil else { return }
guard !didTriggerAutoBackOnLoadFailure else { return }
didTriggerAutoBackOnLoadFailure = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
guard isViewVisible else { return }
goBack()
}
}
if let audioContent = viewModel.audioContent, isShowOrderView {
VStack(spacing: 0) {
@@ -407,31 +422,26 @@ struct ContentDetailView: View {
.sheet(
isPresented: $isShowCommentListView,
content: {
AudioContentCommentListView(
isPresented: $isShowCommentListView,
creatorId: viewModel.audioContent!.creator.creatorId,
audioContentId: viewModel.audioContent!.contentId,
isShowSecret: viewModel.audioContent!.existOrdered
)
NavigationStack {
AudioContentCommentListView(
isPresented: $isShowCommentListView,
creatorId: viewModel.audioContent!.creator.creatorId,
audioContentId: viewModel.audioContent!.contentId,
isShowSecret: viewModel.audioContent!.existOrdered
)
}
.toolbar(.hidden, for: .navigationBar)
}
)
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) {
GeometryReader { geo in
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color(hex: "9970ff"))
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.cornerRadius(20)
.padding(.top, 66.7)
Spacer()
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}
private func goBack() {
if presentationMode.wrappedValue.isPresented {
presentationMode.wrappedValue.dismiss()
} else {
AppState.shared.back()
}
}
}

View File

@@ -22,9 +22,31 @@ struct LiveRoomDonationDialogView: View {
@Binding var isShowing: Bool
let isAudioContentDonation: Bool
let messageLimit: Int
let secretLabel: String
let secretMinimumCanMessage: String
let shouldPrefixSecretInMessagePlaceholder: Bool
let onClickDonation: (Int, String, Bool) -> Void
@StateObject var keyboardHandler = KeyboardHandler()
init(
isShowing: Binding<Bool>,
isAudioContentDonation: Bool,
messageLimit: Int = 1000,
secretLabel: String = I18n.LiveRoom.secretMissionLabel,
secretMinimumCanMessage: String = I18n.LiveRoom.secretMissionMinimumCanMessage,
shouldPrefixSecretInMessagePlaceholder: Bool = true,
onClickDonation: @escaping (Int, String, Bool) -> Void
) {
self._isShowing = isShowing
self.isAudioContentDonation = isAudioContentDonation
self.messageLimit = messageLimit
self.secretLabel = secretLabel
self.secretMinimumCanMessage = secretMinimumCanMessage
self.shouldPrefixSecretInMessagePlaceholder = shouldPrefixSecretInMessagePlaceholder
self.onClickDonation = onClickDonation
}
var body: some View {
ZStack {
@@ -92,7 +114,7 @@ struct LiveRoomDonationDialogView: View {
.resizable()
.frame(width: 20, height: 20)
Text("비밀미션")
Text(secretLabel)
.appFont(size: 14.7, weight: .medium)
.foregroundColor(isSecret ? Color.button : Color.grayee)
}
@@ -204,7 +226,10 @@ struct LiveRoomDonationDialogView: View {
.stroke(Color.graybb, lineWidth: 1)
)
TextField("함께 보낼 \(isSecret ? "비밀 " : "")메시지 입력(최대 1000자)", text: $donationMessage)
TextField(
"함께 보낼 \((isSecret && shouldPrefixSecretInMessagePlaceholder) ? "비밀 " : "")메시지 입력(최대 \(messageLimit)자)",
text: $donationMessage
)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayee)
.padding(13.3)
@@ -243,7 +268,7 @@ struct LiveRoomDonationDialogView: View {
.onTapGesture {
if !donationCan.trimmingCharacters(in: .whitespaces).isEmpty, let can = Int(donationCan) {
if isSecret && can < 10 {
errorMessage = "비밀 미션은 최소 10캔 이상부터 이용이 가능합니다."
errorMessage = secretMinimumCanMessage
isShowErrorPopup = true
} else if can < 1 {
errorMessage = "1캔 이상 후원하실 수 있습니다."
@@ -266,28 +291,14 @@ struct LiveRoomDonationDialogView: View {
.background(Color.gray22)
.cornerRadius(20, corners: [.topLeft, .topRight])
}
.popup(isPresented: $isShowErrorPopup, type: .toast, position: .bottom, autohideIn: 1.3) {
HStack {
Spacer()
Text(errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
.sodaToast(isPresented: $isShowErrorPopup, message: errorMessage, autohideIn: 1.3)
.offset(y: isAudioContentDonation ? 0 : 0 - keyboardHandler.keyboardHeight)
}
}
func limitText() {
if donationMessage.count > 1000 {
donationMessage = String(donationMessage.prefix(1000))
if donationMessage.count > messageLimit {
donationMessage = String(donationMessage.prefix(messageLimit))
}
}
}

View File

@@ -10,9 +10,10 @@ import SwiftUI
struct ContentMainAlarmAllView: View {
@StateObject var viewModel = ContentMainAlarmAllViewModel()
@State private var isInitialized = false
var body: some View {
NavigationView {
Group {
BaseView(isLoading: $viewModel.isLoading) {
VStack(alignment: .leading, spacing: 13.3) {
DetailNavigationBar(title: "새로운 알람")
@@ -81,7 +82,10 @@ struct ContentMainAlarmAllView: View {
}
}
.onAppear {
viewModel.getContentMainAlarmAll()
if !isInitialized {
viewModel.getContentMainAlarmAll()
isInitialized = true
}
}
}
.navigationBarHidden(true)

View File

@@ -50,21 +50,7 @@ struct ContentMainTabAlarmView: View {
}
}
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}

View File

@@ -10,9 +10,10 @@ import SwiftUI
struct ContentMainAsmrAllView: View {
@StateObject var viewModel = ContentNewAllViewModel()
@State private var isInitialized = false
var body: some View {
NavigationView {
Group {
BaseView(isLoading: $viewModel.isLoading) {
VStack(alignment: .leading, spacing: 13.3) {
DetailNavigationBar(title: "새로운 ASMR")
@@ -72,7 +73,14 @@ struct ContentMainAsmrAllView: View {
}
}
.onAppear {
viewModel.selectedTheme = "ASMR"
if !isInitialized {
if viewModel.selectedTheme != "ASMR" {
viewModel.selectedTheme = "ASMR"
} else if viewModel.newContentList.isEmpty {
viewModel.getNewContentList()
}
isInitialized = true
}
}
}
.navigationBarHidden(true)

View File

@@ -60,21 +60,7 @@ struct ContentMainTabAsmrView: View {
}
}
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}

View File

@@ -84,21 +84,7 @@ struct ContentMainTabContentView: View {
}
}
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}

View File

@@ -46,7 +46,7 @@ struct ContentMainViewV2: View {
}
var body: some View {
NavigationView {
Group {
ZStack {
Color.black.ignoresSafeArea()

View File

@@ -10,9 +10,10 @@ import SwiftUI
struct ContentMainIntroduceCreatorAllView: View {
@StateObject var viewModel = ContentMainIntroduceCreatorAllViewModel()
@State private var isInitialized = false
var body: some View {
NavigationView {
Group {
BaseView(isLoading: $viewModel.isLoading) {
VStack(spacing: 13.3) {
DetailNavigationBar(title: "크리에이터 소개")
@@ -48,25 +49,14 @@ struct ContentMainIntroduceCreatorAllView: View {
}
}
.onAppear {
viewModel.getIntroduceCreatorList()
if !isInitialized {
viewModel.getIntroduceCreatorList()
isInitialized = true
}
}
}
.navigationBarHidden(true)
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}
}

View File

@@ -78,21 +78,7 @@ struct ContentMainTabFreeView: View {
}
}
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}

View File

@@ -291,21 +291,7 @@ struct ContentMainTabHomeView: View {
}
}
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}
}

View File

@@ -10,9 +10,10 @@ import SwiftUI
struct ContentMainReplayAllView: View {
@StateObject var viewModel = ContentNewAllViewModel()
@State private var isInitialized = false
var body: some View {
NavigationView {
Group {
BaseView(isLoading: $viewModel.isLoading) {
VStack(alignment: .leading, spacing: 13.3) {
DetailNavigationBar(title: "새로운 라이브 다시듣기")
@@ -72,7 +73,14 @@ struct ContentMainReplayAllView: View {
}
}
.onAppear {
viewModel.selectedTheme = "다시듣기"
if !isInitialized {
if viewModel.selectedTheme != "다시듣기" {
viewModel.selectedTheme = "다시듣기"
} else if viewModel.newContentList.isEmpty {
viewModel.getNewContentList()
}
isInitialized = true
}
}
}
.navigationBarHidden(true)

View File

@@ -60,21 +60,7 @@ struct ContentMainTabReplayView: View {
}
}
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}

View File

@@ -60,21 +60,7 @@ struct CompletedSeriesView: View {
}
}
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
.onAppear {
viewModel.getCompletedSeries()
}

View File

@@ -86,21 +86,7 @@ struct ContentMainTabSeriesView: View {
}
}
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}

View File

@@ -277,23 +277,7 @@ struct ContentModifyView: View {
}
.onTapGesture { hideKeyboard() }
.edgesIgnoringSafeArea(.bottom)
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) {
GeometryReader { geo in
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.cornerRadius(20)
.padding(.top, 66.7)
Spacer()
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
.onAppear {
viewModel.contentId = contentId
viewModel.getAudioContentDetail {

View File

@@ -71,21 +71,7 @@ struct ContentPlaylistListView: View {
}
}
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
.onAppear {
viewModel.getPlaylistList()
}

View File

@@ -138,21 +138,7 @@ struct ContentPlaylistCreateView: View {
}
.padding(.vertical, 13.3)
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
if isShowAddContentView {
PlaylistAddContentView(

View File

@@ -238,21 +238,7 @@ struct ContentPlaylistDetailView: View {
}
}
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
.onAppear {
viewModel.playlistId = playlistId
}

View File

@@ -139,21 +139,7 @@ struct ContentPlaylistModifyView: View {
}
.padding(.vertical, 13.3)
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
.onAppear {
viewModel.playlistId = playlistId
}

View File

@@ -17,6 +17,8 @@ struct SeriesDetailView: View {
@State private var isShowFollowNotifyDialog: Bool = false
@State private var creatorId: Int = 0
@State private var didTriggerAutoBackOnLoadFailure: Bool = false
@State private var isViewVisible: Bool = false
var body: some View {
BaseView(isLoading: $viewModel.isLoading) {
@@ -38,11 +40,7 @@ struct SeriesDetailView: View {
.resizable()
.frame(width: 20, height: 20)
.onTapGesture {
if presentationMode.wrappedValue.isPresented {
presentationMode.wrappedValue.dismiss()
} else {
AppState.shared.back()
}
goBack()
}
Spacer()
@@ -243,9 +241,35 @@ struct SeriesDetailView: View {
.navigationBarBackButtonHidden()
}
.onAppear {
isViewVisible = true
didTriggerAutoBackOnLoadFailure = false
viewModel.seriesId = seriesId
viewModel.getSeriesDetail()
}
.onDisappear {
isViewVisible = false
}
.onChange(of: viewModel.isShowPopup) { isShowing in
guard isShowing else { return }
guard viewModel.seriesDetail == nil else { return }
guard !didTriggerAutoBackOnLoadFailure else { return }
didTriggerAutoBackOnLoadFailure = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
guard isViewVisible else { return }
goBack()
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
private func goBack() {
if presentationMode.wrappedValue.isPresented {
presentationMode.wrappedValue.dismiss()
} else {
AppState.shared.back()
}
}
}

View File

@@ -10,6 +10,7 @@ import SwiftUI
struct SeriesMainByGenreView: View {
@StateObject var viewModel = SeriesMainByGenreViewModel()
@State private var isInitialized = false
var body: some View {
ZStack {
@@ -41,39 +42,27 @@ struct SeriesMainByGenreView: View {
) {
ForEach(viewModel.seriesList.indices, id: \.self) { index in
let item = viewModel.seriesList[index]
NavigationLink {
SeriesDetailView(seriesId: item.seriesId)
} label: {
SeriesMainItemView(item: item, width: width, height: width * 227 / 160)
.contentShape(Rectangle())
.onAppear {
if index == viewModel.seriesList.count - 1 {
viewModel.getSeriesListByGenre()
}
SeriesMainItemView(item: item, width: width, height: width * 227 / 160)
.contentShape(Rectangle())
.onAppear {
if index == viewModel.seriesList.count - 1 {
viewModel.getSeriesListByGenre()
}
}
}
.onTapGesture {
AppState.shared.setAppStep(step: .seriesDetail(seriesId: item.seriesId))
}
}
}
.padding(.horizontal, horizontalPadding)
}
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
.onAppear {
viewModel.getGenreList()
if !isInitialized {
viewModel.getGenreList()
isInitialized = true
}
}
if viewModel.isLoading {

View File

@@ -75,37 +75,20 @@ struct SeriesMainDayOfWeekView: View {
) {
ForEach(viewModel.seriesList.indices, id: \.self) { index in
let item = viewModel.seriesList[index]
NavigationLink {
SeriesDetailView(seriesId: item.seriesId)
} label: {
SeriesMainItemView(item: item, width: width, height: width * 227 / 160)
.contentShape(Rectangle())
.onAppear {
if index == viewModel.seriesList.count - 1 {
viewModel.getDayOfWeekSeriesList(dayOfWeek: dayOfWeek)
}
SeriesMainItemView(item: item, width: width, height: width * 227 / 160)
.contentShape(Rectangle())
.onAppear {
if index == viewModel.seriesList.count - 1 {
viewModel.getDayOfWeekSeriesList(dayOfWeek: dayOfWeek)
}
}
}
.onTapGesture { AppState.shared.setAppStep(step: .seriesDetail(seriesId: item.seriesId)) }
}
}
.padding(.horizontal, horizontalPadding)
}
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
.onAppear {
if !isInitialized {
dayOfWeek = dayOfWeeks[Calendar.current.component(.weekday, from: Date())]

View File

@@ -22,11 +22,8 @@ struct SeriesMainHomeBannerView: View {
ForEach(0..<bannerList.count, id: \.self) { index in
let item = bannerList[index]
NavigationLink {
SeriesDetailView(seriesId: item.seriesId)
} label: {
SeriesMainHomeBannerImageView(url: item.imagePath, width: width, height: height)
}
SeriesMainHomeBannerImageView(url: item.imagePath, width: width, height: height)
.onTapGesture { AppState.shared.setAppStep(step: .seriesDetail(seriesId: item.seriesId)) }
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))

View File

@@ -10,6 +10,7 @@ import SwiftUI
struct SeriesMainHomeView: View {
@StateObject var viewModel = SeriesMainHomeViewModel()
@State private var isInitialized = false
var body: some View {
ZStack {
@@ -43,11 +44,10 @@ struct SeriesMainHomeView: View {
LazyHStack(spacing: 16) {
ForEach(0..<viewModel.completedSeriesList.count, id: \.self) {
let item = viewModel.completedSeriesList[$0]
NavigationLink {
SeriesDetailView(seriesId: item.seriesId)
} label: {
SeriesMainItemView(item: item)
}
SeriesMainItemView(item: item)
.onTapGesture {
AppState.shared.setAppStep(step: .seriesDetail(seriesId: item.seriesId))
}
}
}
.padding(.horizontal, 24)
@@ -89,11 +89,10 @@ struct SeriesMainHomeView: View {
) {
ForEach(viewModel.recommendSeriesList.indices, id: \.self) {
let item = viewModel.recommendSeriesList[$0]
NavigationLink {
SeriesDetailView(seriesId: item.seriesId)
} label: {
SeriesMainItemView(item: item, width: width, height: width * 227 / 160)
}
SeriesMainItemView(item: item, width: width, height: width * 227 / 160)
.onTapGesture {
AppState.shared.setAppStep(step: .seriesDetail(seriesId: item.seriesId))
}
}
}
.padding(.horizontal, horizontalPadding)
@@ -101,23 +100,12 @@ struct SeriesMainHomeView: View {
}
}
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
.onAppear {
viewModel.fetchHome()
if !isInitialized {
viewModel.fetchHome()
isInitialized = true
}
}
if viewModel.isLoading {

View File

@@ -25,7 +25,7 @@ struct SeriesMainView: View {
@State private var selectedTab: InnerTab = .home
var body: some View {
NavigationView {
Group {
BaseView {
VStack(spacing: 0) {
DetailNavigationBar(title: "시리즈 전체보기")

View File

@@ -9,7 +9,8 @@ import SwiftUI
struct SeriesListAllView: View {
@ObservedObject var viewModel = SeriesListAllViewModel()
@StateObject var viewModel = SeriesListAllViewModel()
@State private var isInitialized = false
var creatorId: Int? = nil
var creatorNickname: String? = nil
@@ -18,7 +19,7 @@ struct SeriesListAllView: View {
var isCompleted = false
var body: some View {
NavigationView {
Group {
BaseView(isLoading: $viewModel.isLoading) {
VStack(spacing: 0) {
if isCompleted {
@@ -48,17 +49,16 @@ struct SeriesListAllView: View {
) {
ForEach(0..<viewModel.seriesList.count, id: \.self) { index in
let item = viewModel.seriesList[index]
NavigationLink {
SeriesDetailView(seriesId: item.seriesId)
} label: {
SeriesMainItemView(item: item, width: width, height: width * 227 / 160)
.contentShape(Rectangle())
.onAppear {
if index == viewModel.seriesList.count - 1 {
viewModel.getSeriesList()
}
SeriesMainItemView(item: item, width: width, height: width * 227 / 160)
.contentShape(Rectangle())
.onAppear {
if index == viewModel.seriesList.count - 1 {
viewModel.getSeriesList()
}
}
}
.onTapGesture {
AppState.shared.setAppStep(step: .seriesDetail(seriesId: item.seriesId))
}
}
}
.padding(horizontalPadding)
@@ -67,10 +67,24 @@ struct SeriesListAllView: View {
}
}
.onAppear {
viewModel.creatorId = creatorId
viewModel.isOriginal = isOriginal
viewModel.isCompleted = isCompleted
viewModel.getSeriesList()
let hasFilterChanged =
viewModel.creatorId != creatorId ||
viewModel.isOriginal != isOriginal ||
viewModel.isCompleted != isCompleted
if !isInitialized || hasFilterChanged {
if hasFilterChanged {
viewModel.page = 1
viewModel.isLast = false
viewModel.seriesList.removeAll()
}
viewModel.creatorId = creatorId
viewModel.isOriginal = isOriginal
viewModel.isCompleted = isCompleted
viewModel.getSeriesList()
isInitialized = true
}
}
}
}

View File

@@ -15,300 +15,332 @@ struct ContentView: View {
@State private var message = ""
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
if appState.isRestartApp {
EmptyView()
} else {
HomeView()
NavigationStack(path: $appState.navigationPath) {
ZStack {
Color.black.ignoresSafeArea()
if appState.isRestartApp {
EmptyView()
} else {
HomeView()
}
if case .splash = appState.rootStep {
AppStepLayerView(step: .splash, canPgPaymentViewModel: canPgPaymentViewModel)
.navigationBarBackButtonHidden(true)
}
if let liveDetailSheet = appState.liveDetailSheet {
LiveDetailView(
roomId: liveDetailSheet.roomId,
onClickParticipant: liveDetailSheet.onClickParticipant,
onClickReservation: liveDetailSheet.onClickReservation,
onClickStart: liveDetailSheet.onClickStart,
onClickCancel: liveDetailSheet.onClickCancel,
onClickClose: {
withAnimation {
appState.hideLiveDetailSheet()
}
}
)
}
if isShowDialog {
SodaDialog(
title: I18n.Common.pointGrantTitle,
desc: message,
confirmButtonTitle: I18n.Common.confirm
) {
isShowDialog = false
message = ""
}
}
}
switch appState.appStep {
case .splash:
SplashView()
case .login:
LoginView()
case .signUp:
SignUpView()
case .findPassword:
FindPasswordView()
case .textMessageDetail(let messageItem, let messageBox, let refresh):
TextMessageDetailView(messageItem: messageItem, messageBox: messageBox, refresh: refresh)
case .writeTextMessage(let userId, let nickname):
TextMessageWriteView(replySenderId: userId, replySenderNickname: nickname)
case .writeVoiceMessage(let userId, let nickname, let onRefresh):
VoiceMessageWriteView(replySenderId: userId, replySenderNickname: nickname, onRefresh: onRefresh)
case .settings:
SettingsView()
case .languageSettings:
LanguageSettingsView()
case .notices:
NoticeListView()
case .noticeDetail(let notice):
NoticeDetailView(notice: notice)
case .events:
EventListView()
case .eventDetail(let event):
EventDetailView(event: event)
case .terms:
TermsView(isPrivacyPolicy: false)
case .privacy:
TermsView(isPrivacyPolicy: true)
case .notificationSettings:
NotificationSettingsView()
case .contentViewSettings:
ContentSettingsView()
case .signOut:
SignOutView()
case .canStatus(let refresh):
CanStatusView(refresh: refresh)
case .canCharge(let refresh, let afterCompletionToGoBack):
CanChargeView(refresh: refresh, afterCompletionToGoBack: afterCompletionToGoBack)
case .canPayment(let canProduct, let refresh, let afterCompletionToGoBack):
CanPaymentView(canProduct: canProduct, refresh: refresh, afterCompletionToGoBack: afterCompletionToGoBack)
case .canPgPayment(let canResponse, let refresh, let afterCompletionToGoBack):
CanPgPaymentView(canResponse: canResponse, refresh: refresh, afterCompletionToGoBack: afterCompletionToGoBack)
.environmentObject(canPgPaymentViewModel)
case .liveReservation:
LiveReservationStatusView()
case .liveReservationCancel(let reservationId):
LiveReservationCancelView(reservationId: reservationId)
case .serviceCenter:
ServiceCenterView()
case .createContent:
ContentCreateView()
case .liveReservationComplete(let response):
LiveReservationCompleteView(reservationCompleteData: response)
case .creatorDetail(let userId):
UserProfileView(userId: userId)
case .followerList(let userId):
FollowerListView(userId: userId)
case .modifyContent(let contentId):
ContentModifyView(contentId: contentId)
case .contentListAll(let userId):
ContentListView(userId: userId)
case .contentDetail(let contentId):
ContentDetailView(contentId: contentId)
case .createLive(let timeSettingMode, let onSuccess):
LiveRoomCreateView(
timeSettingMode: timeSettingMode,
onSuccess: onSuccess
)
case .liveNowAll(let onClickParticipant):
LiveNowAllView(onClickParticipant: onClickParticipant)
case .liveReservationAll(let onClickReservation, let onClickStart, let onClickCancel, let onTapCreateLive):
LiveReservationAllView(
onClickReservation: onClickReservation,
onClickStart: onClickStart,
onClickCancel: onClickCancel,
onTapCreateLive: onTapCreateLive
)
case .modifyLive(let room):
LiveRoomEditView(room: room)
case .liveDetail(let roomId, let onClickParticipant, let onClickReservation, let onClickStart, let onClickCancel):
LiveDetailView(
roomId: roomId,
onClickParticipant: onClickParticipant,
onClickReservation: onClickReservation,
onClickStart: onClickStart,
onClickCancel: onClickCancel
)
case .modifyPassword:
ModifyPasswordView()
case .changeNickname:
NicknameUpdateView()
case .profileUpdate(let refresh):
ProfileUpdateView(refresh: refresh)
case .followingList:
FollowCreatorView()
case .orderListAll:
OrderListAllView()
case .userProfileDonationAll(let userId):
UserProfileDonationAllView(userId: userId)
case .userProfileFanTalkAll(let userId):
UserProfileFanTalkAllView(userId: userId)
case .newContentAll(let isFree):
ContentNewAllView(isFree: isFree)
case .curationAll(let title, let curationId):
ContentCurationView(title: title, curationId: curationId)
case .contentRankingAll:
ContentRankingAllView()
case .creatorCommunityAll(let creatorId):
CreatorCommunityAllView(creatorId: creatorId)
case .creatorCommunityWrite(let onSuccess):
CreatorCommunityWriteView(onSuccess: onSuccess)
case .creatorCommunityModify(let postId, let onSuccess):
CreatorCommunityModifyView(postId: postId, onSuccess: onSuccess)
case .canCoupon(let refresh):
CanCouponView(refresh: refresh)
case .contentAllByTheme(let themeId):
ContentAllByThemeView(themeId: themeId)
case .seriesAll(let creatorId, let creatorNickname, let isOriginal, let isCompleted):
SeriesListAllView(creatorId: creatorId, creatorNickname: creatorNickname, isOriginal: isOriginal, isCompleted: isCompleted)
case .seriesDetail(let seriesId):
SeriesDetailView(seriesId: seriesId)
case .seriesContentAll(let seriesId, let seriesTitle):
SeriesContentAllView(seriesId: seriesId, seriesTitle: seriesTitle)
case .tempCanPayment(let orderType, let contentId, let title, let can):
CanPaymentTempView(orderType: orderType, contentId: contentId, title: title, can: can)
case .blockList:
BlockMemberListView()
case .myBox(let currentTab):
ContentBoxView(initCurrentTab: currentTab)
case .auditionDetail(let auditionId):
AuditionDetailView(auditionId: auditionId)
case .auditionRoleDetail(let roleId, let auditionTitle):
AuditionRoleDetailView(
roleId: roleId,
auditionTitle: auditionTitle
)
case .search:
SearchView()
case .contentMain(let startTab):
ContentMainViewV2(selectedTab: startTab)
case .completedSeriesAll:
CompletedSeriesView()
case .newAlarmContentAll:
ContentMainAlarmAllView()
case .newAsmrContentAll:
ContentMainAsmrAllView()
case .newReplayContentAll:
ContentMainReplayAllView()
case .introduceCreatorAll:
ContentMainIntroduceCreatorAllView()
case .message:
MessageView()
case .pointStatus(let refresh):
PointStatusView(refresh: refresh)
case .audition:
AuditionView()
case .characterDetail(let characterId):
CharacterDetailView(characterId: characterId)
case .chatRoom(let id):
ChatRoomView(roomId: id)
case .newCharacterAll:
NewCharacterListView()
case .originalWorkDetail(let originalId):
OriginalWorkDetailView(originalId: originalId)
case .contentAll(let isFree, let isPointOnly):
ContentAllView(isFree: isFree, isPointAvailableOnly: isPointOnly)
case .seriesMain:
SeriesMainView()
default:
EmptyView()
.frame(width: 0, height: 0, alignment: .topLeading)
.onReceive(NotificationCenter.default.publisher(for: .pointGranted)) {
if let msg = $0.object as? String {
self.message = msg
self.isShowDialog = true
}
}
if isShowDialog {
SodaDialog(
title: I18n.Common.pointGrantTitle,
desc: message,
confirmButtonTitle: I18n.Common.confirm
) {
isShowDialog = false
message = ""
.sodaToast(isPresented: $appState.isShowErrorPopup, message: appState.errorMessage, autohideIn: 1)
.navigationDestination(for: AppRoute.self) { route in
if let step = appState.appStep(for: route) {
AppStepLayerView(step: step, canPgPaymentViewModel: canPgPaymentViewModel)
.navigationBarBackButtonHidden(true)
} else {
EmptyView()
.frame(width: 0, height: 0, alignment: .topLeading)
}
}
}
.onReceive(NotificationCenter.default.publisher(for: .pointGranted)) {
if let msg = $0.object as? String {
self.message = msg
self.isShowDialog = true
}
}
.popup(isPresented: $appState.isShowErrorPopup, type: .toast, position: .top, autohideIn: 1) {
GeometryReader { geo in
HStack {
Spacer()
Text(appState.errorMessage)
.padding(.vertical, 13.3)
.frame(width: geo.size.width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.cornerRadius(20)
.padding(.top, 66.7)
Spacer()
}
}
}
}
struct AppStepLayerView: View {
let step: AppStep
@ObservedObject var canPgPaymentViewModel: CanPgPaymentViewModel
@ViewBuilder
var body: some View {
switch step {
case .splash:
SplashView()
case .login:
LoginView()
case .signUp:
SignUpView()
case .findPassword:
FindPasswordView()
case .textMessageDetail(let messageItem, let messageBox, let refresh):
TextMessageDetailView(messageItem: messageItem, messageBox: messageBox, refresh: refresh)
case .writeTextMessage(let userId, let nickname):
TextMessageWriteView(replySenderId: userId, replySenderNickname: nickname)
case .writeVoiceMessage(let userId, let nickname, let onRefresh):
VoiceMessageWriteView(replySenderId: userId, replySenderNickname: nickname, onRefresh: onRefresh)
case .settings:
SettingsView()
case .languageSettings:
LanguageSettingsView()
case .notices:
NoticeListView()
case .noticeDetail(let notice):
NoticeDetailView(notice: notice)
case .events:
EventListView()
case .eventDetail(let event):
EventDetailView(event: event)
case .terms:
TermsView(isPrivacyPolicy: false)
case .privacy:
TermsView(isPrivacyPolicy: true)
case .notificationSettings:
NotificationSettingsView()
case .notificationReceiveSettings:
NotificationReceiveSettingsView()
case .contentViewSettings:
ContentSettingsView()
case .signOut:
SignOutView()
case .canStatus(let refresh):
CanStatusView(refresh: refresh)
case .canCharge(let refresh, let afterCompletionToGoBack):
CanChargeView(refresh: refresh, afterCompletionToGoBack: afterCompletionToGoBack)
case .canPayment(let canProduct, let refresh, let afterCompletionToGoBack):
CanPaymentView(canProduct: canProduct, refresh: refresh, afterCompletionToGoBack: afterCompletionToGoBack)
case .canPgPayment(let canResponse, let refresh, let afterCompletionToGoBack):
CanPgPaymentView(canResponse: canResponse, refresh: refresh, afterCompletionToGoBack: afterCompletionToGoBack)
.environmentObject(canPgPaymentViewModel)
case .liveReservation:
LiveReservationStatusView()
case .liveReservationCancel(let reservationId):
LiveReservationCancelView(reservationId: reservationId)
case .serviceCenter:
ServiceCenterView()
case .createContent:
ContentCreateView()
case .liveReservationComplete(let response):
LiveReservationCompleteView(reservationCompleteData: response)
case .creatorDetail(let userId):
UserProfileView(userId: userId)
case .followerList(let userId):
FollowerListView(userId: userId)
case .modifyContent(let contentId):
ContentModifyView(contentId: contentId)
case .contentListAll(let userId):
ContentListView(userId: userId)
case .contentDetail(let contentId):
ContentDetailView(contentId: contentId)
case .createLive(let timeSettingMode, let onSuccess):
LiveRoomCreateView(
timeSettingMode: timeSettingMode,
onSuccess: onSuccess
)
case .liveNowAll(let onClickParticipant):
LiveNowAllView(onClickParticipant: onClickParticipant)
case .liveReservationAll(let onClickReservation, let onClickStart, let onClickCancel, let onTapCreateLive):
LiveReservationAllView(
onClickReservation: onClickReservation,
onClickStart: onClickStart,
onClickCancel: onClickCancel,
onTapCreateLive: onTapCreateLive
)
case .modifyLive(let room):
LiveRoomEditView(room: room)
case .liveDetail(let roomId, let onClickParticipant, let onClickReservation, let onClickStart, let onClickCancel):
LiveDetailView(
roomId: roomId,
onClickParticipant: onClickParticipant,
onClickReservation: onClickReservation,
onClickStart: onClickStart,
onClickCancel: onClickCancel
)
case .modifyPassword:
ModifyPasswordView()
case .changeNickname:
NicknameUpdateView()
case .profileUpdate(let refresh):
ProfileUpdateView(refresh: refresh)
case .followingList:
FollowCreatorView()
case .orderListAll:
OrderListAllView()
case .userProfileDonationAll(let userId):
UserProfileDonationAllView(userId: userId)
case .channelDonationAll(let creatorId):
ChannelDonationAllView(creatorId: creatorId)
case .userProfileFanTalkAll(let userId):
UserProfileFanTalkAllView(userId: userId)
case .newContentAll(let isFree):
ContentNewAllView(isFree: isFree)
case .curationAll(let title, let curationId):
ContentCurationView(title: title, curationId: curationId)
case .contentRankingAll:
ContentRankingAllView()
case .creatorCommunityAll(let creatorId):
CreatorCommunityAllView(creatorId: creatorId)
case .creatorCommunityWrite(let onSuccess):
CreatorCommunityWriteView(onSuccess: onSuccess)
case .creatorCommunityModify(let postId, let onSuccess):
CreatorCommunityModifyView(postId: postId, onSuccess: onSuccess)
case .canCoupon(let refresh):
CanCouponView(refresh: refresh)
case .contentAllByTheme(let themeId):
ContentAllByThemeView(themeId: themeId)
case .seriesAll(let creatorId, let creatorNickname, let isOriginal, let isCompleted):
SeriesListAllView(creatorId: creatorId, creatorNickname: creatorNickname, isOriginal: isOriginal, isCompleted: isCompleted)
case .seriesDetail(let seriesId):
SeriesDetailView(seriesId: seriesId)
case .seriesContentAll(let seriesId, let seriesTitle):
SeriesContentAllView(seriesId: seriesId, seriesTitle: seriesTitle)
case .tempCanPayment(let orderType, let contentId, let title, let can):
CanPaymentTempView(orderType: orderType, contentId: contentId, title: title, can: can)
case .blockList:
BlockMemberListView()
case .myBox(let currentTab):
ContentBoxView(initCurrentTab: currentTab)
case .auditionDetail(let auditionId):
AuditionDetailView(auditionId: auditionId)
case .auditionRoleDetail(let roleId, let auditionTitle):
AuditionRoleDetailView(
roleId: roleId,
auditionTitle: auditionTitle
)
case .search:
SearchView()
case .contentMain(let startTab):
ContentMainViewV2(selectedTab: startTab)
case .completedSeriesAll:
CompletedSeriesView()
case .newAlarmContentAll:
ContentMainAlarmAllView()
case .newAsmrContentAll:
ContentMainAsmrAllView()
case .newReplayContentAll:
ContentMainReplayAllView()
case .introduceCreatorAll:
ContentMainIntroduceCreatorAllView()
case .message:
MessageView()
case .notificationList:
PushNotificationListView()
case .pointStatus(let refresh):
PointStatusView(refresh: refresh)
case .audition:
AuditionView()
case .characterDetail(let characterId):
CharacterDetailView(characterId: characterId)
case .chatRoom(let id):
ChatRoomView(roomId: id)
case .newCharacterAll:
NewCharacterListView()
case .originalWorkDetail(let originalId):
OriginalWorkDetailView(originalId: originalId)
case .contentAll(let isFree, let isPointOnly):
ContentAllView(isFree: isFree, isPointAvailableOnly: isPointOnly)
case .seriesMain:
SeriesMainView()
case .main:
EmptyView()
.frame(width: 0, height: 0, alignment: .topLeading)
}
}
}

View File

@@ -29,3 +29,6 @@ let GID_SERVER_CLIENT_ID = "758414412471-mosodbj2chno7l1j0iihldh6edmk0gk9.apps.g
let KAKAO_APP_KEY = "20cf19413d63bfdfd30e8e6dff933d33"
let LINE_CHANNEL_ID = "2008995582"
let AGORA_CUSTOMER_ID = "de5dd9ea151f4a43ba1ad8411817b169"
let AGORA_CUSTOMER_SECRET = "3855da8bc5ae4743af8bf4f87408b515"

View File

@@ -121,26 +121,7 @@ struct MemberProfileDialog: View {
viewModel.getMemberProfile(memberId: memberId)
}
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
.onDisappear {
if viewModel.dismissDialog {
isShowing = false
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
if viewModel.isShowUesrBlockConfirm {
UserBlockConfirmDialogView(

View File

@@ -13,12 +13,15 @@ enum ExplorerApi {
case getExplorer
case searchChannel(channel: String)
case getCreatorProfile(userId: Int, isAdultContentVisible: Bool)
case getCreatorDetail(userId: Int)
case getFollowerList(userId: Int, page: Int, size: Int)
case getCreatorProfileCheers(userId: Int, page: Int, size: Int)
case writeCheers(parentCheersId: Int?, creatorId: Int, content: String)
case modifyCheers(request: PutModifyCheersRequest)
case writeCreatorNotice(request: PostCreatorNoticeRequest)
case getCreatorProfileDonationRanking(userId: Int, page: Int, size: Int)
case getCreatorProfileDonationRanking(userId: Int, page: Int, size: Int, period: DonationRankingPeriod?)
case getChannelDonationList(creatorId: Int)
case postChannelDonation(request: PostChannelDonationRequest)
}
extension ExplorerApi: TargetType {
@@ -39,8 +42,11 @@ extension ExplorerApi: TargetType {
case .getCreatorProfile(let userId, _):
return "/explorer/profile/\(userId)"
case .getCreatorDetail(let userId):
return "/explorer/profile/\(userId)/detail"
case .getCreatorProfileDonationRanking(let userId, _, _):
case .getCreatorProfileDonationRanking(let userId, _, _, _):
return "/explorer/profile/\(userId)/donation-rank"
case .getFollowerList(let userId, _, _):
@@ -57,15 +63,18 @@ extension ExplorerApi: TargetType {
case .writeCreatorNotice:
return "/explorer/profile/notice"
case .getChannelDonationList, .postChannelDonation:
return "/explorer/profile/channel-donation"
}
}
var method: Moya.Method {
switch self {
case .getExplorer, .searchChannel, .getCreatorProfile, .getFollowerList, .getCreatorProfileCheers, .getCreatorProfileDonationRanking, .getCreatorRank:
case .getExplorer, .searchChannel, .getCreatorProfile, .getCreatorDetail, .getFollowerList, .getCreatorProfileCheers, .getCreatorProfileDonationRanking, .getCreatorRank, .getChannelDonationList:
return .get
case .writeCheers, .writeCreatorNotice:
case .writeCheers, .writeCreatorNotice, .postChannelDonation:
return .post
case .modifyCheers:
@@ -75,7 +84,7 @@ extension ExplorerApi: TargetType {
var task: Task {
switch self {
case .getExplorer, .getCreatorRank:
case .getExplorer, .getCreatorRank, .getCreatorDetail:
return .requestPlain
case .searchChannel(let channel):
@@ -111,13 +120,23 @@ extension ExplorerApi: TargetType {
case .writeCreatorNotice(let request):
return .requestJSONEncodable(request)
case .getCreatorProfileDonationRanking(_, let page, let size):
let parameters = [
case .getChannelDonationList(let creatorId):
return .requestParameters(parameters: ["creatorId": creatorId], encoding: URLEncoding.queryString)
case .postChannelDonation(let request):
return .requestJSONEncodable(request)
case .getCreatorProfileDonationRanking(_, let page, let size, let period):
var parameters = [
"page": page - 1,
"size": size
] as [String : Any]
if let period {
parameters["period"] = period.rawValue
}
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
}
}

View File

@@ -29,6 +29,10 @@ final class ExplorerRepository {
)
)
}
func getCreatorDetail(id: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getCreatorDetail(userId: id))
}
func getFollowerList(userId: Int, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getFollowerList(userId: userId, page: page, size: size))
@@ -51,7 +55,27 @@ final class ExplorerRepository {
return api.requestPublisher(.writeCreatorNotice(request: PostCreatorNoticeRequest(notice: notice)))
}
func getCreatorProfileDonationRanking(userId: Int, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getCreatorProfileDonationRanking(userId: userId, page: page, size: size))
func getCreatorProfileDonationRanking(
userId: Int,
page: Int,
size: Int,
period: DonationRankingPeriod?
) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getCreatorProfileDonationRanking(
userId: userId,
page: page,
size: size,
period: period
)
)
}
func getChannelDonationList(creatorId: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getChannelDonationList(creatorId: creatorId))
}
func postChannelDonation(request: PostChannelDonationRequest) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.postChannelDonation(request: request))
}
}

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