Compare commits
131 Commits
a8680931fe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aea5b5cdec | ||
|
|
2db4ef1c35 | ||
|
|
9320657bac | ||
|
|
dc99bdf9c9 | ||
|
|
498debbfc7 | ||
|
|
a8fe416cb5 | ||
|
|
3ae2dc19d6 | ||
|
|
0232cd4335 | ||
|
|
a0db58e53f | ||
|
|
70af4923c6 | ||
|
|
9a0e75a391 | ||
|
|
43e32f1867 | ||
|
|
dd5144ec3f | ||
|
|
9cda165d30 | ||
|
|
b99505174a | ||
|
|
e466774a8a | ||
|
|
063c2808de | ||
|
|
0388e48930 | ||
|
|
1b26bc28dd | ||
|
|
dec6fb6fb8 | ||
|
|
9b115f3339 | ||
|
|
dbf696978f | ||
|
|
c9570fc735 | ||
|
|
4f271a77ee | ||
|
|
caa74c607d | ||
|
|
c38c8656e3 | ||
|
|
359df77b83 | ||
|
|
eaed309a1d | ||
|
|
47fe0e787d | ||
|
|
ec79e3ce8b | ||
|
|
e717a12344 | ||
|
|
2dfff32ac1 | ||
|
|
ce191539f2 | ||
|
|
5213f0d98d | ||
|
|
0d9a4880ef | ||
|
|
2d636a9bb8 | ||
|
|
955cbb6592 | ||
|
|
bc9f4ed370 | ||
|
|
d96001e213 | ||
|
|
3f21456048 | ||
|
|
09443761af | ||
|
|
403eee262f | ||
|
|
c0369e6f58 | ||
|
|
f2d589eca2 | ||
|
|
75aa99768e | ||
|
|
2283ea4b44 | ||
|
|
58f83f3083 | ||
|
|
9276d17c26 | ||
|
|
e7a011c519 | ||
|
|
7b756ae4be | ||
|
|
c356815b3e | ||
|
|
26884d9e81 | ||
|
|
981c02a8e1 | ||
|
|
b33aed94c6 | ||
|
|
9c4c304fc6 | ||
|
|
d129583d3b | ||
|
|
bae92eba4b | ||
|
|
b42ca61ce4 | ||
|
|
f3cbf42328 | ||
|
|
9a141a09de | ||
|
|
3ee2316507 | ||
|
|
f3b7d01a3f | ||
|
|
130b8eee40 | ||
|
|
f5ead536ea | ||
|
|
7c98af0c7d | ||
|
|
2063603f32 | ||
|
|
02b56ac6da | ||
|
|
e75d836b99 | ||
|
|
3e11d82938 | ||
|
|
265ac75eab | ||
|
|
8c71b6883b | ||
|
|
0dd111f5d6 | ||
|
|
c4c18341a6 | ||
|
|
6600cd43c1 | ||
|
|
b6580e201f | ||
|
|
abc10d7610 | ||
|
|
0b32a536c9 | ||
|
|
ce923ab2d7 | ||
|
|
03d286e8a9 | ||
|
|
0fa036f04d | ||
|
|
5059cd4a40 | ||
|
|
ca5432532e | ||
|
|
b84098b15e | ||
|
|
2b9018f9cb | ||
|
|
37c34ae91e | ||
|
|
3f24788c29 | ||
|
|
4214829a20 | ||
|
|
884952db5a | ||
|
|
aa735b7053 | ||
|
|
ed6240fdd0 | ||
|
|
32174cd0af | ||
|
|
0775b17fd4 | ||
|
|
610f0eee4b | ||
|
|
c136f5bbc3 | ||
|
|
f514a7ac40 | ||
|
|
0e358d73c3 | ||
|
|
2f1feb9561 | ||
|
|
069a8cf5e8 | ||
|
|
527b499c30 | ||
|
|
f19b04dc0e | ||
|
|
6cebf3052c | ||
|
|
6b571424cf | ||
|
|
1a7368d2a4 | ||
|
|
cd55b3e2f5 | ||
|
|
949c8cec8e | ||
|
|
d87d761670 | ||
|
|
cc14353fa4 | ||
|
|
29f7f3c26c | ||
|
|
033a9d3a0b | ||
|
|
4469872081 | ||
|
|
0dd1cf9cfe | ||
|
|
f93e65372b | ||
|
|
5b679389a6 | ||
|
|
e60942735b | ||
|
|
54d2787be0 | ||
|
|
9ecb663f7b | ||
|
|
641b00e66b | ||
|
|
e3dd508528 | ||
|
|
772af3dfe3 | ||
|
|
5761d6083b | ||
|
|
2a23a341c0 | ||
|
|
e15d114be3 | ||
|
|
29a54937ab | ||
|
|
fb7d0c799f | ||
|
|
ae09776c9b | ||
|
|
e2d46614bd | ||
|
|
5223e44dd4 | ||
|
|
cd3ff1ee31 | ||
|
|
66dc8b29cb | ||
|
|
6e3a7b21f7 | ||
|
|
c4e83c3aef |
@@ -151,8 +151,12 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
|
||||
|
||||
## 문서 작성 규칙
|
||||
- 구현 전 PRD 작성, 사용자 인터뷰, 계획/TASK 문서 작성, 체크리스트 갱신, 검증 기록 누적, 문서 분리 기준은 `docs/agent-guides/documentation-policy.md`를 따른다.
|
||||
- 신규 PRD, 계획/TASK 문서, 코드 리뷰 리포트를 만들기 전에 각각 `docs/sample/sample-prd.md`, `docs/sample/sample-plan-task.md`, `docs/sample/sample-review.md`를 확인하고 현재 샘플 구조를 따른다.
|
||||
- 연속된 하나의 작업은 새 문서를 만들지 말고 기존 PRD와 `plan-task.md`에 이어서 기록한다.
|
||||
- 계획/TASK 문서는 phase heading(`### Phase N: ...`)과 단계별 체크박스(`- [ ] **Task N.N: ...**`)를 사용하고, 각 task에는 대상 파일 경로와 검증 기준을 함께 적는다.
|
||||
- 계획/TASK 문서는 `sample-plan-task.md`의 Phase, Task, Goal, 완료 증거, 검증 기록 구조를 따르고 각 Task에 대상 파일 경로와 검증 기준을 적는다.
|
||||
- 코드 리뷰와 점검 결과는 PRD 및 `plan-task.md`와 같은 작업 디렉터리의 `reviews/` 아래에 `phase<번호>-<구현 내용을 나타내는 영문 kebab-case>.md` 형식의 리포트로 남긴다.
|
||||
- 리뷰에서 수정이 필요하다고 확정한 항목은 코드 수정 전에 `plan-task.md`의 해당 Phase에 실행 가능한 신규 Task로 추가하고, 그 Task를 기준으로 수정 및 검증한다.
|
||||
|
||||
## 문서 유지보수 규칙
|
||||
- 상세 문서 유지보수 규칙은 `docs/agent-guides/documentation-policy.md`를 참조한다.
|
||||
- `docs/sample/**`의 경로 또는 구조가 바뀌면 `AGENTS.md`, `docs/agent-guides/**`, 샘플 간 참조를 함께 갱신해 서로 일치시킨다.
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
21
SodaLive/Resources/Assets.xcassets/v2/ic_arrow_up_gray.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_arrow_up_gray.imageset/ic_arrow_up_gray.png
vendored
Normal file
|
After Width: | Height: | Size: 293 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_arrow_up_white.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_arrow_up_white.imageset/ic_arrow_up_white.png
vendored
Normal file
|
After Width: | Height: | Size: 243 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_bar_bell_colored.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_bar_bell_colored.imageset/ic_bar_bell_colored.png
vendored
Normal file
|
After Width: | Height: | Size: 328 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_bar_storage.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_bar_storage.imageset/ic_bar_storage.png
vendored
Normal file
|
After Width: | Height: | Size: 361 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_content_tag_first_star.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 324 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_content_tag_original.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_content_tag_original.imageset/ic_content_tag_original.png
vendored
Normal file
|
After Width: | Height: | Size: 549 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_content_tag_point.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_content_tag_point.imageset/ic_content_tag_point.png
vendored
Normal file
|
After Width: | Height: | Size: 750 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_bar_back.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_bar_back.imageset/ic_new_bar_back.png
vendored
Normal file
|
After Width: | Height: | Size: 228 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_create_live.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_create_live.imageset/ic_new_create_live.png
vendored
Normal file
|
After Width: | Height: | Size: 606 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_dm.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_dm.imageset/ic_new_dm.png
vendored
Normal file
|
After Width: | Height: | Size: 356 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_donation.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_donation.imageset/ic_new_donation.png
vendored
Normal file
|
After Width: | Height: | Size: 393 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_grid.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_grid.imageset/ic_new_grid.png
vendored
Normal file
|
After Width: | Height: | Size: 529 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_list.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_list.imageset/ic_new_list.png
vendored
Normal file
|
After Width: | Height: | Size: 304 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_more.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_more.imageset/ic_new_more.png
vendored
Normal file
|
After Width: | Height: | Size: 218 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_player_play.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_player_play.imageset/ic_new_player_play.png
vendored
Normal file
|
After Width: | Height: | Size: 249 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_shield_large.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_shield_large.imageset/ic_new_shield_large.png
vendored
Normal file
|
After Width: | Height: | Size: 321 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_shield_small.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_shield_small.imageset/ic_new_shield_small.png
vendored
Normal file
|
After Width: | Height: | Size: 328 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_sort.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_sort.imageset/ic_new_sort.png
vendored
Normal file
|
After Width: | Height: | Size: 358 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_talk.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_talk.imageset/ic_new_talk.png
vendored
Normal file
|
After Width: | Height: | Size: 372 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_upload_audio.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_upload_audio.imageset/ic_new_upload_audio.png
vendored
Normal file
|
After Width: | Height: | Size: 510 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_upload_community_post.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 506 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_x_black.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_x_black.imageset/ic_new_x_black.png
vendored
Normal file
|
After Width: | Height: | Size: 295 B |
@@ -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"
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_pin.imageset/ic_pin.png
vendored
Normal file
|
After Width: | Height: | Size: 373 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_decrease.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_decrease.imageset/ic_rank_caret_decrease.png
vendored
Normal file
|
After Width: | Height: | Size: 231 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_increase.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_increase.imageset/ic_rank_caret_increase.png
vendored
Normal file
|
After Width: | Height: | Size: 216 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_stay.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_stay.imageset/ic_rank_caret_stay.png
vendored
Normal file
|
After Width: | Height: | Size: 192 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_rank_new.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_rank_new.imageset/ic_rank_new.png
vendored
Normal file
|
After Width: | Height: | Size: 802 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_series_original.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_series_original.imageset/ic_series_original.png
vendored
Normal file
|
After Width: | Height: | Size: 603 B |
21
SodaLive/Resources/Assets.xcassets/v2/img_new_only.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/img_new_only.imageset/img_new_only.png
vendored
Normal file
|
After Width: | Height: | Size: 482 B |
@@ -2,6 +2,7 @@ import Foundation
|
||||
|
||||
enum AppDeepLinkAction {
|
||||
case live(roomId: Int)
|
||||
case userCreatorChat(roomId: Int)
|
||||
case content(contentId: Int)
|
||||
case series(seriesId: Int)
|
||||
case community(creatorId: Int, postId: Int?)
|
||||
@@ -57,6 +58,10 @@ enum AppDeepLinkHandler {
|
||||
AppState.shared.pushRoomId = 0
|
||||
AppState.shared.pushRoomId = roomId
|
||||
|
||||
case .userCreatorChat(let roomId):
|
||||
guard roomId > 0 else { return }
|
||||
AppState.shared.setAppStep(step: .userCreatorChatRoom(roomId: roomId))
|
||||
|
||||
case .content(let contentId):
|
||||
guard contentId > 0 else { return }
|
||||
AppState.shared.setAppStep(step: .contentDetail(contentId: contentId))
|
||||
@@ -166,6 +171,12 @@ enum AppDeepLinkHandler {
|
||||
}
|
||||
return .live(roomId: roomId)
|
||||
|
||||
case "chat":
|
||||
guard let identifier = identifier, let roomId = Int(identifier), roomId > 0 else {
|
||||
return nil
|
||||
}
|
||||
return .userCreatorChat(roomId: roomId)
|
||||
|
||||
case "content":
|
||||
guard let identifier = identifier, let contentId = Int(identifier), contentId > 0 else {
|
||||
return nil
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -38,9 +38,25 @@ class AppState: ObservableObject {
|
||||
if isShowPlayer {
|
||||
ContentPlayManager.shared.stopAudio()
|
||||
ContentPlayerPlayManager.shared.resetPlayer()
|
||||
return
|
||||
}
|
||||
|
||||
guard let pendingExternalNavigationAction else {
|
||||
return
|
||||
}
|
||||
|
||||
self.pendingExternalNavigationAction = nil
|
||||
pendingExternalNavigationCancelAction = nil
|
||||
DispatchQueue.main.async {
|
||||
pendingExternalNavigationAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published private(set) var isShowLeaveLiveNavigationDialog = false
|
||||
|
||||
private var pendingExternalNavigationAction: (() -> Void)?
|
||||
private var pendingExternalNavigationCancelAction: (() -> Void)?
|
||||
|
||||
@Published var isShowNotificationSettingsDialog = false
|
||||
|
||||
@@ -64,6 +80,7 @@ class AppState: ObservableObject {
|
||||
@Published var eventPopup: EventItem? = nil
|
||||
@Published var purchasedContentId = 0
|
||||
@Published var purchasedContentOrderType = OrderType.KEEP
|
||||
@Published private(set) var pendingMainChatFilter: MainChatFilter? = nil
|
||||
|
||||
@Published var isRestartApp = false
|
||||
@Published var startTab: HomeViewModel.CurrentTab = .home
|
||||
@@ -147,6 +164,37 @@ class AppState: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func requestExternalNavigation(
|
||||
navigationAction: @escaping () -> Void,
|
||||
cancelAction: @escaping () -> Void
|
||||
) {
|
||||
guard isShowPlayer else {
|
||||
navigationAction()
|
||||
return
|
||||
}
|
||||
|
||||
pendingExternalNavigationAction = navigationAction
|
||||
pendingExternalNavigationCancelAction = cancelAction
|
||||
isShowLeaveLiveNavigationDialog = true
|
||||
}
|
||||
|
||||
func confirmExternalNavigation() {
|
||||
guard pendingExternalNavigationAction != nil else {
|
||||
isShowLeaveLiveNavigationDialog = false
|
||||
return
|
||||
}
|
||||
|
||||
isShowLeaveLiveNavigationDialog = false
|
||||
NotificationCenter.default.post(name: .requestLiveRoomQuitForExternalNavigation, object: nil)
|
||||
}
|
||||
|
||||
func cancelExternalNavigation() {
|
||||
isShowLeaveLiveNavigationDialog = false
|
||||
pendingExternalNavigationAction = nil
|
||||
pendingExternalNavigationCancelAction?()
|
||||
pendingExternalNavigationCancelAction = nil
|
||||
}
|
||||
|
||||
func consumePendingDeepLinkAction() -> AppDeepLinkAction? {
|
||||
let action = pendingDeepLinkAction
|
||||
pendingDeepLinkAction = nil
|
||||
@@ -191,6 +239,16 @@ class AppState: ObservableObject {
|
||||
pendingContentSettingsGuideMessage = nil
|
||||
return message
|
||||
}
|
||||
|
||||
func setPendingMainChatFilter(_ filter: MainChatFilter) {
|
||||
pendingMainChatFilter = filter
|
||||
}
|
||||
|
||||
func consumePendingMainChatFilter() -> MainChatFilter? {
|
||||
let filter = pendingMainChatFilter
|
||||
pendingMainChatFilter = nil
|
||||
return filter
|
||||
}
|
||||
|
||||
// 언어 적용 직후 앱을 소프트 재시작(스플래시 -> 메인)하여 전역 UI를 새로고침
|
||||
func softRestart() {
|
||||
|
||||
@@ -62,7 +62,7 @@ enum AppStep {
|
||||
|
||||
case serviceCenter
|
||||
|
||||
case createContent
|
||||
case createContent(onSuccess: (() -> Void)? = nil)
|
||||
|
||||
case modifyContent(contentId: Int)
|
||||
|
||||
@@ -87,7 +87,7 @@ enum AppStep {
|
||||
onSuccess: (CreateLiveRoomResponse) -> Void
|
||||
)
|
||||
|
||||
case liveNowAll(onClickParticipant: (Int) -> Void)
|
||||
case liveNowAll
|
||||
|
||||
case liveReservationAll(
|
||||
onClickReservation: (Int) -> Void,
|
||||
@@ -127,6 +127,25 @@ enum AppStep {
|
||||
case creatorCommunityWrite(onSuccess: () -> Void)
|
||||
|
||||
case creatorCommunityModify(postId: Int, onSuccess: () -> Void)
|
||||
|
||||
case creatorChannelFanTalkWrite(
|
||||
creatorId: Int,
|
||||
mode: CreatorChannelFanTalkWriteViewModel.Mode,
|
||||
fanTalkId: Int?,
|
||||
initialContent: String,
|
||||
onSuccess: () -> Void
|
||||
)
|
||||
|
||||
case creatorChannelReplyDetail(
|
||||
context: CreatorChannelReplyDetailContext,
|
||||
onFanTalkRefresh: (() -> Void)? = nil,
|
||||
onCommunityRefresh: (() -> Void)? = nil
|
||||
)
|
||||
|
||||
case creatorChannelCommunityPostDetail(
|
||||
postId: Int,
|
||||
onCommunityRefresh: (() -> Void)? = nil
|
||||
)
|
||||
|
||||
case canCoupon(refresh: () -> Void)
|
||||
|
||||
@@ -161,12 +180,18 @@ enum AppStep {
|
||||
case characterDetail(characterId: Int)
|
||||
|
||||
case chatRoom(id: Int)
|
||||
|
||||
|
||||
case userCreatorChatRoom(roomId: Int)
|
||||
|
||||
case userCreatorChatCreator(creatorId: Int)
|
||||
|
||||
case newCharacterAll
|
||||
|
||||
case originalWorkDetail(originalId: Int)
|
||||
|
||||
case contentAll(isFree: Bool = false, isPointOnly: Bool = false)
|
||||
|
||||
case contentOverview(type: ContentOverviewType = .newAndHotAudio)
|
||||
|
||||
case seriesMain
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -149,9 +149,6 @@ struct ChatRoomView: View {
|
||||
|
||||
if viewModel.showQuotaNoticeView {
|
||||
ChatQuotaNoticeItemView(
|
||||
onSelectAd: {
|
||||
viewModel.showRewardedAdForChatQuota()
|
||||
},
|
||||
onSelectCan10: {
|
||||
viewModel.purchaseChatQuota(canOption: .can10)
|
||||
},
|
||||
|
||||
@@ -279,9 +279,16 @@ final class ChatRoomViewModel: ObservableObject {
|
||||
|
||||
func showRewardedAdForChatQuota() {
|
||||
_Concurrency.Task {
|
||||
await YandexRewardedAdManager.shared.showAdIfAvailable(for: .chatRoomQuota) { [weak self] in
|
||||
let isShown = await YandexRewardedAdManager.shared.showAdIfAvailable(for: .chatRoomQuota) { [weak self] in
|
||||
self?.purchaseChatQuota(chargeType: .ad, canOption: nil)
|
||||
}
|
||||
|
||||
if !isShown {
|
||||
await MainActor.run {
|
||||
errorMessage = I18n.Chat.Room.rewardedAdLoadFailed
|
||||
isShowPopup = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,31 +9,11 @@ import SwiftUI
|
||||
|
||||
struct ChatQuotaNoticeItemView: View {
|
||||
|
||||
let onSelectAd: () -> Void
|
||||
let onSelectCan10: () -> Void
|
||||
let onSelectCan20: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
Button {
|
||||
onSelectAd()
|
||||
} label: {
|
||||
Text(I18n.Chat.Room.quotaAdAction(chatCount: 5))
|
||||
.appFont(size: 18, weight: .bold)
|
||||
.foregroundColor(Color(hex: "263238"))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color(hex: "FEF8E3"))
|
||||
.cornerRadius(30)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 30)
|
||||
.stroke(lineWidth: 1)
|
||||
.foregroundColor(Color(hex: "F7CB50"))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
onSelectCan10()
|
||||
@@ -80,7 +60,6 @@ struct ChatQuotaNoticeItemView: View {
|
||||
|
||||
#Preview {
|
||||
ChatQuotaNoticeItemView(
|
||||
onSelectAd: {},
|
||||
onSelectCan10: {},
|
||||
onSelectCan20: {}
|
||||
)
|
||||
|
||||
@@ -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?] = [
|
||||
|
||||
@@ -11,7 +11,6 @@ import YandexMobileAds
|
||||
enum YandexBannerPlacement {
|
||||
case liveTab
|
||||
case liveDetail
|
||||
case contentDetail
|
||||
case creatorCommunityAll
|
||||
case seriesMainHome
|
||||
case seriesMainDayOfWeek
|
||||
@@ -23,10 +22,6 @@ enum YandexBannerPlacement {
|
||||
case chatTalkTabTop
|
||||
}
|
||||
|
||||
enum YandexInterstitialPlacement {
|
||||
case contentDetail
|
||||
}
|
||||
|
||||
enum YandexRewardedPlacement {
|
||||
case chatRoomQuota
|
||||
}
|
||||
@@ -39,8 +34,6 @@ enum YandexAdUnitIdProvider {
|
||||
YANDEX_LIVE_TAB_BANNER_AD_UNIT_ID
|
||||
case .liveDetail:
|
||||
YANDEX_LIVE_DETAIL_BANNER_AD_UNIT_ID
|
||||
case .contentDetail:
|
||||
YANDEX_CONTENT_DETAIL_BANNER_AD_UNIT_ID
|
||||
case .creatorCommunityAll:
|
||||
YANDEX_CREATOR_COMMUNITY_ALL_BANNER_AD_UNIT_ID
|
||||
case .seriesMainHome:
|
||||
@@ -62,13 +55,6 @@ enum YandexAdUnitIdProvider {
|
||||
}
|
||||
}
|
||||
|
||||
static func interstitial(for placement: YandexInterstitialPlacement) -> String {
|
||||
switch placement {
|
||||
case .contentDetail:
|
||||
YANDEX_CONTENT_DETAIL_INTERSTITIAL_AD_UNIT_ID
|
||||
}
|
||||
}
|
||||
|
||||
static func rewarded(for placement: YandexRewardedPlacement) -> String {
|
||||
switch placement {
|
||||
case .chatRoomQuota:
|
||||
@@ -204,117 +190,6 @@ private struct YandexInlineBannerContainer: UIViewRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class YandexInterstitialAdManager: NSObject {
|
||||
|
||||
static let shared = YandexInterstitialAdManager()
|
||||
|
||||
private var interstitialAd: InterstitialAd?
|
||||
private var interstitialAdLoader: InterstitialAdLoader?
|
||||
private var currentPlacement: YandexInterstitialPlacement?
|
||||
private var pendingAction: (@MainActor () -> Void)?
|
||||
private var isLoading = false
|
||||
|
||||
func preloadAd(for placement: YandexInterstitialPlacement) {
|
||||
guard !isLoading else {
|
||||
return
|
||||
}
|
||||
|
||||
if currentPlacement == placement, interstitialAd != nil {
|
||||
return
|
||||
}
|
||||
|
||||
let loader = InterstitialAdLoader()
|
||||
interstitialAdLoader = loader
|
||||
interstitialAd = nil
|
||||
currentPlacement = placement
|
||||
isLoading = true
|
||||
|
||||
Task {
|
||||
do {
|
||||
let loadedAd = try await loader.loadAd(with: AdRequest(adUnitID: YandexAdUnitIdProvider.interstitial(for: placement)))
|
||||
guard currentPlacement == placement else {
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
loadedAd.delegate = self
|
||||
interstitialAd = loadedAd
|
||||
} catch {
|
||||
if currentPlacement == placement {
|
||||
interstitialAd = nil
|
||||
}
|
||||
}
|
||||
|
||||
if currentPlacement == placement {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func showAdIfAvailable(for placement: YandexInterstitialPlacement, then action: @escaping @MainActor () -> Void) {
|
||||
guard let presenter = presentingViewController(), let interstitialAd, currentPlacement == placement else {
|
||||
action()
|
||||
preloadAd(for: placement)
|
||||
return
|
||||
}
|
||||
|
||||
pendingAction = action
|
||||
interstitialAd.show(from: presenter)
|
||||
}
|
||||
|
||||
private func completePendingAction() {
|
||||
let action = pendingAction
|
||||
pendingAction = nil
|
||||
interstitialAd = nil
|
||||
action?()
|
||||
|
||||
if let currentPlacement {
|
||||
preloadAd(for: currentPlacement)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentingViewController() -> UIViewController? {
|
||||
guard
|
||||
let rootViewController = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene })
|
||||
.flatMap({ $0.windows })
|
||||
.first(where: { $0.isKeyWindow })?
|
||||
.rootViewController
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var topViewController = rootViewController
|
||||
|
||||
while let presentedViewController = topViewController.presentedViewController {
|
||||
topViewController = presentedViewController
|
||||
}
|
||||
|
||||
return topViewController
|
||||
}
|
||||
}
|
||||
|
||||
extension YandexInterstitialAdManager: InterstitialAdDelegate {
|
||||
|
||||
func interstitialAdDidShow(_ interstitialAd: InterstitialAd) {
|
||||
}
|
||||
|
||||
func interstitialAdDidDismiss(_ interstitialAd: InterstitialAd) {
|
||||
completePendingAction()
|
||||
}
|
||||
|
||||
func interstitialAdDidClick(_ interstitialAd: InterstitialAd) {
|
||||
}
|
||||
|
||||
func interstitialAd(_ interstitialAd: InterstitialAd, didTrackImpression impressionData: ImpressionData?) {
|
||||
}
|
||||
|
||||
func interstitialAd(_ interstitialAd: InterstitialAd, didFailToShow error: Error) {
|
||||
completePendingAction()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class YandexRewardedAdManager: NSObject {
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {}
|
||||
)
|
||||
|
||||
@@ -199,10 +199,6 @@ struct ContentDetailPlayView: View {
|
||||
.frame(width: screenSize().width - 40)
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
await YandexInterstitialAdManager.shared.preloadAd(for: .contentDetail)
|
||||
}
|
||||
|
||||
if !isPlaying() {
|
||||
stopTimer()
|
||||
}
|
||||
@@ -245,22 +241,7 @@ struct ContentDetailPlayView: View {
|
||||
)
|
||||
}
|
||||
|
||||
guard shouldShowInterstitialBeforePlayback(showPreviewAlert: showPreviewAlert) else {
|
||||
playAction()
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
await YandexInterstitialAdManager.shared.showAdIfAvailable(for: .contentDetail, then: playAction)
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldShowInterstitialBeforePlayback(showPreviewAlert: Bool) -> Bool {
|
||||
if contentPlayManager.contentId == audioContent.contentId {
|
||||
return false
|
||||
}
|
||||
|
||||
return audioContent.price <= 0 || showPreviewAlert
|
||||
playAction()
|
||||
}
|
||||
|
||||
private func sliderRange() -> ClosedRange<Double> {
|
||||
|
||||
@@ -91,10 +91,6 @@ struct ContentDetailView: View {
|
||||
.padding(.top, 13.3)
|
||||
.padding(.horizontal, 13.3)
|
||||
}
|
||||
|
||||
YandexInlineBannerView(placement: .contentDetail)
|
||||
.padding(.top, 13.3)
|
||||
|
||||
ContentDetailInfoView(
|
||||
isExpandDescription: $viewModel.isExpandDescription,
|
||||
isShowPreviewAlert: $viewModel.isShowPreviewAlert,
|
||||
|
||||
@@ -30,21 +30,6 @@ struct ContentView: View {
|
||||
.navigationBarBackButtonHidden(true)
|
||||
}
|
||||
|
||||
if let liveDetailSheet = appState.liveDetailSheet {
|
||||
LiveDetailView(
|
||||
roomId: liveDetailSheet.roomId,
|
||||
onClickParticipant: liveDetailSheet.onClickParticipant,
|
||||
onClickReservation: liveDetailSheet.onClickReservation,
|
||||
onClickStart: liveDetailSheet.onClickStart,
|
||||
onClickCancel: liveDetailSheet.onClickCancel,
|
||||
onClickClose: {
|
||||
withAnimation {
|
||||
appState.hideLiveDetailSheet()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if isShowDialog {
|
||||
SodaDialog(
|
||||
title: I18n.Common.pointGrantTitle,
|
||||
@@ -62,7 +47,6 @@ struct ContentView: View {
|
||||
self.isShowDialog = true
|
||||
}
|
||||
}
|
||||
.sodaToast(isPresented: $appState.isShowErrorPopup, message: appState.errorMessage, autohideIn: 1)
|
||||
.navigationDestination(for: AppRoute.self) { route in
|
||||
if let step = appState.appStep(for: route) {
|
||||
AppStepLayerView(step: step, canPgPaymentViewModel: canPgPaymentViewModel)
|
||||
@@ -73,6 +57,49 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
ZStack {
|
||||
if let liveDetailSheet = appState.liveDetailSheet {
|
||||
LiveDetailView(
|
||||
roomId: liveDetailSheet.roomId,
|
||||
onClickParticipant: liveDetailSheet.onClickParticipant,
|
||||
onClickReservation: liveDetailSheet.onClickReservation,
|
||||
onClickStart: liveDetailSheet.onClickStart,
|
||||
onClickCancel: liveDetailSheet.onClickCancel,
|
||||
onClickClose: {
|
||||
withAnimation {
|
||||
appState.hideLiveDetailSheet()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if appState.isShowPlayer {
|
||||
LiveRoomViewV2()
|
||||
}
|
||||
|
||||
if appState.isShowLeaveLiveNavigationDialog {
|
||||
leaveLiveNavigationDialog
|
||||
}
|
||||
}
|
||||
}
|
||||
.sodaToast(isPresented: $appState.isShowErrorPopup, message: appState.errorMessage, autohideIn: 1)
|
||||
}
|
||||
|
||||
private var leaveLiveNavigationDialog: some View {
|
||||
SodaV2ActionModal(
|
||||
title: I18n.Common.alertTitle,
|
||||
message: I18n.LiveRoom.leaveLiveForNavigationDesc,
|
||||
button1: SodaV2ActionModalButton(
|
||||
label: I18n.Common.confirm,
|
||||
action: appState.confirmExternalNavigation
|
||||
),
|
||||
button2: SodaV2ActionModalButton(
|
||||
label: I18n.Common.cancel,
|
||||
action: appState.cancelExternalNavigation
|
||||
),
|
||||
onDimmedTap: appState.cancelExternalNavigation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,14 +189,14 @@ struct AppStepLayerView: View {
|
||||
case .serviceCenter:
|
||||
ServiceCenterView()
|
||||
|
||||
case .createContent:
|
||||
ContentCreateView()
|
||||
case .createContent(let onSuccess):
|
||||
ContentCreateView(onSuccess: onSuccess)
|
||||
|
||||
case .liveReservationComplete(let response):
|
||||
LiveReservationCompleteView(reservationCompleteData: response)
|
||||
|
||||
case .creatorDetail(let userId):
|
||||
UserProfileView(userId: userId)
|
||||
CreatorChannelView(creatorId: userId)
|
||||
|
||||
case .followerList(let userId):
|
||||
FollowerListView(userId: userId)
|
||||
@@ -189,8 +216,8 @@ struct AppStepLayerView: View {
|
||||
onSuccess: onSuccess
|
||||
)
|
||||
|
||||
case .liveNowAll(let onClickParticipant):
|
||||
LiveNowAllView(onClickParticipant: onClickParticipant)
|
||||
case .liveNowAll:
|
||||
LiveNowAllView()
|
||||
|
||||
case .liveReservationAll(let onClickReservation, let onClickStart, let onClickCancel, let onTapCreateLive):
|
||||
LiveReservationAllView(
|
||||
@@ -254,6 +281,19 @@ struct AppStepLayerView: View {
|
||||
case .creatorCommunityModify(let postId, let onSuccess):
|
||||
CreatorCommunityModifyView(postId: postId, onSuccess: onSuccess)
|
||||
|
||||
case .creatorChannelFanTalkWrite(let creatorId, let mode, _, _, let onSuccess):
|
||||
CreatorChannelFanTalkWriteView(creatorId: creatorId, mode: mode, onSuccess: onSuccess)
|
||||
|
||||
case .creatorChannelReplyDetail(let context, let onFanTalkRefresh, let onCommunityRefresh):
|
||||
CreatorChannelReplyDetailView(
|
||||
context: context,
|
||||
onFanTalkRefresh: onFanTalkRefresh,
|
||||
onCommunityRefresh: onCommunityRefresh
|
||||
)
|
||||
|
||||
case .creatorChannelCommunityPostDetail(let postId, let onCommunityRefresh):
|
||||
CreatorChannelCommunityPostDetailView(postId: postId, onCommunityRefresh: onCommunityRefresh)
|
||||
|
||||
case .canCoupon(let refresh):
|
||||
CanCouponView(refresh: refresh)
|
||||
|
||||
@@ -308,6 +348,12 @@ struct AppStepLayerView: View {
|
||||
case .chatRoom(let id):
|
||||
ChatRoomView(roomId: id)
|
||||
|
||||
case .userCreatorChatRoom(let roomId):
|
||||
UserCreatorChatRoomView(roomId: roomId)
|
||||
|
||||
case .userCreatorChatCreator(let creatorId):
|
||||
UserCreatorChatRoomView(creatorId: creatorId)
|
||||
|
||||
case .newCharacterAll:
|
||||
NewCharacterListView()
|
||||
|
||||
@@ -317,6 +363,11 @@ struct AppStepLayerView: View {
|
||||
case .contentAll(let isFree, let isPointOnly):
|
||||
ContentAllView(isFree: isFree, isPointAvailableOnly: isPointOnly)
|
||||
|
||||
case .contentOverview(let type):
|
||||
ContentOverviewView(type: type) { contentId in
|
||||
AppState.shared.setAppStep(step: .contentDetail(contentId: contentId))
|
||||
}
|
||||
|
||||
case .seriesMain:
|
||||
SeriesMainView()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -300,7 +300,6 @@ final class UserProfileViewModel: ObservableObject {
|
||||
} else {
|
||||
if roomId > 0 {
|
||||
AppState.shared.isShowPlayer = true
|
||||
AppState.shared.setAppStep(step: .main)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,28 @@ import Foundation
|
||||
// String Catalog를 사용하지 않는 컨텍스트에서 사용할 하드코딩 맵 기반 i18n.
|
||||
// 기준 언어 선택은 LanguageHeaderProvider.current("ko"|"en"|"ja").
|
||||
enum I18n {
|
||||
enum UserCreatorChat {
|
||||
static var messagePlaceholder: String {
|
||||
pick(ko: "메시지를 입력하세요", en: "Enter a message", ja: "メッセージを入力してください")
|
||||
}
|
||||
|
||||
static var sendingStatus: String {
|
||||
pick(ko: "전송 중", en: "Sending", ja: "送信中")
|
||||
}
|
||||
|
||||
static var failedStatus: String {
|
||||
pick(ko: "실패", en: "Failed", ja: "失敗")
|
||||
}
|
||||
|
||||
static var joinFailureToast: String {
|
||||
pick(
|
||||
ko: "대화방에 접속하지 못했습니다.",
|
||||
en: "Failed to join the chat room.",
|
||||
ja: "チャットルームに接続できませんでした。"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 채팅방(캐릭터 톡) 관련 문자열
|
||||
enum ChatRoom {
|
||||
// 잠금된 메시지 다이얼로그
|
||||
@@ -278,6 +300,14 @@ enum I18n {
|
||||
)
|
||||
}
|
||||
|
||||
static var rewardedAdLoadFailed: String {
|
||||
pick(
|
||||
ko: "광고를 불러오지 못했어요\n잠시 후 다시 시도해 주세요.",
|
||||
en: "Could not load the ad.\nPlease try again later.",
|
||||
ja: "広告を読み込めませんでした。\nしばらくしてからもう一度お試しください。"
|
||||
)
|
||||
}
|
||||
|
||||
static var backgroundSelectionTitle: String {
|
||||
pick(ko: "배경 이미지 선택", en: "Select background image", ja: "背景画像を選択")
|
||||
}
|
||||
@@ -423,9 +453,9 @@ enum I18n {
|
||||
|
||||
static var pinLimitDesc: String {
|
||||
pick(
|
||||
ko: "이 콘텐츠를 고정하시겠어요? 채널에 콘텐츠를 최대 3개까지 고정할 수 있습니다. 이 콘텐츠를 고정하면 가장 오래된 콘텐츠가 대체됩니다.",
|
||||
en: "Do you want to pin this content? You can pin up to 3 contents in a channel. Pinning this will replace the oldest pinned content.",
|
||||
ja: "このコンテンツを固定しますか?チャンネルには最大3件まで固定できます。このコンテンツを固定すると最も古い固定コンテンツが置き換えられます。"
|
||||
ko: "이 콘텐츠를 고정하시겠어요? 채널에 콘텐츠를 최대 9개까지 고정할 수 있습니다. 이 콘텐츠를 고정하면 가장 오래된 콘텐츠가 대체됩니다.",
|
||||
en: "Do you want to pin this content? You can pin up to 9 contents in a channel. Pinning this will replace the oldest pinned content.",
|
||||
ja: "このコンテンツを固定しますか?チャンネルには最大9件まで固定できます。このコンテンツを固定すると最も古い固定コンテンツが置き換えられます。"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1409,9 +1439,9 @@ enum I18n {
|
||||
enum Splash {
|
||||
static var tagline: String {
|
||||
pick(
|
||||
ko: "목소리로 만나는 새로운 세계",
|
||||
en: "A new world you meet by voice",
|
||||
ja: "声でつながる新しいワールド"
|
||||
ko: "당신의 최애가 기다리는 곳",
|
||||
en: "Meet Your Favorites",
|
||||
ja: "推しが待つ場所"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2337,7 +2367,7 @@ enum I18n {
|
||||
|
||||
enum LiveNow {
|
||||
static var allTitle: String {
|
||||
pick(ko: "지금 라이브 중 전체보기", en: "Live Now - All", ja: "ライブ配信中(全て)")
|
||||
pick(ko: "On Air", en: "On Air", ja: "On Air")
|
||||
}
|
||||
|
||||
static var sectionTitle: String {
|
||||
@@ -2952,6 +2982,44 @@ If you block this user, the following features will be restricted.
|
||||
}
|
||||
}
|
||||
|
||||
enum MainChat {
|
||||
static var filterAll: String {
|
||||
pick(ko: "전체", en: "All", ja: "すべて")
|
||||
}
|
||||
|
||||
static var filterAI: String {
|
||||
pick(ko: "AI 채팅", en: "AI chats", ja: "AIチャット")
|
||||
}
|
||||
|
||||
static var filterDM: String {
|
||||
pick(ko: "DM", en: "DM", ja: "DM")
|
||||
}
|
||||
|
||||
static var emptyStateMessage: String {
|
||||
pick(
|
||||
ko: "대화 목록이 없습니다.",
|
||||
en: "No talks yet.",
|
||||
ja: "トークはまだありません。"
|
||||
)
|
||||
}
|
||||
|
||||
static var loadFailedMessage: String {
|
||||
pick(
|
||||
ko: "대화 목록을 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.",
|
||||
en: "Unable to load talks.\nPlease try again later.",
|
||||
ja: "トーク一覧を読み込めませんでした。\nしばらくしてからもう一度お試しください。"
|
||||
)
|
||||
}
|
||||
|
||||
static var directTag: String {
|
||||
pick(ko: "Direct", en: "Direct", ja: "Direct")
|
||||
}
|
||||
|
||||
static var monthDayDateFormat: String {
|
||||
pick(ko: "M월 d일", en: "MMM d", ja: "M月d日")
|
||||
}
|
||||
}
|
||||
|
||||
enum Main {
|
||||
enum EventPopup {
|
||||
static var doNotShowAgain: String {
|
||||
@@ -2967,7 +3035,7 @@ If you block this user, the following features will be restricted.
|
||||
static var home: String { pick(ko: "홈", en: "Home", ja: "ホーム") }
|
||||
static var content: String { pick(ko: "콘텐츠", en: "Content", ja: "コンテンツ") }
|
||||
static var live: String { pick(ko: "라이브", en: "Live", ja: "ライブ") }
|
||||
static var chat: String { pick(ko: "채팅", en: "Chat", ja: "チャット") }
|
||||
static var chat: String { pick(ko: "대화", en: "Talks", ja: "トーク") }
|
||||
static var my: String { pick(ko: "마이", en: "My", ja: "マイ") }
|
||||
}
|
||||
|
||||
@@ -2998,6 +3066,148 @@ If you block this user, the following features will be restricted.
|
||||
}
|
||||
}
|
||||
|
||||
enum MainContentRecommendation {
|
||||
static var tabTitle: String {
|
||||
pick(ko: "추천", en: "Recommended", ja: "おすすめ")
|
||||
}
|
||||
|
||||
static var emptyStateMessage: String {
|
||||
pick(
|
||||
ko: "추천 오디오가 아직 없어요.",
|
||||
en: "No recommended audio yet.",
|
||||
ja: "おすすめオーディオはまだありません。"
|
||||
)
|
||||
}
|
||||
|
||||
static var loadFailedMessage: String {
|
||||
pick(
|
||||
ko: "추천 오디오를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.",
|
||||
en: "Unable to load recommended audio.\nPlease try again later.",
|
||||
ja: "おすすめオーディオを読み込めませんでした。\nしばらくしてからもう一度お試しください。"
|
||||
)
|
||||
}
|
||||
|
||||
static var latestAudioSectionTitle: String {
|
||||
pick(ko: "새로 올라온 오디오", en: "Latest audio", ja: "新着オーディオ")
|
||||
}
|
||||
|
||||
static var newAndHotSectionTitle: String {
|
||||
pick(ko: "New&Hot", en: "New&Hot", ja: "New&Hot")
|
||||
}
|
||||
|
||||
static var voiceOnOnlySectionTitle: String {
|
||||
pick(ko: "오직 보이스온에서만!", en: "Only on VoiceOn!", ja: "VoiceOnだけで!")
|
||||
}
|
||||
|
||||
static var freeAudioSectionTitle: String {
|
||||
pick(ko: "무료 오디오", en: "Free audio", ja: "無料オーディオ")
|
||||
}
|
||||
|
||||
static var pointAudioSectionTitle: String {
|
||||
pick(ko: "포인트 오디오", en: "Point audio", ja: "ポイントオーディオ")
|
||||
}
|
||||
|
||||
static var mostCommentedAudioSectionTitle: String {
|
||||
pick(ko: "크리에이터의 댓글 많은 오디오", en: "Most commented audio", ja: "最近コメントが多いオーディオ")
|
||||
}
|
||||
|
||||
static var recommendedAudioSectionTitle: String {
|
||||
pick(ko: "추천 오디오", en: "Recommended audio", ja: "おすすめオーディオ")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum MainContentRanking {
|
||||
static var tabTitle: String {
|
||||
pick(ko: "랭킹", en: "Ranking", ja: "ランキング")
|
||||
}
|
||||
|
||||
static var weeklyPopularTabTitle: String {
|
||||
pick(ko: "주간 인기", en: "Weekly popular", ja: "週間人気")
|
||||
}
|
||||
|
||||
static var risingTabTitle: String {
|
||||
pick(ko: "지금 뜨는 중", en: "Rising now", ja: "今話題")
|
||||
}
|
||||
|
||||
static var revenueTabTitle: String {
|
||||
pick(ko: "매출", en: "Revenue", ja: "売上")
|
||||
}
|
||||
|
||||
static var salesCountTabTitle: String {
|
||||
pick(ko: "판매량", en: "Sales", ja: "販売数")
|
||||
}
|
||||
|
||||
static var commentCountTabTitle: String {
|
||||
pick(ko: "댓글수", en: "Comments", ja: "コメント数")
|
||||
}
|
||||
|
||||
static var likeCountTabTitle: String {
|
||||
pick(ko: "좋아요", en: "Likes", ja: "いいね")
|
||||
}
|
||||
|
||||
static var emptyStateMessage: String {
|
||||
pick(
|
||||
ko: "랭킹 콘텐츠가 아직 없어요.",
|
||||
en: "No ranking content yet.",
|
||||
ja: "ランキングコンテンツはまだありません。"
|
||||
)
|
||||
}
|
||||
|
||||
static var loadFailedMessage: String {
|
||||
pick(
|
||||
ko: "랭킹 콘텐츠를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.",
|
||||
en: "Unable to load ranking content.\nPlease try again later.",
|
||||
ja: "ランキングコンテンツを読み込めませんでした。\nしばらくしてからもう一度お試しください。"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum MainContentAll {
|
||||
static var tabTitle: String {
|
||||
pick(ko: "전체", en: "All", ja: "すべて")
|
||||
}
|
||||
|
||||
static var rangeLabel: String {
|
||||
pick(ko: "전체", en: "All", ja: "すべて")
|
||||
}
|
||||
|
||||
static var emptyStateMessage: String {
|
||||
pick(
|
||||
ko: "콘텐츠가 아직 없어요.",
|
||||
en: "No content yet.",
|
||||
ja: "コンテンツはまだありません。"
|
||||
)
|
||||
}
|
||||
|
||||
static var loadFailedMessage: String {
|
||||
pick(
|
||||
ko: "콘텐츠를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.",
|
||||
en: "Unable to load content.\nPlease try again later.",
|
||||
ja: "コンテンツを読み込めませんでした。\nしばらくしてからもう一度お試しください。"
|
||||
)
|
||||
}
|
||||
|
||||
enum TypeChip {
|
||||
static var audio: String { pick(ko: "오디오", en: "Audio", ja: "オーディオ") }
|
||||
static var series: String { pick(ko: "시리즈", en: "Series", ja: "シリーズ") }
|
||||
static var original: String { pick(ko: "오리지널", en: "Original", ja: "オリジナル") }
|
||||
static var free: String { pick(ko: "무료", en: "Free", ja: "無料") }
|
||||
static var point: String { pick(ko: "포인트", en: "Point", ja: "ポイント") }
|
||||
}
|
||||
|
||||
enum DayOfWeek {
|
||||
static var mon: String { pick(ko: "월", en: "MON", ja: "月") }
|
||||
static var tue: String { pick(ko: "화", en: "TUE", ja: "火") }
|
||||
static var wed: String { pick(ko: "수", en: "WED", ja: "水") }
|
||||
static var thu: String { pick(ko: "목", en: "THU", ja: "木") }
|
||||
static var fri: String { pick(ko: "금", en: "FRI", ja: "金") }
|
||||
static var sat: String { pick(ko: "토", en: "SAT", ja: "土") }
|
||||
static var sun: String { pick(ko: "일", en: "SUN", ja: "日") }
|
||||
static var random: String { pick(ko: "기타", en: "OTHER", ja: "その他") }
|
||||
}
|
||||
}
|
||||
|
||||
enum Home {
|
||||
static var liveNowSectionTitle: String {
|
||||
pick(ko: "지금 라이브중", en: "Live now", ja: "ライブ配信中")
|
||||
@@ -3042,6 +3252,283 @@ If you block this user, the following features will be restricted.
|
||||
}
|
||||
}
|
||||
|
||||
enum HomeFollowing {
|
||||
static var emptyStateMessage: String {
|
||||
pick(
|
||||
ko: "아직 팔로잉 소식이 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.",
|
||||
en: "There are no following updates yet.\nFollow creators you're interested in.",
|
||||
ja: "まだフォロー中の新着情報がありません。\n気になるクリエイターをフォローしてみましょう。"
|
||||
)
|
||||
}
|
||||
|
||||
static var loadFailedMessage: String {
|
||||
pick(
|
||||
ko: "팔로잉 정보를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.",
|
||||
en: "Unable to load following updates.\nPlease try again later.",
|
||||
ja: "フォロー情報を読み込めませんでした。\nしばらくしてからもう一度お試しください。"
|
||||
)
|
||||
}
|
||||
|
||||
static var loginRequiredMessage: String {
|
||||
pick(
|
||||
ko: "로그인 후 팔로잉 소식을 확인할 수 있어요.",
|
||||
en: "Log in to see following updates.",
|
||||
ja: "ログインするとフォロー中の新着情報を確認できます。"
|
||||
)
|
||||
}
|
||||
|
||||
static var loginButtonTitle: String {
|
||||
pick(ko: "로그인", en: "Log in", ja: "ログイン")
|
||||
}
|
||||
|
||||
static var allButtonTitle: String {
|
||||
pick(ko: "전체", en: "All", ja: "すべて")
|
||||
}
|
||||
|
||||
static var tabTitle: String {
|
||||
pick(ko: "팔로잉", en: "Following", ja: "フォロー中")
|
||||
}
|
||||
|
||||
static var onAirTitle: String {
|
||||
pick(ko: "On Air", en: "On Air", ja: "On Air")
|
||||
}
|
||||
|
||||
static var liveTag: String {
|
||||
pick(ko: "LIVE", en: "LIVE", ja: "LIVE")
|
||||
}
|
||||
|
||||
static var recentChatTitle: String {
|
||||
pick(ko: "최근 대화", en: "Recent chats", ja: "最近のトーク")
|
||||
}
|
||||
|
||||
static var directTag: String {
|
||||
pick(ko: "Direct", en: "Direct", ja: "Direct")
|
||||
}
|
||||
|
||||
static var monthlyScheduleTitle: String {
|
||||
pick(ko: "이달의 스케줄", en: "This month's schedule", ja: "今月のスケジュール")
|
||||
}
|
||||
|
||||
static var today: String {
|
||||
pick(ko: "오늘", en: "Today", ja: "今日")
|
||||
}
|
||||
|
||||
static var onAirStatus: String {
|
||||
pick(ko: "On Air", en: "On Air", ja: "On Air")
|
||||
}
|
||||
|
||||
static var liveScheduleTag: String {
|
||||
pick(ko: "라이브", en: "Live", ja: "ライブ")
|
||||
}
|
||||
|
||||
static var liveReplayScheduleTag: String {
|
||||
pick(ko: "다시보기", en: "Replay", ja: "アーカイブ")
|
||||
}
|
||||
|
||||
static var audioScheduleTag: String {
|
||||
pick(ko: "오디오", en: "Audio", ja: "オーディオ")
|
||||
}
|
||||
|
||||
static var communityScheduleTag: String {
|
||||
pick(ko: "커뮤니티", en: "Community", ja: "コミュニティ")
|
||||
}
|
||||
|
||||
static var recentNewsTitle: String {
|
||||
pick(ko: "최근 소식", en: "Recent news", ja: "最近のニュース")
|
||||
}
|
||||
|
||||
static func rankingMessagePrefix(_ title: String) -> String {
|
||||
pick(
|
||||
ko: "\(title)님이 이번 주 랭킹 ",
|
||||
en: "\(title) reached ",
|
||||
ja: "\(title)さんが今週のランキング"
|
||||
)
|
||||
}
|
||||
|
||||
static func rankingRankText(_ rank: Int) -> String {
|
||||
pick(ko: "\(rank)위", en: "No. \(rank)", ja: "\(rank)位")
|
||||
}
|
||||
|
||||
static var rankingMessageSuffix: String {
|
||||
pick(ko: "에 올랐어요!", en: " in this week's ranking!", ja: "に入りました!")
|
||||
}
|
||||
|
||||
static var audioTag: String {
|
||||
pick(ko: "오디오", en: "Audio", ja: "オーディオ")
|
||||
}
|
||||
|
||||
static var photoTag: String {
|
||||
pick(ko: "화보", en: "Photo", ja: "フォト")
|
||||
}
|
||||
}
|
||||
|
||||
enum HomeRanking {
|
||||
static var emptyStateMessage: String {
|
||||
pick(
|
||||
ko: "순위 집계 중입니다.\n잠시 후 다시 시도해 주세요.",
|
||||
en: "Rankings are being calculated.\nPlease try again later.",
|
||||
ja: "ランキングを集計中です。\nしばらくしてからもう一度お試しください。"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum CreatorChannelLive {
|
||||
static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") }
|
||||
static var startLive: String { pick(ko: "라이브 시작하기", en: "Start live", ja: "ライブを開始") }
|
||||
static var emptyMessage: String {
|
||||
pick(
|
||||
ko: "크리에이터가 라이브를 준비중입니다.\n기대해 주세요!",
|
||||
en: "The creator is preparing a live.\nPlease stay tuned!",
|
||||
ja: "クリエイターがライブを準備中です。\nお楽しみに!"
|
||||
)
|
||||
}
|
||||
|
||||
enum Sort {
|
||||
static var latest: String { pick(ko: "최신순", en: "Latest", ja: "最新順") }
|
||||
static var popular: String { pick(ko: "인기순", en: "Popular", ja: "人気順") }
|
||||
static var priceHigh: String { pick(ko: "높은 가격순", en: "Highest price", ja: "価格が高い順") }
|
||||
static var priceLow: String { pick(ko: "낮은 가격순", en: "Lowest price", ja: "価格が低い順") }
|
||||
}
|
||||
}
|
||||
|
||||
enum CreatorChannelAudio {
|
||||
static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") }
|
||||
static var countUnit: String { pick(ko: "개", en: "", ja: "件") }
|
||||
static var ownershipPrefix: String {
|
||||
pick(ko: "전체 오디오의 ", en: "You own ", ja: "全オーディオの")
|
||||
}
|
||||
static var ownershipSuffix: String {
|
||||
pick(ko: "를 소장하고 있어요.", en: " of all audio.", ja: "を保有中です。")
|
||||
}
|
||||
static var emptyMessage: String {
|
||||
pick(
|
||||
ko: "크리에이터가 오디오를 준비 중입니다.\n기대해 주세요!",
|
||||
en: "The creator is preparing audio.\nPlease stay tuned!",
|
||||
ja: "クリエイターがオーディオを準備中です。\nお楽しみに!"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum CreatorChannelSeries {
|
||||
static var contentCountUnit: String { pick(ko: "화", en: " episodes", ja: "話") }
|
||||
static var proceeding: String { pick(ko: "연재", en: "Ongoing", ja: "連載") }
|
||||
static var completed: String { pick(ko: "완결", en: "Completed", ja: "完結") }
|
||||
static var emptyMessage: String {
|
||||
pick(
|
||||
ko: "크리에이터가 시리즈를 준비 중입니다.\n기대해 주세요!",
|
||||
en: "The creator is preparing series.\nPlease stay tuned!",
|
||||
ja: "クリエイターがシリーズを準備中です。\nお楽しみに!"
|
||||
)
|
||||
}
|
||||
|
||||
static func totalContentCount(_ count: Int) -> String {
|
||||
pick(ko: "총 \(count)화", en: "Total \(count) episodes", ja: "全\(count)話")
|
||||
}
|
||||
}
|
||||
|
||||
enum CreatorChannelHome {
|
||||
static var currentLive: String { pick(ko: "현재 라이브", en: "Live now", ja: "現在ライブ") }
|
||||
static var latestAudio: String { pick(ko: "최신 오디오 콘텐츠", en: "Latest audio", ja: "最新オーディオ") }
|
||||
static var donation: String { pick(ko: "후원", en: "Donations", ja: "応援") }
|
||||
static var notice: String { pick(ko: "공지", en: "Notice", ja: "お知らせ") }
|
||||
static var schedule: String { pick(ko: "스케줄", en: "Schedule", ja: "スケジュール") }
|
||||
static var audio: String { pick(ko: "오디오", en: "Audio", ja: "オーディオ") }
|
||||
static var series: String { pick(ko: "시리즈", en: "Series", ja: "シリーズ") }
|
||||
static var community: String { pick(ko: "커뮤니티", en: "Community", ja: "コミュニティ") }
|
||||
static var fanTalk: String { pick(ko: "팬Talk", en: "Fan Talk", ja: "ファンTalk") }
|
||||
static var introduce: String { pick(ko: "소개", en: "Introduction", ja: "紹介") }
|
||||
static var activity: String { pick(ko: "활동", en: "Activity", ja: "活動") }
|
||||
static var sns: String { pick(ko: "SNS", en: "SNS", ja: "SNS") }
|
||||
static var follow: String { pick(ko: "팔로우", en: "Follow", ja: "フォロー") }
|
||||
static var following: String { pick(ko: "팔로잉", en: "Following", ja: "フォロー中") }
|
||||
static var talk: String { pick(ko: "대화하기", en: "Start chat", ja: "会話する") }
|
||||
static var dm: String { pick(ko: "DM 보내기", en: "Send DM", ja: "DMを送る") }
|
||||
static var checkDm: String { pick(ko: "DM 확인하기", en: "Check DM", ja: "DMを確認") }
|
||||
static var uploadCommunityPost: String { pick(ko: "커뮤니티 글 올리기", en: "Upload community post", ja: "コミュニティ投稿を作成") }
|
||||
static var uploadAudioContent: String { pick(ko: "오디오 콘텐츠 올리기", en: "Upload audio content", ja: "オーディオコンテンツを投稿") }
|
||||
static var createLive: String { pick(ko: "라이브 만들기", en: "Create live", ja: "ライブを作成") }
|
||||
static var closeFloatingMenu: String { pick(ko: "닫기", en: "Close", ja: "閉じる") }
|
||||
static var viewAll: String { pick(ko: "전체보기", en: "View all", ja: "すべて見る") }
|
||||
static var debut: String { pick(ko: "데뷔", en: "Debut", ja: "デビュー") }
|
||||
static var liveCount: String { pick(ko: "라이브 총 진행 수", en: "Total live count", ja: "ライブ総配信数") }
|
||||
static var liveDurationHours: String { pick(ko: "라이브 누적 진행 시간", en: "Total live hours", ja: "ライブ累計配信時間") }
|
||||
static var liveContributorCount: String { pick(ko: "라이브 누적 참여자", en: "Total live participants", ja: "ライブ累計参加者") }
|
||||
static var audioContentCount: String { pick(ko: "오디오", en: "Audio", ja: "オーディオ") }
|
||||
static var seriesCount: String { pick(ko: "시리즈", en: "Series", ja: "シリーズ") }
|
||||
static var placeholderMessage: String {
|
||||
pick(
|
||||
ko: "준비 중입니다.",
|
||||
en: "Coming soon.",
|
||||
ja: "準備中です。"
|
||||
)
|
||||
}
|
||||
|
||||
static func followerCount(_ count: String) -> String {
|
||||
pick(ko: "팔로워 \(count)명", en: "\(count) followers", ja: "フォロワー\(count)人")
|
||||
}
|
||||
|
||||
static func liveCountValue(_ count: String) -> String {
|
||||
pick(ko: "\(count)회", en: "\(count)", ja: "\(count)回")
|
||||
}
|
||||
|
||||
static func liveDurationHoursValue(_ count: String) -> String {
|
||||
pick(ko: "\(count)시간", en: "\(count) hours", ja: "\(count)時間")
|
||||
}
|
||||
|
||||
static func liveContributorCountValue(_ count: String) -> String {
|
||||
pick(ko: "\(count)명", en: "\(count) people", ja: "\(count)人")
|
||||
}
|
||||
|
||||
static func audioContentCountValue(_ count: String) -> String {
|
||||
pick(ko: "\(count)개", en: "\(count)", ja: "\(count)件")
|
||||
}
|
||||
|
||||
static func seriesCountValue(_ count: String) -> String {
|
||||
pick(ko: "\(count)개", en: "\(count)", ja: "\(count)件")
|
||||
}
|
||||
}
|
||||
|
||||
enum CreatorChannelCommunity {
|
||||
static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") }
|
||||
static var listView: String { pick(ko: "리스트형", en: "List", ja: "リスト型") }
|
||||
static var thumbnailView: String { pick(ko: "썸네일형", en: "Thumbnail", ja: "サムネイル型") }
|
||||
static var commentPlaceholder: String { pick(ko: "댓글을 입력하세요", en: "Enter a comment", ja: "コメントを入力してください") }
|
||||
static var communityEmpty: String {
|
||||
pick(
|
||||
ko: "크리에이터가 커뮤니티를 준비 중입니다.\n기대해 주세요!",
|
||||
en: "The creator is preparing the community.\nPlease look forward to it!",
|
||||
ja: "クリエイターがコミュニティを準備中です。\nお楽しみに!"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enum CreatorChannelReplyDetail {
|
||||
static var replyPlaceholder: String {
|
||||
pick(ko: "답글을 입력하세요", en: "Enter a reply", ja: "返信を入力してください")
|
||||
}
|
||||
|
||||
static var replaceReplyTitle: String {
|
||||
pick(ko: "답글을 수정할까요?", en: "Replace your reply?", ja: "返信を修正しますか?")
|
||||
}
|
||||
|
||||
static var replaceReplyMessage: String {
|
||||
pick(ko: "이미 작성한 답글이 있어요. 확인을 누르면 기존 답글이 새 내용으로 수정됩니다.", en: "You already have a reply. If you continue, the existing reply will be updated with the new content.", ja: "すでに返信があります。確認すると既存の返信が新しい内容に修正されます。")
|
||||
}
|
||||
|
||||
static var replaceReplyConfirm: String {
|
||||
pick(ko: "수정하기", en: "Update", ja: "修正する")
|
||||
}
|
||||
}
|
||||
|
||||
enum CreatorChannelFanTalk {
|
||||
static var send: String { pick(ko: "보내기", en: "Send", ja: "送信") }
|
||||
static var exitModalTitle: String { pick(ko: "입력 종료", en: "Discard input", ja: "入力終了") }
|
||||
static var exitModalMessage: String { pick(ko: "입력을 종료할까요?\n지금 나가면 입력한 내용이 저장되지 않아요.", en: "Discard your input?\nIf you leave now, your input will not be saved.", ja: "入力を終了しますか?\n今退出すると入力した内容は保存されません。") }
|
||||
static var exitModalExit: String { pick(ko: "종료", en: "Discard", ja: "終了") }
|
||||
static var exitModalContinue: String { pick(ko: "계속 작성", en: "Keep writing", ja: "続けて書く") }
|
||||
}
|
||||
|
||||
enum HomeRecommendation {
|
||||
static var activityLive: String {
|
||||
pick(ko: "라이브", en: "Live", ja: "ライブ")
|
||||
@@ -3059,6 +3546,10 @@ If you block this user, the following features will be restricted.
|
||||
pick(ko: "현재 라이브", en: "Live now", ja: "現在ライブ")
|
||||
}
|
||||
|
||||
static var liveAll: String {
|
||||
pick(ko: "전체", en: "All", ja: "すべて")
|
||||
}
|
||||
|
||||
static var activeCreatorSectionTitle: String {
|
||||
pick(ko: "방금 활동한 크리에이터", en: "Recently active creators", ja: "最近活動したクリエイター")
|
||||
}
|
||||
@@ -3146,6 +3637,18 @@ If you block this user, the following features will be restricted.
|
||||
pick(ko: "모두 팔로우 완료", en: "All followed", ja: "すべてフォロー済み")
|
||||
}
|
||||
|
||||
static var followAllConfirmTitle: String {
|
||||
pick(ko: "모두 팔로우할까요?", en: "Follow all creators?", ja: "すべてのクリエイターをフォローしますか?")
|
||||
}
|
||||
|
||||
static var followAllConfirmDescription: String {
|
||||
pick(ko: "추천된 크리에이터를 모두 팔로우합니다.", en: "You’ll follow all recommended creators.", ja: "おすすめのクリエイターをすべてフォローします。")
|
||||
}
|
||||
|
||||
static var followAllConfirmAction: String {
|
||||
pick(ko: "팔로우 하기", en: "Follow", ja: "フォローする")
|
||||
}
|
||||
|
||||
static var more: String {
|
||||
pick(ko: "더보기", en: "More", ja: "もっと見る")
|
||||
}
|
||||
@@ -3204,6 +3707,14 @@ If you block this user, the following features will be restricted.
|
||||
pick(ko: "수정", en: "Edit", ja: "編集")
|
||||
}
|
||||
|
||||
static var editAction: String {
|
||||
pick(ko: "수정하기", en: "Edit", ja: "編集する")
|
||||
}
|
||||
|
||||
static var deleteAction: String {
|
||||
pick(ko: "삭제하기", en: "Delete", ja: "削除する")
|
||||
}
|
||||
|
||||
static var report: String {
|
||||
pick(ko: "신고", en: "Report", ja: "通報")
|
||||
}
|
||||
|
||||
15
SodaLive/Sources/ImagePicker/ImageCropGeometry.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
import CoreGraphics
|
||||
|
||||
enum ImageCropGeometry {
|
||||
static func maximumCropSize(fitting imageSize: CGSize, aspectRatio: CGFloat) -> CGSize {
|
||||
guard imageSize.width > 0, imageSize.height > 0, aspectRatio > 0 else {
|
||||
return .zero
|
||||
}
|
||||
|
||||
if imageSize.width / imageSize.height > aspectRatio {
|
||||
return CGSize(width: imageSize.height * aspectRatio, height: imageSize.height)
|
||||
}
|
||||
|
||||
return CGSize(width: imageSize.width, height: imageSize.width / aspectRatio)
|
||||
}
|
||||
}
|
||||
@@ -274,27 +274,35 @@ struct ImageCropEditorView: View {
|
||||
private func cropSize(in canvas: CGSize) -> CGSize {
|
||||
switch aspectPolicy {
|
||||
case .square:
|
||||
let side = max(120, min(canvas.width, canvas.height) * 0.72)
|
||||
return CGSize(width: side, height: side)
|
||||
let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvas)
|
||||
return ImageCropGeometry.maximumCropSize(fitting: fittedSize, aspectRatio: 1)
|
||||
case .free:
|
||||
if freeCropSize == .zero {
|
||||
return defaultFreeCropSize(in: canvas)
|
||||
}
|
||||
|
||||
let minCropSize: CGFloat = 120
|
||||
let maxCropWidth = max(minCropSize, canvas.width - 24)
|
||||
let maxCropHeight = max(minCropSize, canvas.height - 24)
|
||||
let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvas)
|
||||
let maxCropWidth = fittedSize.width
|
||||
let maxCropHeight = fittedSize.height
|
||||
let minCropWidth = min(120, maxCropWidth)
|
||||
let minCropHeight = min(120, maxCropHeight)
|
||||
return CGSize(
|
||||
width: freeCropSize.width.clamped(min: minCropSize, max: maxCropWidth),
|
||||
height: freeCropSize.height.clamped(min: minCropSize, max: maxCropHeight)
|
||||
width: freeCropSize.width.clamped(min: minCropWidth, max: maxCropWidth),
|
||||
height: freeCropSize.height.clamped(min: minCropHeight, max: maxCropHeight)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func defaultFreeCropSize(in canvas: CGSize) -> CGSize {
|
||||
let width = max(120, min(canvas.width * 0.82, canvas.width - 24))
|
||||
let height = max(120, min(canvas.height * 0.58, canvas.height - 24))
|
||||
return CGSize(width: width, height: height)
|
||||
let preferredSize = CGSize(
|
||||
width: max(120, min(canvas.width * 0.82, canvas.width - 24)),
|
||||
height: max(120, min(canvas.height * 0.58, canvas.height - 24))
|
||||
)
|
||||
let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvas)
|
||||
return ImageCropGeometry.maximumCropSize(
|
||||
fitting: fittedSize,
|
||||
aspectRatio: preferredSize.width / preferredSize.height
|
||||
)
|
||||
}
|
||||
|
||||
private func handlePosition(handle: CropHandle, cropSize: CGSize, canvasSize: CGSize) -> CGPoint {
|
||||
@@ -318,14 +326,16 @@ struct ImageCropEditorView: View {
|
||||
lastFreeCropSize = currentCropSize
|
||||
}
|
||||
|
||||
let minCropSize: CGFloat = 120
|
||||
let maxCropWidth = max(minCropSize, canvasSize.width - 24)
|
||||
let maxCropHeight = max(minCropSize, canvasSize.height - 24)
|
||||
let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvasSize)
|
||||
let maxCropWidth = fittedSize.width
|
||||
let maxCropHeight = fittedSize.height
|
||||
let minCropWidth = min(120, maxCropWidth)
|
||||
let minCropHeight = min(120, maxCropHeight)
|
||||
|
||||
let width = (lastFreeCropSize.width + (translation.width * handle.xDirection))
|
||||
.clamped(min: minCropSize, max: maxCropWidth)
|
||||
.clamped(min: minCropWidth, max: maxCropWidth)
|
||||
let height = (lastFreeCropSize.height + (translation.height * handle.yDirection))
|
||||
.clamped(min: minCropSize, max: maxCropHeight)
|
||||
.clamped(min: minCropHeight, max: maxCropHeight)
|
||||
|
||||
freeCropSize = CGSize(width: width, height: height)
|
||||
adjustTransforms(canvasSize: canvasSize, cropSize: freeCropSize)
|
||||
|
||||
@@ -60,13 +60,6 @@ struct LiveView: View {
|
||||
LazyVStack(spacing: 48) {
|
||||
SectionLiveNowView(
|
||||
items: viewModel.liveNowItems,
|
||||
onClickParticipant: {
|
||||
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
viewModel.enterLiveRoom(roomId: $0)
|
||||
} else {
|
||||
AppState.shared.setAppStep(step: .login)
|
||||
}
|
||||
},
|
||||
onTapItem: { item in
|
||||
onTapLiveNowItem(item.roomId, item.isAdult)
|
||||
},
|
||||
|
||||
@@ -144,7 +144,6 @@ final class LiveViewModel: ObservableObject {
|
||||
} else {
|
||||
if roomId > 0 {
|
||||
AppState.shared.isShowPlayer = true
|
||||
AppState.shared.setAppStep(step: .main)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -14,117 +14,92 @@ struct LiveNowAllItemView: View {
|
||||
let itemWidth: CGFloat
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ZStack {
|
||||
KFImage(URL(string: item.coverImageUrl))
|
||||
.cancelOnDisappear(true)
|
||||
.downsampling(
|
||||
size: CGSize(
|
||||
width: itemWidth,
|
||||
height: itemWidth * 144 / 102
|
||||
)
|
||||
)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: itemWidth, height: itemWidth * 144 / 102, alignment: .center)
|
||||
.cornerRadius(4.7)
|
||||
.clipped()
|
||||
|
||||
LinearGradient(
|
||||
colors: [Color.black.opacity(0.1), Color.black.opacity(0.8)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
|
||||
VStack(alignment: .trailing, spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
HStack(spacing: 1) {
|
||||
if item.price > 0 {
|
||||
Image("ic_can_white")
|
||||
}
|
||||
|
||||
Text(item.price > 0 ? "\(item.price)" : I18n.CreateContent.free)
|
||||
.appFont(size: 12, weight: .medium)
|
||||
.foregroundColor(Color.white)
|
||||
}
|
||||
.padding(.horizontal, 7.3)
|
||||
.padding(.vertical, 4)
|
||||
.background(item.price > 0 ? Color.mainRed3 : Color.gray11)
|
||||
.cornerRadius(13.3)
|
||||
|
||||
Spacer()
|
||||
|
||||
if item.isPrivateRoom {
|
||||
Image("ic_lock")
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20)
|
||||
.padding(2.7)
|
||||
.background(Color.gray33.opacity(0.7))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 3.3)
|
||||
.padding(.top, 3.3)
|
||||
|
||||
Spacer()
|
||||
|
||||
if item.numberOfPeople - item.numberOfParticipate < 3 {
|
||||
HStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 2) {
|
||||
Text(I18n.LiveNow.remaining)
|
||||
.appFont(size: 12, weight: .medium)
|
||||
.foregroundColor(Color.grayee)
|
||||
|
||||
Text("\(item.numberOfPeople - item.numberOfParticipate)")
|
||||
.appFont(size: 12, weight: .medium)
|
||||
.foregroundColor(Color.button)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.gray33.opacity(0.7))
|
||||
.cornerRadius(13.3)
|
||||
}
|
||||
.padding(.horizontal, 3.3)
|
||||
.padding(.bottom, 3.3)
|
||||
}
|
||||
HStack(alignment: .center, spacing: 14) {
|
||||
KFImage(URL(string: item.creatorProfileImage))
|
||||
.cancelOnDisappear(true)
|
||||
.downsampling(size: CGSize(width: 75, height: 75))
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 75, height: 75)
|
||||
.clipShape(Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
liveTag
|
||||
|
||||
Text(startTimeText)
|
||||
.appFont(size: 14, weight: .regular)
|
||||
.foregroundColor(Color.gray500)
|
||||
}
|
||||
|
||||
Text(item.title)
|
||||
.appFont(size: 18, weight: .bold)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
|
||||
Text(item.creatorNickname)
|
||||
.appFont(size: 14, weight: .regular)
|
||||
.foregroundColor(Color.gray500)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.frame(width: itemWidth, height: itemWidth * 144 / 102)
|
||||
|
||||
Text("\(item.title)")
|
||||
.appFont(size: 12, weight: .medium)
|
||||
.foregroundColor(Color(hex: "eeeeee"))
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if item.tags.count > 0 {
|
||||
Text("\(item.tags.map { "#\($0)" }.joined(separator: " "))")
|
||||
.appFont(size: 11, weight: .medium)
|
||||
.foregroundColor(Color.button)
|
||||
}
|
||||
|
||||
HStack(spacing: 5.3) {
|
||||
KFImage(URL(string: item.creatorProfileImage))
|
||||
.cancelOnDisappear(true)
|
||||
.downsampling(
|
||||
size: CGSize(
|
||||
width: 21.3,
|
||||
height: 21.3
|
||||
)
|
||||
)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 21.3, height: 21.3, alignment: .center)
|
||||
.clipShape(Circle())
|
||||
|
||||
Text("\(item.creatorNickname)")
|
||||
.appFont(size: 10, weight: .medium)
|
||||
.foregroundColor(Color.gray77)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
priceView
|
||||
}
|
||||
.frame(width: itemWidth)
|
||||
.frame(width: itemWidth, height: 99)
|
||||
}
|
||||
|
||||
private var liveTag: some View {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(Color.red400)
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
Text("LIVE")
|
||||
.appFont(size: 12, weight: .regular)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 2)
|
||||
.frame(height: 18)
|
||||
.background(Color.black)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var priceView: some View {
|
||||
if item.price > 0 {
|
||||
HStack(spacing: 2) {
|
||||
Image("ic_bar_cash")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 18, height: 18)
|
||||
|
||||
Text("\(item.price)")
|
||||
.appFont(size: 14, weight: .regular)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.trailing, 7)
|
||||
.frame(width: 60, alignment: .trailing)
|
||||
} else {
|
||||
Text(I18n.CreateContent.free)
|
||||
.appFont(size: 14, weight: .regular)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
.padding(.trailing, 7)
|
||||
.frame(width: 60, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
private var startTimeText: String {
|
||||
guard let date = DateParser.parse(item.beginDateTimeUtc) else {
|
||||
return item.beginDateTimeUtc
|
||||
}
|
||||
|
||||
return date.convertDateFormat(dateFormat: "HH:mm")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import BootpayUI
|
||||
struct LiveNowAllView: View {
|
||||
|
||||
@StateObject var viewModel = LiveViewModel()
|
||||
@StateObject var liveAllViewModel = LiveAllViewModel()
|
||||
@StateObject var mypageViewModel = MyPageViewModel()
|
||||
|
||||
let spacing: CGFloat = 16
|
||||
@@ -25,15 +24,6 @@ struct LiveNowAllView: View {
|
||||
@State private var isShowAuthConfirmView = false
|
||||
@State private var pendingAction: (() -> Void)? = nil
|
||||
@State private var payload = Payload()
|
||||
|
||||
var columns: [GridItem] {
|
||||
[
|
||||
GridItem(.flexible(), spacing: spacing, alignment: .top),
|
||||
GridItem(.flexible(), spacing: spacing, alignment: .top)
|
||||
]
|
||||
}
|
||||
|
||||
let onClickParticipant: (Int) -> Void
|
||||
|
||||
var body: some View {
|
||||
BaseView(isLoading: $viewModel.isLoading) {
|
||||
@@ -50,13 +40,13 @@ struct LiveNowAllView: View {
|
||||
viewModel.getLiveNowList()
|
||||
},
|
||||
content: {
|
||||
let itemWidth = (proxy.size.width - (spacing * 3)) / 2
|
||||
let itemWidth = proxy.size.width - (spacing * 2)
|
||||
|
||||
LazyVGrid(columns: columns, spacing: spacing) {
|
||||
LazyVStack(spacing: spacing) {
|
||||
ForEach(0..<viewModel.liveNowItems.count, id: \.self) { index in
|
||||
let item = viewModel.liveNowItems[index]
|
||||
|
||||
LiveNowItemView(item: item, itemWidth: itemWidth)
|
||||
LiveNowAllItemView(item: item, itemWidth: itemWidth)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
handleLiveNowItemTap(
|
||||
@@ -85,24 +75,6 @@ struct LiveNowAllView: View {
|
||||
.edgesIgnoringSafeArea(.bottom)
|
||||
}
|
||||
|
||||
if liveAllViewModel.isShowLiveDetail {
|
||||
LiveDetailView(
|
||||
roomId: liveAllViewModel.selectedRoomId,
|
||||
onClickParticipant: {
|
||||
AppState.shared.isShowPlayer = false
|
||||
onClickParticipant(liveAllViewModel.selectedRoomId)
|
||||
},
|
||||
onClickReservation: {},
|
||||
onClickStart: {},
|
||||
onClickCancel: {},
|
||||
onClickClose: {
|
||||
withAnimation {
|
||||
liveAllViewModel.isShowLiveDetail = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if isShowAuthConfirmView {
|
||||
SodaDialog(
|
||||
title: I18n.Main.Auth.dialogTitle,
|
||||
@@ -120,6 +92,28 @@ struct LiveNowAllView: View {
|
||||
textAlignment: .center
|
||||
)
|
||||
}
|
||||
|
||||
if viewModel.isShowPaymentDialog {
|
||||
LivePaymentDialog(
|
||||
title: viewModel.paymentDialogTitle,
|
||||
desc: viewModel.paymentDialogDesc,
|
||||
desc2: viewModel.paymentDialogDesc2,
|
||||
confirmButtonTitle: viewModel.paymentDialogConfirmTitle,
|
||||
confirmButtonAction: viewModel.paymentDialogConfirmAction,
|
||||
cancelButtonTitle: viewModel.paymentDialogCancelTitle,
|
||||
cancelButtonAction: viewModel.hidePopup,
|
||||
startDateTime: viewModel.liveStartDate,
|
||||
nowDateTime: viewModel.nowDate
|
||||
)
|
||||
}
|
||||
|
||||
if viewModel.isShowPasswordDialog {
|
||||
LiveRoomPasswordDialog(
|
||||
isShowing: $viewModel.isShowPasswordDialog,
|
||||
can: viewModel.secretOrPasswordDialogCan,
|
||||
confirmAction: viewModel.passwordDialogConfirmAction
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
@@ -160,6 +154,7 @@ struct LiveNowAllView: View {
|
||||
isShowAuthView = false
|
||||
}
|
||||
}
|
||||
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
|
||||
}
|
||||
|
||||
private func handleLiveNowItemTap(roomId: Int, isAdult: Bool) {
|
||||
@@ -177,7 +172,7 @@ struct LiveNowAllView: View {
|
||||
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
|
||||
|
||||
if isKoreanCountry && auth == false {
|
||||
pendingAction = { openLiveDetail(roomId: roomId) }
|
||||
pendingAction = { handleLiveNowItemTap(roomId: roomId, isAdult: isAdult) }
|
||||
isShowAuthConfirmView = true
|
||||
return
|
||||
}
|
||||
@@ -191,13 +186,11 @@ struct LiveNowAllView: View {
|
||||
}
|
||||
}
|
||||
|
||||
openLiveDetail(roomId: roomId)
|
||||
enterLiveRoom(roomId: roomId)
|
||||
}
|
||||
|
||||
private func openLiveDetail(roomId: Int) {
|
||||
liveAllViewModel.selectedRoomId = roomId
|
||||
withAnimation {
|
||||
liveAllViewModel.isShowLiveDetail = true
|
||||
}
|
||||
private func enterLiveRoom(roomId: Int) {
|
||||
AppState.shared.isShowPlayer = false
|
||||
viewModel.enterLiveRoom(roomId: roomId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ struct SectionLiveNowView: View {
|
||||
|
||||
let items: [GetRoomListResponse]
|
||||
|
||||
let onClickParticipant: (Int) -> Void
|
||||
let onTapItem: (GetRoomListResponse) -> Void
|
||||
let onTapCreateLive: () -> Void
|
||||
let onClickRefresh: () -> Void
|
||||
@@ -29,7 +28,7 @@ struct SectionLiveNowView: View {
|
||||
Text(I18n.Common.viewAll)
|
||||
.appFont(size: 14, weight: .regular)
|
||||
.foregroundColor(Color(hex: "78909C"))
|
||||
.onTapGesture { AppState.shared.setAppStep(step: .liveNowAll(onClickParticipant: onClickParticipant)) }
|
||||
.onTapGesture { AppState.shared.setAppStep(step: .liveNowAll) }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
@@ -95,7 +94,6 @@ struct SectionLiveNowView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SectionLiveNowView(
|
||||
items: [],
|
||||
onClickParticipant: { _ in },
|
||||
onTapItem: { _ in },
|
||||
onTapCreateLive: {},
|
||||
onClickRefresh: {}
|
||||
|
||||
@@ -272,6 +272,10 @@ struct LiveDetailView: View {
|
||||
.onAppear {
|
||||
viewModel.getDetail(roomId: roomId)
|
||||
}
|
||||
.onChange(of: viewModel.isShowPopup) { isShowing in
|
||||
guard isShowing, viewModel.room == nil else { return }
|
||||
handleDetailLoadFailure()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -473,6 +477,18 @@ struct LiveDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDetailLoadFailure() {
|
||||
let message = viewModel.errorMessage
|
||||
viewModel.isShowPopup = false
|
||||
AppState.shared.isShowErrorPopup = false
|
||||
hideView()
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
AppState.shared.errorMessage = message
|
||||
AppState.shared.isShowErrorPopup = true
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSnsItems(from manager: GetRoomDetailManager) -> [LiveDetailSnsItem] {
|
||||
var items = [LiveDetailSnsItem]()
|
||||
|
||||
|
||||
@@ -462,7 +462,6 @@ struct LiveRoomViewV2: View {
|
||||
}
|
||||
)
|
||||
.padding(.top, isV2VCaptionVisible ? -13.3 : 0)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
|
||||
if viewModel.isShowingNewChat {
|
||||
@@ -1004,7 +1003,6 @@ struct LiveRoomViewV2: View {
|
||||
viewModel.setMute(true)
|
||||
shouldRestoreMicMuteAfterCapture = true
|
||||
}
|
||||
.ignoresSafeArea(.keyboard)
|
||||
.edgesIgnoringSafeArea(appliedKeyboardHeight > 0 ? .bottom : .init())
|
||||
.sheet(
|
||||
isPresented: $viewModel.isShowShareView,
|
||||
@@ -1113,6 +1111,8 @@ struct LiveRoomViewV2: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
private func estimatedHeight(for text: String, width: CGFloat) -> CGFloat {
|
||||
@@ -1198,6 +1198,7 @@ private final class ScreenCaptureSecureHostingController<Content: View>: UIViewC
|
||||
hostingController = UIHostingController(rootView: rootView)
|
||||
self.isSecureModeEnabled = isSecureModeEnabled
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
hostingController.safeAreaRegions = .container
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
|
||||
@@ -214,6 +214,7 @@ struct SettingsView: View {
|
||||
ContentPlayManager.shared.stopAudio()
|
||||
ContentPlayerPlayManager.shared.resetPlayer()
|
||||
viewModel.logout {
|
||||
NotificationCenter.default.post(name: .userCreatorChatSessionClose, object: nil)
|
||||
self.isShowLogoutDialog = false
|
||||
|
||||
UserDefaults.reset()
|
||||
@@ -239,6 +240,7 @@ struct SettingsView: View {
|
||||
ContentPlayManager.shared.stopAudio()
|
||||
ContentPlayerPlayManager.shared.resetPlayer()
|
||||
viewModel.logoutAllDevice {
|
||||
NotificationCenter.default.post(name: .userCreatorChatSessionClose, object: nil)
|
||||
self.isShowLogoutAllDeviceDialog = false
|
||||
UserDefaults.reset()
|
||||
AppState.shared.isRestartApp = true
|
||||
|
||||
@@ -44,6 +44,7 @@ final class SignOutViewModel: ObservableObject {
|
||||
let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData)
|
||||
|
||||
if decoded.success {
|
||||
NotificationCenter.default.post(name: .userCreatorChatSessionClose, object: nil)
|
||||
UserDefaults.reset()
|
||||
AppState.shared.isRestartApp = true
|
||||
AppState.shared.setAppStep(step: .splash)
|
||||
|
||||
@@ -17,10 +17,8 @@ struct SplashView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
Image("splash_bg")
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
Color.black
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
@@ -31,13 +29,9 @@ struct SplashView: View {
|
||||
|
||||
Image("splash_text_logo")
|
||||
.padding(.top, 10)
|
||||
|
||||
Text(I18n.Splash.brandName)
|
||||
.appFont(size: 21, weight: .bold)
|
||||
.foregroundColor(.white)
|
||||
.padding(.top, 52)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.bottom, screenSize().height * 100 / 2337)
|
||||
|
||||
if isShowMaintenancePopup {
|
||||
SodaDialog(
|
||||
@@ -86,6 +80,8 @@ struct SplashView: View {
|
||||
}
|
||||
|
||||
private func fetchLastestVersion() {
|
||||
guard !isRunningForPreviews else { return }
|
||||
|
||||
let remoteConfig = RemoteConfig.remoteConfig()
|
||||
let configSettings = RemoteConfigSettings()
|
||||
configSettings.minimumFetchInterval = 60
|
||||
@@ -120,7 +116,10 @@ struct SplashView: View {
|
||||
|
||||
if !UserDefaults.string(forKey: UserDefaultsKey.token).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if AppState.shared.pushRoomId > 0 {
|
||||
liveViewModel.enterLiveRoom(roomId: AppState.shared.pushRoomId)
|
||||
let roomId = AppState.shared.pushRoomId
|
||||
AppState.shared.pushRoomId = 0
|
||||
AppState.shared.setAppStep(step: .main)
|
||||
liveViewModel.enterLiveRoom(roomId: roomId)
|
||||
} else if AppState.shared.pushChannelId > 0 {
|
||||
AppState.shared.setAppStep(step: .creatorDetail(userId: AppState.shared.pushChannelId))
|
||||
} else if AppState.shared.pushAudioContentId > 0 {
|
||||
@@ -163,6 +162,10 @@ struct SplashView: View {
|
||||
nextAppStep()
|
||||
}
|
||||
}
|
||||
|
||||
private var isRunningForPreviews: Bool {
|
||||
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,10 @@ struct BannerCarousel: View {
|
||||
@GestureState private var dragOffset: CGFloat = 0
|
||||
@State private var autoScrollTimer: Timer?
|
||||
@State private var isSwiping = false
|
||||
@State private var itemsRevision = 0
|
||||
|
||||
init(
|
||||
items: [BannerCarouselItem],
|
||||
height: CGFloat = 120,
|
||||
action: @escaping (BannerCarouselItem) -> Void = { _ in }
|
||||
) {
|
||||
self.items = Array(items.prefix(BannerCarousel.maxItemCount))
|
||||
@@ -71,6 +71,7 @@ struct BannerCarousel: View {
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(dragGesture(step: step))
|
||||
.onChange(of: items) { _ in
|
||||
itemsRevision += 1
|
||||
displayIndex = items.count > 1 ? 1 : 0
|
||||
restartAutoScrollTimer()
|
||||
}
|
||||
@@ -180,8 +181,15 @@ struct BannerCarousel: View {
|
||||
private func wrapIfNeeded() {
|
||||
guard items.count > 1 else { return }
|
||||
|
||||
displayIndex = min(max(displayIndex, 0), items.count + 1)
|
||||
|
||||
let scheduledIndex = displayIndex
|
||||
let scheduledRevision = itemsRevision
|
||||
|
||||
if displayIndex == 0 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + BannerCarousel.transitionDuration) {
|
||||
guard displayIndex == scheduledIndex, itemsRevision == scheduledRevision else { return }
|
||||
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
@@ -190,6 +198,8 @@ struct BannerCarousel: View {
|
||||
}
|
||||
} else if displayIndex == items.count + 1 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + BannerCarousel.transitionDuration) {
|
||||
guard displayIndex == scheduledIndex, itemsRevision == scheduledRevision else { return }
|
||||
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
@@ -200,6 +210,49 @@ struct BannerCarousel: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct BannerCarouselSection<Item>: View {
|
||||
let items: [Item]
|
||||
let id: (Item, Int) -> String
|
||||
let imageUrl: (Item) -> String?
|
||||
let action: (Item) -> Void
|
||||
|
||||
init(
|
||||
items: [Item],
|
||||
id: @escaping (Item, Int) -> String,
|
||||
imageUrl: @escaping (Item) -> String?,
|
||||
action: @escaping (Item) -> Void
|
||||
) {
|
||||
self.items = items
|
||||
self.id = id
|
||||
self.imageUrl = imageUrl
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if !items.isEmpty {
|
||||
BannerCarousel(items: carouselItems) { carouselItem in
|
||||
guard let item = item(for: carouselItem) else { return }
|
||||
action(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var carouselItems: [BannerCarouselItem] {
|
||||
items.enumerated().map { index, item in
|
||||
BannerCarouselItem(
|
||||
id: id(item, index),
|
||||
imageUrl: imageUrl(item)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func item(for carouselItem: BannerCarouselItem) -> Item? {
|
||||
items.enumerated().first { index, item in
|
||||
id(item, index) == carouselItem.id
|
||||
}?.element
|
||||
}
|
||||
}
|
||||
|
||||
struct BannerCarousel_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
BannerCarousel(
|
||||
|
||||