Compare commits
98 Commits
a8680931fe
...
5213f0d98d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
|
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 |
@@ -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.
|
||||
|
||||
@@ -62,7 +62,7 @@ enum AppStep {
|
||||
|
||||
case serviceCenter
|
||||
|
||||
case createContent
|
||||
case createContent(onSuccess: (() -> Void)? = nil)
|
||||
|
||||
case modifyContent(contentId: Int)
|
||||
|
||||
@@ -127,6 +127,25 @@ enum AppStep {
|
||||
case creatorCommunityWrite(onSuccess: () -> Void)
|
||||
|
||||
case creatorCommunityModify(postId: Int, onSuccess: () -> Void)
|
||||
|
||||
case creatorChannelFanTalkWrite(
|
||||
creatorId: Int,
|
||||
mode: CreatorChannelFanTalkWriteViewModel.Mode,
|
||||
fanTalkId: Int?,
|
||||
initialContent: String,
|
||||
onSuccess: () -> Void
|
||||
)
|
||||
|
||||
case creatorChannelReplyDetail(
|
||||
context: CreatorChannelReplyDetailContext,
|
||||
onFanTalkRefresh: (() -> Void)? = nil,
|
||||
onCommunityRefresh: (() -> Void)? = nil
|
||||
)
|
||||
|
||||
case creatorChannelCommunityPostDetail(
|
||||
postId: Int,
|
||||
onCommunityRefresh: (() -> Void)? = nil
|
||||
)
|
||||
|
||||
case canCoupon(refresh: () -> Void)
|
||||
|
||||
@@ -167,6 +186,8 @@ enum AppStep {
|
||||
case originalWorkDetail(originalId: Int)
|
||||
|
||||
case contentAll(isFree: Bool = false, isPointOnly: Bool = false)
|
||||
|
||||
case contentOverview(type: ContentOverviewType = .newAndHotAudio)
|
||||
|
||||
case seriesMain
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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?] = [
|
||||
|
||||
@@ -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: {}
|
||||
)
|
||||
|
||||
@@ -162,14 +162,14 @@ struct AppStepLayerView: View {
|
||||
case .serviceCenter:
|
||||
ServiceCenterView()
|
||||
|
||||
case .createContent:
|
||||
ContentCreateView()
|
||||
case .createContent(let onSuccess):
|
||||
ContentCreateView(onSuccess: onSuccess)
|
||||
|
||||
case .liveReservationComplete(let response):
|
||||
LiveReservationCompleteView(reservationCompleteData: response)
|
||||
|
||||
case .creatorDetail(let userId):
|
||||
UserProfileView(userId: userId)
|
||||
CreatorChannelView(creatorId: userId)
|
||||
|
||||
case .followerList(let userId):
|
||||
FollowerListView(userId: userId)
|
||||
@@ -254,6 +254,19 @@ struct AppStepLayerView: View {
|
||||
case .creatorCommunityModify(let postId, let onSuccess):
|
||||
CreatorCommunityModifyView(postId: postId, onSuccess: onSuccess)
|
||||
|
||||
case .creatorChannelFanTalkWrite(let creatorId, let mode, _, _, let onSuccess):
|
||||
CreatorChannelFanTalkWriteView(creatorId: creatorId, mode: mode, onSuccess: onSuccess)
|
||||
|
||||
case .creatorChannelReplyDetail(let context, let onFanTalkRefresh, let onCommunityRefresh):
|
||||
CreatorChannelReplyDetailView(
|
||||
context: context,
|
||||
onFanTalkRefresh: onFanTalkRefresh,
|
||||
onCommunityRefresh: onCommunityRefresh
|
||||
)
|
||||
|
||||
case .creatorChannelCommunityPostDetail(let postId, let onCommunityRefresh):
|
||||
CreatorChannelCommunityPostDetailView(postId: postId, onCommunityRefresh: onCommunityRefresh)
|
||||
|
||||
case .canCoupon(let refresh):
|
||||
CanCouponView(refresh: refresh)
|
||||
|
||||
@@ -317,6 +330,11 @@ struct AppStepLayerView: View {
|
||||
case .contentAll(let isFree, let isPointOnly):
|
||||
ContentAllView(isFree: isFree, isPointAvailableOnly: isPointOnly)
|
||||
|
||||
case .contentOverview(let type):
|
||||
ContentOverviewView(type: type) { contentId in
|
||||
AppState.shared.setAppStep(step: .contentDetail(contentId: contentId))
|
||||
}
|
||||
|
||||
case .seriesMain:
|
||||
SeriesMainView()
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -2952,6 +2952,44 @@ If you block this user, the following features will be restricted.
|
||||
}
|
||||
}
|
||||
|
||||
enum MainChat {
|
||||
static var filterAll: String {
|
||||
pick(ko: "전체", en: "All", ja: "すべて")
|
||||
}
|
||||
|
||||
static var filterAI: String {
|
||||
pick(ko: "AI 채팅", en: "AI chats", ja: "AIチャット")
|
||||
}
|
||||
|
||||
static var filterDM: String {
|
||||
pick(ko: "DM", en: "DM", ja: "DM")
|
||||
}
|
||||
|
||||
static var emptyStateMessage: String {
|
||||
pick(
|
||||
ko: "대화 목록이 없습니다.",
|
||||
en: "No talks yet.",
|
||||
ja: "トークはまだありません。"
|
||||
)
|
||||
}
|
||||
|
||||
static var loadFailedMessage: String {
|
||||
pick(
|
||||
ko: "대화 목록을 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.",
|
||||
en: "Unable to load talks.\nPlease try again later.",
|
||||
ja: "トーク一覧を読み込めませんでした。\nしばらくしてからもう一度お試しください。"
|
||||
)
|
||||
}
|
||||
|
||||
static var directTag: String {
|
||||
pick(ko: "Direct", en: "Direct", ja: "Direct")
|
||||
}
|
||||
|
||||
static var monthDayDateFormat: String {
|
||||
pick(ko: "M월 d일", en: "MMM d", ja: "M月d日")
|
||||
}
|
||||
}
|
||||
|
||||
enum Main {
|
||||
enum EventPopup {
|
||||
static var doNotShowAgain: String {
|
||||
@@ -2967,7 +3005,7 @@ If you block this user, the following features will be restricted.
|
||||
static var home: String { pick(ko: "홈", en: "Home", ja: "ホーム") }
|
||||
static var content: String { pick(ko: "콘텐츠", en: "Content", ja: "コンテンツ") }
|
||||
static var live: String { pick(ko: "라이브", en: "Live", ja: "ライブ") }
|
||||
static var chat: String { pick(ko: "채팅", en: "Chat", ja: "チャット") }
|
||||
static var chat: String { pick(ko: "대화", en: "Talks", ja: "トーク") }
|
||||
static var my: String { pick(ko: "마이", en: "My", ja: "マイ") }
|
||||
}
|
||||
|
||||
@@ -2998,6 +3036,148 @@ If you block this user, the following features will be restricted.
|
||||
}
|
||||
}
|
||||
|
||||
enum MainContentRecommendation {
|
||||
static var tabTitle: String {
|
||||
pick(ko: "추천", en: "Recommended", ja: "おすすめ")
|
||||
}
|
||||
|
||||
static var emptyStateMessage: String {
|
||||
pick(
|
||||
ko: "추천 오디오가 아직 없어요.",
|
||||
en: "No recommended audio yet.",
|
||||
ja: "おすすめオーディオはまだありません。"
|
||||
)
|
||||
}
|
||||
|
||||
static var loadFailedMessage: String {
|
||||
pick(
|
||||
ko: "추천 오디오를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.",
|
||||
en: "Unable to load recommended audio.\nPlease try again later.",
|
||||
ja: "おすすめオーディオを読み込めませんでした。\nしばらくしてからもう一度お試しください。"
|
||||
)
|
||||
}
|
||||
|
||||
static var latestAudioSectionTitle: String {
|
||||
pick(ko: "새로 올라온 오디오", en: "Latest audio", ja: "新着オーディオ")
|
||||
}
|
||||
|
||||
static var newAndHotSectionTitle: String {
|
||||
pick(ko: "New&Hot", en: "New&Hot", ja: "New&Hot")
|
||||
}
|
||||
|
||||
static var voiceOnOnlySectionTitle: String {
|
||||
pick(ko: "오직 보이스온에서만!", en: "Only on VoiceOn!", ja: "VoiceOnだけで!")
|
||||
}
|
||||
|
||||
static var freeAudioSectionTitle: String {
|
||||
pick(ko: "무료 오디오", en: "Free audio", ja: "無料オーディオ")
|
||||
}
|
||||
|
||||
static var pointAudioSectionTitle: String {
|
||||
pick(ko: "포인트 오디오", en: "Point audio", ja: "ポイントオーディオ")
|
||||
}
|
||||
|
||||
static var mostCommentedAudioSectionTitle: String {
|
||||
pick(ko: "최근 댓글이 많은 오디오", en: "Most commented audio", ja: "最近コメントが多いオーディオ")
|
||||
}
|
||||
|
||||
static var recommendedAudioSectionTitle: String {
|
||||
pick(ko: "추천 오디오", en: "Recommended audio", ja: "おすすめオーディオ")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum MainContentRanking {
|
||||
static var tabTitle: String {
|
||||
pick(ko: "랭킹", en: "Ranking", ja: "ランキング")
|
||||
}
|
||||
|
||||
static var weeklyPopularTabTitle: String {
|
||||
pick(ko: "주간 인기", en: "Weekly popular", ja: "週間人気")
|
||||
}
|
||||
|
||||
static var risingTabTitle: String {
|
||||
pick(ko: "지금 뜨는 중", en: "Rising now", ja: "今話題")
|
||||
}
|
||||
|
||||
static var revenueTabTitle: String {
|
||||
pick(ko: "매출", en: "Revenue", ja: "売上")
|
||||
}
|
||||
|
||||
static var salesCountTabTitle: String {
|
||||
pick(ko: "판매량", en: "Sales", ja: "販売数")
|
||||
}
|
||||
|
||||
static var commentCountTabTitle: String {
|
||||
pick(ko: "댓글수", en: "Comments", ja: "コメント数")
|
||||
}
|
||||
|
||||
static var likeCountTabTitle: String {
|
||||
pick(ko: "좋아요", en: "Likes", ja: "いいね")
|
||||
}
|
||||
|
||||
static var emptyStateMessage: String {
|
||||
pick(
|
||||
ko: "랭킹 콘텐츠가 아직 없어요.",
|
||||
en: "No ranking content yet.",
|
||||
ja: "ランキングコンテンツはまだありません。"
|
||||
)
|
||||
}
|
||||
|
||||
static var loadFailedMessage: String {
|
||||
pick(
|
||||
ko: "랭킹 콘텐츠를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.",
|
||||
en: "Unable to load ranking content.\nPlease try again later.",
|
||||
ja: "ランキングコンテンツを読み込めませんでした。\nしばらくしてからもう一度お試しください。"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum MainContentAll {
|
||||
static var tabTitle: String {
|
||||
pick(ko: "전체", en: "All", ja: "すべて")
|
||||
}
|
||||
|
||||
static var rangeLabel: String {
|
||||
pick(ko: "전체", en: "All", ja: "すべて")
|
||||
}
|
||||
|
||||
static var emptyStateMessage: String {
|
||||
pick(
|
||||
ko: "콘텐츠가 아직 없어요.",
|
||||
en: "No content yet.",
|
||||
ja: "コンテンツはまだありません。"
|
||||
)
|
||||
}
|
||||
|
||||
static var loadFailedMessage: String {
|
||||
pick(
|
||||
ko: "콘텐츠를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.",
|
||||
en: "Unable to load content.\nPlease try again later.",
|
||||
ja: "コンテンツを読み込めませんでした。\nしばらくしてからもう一度お試しください。"
|
||||
)
|
||||
}
|
||||
|
||||
enum TypeChip {
|
||||
static var audio: String { pick(ko: "오디오", en: "Audio", ja: "オーディオ") }
|
||||
static var series: String { pick(ko: "시리즈", en: "Series", ja: "シリーズ") }
|
||||
static var original: String { pick(ko: "오리지널", en: "Original", ja: "オリジナル") }
|
||||
static var free: String { pick(ko: "무료", en: "Free", ja: "無料") }
|
||||
static var point: String { pick(ko: "포인트", en: "Point", ja: "ポイント") }
|
||||
}
|
||||
|
||||
enum DayOfWeek {
|
||||
static var mon: String { pick(ko: "월", en: "MON", ja: "月") }
|
||||
static var tue: String { pick(ko: "화", en: "TUE", ja: "火") }
|
||||
static var wed: String { pick(ko: "수", en: "WED", ja: "水") }
|
||||
static var thu: String { pick(ko: "목", en: "THU", ja: "木") }
|
||||
static var fri: String { pick(ko: "금", en: "FRI", ja: "金") }
|
||||
static var sat: String { pick(ko: "토", en: "SAT", ja: "土") }
|
||||
static var sun: String { pick(ko: "일", en: "SUN", ja: "日") }
|
||||
static var random: String { pick(ko: "기타", en: "OTHER", ja: "その他") }
|
||||
}
|
||||
}
|
||||
|
||||
enum Home {
|
||||
static var liveNowSectionTitle: String {
|
||||
pick(ko: "지금 라이브중", en: "Live now", ja: "ライブ配信中")
|
||||
@@ -3042,6 +3222,281 @@ If you block this user, the following features will be restricted.
|
||||
}
|
||||
}
|
||||
|
||||
enum HomeFollowing {
|
||||
static var emptyStateMessage: String {
|
||||
pick(
|
||||
ko: "팔로잉 소식이 아직 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.",
|
||||
en: "No following updates yet.\nFollow creators you care about.",
|
||||
ja: "フォロー中の新着情報はまだありません。\n気になるクリエイターをフォローしてみましょう。"
|
||||
)
|
||||
}
|
||||
|
||||
static var loadFailedMessage: String {
|
||||
pick(
|
||||
ko: "팔로잉 정보를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.",
|
||||
en: "Unable to load following updates.\nPlease try again later.",
|
||||
ja: "フォロー情報を読み込めませんでした。\nしばらくしてからもう一度お試しください。"
|
||||
)
|
||||
}
|
||||
|
||||
static var loginRequiredMessage: String {
|
||||
pick(
|
||||
ko: "로그인 후 팔로잉 소식을 확인할 수 있어요.",
|
||||
en: "Log in to see following updates.",
|
||||
ja: "ログインするとフォロー中の新着情報を確認できます。"
|
||||
)
|
||||
}
|
||||
|
||||
static var loginButtonTitle: String {
|
||||
pick(ko: "로그인", en: "Log in", ja: "ログイン")
|
||||
}
|
||||
|
||||
static var allButtonTitle: String {
|
||||
pick(ko: "전체", en: "All", ja: "すべて")
|
||||
}
|
||||
|
||||
static var tabTitle: String {
|
||||
pick(ko: "팔로잉", en: "Following", ja: "フォロー中")
|
||||
}
|
||||
|
||||
static var onAirTitle: String {
|
||||
pick(ko: "On Air", en: "On Air", ja: "On Air")
|
||||
}
|
||||
|
||||
static var liveTag: String {
|
||||
pick(ko: "LIVE", en: "LIVE", ja: "LIVE")
|
||||
}
|
||||
|
||||
static var recentChatTitle: String {
|
||||
pick(ko: "최근 대화", en: "Recent chats", ja: "最近のトーク")
|
||||
}
|
||||
|
||||
static var directTag: String {
|
||||
pick(ko: "Direct", en: "Direct", ja: "Direct")
|
||||
}
|
||||
|
||||
static var monthlyScheduleTitle: String {
|
||||
pick(ko: "이달의 스케줄", en: "This month's schedule", ja: "今月のスケジュール")
|
||||
}
|
||||
|
||||
static var today: String {
|
||||
pick(ko: "오늘", en: "Today", ja: "今日")
|
||||
}
|
||||
|
||||
static var onAirStatus: String {
|
||||
pick(ko: "On Air", en: "On Air", ja: "On Air")
|
||||
}
|
||||
|
||||
static var liveScheduleTag: String {
|
||||
pick(ko: "라이브", en: "Live", ja: "ライブ")
|
||||
}
|
||||
|
||||
static var liveReplayScheduleTag: String {
|
||||
pick(ko: "다시보기", en: "Replay", ja: "アーカイブ")
|
||||
}
|
||||
|
||||
static var audioScheduleTag: String {
|
||||
pick(ko: "오디오", en: "Audio", ja: "オーディオ")
|
||||
}
|
||||
|
||||
static var communityScheduleTag: String {
|
||||
pick(ko: "커뮤니티", en: "Community", ja: "コミュニティ")
|
||||
}
|
||||
|
||||
static var recentNewsTitle: String {
|
||||
pick(ko: "최근 소식", en: "Recent news", ja: "最近のニュース")
|
||||
}
|
||||
|
||||
static func rankingMessagePrefix(_ title: String) -> String {
|
||||
pick(
|
||||
ko: "\(title)님이 이번 주 랭킹 ",
|
||||
en: "\(title) reached ",
|
||||
ja: "\(title)さんが今週のランキング"
|
||||
)
|
||||
}
|
||||
|
||||
static func rankingRankText(_ rank: Int) -> String {
|
||||
pick(ko: "\(rank)위", en: "No. \(rank)", ja: "\(rank)位")
|
||||
}
|
||||
|
||||
static var rankingMessageSuffix: String {
|
||||
pick(ko: "에 올랐어요!", en: " in this week's ranking!", ja: "に入りました!")
|
||||
}
|
||||
|
||||
static var audioTag: String {
|
||||
pick(ko: "오디오", en: "Audio", ja: "オーディオ")
|
||||
}
|
||||
|
||||
static var photoTag: String {
|
||||
pick(ko: "화보", en: "Photo", ja: "フォト")
|
||||
}
|
||||
}
|
||||
|
||||
enum HomeRanking {
|
||||
static var emptyStateMessage: String {
|
||||
pick(
|
||||
ko: "순위 집계 중입니다.\n잠시 후 다시 시도해 주세요.",
|
||||
en: "Rankings are being calculated.\nPlease try again later.",
|
||||
ja: "ランキングを集計中です。\nしばらくしてからもう一度お試しください。"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum CreatorChannelLive {
|
||||
static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") }
|
||||
static var startLive: String { pick(ko: "라이브 시작하기", en: "Start live", ja: "ライブを開始") }
|
||||
static var emptyMessage: String {
|
||||
pick(
|
||||
ko: "크리에이터가 라이브를 준비중입니다.\n기대해 주세요!",
|
||||
en: "The creator is preparing a live.\nPlease stay tuned!",
|
||||
ja: "クリエイターがライブを準備中です。\nお楽しみに!"
|
||||
)
|
||||
}
|
||||
|
||||
enum Sort {
|
||||
static var latest: String { pick(ko: "최신순", en: "Latest", ja: "最新順") }
|
||||
static var popular: String { pick(ko: "인기순", en: "Popular", ja: "人気順") }
|
||||
static var priceHigh: String { pick(ko: "높은 가격순", en: "Highest price", ja: "価格が高い順") }
|
||||
static var priceLow: String { pick(ko: "낮은 가격순", en: "Lowest price", ja: "価格が低い順") }
|
||||
}
|
||||
}
|
||||
|
||||
enum CreatorChannelAudio {
|
||||
static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") }
|
||||
static var countUnit: String { pick(ko: "개", en: "", ja: "件") }
|
||||
static var ownershipPrefix: String {
|
||||
pick(ko: "전체 오디오의 ", en: "You own ", ja: "全オーディオの")
|
||||
}
|
||||
static var ownershipSuffix: String {
|
||||
pick(ko: "를 소장하고 있어요.", en: " of all audio.", ja: "を保有中です。")
|
||||
}
|
||||
static var emptyMessage: String {
|
||||
pick(
|
||||
ko: "크리에이터가 오디오를 준비 중입니다.\n기대해 주세요!",
|
||||
en: "The creator is preparing audio.\nPlease stay tuned!",
|
||||
ja: "クリエイターがオーディオを準備中です。\nお楽しみに!"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum CreatorChannelSeries {
|
||||
static var contentCountUnit: String { pick(ko: "화", en: " episodes", ja: "話") }
|
||||
static var proceeding: String { pick(ko: "연재", en: "Ongoing", ja: "連載") }
|
||||
static var completed: String { pick(ko: "완결", en: "Completed", ja: "完結") }
|
||||
static var emptyMessage: String {
|
||||
pick(
|
||||
ko: "크리에이터가 시리즈를 준비 중입니다.\n기대해 주세요!",
|
||||
en: "The creator is preparing series.\nPlease stay tuned!",
|
||||
ja: "クリエイターがシリーズを準備中です。\nお楽しみに!"
|
||||
)
|
||||
}
|
||||
|
||||
static func totalContentCount(_ count: Int) -> String {
|
||||
pick(ko: "총 \(count)화", en: "Total \(count) episodes", ja: "全\(count)話")
|
||||
}
|
||||
}
|
||||
|
||||
enum CreatorChannelHome {
|
||||
static var currentLive: String { pick(ko: "현재 라이브", en: "Live now", ja: "現在ライブ") }
|
||||
static var latestAudio: String { pick(ko: "최신 오디오 콘텐츠", en: "Latest audio", ja: "最新オーディオ") }
|
||||
static var donation: String { pick(ko: "후원", en: "Donations", ja: "応援") }
|
||||
static var notice: String { pick(ko: "공지", en: "Notice", ja: "お知らせ") }
|
||||
static var schedule: String { pick(ko: "스케줄", en: "Schedule", ja: "スケジュール") }
|
||||
static var audio: String { pick(ko: "오디오", en: "Audio", ja: "オーディオ") }
|
||||
static var series: String { pick(ko: "시리즈", en: "Series", ja: "シリーズ") }
|
||||
static var community: String { pick(ko: "커뮤니티", en: "Community", ja: "コミュニティ") }
|
||||
static var fanTalk: String { pick(ko: "팬Talk", en: "Fan Talk", ja: "ファンTalk") }
|
||||
static var introduce: String { pick(ko: "소개", en: "Introduction", ja: "紹介") }
|
||||
static var activity: String { pick(ko: "활동", en: "Activity", ja: "活動") }
|
||||
static var sns: String { pick(ko: "SNS", en: "SNS", ja: "SNS") }
|
||||
static var follow: String { pick(ko: "팔로우", en: "Follow", ja: "フォロー") }
|
||||
static var following: String { pick(ko: "팔로잉", en: "Following", ja: "フォロー中") }
|
||||
static var talk: String { pick(ko: "대화하기", en: "Start chat", ja: "会話する") }
|
||||
static var uploadCommunityPost: String { pick(ko: "커뮤니티 글 올리기", en: "Upload community post", ja: "コミュニティ投稿を作成") }
|
||||
static var uploadAudioContent: String { pick(ko: "오디오 콘텐츠 올리기", en: "Upload audio content", ja: "オーディオコンテンツを投稿") }
|
||||
static var createLive: String { pick(ko: "라이브 만들기", en: "Create live", ja: "ライブを作成") }
|
||||
static var closeFloatingMenu: String { pick(ko: "닫기", en: "Close", ja: "閉じる") }
|
||||
static var viewAll: String { pick(ko: "전체보기", en: "View all", ja: "すべて見る") }
|
||||
static var debut: String { pick(ko: "데뷔", en: "Debut", ja: "デビュー") }
|
||||
static var liveCount: String { pick(ko: "라이브 총 진행 수", en: "Total live count", ja: "ライブ総配信数") }
|
||||
static var liveDurationHours: String { pick(ko: "라이브 누적 진행 시간", en: "Total live hours", ja: "ライブ累計配信時間") }
|
||||
static var liveContributorCount: String { pick(ko: "라이브 누적 참여자", en: "Total live participants", ja: "ライブ累計参加者") }
|
||||
static var audioContentCount: String { pick(ko: "오디오", en: "Audio", ja: "オーディオ") }
|
||||
static var seriesCount: String { pick(ko: "시리즈", en: "Series", ja: "シリーズ") }
|
||||
static var placeholderMessage: String {
|
||||
pick(
|
||||
ko: "준비 중입니다.",
|
||||
en: "Coming soon.",
|
||||
ja: "準備中です。"
|
||||
)
|
||||
}
|
||||
|
||||
static func followerCount(_ count: String) -> String {
|
||||
pick(ko: "팔로워 \(count)명", en: "\(count) followers", ja: "フォロワー\(count)人")
|
||||
}
|
||||
|
||||
static func liveCountValue(_ count: String) -> String {
|
||||
pick(ko: "\(count)회", en: "\(count)", ja: "\(count)回")
|
||||
}
|
||||
|
||||
static func liveDurationHoursValue(_ count: String) -> String {
|
||||
pick(ko: "\(count)시간", en: "\(count) hours", ja: "\(count)時間")
|
||||
}
|
||||
|
||||
static func liveContributorCountValue(_ count: String) -> String {
|
||||
pick(ko: "\(count)명", en: "\(count) people", ja: "\(count)人")
|
||||
}
|
||||
|
||||
static func audioContentCountValue(_ count: String) -> String {
|
||||
pick(ko: "\(count)개", en: "\(count)", ja: "\(count)件")
|
||||
}
|
||||
|
||||
static func seriesCountValue(_ count: String) -> String {
|
||||
pick(ko: "\(count)개", en: "\(count)", ja: "\(count)件")
|
||||
}
|
||||
}
|
||||
|
||||
enum CreatorChannelCommunity {
|
||||
static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") }
|
||||
static var listView: String { pick(ko: "리스트형", en: "List", ja: "リスト型") }
|
||||
static var thumbnailView: String { pick(ko: "썸네일형", en: "Thumbnail", ja: "サムネイル型") }
|
||||
static var commentPlaceholder: String { pick(ko: "댓글을 입력하세요", en: "Enter a comment", ja: "コメントを入力してください") }
|
||||
static var communityEmpty: String {
|
||||
pick(
|
||||
ko: "크리에이터가 커뮤니티를 준비 중입니다.\n기대해 주세요!",
|
||||
en: "The creator is preparing the community.\nPlease look forward to it!",
|
||||
ja: "クリエイターがコミュニティを準備中です。\nお楽しみに!"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enum CreatorChannelReplyDetail {
|
||||
static var replyPlaceholder: String {
|
||||
pick(ko: "답글을 입력하세요", en: "Enter a reply", ja: "返信を入力してください")
|
||||
}
|
||||
|
||||
static var replaceReplyTitle: String {
|
||||
pick(ko: "답글을 수정할까요?", en: "Replace your reply?", ja: "返信を修正しますか?")
|
||||
}
|
||||
|
||||
static var replaceReplyMessage: String {
|
||||
pick(ko: "이미 작성한 답글이 있어요. 확인을 누르면 기존 답글이 새 내용으로 수정됩니다.", en: "You already have a reply. If you continue, the existing reply will be updated with the new content.", ja: "すでに返信があります。確認すると既存の返信が新しい内容に修正されます。")
|
||||
}
|
||||
|
||||
static var replaceReplyConfirm: String {
|
||||
pick(ko: "수정하기", en: "Update", ja: "修正する")
|
||||
}
|
||||
}
|
||||
|
||||
enum CreatorChannelFanTalk {
|
||||
static var send: String { pick(ko: "보내기", en: "Send", ja: "送信") }
|
||||
static var exitModalTitle: String { pick(ko: "입력 종료", en: "Discard input", ja: "入力終了") }
|
||||
static var exitModalMessage: String { pick(ko: "입력을 종료할까요?\n지금 나가면 입력한 내용이 저장되지 않아요.", en: "Discard your input?\nIf you leave now, your input will not be saved.", ja: "入力を終了しますか?\n今退出すると入力した内容は保存されません。") }
|
||||
static var exitModalExit: String { pick(ko: "종료", en: "Discard", ja: "終了") }
|
||||
static var exitModalContinue: String { pick(ko: "계속 작성", en: "Keep writing", ja: "続けて書く") }
|
||||
}
|
||||
|
||||
enum HomeRecommendation {
|
||||
static var activityLive: String {
|
||||
pick(ko: "라이브", en: "Live", ja: "ライブ")
|
||||
@@ -3204,6 +3659,14 @@ If you block this user, the following features will be restricted.
|
||||
pick(ko: "수정", en: "Edit", ja: "編集")
|
||||
}
|
||||
|
||||
static var editAction: String {
|
||||
pick(ko: "수정하기", en: "Edit", ja: "編集する")
|
||||
}
|
||||
|
||||
static var deleteAction: String {
|
||||
pick(ko: "삭제하기", en: "Delete", ja: "削除する")
|
||||
}
|
||||
|
||||
static var report: String {
|
||||
pick(ko: "신고", en: "Report", ja: "通報")
|
||||
}
|
||||
|
||||
@@ -86,6 +86,8 @@ struct SplashView: View {
|
||||
}
|
||||
|
||||
private func fetchLastestVersion() {
|
||||
guard !isRunningForPreviews else { return }
|
||||
|
||||
let remoteConfig = RemoteConfig.remoteConfig()
|
||||
let configSettings = RemoteConfigSettings()
|
||||
configSettings.minimumFetchInterval = 60
|
||||
@@ -163,6 +165,10 @@ struct SplashView: View {
|
||||
nextAppStep()
|
||||
}
|
||||
}
|
||||
|
||||
private var isRunningForPreviews: Bool {
|
||||
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,6 @@ struct BannerCarousel: View {
|
||||
|
||||
init(
|
||||
items: [BannerCarouselItem],
|
||||
height: CGFloat = 120,
|
||||
action: @escaping (BannerCarouselItem) -> Void = { _ in }
|
||||
) {
|
||||
self.items = Array(items.prefix(BannerCarousel.maxItemCount))
|
||||
@@ -200,6 +199,49 @@ struct BannerCarousel: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct BannerCarouselSection<Item>: View {
|
||||
let items: [Item]
|
||||
let id: (Item, Int) -> String
|
||||
let imageUrl: (Item) -> String?
|
||||
let action: (Item) -> Void
|
||||
|
||||
init(
|
||||
items: [Item],
|
||||
id: @escaping (Item, Int) -> String,
|
||||
imageUrl: @escaping (Item) -> String?,
|
||||
action: @escaping (Item) -> Void
|
||||
) {
|
||||
self.items = items
|
||||
self.id = id
|
||||
self.imageUrl = imageUrl
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if !items.isEmpty {
|
||||
BannerCarousel(items: carouselItems) { carouselItem in
|
||||
guard let item = item(for: carouselItem) else { return }
|
||||
action(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var carouselItems: [BannerCarouselItem] {
|
||||
items.enumerated().map { index, item in
|
||||
BannerCarouselItem(
|
||||
id: id(item, index),
|
||||
imageUrl: imageUrl(item)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func item(for carouselItem: BannerCarouselItem) -> Item? {
|
||||
items.enumerated().first { index, item in
|
||||
id(item, index) == carouselItem.id
|
||||
}?.element
|
||||
}
|
||||
}
|
||||
|
||||
struct BannerCarousel_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
BannerCarousel(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct CommunityPostCard: View {
|
||||
let postId: Int
|
||||
@@ -67,8 +68,11 @@ struct CommunityPostCard: View {
|
||||
imageContent
|
||||
}
|
||||
|
||||
reactionBar
|
||||
if isAccessiblePost {
|
||||
reactionBar
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(SodaSpacing.s14)
|
||||
.background(Color.gray900)
|
||||
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||
@@ -110,28 +114,26 @@ struct CommunityPostCard: View {
|
||||
.lineLimit(5)
|
||||
.truncationMode(.tail)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var imageContent: some View {
|
||||
GeometryReader { proxy in
|
||||
ZStack {
|
||||
DownsampledKFImage(
|
||||
url: URL(string: imageUrl ?? ""),
|
||||
size: CGSize(width: proxy.size.width, height: proxy.size.height)
|
||||
)
|
||||
ZStack {
|
||||
KFImage(URL(string: imageUrl ?? ""))
|
||||
.cancelOnDisappear(true)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.gray800)
|
||||
.frame(width: proxy.size.width, height: proxy.size.height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||
.blur(radius: isLocked ? 12 : 0)
|
||||
|
||||
if isLocked {
|
||||
paidLockOverlay
|
||||
} else if hasAudio {
|
||||
audioPlayButton
|
||||
}
|
||||
if isLocked {
|
||||
paidLockOverlay
|
||||
} else if hasAudio {
|
||||
audioPlayButton
|
||||
}
|
||||
}
|
||||
.aspectRatio(346.0 / 236.0, contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||
}
|
||||
|
||||
@@ -148,7 +150,7 @@ struct CommunityPostCard: View {
|
||||
.frame(width: 24, height: 24)
|
||||
|
||||
HStack(spacing: SodaSpacing.s6) {
|
||||
Image("ic_can")
|
||||
Image("ic_bar_cash")
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20)
|
||||
|
||||
@@ -224,6 +226,10 @@ struct CommunityPostCard: View {
|
||||
price > 0 && !existOrdered
|
||||
}
|
||||
|
||||
private var isAccessiblePost: Bool {
|
||||
price == 0 || existOrdered
|
||||
}
|
||||
|
||||
private func toggleLike() {
|
||||
localIsLike.toggle()
|
||||
localLikeCount += localIsLike ? 1 : -1
|
||||
@@ -236,39 +242,56 @@ struct CommunityPostCard_Previews: PreviewProvider {
|
||||
private static let previewImageUrl = "https://picsum.photos/500/500"
|
||||
|
||||
static var previews: some View {
|
||||
VStack(spacing: SodaSpacing.s8) {
|
||||
CommunityPostCard(
|
||||
postId: 1,
|
||||
creatorNickname: "크리에이터 이름",
|
||||
creatorProfileImageUrl: previewImageUrl,
|
||||
content: "크리에이터가 커뮤니티에 올린 글이 보이는 부분 크리에이터가 커뮤니티에 올린 글이 보이는 부분",
|
||||
imageUrl: nil,
|
||||
audioUrl: nil,
|
||||
price: 0,
|
||||
existOrdered: false,
|
||||
createdAt: "2분 전",
|
||||
isLike: false,
|
||||
likeCount: 5,
|
||||
commentCount: 6
|
||||
)
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(spacing: SodaSpacing.s8) {
|
||||
CommunityPostCard(
|
||||
postId: 1,
|
||||
creatorNickname: "크리에이터 이름",
|
||||
creatorProfileImageUrl: previewImageUrl,
|
||||
content: "크리에이터가 커뮤니티에 올린 글이 보이는 부분 크리에이터가 커뮤니티에 올린 글이 보이는 부분",
|
||||
imageUrl: nil,
|
||||
audioUrl: nil,
|
||||
price: 0,
|
||||
existOrdered: false,
|
||||
createdAt: "2분 전",
|
||||
isLike: false,
|
||||
likeCount: 5,
|
||||
commentCount: 6
|
||||
)
|
||||
|
||||
CommunityPostCard(
|
||||
postId: 2,
|
||||
creatorNickname: "크리에이터 이름",
|
||||
creatorProfileImageUrl: previewImageUrl,
|
||||
content: "이미지가 있는 커뮤니티 게시글입니다.",
|
||||
imageUrl: previewImageUrl,
|
||||
audioUrl: "https://example.com/audio.mp3",
|
||||
price: 30,
|
||||
existOrdered: false,
|
||||
createdAt: "2분 전",
|
||||
isLike: true,
|
||||
likeCount: 5,
|
||||
commentCount: 6
|
||||
)
|
||||
CommunityPostCard(
|
||||
postId: 2,
|
||||
creatorNickname: "크리에이터 이름",
|
||||
creatorProfileImageUrl: previewImageUrl,
|
||||
content: "이미지가 있는 커뮤니티 게시글입니다.",
|
||||
imageUrl: previewImageUrl,
|
||||
audioUrl: "https://example.com/audio.mp3",
|
||||
price: 30,
|
||||
existOrdered: false,
|
||||
createdAt: "2분 전",
|
||||
isLike: true,
|
||||
likeCount: 5,
|
||||
commentCount: 6
|
||||
)
|
||||
|
||||
CommunityPostCard(
|
||||
postId: 3,
|
||||
creatorNickname: "크리에이터 이름",
|
||||
creatorProfileImageUrl: previewImageUrl,
|
||||
content: "이미지가 있는 커뮤니티 게시글입니다.",
|
||||
imageUrl: previewImageUrl,
|
||||
audioUrl: "https://example.com/audio.mp3",
|
||||
price: 0,
|
||||
existOrdered: false,
|
||||
createdAt: "2분 전",
|
||||
isLike: true,
|
||||
likeCount: 5,
|
||||
commentCount: 6
|
||||
)
|
||||
}
|
||||
.padding(SodaSpacing.s14)
|
||||
.background(Color.black)
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
.padding(SodaSpacing.s14)
|
||||
.background(Color.black)
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
}
|
||||
|
||||
90
SodaLive/Sources/V2/Component/Modal/SodaV2ActionModal.swift
Normal file
@@ -0,0 +1,90 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SodaV2ActionModalButton {
|
||||
let label: String?
|
||||
let action: (() -> Void)?
|
||||
}
|
||||
|
||||
struct SodaV2ActionModal: View {
|
||||
let title: String
|
||||
let message: String
|
||||
let button1: SodaV2ActionModalButton?
|
||||
let button2: SodaV2ActionModalButton?
|
||||
let onDimmedTap: () -> Void
|
||||
|
||||
private var visibleButtons: [(label: String, action: () -> Void, isPrimary: Bool)] {
|
||||
var buttons = [(label: String, action: () -> Void, isPrimary: Bool)]()
|
||||
|
||||
if let button2,
|
||||
let label = button2.label,
|
||||
let action = button2.action {
|
||||
buttons.append((label, action, false))
|
||||
}
|
||||
|
||||
if let button1,
|
||||
let label = button1.label,
|
||||
let action = button1.action {
|
||||
buttons.append((label, action, true))
|
||||
}
|
||||
|
||||
return buttons
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.opacity(0.6)
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture(perform: onDimmedTap)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Text(title)
|
||||
.appFont(size: 20, weight: .bold)
|
||||
.foregroundColor(Color.white)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(width: 340, height: 64)
|
||||
|
||||
Text(message)
|
||||
.appFont(size: 16, weight: .regular)
|
||||
.foregroundColor(Color.white)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(20)
|
||||
.frame(width: 340)
|
||||
|
||||
buttonArea
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 14)
|
||||
.frame(width: 340)
|
||||
}
|
||||
.background(Color.gray900)
|
||||
.cornerRadius(14)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var buttonArea: some View {
|
||||
let buttons = visibleButtons
|
||||
|
||||
if buttons.count == 1, let button = buttons.first {
|
||||
actionButton(title: button.label, isPrimary: true, action: button.action)
|
||||
} else if buttons.count == 2 {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Array(buttons.enumerated()), id: \.offset) { _, button in
|
||||
actionButton(title: button.label, isPrimary: button.isPrimary, action: button.action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func actionButton(title: String, isPrimary: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.appFont(size: 18, weight: .medium)
|
||||
.foregroundColor(isPrimary ? Color.soda400 : Color.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(14)
|
||||
.background(Color.clear)
|
||||
.cornerRadius(100)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct CreatorChannelAudioOwnershipRateView: View {
|
||||
let purchasedAudioContentRate: Double
|
||||
let purchasedAudioContentCount: Int
|
||||
let paidAudioContentCount: Int
|
||||
|
||||
private var progress: CGFloat {
|
||||
CGFloat(max(0, min(purchasedAudioContentRate / 100, 1)))
|
||||
}
|
||||
|
||||
private var rateText: String {
|
||||
let rounded = purchasedAudioContentRate.rounded()
|
||||
if rounded == purchasedAudioContentRate {
|
||||
return "\(Int(rounded))%"
|
||||
}
|
||||
return String(format: "%.1f%%", purchasedAudioContentRate)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: SodaSpacing.s14) {
|
||||
HStack(alignment: .center, spacing: SodaSpacing.s4) {
|
||||
(
|
||||
Text(I18n.CreatorChannelAudio.ownershipPrefix)
|
||||
.foregroundColor(.white)
|
||||
+ Text(rateText)
|
||||
.foregroundColor(Color.soda400)
|
||||
+ Text(I18n.CreatorChannelAudio.ownershipSuffix)
|
||||
.foregroundColor(.white)
|
||||
)
|
||||
.appFont(size: 14, weight: .medium)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer(minLength: SodaSpacing.s8)
|
||||
|
||||
(
|
||||
Text(purchasedAudioContentCount.comma())
|
||||
.foregroundColor(.white)
|
||||
+ Text("/\(paidAudioContentCount.comma())\(I18n.CreatorChannelAudio.countUnit)")
|
||||
.foregroundColor(Color.gray500)
|
||||
)
|
||||
.appFont(size: 14, weight: .medium)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
GeometryReader { proxy in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Color.gray800)
|
||||
|
||||
Capsule()
|
||||
.fill(Color.soda400)
|
||||
.frame(width: proxy.size.width * progress)
|
||||
}
|
||||
}
|
||||
.frame(height: 4)
|
||||
}
|
||||
.padding(SodaSpacing.s14)
|
||||
.background(Color.gray900)
|
||||
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||
.padding(.horizontal, SodaSpacing.s14)
|
||||
}
|
||||
}
|
||||
|
||||
struct CreatorChannelAudioOwnershipRateView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CreatorChannelAudioOwnershipRateView(
|
||||
purchasedAudioContentRate: 58,
|
||||
purchasedAudioContentCount: 12,
|
||||
paidAudioContentCount: 43
|
||||
)
|
||||
.background(Color.black)
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CreatorChannelAudioUploadButton: View {
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Button(action: action) {
|
||||
HStack(spacing: SodaSpacing.s6) {
|
||||
Image("ic_new_upload_audio")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 24, height: 24)
|
||||
|
||||
Text(I18n.CreatorChannelHome.uploadAudioContent)
|
||||
.appFont(size: 16, weight: .bold)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52)
|
||||
.padding(.horizontal, SodaSpacing.s14)
|
||||
.background(Color.soda400)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.top, SodaSpacing.s14)
|
||||
.padding(.bottom, 34)
|
||||
.background(Color.black)
|
||||
}
|
||||
}
|
||||
|
||||
struct CreatorChannelAudioUploadButton_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CreatorChannelAudioUploadButton(action: {})
|
||||
.padding(SodaSpacing.s20)
|
||||
.background(Color.black)
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CreatorChannelAudioTabView: View {
|
||||
let creatorId: Int
|
||||
let isOwnCreatorChannel: Bool
|
||||
let onTapContent: (Int) -> Void
|
||||
|
||||
@StateObject private var viewModel = CreatorChannelAudioViewModel()
|
||||
@State private var isSortPopupPresented = false
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.background(Color.black)
|
||||
.onAppear {
|
||||
if viewModel.hasLoaded == false {
|
||||
viewModel.fetchFirstPage(creatorId: creatorId)
|
||||
}
|
||||
}
|
||||
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1)
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
VStack(spacing: 0) {
|
||||
if isEmptyState {
|
||||
emptyStateView
|
||||
} else {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
VStack(spacing: 0) {
|
||||
CapsuleTabBar(
|
||||
items: viewModel.displayThemes,
|
||||
selectedItem: Binding(
|
||||
get: { viewModel.selectedTheme },
|
||||
set: { viewModel.selectTheme($0, creatorId: creatorId) }
|
||||
),
|
||||
title: { $0.themeName }
|
||||
)
|
||||
|
||||
CreatorChannelSortBar(
|
||||
selectedSort: viewModel.selectedSort,
|
||||
totalCount: viewModel.response?.audioContentCount,
|
||||
onTapSort: {
|
||||
isSortPopupPresented.toggle()
|
||||
}
|
||||
)
|
||||
|
||||
LazyVStack(spacing: 0) {
|
||||
if shouldShowOwnershipRate, let response = viewModel.response {
|
||||
CreatorChannelAudioOwnershipRateView(
|
||||
purchasedAudioContentRate: response.purchasedAudioContentRate,
|
||||
purchasedAudioContentCount: response.purchasedAudioContentCount,
|
||||
paidAudioContentCount: response.paidAudioContentCount
|
||||
)
|
||||
.padding(.bottom, SodaSpacing.s8)
|
||||
}
|
||||
|
||||
ForEach(viewModel.audioContents) { audioContent in
|
||||
AudioContentListRow(
|
||||
item: AudioContentListRowItem(
|
||||
audioContent: audioContent,
|
||||
subtitle: audioContent.duration
|
||||
),
|
||||
style: .purchaseState
|
||||
) {
|
||||
onTapContent(audioContent.audioContentId)
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.fetchNextPageIfNeeded(creatorId: creatorId, currentItem: audioContent)
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.isLoadingNextPage {
|
||||
ProgressView()
|
||||
.padding(.vertical, SodaSpacing.s20)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, isOwnCreatorChannel ? 70 : SodaSpacing.s20)
|
||||
}
|
||||
|
||||
if isSortPopupPresented {
|
||||
Color.black
|
||||
.opacity(0.001)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onTapGesture {
|
||||
isSortPopupPresented = false
|
||||
}
|
||||
|
||||
CreatorChannelSortContextPopup(selectedSort: viewModel.selectedSort) { sort in
|
||||
viewModel.selectSort(sort, creatorId: creatorId)
|
||||
isSortPopupPresented = false
|
||||
}
|
||||
.padding(.top, 104)
|
||||
.padding(.trailing, SodaSpacing.s14)
|
||||
.zIndex(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .topTrailing)
|
||||
.onChange(of: viewModel.selectedSort) { _ in
|
||||
isSortPopupPresented = false
|
||||
}
|
||||
.onChange(of: viewModel.selectedTheme) { _ in
|
||||
isSortPopupPresented = false
|
||||
}
|
||||
.onDisappear {
|
||||
isSortPopupPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var shouldShowOwnershipRate: Bool {
|
||||
guard isOwnCreatorChannel == false else { return false }
|
||||
return viewModel.isAllThemeSelected
|
||||
}
|
||||
|
||||
private var isEmptyState: Bool {
|
||||
guard viewModel.hasLoaded, let response = viewModel.response else { return false }
|
||||
return response.audioContentCount == 0
|
||||
}
|
||||
|
||||
private var emptyStateView: some View {
|
||||
Text(I18n.CreatorChannelAudio.emptyMessage)
|
||||
.appFont(.body3)
|
||||
.foregroundColor(Color.gray500)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 48)
|
||||
}
|
||||
}
|
||||
|
||||
struct CreatorChannelAudioTabView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CreatorChannelAudioTabView(
|
||||
creatorId: 1,
|
||||
isOwnCreatorChannel: false,
|
||||
onTapContent: { _ in }
|
||||
)
|
||||
.background(Color.black)
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
struct CreatorChannelAudioThemeItem: Hashable {
|
||||
let themeId: Int?
|
||||
let themeName: String
|
||||
}
|
||||
|
||||
final class CreatorChannelAudioViewModel: ObservableObject {
|
||||
private let repository = CreatorChannelAudioRepository()
|
||||
private var subscription = Set<AnyCancellable>()
|
||||
private let pageSize = 20
|
||||
private var latestRequestId = 0
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingNextPage = false
|
||||
@Published var response: CreatorChannelAudioTabResponse?
|
||||
@Published var audioContents = [CreatorChannelAudioContentResponse]()
|
||||
@Published var selectedSort: ContentSort = .latest
|
||||
@Published var selectedTheme = CreatorChannelAudioThemeItem.all
|
||||
@Published var page = 0
|
||||
@Published var size = 20
|
||||
@Published var hasNext = false
|
||||
@Published var hasLoaded = false
|
||||
@Published var errorMessage = ""
|
||||
@Published var isShowPopup = false
|
||||
|
||||
var displayThemes: [CreatorChannelAudioThemeItem] {
|
||||
let themes = response?.themes.map {
|
||||
CreatorChannelAudioThemeItem(themeId: $0.themeId, themeName: $0.themeName)
|
||||
} ?? []
|
||||
|
||||
return [CreatorChannelAudioThemeItem.all] + themes
|
||||
}
|
||||
|
||||
var isAllThemeSelected: Bool {
|
||||
selectedTheme.themeId == nil
|
||||
}
|
||||
|
||||
func fetchFirstPage(creatorId: Int) {
|
||||
fetchAudio(creatorId: creatorId, page: 0, isNextPage: false)
|
||||
}
|
||||
|
||||
func fetchNextPageIfNeeded(creatorId: Int, currentItem: CreatorChannelAudioContentResponse) {
|
||||
guard audioContents.last?.audioContentId == currentItem.audioContentId else { return }
|
||||
guard hasNext, isLoadingNextPage == false, isLoading == false else { return }
|
||||
|
||||
fetchAudio(creatorId: creatorId, page: page + 1, isNextPage: true)
|
||||
}
|
||||
|
||||
func selectSort(_ sort: ContentSort, creatorId: Int) {
|
||||
guard selectedSort != sort else { return }
|
||||
|
||||
selectedSort = sort
|
||||
fetchFirstPage(creatorId: creatorId)
|
||||
}
|
||||
|
||||
func selectTheme(_ theme: CreatorChannelAudioThemeItem, creatorId: Int) {
|
||||
guard selectedTheme != theme else { return }
|
||||
|
||||
selectedTheme = theme
|
||||
fetchFirstPage(creatorId: creatorId)
|
||||
}
|
||||
|
||||
private func fetchAudio(creatorId: Int, page requestPage: Int, isNextPage: Bool) {
|
||||
latestRequestId += 1
|
||||
let requestId = latestRequestId
|
||||
|
||||
if isNextPage {
|
||||
isLoadingNextPage = true
|
||||
} else {
|
||||
isLoading = true
|
||||
}
|
||||
|
||||
repository.getAudio(
|
||||
creatorId: creatorId,
|
||||
page: requestPage,
|
||||
size: pageSize,
|
||||
sort: selectedSort,
|
||||
themeId: selectedTheme.themeId
|
||||
)
|
||||
.sink { [weak self] result in
|
||||
guard let self else { return }
|
||||
guard requestId == self.latestRequestId else { return }
|
||||
|
||||
switch result {
|
||||
case .finished:
|
||||
DEBUG_LOG("finish")
|
||||
case .failure(let error):
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.applyFailureState()
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self else { return }
|
||||
guard requestId == self.latestRequestId else { return }
|
||||
|
||||
var shouldFetchNextPage = false
|
||||
|
||||
do {
|
||||
let jsonDecoder = JSONDecoder()
|
||||
let decoded = try jsonDecoder.decode(ApiResponse<CreatorChannelAudioTabResponse>.self, from: response.data)
|
||||
|
||||
if let data = decoded.data, decoded.success {
|
||||
shouldFetchNextPage = self.applySuccessState(data, isNextPage: isNextPage)
|
||||
} else {
|
||||
self.errorMessage = decoded.message ?? I18n.Common.commonError
|
||||
self.isShowPopup = true
|
||||
}
|
||||
} catch {
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.errorMessage = I18n.Common.commonError
|
||||
self.isShowPopup = true
|
||||
}
|
||||
|
||||
self.hasLoaded = true
|
||||
self.isLoading = false
|
||||
self.isLoadingNextPage = false
|
||||
|
||||
if shouldFetchNextPage {
|
||||
self.fetchAudio(creatorId: creatorId, page: self.page + 1, isNextPage: true)
|
||||
}
|
||||
}
|
||||
.store(in: &subscription)
|
||||
}
|
||||
|
||||
private func applySuccessState(_ data: CreatorChannelAudioTabResponse, isNextPage: Bool) -> Bool {
|
||||
response = data
|
||||
selectedSort = data.sort
|
||||
selectedTheme = selectedTheme(from: data)
|
||||
page = data.page
|
||||
size = data.size
|
||||
hasNext = data.hasNext
|
||||
|
||||
let visibleAudioContents = data.audioContents.filter {
|
||||
guard let duration = $0.duration?.trimmingCharacters(in: .whitespacesAndNewlines) else { return false }
|
||||
return duration.isEmpty == false
|
||||
}
|
||||
|
||||
if isNextPage {
|
||||
audioContents.append(contentsOf: visibleAudioContents)
|
||||
} else {
|
||||
audioContents = visibleAudioContents
|
||||
}
|
||||
|
||||
return visibleAudioContents.isEmpty && data.hasNext
|
||||
}
|
||||
|
||||
private func selectedTheme(from data: CreatorChannelAudioTabResponse) -> CreatorChannelAudioThemeItem {
|
||||
guard let themeId = data.themeId else {
|
||||
return .all
|
||||
}
|
||||
|
||||
if let theme = data.themes.first(where: { $0.themeId == themeId }) {
|
||||
return CreatorChannelAudioThemeItem(themeId: theme.themeId, themeName: theme.themeName)
|
||||
}
|
||||
|
||||
return CreatorChannelAudioThemeItem(themeId: themeId, themeName: selectedTheme.themeName)
|
||||
}
|
||||
|
||||
private func applyFailureState() {
|
||||
errorMessage = I18n.Common.commonError
|
||||
isShowPopup = true
|
||||
hasLoaded = true
|
||||
isLoading = false
|
||||
isLoadingNextPage = false
|
||||
}
|
||||
}
|
||||
|
||||
private extension CreatorChannelAudioThemeItem {
|
||||
static var all: CreatorChannelAudioThemeItem {
|
||||
CreatorChannelAudioThemeItem(themeId: nil, themeName: I18n.CreatorChannelAudio.totalLabel)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
struct CreatorChannelAudioTabResponse: Decodable {
|
||||
let audioContentCount: Int
|
||||
let paidAudioContentCount: Int
|
||||
let purchasedAudioContentCount: Int
|
||||
let purchasedAudioContentRate: Double
|
||||
let themes: [CreatorChannelAudioThemeResponse]
|
||||
let audioContents: [CreatorChannelAudioContentResponse]
|
||||
let sort: ContentSort
|
||||
let themeId: Int?
|
||||
let page: Int
|
||||
let size: Int
|
||||
let hasNext: Bool
|
||||
}
|
||||
|
||||
struct CreatorChannelAudioThemeResponse: Decodable, Hashable {
|
||||
let themeId: Int
|
||||
let themeName: String
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
|
||||
import Moya
|
||||
|
||||
enum CreatorChannelAudioApi {
|
||||
case getAudio(creatorId: Int, page: Int, size: Int, sort: ContentSort, themeId: Int?)
|
||||
}
|
||||
|
||||
extension CreatorChannelAudioApi: TargetType {
|
||||
var baseURL: URL {
|
||||
return URL(string: BASE_URL)!
|
||||
}
|
||||
|
||||
var path: String {
|
||||
switch self {
|
||||
case .getAudio(let creatorId, _, _, _, _):
|
||||
return "/api/v2/creator-channels/\(creatorId)/audio"
|
||||
}
|
||||
}
|
||||
|
||||
var method: Moya.Method {
|
||||
switch self {
|
||||
case .getAudio:
|
||||
return .get
|
||||
}
|
||||
}
|
||||
|
||||
var task: Moya.Task {
|
||||
switch self {
|
||||
case .getAudio(_, let page, let size, let sort, let themeId):
|
||||
var parameters: [String: Any] = [
|
||||
"page": page,
|
||||
"size": size,
|
||||
"sort": sort.rawValue
|
||||
]
|
||||
|
||||
if let themeId {
|
||||
parameters["themeId"] = themeId
|
||||
}
|
||||
|
||||
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
|
||||
}
|
||||
}
|
||||
|
||||
var headers: [String: String]? {
|
||||
return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
import CombineMoya
|
||||
import Moya
|
||||
|
||||
final class CreatorChannelAudioRepository {
|
||||
private let api = MoyaProvider<CreatorChannelAudioApi>()
|
||||
|
||||
func getAudio(
|
||||
creatorId: Int,
|
||||
page: Int,
|
||||
size: Int,
|
||||
sort: ContentSort,
|
||||
themeId: Int?
|
||||
) -> AnyPublisher<Response, MoyaError> {
|
||||
return api.requestPublisher(
|
||||
.getAudio(
|
||||
creatorId: creatorId,
|
||||
page: page,
|
||||
size: size,
|
||||
sort: sort,
|
||||
themeId: themeId
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CreatorChannelCommunityEmptyView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Text(I18n.CreatorChannelCommunity.communityEmpty)
|
||||
.appFont(size: 16, weight: .regular)
|
||||
.foregroundColor(Color.gray500)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(SodaSpacing.s4)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 316)
|
||||
.padding(.bottom, 120)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CreatorChannelCommunityGridItem: View {
|
||||
let post: CreatorChannelCommunityPostItem
|
||||
let isOwnPost: Bool
|
||||
let onTapPurchase: () -> Void
|
||||
let onTapDetail: () -> Void
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
ZStack {
|
||||
if isPaidLocked {
|
||||
Button(action: onTapPurchase) {
|
||||
Color.gray400
|
||||
.overlay(paidPriceOverlay)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
tileContent(proxy: proxy)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: onTapDetail)
|
||||
}
|
||||
}
|
||||
.frame(width: proxy.size.width, height: proxy.size.width)
|
||||
.clipped()
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if post.isPinned {
|
||||
Image("ic_pin")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 18, height: 18)
|
||||
.padding(6)
|
||||
.shadow(color: .black.opacity(0.45), radius: 2, x: 0, y: 1)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func tileContent(proxy: GeometryProxy) -> some View {
|
||||
if let imageUrl = post.imageUrl, !imageUrl.isEmpty {
|
||||
DownsampledKFImage(url: URL(string: imageUrl), size: CGSize(width: proxy.size.width, height: proxy.size.width))
|
||||
.frame(width: proxy.size.width, height: proxy.size.width)
|
||||
.aspectRatio(1, contentMode: .fill)
|
||||
.clipped()
|
||||
} else {
|
||||
ZStack {
|
||||
Color.gray900
|
||||
|
||||
Text(fallbackText)
|
||||
.appFont(size: 12, weight: .medium)
|
||||
.foregroundColor(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(3)
|
||||
.padding(SodaSpacing.s8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var paidPriceOverlay: some View {
|
||||
VStack(spacing: SodaSpacing.s4) {
|
||||
Image("ic_new_community_lock")
|
||||
.resizable()
|
||||
.renderingMode(.template)
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 24, height: 24)
|
||||
|
||||
HStack(spacing: SodaSpacing.s6) {
|
||||
Image("ic_bar_cash")
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20)
|
||||
|
||||
Text("\(post.price)")
|
||||
.appFont(size: 16, weight: .medium)
|
||||
.foregroundColor(.black)
|
||||
}
|
||||
.padding(SodaSpacing.s8)
|
||||
.background(Color.white)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
private var isPaidLocked: Bool {
|
||||
post.price > 0 && post.existOrdered == false && isOwnPost == false
|
||||
}
|
||||
|
||||
private var fallbackText: String {
|
||||
let content = post.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !content.isEmpty else { return "-" }
|
||||
return String(content.prefix(18))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import SwiftUI
|
||||
|
||||
import Kingfisher
|
||||
import SDWebImageSwiftUI
|
||||
|
||||
struct CreatorChannelCommunityListItem: View {
|
||||
let post: CreatorChannelCommunityPostItem
|
||||
let isOwnPost: Bool
|
||||
let isOwnCreatorChannel: Bool
|
||||
let onTapLike: () -> Void
|
||||
let onTapMore: () -> Void
|
||||
let onTapPurchase: () -> Void
|
||||
let onTapDetail: () -> Void
|
||||
|
||||
@State private var localIsLike: Bool
|
||||
@State private var localLikeCount: Int
|
||||
@StateObject private var playManager = CreatorCommunityMediaPlayerManager.shared
|
||||
@StateObject private var contentPlayManager = ContentPlayManager.shared
|
||||
|
||||
init(
|
||||
post: CreatorChannelCommunityPostItem,
|
||||
isOwnPost: Bool,
|
||||
isOwnCreatorChannel: Bool,
|
||||
onTapLike: @escaping () -> Void,
|
||||
onTapMore: @escaping () -> Void,
|
||||
onTapPurchase: @escaping () -> Void,
|
||||
onTapDetail: @escaping () -> Void
|
||||
) {
|
||||
self.post = post
|
||||
self.isOwnPost = isOwnPost
|
||||
self.isOwnCreatorChannel = isOwnCreatorChannel
|
||||
self.onTapLike = onTapLike
|
||||
self.onTapMore = onTapMore
|
||||
self.onTapPurchase = onTapPurchase
|
||||
self.onTapDetail = onTapDetail
|
||||
_localIsLike = State(initialValue: post.isLiked ?? false)
|
||||
_localLikeCount = State(initialValue: post.likeCount)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: SodaSpacing.s16) {
|
||||
if post.isPinned {
|
||||
pinnedLabel
|
||||
}
|
||||
|
||||
header
|
||||
|
||||
contentText
|
||||
|
||||
if isPaidLocked {
|
||||
paidLockedImage
|
||||
} else if let imageUrl = post.imageUrl, !imageUrl.isEmpty {
|
||||
imageContent(imageUrl: imageUrl)
|
||||
}
|
||||
|
||||
reactionBar
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(SodaSpacing.s14)
|
||||
.background(Color.gray900)
|
||||
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||
.contentShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||
.onTapGesture(perform: onTapDetail)
|
||||
.onChange(of: post.isLiked) { newValue in
|
||||
localIsLike = newValue ?? false
|
||||
}
|
||||
.onChange(of: post.likeCount) { newValue in
|
||||
localLikeCount = newValue
|
||||
}
|
||||
}
|
||||
|
||||
private var pinnedLabel: some View {
|
||||
HStack(spacing: 2) {
|
||||
Image("ic_pin")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 18, height: 18)
|
||||
|
||||
Text("Notice")
|
||||
.appFont(size: 14, weight: .medium)
|
||||
.foregroundColor(Color(hex: "73FF01"))
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: SodaSpacing.s8) {
|
||||
HStack(spacing: SodaSpacing.s8) {
|
||||
DownsampledKFImage(url: URL(string: post.creatorProfileUrl), size: CGSize(width: 42, height: 42))
|
||||
.background(Color.gray800)
|
||||
.clipShape(Circle())
|
||||
.frame(width: 42, height: 42)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(post.creatorNickname)
|
||||
.appFont(size: 14, weight: .medium)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(post.relativeTimeText())
|
||||
.appFont(size: 14, weight: .regular)
|
||||
.foregroundColor(Color.gray500)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if shouldShowOwnerControls {
|
||||
HStack(spacing: SodaSpacing.s4) {
|
||||
if post.price > 0 {
|
||||
priceTag
|
||||
}
|
||||
|
||||
moreButton
|
||||
}
|
||||
} else if !isOwnPost {
|
||||
moreButton
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var priceTag: some View {
|
||||
HStack(spacing: 2) {
|
||||
Image("ic_bar_cash")
|
||||
.resizable()
|
||||
.frame(width: 18, height: 18)
|
||||
|
||||
Text("\(post.price)")
|
||||
.appFont(size: 12, weight: .regular)
|
||||
.foregroundColor(.black)
|
||||
}
|
||||
.padding(.horizontal, SodaSpacing.s4)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.white)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
private var moreButton: some View {
|
||||
Button(action: onTapMore) {
|
||||
Image("ic_seemore_vertical")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 24, height: 24)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var contentText: some View {
|
||||
Text(post.content)
|
||||
.appFont(size: 16, weight: .regular)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(5)
|
||||
.truncationMode(.tail)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func imageContent(imageUrl: String) -> some View {
|
||||
ZStack {
|
||||
WebImage(url: URL(string: imageUrl))
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.gray800)
|
||||
|
||||
if let audioUrl = post.audioUrl, !audioUrl.isEmpty {
|
||||
audioPlayButton(audioUrl: audioUrl)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||
}
|
||||
|
||||
private var paidLockedImage: some View {
|
||||
Button(action: onTapPurchase) {
|
||||
RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)
|
||||
.fill(Color.gray400)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 236)
|
||||
.overlay(paidPriceOverlay)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var paidPriceOverlay: some View {
|
||||
VStack(spacing: SodaSpacing.s4) {
|
||||
Image("ic_new_community_lock")
|
||||
.resizable()
|
||||
.renderingMode(.template)
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 24, height: 24)
|
||||
|
||||
HStack(spacing: SodaSpacing.s6) {
|
||||
Image("ic_bar_cash")
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20)
|
||||
|
||||
Text("\(post.price)")
|
||||
.appFont(size: 16, weight: .medium)
|
||||
.foregroundColor(.black)
|
||||
}
|
||||
.padding(SodaSpacing.s8)
|
||||
.background(Color.white)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
private func audioPlayButton(audioUrl: String) -> some View {
|
||||
Button {
|
||||
contentPlayManager.pauseAudio()
|
||||
playManager.toggleContent(item: CreatorCommunityContentItem(contentId: post.postId, url: audioUrl))
|
||||
} label: {
|
||||
Image(playManager.isPlaying && playManager.currentPlayingContentId == post.postId ? "btn_audio_content_pause" : "btn_audio_content_play")
|
||||
.resizable()
|
||||
.frame(width: 48, height: 48)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var reactionBar: some View {
|
||||
HStack(spacing: 15) {
|
||||
if !isPaidLocked {
|
||||
Button {
|
||||
localIsLike.toggle()
|
||||
localLikeCount += localIsLike ? 1 : -1
|
||||
localLikeCount = max(0, localLikeCount)
|
||||
onTapLike()
|
||||
} label: {
|
||||
HStack(spacing: SodaSpacing.s4) {
|
||||
Image(localIsLike ? "ic_feed_community_heart_fill" : "ic_feed_community_heart")
|
||||
.resizable()
|
||||
.frame(width: 18, height: 18)
|
||||
|
||||
Text("\(localLikeCount)")
|
||||
.appFont(size: 16, weight: .regular)
|
||||
.foregroundColor(Color.gray400)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if post.isCommentAvailable && !isPaidLocked {
|
||||
HStack(spacing: SodaSpacing.s4) {
|
||||
Image("ic_feed_community_reply")
|
||||
.resizable()
|
||||
.frame(width: 18, height: 18)
|
||||
|
||||
Text("\(post.commentCount)")
|
||||
.appFont(size: 16, weight: .regular)
|
||||
.foregroundColor(Color.gray400)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 24)
|
||||
}
|
||||
|
||||
private var isPaidLocked: Bool {
|
||||
post.price > 0 && post.existOrdered == false && isOwnPost == false
|
||||
}
|
||||
|
||||
private var shouldShowOwnerControls: Bool {
|
||||
isOwnCreatorChannel && isOwnPost
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CreatorChannelCommunityUploadButton: View {
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Button(action: action) {
|
||||
HStack(spacing: SodaSpacing.s6) {
|
||||
Image("ic_new_upload_community_post")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 24, height: 24)
|
||||
|
||||
Text(I18n.CreatorChannelHome.uploadCommunityPost)
|
||||
.appFont(size: 16, weight: .bold)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52)
|
||||
.padding(.horizontal, SodaSpacing.s14)
|
||||
.background(Color.soda400)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.top, SodaSpacing.s14)
|
||||
.padding(.bottom, 34)
|
||||
.background(Color.black)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CreatorChannelCommunityViewModeBar: View {
|
||||
let communityPostCount: Int
|
||||
let viewMode: CreatorChannelCommunityViewMode
|
||||
let onTapToggle: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: SodaSpacing.s8) {
|
||||
HStack(spacing: SodaSpacing.s4) {
|
||||
Text(I18n.CreatorChannelCommunity.totalLabel)
|
||||
.appFont(size: 16, weight: .medium)
|
||||
.foregroundColor(.white)
|
||||
|
||||
Text("\(communityPostCount)")
|
||||
.appFont(size: 16, weight: .medium)
|
||||
.foregroundColor(Color.gray500)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: onTapToggle) {
|
||||
HStack(spacing: SodaSpacing.s4) {
|
||||
Text(viewModeTitle)
|
||||
.appFont(size: 16, weight: .regular)
|
||||
.foregroundColor(Color.gray400)
|
||||
|
||||
Image(viewModeIconName)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 18, height: 18)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, SodaSpacing.s14)
|
||||
.frame(height: 52)
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
private var viewModeTitle: String {
|
||||
switch viewMode {
|
||||
case .list:
|
||||
return I18n.CreatorChannelCommunity.listView
|
||||
case .thumbnail:
|
||||
return I18n.CreatorChannelCommunity.thumbnailView
|
||||
}
|
||||
}
|
||||
|
||||
private var viewModeIconName: String {
|
||||
switch viewMode {
|
||||
case .list:
|
||||
return "ic_new_list"
|
||||
case .thumbnail:
|
||||
return "ic_new_grid"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CreatorChannelCommunityTabView: View {
|
||||
let creatorId: Int
|
||||
let isOwnCreatorChannel: Bool
|
||||
@ObservedObject var viewModel: CreatorChannelCommunityViewModel
|
||||
let onTapDetail: (CreatorChannelCommunityPostItem) -> Void
|
||||
|
||||
private let gridColumns = [
|
||||
GridItem(.flexible(), spacing: 0),
|
||||
GridItem(.flexible(), spacing: 0),
|
||||
GridItem(.flexible(), spacing: 0)
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.background(Color.black)
|
||||
.onAppear {
|
||||
if viewModel.hasLoaded == false {
|
||||
viewModel.fetchFirstPage(creatorId: creatorId)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.isShowCommentListView) {
|
||||
NavigationStack {
|
||||
CreatorCommunityCommentListView(
|
||||
isPresented: $viewModel.isShowCommentListView,
|
||||
creatorId: creatorId,
|
||||
postId: viewModel.selectedPostId,
|
||||
isShowSecret: viewModel.isShowSecret
|
||||
)
|
||||
}
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
}
|
||||
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1)
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
VStack(spacing: 0) {
|
||||
if isEmptyState {
|
||||
CreatorChannelCommunityEmptyView()
|
||||
} else {
|
||||
CreatorChannelCommunityViewModeBar(
|
||||
communityPostCount: viewModel.communityPostCount,
|
||||
viewMode: viewModel.viewMode,
|
||||
onTapToggle: viewModel.toggleViewMode
|
||||
)
|
||||
|
||||
if viewModel.viewMode == .list {
|
||||
listContent
|
||||
} else {
|
||||
gridContent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var listContent: some View {
|
||||
LazyVStack(spacing: SodaSpacing.s8) {
|
||||
ForEach(viewModel.communityPosts) { post in
|
||||
let isOwnPost = post.creatorId == UserDefaults.int(forKey: .userId)
|
||||
|
||||
CreatorChannelCommunityListItem(
|
||||
post: post,
|
||||
isOwnPost: isOwnPost,
|
||||
isOwnCreatorChannel: isOwnCreatorChannel,
|
||||
onTapLike: {
|
||||
viewModel.communityPostLike(postId: post.postId)
|
||||
},
|
||||
onTapMore: {
|
||||
viewModel.openReportMenu(post: post)
|
||||
},
|
||||
onTapPurchase: {
|
||||
viewModel.openPurchaseDialog(post: post)
|
||||
},
|
||||
onTapDetail: {
|
||||
onTapDetail(post)
|
||||
}
|
||||
)
|
||||
.onAppear {
|
||||
viewModel.fetchNextPageIfNeeded(creatorId: creatorId, currentItem: post)
|
||||
}
|
||||
}
|
||||
|
||||
loadingNextPageView
|
||||
}
|
||||
.padding(.horizontal, SodaSpacing.s14)
|
||||
.padding(.bottom, contentBottomPadding)
|
||||
}
|
||||
|
||||
private var gridContent: some View {
|
||||
LazyVGrid(columns: gridColumns, spacing: 0) {
|
||||
ForEach(viewModel.communityPosts) { post in
|
||||
let isOwnPost = post.creatorId == UserDefaults.int(forKey: .userId)
|
||||
|
||||
CreatorChannelCommunityGridItem(
|
||||
post: post,
|
||||
isOwnPost: isOwnPost,
|
||||
onTapPurchase: {
|
||||
viewModel.openPurchaseDialog(post: post)
|
||||
},
|
||||
onTapDetail: {
|
||||
onTapDetail(post)
|
||||
}
|
||||
)
|
||||
.onAppear {
|
||||
viewModel.fetchNextPageIfNeeded(creatorId: creatorId, currentItem: post)
|
||||
}
|
||||
}
|
||||
|
||||
loadingNextPageView
|
||||
}
|
||||
.padding(.bottom, contentBottomPadding)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var loadingNextPageView: some View {
|
||||
if viewModel.isLoadingNextPage {
|
||||
ProgressView()
|
||||
.padding(.vertical, SodaSpacing.s20)
|
||||
}
|
||||
}
|
||||
|
||||
private var isEmptyState: Bool {
|
||||
guard viewModel.hasLoaded else { return false }
|
||||
return viewModel.communityPostCount == 0
|
||||
}
|
||||
|
||||
private var contentBottomPadding: CGFloat {
|
||||
isOwnCreatorChannel ? 120 : SodaSpacing.s20
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
import Moya
|
||||
|
||||
enum CreatorChannelCommunityViewMode {
|
||||
case list
|
||||
case thumbnail
|
||||
}
|
||||
|
||||
final class CreatorChannelCommunityViewModel: ObservableObject {
|
||||
private let repository = CreatorChannelCommunityRepository()
|
||||
private let communityRepository = CreatorCommunityRepository()
|
||||
private let reportRepository = ReportRepository()
|
||||
private var subscription = Set<AnyCancellable>()
|
||||
private let pageSize = 20
|
||||
private var latestRequestId = 0
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingNextPage = false
|
||||
@Published var response: CreatorChannelCommunityTabResponse?
|
||||
@Published var communityPosts = [CreatorChannelCommunityPostItem]()
|
||||
@Published var viewMode: CreatorChannelCommunityViewMode = .list
|
||||
@Published var page = 0
|
||||
@Published var size = 20
|
||||
@Published var hasNext = false
|
||||
@Published var hasLoaded = false
|
||||
@Published var errorMessage = ""
|
||||
@Published var isShowPopup = false
|
||||
@Published var isShowCommentListView = false {
|
||||
didSet {
|
||||
if !isShowCommentListView {
|
||||
selectedPostId = 0
|
||||
isShowSecret = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@Published var isShowReportMenu = false
|
||||
@Published var isShowReportView = false {
|
||||
didSet {
|
||||
if !isShowReportView {
|
||||
selectedPostId = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@Published var isShowDeleteConfirm = false {
|
||||
didSet {
|
||||
if !isShowDeleteConfirm {
|
||||
selectedPostId = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@Published var isShowPostPurchaseView = false {
|
||||
didSet {
|
||||
if !isShowPostPurchaseView {
|
||||
selectedPostId = 0
|
||||
selectedPostPrice = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@Published var selectedPostId = 0
|
||||
@Published var selectedPostPrice = 0
|
||||
@Published var selectedPostIsPinned = false
|
||||
@Published var isShowSecret = false
|
||||
|
||||
var communityPostCount: Int {
|
||||
response?.communityPostCount ?? 0
|
||||
}
|
||||
|
||||
func fetchFirstPage(creatorId: Int) {
|
||||
fetchCommunity(creatorId: creatorId, page: 0, isNextPage: false)
|
||||
}
|
||||
|
||||
func fetchNextPageIfNeeded(creatorId: Int, currentItem: CreatorChannelCommunityPostItem) {
|
||||
guard communityPosts.last?.postId == currentItem.postId else { return }
|
||||
guard hasNext, isLoadingNextPage == false, isLoading == false else { return }
|
||||
|
||||
fetchCommunity(creatorId: creatorId, page: page + 1, isNextPage: true)
|
||||
}
|
||||
|
||||
func toggleViewMode() {
|
||||
viewMode = viewMode == .list ? .thumbnail : .list
|
||||
}
|
||||
|
||||
func isPaidLocked(_ post: CreatorChannelCommunityPostItem, isOwnPost: Bool) -> Bool {
|
||||
post.price > 0 && post.existOrdered == false && isOwnPost == false
|
||||
}
|
||||
|
||||
func openCommentList(post: CreatorChannelCommunityPostItem) {
|
||||
selectedPostId = post.postId
|
||||
isShowSecret = post.price > 0 && post.existOrdered && post.creatorId != UserDefaults.int(forKey: .userId)
|
||||
isShowCommentListView = true
|
||||
}
|
||||
|
||||
func openReportMenu(post: CreatorChannelCommunityPostItem) {
|
||||
selectedPostId = post.postId
|
||||
selectedPostPrice = post.price
|
||||
selectedPostIsPinned = post.isPinned
|
||||
isShowReportMenu = true
|
||||
}
|
||||
|
||||
func openPurchaseDialog(post: CreatorChannelCommunityPostItem) {
|
||||
selectedPostId = post.postId
|
||||
selectedPostPrice = post.price
|
||||
isShowPostPurchaseView = true
|
||||
}
|
||||
|
||||
func communityPostLike(postId: Int) {
|
||||
communityRepository.communityPostLike(postId: postId)
|
||||
.sink { result in
|
||||
switch result {
|
||||
case .finished:
|
||||
DEBUG_LOG("finish")
|
||||
case .failure(let error):
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
}
|
||||
} receiveValue: { _ in }
|
||||
.store(in: &subscription)
|
||||
}
|
||||
|
||||
func report(reason: String) {
|
||||
guard selectedPostId > 0 else { return }
|
||||
|
||||
isLoading = true
|
||||
let request = ReportRequest(type: .COMMUNITY_POST, reason: reason, communityPostId: selectedPostId)
|
||||
reportRepository.report(request: request)
|
||||
.sink { [weak self] result in
|
||||
guard let self else { return }
|
||||
|
||||
switch result {
|
||||
case .finished:
|
||||
DEBUG_LOG("finish")
|
||||
case .failure(let error):
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.applyFailureState()
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self else { return }
|
||||
|
||||
self.isLoading = false
|
||||
self.selectedPostId = 0
|
||||
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
|
||||
self.errorMessage = decoded.message ?? I18n.Common.commonError
|
||||
self.isShowPopup = true
|
||||
} catch {
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.errorMessage = I18n.Common.commonError
|
||||
self.isShowPopup = true
|
||||
}
|
||||
}
|
||||
.store(in: &subscription)
|
||||
}
|
||||
|
||||
func deleteCommunityPost(creatorId: Int) {
|
||||
guard selectedPostId > 0 else { return }
|
||||
|
||||
isLoading = true
|
||||
let request = ModifyCommunityPostRequest(creatorCommunityId: selectedPostId, isActive: false)
|
||||
var multipartData = [MultipartFormData]()
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = .withoutEscapingSlashes
|
||||
|
||||
guard let jsonData = try? encoder.encode(request) else {
|
||||
applyFailureState()
|
||||
return
|
||||
}
|
||||
|
||||
multipartData.append(MultipartFormData(provider: .data(jsonData), name: "request"))
|
||||
communityRepository.modifyCommunityPost(parameters: multipartData)
|
||||
.sink { [weak self] result in
|
||||
guard let self else { return }
|
||||
|
||||
switch result {
|
||||
case .finished:
|
||||
DEBUG_LOG("finish")
|
||||
case .failure(let error):
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.applyFailureState()
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self else { return }
|
||||
|
||||
self.isLoading = false
|
||||
self.selectedPostId = 0
|
||||
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
|
||||
if decoded.success {
|
||||
self.errorMessage = I18n.Explorer.deleted
|
||||
self.isShowPopup = true
|
||||
self.fetchFirstPage(creatorId: creatorId)
|
||||
} else {
|
||||
self.errorMessage = decoded.message ?? I18n.Common.commonError
|
||||
self.isShowPopup = true
|
||||
}
|
||||
} catch {
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.errorMessage = I18n.Common.commonError
|
||||
self.isShowPopup = true
|
||||
}
|
||||
}
|
||||
.store(in: &subscription)
|
||||
}
|
||||
|
||||
func updateCommunityPostFixed(creatorId: Int) {
|
||||
guard selectedPostId > 0 else { return }
|
||||
|
||||
isLoading = true
|
||||
let request = UpdateCommunityPostFixedRequest(postId: selectedPostId, isFixed: !selectedPostIsPinned)
|
||||
communityRepository.updateCommunityPostFixed(request: request)
|
||||
.sink { [weak self] result in
|
||||
guard let self else { return }
|
||||
|
||||
switch result {
|
||||
case .finished:
|
||||
DEBUG_LOG("finish")
|
||||
case .failure(let error):
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.applyFailureState()
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self else { return }
|
||||
|
||||
self.isLoading = false
|
||||
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
|
||||
if decoded.success {
|
||||
self.fetchFirstPage(creatorId: creatorId)
|
||||
} else {
|
||||
self.errorMessage = decoded.message ?? I18n.Common.commonError
|
||||
self.isShowPopup = true
|
||||
}
|
||||
} catch {
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.errorMessage = I18n.Common.commonError
|
||||
self.isShowPopup = true
|
||||
}
|
||||
}
|
||||
.store(in: &subscription)
|
||||
}
|
||||
|
||||
func purchaseCommunityPost(creatorId: Int) {
|
||||
guard selectedPostId > 0 else { return }
|
||||
|
||||
isLoading = true
|
||||
communityRepository.purchaseCommunityPost(postId: selectedPostId)
|
||||
.sink { [weak self] result in
|
||||
guard let self else { return }
|
||||
|
||||
switch result {
|
||||
case .finished:
|
||||
DEBUG_LOG("finish")
|
||||
case .failure(let error):
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.applyFailureState()
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self else { return }
|
||||
|
||||
self.isLoading = false
|
||||
self.selectedPostId = 0
|
||||
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
|
||||
if decoded.success {
|
||||
self.fetchFirstPage(creatorId: creatorId)
|
||||
} else {
|
||||
self.errorMessage = decoded.message ?? I18n.Common.commonError
|
||||
self.isShowPopup = true
|
||||
}
|
||||
} catch {
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.fetchFirstPage(creatorId: creatorId)
|
||||
}
|
||||
}
|
||||
.store(in: &subscription)
|
||||
}
|
||||
|
||||
private func fetchCommunity(creatorId: Int, page requestPage: Int, isNextPage: Bool) {
|
||||
latestRequestId += 1
|
||||
let requestId = latestRequestId
|
||||
|
||||
if isNextPage {
|
||||
isLoadingNextPage = true
|
||||
} else {
|
||||
isLoading = true
|
||||
}
|
||||
|
||||
repository.getCommunity(creatorId: creatorId, page: requestPage, size: pageSize)
|
||||
.sink { [weak self] result in
|
||||
guard let self else { return }
|
||||
guard requestId == self.latestRequestId else { return }
|
||||
|
||||
switch result {
|
||||
case .finished:
|
||||
DEBUG_LOG("finish")
|
||||
self.isLoading = false
|
||||
self.isLoadingNextPage = false
|
||||
case .failure(let error):
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.applyFailureState()
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self else { return }
|
||||
guard requestId == self.latestRequestId else { return }
|
||||
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(ApiResponse<CreatorChannelCommunityTabResponse>.self, from: response.data)
|
||||
if let data = decoded.data, decoded.success {
|
||||
self.applySuccessState(data, isNextPage: isNextPage)
|
||||
self.hasLoaded = true
|
||||
} else {
|
||||
self.errorMessage = decoded.message ?? I18n.Common.commonError
|
||||
self.isShowPopup = true
|
||||
}
|
||||
} catch {
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.errorMessage = I18n.Common.commonError
|
||||
self.isShowPopup = true
|
||||
}
|
||||
|
||||
self.isLoading = false
|
||||
self.isLoadingNextPage = false
|
||||
}
|
||||
.store(in: &subscription)
|
||||
}
|
||||
|
||||
private func applySuccessState(_ data: CreatorChannelCommunityTabResponse, isNextPage: Bool) {
|
||||
response = data
|
||||
page = data.page
|
||||
size = data.size
|
||||
hasNext = data.hasNext
|
||||
|
||||
if isNextPage {
|
||||
communityPosts.append(contentsOf: data.communityPosts)
|
||||
} else {
|
||||
communityPosts = data.communityPosts
|
||||
}
|
||||
}
|
||||
|
||||
private func applyFailureState() {
|
||||
errorMessage = I18n.Common.commonError
|
||||
isShowPopup = true
|
||||
hasLoaded = true
|
||||
isLoading = false
|
||||
isLoadingNextPage = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CreatorChannelCommunityPostDetailCommentListView: View {
|
||||
let creatorId: Int
|
||||
let postId: Int
|
||||
let commentCount: Int
|
||||
let comments: [CreatorChannelCommunityCommentResponse]
|
||||
let selectedComment: CreatorChannelCommunityCommentResponse?
|
||||
let canEdit: (CreatorChannelCommunityCommentResponse) -> Bool
|
||||
let canDelete: (CreatorChannelCommunityCommentResponse) -> Bool
|
||||
let onAppearComment: (CreatorChannelCommunityCommentResponse) -> Void
|
||||
let onTapReplyDetail: (CreatorChannelCommunityCommentResponse) -> Void
|
||||
let onTapMore: (CreatorChannelCommunityCommentResponse) -> Void
|
||||
let onTapEdit: (CreatorChannelCommunityCommentResponse) -> Void
|
||||
let onTapDelete: (CreatorChannelCommunityCommentResponse) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: SodaSpacing.s16) {
|
||||
Text("댓글 \(commentCount)")
|
||||
.appFont(size: 16, weight: .medium)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, SodaSpacing.s14)
|
||||
|
||||
LazyVStack(alignment: .leading, spacing: SodaSpacing.s20) {
|
||||
ForEach(comments, id: \.commentId) { comment in
|
||||
ZStack(alignment: .topTrailing) {
|
||||
CreatorChannelCommunityPostDetailCommentRow(
|
||||
comment: comment,
|
||||
creatorId: creatorId,
|
||||
onTapReplyDetail: { onTapReplyDetail(comment) },
|
||||
onTapMore: { onTapMore(comment) }
|
||||
)
|
||||
|
||||
if selectedComment?.commentId == comment.commentId {
|
||||
CreatorChannelActionPopup(
|
||||
canEdit: canEdit(comment),
|
||||
canDelete: canDelete(comment),
|
||||
onTapEdit: { onTapEdit(comment) },
|
||||
onTapDelete: { onTapDelete(comment) }
|
||||
)
|
||||
.padding(.top, 28)
|
||||
}
|
||||
}
|
||||
.onAppear { onAppearComment(comment) }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, SodaSpacing.s14)
|
||||
}
|
||||
.padding(.vertical, SodaSpacing.s20)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CreatorChannelCommunityPostDetailCommentRow: View {
|
||||
let comment: CreatorChannelCommunityCommentResponse
|
||||
let creatorId: Int
|
||||
let onTapReplyDetail: () -> Void
|
||||
let onTapMore: () -> Void
|
||||
|
||||
private var currentUserId: Int { UserDefaults.int(forKey: .userId) }
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTapReplyDetail) {
|
||||
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
|
||||
HStack(alignment: .top, spacing: SodaSpacing.s8) {
|
||||
DownsampledKFImage(url: URL(string: comment.writerProfileImageUrl), size: CGSize(width: 42, height: 42))
|
||||
.background(Color.gray800)
|
||||
.clipShape(Circle())
|
||||
.frame(width: 42, height: 42)
|
||||
|
||||
VStack(alignment: .leading, spacing: SodaSpacing.s8) {
|
||||
header
|
||||
|
||||
Text(comment.content)
|
||||
.appFont(size: 16, weight: .regular)
|
||||
.foregroundColor(.white)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
if let latestReply = comment.latestReply {
|
||||
latestReplyCard(latestReply)
|
||||
.padding(.leading, 50)
|
||||
.background(alignment: .leading) {
|
||||
CreatorChannelCommunityPostDetailReplyConnector()
|
||||
.stroke(Color.gray800, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
|
||||
.frame(width: 42)
|
||||
.padding(.leading, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: SodaSpacing.s8) {
|
||||
Text(comment.writerNickname)
|
||||
.appFont(size: 14, weight: .medium)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(comment.relativeTimeText())
|
||||
.appFont(size: 14, weight: .regular)
|
||||
.foregroundColor(Color.gray500)
|
||||
.lineLimit(1)
|
||||
|
||||
if comment.isSecret {
|
||||
Text(I18n.Explorer.secretComment)
|
||||
.appFont(size: 11, weight: .medium)
|
||||
.foregroundColor(Color.grayee)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.soda400.opacity(0.2))
|
||||
.cornerRadius(3)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if showsMoreButton {
|
||||
Button(action: onTapMore) {
|
||||
Image("ic_new_more")
|
||||
.resizable()
|
||||
.frame(width: 24, height: 24)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func latestReplyCard(_ latestReply: CreatorChannelCommunityReplyResponse) -> some View {
|
||||
VStack(alignment: .leading, spacing: SodaSpacing.s8) {
|
||||
HStack(spacing: SodaSpacing.s8) {
|
||||
DownsampledKFImage(url: URL(string: latestReply.writerProfileImageUrl), size: CGSize(width: 20, height: 20))
|
||||
.background(Color.gray800)
|
||||
.clipShape(Circle())
|
||||
.frame(width: 20, height: 20)
|
||||
|
||||
Text(latestReply.writerNickname)
|
||||
.appFont(size: 13, weight: .medium)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(latestReply.relativeTimeText())
|
||||
.appFont(size: 14, weight: .regular)
|
||||
.foregroundColor(Color.gray500)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
Text(latestReply.content)
|
||||
.appFont(size: 16, weight: .regular)
|
||||
.foregroundColor(.white)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(SodaSpacing.s12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.gray900)
|
||||
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||
}
|
||||
|
||||
private var showsMoreButton: Bool {
|
||||
comment.writerId == currentUserId || creatorId == currentUserId
|
||||
}
|
||||
}
|
||||
|
||||
private struct CreatorChannelCommunityPostDetailReplyConnector: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
let startX: CGFloat = 0
|
||||
let endX: CGFloat = rect.width
|
||||
let endY = rect.height - 8
|
||||
let bendY = max(0, endY - 14)
|
||||
|
||||
path.move(to: CGPoint(x: startX, y: 0))
|
||||
path.addLine(to: CGPoint(x: startX, y: bendY))
|
||||
path.addQuadCurve(to: CGPoint(x: endX, y: endY), control: CGPoint(x: startX, y: endY))
|
||||
return path
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import SwiftUI
|
||||
|
||||
import SDWebImageSwiftUI
|
||||
|
||||
struct CreatorChannelCommunityPostDetailContentView: View {
|
||||
let detail: CreatorChannelCommunityPostDetailResponse
|
||||
let onTapLike: () -> Void
|
||||
let onTapPurchase: () -> Void
|
||||
|
||||
@StateObject private var playManager = CreatorCommunityMediaPlayerManager.shared
|
||||
@StateObject private var contentPlayManager = ContentPlayManager.shared
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: SodaSpacing.s16) {
|
||||
if detail.isPinned {
|
||||
pinnedLabel
|
||||
}
|
||||
|
||||
header
|
||||
|
||||
Text(detail.content)
|
||||
.appFont(size: 16, weight: .regular)
|
||||
.foregroundColor(.white)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
mediaContent
|
||||
reactionBar
|
||||
}
|
||||
.padding(SodaSpacing.s14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
private var pinnedLabel: some View {
|
||||
HStack(spacing: 2) {
|
||||
Image("ic_pin")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 18, height: 18)
|
||||
|
||||
Text("Notice")
|
||||
.appFont(size: 14, weight: .medium)
|
||||
.foregroundColor(Color(hex: "73FF01"))
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: SodaSpacing.s8) {
|
||||
DownsampledKFImage(url: URL(string: detail.creatorProfileUrl), size: CGSize(width: 42, height: 42))
|
||||
.background(Color.gray800)
|
||||
.clipShape(Circle())
|
||||
.frame(width: 42, height: 42)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(detail.creatorNickname)
|
||||
.appFont(size: 14, weight: .medium)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(detail.relativeTimeText())
|
||||
.appFont(size: 14, weight: .regular)
|
||||
.foregroundColor(Color.gray500)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if detail.price > 0 && detail.existOrdered && isOwnPost == false {
|
||||
purchaseCompleteTag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var mediaContent: some View {
|
||||
if isPaidLocked {
|
||||
Button(action: onTapPurchase) {
|
||||
RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)
|
||||
.fill(Color.gray800)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 236)
|
||||
.overlay(paidLockedOverlay)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else if let imageUrl = detail.imageUrl, imageUrl.isEmpty == false {
|
||||
ZStack {
|
||||
WebImage(url: URL(string: imageUrl))
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.gray800)
|
||||
|
||||
audioPlayButton
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||
} else if detail.audioUrl?.isEmpty == false {
|
||||
RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)
|
||||
.fill(Color.gray900)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 132)
|
||||
.overlay(audioPlayButton)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var audioPlayButton: some View {
|
||||
if let audioUrl = detail.audioUrl, audioUrl.isEmpty == false {
|
||||
Button {
|
||||
contentPlayManager.pauseAudio()
|
||||
playManager.toggleContent(item: CreatorCommunityContentItem(contentId: detail.postId, url: audioUrl))
|
||||
} label: {
|
||||
Image(playManager.isPlaying && playManager.currentPlayingContentId == detail.postId ? "btn_audio_content_pause" : "btn_audio_content_play")
|
||||
.resizable()
|
||||
.frame(width: 48, height: 48)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private var paidLockedOverlay: some View {
|
||||
VStack(spacing: SodaSpacing.s4) {
|
||||
Image("ic_new_community_lock")
|
||||
.resizable()
|
||||
.renderingMode(.template)
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 24, height: 24)
|
||||
|
||||
HStack(spacing: SodaSpacing.s6) {
|
||||
Image("ic_bar_cash")
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20)
|
||||
|
||||
Text("\(detail.price)")
|
||||
.appFont(size: 16, weight: .medium)
|
||||
.foregroundColor(Color.black)
|
||||
}
|
||||
.padding(SodaSpacing.s8)
|
||||
.background(Color.white)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
private var purchaseCompleteTag: some View {
|
||||
Text("구매완료")
|
||||
.appFont(size: 12, weight: .medium)
|
||||
.foregroundColor(Color.black)
|
||||
.padding(.horizontal, SodaSpacing.s8)
|
||||
.padding(.vertical, SodaSpacing.s4)
|
||||
.background(Color.soda400)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
private var reactionBar: some View {
|
||||
HStack(spacing: 15) {
|
||||
if isPaidLocked == false {
|
||||
Button(action: onTapLike) {
|
||||
HStack(spacing: SodaSpacing.s4) {
|
||||
Image(detail.isLiked ? "ic_feed_community_heart_fill" : "ic_feed_community_heart")
|
||||
.resizable()
|
||||
.frame(width: 18, height: 18)
|
||||
|
||||
Text("\(detail.likeCount)")
|
||||
.appFont(size: 16, weight: .regular)
|
||||
.foregroundColor(Color.gray500)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if detail.isCommentAvailable {
|
||||
HStack(spacing: SodaSpacing.s4) {
|
||||
Image("ic_feed_community_reply")
|
||||
.resizable()
|
||||
.frame(width: 18, height: 18)
|
||||
|
||||
Text("\(detail.commentCount)")
|
||||
.appFont(size: 16, weight: .regular)
|
||||
.foregroundColor(Color.gray500)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 24)
|
||||
}
|
||||
|
||||
private var isOwnPost: Bool {
|
||||
detail.creatorId == UserDefaults.int(forKey: .userId)
|
||||
}
|
||||
|
||||
private var isPaidLocked: Bool {
|
||||
detail.price > 0 && detail.existOrdered == false && isOwnPost == false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CreatorChannelCommunityPostDetailInputBar: View {
|
||||
@Binding var text: String
|
||||
@Binding var isSecret: Bool
|
||||
let isShowSecret: Bool
|
||||
let isSendEnabled: Bool
|
||||
let isEditing: Bool
|
||||
let onTapSend: () -> Void
|
||||
let onTapCancelEditing: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(Color.gray800)
|
||||
.frame(height: 1)
|
||||
|
||||
if isShowSecret {
|
||||
secretToggle
|
||||
}
|
||||
|
||||
HStack(alignment: .bottom, spacing: SodaSpacing.s8) {
|
||||
if isEditing {
|
||||
Button(action: onTapCancelEditing) {
|
||||
Image("ic_close_white")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 20, height: 20)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
TextField(I18n.CreatorChannelCommunity.commentPlaceholder, text: $text, axis: .vertical)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.appFont(size: 15, weight: .regular)
|
||||
.foregroundColor(.white)
|
||||
.accentColor(Color.soda400)
|
||||
.lineLimit(1...3)
|
||||
.padding(.horizontal, SodaSpacing.s14)
|
||||
.padding(.vertical, SodaSpacing.s8)
|
||||
.frame(minHeight: 40)
|
||||
.background(Color.gray900)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
|
||||
Button(action: {
|
||||
guard isSendEnabled else { return }
|
||||
onTapSend()
|
||||
}) {
|
||||
Image(isSendEnabled ? "ic_arrow_up_white" : "ic_arrow_up_gray")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 20, height: 20)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(isSendEnabled ? Color.soda400 : Color.gray900)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isSendEnabled == false)
|
||||
}
|
||||
.padding(.horizontal, SodaSpacing.s14)
|
||||
.padding(.vertical, SodaSpacing.s8)
|
||||
}
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
private var secretToggle: some View {
|
||||
Button {
|
||||
isSecret.toggle()
|
||||
} label: {
|
||||
HStack(spacing: SodaSpacing.s6) {
|
||||
Image(isSecret ? "btn_square_select_checked" : "btn_square_select_normal")
|
||||
.resizable()
|
||||
.frame(width: 18, height: 18)
|
||||
|
||||
Text(I18n.Explorer.secretComment)
|
||||
.appFont(size: 13, weight: .regular)
|
||||
.foregroundColor(Color.gray500)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, SodaSpacing.s14)
|
||||
.padding(.top, SodaSpacing.s8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct CreatorChannelCommunityPostDetailInputBar_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
PreviewWrapper(
|
||||
text: "Secret comment",
|
||||
isSecret: true,
|
||||
isShowSecret: true,
|
||||
isSendEnabled: true,
|
||||
isEditing: false
|
||||
)
|
||||
.padding(.vertical, SodaSpacing.s16)
|
||||
.background(Color.black)
|
||||
.previewLayout(.sizeThatFits)
|
||||
.previewDisplayName("isShowSecret: true")
|
||||
|
||||
PreviewWrapper(
|
||||
text: "Public comment",
|
||||
isSecret: false,
|
||||
isShowSecret: false,
|
||||
isSendEnabled: true,
|
||||
isEditing: false
|
||||
)
|
||||
.padding(.vertical, SodaSpacing.s16)
|
||||
.background(Color.black)
|
||||
.previewLayout(.sizeThatFits)
|
||||
.previewDisplayName("isShowSecret: false")
|
||||
}
|
||||
}
|
||||
|
||||
private struct PreviewWrapper: View {
|
||||
@State private var text: String
|
||||
@State private var isSecret: Bool
|
||||
let isShowSecret: Bool
|
||||
let isSendEnabled: Bool
|
||||
let isEditing: Bool
|
||||
|
||||
init(
|
||||
text: String,
|
||||
isSecret: Bool,
|
||||
isShowSecret: Bool,
|
||||
isSendEnabled: Bool,
|
||||
isEditing: Bool
|
||||
) {
|
||||
_text = State(initialValue: text)
|
||||
_isSecret = State(initialValue: isSecret)
|
||||
self.isShowSecret = isShowSecret
|
||||
self.isSendEnabled = isSendEnabled
|
||||
self.isEditing = isEditing
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
CreatorChannelCommunityPostDetailInputBar(
|
||||
text: $text,
|
||||
isSecret: $isSecret,
|
||||
isShowSecret: isShowSecret,
|
||||
isSendEnabled: isSendEnabled,
|
||||
isEditing: isEditing,
|
||||
onTapSend: {},
|
||||
onTapCancelEditing: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CreatorChannelCommunityPostDetailView: View {
|
||||
let postId: Int
|
||||
let onCommunityRefresh: (() -> Void)?
|
||||
|
||||
@StateObject private var viewModel = CreatorChannelCommunityPostDetailViewModel()
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
VStack(spacing: 0) {
|
||||
CreatorChannelReplyDetailTitleBar {
|
||||
AppState.shared.back()
|
||||
}
|
||||
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if let detail = viewModel.detail {
|
||||
CreatorChannelCommunityPostDetailContentView(
|
||||
detail: detail,
|
||||
onTapLike: viewModel.toggleLike,
|
||||
onTapPurchase: showPurchaseError
|
||||
)
|
||||
|
||||
if detail.isCommentAvailable {
|
||||
CreatorChannelCommunityPostDetailCommentListView(
|
||||
creatorId: detail.creatorId,
|
||||
postId: detail.postId,
|
||||
commentCount: detail.commentCount,
|
||||
comments: viewModel.comments,
|
||||
selectedComment: viewModel.selectedComment,
|
||||
canEdit: viewModel.canEdit,
|
||||
canDelete: viewModel.canDelete,
|
||||
onAppearComment: viewModel.fetchNextCommentsIfNeeded,
|
||||
onTapReplyDetail: showReplyDetail,
|
||||
onTapMore: viewModel.selectComment,
|
||||
onTapEdit: viewModel.beginEditing,
|
||||
onTapDelete: viewModel.presentDeleteDialog
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: SodaSpacing.s20)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color.black.ignoresSafeArea())
|
||||
|
||||
if viewModel.isLoading && viewModel.hasLoaded == false {
|
||||
LoadingView()
|
||||
}
|
||||
|
||||
if viewModel.isShowDeleteDialog {
|
||||
SodaV2ActionModal(
|
||||
title: I18n.Common.commentDeleteTitle,
|
||||
message: I18n.Common.confirmDeleteQuestion,
|
||||
button1: SodaV2ActionModalButton(
|
||||
label: I18n.Common.delete,
|
||||
action: viewModel.confirmDelete
|
||||
),
|
||||
button2: SodaV2ActionModalButton(
|
||||
label: I18n.Common.cancel,
|
||||
action: dismissDeleteDialog
|
||||
),
|
||||
onDimmedTap: dismissDeleteDialog
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.onAppear {
|
||||
viewModel.configure(postId: postId, onCommunityRefresh: onCommunityRefresh)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if viewModel.detail?.isCommentAvailable == true {
|
||||
CreatorChannelCommunityPostDetailInputBar(
|
||||
text: $viewModel.commentText,
|
||||
isSecret: $viewModel.isSecret,
|
||||
isShowSecret: viewModel.isShowSecret,
|
||||
isSendEnabled: viewModel.isCommentSendEnabled,
|
||||
isEditing: viewModel.isEditing,
|
||||
onTapSend: viewModel.sendComment,
|
||||
onTapCancelEditing: viewModel.cancelEditing
|
||||
)
|
||||
}
|
||||
}
|
||||
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1)
|
||||
}
|
||||
|
||||
private func dismissDeleteDialog() {
|
||||
viewModel.isShowDeleteDialog = false
|
||||
viewModel.deletingComment = nil
|
||||
}
|
||||
|
||||
private func showReplyDetail(_ comment: CreatorChannelCommunityCommentResponse) {
|
||||
guard let detail = viewModel.detail else { return }
|
||||
AppState.shared.setAppStep(
|
||||
step: .creatorChannelReplyDetail(
|
||||
context: .communityPostDetail(
|
||||
creatorId: detail.creatorId,
|
||||
postId: detail.postId,
|
||||
parentComment: comment
|
||||
),
|
||||
onCommunityRefresh: {
|
||||
viewModel.fetchDetail(postId: postId)
|
||||
onCommunityRefresh?()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func showPurchaseError() {
|
||||
viewModel.errorMessage = I18n.Common.commonError
|
||||
viewModel.isShowPopup = true
|
||||
}
|
||||
|
||||
}
|
||||