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" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "ic_pin.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
@@ -9,7 +10,6 @@
|
|||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "ic_pin.png",
|
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "3x"
|
"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>()
|
private var subscription = Set<AnyCancellable>()
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||||
|
guard !Self.isRunningForPreviews else { return true }
|
||||||
|
|
||||||
FirebaseApp.configure()
|
FirebaseApp.configure()
|
||||||
LoginManager.shared.setup(channelID: LINE_CHANNEL_ID, universalLinkURL: nil)
|
LoginManager.shared.setup(channelID: LINE_CHANNEL_ID, universalLinkURL: nil)
|
||||||
Notifly.initialize(projectId: NOTIFLY_PROJECT_ID, username: NOTIFLY_USERNAME, password: NOTIFLY_PASSWORD)
|
Notifly.initialize(projectId: NOTIFLY_PROJECT_ID, username: NOTIFLY_USERNAME, password: NOTIFLY_PASSWORD)
|
||||||
@@ -50,6 +52,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
|
|
||||||
return true
|
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) {
|
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||||
// Print full message.
|
// Print full message.
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ enum AppStep {
|
|||||||
|
|
||||||
case serviceCenter
|
case serviceCenter
|
||||||
|
|
||||||
case createContent
|
case createContent(onSuccess: (() -> Void)? = nil)
|
||||||
|
|
||||||
case modifyContent(contentId: Int)
|
case modifyContent(contentId: Int)
|
||||||
|
|
||||||
@@ -127,6 +127,25 @@ enum AppStep {
|
|||||||
case creatorCommunityWrite(onSuccess: () -> Void)
|
case creatorCommunityWrite(onSuccess: () -> Void)
|
||||||
|
|
||||||
case creatorCommunityModify(postId: Int, 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)
|
case canCoupon(refresh: () -> Void)
|
||||||
|
|
||||||
@@ -167,6 +186,8 @@ enum AppStep {
|
|||||||
case originalWorkDetail(originalId: Int)
|
case originalWorkDetail(originalId: Int)
|
||||||
|
|
||||||
case contentAll(isFree: Bool = false, isPointOnly: Bool = false)
|
case contentAll(isFree: Bool = false, isPointOnly: Bool = false)
|
||||||
|
|
||||||
|
case contentOverview(type: ContentOverviewType = .newAndHotAudio)
|
||||||
|
|
||||||
case seriesMain
|
case seriesMain
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,14 @@ struct SodaLiveApp: App {
|
|||||||
configureImageCache()
|
configureImageCache()
|
||||||
// 앱 시작 직후, 초기 네트워크 요청도 올바른 언어 헤더를 갖도록 동기 초기화
|
// 앱 시작 직후, 초기 네트워크 요청도 올바른 언어 헤더를 갖도록 동기 초기화
|
||||||
LanguageHeaderProvider.initialize()
|
LanguageHeaderProvider.initialize()
|
||||||
|
guard !Self.isRunningForPreviews else { return }
|
||||||
|
|
||||||
KakaoSDK.initSDK(appKey: KAKAO_APP_KEY)
|
KakaoSDK.initSDK(appKey: KAKAO_APP_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static var isRunningForPreviews: Bool {
|
||||||
|
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
||||||
|
}
|
||||||
|
|
||||||
private func configureImageCache() {
|
private func configureImageCache() {
|
||||||
// Kingfisher 전역 캐시 상한 설정
|
// Kingfisher 전역 캐시 상한 설정
|
||||||
@@ -50,6 +56,8 @@ struct SodaLiveApp: App {
|
|||||||
ContentView(canPgPaymentViewModel: canPgPaymentViewModel)
|
ContentView(canPgPaymentViewModel: canPgPaymentViewModel)
|
||||||
.environment(\.locale, languageEnvironment.locale)
|
.environment(\.locale, languageEnvironment.locale)
|
||||||
.task {
|
.task {
|
||||||
|
guard !Self.isRunningForPreviews else { return }
|
||||||
|
|
||||||
await LanguageContainer.service.bootstrap()
|
await LanguageContainer.service.bootstrap()
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)) { _ in
|
||||||
@@ -62,6 +70,8 @@ struct SodaLiveApp: App {
|
|||||||
ImageCache.default.clearMemoryCache()
|
ImageCache.default.clearMemoryCache()
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||||
|
guard !Self.isRunningForPreviews else { return }
|
||||||
|
|
||||||
UIApplication.shared.applicationIconBadgeNumber = 0
|
UIApplication.shared.applicationIconBadgeNumber = 0
|
||||||
AppsFlyerLib.shared().start()
|
AppsFlyerLib.shared().start()
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,44 @@ enum DateParser {
|
|||||||
|
|
||||||
return I18n.Time.daysAgo(max(1, Int(interval / 86_400)))
|
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 유사 → 공백 구분 기본
|
// 시도 순서: ISO8601(소수초 포함) → ISO8601 → RFC3339 유사 → 공백 구분 기본
|
||||||
private static let parsers: [(String) -> Date?] = [
|
private static let parsers: [(String) -> Date?] = [
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ struct ContentListView: View {
|
|||||||
.cornerRadius(5.3)
|
.cornerRadius(5.3)
|
||||||
.padding(.top, 13.3)
|
.padding(.top, 13.3)
|
||||||
.padding(.horizontal, 13.3)
|
.padding(.horizontal, 13.3)
|
||||||
.onTapGesture { AppState.shared.setAppStep(step: .createContent) }
|
.onTapGesture { AppState.shared.setAppStep(step: .createContent()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 13.3) {
|
HStack(spacing: 13.3) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Kingfisher
|
|||||||
|
|
||||||
struct ContentCreateView: View {
|
struct ContentCreateView: View {
|
||||||
@Environment(\.locale) private var locale
|
@Environment(\.locale) private var locale
|
||||||
|
let onSuccess: (() -> Void)?
|
||||||
|
|
||||||
@StateObject var keyboardHandler = KeyboardHandler()
|
@StateObject var keyboardHandler = KeyboardHandler()
|
||||||
@StateObject private var viewModel = ContentCreateViewModel()
|
@StateObject private var viewModel = ContentCreateViewModel()
|
||||||
@@ -23,6 +24,10 @@ struct ContentCreateView: View {
|
|||||||
@State private var isShowSelectThemeView = false
|
@State private var isShowSelectThemeView = false
|
||||||
@State private var isShowSelectDateView = false
|
@State private var isShowSelectDateView = false
|
||||||
@State private var isShowSelectTimeView = false
|
@State private var isShowSelectTimeView = false
|
||||||
|
|
||||||
|
init(onSuccess: (() -> Void)? = nil) {
|
||||||
|
self.onSuccess = onSuccess
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let normalizedCountryCode = UserDefaults
|
let normalizedCountryCode = UserDefaults
|
||||||
@@ -655,7 +660,10 @@ struct ContentCreateView: View {
|
|||||||
title: I18n.CreateContent.uploadTitle,
|
title: I18n.CreateContent.uploadTitle,
|
||||||
desc: I18n.CreateContent.uploadDescription,
|
desc: I18n.CreateContent.uploadDescription,
|
||||||
confirmButtonTitle: I18n.Common.confirm,
|
confirmButtonTitle: I18n.Common.confirm,
|
||||||
confirmButtonAction: { AppState.shared.back() },
|
confirmButtonAction: {
|
||||||
|
onSuccess?()
|
||||||
|
AppState.shared.back()
|
||||||
|
},
|
||||||
cancelButtonTitle: "",
|
cancelButtonTitle: "",
|
||||||
cancelButtonAction: {}
|
cancelButtonAction: {}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -162,14 +162,14 @@ struct AppStepLayerView: View {
|
|||||||
case .serviceCenter:
|
case .serviceCenter:
|
||||||
ServiceCenterView()
|
ServiceCenterView()
|
||||||
|
|
||||||
case .createContent:
|
case .createContent(let onSuccess):
|
||||||
ContentCreateView()
|
ContentCreateView(onSuccess: onSuccess)
|
||||||
|
|
||||||
case .liveReservationComplete(let response):
|
case .liveReservationComplete(let response):
|
||||||
LiveReservationCompleteView(reservationCompleteData: response)
|
LiveReservationCompleteView(reservationCompleteData: response)
|
||||||
|
|
||||||
case .creatorDetail(let userId):
|
case .creatorDetail(let userId):
|
||||||
UserProfileView(userId: userId)
|
CreatorChannelView(creatorId: userId)
|
||||||
|
|
||||||
case .followerList(let userId):
|
case .followerList(let userId):
|
||||||
FollowerListView(userId: userId)
|
FollowerListView(userId: userId)
|
||||||
@@ -254,6 +254,19 @@ struct AppStepLayerView: View {
|
|||||||
case .creatorCommunityModify(let postId, let onSuccess):
|
case .creatorCommunityModify(let postId, let onSuccess):
|
||||||
CreatorCommunityModifyView(postId: postId, onSuccess: 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):
|
case .canCoupon(let refresh):
|
||||||
CanCouponView(refresh: refresh)
|
CanCouponView(refresh: refresh)
|
||||||
|
|
||||||
@@ -317,6 +330,11 @@ struct AppStepLayerView: View {
|
|||||||
case .contentAll(let isFree, let isPointOnly):
|
case .contentAll(let isFree, let isPointOnly):
|
||||||
ContentAllView(isFree: isFree, isPointAvailableOnly: isPointOnly)
|
ContentAllView(isFree: isFree, isPointAvailableOnly: isPointOnly)
|
||||||
|
|
||||||
|
case .contentOverview(let type):
|
||||||
|
ContentOverviewView(type: type) { contentId in
|
||||||
|
AppState.shared.setAppStep(step: .contentDetail(contentId: contentId))
|
||||||
|
}
|
||||||
|
|
||||||
case .seriesMain:
|
case .seriesMain:
|
||||||
SeriesMainView()
|
SeriesMainView()
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ struct CreatorCommunityCommentItemView: View {
|
|||||||
let modifyComment: (Int, String) -> Void
|
let modifyComment: (Int, String) -> Void
|
||||||
let onClickDelete: (Int) -> Void
|
let onClickDelete: (Int) -> Void
|
||||||
let onClickProfile: (Int) -> Void
|
let onClickProfile: (Int) -> Void
|
||||||
|
let onTapReplyDetail: () -> Void
|
||||||
|
|
||||||
@State var isShowPopupMenu: Bool = false
|
@State var isShowPopupMenu: Bool = false
|
||||||
@State var isModeModify: Bool = false
|
@State var isModeModify: Bool = false
|
||||||
@@ -110,19 +111,19 @@ struct CreatorCommunityCommentItemView: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.lineSpacing(8)
|
.lineSpacing(8)
|
||||||
.padding(.top, 13.3)
|
.padding(.top, 13.3)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
if isReplyComment == false {
|
||||||
|
onTapReplyDetail()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !isReplyComment {
|
if !isReplyComment {
|
||||||
NavigationLink(
|
Text(commentItem.replyCount > 0 ? I18n.Explorer.replyCount(commentItem.replyCount) : I18n.Explorer.replyWrite)
|
||||||
destination: CreatorCommunityCommentReplyView(
|
.appFont(size: 13.3, weight: .medium)
|
||||||
creatorId: creatorId,
|
.foregroundColor(Color.button)
|
||||||
postId: postId,
|
.onTapGesture(perform: onTapReplyDetail)
|
||||||
parentComment: commentItem
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text(commentItem.replyCount > 0 ? I18n.Explorer.replyCount(commentItem.replyCount) : I18n.Explorer.replyWrite)
|
|
||||||
.appFont(size: 13.3, weight: .medium)
|
|
||||||
.foregroundColor(Color.button)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,20 @@ struct CreatorCommunityCommentListView: View {
|
|||||||
onClickProfile: {
|
onClickProfile: {
|
||||||
memberId = $0
|
memberId = $0
|
||||||
isShowMemberProfilePopup = true
|
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)
|
.padding(.horizontal, 26.7)
|
||||||
|
|||||||
@@ -105,7 +105,8 @@ struct CreatorCommunityCommentReplyView: View {
|
|||||||
onClickProfile: {
|
onClickProfile: {
|
||||||
memberId = $0
|
memberId = $0
|
||||||
isShowMemberProfilePopup = true
|
isShowMemberProfilePopup = true
|
||||||
}
|
},
|
||||||
|
onTapReplyDetail: {}
|
||||||
)
|
)
|
||||||
.padding(.horizontal, 26.7)
|
.padding(.horizontal, 26.7)
|
||||||
.padding(.bottom, 13.3)
|
.padding(.bottom, 13.3)
|
||||||
@@ -131,7 +132,8 @@ struct CreatorCommunityCommentReplyView: View {
|
|||||||
onClickProfile: {
|
onClickProfile: {
|
||||||
memberId = $0
|
memberId = $0
|
||||||
isShowMemberProfilePopup = true
|
isShowMemberProfilePopup = true
|
||||||
}
|
},
|
||||||
|
onTapReplyDetail: {}
|
||||||
)
|
)
|
||||||
.padding(.horizontal, 26.7)
|
.padding(.horizontal, 26.7)
|
||||||
.padding(.leading, 13.3)
|
.padding(.leading, 13.3)
|
||||||
|
|||||||
@@ -52,14 +52,14 @@ struct CreatorCommunityReportView: View {
|
|||||||
|
|
||||||
Text(I18n.Common.cancel)
|
Text(I18n.Common.cancel)
|
||||||
.appFont(size: 13.3, weight: .medium)
|
.appFont(size: 13.3, weight: .medium)
|
||||||
.foregroundColor(Color(hex: "9970ff"))
|
.foregroundColor(Color.soda400)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
isShowing = false
|
isShowing = false
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(I18n.Report.reportAction)
|
Text(I18n.Report.reportAction)
|
||||||
.appFont(size: 13.3, weight: .medium)
|
.appFont(size: 13.3, weight: .medium)
|
||||||
.foregroundColor(Color(hex: "9970ff"))
|
.foregroundColor(Color.soda400)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if let selectedIndex = selectedIndex {
|
if let selectedIndex = selectedIndex {
|
||||||
confirmAction(reasons[selectedIndex])
|
confirmAction(reasons[selectedIndex])
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ struct UserProfileContentView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(Color.button)
|
.background(Color.button)
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
.onTapGesture { AppState.shared.setAppStep(step: .createContent) }
|
.onTapGesture { AppState.shared.setAppStep(step: .createContent()) }
|
||||||
} else {
|
} else {
|
||||||
collectionInfoView()
|
collectionInfoView()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ struct HomeTabView: View {
|
|||||||
.padding(.trailing, 16.7)
|
.padding(.trailing, 16.7)
|
||||||
.padding(.bottom, 16.7)
|
.padding(.bottom, 16.7)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
AppState.shared.setAppStep(step: .createContent)
|
AppState.shared.setAppStep(step: .createContent())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
enum SeriesPublishedDaysOfWeek: String, Encodable {
|
enum SeriesPublishedDaysOfWeek: String, Encodable, Decodable, CaseIterable, Hashable {
|
||||||
case SUN, MON, TUE, WED, THU, FRI, SAT, RANDOM
|
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 Main {
|
||||||
enum EventPopup {
|
enum EventPopup {
|
||||||
static var doNotShowAgain: String {
|
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 home: String { pick(ko: "홈", en: "Home", ja: "ホーム") }
|
||||||
static var content: String { pick(ko: "콘텐츠", en: "Content", ja: "コンテンツ") }
|
static var content: String { pick(ko: "콘텐츠", en: "Content", ja: "コンテンツ") }
|
||||||
static var live: String { pick(ko: "라이브", en: "Live", 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: "マイ") }
|
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 {
|
enum Home {
|
||||||
static var liveNowSectionTitle: String {
|
static var liveNowSectionTitle: String {
|
||||||
pick(ko: "지금 라이브중", en: "Live now", ja: "ライブ配信中")
|
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 {
|
enum HomeRecommendation {
|
||||||
static var activityLive: String {
|
static var activityLive: String {
|
||||||
pick(ko: "라이브", en: "Live", ja: "ライブ")
|
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: "編集")
|
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 {
|
static var report: String {
|
||||||
pick(ko: "신고", en: "Report", ja: "通報")
|
pick(ko: "신고", en: "Report", ja: "通報")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ struct SplashView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func fetchLastestVersion() {
|
private func fetchLastestVersion() {
|
||||||
|
guard !isRunningForPreviews else { return }
|
||||||
|
|
||||||
let remoteConfig = RemoteConfig.remoteConfig()
|
let remoteConfig = RemoteConfig.remoteConfig()
|
||||||
let configSettings = RemoteConfigSettings()
|
let configSettings = RemoteConfigSettings()
|
||||||
configSettings.minimumFetchInterval = 60
|
configSettings.minimumFetchInterval = 60
|
||||||
@@ -163,6 +165,10 @@ struct SplashView: View {
|
|||||||
nextAppStep()
|
nextAppStep()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isRunningForPreviews: Bool {
|
||||||
|
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#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(
|
init(
|
||||||
items: [BannerCarouselItem],
|
items: [BannerCarouselItem],
|
||||||
height: CGFloat = 120,
|
|
||||||
action: @escaping (BannerCarouselItem) -> Void = { _ in }
|
action: @escaping (BannerCarouselItem) -> Void = { _ in }
|
||||||
) {
|
) {
|
||||||
self.items = Array(items.prefix(BannerCarousel.maxItemCount))
|
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 {
|
struct BannerCarousel_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
BannerCarousel(
|
BannerCarousel(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
struct CommunityPostCard: View {
|
struct CommunityPostCard: View {
|
||||||
let postId: Int
|
let postId: Int
|
||||||
@@ -67,8 +68,11 @@ struct CommunityPostCard: View {
|
|||||||
imageContent
|
imageContent
|
||||||
}
|
}
|
||||||
|
|
||||||
reactionBar
|
if isAccessiblePost {
|
||||||
|
reactionBar
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(SodaSpacing.s14)
|
.padding(SodaSpacing.s14)
|
||||||
.background(Color.gray900)
|
.background(Color.gray900)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||||
@@ -110,28 +114,26 @@ struct CommunityPostCard: View {
|
|||||||
.lineLimit(5)
|
.lineLimit(5)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var imageContent: some View {
|
private var imageContent: some View {
|
||||||
GeometryReader { proxy in
|
ZStack {
|
||||||
ZStack {
|
KFImage(URL(string: imageUrl ?? ""))
|
||||||
DownsampledKFImage(
|
.cancelOnDisappear(true)
|
||||||
url: URL(string: imageUrl ?? ""),
|
.resizable()
|
||||||
size: CGSize(width: proxy.size.width, height: proxy.size.height)
|
.scaledToFit()
|
||||||
)
|
.frame(maxWidth: .infinity)
|
||||||
.background(Color.gray800)
|
.background(Color.gray800)
|
||||||
.frame(width: proxy.size.width, height: proxy.size.height)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
|
||||||
.blur(radius: isLocked ? 12 : 0)
|
.blur(radius: isLocked ? 12 : 0)
|
||||||
|
|
||||||
if isLocked {
|
if isLocked {
|
||||||
paidLockOverlay
|
paidLockOverlay
|
||||||
} else if hasAudio {
|
} else if hasAudio {
|
||||||
audioPlayButton
|
audioPlayButton
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.aspectRatio(346.0 / 236.0, contentMode: .fit)
|
.frame(maxWidth: .infinity)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +150,7 @@ struct CommunityPostCard: View {
|
|||||||
.frame(width: 24, height: 24)
|
.frame(width: 24, height: 24)
|
||||||
|
|
||||||
HStack(spacing: SodaSpacing.s6) {
|
HStack(spacing: SodaSpacing.s6) {
|
||||||
Image("ic_can")
|
Image("ic_bar_cash")
|
||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 20, height: 20)
|
.frame(width: 20, height: 20)
|
||||||
|
|
||||||
@@ -224,6 +226,10 @@ struct CommunityPostCard: View {
|
|||||||
price > 0 && !existOrdered
|
price > 0 && !existOrdered
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isAccessiblePost: Bool {
|
||||||
|
price == 0 || existOrdered
|
||||||
|
}
|
||||||
|
|
||||||
private func toggleLike() {
|
private func toggleLike() {
|
||||||
localIsLike.toggle()
|
localIsLike.toggle()
|
||||||
localLikeCount += localIsLike ? 1 : -1
|
localLikeCount += localIsLike ? 1 : -1
|
||||||
@@ -236,39 +242,56 @@ struct CommunityPostCard_Previews: PreviewProvider {
|
|||||||
private static let previewImageUrl = "https://picsum.photos/500/500"
|
private static let previewImageUrl = "https://picsum.photos/500/500"
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
VStack(spacing: SodaSpacing.s8) {
|
ScrollView(.vertical, showsIndicators: false) {
|
||||||
CommunityPostCard(
|
VStack(spacing: SodaSpacing.s8) {
|
||||||
postId: 1,
|
CommunityPostCard(
|
||||||
creatorNickname: "크리에이터 이름",
|
postId: 1,
|
||||||
creatorProfileImageUrl: previewImageUrl,
|
creatorNickname: "크리에이터 이름",
|
||||||
content: "크리에이터가 커뮤니티에 올린 글이 보이는 부분 크리에이터가 커뮤니티에 올린 글이 보이는 부분",
|
creatorProfileImageUrl: previewImageUrl,
|
||||||
imageUrl: nil,
|
content: "크리에이터가 커뮤니티에 올린 글이 보이는 부분 크리에이터가 커뮤니티에 올린 글이 보이는 부분",
|
||||||
audioUrl: nil,
|
imageUrl: nil,
|
||||||
price: 0,
|
audioUrl: nil,
|
||||||
existOrdered: false,
|
price: 0,
|
||||||
createdAt: "2분 전",
|
existOrdered: false,
|
||||||
isLike: false,
|
createdAt: "2분 전",
|
||||||
likeCount: 5,
|
isLike: false,
|
||||||
commentCount: 6
|
likeCount: 5,
|
||||||
)
|
commentCount: 6
|
||||||
|
)
|
||||||
|
|
||||||
CommunityPostCard(
|
CommunityPostCard(
|
||||||
postId: 2,
|
postId: 2,
|
||||||
creatorNickname: "크리에이터 이름",
|
creatorNickname: "크리에이터 이름",
|
||||||
creatorProfileImageUrl: previewImageUrl,
|
creatorProfileImageUrl: previewImageUrl,
|
||||||
content: "이미지가 있는 커뮤니티 게시글입니다.",
|
content: "이미지가 있는 커뮤니티 게시글입니다.",
|
||||||
imageUrl: previewImageUrl,
|
imageUrl: previewImageUrl,
|
||||||
audioUrl: "https://example.com/audio.mp3",
|
audioUrl: "https://example.com/audio.mp3",
|
||||||
price: 30,
|
price: 30,
|
||||||
existOrdered: false,
|
existOrdered: false,
|
||||||
createdAt: "2분 전",
|
createdAt: "2분 전",
|
||||||
isLike: true,
|
isLike: true,
|
||||||
likeCount: 5,
|
likeCount: 5,
|
||||||
commentCount: 6
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||