Compare commits

98 Commits

Author SHA1 Message Date
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
283 changed files with 28988 additions and 146 deletions

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

@@ -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

@@ -62,7 +62,7 @@ enum AppStep {
case serviceCenter
case createContent
case createContent(onSuccess: (() -> Void)? = nil)
case modifyContent(contentId: Int)
@@ -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)
@@ -167,6 +186,8 @@ enum AppStep {
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

@@ -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

@@ -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

@@ -162,14 +162,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)
@@ -254,6 +254,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)
@@ -317,6 +330,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

@@ -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

@@ -2952,6 +2952,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 +3005,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 +3036,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 +3222,281 @@ If you block this user, the following features will be restricted.
}
}
enum HomeFollowing {
static var emptyStateMessage: String {
pick(
ko: "팔로잉 소식이 아직 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.",
en: "No following updates yet.\nFollow creators you care about.",
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 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: "ライブ")
@@ -3204,6 +3659,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

@@ -86,6 +86,8 @@ struct SplashView: View {
}
private func fetchLastestVersion() {
guard !isRunningForPreviews else { return }
let remoteConfig = RemoteConfig.remoteConfig()
let configSettings = RemoteConfigSettings()
configSettings.minimumFetchInterval = 60
@@ -163,6 +165,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

@@ -25,7 +25,6 @@ struct BannerCarousel: View {
init(
items: [BannerCarouselItem],
height: CGFloat = 120,
action: @escaping (BannerCarouselItem) -> Void = { _ in }
) {
self.items = Array(items.prefix(BannerCarousel.maxItemCount))
@@ -200,6 +199,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(

View File

@@ -1,4 +1,5 @@
import SwiftUI
import Kingfisher
struct CommunityPostCard: View {
let postId: Int
@@ -67,8 +68,11 @@ struct CommunityPostCard: View {
imageContent
}
reactionBar
if isAccessiblePost {
reactionBar
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(SodaSpacing.s14)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
@@ -110,28 +114,26 @@ struct CommunityPostCard: View {
.lineLimit(5)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
private var imageContent: some View {
GeometryReader { proxy in
ZStack {
DownsampledKFImage(
url: URL(string: imageUrl ?? ""),
size: CGSize(width: proxy.size.width, height: proxy.size.height)
)
ZStack {
KFImage(URL(string: imageUrl ?? ""))
.cancelOnDisappear(true)
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity)
.background(Color.gray800)
.frame(width: proxy.size.width, height: proxy.size.height)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
.blur(radius: isLocked ? 12 : 0)
if isLocked {
paidLockOverlay
} else if hasAudio {
audioPlayButton
}
if isLocked {
paidLockOverlay
} else if hasAudio {
audioPlayButton
}
}
.aspectRatio(346.0 / 236.0, contentMode: .fit)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
@@ -148,7 +150,7 @@ struct CommunityPostCard: View {
.frame(width: 24, height: 24)
HStack(spacing: SodaSpacing.s6) {
Image("ic_can")
Image("ic_bar_cash")
.resizable()
.frame(width: 20, height: 20)
@@ -224,6 +226,10 @@ struct CommunityPostCard: View {
price > 0 && !existOrdered
}
private var isAccessiblePost: Bool {
price == 0 || existOrdered
}
private func toggleLike() {
localIsLike.toggle()
localLikeCount += localIsLike ? 1 : -1
@@ -236,39 +242,56 @@ struct CommunityPostCard_Previews: PreviewProvider {
private static let previewImageUrl = "https://picsum.photos/500/500"
static var previews: some View {
VStack(spacing: SodaSpacing.s8) {
CommunityPostCard(
postId: 1,
creatorNickname: "크리에이터 이름",
creatorProfileImageUrl: previewImageUrl,
content: "크리에이터가 커뮤니티에 올린 글이 보이는 부분 크리에이터가 커뮤니티에 올린 글이 보이는 부분",
imageUrl: nil,
audioUrl: nil,
price: 0,
existOrdered: false,
createdAt: "2분 전",
isLike: false,
likeCount: 5,
commentCount: 6
)
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: SodaSpacing.s8) {
CommunityPostCard(
postId: 1,
creatorNickname: "크리에이터 이름",
creatorProfileImageUrl: previewImageUrl,
content: "크리에이터가 커뮤니티에 올린 글이 보이는 부분 크리에이터가 커뮤니티에 올린 글이 보이는 부분",
imageUrl: nil,
audioUrl: nil,
price: 0,
existOrdered: false,
createdAt: "2분 전",
isLike: false,
likeCount: 5,
commentCount: 6
)
CommunityPostCard(
postId: 2,
creatorNickname: "크리에이터 이름",
creatorProfileImageUrl: previewImageUrl,
content: "이미지가 있는 커뮤니티 게시글입니다.",
imageUrl: previewImageUrl,
audioUrl: "https://example.com/audio.mp3",
price: 30,
existOrdered: false,
createdAt: "2분 전",
isLike: true,
likeCount: 5,
commentCount: 6
)
CommunityPostCard(
postId: 2,
creatorNickname: "크리에이터 이름",
creatorProfileImageUrl: previewImageUrl,
content: "이미지가 있는 커뮤니티 게시글입니다.",
imageUrl: previewImageUrl,
audioUrl: "https://example.com/audio.mp3",
price: 30,
existOrdered: false,
createdAt: "2분 전",
isLike: true,
likeCount: 5,
commentCount: 6
)
CommunityPostCard(
postId: 3,
creatorNickname: "크리에이터 이름",
creatorProfileImageUrl: previewImageUrl,
content: "이미지가 있는 커뮤니티 게시글입니다.",
imageUrl: previewImageUrl,
audioUrl: "https://example.com/audio.mp3",
price: 0,
existOrdered: false,
createdAt: "2분 전",
isLike: true,
likeCount: 5,
commentCount: 6
)
}
.padding(SodaSpacing.s14)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
.padding(SodaSpacing.s14)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,90 @@
import SwiftUI
struct SodaV2ActionModalButton {
let label: String?
let action: (() -> Void)?
}
struct SodaV2ActionModal: View {
let title: String
let message: String
let button1: SodaV2ActionModalButton?
let button2: SodaV2ActionModalButton?
let onDimmedTap: () -> Void
private var visibleButtons: [(label: String, action: () -> Void, isPrimary: Bool)] {
var buttons = [(label: String, action: () -> Void, isPrimary: Bool)]()
if let button2,
let label = button2.label,
let action = button2.action {
buttons.append((label, action, false))
}
if let button1,
let label = button1.label,
let action = button1.action {
buttons.append((label, action, true))
}
return buttons
}
var body: some View {
ZStack {
Color.black.opacity(0.6)
.ignoresSafeArea()
.onTapGesture(perform: onDimmedTap)
VStack(spacing: 0) {
Text(title)
.appFont(size: 20, weight: .bold)
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.frame(width: 340, height: 64)
Text(message)
.appFont(size: 16, weight: .regular)
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.padding(20)
.frame(width: 340)
buttonArea
.padding(.horizontal, 20)
.padding(.vertical, 14)
.frame(width: 340)
}
.background(Color.gray900)
.cornerRadius(14)
}
}
@ViewBuilder
private var buttonArea: some View {
let buttons = visibleButtons
if buttons.count == 1, let button = buttons.first {
actionButton(title: button.label, isPrimary: true, action: button.action)
} else if buttons.count == 2 {
HStack(spacing: 8) {
ForEach(Array(buttons.enumerated()), id: \.offset) { _, button in
actionButton(title: button.label, isPrimary: button.isPrimary, action: button.action)
}
}
}
}
private func actionButton(title: String, isPrimary: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(title)
.appFont(size: 18, weight: .medium)
.foregroundColor(isPrimary ? Color.soda400 : Color.white)
.frame(maxWidth: .infinity)
.padding(14)
.background(Color.clear)
.cornerRadius(100)
}
.buttonStyle(.plain)
}
}

View File

@@ -0,0 +1,76 @@
import Foundation
import SwiftUI
struct CreatorChannelAudioOwnershipRateView: View {
let purchasedAudioContentRate: Double
let purchasedAudioContentCount: Int
let paidAudioContentCount: Int
private var progress: CGFloat {
CGFloat(max(0, min(purchasedAudioContentRate / 100, 1)))
}
private var rateText: String {
let rounded = purchasedAudioContentRate.rounded()
if rounded == purchasedAudioContentRate {
return "\(Int(rounded))%"
}
return String(format: "%.1f%%", purchasedAudioContentRate)
}
var body: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s14) {
HStack(alignment: .center, spacing: SodaSpacing.s4) {
(
Text(I18n.CreatorChannelAudio.ownershipPrefix)
.foregroundColor(.white)
+ Text(rateText)
.foregroundColor(Color.soda400)
+ Text(I18n.CreatorChannelAudio.ownershipSuffix)
.foregroundColor(.white)
)
.appFont(size: 14, weight: .medium)
.lineLimit(1)
Spacer(minLength: SodaSpacing.s8)
(
Text(purchasedAudioContentCount.comma())
.foregroundColor(.white)
+ Text("/\(paidAudioContentCount.comma())\(I18n.CreatorChannelAudio.countUnit)")
.foregroundColor(Color.gray500)
)
.appFont(size: 14, weight: .medium)
.lineLimit(1)
}
GeometryReader { proxy in
ZStack(alignment: .leading) {
Capsule()
.fill(Color.gray800)
Capsule()
.fill(Color.soda400)
.frame(width: proxy.size.width * progress)
}
}
.frame(height: 4)
}
.padding(SodaSpacing.s14)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
.padding(.horizontal, SodaSpacing.s14)
}
}
struct CreatorChannelAudioOwnershipRateView_Previews: PreviewProvider {
static var previews: some View {
CreatorChannelAudioOwnershipRateView(
purchasedAudioContentRate: 58,
purchasedAudioContentCount: 12,
paidAudioContentCount: 43
)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,41 @@
import SwiftUI
struct CreatorChannelAudioUploadButton: View {
let action: () -> Void
var body: some View {
VStack {
Button(action: action) {
HStack(spacing: SodaSpacing.s6) {
Image("ic_new_upload_audio")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
Text(I18n.CreatorChannelHome.uploadAudioContent)
.appFont(size: 16, weight: .bold)
.foregroundColor(.white)
.lineLimit(1)
}
.frame(maxWidth: .infinity)
.frame(height: 52)
.padding(.horizontal, SodaSpacing.s14)
.background(Color.soda400)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
.padding(.top, SodaSpacing.s14)
.padding(.bottom, 34)
.background(Color.black)
}
}
struct CreatorChannelAudioUploadButton_Previews: PreviewProvider {
static var previews: some View {
CreatorChannelAudioUploadButton(action: {})
.padding(SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,140 @@
import SwiftUI
struct CreatorChannelAudioTabView: View {
let creatorId: Int
let isOwnCreatorChannel: Bool
let onTapContent: (Int) -> Void
@StateObject private var viewModel = CreatorChannelAudioViewModel()
@State private var isSortPopupPresented = false
var body: some View {
content
.background(Color.black)
.onAppear {
if viewModel.hasLoaded == false {
viewModel.fetchFirstPage(creatorId: creatorId)
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1)
}
private var content: some View {
VStack(spacing: 0) {
if isEmptyState {
emptyStateView
} else {
ZStack(alignment: .topTrailing) {
VStack(spacing: 0) {
CapsuleTabBar(
items: viewModel.displayThemes,
selectedItem: Binding(
get: { viewModel.selectedTheme },
set: { viewModel.selectTheme($0, creatorId: creatorId) }
),
title: { $0.themeName }
)
CreatorChannelSortBar(
selectedSort: viewModel.selectedSort,
totalCount: viewModel.response?.audioContentCount,
onTapSort: {
isSortPopupPresented.toggle()
}
)
LazyVStack(spacing: 0) {
if shouldShowOwnershipRate, let response = viewModel.response {
CreatorChannelAudioOwnershipRateView(
purchasedAudioContentRate: response.purchasedAudioContentRate,
purchasedAudioContentCount: response.purchasedAudioContentCount,
paidAudioContentCount: response.paidAudioContentCount
)
.padding(.bottom, SodaSpacing.s8)
}
ForEach(viewModel.audioContents) { audioContent in
AudioContentListRow(
item: AudioContentListRowItem(
audioContent: audioContent,
subtitle: audioContent.duration
),
style: .purchaseState
) {
onTapContent(audioContent.audioContentId)
}
.onAppear {
viewModel.fetchNextPageIfNeeded(creatorId: creatorId, currentItem: audioContent)
}
}
if viewModel.isLoadingNextPage {
ProgressView()
.padding(.vertical, SodaSpacing.s20)
}
}
.padding(.bottom, isOwnCreatorChannel ? 70 : SodaSpacing.s20)
}
if isSortPopupPresented {
Color.black
.opacity(0.001)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onTapGesture {
isSortPopupPresented = false
}
CreatorChannelSortContextPopup(selectedSort: viewModel.selectedSort) { sort in
viewModel.selectSort(sort, creatorId: creatorId)
isSortPopupPresented = false
}
.padding(.top, 104)
.padding(.trailing, SodaSpacing.s14)
.zIndex(1)
}
}
.frame(maxWidth: .infinity, alignment: .topTrailing)
.onChange(of: viewModel.selectedSort) { _ in
isSortPopupPresented = false
}
.onChange(of: viewModel.selectedTheme) { _ in
isSortPopupPresented = false
}
.onDisappear {
isSortPopupPresented = false
}
}
}
}
private var shouldShowOwnershipRate: Bool {
guard isOwnCreatorChannel == false else { return false }
return viewModel.isAllThemeSelected
}
private var isEmptyState: Bool {
guard viewModel.hasLoaded, let response = viewModel.response else { return false }
return response.audioContentCount == 0
}
private var emptyStateView: some View {
Text(I18n.CreatorChannelAudio.emptyMessage)
.appFont(.body3)
.foregroundColor(Color.gray500)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
}
}
struct CreatorChannelAudioTabView_Previews: PreviewProvider {
static var previews: some View {
CreatorChannelAudioTabView(
creatorId: 1,
isOwnCreatorChannel: false,
onTapContent: { _ in }
)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,173 @@
import Foundation
import Combine
struct CreatorChannelAudioThemeItem: Hashable {
let themeId: Int?
let themeName: String
}
final class CreatorChannelAudioViewModel: ObservableObject {
private let repository = CreatorChannelAudioRepository()
private var subscription = Set<AnyCancellable>()
private let pageSize = 20
private var latestRequestId = 0
@Published var isLoading = false
@Published var isLoadingNextPage = false
@Published var response: CreatorChannelAudioTabResponse?
@Published var audioContents = [CreatorChannelAudioContentResponse]()
@Published var selectedSort: ContentSort = .latest
@Published var selectedTheme = CreatorChannelAudioThemeItem.all
@Published var page = 0
@Published var size = 20
@Published var hasNext = false
@Published var hasLoaded = false
@Published var errorMessage = ""
@Published var isShowPopup = false
var displayThemes: [CreatorChannelAudioThemeItem] {
let themes = response?.themes.map {
CreatorChannelAudioThemeItem(themeId: $0.themeId, themeName: $0.themeName)
} ?? []
return [CreatorChannelAudioThemeItem.all] + themes
}
var isAllThemeSelected: Bool {
selectedTheme.themeId == nil
}
func fetchFirstPage(creatorId: Int) {
fetchAudio(creatorId: creatorId, page: 0, isNextPage: false)
}
func fetchNextPageIfNeeded(creatorId: Int, currentItem: CreatorChannelAudioContentResponse) {
guard audioContents.last?.audioContentId == currentItem.audioContentId else { return }
guard hasNext, isLoadingNextPage == false, isLoading == false else { return }
fetchAudio(creatorId: creatorId, page: page + 1, isNextPage: true)
}
func selectSort(_ sort: ContentSort, creatorId: Int) {
guard selectedSort != sort else { return }
selectedSort = sort
fetchFirstPage(creatorId: creatorId)
}
func selectTheme(_ theme: CreatorChannelAudioThemeItem, creatorId: Int) {
guard selectedTheme != theme else { return }
selectedTheme = theme
fetchFirstPage(creatorId: creatorId)
}
private func fetchAudio(creatorId: Int, page requestPage: Int, isNextPage: Bool) {
latestRequestId += 1
let requestId = latestRequestId
if isNextPage {
isLoadingNextPage = true
} else {
isLoading = true
}
repository.getAudio(
creatorId: creatorId,
page: requestPage,
size: pageSize,
sort: selectedSort,
themeId: selectedTheme.themeId
)
.sink { [weak self] result in
guard let self else { return }
guard requestId == self.latestRequestId else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.applyFailureState()
}
} receiveValue: { [weak self] response in
guard let self else { return }
guard requestId == self.latestRequestId else { return }
var shouldFetchNextPage = false
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<CreatorChannelAudioTabResponse>.self, from: response.data)
if let data = decoded.data, decoded.success {
shouldFetchNextPage = self.applySuccessState(data, isNextPage: isNextPage)
} else {
self.errorMessage = decoded.message ?? I18n.Common.commonError
self.isShowPopup = true
}
} catch {
ERROR_LOG(error.localizedDescription)
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
}
self.hasLoaded = true
self.isLoading = false
self.isLoadingNextPage = false
if shouldFetchNextPage {
self.fetchAudio(creatorId: creatorId, page: self.page + 1, isNextPage: true)
}
}
.store(in: &subscription)
}
private func applySuccessState(_ data: CreatorChannelAudioTabResponse, isNextPage: Bool) -> Bool {
response = data
selectedSort = data.sort
selectedTheme = selectedTheme(from: data)
page = data.page
size = data.size
hasNext = data.hasNext
let visibleAudioContents = data.audioContents.filter {
guard let duration = $0.duration?.trimmingCharacters(in: .whitespacesAndNewlines) else { return false }
return duration.isEmpty == false
}
if isNextPage {
audioContents.append(contentsOf: visibleAudioContents)
} else {
audioContents = visibleAudioContents
}
return visibleAudioContents.isEmpty && data.hasNext
}
private func selectedTheme(from data: CreatorChannelAudioTabResponse) -> CreatorChannelAudioThemeItem {
guard let themeId = data.themeId else {
return .all
}
if let theme = data.themes.first(where: { $0.themeId == themeId }) {
return CreatorChannelAudioThemeItem(themeId: theme.themeId, themeName: theme.themeName)
}
return CreatorChannelAudioThemeItem(themeId: themeId, themeName: selectedTheme.themeName)
}
private func applyFailureState() {
errorMessage = I18n.Common.commonError
isShowPopup = true
hasLoaded = true
isLoading = false
isLoadingNextPage = false
}
}
private extension CreatorChannelAudioThemeItem {
static var all: CreatorChannelAudioThemeItem {
CreatorChannelAudioThemeItem(themeId: nil, themeName: I18n.CreatorChannelAudio.totalLabel)
}
}

View File

@@ -0,0 +1,20 @@
import Foundation
struct CreatorChannelAudioTabResponse: Decodable {
let audioContentCount: Int
let paidAudioContentCount: Int
let purchasedAudioContentCount: Int
let purchasedAudioContentRate: Double
let themes: [CreatorChannelAudioThemeResponse]
let audioContents: [CreatorChannelAudioContentResponse]
let sort: ContentSort
let themeId: Int?
let page: Int
let size: Int
let hasNext: Bool
}
struct CreatorChannelAudioThemeResponse: Decodable, Hashable {
let themeId: Int
let themeName: String
}

View File

@@ -0,0 +1,48 @@
import Foundation
import Moya
enum CreatorChannelAudioApi {
case getAudio(creatorId: Int, page: Int, size: Int, sort: ContentSort, themeId: Int?)
}
extension CreatorChannelAudioApi: TargetType {
var baseURL: URL {
return URL(string: BASE_URL)!
}
var path: String {
switch self {
case .getAudio(let creatorId, _, _, _, _):
return "/api/v2/creator-channels/\(creatorId)/audio"
}
}
var method: Moya.Method {
switch self {
case .getAudio:
return .get
}
}
var task: Moya.Task {
switch self {
case .getAudio(_, let page, let size, let sort, let themeId):
var parameters: [String: Any] = [
"page": page,
"size": size,
"sort": sort.rawValue
]
if let themeId {
parameters["themeId"] = themeId
}
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
}
}
var headers: [String: String]? {
return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
}
}

View File

@@ -0,0 +1,27 @@
import Foundation
import Combine
import CombineMoya
import Moya
final class CreatorChannelAudioRepository {
private let api = MoyaProvider<CreatorChannelAudioApi>()
func getAudio(
creatorId: Int,
page: Int,
size: Int,
sort: ContentSort,
themeId: Int?
) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getAudio(
creatorId: creatorId,
page: page,
size: size,
sort: sort,
themeId: themeId
)
)
}
}

View File

@@ -0,0 +1,16 @@
import SwiftUI
struct CreatorChannelCommunityEmptyView: View {
var body: some View {
VStack(spacing: 0) {
Text(I18n.CreatorChannelCommunity.communityEmpty)
.appFont(size: 16, weight: .regular)
.foregroundColor(Color.gray500)
.multilineTextAlignment(.center)
.lineSpacing(SodaSpacing.s4)
}
.frame(maxWidth: .infinity)
.padding(.top, 316)
.padding(.bottom, 120)
}
}

View File

@@ -0,0 +1,94 @@
import SwiftUI
struct CreatorChannelCommunityGridItem: View {
let post: CreatorChannelCommunityPostItem
let isOwnPost: Bool
let onTapPurchase: () -> Void
let onTapDetail: () -> Void
var body: some View {
GeometryReader { proxy in
ZStack {
if isPaidLocked {
Button(action: onTapPurchase) {
Color.gray400
.overlay(paidPriceOverlay)
}
.buttonStyle(.plain)
} else {
tileContent(proxy: proxy)
.contentShape(Rectangle())
.onTapGesture(perform: onTapDetail)
}
}
.frame(width: proxy.size.width, height: proxy.size.width)
.clipped()
.overlay(alignment: .topTrailing) {
if post.isPinned {
Image("ic_pin")
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
.padding(6)
.shadow(color: .black.opacity(0.45), radius: 2, x: 0, y: 1)
.allowsHitTesting(false)
}
}
}
.aspectRatio(1, contentMode: .fit)
}
@ViewBuilder
private func tileContent(proxy: GeometryProxy) -> some View {
if let imageUrl = post.imageUrl, !imageUrl.isEmpty {
DownsampledKFImage(url: URL(string: imageUrl), size: CGSize(width: proxy.size.width, height: proxy.size.width))
.frame(width: proxy.size.width, height: proxy.size.width)
.aspectRatio(1, contentMode: .fill)
.clipped()
} else {
ZStack {
Color.gray900
Text(fallbackText)
.appFont(size: 12, weight: .medium)
.foregroundColor(.white)
.multilineTextAlignment(.center)
.lineLimit(3)
.padding(SodaSpacing.s8)
}
}
}
private var paidPriceOverlay: some View {
VStack(spacing: SodaSpacing.s4) {
Image("ic_new_community_lock")
.resizable()
.renderingMode(.template)
.foregroundColor(.white)
.frame(width: 24, height: 24)
HStack(spacing: SodaSpacing.s6) {
Image("ic_bar_cash")
.resizable()
.frame(width: 20, height: 20)
Text("\(post.price)")
.appFont(size: 16, weight: .medium)
.foregroundColor(.black)
}
.padding(SodaSpacing.s8)
.background(Color.white)
.clipShape(Capsule())
}
}
private var isPaidLocked: Bool {
post.price > 0 && post.existOrdered == false && isOwnPost == false
}
private var fallbackText: String {
let content = post.content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return "-" }
return String(content.prefix(18))
}
}

View File

@@ -0,0 +1,264 @@
import SwiftUI
import Kingfisher
import SDWebImageSwiftUI
struct CreatorChannelCommunityListItem: View {
let post: CreatorChannelCommunityPostItem
let isOwnPost: Bool
let isOwnCreatorChannel: Bool
let onTapLike: () -> Void
let onTapMore: () -> Void
let onTapPurchase: () -> Void
let onTapDetail: () -> Void
@State private var localIsLike: Bool
@State private var localLikeCount: Int
@StateObject private var playManager = CreatorCommunityMediaPlayerManager.shared
@StateObject private var contentPlayManager = ContentPlayManager.shared
init(
post: CreatorChannelCommunityPostItem,
isOwnPost: Bool,
isOwnCreatorChannel: Bool,
onTapLike: @escaping () -> Void,
onTapMore: @escaping () -> Void,
onTapPurchase: @escaping () -> Void,
onTapDetail: @escaping () -> Void
) {
self.post = post
self.isOwnPost = isOwnPost
self.isOwnCreatorChannel = isOwnCreatorChannel
self.onTapLike = onTapLike
self.onTapMore = onTapMore
self.onTapPurchase = onTapPurchase
self.onTapDetail = onTapDetail
_localIsLike = State(initialValue: post.isLiked ?? false)
_localLikeCount = State(initialValue: post.likeCount)
}
var body: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s16) {
if post.isPinned {
pinnedLabel
}
header
contentText
if isPaidLocked {
paidLockedImage
} else if let imageUrl = post.imageUrl, !imageUrl.isEmpty {
imageContent(imageUrl: imageUrl)
}
reactionBar
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(SodaSpacing.s14)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
.contentShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
.onTapGesture(perform: onTapDetail)
.onChange(of: post.isLiked) { newValue in
localIsLike = newValue ?? false
}
.onChange(of: post.likeCount) { newValue in
localLikeCount = newValue
}
}
private var pinnedLabel: some View {
HStack(spacing: 2) {
Image("ic_pin")
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
Text("Notice")
.appFont(size: 14, weight: .medium)
.foregroundColor(Color(hex: "73FF01"))
}
}
private var header: some View {
HStack(spacing: SodaSpacing.s8) {
HStack(spacing: SodaSpacing.s8) {
DownsampledKFImage(url: URL(string: post.creatorProfileUrl), size: CGSize(width: 42, height: 42))
.background(Color.gray800)
.clipShape(Circle())
.frame(width: 42, height: 42)
VStack(alignment: .leading, spacing: 2) {
Text(post.creatorNickname)
.appFont(size: 14, weight: .medium)
.foregroundColor(.white)
.lineLimit(1)
Text(post.relativeTimeText())
.appFont(size: 14, weight: .regular)
.foregroundColor(Color.gray500)
.lineLimit(1)
}
}
Spacer(minLength: 0)
if shouldShowOwnerControls {
HStack(spacing: SodaSpacing.s4) {
if post.price > 0 {
priceTag
}
moreButton
}
} else if !isOwnPost {
moreButton
}
}
}
private var priceTag: some View {
HStack(spacing: 2) {
Image("ic_bar_cash")
.resizable()
.frame(width: 18, height: 18)
Text("\(post.price)")
.appFont(size: 12, weight: .regular)
.foregroundColor(.black)
}
.padding(.horizontal, SodaSpacing.s4)
.padding(.vertical, 2)
.background(Color.white)
.clipShape(Capsule())
}
private var moreButton: some View {
Button(action: onTapMore) {
Image("ic_seemore_vertical")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
}
.buttonStyle(.plain)
}
private var contentText: some View {
Text(post.content)
.appFont(size: 16, weight: .regular)
.foregroundColor(.white)
.lineLimit(5)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func imageContent(imageUrl: String) -> some View {
ZStack {
WebImage(url: URL(string: imageUrl))
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity)
.background(Color.gray800)
if let audioUrl = post.audioUrl, !audioUrl.isEmpty {
audioPlayButton(audioUrl: audioUrl)
}
}
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
private var paidLockedImage: some View {
Button(action: onTapPurchase) {
RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)
.fill(Color.gray400)
.frame(maxWidth: .infinity)
.frame(height: 236)
.overlay(paidPriceOverlay)
}
.buttonStyle(.plain)
}
private var paidPriceOverlay: some View {
VStack(spacing: SodaSpacing.s4) {
Image("ic_new_community_lock")
.resizable()
.renderingMode(.template)
.foregroundColor(.white)
.frame(width: 24, height: 24)
HStack(spacing: SodaSpacing.s6) {
Image("ic_bar_cash")
.resizable()
.frame(width: 20, height: 20)
Text("\(post.price)")
.appFont(size: 16, weight: .medium)
.foregroundColor(.black)
}
.padding(SodaSpacing.s8)
.background(Color.white)
.clipShape(Capsule())
}
}
private func audioPlayButton(audioUrl: String) -> some View {
Button {
contentPlayManager.pauseAudio()
playManager.toggleContent(item: CreatorCommunityContentItem(contentId: post.postId, url: audioUrl))
} label: {
Image(playManager.isPlaying && playManager.currentPlayingContentId == post.postId ? "btn_audio_content_pause" : "btn_audio_content_play")
.resizable()
.frame(width: 48, height: 48)
}
.buttonStyle(.plain)
}
private var reactionBar: some View {
HStack(spacing: 15) {
if !isPaidLocked {
Button {
localIsLike.toggle()
localLikeCount += localIsLike ? 1 : -1
localLikeCount = max(0, localLikeCount)
onTapLike()
} label: {
HStack(spacing: SodaSpacing.s4) {
Image(localIsLike ? "ic_feed_community_heart_fill" : "ic_feed_community_heart")
.resizable()
.frame(width: 18, height: 18)
Text("\(localLikeCount)")
.appFont(size: 16, weight: .regular)
.foregroundColor(Color.gray400)
}
}
.buttonStyle(.plain)
}
if post.isCommentAvailable && !isPaidLocked {
HStack(spacing: SodaSpacing.s4) {
Image("ic_feed_community_reply")
.resizable()
.frame(width: 18, height: 18)
Text("\(post.commentCount)")
.appFont(size: 16, weight: .regular)
.foregroundColor(Color.gray400)
}
}
}
.frame(height: 24)
}
private var isPaidLocked: Bool {
post.price > 0 && post.existOrdered == false && isOwnPost == false
}
private var shouldShowOwnerControls: Bool {
isOwnCreatorChannel && isOwnPost
}
}

View File

@@ -0,0 +1,32 @@
import SwiftUI
struct CreatorChannelCommunityUploadButton: View {
let action: () -> Void
var body: some View {
VStack {
Button(action: action) {
HStack(spacing: SodaSpacing.s6) {
Image("ic_new_upload_community_post")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
Text(I18n.CreatorChannelHome.uploadCommunityPost)
.appFont(size: 16, weight: .bold)
.foregroundColor(.white)
.lineLimit(1)
}
.frame(maxWidth: .infinity)
.frame(height: 52)
.padding(.horizontal, SodaSpacing.s14)
.background(Color.soda400)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
.padding(.top, SodaSpacing.s14)
.padding(.bottom, 34)
.background(Color.black)
}
}

View File

@@ -0,0 +1,59 @@
import SwiftUI
struct CreatorChannelCommunityViewModeBar: View {
let communityPostCount: Int
let viewMode: CreatorChannelCommunityViewMode
let onTapToggle: () -> Void
var body: some View {
HStack(alignment: .center, spacing: SodaSpacing.s8) {
HStack(spacing: SodaSpacing.s4) {
Text(I18n.CreatorChannelCommunity.totalLabel)
.appFont(size: 16, weight: .medium)
.foregroundColor(.white)
Text("\(communityPostCount)")
.appFont(size: 16, weight: .medium)
.foregroundColor(Color.gray500)
}
Spacer()
Button(action: onTapToggle) {
HStack(spacing: SodaSpacing.s4) {
Text(viewModeTitle)
.appFont(size: 16, weight: .regular)
.foregroundColor(Color.gray400)
Image(viewModeIconName)
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.padding(.horizontal, SodaSpacing.s14)
.frame(height: 52)
.background(Color.black)
}
private var viewModeTitle: String {
switch viewMode {
case .list:
return I18n.CreatorChannelCommunity.listView
case .thumbnail:
return I18n.CreatorChannelCommunity.thumbnailView
}
}
private var viewModeIconName: String {
switch viewMode {
case .list:
return "ic_new_list"
case .thumbnail:
return "ic_new_grid"
}
}
}

View File

@@ -0,0 +1,132 @@
import SwiftUI
struct CreatorChannelCommunityTabView: View {
let creatorId: Int
let isOwnCreatorChannel: Bool
@ObservedObject var viewModel: CreatorChannelCommunityViewModel
let onTapDetail: (CreatorChannelCommunityPostItem) -> Void
private let gridColumns = [
GridItem(.flexible(), spacing: 0),
GridItem(.flexible(), spacing: 0),
GridItem(.flexible(), spacing: 0)
]
var body: some View {
content
.background(Color.black)
.onAppear {
if viewModel.hasLoaded == false {
viewModel.fetchFirstPage(creatorId: creatorId)
}
}
.sheet(isPresented: $viewModel.isShowCommentListView) {
NavigationStack {
CreatorCommunityCommentListView(
isPresented: $viewModel.isShowCommentListView,
creatorId: creatorId,
postId: viewModel.selectedPostId,
isShowSecret: viewModel.isShowSecret
)
}
.toolbar(.hidden, for: .navigationBar)
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1)
}
private var content: some View {
VStack(spacing: 0) {
if isEmptyState {
CreatorChannelCommunityEmptyView()
} else {
CreatorChannelCommunityViewModeBar(
communityPostCount: viewModel.communityPostCount,
viewMode: viewModel.viewMode,
onTapToggle: viewModel.toggleViewMode
)
if viewModel.viewMode == .list {
listContent
} else {
gridContent
}
}
}
}
private var listContent: some View {
LazyVStack(spacing: SodaSpacing.s8) {
ForEach(viewModel.communityPosts) { post in
let isOwnPost = post.creatorId == UserDefaults.int(forKey: .userId)
CreatorChannelCommunityListItem(
post: post,
isOwnPost: isOwnPost,
isOwnCreatorChannel: isOwnCreatorChannel,
onTapLike: {
viewModel.communityPostLike(postId: post.postId)
},
onTapMore: {
viewModel.openReportMenu(post: post)
},
onTapPurchase: {
viewModel.openPurchaseDialog(post: post)
},
onTapDetail: {
onTapDetail(post)
}
)
.onAppear {
viewModel.fetchNextPageIfNeeded(creatorId: creatorId, currentItem: post)
}
}
loadingNextPageView
}
.padding(.horizontal, SodaSpacing.s14)
.padding(.bottom, contentBottomPadding)
}
private var gridContent: some View {
LazyVGrid(columns: gridColumns, spacing: 0) {
ForEach(viewModel.communityPosts) { post in
let isOwnPost = post.creatorId == UserDefaults.int(forKey: .userId)
CreatorChannelCommunityGridItem(
post: post,
isOwnPost: isOwnPost,
onTapPurchase: {
viewModel.openPurchaseDialog(post: post)
},
onTapDetail: {
onTapDetail(post)
}
)
.onAppear {
viewModel.fetchNextPageIfNeeded(creatorId: creatorId, currentItem: post)
}
}
loadingNextPageView
}
.padding(.bottom, contentBottomPadding)
}
@ViewBuilder
private var loadingNextPageView: some View {
if viewModel.isLoadingNextPage {
ProgressView()
.padding(.vertical, SodaSpacing.s20)
}
}
private var isEmptyState: Bool {
guard viewModel.hasLoaded else { return false }
return viewModel.communityPostCount == 0
}
private var contentBottomPadding: CGFloat {
isOwnCreatorChannel ? 120 : SodaSpacing.s20
}
}

View File

@@ -0,0 +1,351 @@
import Foundation
import Combine
import Moya
enum CreatorChannelCommunityViewMode {
case list
case thumbnail
}
final class CreatorChannelCommunityViewModel: ObservableObject {
private let repository = CreatorChannelCommunityRepository()
private let communityRepository = CreatorCommunityRepository()
private let reportRepository = ReportRepository()
private var subscription = Set<AnyCancellable>()
private let pageSize = 20
private var latestRequestId = 0
@Published var isLoading = false
@Published var isLoadingNextPage = false
@Published var response: CreatorChannelCommunityTabResponse?
@Published var communityPosts = [CreatorChannelCommunityPostItem]()
@Published var viewMode: CreatorChannelCommunityViewMode = .list
@Published var page = 0
@Published var size = 20
@Published var hasNext = false
@Published var hasLoaded = false
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isShowCommentListView = false {
didSet {
if !isShowCommentListView {
selectedPostId = 0
isShowSecret = false
}
}
}
@Published var isShowReportMenu = false
@Published var isShowReportView = false {
didSet {
if !isShowReportView {
selectedPostId = 0
}
}
}
@Published var isShowDeleteConfirm = false {
didSet {
if !isShowDeleteConfirm {
selectedPostId = 0
}
}
}
@Published var isShowPostPurchaseView = false {
didSet {
if !isShowPostPurchaseView {
selectedPostId = 0
selectedPostPrice = 0
}
}
}
@Published var selectedPostId = 0
@Published var selectedPostPrice = 0
@Published var selectedPostIsPinned = false
@Published var isShowSecret = false
var communityPostCount: Int {
response?.communityPostCount ?? 0
}
func fetchFirstPage(creatorId: Int) {
fetchCommunity(creatorId: creatorId, page: 0, isNextPage: false)
}
func fetchNextPageIfNeeded(creatorId: Int, currentItem: CreatorChannelCommunityPostItem) {
guard communityPosts.last?.postId == currentItem.postId else { return }
guard hasNext, isLoadingNextPage == false, isLoading == false else { return }
fetchCommunity(creatorId: creatorId, page: page + 1, isNextPage: true)
}
func toggleViewMode() {
viewMode = viewMode == .list ? .thumbnail : .list
}
func isPaidLocked(_ post: CreatorChannelCommunityPostItem, isOwnPost: Bool) -> Bool {
post.price > 0 && post.existOrdered == false && isOwnPost == false
}
func openCommentList(post: CreatorChannelCommunityPostItem) {
selectedPostId = post.postId
isShowSecret = post.price > 0 && post.existOrdered && post.creatorId != UserDefaults.int(forKey: .userId)
isShowCommentListView = true
}
func openReportMenu(post: CreatorChannelCommunityPostItem) {
selectedPostId = post.postId
selectedPostPrice = post.price
selectedPostIsPinned = post.isPinned
isShowReportMenu = true
}
func openPurchaseDialog(post: CreatorChannelCommunityPostItem) {
selectedPostId = post.postId
selectedPostPrice = post.price
isShowPostPurchaseView = true
}
func communityPostLike(postId: Int) {
communityRepository.communityPostLike(postId: postId)
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { _ in }
.store(in: &subscription)
}
func report(reason: String) {
guard selectedPostId > 0 else { return }
isLoading = true
let request = ReportRequest(type: .COMMUNITY_POST, reason: reason, communityPostId: selectedPostId)
reportRepository.report(request: request)
.sink { [weak self] result in
guard let self else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.applyFailureState()
}
} receiveValue: { [weak self] response in
guard let self else { return }
self.isLoading = false
self.selectedPostId = 0
do {
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
self.errorMessage = decoded.message ?? I18n.Common.commonError
self.isShowPopup = true
} catch {
ERROR_LOG(error.localizedDescription)
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
}
}
.store(in: &subscription)
}
func deleteCommunityPost(creatorId: Int) {
guard selectedPostId > 0 else { return }
isLoading = true
let request = ModifyCommunityPostRequest(creatorCommunityId: selectedPostId, isActive: false)
var multipartData = [MultipartFormData]()
let encoder = JSONEncoder()
encoder.outputFormatting = .withoutEscapingSlashes
guard let jsonData = try? encoder.encode(request) else {
applyFailureState()
return
}
multipartData.append(MultipartFormData(provider: .data(jsonData), name: "request"))
communityRepository.modifyCommunityPost(parameters: multipartData)
.sink { [weak self] result in
guard let self else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.applyFailureState()
}
} receiveValue: { [weak self] response in
guard let self else { return }
self.isLoading = false
self.selectedPostId = 0
do {
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
if decoded.success {
self.errorMessage = I18n.Explorer.deleted
self.isShowPopup = true
self.fetchFirstPage(creatorId: creatorId)
} else {
self.errorMessage = decoded.message ?? I18n.Common.commonError
self.isShowPopup = true
}
} catch {
ERROR_LOG(error.localizedDescription)
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
}
}
.store(in: &subscription)
}
func updateCommunityPostFixed(creatorId: Int) {
guard selectedPostId > 0 else { return }
isLoading = true
let request = UpdateCommunityPostFixedRequest(postId: selectedPostId, isFixed: !selectedPostIsPinned)
communityRepository.updateCommunityPostFixed(request: request)
.sink { [weak self] result in
guard let self else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.applyFailureState()
}
} receiveValue: { [weak self] response in
guard let self else { return }
self.isLoading = false
do {
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
if decoded.success {
self.fetchFirstPage(creatorId: creatorId)
} else {
self.errorMessage = decoded.message ?? I18n.Common.commonError
self.isShowPopup = true
}
} catch {
ERROR_LOG(error.localizedDescription)
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
}
}
.store(in: &subscription)
}
func purchaseCommunityPost(creatorId: Int) {
guard selectedPostId > 0 else { return }
isLoading = true
communityRepository.purchaseCommunityPost(postId: selectedPostId)
.sink { [weak self] result in
guard let self else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.applyFailureState()
}
} receiveValue: { [weak self] response in
guard let self else { return }
self.isLoading = false
self.selectedPostId = 0
do {
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
if decoded.success {
self.fetchFirstPage(creatorId: creatorId)
} else {
self.errorMessage = decoded.message ?? I18n.Common.commonError
self.isShowPopup = true
}
} catch {
ERROR_LOG(error.localizedDescription)
self.fetchFirstPage(creatorId: creatorId)
}
}
.store(in: &subscription)
}
private func fetchCommunity(creatorId: Int, page requestPage: Int, isNextPage: Bool) {
latestRequestId += 1
let requestId = latestRequestId
if isNextPage {
isLoadingNextPage = true
} else {
isLoading = true
}
repository.getCommunity(creatorId: creatorId, page: requestPage, size: pageSize)
.sink { [weak self] result in
guard let self else { return }
guard requestId == self.latestRequestId else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
self.isLoading = false
self.isLoadingNextPage = false
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.applyFailureState()
}
} receiveValue: { [weak self] response in
guard let self else { return }
guard requestId == self.latestRequestId else { return }
do {
let decoded = try JSONDecoder().decode(ApiResponse<CreatorChannelCommunityTabResponse>.self, from: response.data)
if let data = decoded.data, decoded.success {
self.applySuccessState(data, isNextPage: isNextPage)
self.hasLoaded = true
} else {
self.errorMessage = decoded.message ?? I18n.Common.commonError
self.isShowPopup = true
}
} catch {
ERROR_LOG(error.localizedDescription)
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
}
self.isLoading = false
self.isLoadingNextPage = false
}
.store(in: &subscription)
}
private func applySuccessState(_ data: CreatorChannelCommunityTabResponse, isNextPage: Bool) {
response = data
page = data.page
size = data.size
hasNext = data.hasNext
if isNextPage {
communityPosts.append(contentsOf: data.communityPosts)
} else {
communityPosts = data.communityPosts
}
}
private func applyFailureState() {
errorMessage = I18n.Common.commonError
isShowPopup = true
hasLoaded = true
isLoading = false
isLoadingNextPage = false
}
}

View File

@@ -0,0 +1,51 @@
import SwiftUI
struct CreatorChannelCommunityPostDetailCommentListView: View {
let creatorId: Int
let postId: Int
let commentCount: Int
let comments: [CreatorChannelCommunityCommentResponse]
let selectedComment: CreatorChannelCommunityCommentResponse?
let canEdit: (CreatorChannelCommunityCommentResponse) -> Bool
let canDelete: (CreatorChannelCommunityCommentResponse) -> Bool
let onAppearComment: (CreatorChannelCommunityCommentResponse) -> Void
let onTapReplyDetail: (CreatorChannelCommunityCommentResponse) -> Void
let onTapMore: (CreatorChannelCommunityCommentResponse) -> Void
let onTapEdit: (CreatorChannelCommunityCommentResponse) -> Void
let onTapDelete: (CreatorChannelCommunityCommentResponse) -> Void
var body: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s16) {
Text("댓글 \(commentCount)")
.appFont(size: 16, weight: .medium)
.foregroundColor(.white)
.padding(.horizontal, SodaSpacing.s14)
LazyVStack(alignment: .leading, spacing: SodaSpacing.s20) {
ForEach(comments, id: \.commentId) { comment in
ZStack(alignment: .topTrailing) {
CreatorChannelCommunityPostDetailCommentRow(
comment: comment,
creatorId: creatorId,
onTapReplyDetail: { onTapReplyDetail(comment) },
onTapMore: { onTapMore(comment) }
)
if selectedComment?.commentId == comment.commentId {
CreatorChannelActionPopup(
canEdit: canEdit(comment),
canDelete: canDelete(comment),
onTapEdit: { onTapEdit(comment) },
onTapDelete: { onTapDelete(comment) }
)
.padding(.top, 28)
}
}
.onAppear { onAppearComment(comment) }
}
}
.padding(.horizontal, SodaSpacing.s14)
}
.padding(.vertical, SodaSpacing.s20)
}
}

View File

@@ -0,0 +1,134 @@
import SwiftUI
struct CreatorChannelCommunityPostDetailCommentRow: View {
let comment: CreatorChannelCommunityCommentResponse
let creatorId: Int
let onTapReplyDetail: () -> Void
let onTapMore: () -> Void
private var currentUserId: Int { UserDefaults.int(forKey: .userId) }
var body: some View {
Button(action: onTapReplyDetail) {
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
HStack(alignment: .top, spacing: SodaSpacing.s8) {
DownsampledKFImage(url: URL(string: comment.writerProfileImageUrl), size: CGSize(width: 42, height: 42))
.background(Color.gray800)
.clipShape(Circle())
.frame(width: 42, height: 42)
VStack(alignment: .leading, spacing: SodaSpacing.s8) {
header
Text(comment.content)
.appFont(size: 16, weight: .regular)
.foregroundColor(.white)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
if let latestReply = comment.latestReply {
latestReplyCard(latestReply)
.padding(.leading, 50)
.background(alignment: .leading) {
CreatorChannelCommunityPostDetailReplyConnector()
.stroke(Color.gray800, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
.frame(width: 42)
.padding(.leading, 20)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private var header: some View {
HStack(spacing: SodaSpacing.s8) {
Text(comment.writerNickname)
.appFont(size: 14, weight: .medium)
.foregroundColor(.white)
.lineLimit(1)
Text(comment.relativeTimeText())
.appFont(size: 14, weight: .regular)
.foregroundColor(Color.gray500)
.lineLimit(1)
if comment.isSecret {
Text(I18n.Explorer.secretComment)
.appFont(size: 11, weight: .medium)
.foregroundColor(Color.grayee)
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(Color.soda400.opacity(0.2))
.cornerRadius(3)
}
Spacer(minLength: 0)
if showsMoreButton {
Button(action: onTapMore) {
Image("ic_new_more")
.resizable()
.frame(width: 24, height: 24)
}
.buttonStyle(.plain)
}
}
}
private func latestReplyCard(_ latestReply: CreatorChannelCommunityReplyResponse) -> some View {
VStack(alignment: .leading, spacing: SodaSpacing.s8) {
HStack(spacing: SodaSpacing.s8) {
DownsampledKFImage(url: URL(string: latestReply.writerProfileImageUrl), size: CGSize(width: 20, height: 20))
.background(Color.gray800)
.clipShape(Circle())
.frame(width: 20, height: 20)
Text(latestReply.writerNickname)
.appFont(size: 13, weight: .medium)
.foregroundColor(.white)
.lineLimit(1)
Text(latestReply.relativeTimeText())
.appFont(size: 14, weight: .regular)
.foregroundColor(Color.gray500)
.lineLimit(1)
Spacer(minLength: 0)
}
Text(latestReply.content)
.appFont(size: 16, weight: .regular)
.foregroundColor(.white)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(SodaSpacing.s12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
private var showsMoreButton: Bool {
comment.writerId == currentUserId || creatorId == currentUserId
}
}
private struct CreatorChannelCommunityPostDetailReplyConnector: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
let startX: CGFloat = 0
let endX: CGFloat = rect.width
let endY = rect.height - 8
let bendY = max(0, endY - 14)
path.move(to: CGPoint(x: startX, y: 0))
path.addLine(to: CGPoint(x: startX, y: bendY))
path.addQuadCurve(to: CGPoint(x: endX, y: endY), control: CGPoint(x: startX, y: endY))
return path
}
}

View File

@@ -0,0 +1,194 @@
import SwiftUI
import SDWebImageSwiftUI
struct CreatorChannelCommunityPostDetailContentView: View {
let detail: CreatorChannelCommunityPostDetailResponse
let onTapLike: () -> Void
let onTapPurchase: () -> Void
@StateObject private var playManager = CreatorCommunityMediaPlayerManager.shared
@StateObject private var contentPlayManager = ContentPlayManager.shared
var body: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s16) {
if detail.isPinned {
pinnedLabel
}
header
Text(detail.content)
.appFont(size: 16, weight: .regular)
.foregroundColor(.white)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
mediaContent
reactionBar
}
.padding(SodaSpacing.s14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.black)
}
private var pinnedLabel: some View {
HStack(spacing: 2) {
Image("ic_pin")
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
Text("Notice")
.appFont(size: 14, weight: .medium)
.foregroundColor(Color(hex: "73FF01"))
}
}
private var header: some View {
HStack(spacing: SodaSpacing.s8) {
DownsampledKFImage(url: URL(string: detail.creatorProfileUrl), size: CGSize(width: 42, height: 42))
.background(Color.gray800)
.clipShape(Circle())
.frame(width: 42, height: 42)
VStack(alignment: .leading, spacing: 2) {
Text(detail.creatorNickname)
.appFont(size: 14, weight: .medium)
.foregroundColor(.white)
.lineLimit(1)
Text(detail.relativeTimeText())
.appFont(size: 14, weight: .regular)
.foregroundColor(Color.gray500)
.lineLimit(1)
}
Spacer(minLength: 0)
if detail.price > 0 && detail.existOrdered && isOwnPost == false {
purchaseCompleteTag
}
}
}
@ViewBuilder
private var mediaContent: some View {
if isPaidLocked {
Button(action: onTapPurchase) {
RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)
.fill(Color.gray800)
.frame(maxWidth: .infinity)
.frame(height: 236)
.overlay(paidLockedOverlay)
}
.buttonStyle(.plain)
} else if let imageUrl = detail.imageUrl, imageUrl.isEmpty == false {
ZStack {
WebImage(url: URL(string: imageUrl))
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity)
.background(Color.gray800)
audioPlayButton
}
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
} else if detail.audioUrl?.isEmpty == false {
RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)
.fill(Color.gray900)
.frame(maxWidth: .infinity)
.frame(height: 132)
.overlay(audioPlayButton)
}
}
@ViewBuilder
private var audioPlayButton: some View {
if let audioUrl = detail.audioUrl, audioUrl.isEmpty == false {
Button {
contentPlayManager.pauseAudio()
playManager.toggleContent(item: CreatorCommunityContentItem(contentId: detail.postId, url: audioUrl))
} label: {
Image(playManager.isPlaying && playManager.currentPlayingContentId == detail.postId ? "btn_audio_content_pause" : "btn_audio_content_play")
.resizable()
.frame(width: 48, height: 48)
}
.buttonStyle(.plain)
}
}
private var paidLockedOverlay: some View {
VStack(spacing: SodaSpacing.s4) {
Image("ic_new_community_lock")
.resizable()
.renderingMode(.template)
.foregroundColor(.white)
.frame(width: 24, height: 24)
HStack(spacing: SodaSpacing.s6) {
Image("ic_bar_cash")
.resizable()
.frame(width: 20, height: 20)
Text("\(detail.price)")
.appFont(size: 16, weight: .medium)
.foregroundColor(Color.black)
}
.padding(SodaSpacing.s8)
.background(Color.white)
.clipShape(Capsule())
}
}
private var purchaseCompleteTag: some View {
Text("구매완료")
.appFont(size: 12, weight: .medium)
.foregroundColor(Color.black)
.padding(.horizontal, SodaSpacing.s8)
.padding(.vertical, SodaSpacing.s4)
.background(Color.soda400)
.clipShape(Capsule())
}
private var reactionBar: some View {
HStack(spacing: 15) {
if isPaidLocked == false {
Button(action: onTapLike) {
HStack(spacing: SodaSpacing.s4) {
Image(detail.isLiked ? "ic_feed_community_heart_fill" : "ic_feed_community_heart")
.resizable()
.frame(width: 18, height: 18)
Text("\(detail.likeCount)")
.appFont(size: 16, weight: .regular)
.foregroundColor(Color.gray500)
}
}
.buttonStyle(.plain)
if detail.isCommentAvailable {
HStack(spacing: SodaSpacing.s4) {
Image("ic_feed_community_reply")
.resizable()
.frame(width: 18, height: 18)
Text("\(detail.commentCount)")
.appFont(size: 16, weight: .regular)
.foregroundColor(Color.gray500)
}
}
}
}
.frame(height: 24)
}
private var isOwnPost: Bool {
detail.creatorId == UserDefaults.int(forKey: .userId)
}
private var isPaidLocked: Bool {
detail.price > 0 && detail.existOrdered == false && isOwnPost == false
}
}

View File

@@ -0,0 +1,152 @@
import SwiftUI
struct CreatorChannelCommunityPostDetailInputBar: View {
@Binding var text: String
@Binding var isSecret: Bool
let isShowSecret: Bool
let isSendEnabled: Bool
let isEditing: Bool
let onTapSend: () -> Void
let onTapCancelEditing: () -> Void
var body: some View {
VStack(spacing: 0) {
Rectangle()
.fill(Color.gray800)
.frame(height: 1)
if isShowSecret {
secretToggle
}
HStack(alignment: .bottom, spacing: SodaSpacing.s8) {
if isEditing {
Button(action: onTapCancelEditing) {
Image("ic_close_white")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
}
TextField(I18n.CreatorChannelCommunity.commentPlaceholder, text: $text, axis: .vertical)
.autocapitalization(.none)
.disableAutocorrection(true)
.appFont(size: 15, weight: .regular)
.foregroundColor(.white)
.accentColor(Color.soda400)
.lineLimit(1...3)
.padding(.horizontal, SodaSpacing.s14)
.padding(.vertical, SodaSpacing.s8)
.frame(minHeight: 40)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
Button(action: {
guard isSendEnabled else { return }
onTapSend()
}) {
Image(isSendEnabled ? "ic_arrow_up_white" : "ic_arrow_up_gray")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.frame(width: 36, height: 36)
.background(isSendEnabled ? Color.soda400 : Color.gray900)
.clipShape(Circle())
}
.buttonStyle(.plain)
.disabled(isSendEnabled == false)
}
.padding(.horizontal, SodaSpacing.s14)
.padding(.vertical, SodaSpacing.s8)
}
.background(Color.black)
}
private var secretToggle: some View {
Button {
isSecret.toggle()
} label: {
HStack(spacing: SodaSpacing.s6) {
Image(isSecret ? "btn_square_select_checked" : "btn_square_select_normal")
.resizable()
.frame(width: 18, height: 18)
Text(I18n.Explorer.secretComment)
.appFont(size: 13, weight: .regular)
.foregroundColor(Color.gray500)
Spacer(minLength: 0)
}
.padding(.horizontal, SodaSpacing.s14)
.padding(.top, SodaSpacing.s8)
}
.buttonStyle(.plain)
}
}
struct CreatorChannelCommunityPostDetailInputBar_Previews: PreviewProvider {
static var previews: some View {
Group {
PreviewWrapper(
text: "Secret comment",
isSecret: true,
isShowSecret: true,
isSendEnabled: true,
isEditing: false
)
.padding(.vertical, SodaSpacing.s16)
.background(Color.black)
.previewLayout(.sizeThatFits)
.previewDisplayName("isShowSecret: true")
PreviewWrapper(
text: "Public comment",
isSecret: false,
isShowSecret: false,
isSendEnabled: true,
isEditing: false
)
.padding(.vertical, SodaSpacing.s16)
.background(Color.black)
.previewLayout(.sizeThatFits)
.previewDisplayName("isShowSecret: false")
}
}
private struct PreviewWrapper: View {
@State private var text: String
@State private var isSecret: Bool
let isShowSecret: Bool
let isSendEnabled: Bool
let isEditing: Bool
init(
text: String,
isSecret: Bool,
isShowSecret: Bool,
isSendEnabled: Bool,
isEditing: Bool
) {
_text = State(initialValue: text)
_isSecret = State(initialValue: isSecret)
self.isShowSecret = isShowSecret
self.isSendEnabled = isSendEnabled
self.isEditing = isEditing
}
var body: some View {
CreatorChannelCommunityPostDetailInputBar(
text: $text,
isSecret: $isSecret,
isShowSecret: isShowSecret,
isSendEnabled: isSendEnabled,
isEditing: isEditing,
onTapSend: {},
onTapCancelEditing: {}
)
}
}
}

View File

@@ -0,0 +1,116 @@
import SwiftUI
struct CreatorChannelCommunityPostDetailView: View {
let postId: Int
let onCommunityRefresh: (() -> Void)?
@StateObject private var viewModel = CreatorChannelCommunityPostDetailViewModel()
var body: some View {
ZStack {
VStack(spacing: 0) {
CreatorChannelReplyDetailTitleBar {
AppState.shared.back()
}
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
if let detail = viewModel.detail {
CreatorChannelCommunityPostDetailContentView(
detail: detail,
onTapLike: viewModel.toggleLike,
onTapPurchase: showPurchaseError
)
if detail.isCommentAvailable {
CreatorChannelCommunityPostDetailCommentListView(
creatorId: detail.creatorId,
postId: detail.postId,
commentCount: detail.commentCount,
comments: viewModel.comments,
selectedComment: viewModel.selectedComment,
canEdit: viewModel.canEdit,
canDelete: viewModel.canDelete,
onAppearComment: viewModel.fetchNextCommentsIfNeeded,
onTapReplyDetail: showReplyDetail,
onTapMore: viewModel.selectComment,
onTapEdit: viewModel.beginEditing,
onTapDelete: viewModel.presentDeleteDialog
)
}
}
Spacer(minLength: SodaSpacing.s20)
}
}
}
.background(Color.black.ignoresSafeArea())
if viewModel.isLoading && viewModel.hasLoaded == false {
LoadingView()
}
if viewModel.isShowDeleteDialog {
SodaV2ActionModal(
title: I18n.Common.commentDeleteTitle,
message: I18n.Common.confirmDeleteQuestion,
button1: SodaV2ActionModalButton(
label: I18n.Common.delete,
action: viewModel.confirmDelete
),
button2: SodaV2ActionModalButton(
label: I18n.Common.cancel,
action: dismissDeleteDialog
),
onDimmedTap: dismissDeleteDialog
)
}
}
.navigationBarBackButtonHidden(true)
.onAppear {
viewModel.configure(postId: postId, onCommunityRefresh: onCommunityRefresh)
}
.safeAreaInset(edge: .bottom) {
if viewModel.detail?.isCommentAvailable == true {
CreatorChannelCommunityPostDetailInputBar(
text: $viewModel.commentText,
isSecret: $viewModel.isSecret,
isShowSecret: viewModel.isShowSecret,
isSendEnabled: viewModel.isCommentSendEnabled,
isEditing: viewModel.isEditing,
onTapSend: viewModel.sendComment,
onTapCancelEditing: viewModel.cancelEditing
)
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1)
}
private func dismissDeleteDialog() {
viewModel.isShowDeleteDialog = false
viewModel.deletingComment = nil
}
private func showReplyDetail(_ comment: CreatorChannelCommunityCommentResponse) {
guard let detail = viewModel.detail else { return }
AppState.shared.setAppStep(
step: .creatorChannelReplyDetail(
context: .communityPostDetail(
creatorId: detail.creatorId,
postId: detail.postId,
parentComment: comment
),
onCommunityRefresh: {
viewModel.fetchDetail(postId: postId)
onCommunityRefresh?()
}
)
)
}
private func showPurchaseError() {
viewModel.errorMessage = I18n.Common.commonError
viewModel.isShowPopup = true
}
}

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