131 Commits

Author SHA1 Message Date
Yu Sung
aea5b5cdec feat(creator): 팔로워 목록 이동을 연결한다 2026-08-03 21:43:56 +09:00
Yu Sung
2db4ef1c35 fix(imagepicker): 크롭 범위를 보정한다 2026-08-03 21:13:45 +09:00
Yu Sung
9320657bac chore(project): 앱 버전을 1.56.0으로 올린다 2026-08-03 18:22:10 +09:00
Yu Sung
dc99bdf9c9 fix(main): 현재 라이브 전체보기를 보정한다 2026-08-03 15:37:27 +09:00
Yu Sung
498debbfc7 fix(main): 현재 라이브 전체 스타일을 보정한다 2026-08-03 14:06:28 +09:00
Yu Sung
a8fe416cb5 fix(main): 추천 홈 후속 검증을 보완한다 2026-07-31 21:12:32 +09:00
Yu Sung
3ae2dc19d6 fix(main): 최근 활동 이동을 보정한다 2026-07-30 16:13:32 +09:00
Yu Sung
0232cd4335 docs(agent): 문서 샘플 기준을 정리한다 2026-07-30 14:30:32 +09:00
Yu Sung
a0db58e53f feat(main): 라이브 전체 진입을 추가한다 2026-07-22 15:47:52 +09:00
Yu Sung
70af4923c6 feat(main): 생성 메뉴를 홈에 추가한다 2026-07-20 19:14:02 +09:00
Yu Sung
9a0e75a391 fix(creator): 후원 카드 너비를 보정한다 2026-07-20 18:09:51 +09:00
Yu Sung
43e32f1867 fix(live): 라이브 상세 표시를 보정한다 2026-07-20 17:31:49 +09:00
Yu Sung
dd5144ec3f chore(project): 앱 버전을 1.55.1로 올린다 2026-07-15 14:42:17 +09:00
Yu Sung
9cda165d30 fix(main): 이벤트 팝업 배경 투명도를 조정한다 2026-07-15 14:42:10 +09:00
Yu Sung
b99505174a fix(main): 복귀 시 메인 초기화를 한 번만 실행한다 2026-07-15 14:42:02 +09:00
Yu Sung
e466774a8a docs(main): 복귀 초기화 방지 계획을 추가한다 2026-07-15 14:41:46 +09:00
Yu Sung
063c2808de docs(live): 복귀 동작 계획을 갱신한다 2026-07-14 18:12:53 +09:00
Yu Sung
0388e48930 fix(live): 라이브 종료 후 이전 화면을 유지한다 2026-07-14 18:12:21 +09:00
Yu Sung
1b26bc28dd fix(live): 라이브룸 전역 표시를 보정한다 2026-07-14 17:42:05 +09:00
Yu Sung
dec6fb6fb8 fix(content): 콘텐츠 상세 광고를 제거한다 2026-07-13 16:57:55 +09:00
Yu Sung
9b115f3339 fix(main): 팔로잉 빈 섹션을 숨긴다 2026-07-13 16:15:19 +09:00
Yu Sung
dbf696978f fix(chat): Direct 대화방 진입을 분기한다 2026-07-12 23:40:39 +09:00
Yu Sung
c9570fc735 fix(chat): 채팅 쿼터 구매 동선을 정리한다 2026-07-12 21:59:04 +09:00
Yu Sung
4f271a77ee fix(creator): 본인 채널 DM 확인을 추가한다 2026-07-12 18:48:27 +09:00
Yu Sung
caa74c607d chore(project): 빌드 번호를 올린다 2026-07-12 17:33:28 +09:00
Yu Sung
c38c8656e3 fix(main): 보이스온 전용 카드 크기를 조정한다 2026-07-12 17:33:05 +09:00
Yu Sung
359df77b83 fix(splash): 스플래시 화면을 변경한다 2026-07-12 09:06:49 +09:00
Yu Sung
eaed309a1d fix(creator): 커뮤니티 반응 순서를 조정한다 2026-07-12 08:33:13 +09:00
Yu Sung
47fe0e787d fix(main): 장르 크리에이터 섹션을 숨긴다 2026-07-12 06:29:05 +09:00
Yu Sung
ec79e3ce8b fix(main): 팔로잉 커뮤니티 상세로 이동한다 2026-07-12 05:54:02 +09:00
Yu Sung
e717a12344 fix(main): 댓글 많은 오디오 제목을 변경한다 2026-07-12 05:10:47 +09:00
Yu Sung
2dfff32ac1 feat(chat): 유저 크리에이터 DM을 추가한다 2026-07-12 04:48:17 +09:00
Yu Sung
ce191539f2 fix(main): 추천 커뮤니티 상세로 이동한다 2026-07-10 13:04:06 +09:00
Yu Sung
5213f0d98d fix(main): 랭킹 배지 크기를 조정한다 2026-07-09 16:40:34 +09:00
Yu Sung
0d9a4880ef fix(creator): 팬Talk 답글 응답을 반영한다 2026-07-09 07:00:16 +09:00
Yu Sung
2d636a9bb8 fix(creator): 답글 헤더 배치를 조정한다 2026-07-09 05:21:10 +09:00
Yu Sung
955cbb6592 fix(main): 추천 섹션 순서를 조정한다 2026-07-09 04:32:27 +09:00
Yu Sung
bc9f4ed370 fix(creator): 팬Talk 답글 수정을 확인한다 2026-07-09 02:54:20 +09:00
Yu Sung
d96001e213 refactor(v2): 액션 모달로 전환한다 2026-07-08 19:02:14 +09:00
Yu Sung
3f21456048 fix(creator): 고액 후원 글자색을 조정한다 2026-07-08 16:28:11 +09:00
Yu Sung
09443761af feat(main): 홈 대화 탭을 추가한다 2026-07-08 15:54:07 +09:00
Yu Sung
403eee262f feat(main): 콘텐츠 탭 타이틀바를 정리한다 2026-07-08 03:45:32 +09:00
Yu Sung
c0369e6f58 feat(creator): 커뮤니티 게시글 상세를 연결한다 2026-07-08 01:56:57 +09:00
Yu Sung
f2d589eca2 fix(creator): 팬Talk 액션 팝업을 정리한다 2026-07-07 15:44:45 +09:00
Yu Sung
75aa99768e feat(creator): 팬Talk 답글 상세를 연결한다 2026-07-07 15:04:34 +09:00
Yu Sung
2283ea4b44 feat(creator): 커뮤니티 답글 상세를 연결한다 2026-07-07 15:04:28 +09:00
Yu Sung
58f83f3083 feat(creator): 답글 상세 경로를 연결한다 2026-07-07 15:04:21 +09:00
Yu Sung
9276d17c26 feat(creator): 답글 상세 화면을 등록한다 2026-07-07 15:04:15 +09:00
Yu Sung
e7a011c519 feat(creator): 답글 상세 컴포넌트를 추가한다 2026-07-07 15:04:09 +09:00
Yu Sung
7b756ae4be feat(creator): 답글 상세 상태를 추가한다 2026-07-07 15:03:56 +09:00
Yu Sung
c356815b3e feat(creator): 답글 상세 모델을 추가한다 2026-07-07 15:03:49 +09:00
Yu Sung
26884d9e81 docs(creator): 답글 상세 계획을 추가한다 2026-07-07 15:03:43 +09:00
Yu Sung
981c02a8e1 feat(content): 추천 전체보기를 연결한다 2026-07-07 11:54:31 +09:00
Yu Sung
b33aed94c6 feat(content): 랭킹 유형 탭을 추가한다 2026-07-07 00:38:48 +09:00
Yu Sung
9c4c304fc6 feat(creator): 팬Talk 쓰기 화면을 연결한다 2026-07-06 22:54:20 +09:00
Yu Sung
d129583d3b feat(content): 랭킹 탭을 추가한다 2026-07-06 20:19:27 +09:00
Yu Sung
bae92eba4b feat(content): 전체 탭을 추가한다 2026-07-06 16:28:33 +09:00
Yu Sung
b42ca61ce4 feat(content): 추천 오디오 탭을 추가한다 2026-07-06 14:03:09 +09:00
Yu Sung
f3cbf42328 docs(content): 추천 탭 계획 문서를 추가한다 2026-07-06 01:13:25 +09:00
Yu Sung
9a141a09de fix(home): 추천 크리에이터 카드를 화면 폭에 맞춘다 2026-07-06 00:57:10 +09:00
Yu Sung
3ee2316507 fix(creator): 선택 탭을 가운데 정렬한다 2026-07-05 23:12:38 +09:00
Yu Sung
f3b7d01a3f feat(creator): 커뮤니티 탭을 추가한다 2026-07-05 22:25:07 +09:00
Yu Sung
130b8eee40 feat(creator): 팬톡 답글 연결선을 추가한다 2026-07-05 19:34:43 +09:00
Yu Sung
f5ead536ea feat(creator): 팬Talk 탭을 추가한다 2026-07-05 00:44:03 +09:00
Yu Sung
7c98af0c7d feat(creator): 후원 탭을 연결한다 2026-07-04 22:45:07 +09:00
Yu Sung
2063603f32 refactor(creator): 후원 카드를 분리한다 2026-07-04 22:44:57 +09:00
Yu Sung
02b56ac6da feat(creator): 후원 탭 UI 조각을 추가한다 2026-07-04 22:44:48 +09:00
Yu Sung
e75d836b99 feat(creator): 후원 탭 상태를 추가한다 2026-07-04 22:44:39 +09:00
Yu Sung
3e11d82938 feat(creator): 후원 탭 API를 추가한다 2026-07-04 22:44:31 +09:00
Yu Sung
265ac75eab docs(creator): 후원 탭 계획을 추가한다 2026-07-04 22:44:20 +09:00
Yu Sung
8c71b6883b feat(creator): 시리즈 탭을 추가한다 2026-07-04 18:50:24 +09:00
Yu Sung
0dd111f5d6 feat(creator): 정렬 팝업을 적용한다 2026-07-04 17:11:19 +09:00
Yu Sung
c4c18341a6 feat(creator): 오디오 탭을 추가한다 2026-07-04 16:40:35 +09:00
Yu Sung
6600cd43c1 feat(creator): 라이브 탭 빈 상태를 추가한다 2026-07-04 03:39:41 +09:00
Yu Sung
b6580e201f feat(creator): 라이브 탭 UI를 마무리한다 2026-07-04 03:20:46 +09:00
Yu Sung
abc10d7610 refactor(creator): 오디오 아이템 이름을 정리한다 2026-07-04 01:48:35 +09:00
Yu Sung
0b32a536c9 feat(creator): 라이브 탭 화면을 추가한다 2026-07-04 01:06:38 +09:00
Yu Sung
ce923ab2d7 feat(creator): 라이브 탭 기반을 추가한다 2026-07-03 23:53:08 +09:00
Yu Sung
03d286e8a9 docs(creator): 검증 기록을 갱신한다 2026-07-03 22:12:21 +09:00
Yu Sung
0fa036f04d feat(creator): 플로팅 액션 진입을 연결한다 2026-07-03 22:02:11 +09:00
Yu Sung
5059cd4a40 feat(creator): 본인 채널 액션 기반을 추가한다 2026-07-03 21:05:40 +09:00
Yu Sung
ca5432532e docs(creator): 최종 검증 기록을 갱신한다 2026-07-03 19:34:24 +09:00
Yu Sung
b84098b15e feat(creator): 대화하기 채팅방 진입을 연결한다 2026-07-03 18:57:17 +09:00
Yu Sung
2b9018f9cb fix(home): AI 캐릭터 탭 이동을 맞춘다 2026-07-03 18:31:31 +09:00
Yu Sung
37c34ae91e feat(creator): 대화하기 액션을 연결한다 2026-07-03 18:13:04 +09:00
Yu Sung
3f24788c29 feat(creator): 소개, 활동, SNS 섹션을 연결한다 2026-07-03 17:27:53 +09:00
Yu Sung
4214829a20 feat(creator): 팬Talk 섹션을 연결한다 2026-07-03 16:49:48 +09:00
Yu Sung
884952db5a feat(creator): 커뮤니티 좋아요를 연결한다 2026-07-03 16:10:10 +09:00
Yu Sung
aa735b7053 feat(creator): 커뮤니티 섹션을 연결한다 2026-07-03 15:58:32 +09:00
Yu Sung
ed6240fdd0 feat(creator): 시리즈 섹션을 연결한다 2026-07-03 15:07:24 +09:00
Yu Sung
32174cd0af feat(creator): 오디오 섹션을 연결한다 2026-07-03 14:34:27 +09:00
Yu Sung
0775b17fd4 feat(creator): 스케줄 섹션을 연결한다 2026-07-03 13:59:50 +09:00
Yu Sung
610f0eee4b feat(creator): 공지 섹션을 연결한다 2026-07-03 13:09:22 +09:00
Yu Sung
c136f5bbc3 feat(creator): 채널 후원 섹션을 연결한다 2026-07-03 11:45:53 +09:00
Yu Sung
f514a7ac40 feat(creator): 최신 오디오 섹션을 연결한다 2026-07-03 03:34:36 +09:00
Yu Sung
0e358d73c3 feat(creator): 현재 라이브 섹션을 연결한다 2026-07-03 02:48:42 +09:00
Yu Sung
2f1feb9561 feat(creator): 홈 탭 콘텐츠를 연결한다 2026-07-03 01:53:30 +09:00
Yu Sung
069a8cf5e8 feat(creator): 타이틀 바 닉네임을 표시한다 2026-07-03 01:43:16 +09:00
Yu Sung
527b499c30 docs(creator): 채널 홈 계획을 갱신한다 2026-07-03 01:35:08 +09:00
Yu Sung
f19b04dc0e feat(creator): 채널 탭 고정을 연결한다 2026-07-03 01:34:40 +09:00
Yu Sung
6cebf3052c feat(creator): 채널 탭 바를 분리한다 2026-07-02 23:22:46 +09:00
Yu Sung
6b571424cf feat(creator): 채널 헤더를 연결한다 2026-07-02 21:54:16 +09:00
Yu Sung
1a7368d2a4 fix(creator): 채널 홈 응답 모델을 맞춘다 2026-07-02 18:31:05 +09:00
Yu Sung
cd55b3e2f5 feat(creator): 채널 타이틀 바를 분리한다 2026-07-02 16:41:00 +09:00
Yu Sung
949c8cec8e feat(creator): 채널 공통 셸을 분리한다 2026-07-02 15:50:38 +09:00
Yu Sung
d87d761670 docs(creator): 채널 홈 공통 셸 계획을 갱신한다 2026-07-02 15:13:09 +09:00
Yu Sung
cc14353fa4 docs(creator): 채널 홈 구현 기록을 갱신한다 2026-07-01 20:14:17 +09:00
Yu Sung
29f7f3c26c feat(creator): 채널 홈 기본 화면을 연결한다 2026-07-01 20:14:08 +09:00
Yu Sung
033a9d3a0b docs(creator): 채널 홈 계획을 정리한다 2026-07-01 19:44:54 +09:00
Yu Sung
4469872081 feat(assets): 채널 홈 아이콘을 추가한다 2026-07-01 19:33:06 +09:00
Yu Sung
0dd1cf9cfe docs(home): 팔로잉 검증 기록을 갱신한다 2026-07-01 17:54:31 +09:00
Yu Sung
f93e65372b feat(home): 팔로잉 탭 화면을 조립한다 2026-07-01 17:36:07 +09:00
Yu Sung
5b679389a6 feat(home): 콘텐츠 소식 카드를 추가한다 2026-07-01 16:53:49 +09:00
Yu Sung
e60942735b feat(home): 최근 소식 섹션을 추가한다 2026-07-01 15:56:33 +09:00
Yu Sung
54d2787be0 feat(home): 랭킹 소식 카드를 추가한다 2026-07-01 15:27:39 +09:00
Yu Sung
9ecb663f7b docs(home): 팔로잉 다국어화 기록을 추가한다 2026-07-01 14:14:00 +09:00
Yu Sung
641b00e66b refactor(home): 팔로잉 문구를 다국어화한다 2026-07-01 14:13:43 +09:00
Yu Sung
e3dd508528 feat(home): 팔로잉 스케줄 섹션을 추가한다 2026-07-01 13:38:43 +09:00
Yu Sung
772af3dfe3 feat(home): 팔로잉 최근 대화를 연결한다 2026-07-01 11:43:45 +09:00
Yu Sung
5761d6083b feat(home): 팔로잉 On Air 섹션을 추가한다 2026-07-01 10:36:03 +09:00
Yu Sung
2a23a341c0 feat(home): 팔로잉 크리에이터 탭을 연결한다 2026-07-01 09:55:14 +09:00
Yu Sung
e15d114be3 feat(home): 팔로잉 API 데이터를 연결한다 2026-07-01 00:24:37 +09:00
Yu Sung
29a54937ab docs(home): 팔로잉 탭 계획을 조정한다 2026-07-01 00:14:01 +09:00
Yu Sung
fb7d0c799f feat(home): 팔로잉 크리에이터 섹션을 추가한다 2026-07-01 00:04:17 +09:00
Yu Sung
ae09776c9b feat(home): 팔로잉 탭 API 상태를 추가한다 2026-06-30 22:04:40 +09:00
Yu Sung
e2d46614bd docs(home): 팔로잉 탭 계획을 추가한다 2026-06-30 21:24:31 +09:00
Yu Sung
5223e44dd4 docs(home): Phase 7 검증 기록을 갱신한다 2026-06-30 17:38:45 +09:00
Yu Sung
cd3ff1ee31 feat(home): 랭킹 탭 화면을 조립한다 2026-06-30 16:39:42 +09:00
Yu Sung
66dc8b29cb feat(home): 랭킹 순위 UI를 추가한다 2026-06-30 16:17:02 +09:00
Yu Sung
6e3a7b21f7 feat(home): 순위 변화 배지를 추가한다 2026-06-30 15:30:39 +09:00
Yu Sung
c4e83c3aef feat(home): 랭킹 API 기반 상태를 추가한다 2026-06-30 15:06:47 +09:00
363 changed files with 37622 additions and 896 deletions

View File

@@ -151,8 +151,12 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
## 문서 작성 규칙
- 구현 전 PRD 작성, 사용자 인터뷰, 계획/TASK 문서 작성, 체크리스트 갱신, 검증 기록 누적, 문서 분리 기준은 `docs/agent-guides/documentation-policy.md`를 따른다.
- 신규 PRD, 계획/TASK 문서, 코드 리뷰 리포트를 만들기 전에 각각 `docs/sample/sample-prd.md`, `docs/sample/sample-plan-task.md`, `docs/sample/sample-review.md`를 확인하고 현재 샘플 구조를 따른다.
- 연속된 하나의 작업은 새 문서를 만들지 말고 기존 PRD와 `plan-task.md`에 이어서 기록한다.
- 계획/TASK 문서는 phase heading(`### Phase N: ...`)과 단계별 체크박스(`- [ ] **Task N.N: ...**`)를 사용하고,task에 대상 파일 경로와 검증 기준을 함께 적는다.
- 계획/TASK 문서는 `sample-plan-task.md`의 Phase, Task, Goal, 완료 증거, 검증 기록 구조를 따르고Task에 대상 파일 경로와 검증 기준을 적는다.
- 코드 리뷰와 점검 결과는 PRD 및 `plan-task.md`와 같은 작업 디렉터리의 `reviews/` 아래에 `phase<번호>-<구현 내용을 나타내는 영문 kebab-case>.md` 형식의 리포트로 남긴다.
- 리뷰에서 수정이 필요하다고 확정한 항목은 코드 수정 전에 `plan-task.md`의 해당 Phase에 실행 가능한 신규 Task로 추가하고, 그 Task를 기준으로 수정 및 검증한다.
## 문서 유지보수 규칙
- 상세 문서 유지보수 규칙은 `docs/agent-guides/documentation-policy.md`를 참조한다.
- `docs/sample/**`의 경로 또는 구조가 바뀌면 `AGENTS.md`, `docs/agent-guides/**`, 샘플 간 참조를 함께 갱신해 서로 일치시킨다.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_arrow_up_gray.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: 293 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_arrow_up_white.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: 243 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_bar_bell_colored.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: 328 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_bar_storage.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: 361 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_content_tag_first_star.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: 324 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_content_tag_original.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: 549 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_content_tag_point.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: 750 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_bar_back.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: 228 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_create_live.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: 606 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_dm.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: 356 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_donation.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: 393 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_grid.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: 529 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_list.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: 304 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_more.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: 218 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_player_play.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: 249 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_shield_large.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: 321 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_shield_small.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: 328 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_sort.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: 358 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_talk.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: 372 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_upload_audio.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: 510 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_upload_community_post.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: 506 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_x_black.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: 295 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_rank_caret_decrease.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: 231 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_rank_caret_increase.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: 216 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_rank_caret_stay.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: 192 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_rank_new.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: 802 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_series_original.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: 603 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "img_new_only.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: 482 B

View File

@@ -2,6 +2,7 @@ import Foundation
enum AppDeepLinkAction {
case live(roomId: Int)
case userCreatorChat(roomId: Int)
case content(contentId: Int)
case series(seriesId: Int)
case community(creatorId: Int, postId: Int?)
@@ -57,6 +58,10 @@ enum AppDeepLinkHandler {
AppState.shared.pushRoomId = 0
AppState.shared.pushRoomId = roomId
case .userCreatorChat(let roomId):
guard roomId > 0 else { return }
AppState.shared.setAppStep(step: .userCreatorChatRoom(roomId: roomId))
case .content(let contentId):
guard contentId > 0 else { return }
AppState.shared.setAppStep(step: .contentDetail(contentId: contentId))
@@ -166,6 +171,12 @@ enum AppDeepLinkHandler {
}
return .live(roomId: roomId)
case "chat":
guard let identifier = identifier, let roomId = Int(identifier), roomId > 0 else {
return nil
}
return .userCreatorChat(roomId: roomId)
case "content":
guard let identifier = identifier, let contentId = Int(identifier), contentId > 0 else {
return nil

View File

@@ -25,6 +25,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
private var subscription = Set<AnyCancellable>()
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
guard !Self.isRunningForPreviews else { return true }
FirebaseApp.configure()
LoginManager.shared.setup(channelID: LINE_CHANNEL_ID, universalLinkURL: nil)
Notifly.initialize(projectId: NOTIFLY_PROJECT_ID, username: NOTIFLY_USERNAME, password: NOTIFLY_PASSWORD)
@@ -50,6 +52,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return true
}
private static var isRunningForPreviews: Bool {
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
}
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
// Print full message.

View File

@@ -38,9 +38,25 @@ class AppState: ObservableObject {
if isShowPlayer {
ContentPlayManager.shared.stopAudio()
ContentPlayerPlayManager.shared.resetPlayer()
return
}
guard let pendingExternalNavigationAction else {
return
}
self.pendingExternalNavigationAction = nil
pendingExternalNavigationCancelAction = nil
DispatchQueue.main.async {
pendingExternalNavigationAction()
}
}
}
@Published private(set) var isShowLeaveLiveNavigationDialog = false
private var pendingExternalNavigationAction: (() -> Void)?
private var pendingExternalNavigationCancelAction: (() -> Void)?
@Published var isShowNotificationSettingsDialog = false
@@ -64,6 +80,7 @@ class AppState: ObservableObject {
@Published var eventPopup: EventItem? = nil
@Published var purchasedContentId = 0
@Published var purchasedContentOrderType = OrderType.KEEP
@Published private(set) var pendingMainChatFilter: MainChatFilter? = nil
@Published var isRestartApp = false
@Published var startTab: HomeViewModel.CurrentTab = .home
@@ -147,6 +164,37 @@ class AppState: ObservableObject {
}
}
func requestExternalNavigation(
navigationAction: @escaping () -> Void,
cancelAction: @escaping () -> Void
) {
guard isShowPlayer else {
navigationAction()
return
}
pendingExternalNavigationAction = navigationAction
pendingExternalNavigationCancelAction = cancelAction
isShowLeaveLiveNavigationDialog = true
}
func confirmExternalNavigation() {
guard pendingExternalNavigationAction != nil else {
isShowLeaveLiveNavigationDialog = false
return
}
isShowLeaveLiveNavigationDialog = false
NotificationCenter.default.post(name: .requestLiveRoomQuitForExternalNavigation, object: nil)
}
func cancelExternalNavigation() {
isShowLeaveLiveNavigationDialog = false
pendingExternalNavigationAction = nil
pendingExternalNavigationCancelAction?()
pendingExternalNavigationCancelAction = nil
}
func consumePendingDeepLinkAction() -> AppDeepLinkAction? {
let action = pendingDeepLinkAction
pendingDeepLinkAction = nil
@@ -191,6 +239,16 @@ class AppState: ObservableObject {
pendingContentSettingsGuideMessage = nil
return message
}
func setPendingMainChatFilter(_ filter: MainChatFilter) {
pendingMainChatFilter = filter
}
func consumePendingMainChatFilter() -> MainChatFilter? {
let filter = pendingMainChatFilter
pendingMainChatFilter = nil
return filter
}
// ( -> ) UI
func softRestart() {

View File

@@ -62,7 +62,7 @@ enum AppStep {
case serviceCenter
case createContent
case createContent(onSuccess: (() -> Void)? = nil)
case modifyContent(contentId: Int)
@@ -87,7 +87,7 @@ enum AppStep {
onSuccess: (CreateLiveRoomResponse) -> Void
)
case liveNowAll(onClickParticipant: (Int) -> Void)
case liveNowAll
case liveReservationAll(
onClickReservation: (Int) -> Void,
@@ -127,6 +127,25 @@ enum AppStep {
case creatorCommunityWrite(onSuccess: () -> Void)
case creatorCommunityModify(postId: Int, onSuccess: () -> Void)
case creatorChannelFanTalkWrite(
creatorId: Int,
mode: CreatorChannelFanTalkWriteViewModel.Mode,
fanTalkId: Int?,
initialContent: String,
onSuccess: () -> Void
)
case creatorChannelReplyDetail(
context: CreatorChannelReplyDetailContext,
onFanTalkRefresh: (() -> Void)? = nil,
onCommunityRefresh: (() -> Void)? = nil
)
case creatorChannelCommunityPostDetail(
postId: Int,
onCommunityRefresh: (() -> Void)? = nil
)
case canCoupon(refresh: () -> Void)
@@ -161,12 +180,18 @@ enum AppStep {
case characterDetail(characterId: Int)
case chatRoom(id: Int)
case userCreatorChatRoom(roomId: Int)
case userCreatorChatCreator(creatorId: Int)
case newCharacterAll
case originalWorkDetail(originalId: Int)
case contentAll(isFree: Bool = false, isPointOnly: Bool = false)
case contentOverview(type: ContentOverviewType = .newAndHotAudio)
case seriesMain
}

View File

@@ -29,8 +29,14 @@ struct SodaLiveApp: App {
configureImageCache()
// ,
LanguageHeaderProvider.initialize()
guard !Self.isRunningForPreviews else { return }
KakaoSDK.initSDK(appKey: KAKAO_APP_KEY)
}
private static var isRunningForPreviews: Bool {
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
}
private func configureImageCache() {
// Kingfisher
@@ -50,6 +56,8 @@ struct SodaLiveApp: App {
ContentView(canPgPaymentViewModel: canPgPaymentViewModel)
.environment(\.locale, languageEnvironment.locale)
.task {
guard !Self.isRunningForPreviews else { return }
await LanguageContainer.service.bootstrap()
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)) { _ in
@@ -62,6 +70,8 @@ struct SodaLiveApp: App {
ImageCache.default.clearMemoryCache()
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
guard !Self.isRunningForPreviews else { return }
UIApplication.shared.applicationIconBadgeNumber = 0
AppsFlyerLib.shared().start()

View File

@@ -149,9 +149,6 @@ struct ChatRoomView: View {
if viewModel.showQuotaNoticeView {
ChatQuotaNoticeItemView(
onSelectAd: {
viewModel.showRewardedAdForChatQuota()
},
onSelectCan10: {
viewModel.purchaseChatQuota(canOption: .can10)
},

View File

@@ -279,9 +279,16 @@ final class ChatRoomViewModel: ObservableObject {
func showRewardedAdForChatQuota() {
_Concurrency.Task {
await YandexRewardedAdManager.shared.showAdIfAvailable(for: .chatRoomQuota) { [weak self] in
let isShown = await YandexRewardedAdManager.shared.showAdIfAvailable(for: .chatRoomQuota) { [weak self] in
self?.purchaseChatQuota(chargeType: .ad, canOption: nil)
}
if !isShown {
await MainActor.run {
errorMessage = I18n.Chat.Room.rewardedAdLoadFailed
isShowPopup = true
}
}
}
}

View File

@@ -9,31 +9,11 @@ import SwiftUI
struct ChatQuotaNoticeItemView: View {
let onSelectAd: () -> Void
let onSelectCan10: () -> Void
let onSelectCan20: () -> Void
var body: some View {
VStack(spacing: 10) {
Button {
onSelectAd()
} label: {
Text(I18n.Chat.Room.quotaAdAction(chatCount: 5))
.appFont(size: 18, weight: .bold)
.foregroundColor(Color(hex: "263238"))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.frame(maxWidth: .infinity)
.background(Color(hex: "FEF8E3"))
.cornerRadius(30)
.overlay {
RoundedRectangle(cornerRadius: 30)
.stroke(lineWidth: 1)
.foregroundColor(Color(hex: "F7CB50"))
}
.buttonStyle(.plain)
HStack(spacing: 8) {
Button {
onSelectCan10()
@@ -80,7 +60,6 @@ struct ChatQuotaNoticeItemView: View {
#Preview {
ChatQuotaNoticeItemView(
onSelectAd: {},
onSelectCan10: {},
onSelectCan20: {}
)

View File

@@ -47,6 +47,44 @@ enum DateParser {
return I18n.Time.daysAgo(max(1, Int(interval / 86_400)))
}
static func chatListDateText(fromUTC text: String, fallback: String, now: Date = Date()) -> String {
guard let date = parse(text) else {
return fallback
}
let interval = max(0, now.timeIntervalSince(date))
if interval < 60 {
return I18n.Time.justNow
}
if interval < 3600 {
return I18n.Time.minutesAgo(max(1, Int(interval / 60)))
}
if interval < 86_400 {
return I18n.Time.hoursAgo(max(1, Int(interval / 3600)))
}
if interval < 604_800 {
return I18n.Time.daysAgo(max(1, Int(interval / 86_400)))
}
let calendar = Calendar.current
if calendar.component(.year, from: date) == calendar.component(.year, from: now) {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: LanguageHeaderProvider.current)
formatter.timeZone = calendar.timeZone
formatter.dateFormat = I18n.MainChat.monthDayDateFormat
return formatter.string(from: date)
}
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = calendar.timeZone
formatter.dateFormat = "yyyy.MM.dd"
return formatter.string(from: date)
}
// : ISO8601( ) ISO8601 RFC3339
private static let parsers: [(String) -> Date?] = [

View File

@@ -11,7 +11,6 @@ import YandexMobileAds
enum YandexBannerPlacement {
case liveTab
case liveDetail
case contentDetail
case creatorCommunityAll
case seriesMainHome
case seriesMainDayOfWeek
@@ -23,10 +22,6 @@ enum YandexBannerPlacement {
case chatTalkTabTop
}
enum YandexInterstitialPlacement {
case contentDetail
}
enum YandexRewardedPlacement {
case chatRoomQuota
}
@@ -39,8 +34,6 @@ enum YandexAdUnitIdProvider {
YANDEX_LIVE_TAB_BANNER_AD_UNIT_ID
case .liveDetail:
YANDEX_LIVE_DETAIL_BANNER_AD_UNIT_ID
case .contentDetail:
YANDEX_CONTENT_DETAIL_BANNER_AD_UNIT_ID
case .creatorCommunityAll:
YANDEX_CREATOR_COMMUNITY_ALL_BANNER_AD_UNIT_ID
case .seriesMainHome:
@@ -62,13 +55,6 @@ enum YandexAdUnitIdProvider {
}
}
static func interstitial(for placement: YandexInterstitialPlacement) -> String {
switch placement {
case .contentDetail:
YANDEX_CONTENT_DETAIL_INTERSTITIAL_AD_UNIT_ID
}
}
static func rewarded(for placement: YandexRewardedPlacement) -> String {
switch placement {
case .chatRoomQuota:
@@ -204,117 +190,6 @@ private struct YandexInlineBannerContainer: UIViewRepresentable {
}
}
@MainActor
final class YandexInterstitialAdManager: NSObject {
static let shared = YandexInterstitialAdManager()
private var interstitialAd: InterstitialAd?
private var interstitialAdLoader: InterstitialAdLoader?
private var currentPlacement: YandexInterstitialPlacement?
private var pendingAction: (@MainActor () -> Void)?
private var isLoading = false
func preloadAd(for placement: YandexInterstitialPlacement) {
guard !isLoading else {
return
}
if currentPlacement == placement, interstitialAd != nil {
return
}
let loader = InterstitialAdLoader()
interstitialAdLoader = loader
interstitialAd = nil
currentPlacement = placement
isLoading = true
Task {
do {
let loadedAd = try await loader.loadAd(with: AdRequest(adUnitID: YandexAdUnitIdProvider.interstitial(for: placement)))
guard currentPlacement == placement else {
isLoading = false
return
}
loadedAd.delegate = self
interstitialAd = loadedAd
} catch {
if currentPlacement == placement {
interstitialAd = nil
}
}
if currentPlacement == placement {
isLoading = false
}
}
}
func showAdIfAvailable(for placement: YandexInterstitialPlacement, then action: @escaping @MainActor () -> Void) {
guard let presenter = presentingViewController(), let interstitialAd, currentPlacement == placement else {
action()
preloadAd(for: placement)
return
}
pendingAction = action
interstitialAd.show(from: presenter)
}
private func completePendingAction() {
let action = pendingAction
pendingAction = nil
interstitialAd = nil
action?()
if let currentPlacement {
preloadAd(for: currentPlacement)
}
}
private func presentingViewController() -> UIViewController? {
guard
let rootViewController = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.flatMap({ $0.windows })
.first(where: { $0.isKeyWindow })?
.rootViewController
else {
return nil
}
var topViewController = rootViewController
while let presentedViewController = topViewController.presentedViewController {
topViewController = presentedViewController
}
return topViewController
}
}
extension YandexInterstitialAdManager: InterstitialAdDelegate {
func interstitialAdDidShow(_ interstitialAd: InterstitialAd) {
}
func interstitialAdDidDismiss(_ interstitialAd: InterstitialAd) {
completePendingAction()
}
func interstitialAdDidClick(_ interstitialAd: InterstitialAd) {
}
func interstitialAd(_ interstitialAd: InterstitialAd, didTrackImpression impressionData: ImpressionData?) {
}
func interstitialAd(_ interstitialAd: InterstitialAd, didFailToShow error: Error) {
completePendingAction()
}
}
@MainActor
final class YandexRewardedAdManager: NSObject {

View File

@@ -55,7 +55,7 @@ struct ContentListView: View {
.cornerRadius(5.3)
.padding(.top, 13.3)
.padding(.horizontal, 13.3)
.onTapGesture { AppState.shared.setAppStep(step: .createContent) }
.onTapGesture { AppState.shared.setAppStep(step: .createContent()) }
}
HStack(spacing: 13.3) {

View File

@@ -10,6 +10,7 @@ import Kingfisher
struct ContentCreateView: View {
@Environment(\.locale) private var locale
let onSuccess: (() -> Void)?
@StateObject var keyboardHandler = KeyboardHandler()
@StateObject private var viewModel = ContentCreateViewModel()
@@ -23,6 +24,10 @@ struct ContentCreateView: View {
@State private var isShowSelectThemeView = false
@State private var isShowSelectDateView = false
@State private var isShowSelectTimeView = false
init(onSuccess: (() -> Void)? = nil) {
self.onSuccess = onSuccess
}
var body: some View {
let normalizedCountryCode = UserDefaults
@@ -655,7 +660,10 @@ struct ContentCreateView: View {
title: I18n.CreateContent.uploadTitle,
desc: I18n.CreateContent.uploadDescription,
confirmButtonTitle: I18n.Common.confirm,
confirmButtonAction: { AppState.shared.back() },
confirmButtonAction: {
onSuccess?()
AppState.shared.back()
},
cancelButtonTitle: "",
cancelButtonAction: {}
)

View File

@@ -199,10 +199,6 @@ struct ContentDetailPlayView: View {
.frame(width: screenSize().width - 40)
}
.onAppear {
Task {
await YandexInterstitialAdManager.shared.preloadAd(for: .contentDetail)
}
if !isPlaying() {
stopTimer()
}
@@ -245,22 +241,7 @@ struct ContentDetailPlayView: View {
)
}
guard shouldShowInterstitialBeforePlayback(showPreviewAlert: showPreviewAlert) else {
playAction()
return
}
Task {
await YandexInterstitialAdManager.shared.showAdIfAvailable(for: .contentDetail, then: playAction)
}
}
private func shouldShowInterstitialBeforePlayback(showPreviewAlert: Bool) -> Bool {
if contentPlayManager.contentId == audioContent.contentId {
return false
}
return audioContent.price <= 0 || showPreviewAlert
playAction()
}
private func sliderRange() -> ClosedRange<Double> {

View File

@@ -91,10 +91,6 @@ struct ContentDetailView: View {
.padding(.top, 13.3)
.padding(.horizontal, 13.3)
}
YandexInlineBannerView(placement: .contentDetail)
.padding(.top, 13.3)
ContentDetailInfoView(
isExpandDescription: $viewModel.isExpandDescription,
isShowPreviewAlert: $viewModel.isShowPreviewAlert,

View File

@@ -30,21 +30,6 @@ struct ContentView: View {
.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,
@@ -62,7 +47,6 @@ struct ContentView: View {
self.isShowDialog = true
}
}
.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)
@@ -73,6 +57,49 @@ struct ContentView: View {
}
}
}
.overlay {
ZStack {
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 appState.isShowPlayer {
LiveRoomViewV2()
}
if appState.isShowLeaveLiveNavigationDialog {
leaveLiveNavigationDialog
}
}
}
.sodaToast(isPresented: $appState.isShowErrorPopup, message: appState.errorMessage, autohideIn: 1)
}
private var leaveLiveNavigationDialog: some View {
SodaV2ActionModal(
title: I18n.Common.alertTitle,
message: I18n.LiveRoom.leaveLiveForNavigationDesc,
button1: SodaV2ActionModalButton(
label: I18n.Common.confirm,
action: appState.confirmExternalNavigation
),
button2: SodaV2ActionModalButton(
label: I18n.Common.cancel,
action: appState.cancelExternalNavigation
),
onDimmedTap: appState.cancelExternalNavigation
)
}
}
@@ -162,14 +189,14 @@ struct AppStepLayerView: View {
case .serviceCenter:
ServiceCenterView()
case .createContent:
ContentCreateView()
case .createContent(let onSuccess):
ContentCreateView(onSuccess: onSuccess)
case .liveReservationComplete(let response):
LiveReservationCompleteView(reservationCompleteData: response)
case .creatorDetail(let userId):
UserProfileView(userId: userId)
CreatorChannelView(creatorId: userId)
case .followerList(let userId):
FollowerListView(userId: userId)
@@ -189,8 +216,8 @@ struct AppStepLayerView: View {
onSuccess: onSuccess
)
case .liveNowAll(let onClickParticipant):
LiveNowAllView(onClickParticipant: onClickParticipant)
case .liveNowAll:
LiveNowAllView()
case .liveReservationAll(let onClickReservation, let onClickStart, let onClickCancel, let onTapCreateLive):
LiveReservationAllView(
@@ -254,6 +281,19 @@ struct AppStepLayerView: View {
case .creatorCommunityModify(let postId, let onSuccess):
CreatorCommunityModifyView(postId: postId, onSuccess: onSuccess)
case .creatorChannelFanTalkWrite(let creatorId, let mode, _, _, let onSuccess):
CreatorChannelFanTalkWriteView(creatorId: creatorId, mode: mode, onSuccess: onSuccess)
case .creatorChannelReplyDetail(let context, let onFanTalkRefresh, let onCommunityRefresh):
CreatorChannelReplyDetailView(
context: context,
onFanTalkRefresh: onFanTalkRefresh,
onCommunityRefresh: onCommunityRefresh
)
case .creatorChannelCommunityPostDetail(let postId, let onCommunityRefresh):
CreatorChannelCommunityPostDetailView(postId: postId, onCommunityRefresh: onCommunityRefresh)
case .canCoupon(let refresh):
CanCouponView(refresh: refresh)
@@ -308,6 +348,12 @@ struct AppStepLayerView: View {
case .chatRoom(let id):
ChatRoomView(roomId: id)
case .userCreatorChatRoom(let roomId):
UserCreatorChatRoomView(roomId: roomId)
case .userCreatorChatCreator(let creatorId):
UserCreatorChatRoomView(creatorId: creatorId)
case .newCharacterAll:
NewCharacterListView()
@@ -317,6 +363,11 @@ struct AppStepLayerView: View {
case .contentAll(let isFree, let isPointOnly):
ContentAllView(isFree: isFree, isPointAvailableOnly: isPointOnly)
case .contentOverview(let type):
ContentOverviewView(type: type) { contentId in
AppState.shared.setAppStep(step: .contentDetail(contentId: contentId))
}
case .seriesMain:
SeriesMainView()

View File

@@ -18,6 +18,7 @@ struct CreatorCommunityCommentItemView: View {
let modifyComment: (Int, String) -> Void
let onClickDelete: (Int) -> Void
let onClickProfile: (Int) -> Void
let onTapReplyDetail: () -> Void
@State var isShowPopupMenu: Bool = false
@State var isModeModify: Bool = false
@@ -110,19 +111,19 @@ struct CreatorCommunityCommentItemView: View {
.fixedSize(horizontal: false, vertical: true)
.lineSpacing(8)
.padding(.top, 13.3)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
if isReplyComment == false {
onTapReplyDetail()
}
}
if !isReplyComment {
NavigationLink(
destination: CreatorCommunityCommentReplyView(
creatorId: creatorId,
postId: postId,
parentComment: commentItem
)
) {
Text(commentItem.replyCount > 0 ? I18n.Explorer.replyCount(commentItem.replyCount) : I18n.Explorer.replyWrite)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.button)
}
Text(commentItem.replyCount > 0 ? I18n.Explorer.replyCount(commentItem.replyCount) : I18n.Explorer.replyWrite)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.button)
.onTapGesture(perform: onTapReplyDetail)
}
}
}

View File

@@ -145,6 +145,20 @@ struct CreatorCommunityCommentListView: View {
onClickProfile: {
memberId = $0
isShowMemberProfilePopup = true
},
onTapReplyDetail: {
isPresented = false
DispatchQueue.main.async {
AppState.shared.setAppStep(
step: .creatorChannelReplyDetail(
context: .community(
creatorId: creatorId,
postId: postId,
parentComment: comment
)
)
)
}
}
)
.padding(.horizontal, 26.7)

View File

@@ -105,7 +105,8 @@ struct CreatorCommunityCommentReplyView: View {
onClickProfile: {
memberId = $0
isShowMemberProfilePopup = true
}
},
onTapReplyDetail: {}
)
.padding(.horizontal, 26.7)
.padding(.bottom, 13.3)
@@ -131,7 +132,8 @@ struct CreatorCommunityCommentReplyView: View {
onClickProfile: {
memberId = $0
isShowMemberProfilePopup = true
}
},
onTapReplyDetail: {}
)
.padding(.horizontal, 26.7)
.padding(.leading, 13.3)

View File

@@ -52,14 +52,14 @@ struct CreatorCommunityReportView: View {
Text(I18n.Common.cancel)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "9970ff"))
.foregroundColor(Color.soda400)
.onTapGesture {
isShowing = false
}
Text(I18n.Report.reportAction)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "9970ff"))
.foregroundColor(Color.soda400)
.onTapGesture {
if let selectedIndex = selectedIndex {
confirmAction(reasons[selectedIndex])

View File

@@ -46,7 +46,7 @@ struct UserProfileContentView: View {
.frame(maxWidth: .infinity)
.background(Color.button)
.cornerRadius(12)
.onTapGesture { AppState.shared.setAppStep(step: .createContent) }
.onTapGesture { AppState.shared.setAppStep(step: .createContent()) }
} else {
collectionInfoView()
}

View File

@@ -300,7 +300,6 @@ final class UserProfileViewModel: ObservableObject {
} else {
if roomId > 0 {
AppState.shared.isShowPlayer = true
AppState.shared.setAppStep(step: .main)
}
}
} else {

View File

@@ -408,7 +408,7 @@ struct HomeTabView: View {
.padding(.trailing, 16.7)
.padding(.bottom, 16.7)
.onTapGesture {
AppState.shared.setAppStep(step: .createContent)
AppState.shared.setAppStep(step: .createContent())
}
}

View File

@@ -8,7 +8,7 @@
import Foundation
import Combine
enum SeriesPublishedDaysOfWeek: String, Encodable {
enum SeriesPublishedDaysOfWeek: String, Encodable, Decodable, CaseIterable, Hashable {
case SUN, MON, TUE, WED, THU, FRI, SAT, RANDOM
}

View File

@@ -11,6 +11,28 @@ import Foundation
// String Catalog i18n.
// LanguageHeaderProvider.current("ko"|"en"|"ja").
enum I18n {
enum UserCreatorChat {
static var messagePlaceholder: String {
pick(ko: "메시지를 입력하세요", en: "Enter a message", ja: "メッセージを入力してください")
}
static var sendingStatus: String {
pick(ko: "전송 중", en: "Sending", ja: "送信中")
}
static var failedStatus: String {
pick(ko: "실패", en: "Failed", ja: "失敗")
}
static var joinFailureToast: String {
pick(
ko: "대화방에 접속하지 못했습니다.",
en: "Failed to join the chat room.",
ja: "チャットルームに接続できませんでした。"
)
}
}
// ( )
enum ChatRoom {
//
@@ -278,6 +300,14 @@ enum I18n {
)
}
static var rewardedAdLoadFailed: String {
pick(
ko: "광고를 불러오지 못했어요\n잠시 후 다시 시도해 주세요.",
en: "Could not load the ad.\nPlease try again later.",
ja: "広告を読み込めませんでした。\nしばらくしてからもう一度お試しください。"
)
}
static var backgroundSelectionTitle: String {
pick(ko: "배경 이미지 선택", en: "Select background image", ja: "背景画像を選択")
}
@@ -423,9 +453,9 @@ enum I18n {
static var pinLimitDesc: String {
pick(
ko: "이 콘텐츠를 고정하시겠어요? 채널에 콘텐츠를 최대 3개까지 고정할 수 있습니다. 이 콘텐츠를 고정하면 가장 오래된 콘텐츠가 대체됩니다.",
en: "Do you want to pin this content? You can pin up to 3 contents in a channel. Pinning this will replace the oldest pinned content.",
ja: "このコンテンツを固定しますか?チャンネルには最大3件まで固定できます。このコンテンツを固定すると最も古い固定コンテンツが置き換えられます。"
ko: "이 콘텐츠를 고정하시겠어요? 채널에 콘텐츠를 최대 9개까지 고정할 수 있습니다. 이 콘텐츠를 고정하면 가장 오래된 콘텐츠가 대체됩니다.",
en: "Do you want to pin this content? You can pin up to 9 contents in a channel. Pinning this will replace the oldest pinned content.",
ja: "このコンテンツを固定しますか?チャンネルには最大9件まで固定できます。このコンテンツを固定すると最も古い固定コンテンツが置き換えられます。"
)
}
@@ -1409,9 +1439,9 @@ enum I18n {
enum Splash {
static var tagline: String {
pick(
ko: "목소리로 만나는 새로운 세계",
en: "A new world you meet by voice",
ja: "声でつながる新しいワールド"
ko: "당신의 최애가 기다리는 곳",
en: "Meet Your Favorites",
ja: "推しが待つ場所"
)
}
@@ -2337,7 +2367,7 @@ enum I18n {
enum LiveNow {
static var allTitle: String {
pick(ko: "지금 라이브 중 전체보기", en: "Live Now - All", ja: "ライブ配信中(全て)")
pick(ko: "On Air", en: "On Air", ja: "On Air")
}
static var sectionTitle: String {
@@ -2952,6 +2982,44 @@ If you block this user, the following features will be restricted.
}
}
enum MainChat {
static var filterAll: String {
pick(ko: "전체", en: "All", ja: "すべて")
}
static var filterAI: String {
pick(ko: "AI 채팅", en: "AI chats", ja: "AIチャット")
}
static var filterDM: String {
pick(ko: "DM", en: "DM", ja: "DM")
}
static var emptyStateMessage: String {
pick(
ko: "대화 목록이 없습니다.",
en: "No talks yet.",
ja: "トークはまだありません。"
)
}
static var loadFailedMessage: String {
pick(
ko: "대화 목록을 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.",
en: "Unable to load talks.\nPlease try again later.",
ja: "トーク一覧を読み込めませんでした。\nしばらくしてからもう一度お試しください。"
)
}
static var directTag: String {
pick(ko: "Direct", en: "Direct", ja: "Direct")
}
static var monthDayDateFormat: String {
pick(ko: "M월 d일", en: "MMM d", ja: "M月d日")
}
}
enum Main {
enum EventPopup {
static var doNotShowAgain: String {
@@ -2967,7 +3035,7 @@ If you block this user, the following features will be restricted.
static var home: String { pick(ko: "", en: "Home", ja: "ホーム") }
static var content: String { pick(ko: "콘텐츠", en: "Content", ja: "コンテンツ") }
static var live: String { pick(ko: "라이브", en: "Live", ja: "ライブ") }
static var chat: String { pick(ko: "채팅", en: "Chat", ja: "チャッ") }
static var chat: String { pick(ko: "대화", en: "Talks", ja: "ーク") }
static var my: String { pick(ko: "마이", en: "My", ja: "マイ") }
}
@@ -2998,6 +3066,148 @@ If you block this user, the following features will be restricted.
}
}
enum MainContentRecommendation {
static var tabTitle: String {
pick(ko: "추천", en: "Recommended", ja: "おすすめ")
}
static var emptyStateMessage: String {
pick(
ko: "추천 오디오가 아직 없어요.",
en: "No recommended audio yet.",
ja: "おすすめオーディオはまだありません。"
)
}
static var loadFailedMessage: String {
pick(
ko: "추천 오디오를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.",
en: "Unable to load recommended audio.\nPlease try again later.",
ja: "おすすめオーディオを読み込めませんでした。\nしばらくしてからもう一度お試しください。"
)
}
static var latestAudioSectionTitle: String {
pick(ko: "새로 올라온 오디오", en: "Latest audio", ja: "新着オーディオ")
}
static var newAndHotSectionTitle: String {
pick(ko: "New&Hot", en: "New&Hot", ja: "New&Hot")
}
static var voiceOnOnlySectionTitle: String {
pick(ko: "오직 보이스온에서만!", en: "Only on VoiceOn!", ja: "VoiceOnだけで")
}
static var freeAudioSectionTitle: String {
pick(ko: "무료 오디오", en: "Free audio", ja: "無料オーディオ")
}
static var pointAudioSectionTitle: String {
pick(ko: "포인트 오디오", en: "Point audio", ja: "ポイントオーディオ")
}
static var mostCommentedAudioSectionTitle: String {
pick(ko: "크리에이터의 댓글 많은 오디오", en: "Most commented audio", ja: "最近コメントが多いオーディオ")
}
static var recommendedAudioSectionTitle: String {
pick(ko: "추천 오디오", en: "Recommended audio", ja: "おすすめオーディオ")
}
}
enum MainContentRanking {
static var tabTitle: String {
pick(ko: "랭킹", en: "Ranking", ja: "ランキング")
}
static var weeklyPopularTabTitle: String {
pick(ko: "주간 인기", en: "Weekly popular", ja: "週間人気")
}
static var risingTabTitle: String {
pick(ko: "지금 뜨는 중", en: "Rising now", ja: "今話題")
}
static var revenueTabTitle: String {
pick(ko: "매출", en: "Revenue", ja: "売上")
}
static var salesCountTabTitle: String {
pick(ko: "판매량", en: "Sales", ja: "販売数")
}
static var commentCountTabTitle: String {
pick(ko: "댓글수", en: "Comments", ja: "コメント数")
}
static var likeCountTabTitle: String {
pick(ko: "좋아요", en: "Likes", ja: "いいね")
}
static var emptyStateMessage: String {
pick(
ko: "랭킹 콘텐츠가 아직 없어요.",
en: "No ranking content yet.",
ja: "ランキングコンテンツはまだありません。"
)
}
static var loadFailedMessage: String {
pick(
ko: "랭킹 콘텐츠를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.",
en: "Unable to load ranking content.\nPlease try again later.",
ja: "ランキングコンテンツを読み込めませんでした。\nしばらくしてからもう一度お試しください。"
)
}
}
enum MainContentAll {
static var tabTitle: String {
pick(ko: "전체", en: "All", ja: "すべて")
}
static var rangeLabel: String {
pick(ko: "전체", en: "All", ja: "すべて")
}
static var emptyStateMessage: String {
pick(
ko: "콘텐츠가 아직 없어요.",
en: "No content yet.",
ja: "コンテンツはまだありません。"
)
}
static var loadFailedMessage: String {
pick(
ko: "콘텐츠를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.",
en: "Unable to load content.\nPlease try again later.",
ja: "コンテンツを読み込めませんでした。\nしばらくしてからもう一度お試しください。"
)
}
enum TypeChip {
static var audio: String { pick(ko: "오디오", en: "Audio", ja: "オーディオ") }
static var series: String { pick(ko: "시리즈", en: "Series", ja: "シリーズ") }
static var original: String { pick(ko: "오리지널", en: "Original", ja: "オリジナル") }
static var free: String { pick(ko: "무료", en: "Free", ja: "無料") }
static var point: String { pick(ko: "포인트", en: "Point", ja: "ポイント") }
}
enum DayOfWeek {
static var mon: String { pick(ko: "", en: "MON", ja: "") }
static var tue: String { pick(ko: "", en: "TUE", ja: "") }
static var wed: String { pick(ko: "", en: "WED", ja: "") }
static var thu: String { pick(ko: "", en: "THU", ja: "") }
static var fri: String { pick(ko: "", en: "FRI", ja: "") }
static var sat: String { pick(ko: "", en: "SAT", ja: "") }
static var sun: String { pick(ko: "", en: "SUN", ja: "") }
static var random: String { pick(ko: "기타", en: "OTHER", ja: "その他") }
}
}
enum Home {
static var liveNowSectionTitle: String {
pick(ko: "지금 라이브중", en: "Live now", ja: "ライブ配信中")
@@ -3042,6 +3252,283 @@ If you block this user, the following features will be restricted.
}
}
enum HomeFollowing {
static var emptyStateMessage: String {
pick(
ko: "아직 팔로잉 소식이 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.",
en: "There are no following updates yet.\nFollow creators you're interested in.",
ja: "まだフォロー中の新着情報がありません。\n気になるクリエイターをフォローしてみましょう。"
)
}
static var loadFailedMessage: String {
pick(
ko: "팔로잉 정보를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.",
en: "Unable to load following updates.\nPlease try again later.",
ja: "フォロー情報を読み込めませんでした。\nしばらくしてからもう一度お試しください。"
)
}
static var loginRequiredMessage: String {
pick(
ko: "로그인 후 팔로잉 소식을 확인할 수 있어요.",
en: "Log in to see following updates.",
ja: "ログインするとフォロー中の新着情報を確認できます。"
)
}
static var loginButtonTitle: String {
pick(ko: "로그인", en: "Log in", ja: "ログイン")
}
static var allButtonTitle: String {
pick(ko: "전체", en: "All", ja: "すべて")
}
static var tabTitle: String {
pick(ko: "팔로잉", en: "Following", ja: "フォロー中")
}
static var onAirTitle: String {
pick(ko: "On Air", en: "On Air", ja: "On Air")
}
static var liveTag: String {
pick(ko: "LIVE", en: "LIVE", ja: "LIVE")
}
static var recentChatTitle: String {
pick(ko: "최근 대화", en: "Recent chats", ja: "最近のトーク")
}
static var directTag: String {
pick(ko: "Direct", en: "Direct", ja: "Direct")
}
static var monthlyScheduleTitle: String {
pick(ko: "이달의 스케줄", en: "This month's schedule", ja: "今月のスケジュール")
}
static var today: String {
pick(ko: "오늘", en: "Today", ja: "今日")
}
static var onAirStatus: String {
pick(ko: "On Air", en: "On Air", ja: "On Air")
}
static var liveScheduleTag: String {
pick(ko: "라이브", en: "Live", ja: "ライブ")
}
static var liveReplayScheduleTag: String {
pick(ko: "다시보기", en: "Replay", ja: "アーカイブ")
}
static var audioScheduleTag: String {
pick(ko: "오디오", en: "Audio", ja: "オーディオ")
}
static var communityScheduleTag: String {
pick(ko: "커뮤니티", en: "Community", ja: "コミュニティ")
}
static var recentNewsTitle: String {
pick(ko: "최근 소식", en: "Recent news", ja: "最近のニュース")
}
static func rankingMessagePrefix(_ title: String) -> String {
pick(
ko: "\(title)님이 이번 주 랭킹 ",
en: "\(title) reached ",
ja: "\(title)さんが今週のランキング"
)
}
static func rankingRankText(_ rank: Int) -> String {
pick(ko: "\(rank)", en: "No. \(rank)", ja: "\(rank)")
}
static var rankingMessageSuffix: String {
pick(ko: "에 올랐어요!", en: " in this week's ranking!", ja: "に入りました!")
}
static var audioTag: String {
pick(ko: "오디오", en: "Audio", ja: "オーディオ")
}
static var photoTag: String {
pick(ko: "화보", en: "Photo", ja: "フォト")
}
}
enum HomeRanking {
static var emptyStateMessage: String {
pick(
ko: "순위 집계 중입니다.\n잠시 후 다시 시도해 주세요.",
en: "Rankings are being calculated.\nPlease try again later.",
ja: "ランキングを集計中です。\nしばらくしてからもう一度お試しください。"
)
}
}
enum CreatorChannelLive {
static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") }
static var startLive: String { pick(ko: "라이브 시작하기", en: "Start live", ja: "ライブを開始") }
static var emptyMessage: String {
pick(
ko: "크리에이터가 라이브를 준비중입니다.\n기대해 주세요!",
en: "The creator is preparing a live.\nPlease stay tuned!",
ja: "クリエイターがライブを準備中です。\nお楽しみに!"
)
}
enum Sort {
static var latest: String { pick(ko: "최신순", en: "Latest", ja: "最新順") }
static var popular: String { pick(ko: "인기순", en: "Popular", ja: "人気順") }
static var priceHigh: String { pick(ko: "높은 가격순", en: "Highest price", ja: "価格が高い順") }
static var priceLow: String { pick(ko: "낮은 가격순", en: "Lowest price", ja: "価格が低い順") }
}
}
enum CreatorChannelAudio {
static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") }
static var countUnit: String { pick(ko: "", en: "", ja: "") }
static var ownershipPrefix: String {
pick(ko: "전체 오디오의 ", en: "You own ", ja: "全オーディオの")
}
static var ownershipSuffix: String {
pick(ko: "를 소장하고 있어요.", en: " of all audio.", ja: "を保有中です。")
}
static var emptyMessage: String {
pick(
ko: "크리에이터가 오디오를 준비 중입니다.\n기대해 주세요!",
en: "The creator is preparing audio.\nPlease stay tuned!",
ja: "クリエイターがオーディオを準備中です。\nお楽しみに!"
)
}
}
enum CreatorChannelSeries {
static var contentCountUnit: String { pick(ko: "", en: " episodes", ja: "") }
static var proceeding: String { pick(ko: "연재", en: "Ongoing", ja: "連載") }
static var completed: String { pick(ko: "완결", en: "Completed", ja: "完結") }
static var emptyMessage: String {
pick(
ko: "크리에이터가 시리즈를 준비 중입니다.\n기대해 주세요!",
en: "The creator is preparing series.\nPlease stay tuned!",
ja: "クリエイターがシリーズを準備中です。\nお楽しみに!"
)
}
static func totalContentCount(_ count: Int) -> String {
pick(ko: "\(count)", en: "Total \(count) episodes", ja: "\(count)")
}
}
enum CreatorChannelHome {
static var currentLive: String { pick(ko: "현재 라이브", en: "Live now", ja: "現在ライブ") }
static var latestAudio: String { pick(ko: "최신 오디오 콘텐츠", en: "Latest audio", ja: "最新オーディオ") }
static var donation: String { pick(ko: "후원", en: "Donations", ja: "応援") }
static var notice: String { pick(ko: "공지", en: "Notice", ja: "お知らせ") }
static var schedule: String { pick(ko: "스케줄", en: "Schedule", ja: "スケジュール") }
static var audio: String { pick(ko: "오디오", en: "Audio", ja: "オーディオ") }
static var series: String { pick(ko: "시리즈", en: "Series", ja: "シリーズ") }
static var community: String { pick(ko: "커뮤니티", en: "Community", ja: "コミュニティ") }
static var fanTalk: String { pick(ko: "팬Talk", en: "Fan Talk", ja: "ファンTalk") }
static var introduce: String { pick(ko: "소개", en: "Introduction", ja: "紹介") }
static var activity: String { pick(ko: "활동", en: "Activity", ja: "活動") }
static var sns: String { pick(ko: "SNS", en: "SNS", ja: "SNS") }
static var follow: String { pick(ko: "팔로우", en: "Follow", ja: "フォロー") }
static var following: String { pick(ko: "팔로잉", en: "Following", ja: "フォロー中") }
static var talk: String { pick(ko: "대화하기", en: "Start chat", ja: "会話する") }
static var dm: String { pick(ko: "DM 보내기", en: "Send DM", ja: "DMを送る") }
static var checkDm: String { pick(ko: "DM 확인하기", en: "Check DM", ja: "DMを確認") }
static var uploadCommunityPost: String { pick(ko: "커뮤니티 글 올리기", en: "Upload community post", ja: "コミュニティ投稿を作成") }
static var uploadAudioContent: String { pick(ko: "오디오 콘텐츠 올리기", en: "Upload audio content", ja: "オーディオコンテンツを投稿") }
static var createLive: String { pick(ko: "라이브 만들기", en: "Create live", ja: "ライブを作成") }
static var closeFloatingMenu: String { pick(ko: "닫기", en: "Close", ja: "閉じる") }
static var viewAll: String { pick(ko: "전체보기", en: "View all", ja: "すべて見る") }
static var debut: String { pick(ko: "데뷔", en: "Debut", ja: "デビュー") }
static var liveCount: String { pick(ko: "라이브 총 진행 수", en: "Total live count", ja: "ライブ総配信数") }
static var liveDurationHours: String { pick(ko: "라이브 누적 진행 시간", en: "Total live hours", ja: "ライブ累計配信時間") }
static var liveContributorCount: String { pick(ko: "라이브 누적 참여자", en: "Total live participants", ja: "ライブ累計参加者") }
static var audioContentCount: String { pick(ko: "오디오", en: "Audio", ja: "オーディオ") }
static var seriesCount: String { pick(ko: "시리즈", en: "Series", ja: "シリーズ") }
static var placeholderMessage: String {
pick(
ko: "준비 중입니다.",
en: "Coming soon.",
ja: "準備中です。"
)
}
static func followerCount(_ count: String) -> String {
pick(ko: "팔로워 \(count)", en: "\(count) followers", ja: "フォロワー\(count)")
}
static func liveCountValue(_ count: String) -> String {
pick(ko: "\(count)", en: "\(count)", ja: "\(count)")
}
static func liveDurationHoursValue(_ count: String) -> String {
pick(ko: "\(count)시간", en: "\(count) hours", ja: "\(count)時間")
}
static func liveContributorCountValue(_ count: String) -> String {
pick(ko: "\(count)", en: "\(count) people", ja: "\(count)")
}
static func audioContentCountValue(_ count: String) -> String {
pick(ko: "\(count)", en: "\(count)", ja: "\(count)")
}
static func seriesCountValue(_ count: String) -> String {
pick(ko: "\(count)", en: "\(count)", ja: "\(count)")
}
}
enum CreatorChannelCommunity {
static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") }
static var listView: String { pick(ko: "리스트형", en: "List", ja: "リスト型") }
static var thumbnailView: String { pick(ko: "썸네일형", en: "Thumbnail", ja: "サムネイル型") }
static var commentPlaceholder: String { pick(ko: "댓글을 입력하세요", en: "Enter a comment", ja: "コメントを入力してください") }
static var communityEmpty: String {
pick(
ko: "크리에이터가 커뮤니티를 준비 중입니다.\n기대해 주세요!",
en: "The creator is preparing the community.\nPlease look forward to it!",
ja: "クリエイターがコミュニティを準備中です。\nお楽しみに!"
)
}
}
enum CreatorChannelReplyDetail {
static var replyPlaceholder: String {
pick(ko: "답글을 입력하세요", en: "Enter a reply", ja: "返信を入力してください")
}
static var replaceReplyTitle: String {
pick(ko: "답글을 수정할까요?", en: "Replace your reply?", ja: "返信を修正しますか?")
}
static var replaceReplyMessage: String {
pick(ko: "이미 작성한 답글이 있어요. 확인을 누르면 기존 답글이 새 내용으로 수정됩니다.", en: "You already have a reply. If you continue, the existing reply will be updated with the new content.", ja: "すでに返信があります。確認すると既存の返信が新しい内容に修正されます。")
}
static var replaceReplyConfirm: String {
pick(ko: "수정하기", en: "Update", ja: "修正する")
}
}
enum CreatorChannelFanTalk {
static var send: String { pick(ko: "보내기", en: "Send", ja: "送信") }
static var exitModalTitle: String { pick(ko: "입력 종료", en: "Discard input", ja: "入力終了") }
static var exitModalMessage: String { pick(ko: "입력을 종료할까요?\n지금 나가면 입력한 내용이 저장되지 않아요.", en: "Discard your input?\nIf you leave now, your input will not be saved.", ja: "入力を終了しますか?\n今退出すると入力した内容は保存されません。") }
static var exitModalExit: String { pick(ko: "종료", en: "Discard", ja: "終了") }
static var exitModalContinue: String { pick(ko: "계속 작성", en: "Keep writing", ja: "続けて書く") }
}
enum HomeRecommendation {
static var activityLive: String {
pick(ko: "라이브", en: "Live", ja: "ライブ")
@@ -3059,6 +3546,10 @@ If you block this user, the following features will be restricted.
pick(ko: "현재 라이브", en: "Live now", ja: "現在ライブ")
}
static var liveAll: String {
pick(ko: "전체", en: "All", ja: "すべて")
}
static var activeCreatorSectionTitle: String {
pick(ko: "방금 활동한 크리에이터", en: "Recently active creators", ja: "最近活動したクリエイター")
}
@@ -3146,6 +3637,18 @@ If you block this user, the following features will be restricted.
pick(ko: "모두 팔로우 완료", en: "All followed", ja: "すべてフォロー済み")
}
static var followAllConfirmTitle: String {
pick(ko: "모두 팔로우할까요?", en: "Follow all creators?", ja: "すべてのクリエイターをフォローしますか?")
}
static var followAllConfirmDescription: String {
pick(ko: "추천된 크리에이터를 모두 팔로우합니다.", en: "Youll follow all recommended creators.", ja: "おすすめのクリエイターをすべてフォローします。")
}
static var followAllConfirmAction: String {
pick(ko: "팔로우 하기", en: "Follow", ja: "フォローする")
}
static var more: String {
pick(ko: "더보기", en: "More", ja: "もっと見る")
}
@@ -3204,6 +3707,14 @@ If you block this user, the following features will be restricted.
pick(ko: "수정", en: "Edit", ja: "編集")
}
static var editAction: String {
pick(ko: "수정하기", en: "Edit", ja: "編集する")
}
static var deleteAction: String {
pick(ko: "삭제하기", en: "Delete", ja: "削除する")
}
static var report: String {
pick(ko: "신고", en: "Report", ja: "通報")
}

View File

@@ -0,0 +1,15 @@
import CoreGraphics
enum ImageCropGeometry {
static func maximumCropSize(fitting imageSize: CGSize, aspectRatio: CGFloat) -> CGSize {
guard imageSize.width > 0, imageSize.height > 0, aspectRatio > 0 else {
return .zero
}
if imageSize.width / imageSize.height > aspectRatio {
return CGSize(width: imageSize.height * aspectRatio, height: imageSize.height)
}
return CGSize(width: imageSize.width, height: imageSize.width / aspectRatio)
}
}

View File

@@ -274,27 +274,35 @@ struct ImageCropEditorView: View {
private func cropSize(in canvas: CGSize) -> CGSize {
switch aspectPolicy {
case .square:
let side = max(120, min(canvas.width, canvas.height) * 0.72)
return CGSize(width: side, height: side)
let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvas)
return ImageCropGeometry.maximumCropSize(fitting: fittedSize, aspectRatio: 1)
case .free:
if freeCropSize == .zero {
return defaultFreeCropSize(in: canvas)
}
let minCropSize: CGFloat = 120
let maxCropWidth = max(minCropSize, canvas.width - 24)
let maxCropHeight = max(minCropSize, canvas.height - 24)
let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvas)
let maxCropWidth = fittedSize.width
let maxCropHeight = fittedSize.height
let minCropWidth = min(120, maxCropWidth)
let minCropHeight = min(120, maxCropHeight)
return CGSize(
width: freeCropSize.width.clamped(min: minCropSize, max: maxCropWidth),
height: freeCropSize.height.clamped(min: minCropSize, max: maxCropHeight)
width: freeCropSize.width.clamped(min: minCropWidth, max: maxCropWidth),
height: freeCropSize.height.clamped(min: minCropHeight, max: maxCropHeight)
)
}
}
private func defaultFreeCropSize(in canvas: CGSize) -> CGSize {
let width = max(120, min(canvas.width * 0.82, canvas.width - 24))
let height = max(120, min(canvas.height * 0.58, canvas.height - 24))
return CGSize(width: width, height: height)
let preferredSize = CGSize(
width: max(120, min(canvas.width * 0.82, canvas.width - 24)),
height: max(120, min(canvas.height * 0.58, canvas.height - 24))
)
let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvas)
return ImageCropGeometry.maximumCropSize(
fitting: fittedSize,
aspectRatio: preferredSize.width / preferredSize.height
)
}
private func handlePosition(handle: CropHandle, cropSize: CGSize, canvasSize: CGSize) -> CGPoint {
@@ -318,14 +326,16 @@ struct ImageCropEditorView: View {
lastFreeCropSize = currentCropSize
}
let minCropSize: CGFloat = 120
let maxCropWidth = max(minCropSize, canvasSize.width - 24)
let maxCropHeight = max(minCropSize, canvasSize.height - 24)
let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvasSize)
let maxCropWidth = fittedSize.width
let maxCropHeight = fittedSize.height
let minCropWidth = min(120, maxCropWidth)
let minCropHeight = min(120, maxCropHeight)
let width = (lastFreeCropSize.width + (translation.width * handle.xDirection))
.clamped(min: minCropSize, max: maxCropWidth)
.clamped(min: minCropWidth, max: maxCropWidth)
let height = (lastFreeCropSize.height + (translation.height * handle.yDirection))
.clamped(min: minCropSize, max: maxCropHeight)
.clamped(min: minCropHeight, max: maxCropHeight)
freeCropSize = CGSize(width: width, height: height)
adjustTransforms(canvasSize: canvasSize, cropSize: freeCropSize)

View File

@@ -60,13 +60,6 @@ struct LiveView: View {
LazyVStack(spacing: 48) {
SectionLiveNowView(
items: viewModel.liveNowItems,
onClickParticipant: {
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
viewModel.enterLiveRoom(roomId: $0)
} else {
AppState.shared.setAppStep(step: .login)
}
},
onTapItem: { item in
onTapLiveNowItem(item.roomId, item.isAdult)
},

View File

@@ -144,7 +144,6 @@ final class LiveViewModel: ObservableObject {
} else {
if roomId > 0 {
AppState.shared.isShowPlayer = true
AppState.shared.setAppStep(step: .main)
}
}
} else {

View File

@@ -14,117 +14,92 @@ struct LiveNowAllItemView: View {
let itemWidth: CGFloat
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ZStack {
KFImage(URL(string: item.coverImageUrl))
.cancelOnDisappear(true)
.downsampling(
size: CGSize(
width: itemWidth,
height: itemWidth * 144 / 102
)
)
.resizable()
.scaledToFill()
.frame(width: itemWidth, height: itemWidth * 144 / 102, alignment: .center)
.cornerRadius(4.7)
.clipped()
LinearGradient(
colors: [Color.black.opacity(0.1), Color.black.opacity(0.8)],
startPoint: .top,
endPoint: .bottom
)
VStack(alignment: .trailing, spacing: 0) {
HStack(spacing: 0) {
HStack(spacing: 1) {
if item.price > 0 {
Image("ic_can_white")
}
Text(item.price > 0 ? "\(item.price)" : I18n.CreateContent.free)
.appFont(size: 12, weight: .medium)
.foregroundColor(Color.white)
}
.padding(.horizontal, 7.3)
.padding(.vertical, 4)
.background(item.price > 0 ? Color.mainRed3 : Color.gray11)
.cornerRadius(13.3)
Spacer()
if item.isPrivateRoom {
Image("ic_lock")
.resizable()
.frame(width: 20, height: 20)
.padding(2.7)
.background(Color.gray33.opacity(0.7))
.clipShape(Circle())
}
}
.padding(.horizontal, 3.3)
.padding(.top, 3.3)
Spacer()
if item.numberOfPeople - item.numberOfParticipate < 3 {
HStack(spacing: 0) {
Spacer()
HStack(spacing: 2) {
Text(I18n.LiveNow.remaining)
.appFont(size: 12, weight: .medium)
.foregroundColor(Color.grayee)
Text("\(item.numberOfPeople - item.numberOfParticipate)")
.appFont(size: 12, weight: .medium)
.foregroundColor(Color.button)
}
.padding(.horizontal, 4)
.padding(.vertical, 3)
.background(Color.gray33.opacity(0.7))
.cornerRadius(13.3)
}
.padding(.horizontal, 3.3)
.padding(.bottom, 3.3)
}
HStack(alignment: .center, spacing: 14) {
KFImage(URL(string: item.creatorProfileImage))
.cancelOnDisappear(true)
.downsampling(size: CGSize(width: 75, height: 75))
.resizable()
.scaledToFill()
.frame(width: 75, height: 75)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
liveTag
Text(startTimeText)
.appFont(size: 14, weight: .regular)
.foregroundColor(Color.gray500)
}
Text(item.title)
.appFont(size: 18, weight: .bold)
.foregroundColor(.white)
.lineLimit(1)
.truncationMode(.tail)
Text(item.creatorNickname)
.appFont(size: 14, weight: .regular)
.foregroundColor(Color.gray500)
.lineLimit(1)
.truncationMode(.tail)
}
.frame(width: itemWidth, height: itemWidth * 144 / 102)
Text("\(item.title)")
.appFont(size: 12, weight: .medium)
.foregroundColor(Color(hex: "eeeeee"))
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
if item.tags.count > 0 {
Text("\(item.tags.map { "#\($0)" }.joined(separator: " "))")
.appFont(size: 11, weight: .medium)
.foregroundColor(Color.button)
}
HStack(spacing: 5.3) {
KFImage(URL(string: item.creatorProfileImage))
.cancelOnDisappear(true)
.downsampling(
size: CGSize(
width: 21.3,
height: 21.3
)
)
.resizable()
.scaledToFill()
.frame(width: 21.3, height: 21.3, alignment: .center)
.clipShape(Circle())
Text("\(item.creatorNickname)")
.appFont(size: 10, weight: .medium)
.foregroundColor(Color.gray77)
}
.frame(maxWidth: .infinity, alignment: .leading)
priceView
}
.frame(width: itemWidth)
.frame(width: itemWidth, height: 99)
}
private var liveTag: some View {
HStack(spacing: 4) {
Circle()
.fill(Color.red400)
.frame(width: 8, height: 8)
Text("LIVE")
.appFont(size: 12, weight: .regular)
.foregroundColor(.white)
}
.padding(.horizontal, 4)
.padding(.vertical, 2)
.frame(height: 18)
.background(Color.black)
.clipShape(Capsule())
}
@ViewBuilder
private var priceView: some View {
if item.price > 0 {
HStack(spacing: 2) {
Image("ic_bar_cash")
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
Text("\(item.price)")
.appFont(size: 14, weight: .regular)
.foregroundColor(.white)
.lineLimit(1)
}
.padding(.trailing, 7)
.frame(width: 60, alignment: .trailing)
} else {
Text(I18n.CreateContent.free)
.appFont(size: 14, weight: .regular)
.foregroundColor(.white)
.lineLimit(1)
.padding(.trailing, 7)
.frame(width: 60, alignment: .trailing)
}
}
private var startTimeText: String {
guard let date = DateParser.parse(item.beginDateTimeUtc) else {
return item.beginDateTimeUtc
}
return date.convertDateFormat(dateFormat: "HH:mm")
}
}

View File

@@ -13,7 +13,6 @@ import BootpayUI
struct LiveNowAllView: View {
@StateObject var viewModel = LiveViewModel()
@StateObject var liveAllViewModel = LiveAllViewModel()
@StateObject var mypageViewModel = MyPageViewModel()
let spacing: CGFloat = 16
@@ -25,15 +24,6 @@ struct LiveNowAllView: View {
@State private var isShowAuthConfirmView = false
@State private var pendingAction: (() -> Void)? = nil
@State private var payload = Payload()
var columns: [GridItem] {
[
GridItem(.flexible(), spacing: spacing, alignment: .top),
GridItem(.flexible(), spacing: spacing, alignment: .top)
]
}
let onClickParticipant: (Int) -> Void
var body: some View {
BaseView(isLoading: $viewModel.isLoading) {
@@ -50,13 +40,13 @@ struct LiveNowAllView: View {
viewModel.getLiveNowList()
},
content: {
let itemWidth = (proxy.size.width - (spacing * 3)) / 2
let itemWidth = proxy.size.width - (spacing * 2)
LazyVGrid(columns: columns, spacing: spacing) {
LazyVStack(spacing: spacing) {
ForEach(0..<viewModel.liveNowItems.count, id: \.self) { index in
let item = viewModel.liveNowItems[index]
LiveNowItemView(item: item, itemWidth: itemWidth)
LiveNowAllItemView(item: item, itemWidth: itemWidth)
.contentShape(Rectangle())
.onTapGesture {
handleLiveNowItemTap(
@@ -85,24 +75,6 @@ struct LiveNowAllView: View {
.edgesIgnoringSafeArea(.bottom)
}
if liveAllViewModel.isShowLiveDetail {
LiveDetailView(
roomId: liveAllViewModel.selectedRoomId,
onClickParticipant: {
AppState.shared.isShowPlayer = false
onClickParticipant(liveAllViewModel.selectedRoomId)
},
onClickReservation: {},
onClickStart: {},
onClickCancel: {},
onClickClose: {
withAnimation {
liveAllViewModel.isShowLiveDetail = false
}
}
)
}
if isShowAuthConfirmView {
SodaDialog(
title: I18n.Main.Auth.dialogTitle,
@@ -120,6 +92,28 @@ struct LiveNowAllView: View {
textAlignment: .center
)
}
if viewModel.isShowPaymentDialog {
LivePaymentDialog(
title: viewModel.paymentDialogTitle,
desc: viewModel.paymentDialogDesc,
desc2: viewModel.paymentDialogDesc2,
confirmButtonTitle: viewModel.paymentDialogConfirmTitle,
confirmButtonAction: viewModel.paymentDialogConfirmAction,
cancelButtonTitle: viewModel.paymentDialogCancelTitle,
cancelButtonAction: viewModel.hidePopup,
startDateTime: viewModel.liveStartDate,
nowDateTime: viewModel.nowDate
)
}
if viewModel.isShowPasswordDialog {
LiveRoomPasswordDialog(
isShowing: $viewModel.isShowPasswordDialog,
can: viewModel.secretOrPasswordDialogCan,
confirmAction: viewModel.passwordDialogConfirmAction
)
}
}
}
.onAppear {
@@ -160,6 +154,7 @@ struct LiveNowAllView: View {
isShowAuthView = false
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
private func handleLiveNowItemTap(roomId: Int, isAdult: Bool) {
@@ -177,7 +172,7 @@ struct LiveNowAllView: View {
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
if isKoreanCountry && auth == false {
pendingAction = { openLiveDetail(roomId: roomId) }
pendingAction = { handleLiveNowItemTap(roomId: roomId, isAdult: isAdult) }
isShowAuthConfirmView = true
return
}
@@ -191,13 +186,11 @@ struct LiveNowAllView: View {
}
}
openLiveDetail(roomId: roomId)
enterLiveRoom(roomId: roomId)
}
private func openLiveDetail(roomId: Int) {
liveAllViewModel.selectedRoomId = roomId
withAnimation {
liveAllViewModel.isShowLiveDetail = true
}
private func enterLiveRoom(roomId: Int) {
AppState.shared.isShowPlayer = false
viewModel.enterLiveRoom(roomId: roomId)
}
}

View File

@@ -11,7 +11,6 @@ struct SectionLiveNowView: View {
let items: [GetRoomListResponse]
let onClickParticipant: (Int) -> Void
let onTapItem: (GetRoomListResponse) -> Void
let onTapCreateLive: () -> Void
let onClickRefresh: () -> Void
@@ -29,7 +28,7 @@ struct SectionLiveNowView: View {
Text(I18n.Common.viewAll)
.appFont(size: 14, weight: .regular)
.foregroundColor(Color(hex: "78909C"))
.onTapGesture { AppState.shared.setAppStep(step: .liveNowAll(onClickParticipant: onClickParticipant)) }
.onTapGesture { AppState.shared.setAppStep(step: .liveNowAll) }
}
}
.padding(.horizontal, 24)
@@ -95,7 +94,6 @@ struct SectionLiveNowView_Previews: PreviewProvider {
static var previews: some View {
SectionLiveNowView(
items: [],
onClickParticipant: { _ in },
onTapItem: { _ in },
onTapCreateLive: {},
onClickRefresh: {}

View File

@@ -272,6 +272,10 @@ struct LiveDetailView: View {
.onAppear {
viewModel.getDetail(roomId: roomId)
}
.onChange(of: viewModel.isShowPopup) { isShowing in
guard isShowing, viewModel.room == nil else { return }
handleDetailLoadFailure()
}
}
@ViewBuilder
@@ -473,6 +477,18 @@ struct LiveDetailView: View {
}
}
private func handleDetailLoadFailure() {
let message = viewModel.errorMessage
viewModel.isShowPopup = false
AppState.shared.isShowErrorPopup = false
hideView()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
AppState.shared.errorMessage = message
AppState.shared.isShowErrorPopup = true
}
}
private func makeSnsItems(from manager: GetRoomDetailManager) -> [LiveDetailSnsItem] {
var items = [LiveDetailSnsItem]()

View File

@@ -462,7 +462,6 @@ struct LiveRoomViewV2: View {
}
)
.padding(.top, isV2VCaptionVisible ? -13.3 : 0)
.padding(.bottom, 10)
}
if viewModel.isShowingNewChat {
@@ -1004,7 +1003,6 @@ struct LiveRoomViewV2: View {
viewModel.setMute(true)
shouldRestoreMicMuteAfterCapture = true
}
.ignoresSafeArea(.keyboard)
.edgesIgnoringSafeArea(appliedKeyboardHeight > 0 ? .bottom : .init())
.sheet(
isPresented: $viewModel.isShowShareView,
@@ -1113,6 +1111,8 @@ struct LiveRoomViewV2: View {
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea()
}
private func estimatedHeight(for text: String, width: CGFloat) -> CGFloat {
@@ -1198,6 +1198,7 @@ private final class ScreenCaptureSecureHostingController<Content: View>: UIViewC
hostingController = UIHostingController(rootView: rootView)
self.isSecureModeEnabled = isSecureModeEnabled
super.init(nibName: nil, bundle: nil)
hostingController.safeAreaRegions = .container
}
required init?(coder: NSCoder) {

View File

@@ -214,6 +214,7 @@ struct SettingsView: View {
ContentPlayManager.shared.stopAudio()
ContentPlayerPlayManager.shared.resetPlayer()
viewModel.logout {
NotificationCenter.default.post(name: .userCreatorChatSessionClose, object: nil)
self.isShowLogoutDialog = false
UserDefaults.reset()
@@ -239,6 +240,7 @@ struct SettingsView: View {
ContentPlayManager.shared.stopAudio()
ContentPlayerPlayManager.shared.resetPlayer()
viewModel.logoutAllDevice {
NotificationCenter.default.post(name: .userCreatorChatSessionClose, object: nil)
self.isShowLogoutAllDeviceDialog = false
UserDefaults.reset()
AppState.shared.isRestartApp = true

View File

@@ -44,6 +44,7 @@ final class SignOutViewModel: ObservableObject {
let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData)
if decoded.success {
NotificationCenter.default.post(name: .userCreatorChatSessionClose, object: nil)
UserDefaults.reset()
AppState.shared.isRestartApp = true
AppState.shared.setAppStep(step: .splash)

View File

@@ -17,10 +17,8 @@ struct SplashView: View {
var body: some View {
ZStack(alignment: .top) {
Image("splash_bg")
.resizable()
.scaledToFill()
.edgesIgnoringSafeArea(.all)
Color.black
.ignoresSafeArea()
VStack(spacing: 0) {
Spacer()
@@ -31,13 +29,9 @@ struct SplashView: View {
Image("splash_text_logo")
.padding(.top, 10)
Text(I18n.Splash.brandName)
.appFont(size: 21, weight: .bold)
.foregroundColor(.white)
.padding(.top, 52)
Spacer()
}
.padding(.bottom, screenSize().height * 100 / 2337)
if isShowMaintenancePopup {
SodaDialog(
@@ -86,6 +80,8 @@ struct SplashView: View {
}
private func fetchLastestVersion() {
guard !isRunningForPreviews else { return }
let remoteConfig = RemoteConfig.remoteConfig()
let configSettings = RemoteConfigSettings()
configSettings.minimumFetchInterval = 60
@@ -120,7 +116,10 @@ struct SplashView: View {
if !UserDefaults.string(forKey: UserDefaultsKey.token).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if AppState.shared.pushRoomId > 0 {
liveViewModel.enterLiveRoom(roomId: AppState.shared.pushRoomId)
let roomId = AppState.shared.pushRoomId
AppState.shared.pushRoomId = 0
AppState.shared.setAppStep(step: .main)
liveViewModel.enterLiveRoom(roomId: roomId)
} else if AppState.shared.pushChannelId > 0 {
AppState.shared.setAppStep(step: .creatorDetail(userId: AppState.shared.pushChannelId))
} else if AppState.shared.pushAudioContentId > 0 {
@@ -163,6 +162,10 @@ struct SplashView: View {
nextAppStep()
}
}
private var isRunningForPreviews: Bool {
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
}
}
#Preview {

View File

@@ -0,0 +1,364 @@
import SwiftUI
struct AudioContentListRowItem: Identifiable {
let id: Int
let title: String
let subtitle: String?
let imageUrl: String?
let price: Int
let isAdult: Bool
let isPointAvailable: Bool
let isFirstContent: Bool
let isOriginalSeries: Bool
let isOwned: Bool
let isRented: Bool
}
extension AudioContentListRowItem {
init(audioContent: CreatorChannelAudioContentResponse, subtitle: String?) {
self.init(
id: audioContent.audioContentId,
title: audioContent.title,
subtitle: subtitle,
imageUrl: audioContent.imageUrl,
price: audioContent.price,
isAdult: audioContent.isAdult,
isPointAvailable: audioContent.isPointAvailable,
isFirstContent: audioContent.isFirstContent,
isOriginalSeries: audioContent.isOriginalSeries == true,
isOwned: audioContent.isOwned,
isRented: audioContent.isRented
)
}
}
enum AudioContentListRowStyle {
case compact(width: CGFloat?, showsAdultTag: Bool = false)
case purchaseState
static func compactWidth(screenWidth: CGFloat = UIScreen.main.bounds.width) -> CGFloat {
let baselineScreenWidth: CGFloat = 402
let baselineRowWidth: CGFloat = 346
return baselineRowWidth * min(screenWidth, baselineScreenWidth) / baselineScreenWidth
}
}
struct AudioContentListRow: View {
let item: AudioContentListRowItem
let style: AudioContentListRowStyle
let action: () -> Void
init(
item: AudioContentListRowItem,
style: AudioContentListRowStyle,
action: @escaping () -> Void = {}
) {
self.item = item
self.style = style
self.action = action
}
var body: some View {
Button {
action()
} label: {
HStack(alignment: .center, spacing: SodaSpacing.s14) {
thumbnail
titleArea
.frame(maxWidth: .infinity, alignment: .leading)
if showsTrailingState {
trailingState
}
}
.padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
.frame(width: rowWidth, alignment: .leading)
.frame(maxWidth: maxRowWidth, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private var titleArea: some View {
VStack(alignment: .leading, spacing: titleSpacing) {
Text(item.title)
.appFont(size: 16, weight: .bold)
.foregroundColor(.white)
.lineLimit(titleLineLimit)
.truncationMode(.tail)
if let subtitle = item.subtitle, !subtitle.isEmpty {
Text(subtitle)
.appFont(size: 14, weight: .medium)
.foregroundColor(Color.gray500)
.lineLimit(1)
.truncationMode(.tail)
}
}
}
private var thumbnail: some View {
ZStack(alignment: .topLeading) {
DownsampledKFImage(
url: item.imageUrl.flatMap(URL.init(string:)),
size: CGSize(width: 88, height: 88)
)
.background(Color.gray800)
VStack(alignment: .leading, spacing: 0) {
topTagList
Spacer(minLength: 0)
bottomTagList
}
}
.frame(width: 88, height: 88)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
@ViewBuilder
private var topTagList: some View {
HStack(spacing: 0) {
if item.isOriginalSeries {
CreatorChannelAudioImageTag(imageName: "ic_content_tag_original")
}
if item.isFirstContent {
CreatorChannelAudioFirstTag()
}
if showsAdultTag {
Spacer()
if item.isAdult {
CreatorChannelAudioAdultTag()
.padding(.top, 6)
.padding(.trailing, 6)
}
}
}
}
@ViewBuilder
private var bottomTagList: some View {
HStack(spacing: 0) {
if item.isPointAvailable {
CreatorChannelAudioImageTag(imageName: "ic_content_tag_point")
}
if item.price == 0 {
CreatorChannelAudioFreeTag()
}
}
}
@ViewBuilder
private var trailingState: some View {
if item.isOwned {
playbackState(I18n.Content.Status.owned)
} else if item.isRented {
playbackState(I18n.Content.Status.rented)
} else if item.price == 0 {
playbackState("")
} else {
HStack(spacing: 2) {
Image("ic_bar_cash")
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
Text(item.price.comma())
.appFont(size: 14, weight: .regular)
.foregroundColor(.white)
.lineLimit(1)
}
}
}
private func playbackState(_ title: String) -> some View {
VStack(spacing: SodaSpacing.s4) {
ZStack {
Color.white
Image("ic_new_player_play")
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
}
.frame(width: 28, height: 28)
.clipShape(Circle())
if !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Text(title)
.appFont(size: 12, weight: .medium)
.foregroundColor(Color.gray400)
.lineLimit(1)
}
}
}
private var rowWidth: CGFloat? {
if case let .compact(width, _) = style {
return width
}
return nil
}
private var maxRowWidth: CGFloat? {
switch style {
case .compact:
return nil
case .purchaseState:
return .infinity
}
}
private var horizontalPadding: CGFloat {
switch style {
case .compact:
return 0
case .purchaseState:
return SodaSpacing.s20
}
}
private var verticalPadding: CGFloat {
switch style {
case .compact:
return 0
case .purchaseState:
return SodaSpacing.s8
}
}
private var titleSpacing: CGFloat {
switch style {
case .compact:
return 2
case .purchaseState:
return SodaSpacing.s6
}
}
private var titleLineLimit: Int {
switch style {
case .compact:
return 1
case .purchaseState:
return 2
}
}
private var showsTrailingState: Bool {
if case .purchaseState = style {
return true
}
return false
}
private var showsAdultTag: Bool {
switch style {
case let .compact(_, showsAdultTag):
return showsAdultTag
case .purchaseState:
return true
}
}
}
struct CreatorChannelAudioImageTag: View {
let imageName: String
var body: some View {
Image(imageName)
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
}
}
struct CreatorChannelAudioFirstTag: View {
var body: some View {
ZStack {
Color(hex: "FF34B8")
Image("ic_content_tag_first_star")
.resizable()
.scaledToFit()
.padding(SodaSpacing.s4)
}
.frame(width: 24, height: 24)
}
}
struct CreatorChannelAudioAdultTag: View {
var body: some View {
ZStack {
Color(hex: "FF4C3C")
Image("ic_new_shield_small")
.resizable()
.scaledToFit()
.padding(2)
}
.frame(width: 18, height: 18)
.cornerRadius(4)
}
}
struct CreatorChannelAudioFreeTag: View {
var body: some View {
Text("무료")
.appFont(size: 14, weight: .semibold)
.foregroundColor(.white)
.lineLimit(1)
.padding(SodaSpacing.s4)
.frame(height: 24)
.background(Color.soda900)
}
}
struct AudioContentListRow_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: SodaSpacing.s8) {
AudioContentListRow(
item: AudioContentListRowItem(
id: 1,
title: "콘텐츠 이름이 한 줄 콘텐츠 이름이 한 줄 콘텐츠 이름이 한 줄",
subtitle: "3:00 • 시리즈 이름",
imageUrl: "https://picsum.photos/300/300",
price: 0,
isAdult: false,
isPointAvailable: true,
isFirstContent: true,
isOriginalSeries: true,
isOwned: false,
isRented: false
),
style: .compact(width: 346)
)
AudioContentListRow(
item: AudioContentListRowItem(
id: 2,
title: "라이브 다시듣기 제목이 두 줄까지 표시됩니다",
subtitle: "1:43:25",
imageUrl: "https://picsum.photos/300/300",
price: 3000,
isAdult: true,
isPointAvailable: true,
isFirstContent: true,
isOriginalSeries: true,
isOwned: false,
isRented: false
),
style: .purchaseState
)
}
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,259 @@
import SwiftUI
struct AudioContentThumbnailCardItem: Identifiable {
let id: Int
let title: String
let subtitle: String
let imageUrl: String?
let price: Int
let isAdult: Bool
let isPointAvailable: Bool
let isFirstContent: Bool
let isOriginalSeries: Bool
}
struct AudioContentThumbnailCard: View {
let item: AudioContentThumbnailCardItem
let width: CGFloat
let titleTypography: SodaTypography
let subtitleTypography: SodaTypography
let labelHorizontalPadding: CGFloat
init(
item: AudioContentThumbnailCardItem,
size: AudioContentCardSize
) {
self.item = item
self.width = size.width
self.titleTypography = size.titleTypography
self.subtitleTypography = size.subtitleTypography
self.labelHorizontalPadding = size.labelHorizontalPadding
}
init(
item: AudioContentThumbnailCardItem,
width: CGFloat,
titleTypography: SodaTypography = .heading4,
subtitleTypography: SodaTypography = .body5,
labelHorizontalPadding: CGFloat = 0
) {
self.item = item
self.width = width
self.titleTypography = titleTypography
self.subtitleTypography = subtitleTypography
self.labelHorizontalPadding = labelHorizontalPadding
}
var body: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s8) {
thumbnail
labelContent
}
.frame(width: width, alignment: .leading)
}
private var thumbnail: some View {
ZStack(alignment: .topLeading) {
DownsampledKFImage(
url: item.imageUrl.flatMap(URL.init(string:)),
size: CGSize(width: width, height: width)
)
.background(Color.gray800)
VStack(alignment: .leading, spacing: 0) {
topTagRow
Spacer(minLength: 0)
bottomTagRow
}
}
.frame(width: width, height: width)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
private var labelContent: some View {
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.appFont(titleTypography)
.foregroundColor(.white)
.lineLimit(1)
.truncationMode(.tail)
Text(item.subtitle)
.appFont(subtitleTypography)
.foregroundColor(Color.gray500)
.lineLimit(1)
.truncationMode(.tail)
}
.frame(width: width - (labelHorizontalPadding * 2), alignment: .leading)
.padding(.horizontal, labelHorizontalPadding)
.frame(width: width, alignment: .leading)
}
@ViewBuilder
private var topTagRow: some View {
HStack(spacing: 0) {
if item.isOriginalSeries {
originalTag
}
if item.isFirstContent {
firstTag
}
Spacer(minLength: 0)
if item.isAdult {
adultTag
.padding(.top, SodaSpacing.s6)
.padding(.trailing, SodaSpacing.s6)
}
}
}
@ViewBuilder
private var bottomTagRow: some View {
HStack(spacing: 0) {
if item.isPointAvailable {
pointTag
}
if item.price == 0 {
freeTag
}
}
}
private var originalTag: some View {
Image("ic_content_tag_original")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
}
private var firstTag: some View {
ZStack {
Image("ic_content_tag_first_star")
.resizable()
.scaledToFit()
.frame(width: 17, height: 17)
}
.padding(4)
.background(Color(hex: "FF34B8"))
}
private var adultTag: some View {
ZStack {
Color(hex: "FF4C3C")
Image("ic_new_shield_small")
.resizable()
.scaledToFit()
.padding(2)
}
.frame(width: 18, height: 18)
.cornerRadius(4)
}
private var pointTag: some View {
Image("ic_content_tag_point")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
}
private var freeTag: some View {
Text("무료")
.appFont(size: 14, weight: .semibold)
.foregroundColor(.white)
.lineLimit(1)
.padding(.horizontal, SodaSpacing.s4)
.frame(height: 24)
.background(Color.soda900)
}
}
extension AudioContentThumbnailCardItem {
init(audioCard: AudioCardResponse) {
self.init(
id: audioCard.audioContentId,
title: audioCard.title,
subtitle: audioCard.creatorNickname,
imageUrl: audioCard.imageUrl,
price: audioCard.price,
isAdult: audioCard.isAdult,
isPointAvailable: audioCard.isPointAvailable,
isFirstContent: audioCard.isFirstContent,
isOriginalSeries: audioCard.isOriginalSeries
)
}
init(contentOverviewItem: ContentOverviewItemResponse) {
self.init(
id: contentOverviewItem.contentId,
title: contentOverviewItem.title,
subtitle: contentOverviewItem.creatorNickname,
imageUrl: contentOverviewItem.coverImage,
price: contentOverviewItem.price,
isAdult: contentOverviewItem.isAdult,
isPointAvailable: contentOverviewItem.isPointAvailable,
isFirstContent: contentOverviewItem.isFirstContent,
isOriginalSeries: contentOverviewItem.isOriginalSeries
)
}
init(mainContentAudio: MainContentAudioResponse) {
self.init(
id: mainContentAudio.audioContentId,
title: mainContentAudio.title,
subtitle: mainContentAudio.creatorNickname,
imageUrl: mainContentAudio.imageUrl,
price: mainContentAudio.price,
isAdult: mainContentAudio.isAdult,
isPointAvailable: mainContentAudio.isPointAvailable,
isFirstContent: mainContentAudio.isFirstContent,
isOriginalSeries: mainContentAudio.isOriginalSeries
)
}
}
struct AudioContentThumbnailCard_Previews: PreviewProvider {
static var previews: some View {
HStack(alignment: .top, spacing: SodaSpacing.s14) {
AudioContentThumbnailCard(
item: AudioContentThumbnailCardItem(
id: 1,
title: "오디오 콘텐츠 제목입니다",
subtitle: "크리에이터명",
imageUrl: "https://picsum.photos/300/300",
price: 0,
isAdult: true,
isPointAvailable: true,
isFirstContent: true,
isOriginalSeries: true
),
size: .medium
)
AudioContentThumbnailCard(
item: AudioContentThumbnailCardItem(
id: 2,
title: "추천 오디오",
subtitle: "크리에이터명",
imageUrl: "https://picsum.photos/300/300",
price: 10,
isAdult: false,
isPointAvailable: false,
isFirstContent: true,
isOriginalSeries: true
),
width: 170
)
}
.padding(SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -22,10 +22,10 @@ struct BannerCarousel: View {
@GestureState private var dragOffset: CGFloat = 0
@State private var autoScrollTimer: Timer?
@State private var isSwiping = false
@State private var itemsRevision = 0
init(
items: [BannerCarouselItem],
height: CGFloat = 120,
action: @escaping (BannerCarouselItem) -> Void = { _ in }
) {
self.items = Array(items.prefix(BannerCarousel.maxItemCount))
@@ -71,6 +71,7 @@ struct BannerCarousel: View {
.contentShape(Rectangle())
.simultaneousGesture(dragGesture(step: step))
.onChange(of: items) { _ in
itemsRevision += 1
displayIndex = items.count > 1 ? 1 : 0
restartAutoScrollTimer()
}
@@ -180,8 +181,15 @@ struct BannerCarousel: View {
private func wrapIfNeeded() {
guard items.count > 1 else { return }
displayIndex = min(max(displayIndex, 0), items.count + 1)
let scheduledIndex = displayIndex
let scheduledRevision = itemsRevision
if displayIndex == 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + BannerCarousel.transitionDuration) {
guard displayIndex == scheduledIndex, itemsRevision == scheduledRevision else { return }
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
@@ -190,6 +198,8 @@ struct BannerCarousel: View {
}
} else if displayIndex == items.count + 1 {
DispatchQueue.main.asyncAfter(deadline: .now() + BannerCarousel.transitionDuration) {
guard displayIndex == scheduledIndex, itemsRevision == scheduledRevision else { return }
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
@@ -200,6 +210,49 @@ struct BannerCarousel: View {
}
}
struct BannerCarouselSection<Item>: View {
let items: [Item]
let id: (Item, Int) -> String
let imageUrl: (Item) -> String?
let action: (Item) -> Void
init(
items: [Item],
id: @escaping (Item, Int) -> String,
imageUrl: @escaping (Item) -> String?,
action: @escaping (Item) -> Void
) {
self.items = items
self.id = id
self.imageUrl = imageUrl
self.action = action
}
var body: some View {
if !items.isEmpty {
BannerCarousel(items: carouselItems) { carouselItem in
guard let item = item(for: carouselItem) else { return }
action(item)
}
}
}
private var carouselItems: [BannerCarouselItem] {
items.enumerated().map { index, item in
BannerCarouselItem(
id: id(item, index),
imageUrl: imageUrl(item)
)
}
}
private func item(for carouselItem: BannerCarouselItem) -> Item? {
items.enumerated().first { index, item in
id(item, index) == carouselItem.id
}?.element
}
}
struct BannerCarousel_Previews: PreviewProvider {
static var previews: some View {
BannerCarousel(

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