48 Commits
Author SHA1 Message Date
Yu Sung f8593dfa33 chore(project): 빌드 번호를 7로 올린다 2026-08-26 18:10:33 +09:00
Yu Sung 86f6db3e55 feat(mypage): 배너에 일본어 이미지를 적용한다 2026-08-26 18:07:33 +09:00
Yu Sung 8aaf31f1a1 chore(project): 빌드 번호를 6으로 올린다 2026-08-26 15:12:58 +09:00
Yu Sung ac29fd7114 fix(donation): 빈 상태와 캔 표시를 수정한다 2026-08-26 15:12:24 +09:00
Yu Sung 05b86bf314 fix(fantalk): 신고 문구와 빈 상태 표시를 수정한다 2026-08-26 15:11:55 +09:00
Yu Sung 32ca4e121a chore(project): 빌드 번호를 5로 올린다 2026-08-26 13:19:02 +09:00
Yu Sung e3642b3f1b fix(donation): 후원 문구에 다국어를 적용한다 2026-08-26 13:18:27 +09:00
Yu Sung e46198f87a feat(refresh): Lottie 새로고침 표시를 적용한다 2026-08-26 01:21:26 +09:00
Yu Sung fc0f7e2b50 fix(event): 이벤트 목록 중복 로드를 막는다 2026-08-25 18:23:55 +09:00
Yu Sung d1cb2e28a6 fix(home): 모두 팔로우 확인 문구를 정리한다 2026-08-25 17:42:09 +09:00
Yu Sung cb3778a76f feat(fantalk): 쓰기 입력 제한을 적용한다 2026-08-25 17:36:44 +09:00
Yu Sung 425099955b fix(component): 텍스트 탭 가로 스크롤을 적용한다 2026-08-25 16:11:35 +09:00
Yu Sung a54465cb1e chore(app-icon): 앱 아이콘 변경 2026-08-25 14:18:59 +09:00
Yu Sung c61a046b98 chore(splash): 스플래시 화면을 정리한다 2026-08-25 14:15:58 +09:00
Yu Sung af811a95ad chore(asset): 홈 로고 에셋을 정리한다 2026-08-25 14:11:43 +09:00
Yu Sung 670ae76c73 chore(brand): 앱 명칭을 소다라이브로 변경한다 2026-08-25 14:05:28 +09:00
Yu Sung 6820a18691 chore(project): 빌드 번호를 3으로 올린다 2026-08-19 17:42:25 +09:00
Yu Sung 196959ba36 fix(home): 팔로잉 빈 상태 문구를 수정한다 2026-08-19 17:41:42 +09:00
Yu Sung 6ff3dfb7ae feat(component): 캡슐 탭 선택 색상을 변경한다 2026-08-18 14:50:47 +09:00
Yu Sung b1cdbfe013 feat(home): 팔로잉 전체 버튼을 고정한다 2026-08-18 13:59:03 +09:00
Yu Sung 9339a22a7d chore(navigation): 선택 탭 아이콘을 교체한다 2026-08-18 13:35:14 +09:00
Yu Sung 2a174e5880 feat(chat): 대화 탭 당겨서 새로고침을 추가한다 2026-08-18 13:30:33 +09:00
Yu Sung bdd885a082 feat(content): 콘텐츠 탭 당겨서 새로고침을 추가한다 2026-08-18 13:30:15 +09:00
Yu Sung d2561272c0 feat(home): 홈 탭 당겨서 새로고침을 추가한다 2026-08-18 13:29:55 +09:00
Yu Sung 9297cd28f9 docs(main): 메인 탭 새로고침 계획을 기록한다 2026-08-18 13:29:36 +09:00
Yu Sung f38ed74865 docs(follow): 팔로우 해제 확인 계획을 기록한다 2026-08-18 11:41:13 +09:00
Yu Sung 16b1eab206 feat(follow): 팔로우 해제 확인 팝업을 추가한다 2026-08-18 11:40:35 +09:00
Yu Sung 702d82ebb1 fix(creator-channel): 일본어 보기 모드 제목을 숨긴다 2026-08-14 19:36:34 +09:00
Yu Sung 36d6b3ece7 fix(i18n): 전체 수량 표기를 다국어화한다 2026-08-14 18:53:28 +09:00
Yu Sung 6db26c55a8 feat(creator-channel): 홈 빈 상태 동작을 보강한다 2026-08-14 18:41:04 +09:00
Yu Sung 2203fc9089 fix(i18n): 팬레터 탭 빈 상태를 다국어화한다 2026-08-14 18:40:47 +09:00
Yu Sung d002920567 fix(i18n): 후원 탭 빈 상태를 다국어화한다 2026-08-14 18:40:18 +09:00
Yu Sung 4b209dcd72 feat(creator-channel): 차단·신고 메뉴를 연결한다 2026-08-14 16:41:33 +09:00
Yu Sung 080f36e690 fix(i18n): 전체 수량 표기를 통일한다 2026-08-14 14:16:54 +09:00
Yu Sung b011616175 fix(i18n): 크리에이터 채널 탭을 다국어화한다 2026-08-14 13:41:33 +09:00
Yu Sung 1e68e2a282 fix(i18n): 팬레터 탭 문구를 반영한다 2026-08-14 12:12:53 +09:00
Yu Sung b3a5b8f3ae fix(creator-channel): 탭 초기 위치를 보정한다 2026-08-14 12:03:00 +09:00
Yu Sung c9111fac0c fix(creator-channel): 홈 - 커뮤니티 전체보기 버튼 터치영역 수정 2026-08-14 11:39:58 +09:00
Yu Sung 643ddbc813 chore(project): 앱 버전을 1.56.1로 갱신한다 2026-08-11 20:04:27 +09:00
Yu Sung f4fffa3d70 feat(i18n): 일본어 문자열을 최신화한다 2026-08-11 20:04:01 +09:00
Yu Sung c8636a22f1 docs(creator): 커뮤니티 잠금 기록을 갱신한다 2026-08-10 18:44:14 +09:00
Yu Sung 8cbac16382 fix(creator): 잠금 게시글 더보기를 숨긴다 2026-08-10 18:43:57 +09:00
Yu Sung 57ac574bb9 fix(creator): 상세 댓글 잠금을 적용한다 2026-08-10 18:43:43 +09:00
Yu Sung a845b54f0f fix(creator): 구매완료 배지를 제거한다 2026-08-10 16:39:23 +09:00
Yu Sung 00c40161c9 fix(creator): 공지 상세 이동을 연결한다 2026-08-10 15:42:46 +09:00
Yu Sung c372d4a522 feat(creator): 커뮤니티 아이템 표시를 통일한다 2026-08-10 15:11:02 +09:00
Yu Sung 89c070875c fix(main): 현재 라이브 전체 버튼을 고정한다 2026-08-04 01:03:24 +09:00
Yu Sung f671f4f76c chore(project): 빌드 번호를 올린다 2026-08-03 23:05:03 +09:00
137 changed files with 8272 additions and 954 deletions
+62 -11
View File
@@ -387,6 +387,10 @@
405C20942D07FC6300804C0B /* UpdatePlaylistRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 405C20922D07FC6300804C0B /* UpdatePlaylistRequest.swift */; }; 405C20942D07FC6300804C0B /* UpdatePlaylistRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 405C20922D07FC6300804C0B /* UpdatePlaylistRequest.swift */; };
405C3FB32D78AB7900A1551A /* AppsFlyerLib-Static in Frameworks */ = {isa = PBXBuildFile; productRef = 405C3FB22D78AB7900A1551A /* AppsFlyerLib-Static */; }; 405C3FB32D78AB7900A1551A /* AppsFlyerLib-Static in Frameworks */ = {isa = PBXBuildFile; productRef = 405C3FB22D78AB7900A1551A /* AppsFlyerLib-Static */; };
405C3FB52D78AB8900A1551A /* AppsFlyerLib-Static in Frameworks */ = {isa = PBXBuildFile; productRef = 405C3FB42D78AB8900A1551A /* AppsFlyerLib-Static */; }; 405C3FB52D78AB8900A1551A /* AppsFlyerLib-Static in Frameworks */ = {isa = PBXBuildFile; productRef = 405C3FB42D78AB8900A1551A /* AppsFlyerLib-Static */; };
405EC8BB303D97F30037B269 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 405EC8BA303D97F30037B269 /* Lottie */; };
405EC8BD303D98100037B269 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 405EC8BC303D98100037B269 /* Lottie */; };
405EC8BF303D99D20037B269 /* pull-to-refresh.json in Resources */ = {isa = PBXBuildFile; fileRef = 405EC8BE303D99D20037B269 /* pull-to-refresh.json */; };
405EC8C0303D99D20037B269 /* pull-to-refresh.json in Resources */ = {isa = PBXBuildFile; fileRef = 405EC8BE303D99D20037B269 /* pull-to-refresh.json */; };
405F791B2D2B84D70037DE3A /* AuditionItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 405F791A2D2B84D70037DE3A /* AuditionItemView.swift */; }; 405F791B2D2B84D70037DE3A /* AuditionItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 405F791A2D2B84D70037DE3A /* AuditionItemView.swift */; };
405F791C2D2B84D70037DE3A /* AuditionItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 405F791A2D2B84D70037DE3A /* AuditionItemView.swift */; }; 405F791C2D2B84D70037DE3A /* AuditionItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 405F791A2D2B84D70037DE3A /* AuditionItemView.swift */; };
405F791F2D2BA3140037DE3A /* AuditionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 405F791E2D2BA3140037DE3A /* AuditionDetailView.swift */; }; 405F791F2D2BA3140037DE3A /* AuditionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 405F791E2D2BA3140037DE3A /* AuditionDetailView.swift */; };
@@ -1077,8 +1081,6 @@
40B28AB12A839E59008BE463 /* DetailNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B28AAF2A839E59008BE463 /* DetailNavigationBar.swift */; }; 40B28AB12A839E59008BE463 /* DetailNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B28AAF2A839E59008BE463 /* DetailNavigationBar.swift */; };
40B28AB42A839E9D008BE463 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B28AB32A839E9D008BE463 /* ImagePicker.swift */; }; 40B28AB42A839E9D008BE463 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B28AB32A839E9D008BE463 /* ImagePicker.swift */; };
40B28AB52A839E9D008BE463 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B28AB32A839E9D008BE463 /* ImagePicker.swift */; }; 40B28AB52A839E9D008BE463 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B28AB32A839E9D008BE463 /* ImagePicker.swift */; };
7A03C0022E41400100C0DE01 /* ImageCropGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A03C0012E41400100C0DE01 /* ImageCropGeometry.swift */; };
7A03C0032E41400100C0DE01 /* ImageCropGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A03C0012E41400100C0DE01 /* ImageCropGeometry.swift */; };
40B37EE82A90A32C000DE782 /* ProfileUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B37EE72A90A32C000DE782 /* ProfileUpdateView.swift */; }; 40B37EE82A90A32C000DE782 /* ProfileUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B37EE72A90A32C000DE782 /* ProfileUpdateView.swift */; };
40B37EE92A90A32C000DE782 /* ProfileUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B37EE72A90A32C000DE782 /* ProfileUpdateView.swift */; }; 40B37EE92A90A32C000DE782 /* ProfileUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B37EE72A90A32C000DE782 /* ProfileUpdateView.swift */; };
40B37EEC2A90A3CF000DE782 /* ProfileUpdateViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B37EEB2A90A3CF000DE782 /* ProfileUpdateViewModel.swift */; }; 40B37EEC2A90A3CF000DE782 /* ProfileUpdateViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B37EEB2A90A3CF000DE782 /* ProfileUpdateViewModel.swift */; };
@@ -1630,6 +1632,8 @@
72F06849269BC1F597650385 /* CreatorDetailDialogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7406AE69F7667FAA2E6DD23 /* CreatorDetailDialogView.swift */; }; 72F06849269BC1F597650385 /* CreatorDetailDialogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7406AE69F7667FAA2E6DD23 /* CreatorDetailDialogView.swift */; };
7389BAA35C414A1A0C1FE616 /* BannerCarousel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24266E32FC49D7C31610C2AD /* BannerCarousel.swift */; }; 7389BAA35C414A1A0C1FE616 /* BannerCarousel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24266E32FC49D7C31610C2AD /* BannerCarousel.swift */; };
753741799DEF278DC4C515AA /* KakaoSDKAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 8069DA64D0A280105DF612B6 /* KakaoSDKAuth */; }; 753741799DEF278DC4C515AA /* KakaoSDKAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 8069DA64D0A280105DF612B6 /* KakaoSDKAuth */; };
7A03C0022E41400100C0DE01 /* ImageCropGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A03C0012E41400100C0DE01 /* ImageCropGeometry.swift */; };
7A03C0032E41400100C0DE01 /* ImageCropGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A03C0012E41400100C0DE01 /* ImageCropGeometry.swift */; };
7A1DDAAE227BF176BDEF1CB9 /* TextTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E61608194B084224DB6B78B5 /* TextTabBar.swift */; }; 7A1DDAAE227BF176BDEF1CB9 /* TextTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E61608194B084224DB6B78B5 /* TextTabBar.swift */; };
7AB453CE38CA0B3D612E9467 /* AudioContentCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E92246F5C1254CBED72AD9AD /* AudioContentCard.swift */; }; 7AB453CE38CA0B3D612E9467 /* AudioContentCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E92246F5C1254CBED72AD9AD /* AudioContentCard.swift */; };
7B2EE8B4132668F0267B10CB /* UserProfileChannelDonationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 214C32BF44DF384FD6925E0B /* UserProfileChannelDonationView.swift */; }; 7B2EE8B4132668F0267B10CB /* UserProfileChannelDonationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 214C32BF44DF384FD6925E0B /* UserProfileChannelDonationView.swift */; };
@@ -1668,6 +1672,10 @@
A3BDD0D3E14CD72B022AF6A7 /* FollowButtonImageAsset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC033E531A9420363D017CE /* FollowButtonImageAsset.swift */; }; A3BDD0D3E14CD72B022AF6A7 /* FollowButtonImageAsset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC033E531A9420363D017CE /* FollowButtonImageAsset.swift */; };
A3EB81FD727C63FE034048B9 /* Spacing.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6B18C00DE256DCC53030630 /* Spacing.swift */; }; A3EB81FD727C63FE034048B9 /* Spacing.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6B18C00DE256DCC53030630 /* Spacing.swift */; };
A4EB669DFFFB64681E045EF3 /* MainHomeRecommendationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76506E56D017236824FE9B8F /* MainHomeRecommendationViewModel.swift */; }; A4EB669DFFFB64681E045EF3 /* MainHomeRecommendationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76506E56D017236824FE9B8F /* MainHomeRecommendationViewModel.swift */; };
A4F100000000000000000001 /* LottieRefreshIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4F100000000000000000005 /* LottieRefreshIndicator.swift */; };
A4F100000000000000000002 /* LottieRefreshIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4F100000000000000000005 /* LottieRefreshIndicator.swift */; };
A4F100000000000000000003 /* LottieRefreshableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4F100000000000000000006 /* LottieRefreshableScrollView.swift */; };
A4F100000000000000000004 /* LottieRefreshableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4F100000000000000000006 /* LottieRefreshableScrollView.swift */; };
A840136779D6CC95F94CB681 /* HomeTitleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA81E8424A575A0F8546D31 /* HomeTitleBar.swift */; }; A840136779D6CC95F94CB681 /* HomeTitleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA81E8424A575A0F8546D31 /* HomeTitleBar.swift */; };
ACD71056E97E13E48941ABBB /* MainPlaceholderTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7900EA9FD1150BABE64CF1B3 /* MainPlaceholderTabView.swift */; }; ACD71056E97E13E48941ABBB /* MainPlaceholderTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7900EA9FD1150BABE64CF1B3 /* MainPlaceholderTabView.swift */; };
AD6424211AA898794BBE8470 /* SectionTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D16C12DFB83FE164B38CC066 /* SectionTitle.swift */; }; AD6424211AA898794BBE8470 /* SectionTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D16C12DFB83FE164B38CC066 /* SectionTitle.swift */; };
@@ -2311,6 +2319,7 @@
405C208C2D07FBC400804C0B /* ContentPlaylistModifyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentPlaylistModifyView.swift; sourceTree = "<group>"; }; 405C208C2D07FBC400804C0B /* ContentPlaylistModifyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentPlaylistModifyView.swift; sourceTree = "<group>"; };
405C208F2D07FBEC00804C0B /* ContentPlaylistModifyViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentPlaylistModifyViewModel.swift; sourceTree = "<group>"; }; 405C208F2D07FBEC00804C0B /* ContentPlaylistModifyViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentPlaylistModifyViewModel.swift; sourceTree = "<group>"; };
405C20922D07FC6300804C0B /* UpdatePlaylistRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePlaylistRequest.swift; sourceTree = "<group>"; }; 405C20922D07FC6300804C0B /* UpdatePlaylistRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePlaylistRequest.swift; sourceTree = "<group>"; };
405EC8BE303D99D20037B269 /* pull-to-refresh.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "pull-to-refresh.json"; sourceTree = "<group>"; };
405F791A2D2B84D70037DE3A /* AuditionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuditionItemView.swift; sourceTree = "<group>"; }; 405F791A2D2B84D70037DE3A /* AuditionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuditionItemView.swift; sourceTree = "<group>"; };
405F791E2D2BA3140037DE3A /* AuditionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuditionDetailView.swift; sourceTree = "<group>"; }; 405F791E2D2BA3140037DE3A /* AuditionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuditionDetailView.swift; sourceTree = "<group>"; };
405F79212D2BA3230037DE3A /* AuditionDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuditionDetailViewModel.swift; sourceTree = "<group>"; }; 405F79212D2BA3230037DE3A /* AuditionDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuditionDetailViewModel.swift; sourceTree = "<group>"; };
@@ -2646,7 +2655,6 @@
40B28AAC2A839DE4008BE463 /* BaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseView.swift; sourceTree = "<group>"; }; 40B28AAC2A839DE4008BE463 /* BaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseView.swift; sourceTree = "<group>"; };
40B28AAF2A839E59008BE463 /* DetailNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailNavigationBar.swift; sourceTree = "<group>"; }; 40B28AAF2A839E59008BE463 /* DetailNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailNavigationBar.swift; sourceTree = "<group>"; };
40B28AB32A839E9D008BE463 /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = "<group>"; }; 40B28AB32A839E9D008BE463 /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = "<group>"; };
7A03C0012E41400100C0DE01 /* ImageCropGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCropGeometry.swift; sourceTree = "<group>"; };
40B37EE72A90A32C000DE782 /* ProfileUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileUpdateView.swift; sourceTree = "<group>"; }; 40B37EE72A90A32C000DE782 /* ProfileUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileUpdateView.swift; sourceTree = "<group>"; };
40B37EEB2A90A3CF000DE782 /* ProfileUpdateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileUpdateViewModel.swift; sourceTree = "<group>"; }; 40B37EEB2A90A3CF000DE782 /* ProfileUpdateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileUpdateViewModel.swift; sourceTree = "<group>"; };
40B37EF02A90A484000DE782 /* ModifyPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifyPasswordView.swift; sourceTree = "<group>"; }; 40B37EF02A90A484000DE782 /* ModifyPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifyPasswordView.swift; sourceTree = "<group>"; };
@@ -2928,6 +2936,7 @@
74990A0798E64A97237DD113 /* CapsuleTabBar.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CapsuleTabBar.swift; sourceTree = "<group>"; }; 74990A0798E64A97237DD113 /* CapsuleTabBar.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CapsuleTabBar.swift; sourceTree = "<group>"; };
76506E56D017236824FE9B8F /* MainHomeRecommendationViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainHomeRecommendationViewModel.swift; sourceTree = "<group>"; }; 76506E56D017236824FE9B8F /* MainHomeRecommendationViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainHomeRecommendationViewModel.swift; sourceTree = "<group>"; };
7900EA9FD1150BABE64CF1B3 /* MainPlaceholderTabView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainPlaceholderTabView.swift; sourceTree = "<group>"; }; 7900EA9FD1150BABE64CF1B3 /* MainPlaceholderTabView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainPlaceholderTabView.swift; sourceTree = "<group>"; };
7A03C0012E41400100C0DE01 /* ImageCropGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCropGeometry.swift; sourceTree = "<group>"; };
7C21D69F717F8D80644F6AEE /* PushNotificationListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushNotificationListView.swift; sourceTree = "<group>"; }; 7C21D69F717F8D80644F6AEE /* PushNotificationListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushNotificationListView.swift; sourceTree = "<group>"; };
7C22ACAC456B3BCB2D9C568D /* AcceptLanguageDebugLogger.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AcceptLanguageDebugLogger.swift; sourceTree = "<group>"; }; 7C22ACAC456B3BCB2D9C568D /* AcceptLanguageDebugLogger.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AcceptLanguageDebugLogger.swift; sourceTree = "<group>"; };
826D626B7714E5C28ED31074 /* I18n.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = I18n.swift; sourceTree = "<group>"; }; 826D626B7714E5C28ED31074 /* I18n.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = I18n.swift; sourceTree = "<group>"; };
@@ -2937,6 +2946,8 @@
959B90DBB4BB5911AB10C442 /* Pods-SodaLive-dev.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SodaLive-dev.debug.xcconfig"; path = "Target Support Files/Pods-SodaLive-dev/Pods-SodaLive-dev.debug.xcconfig"; sourceTree = "<group>"; }; 959B90DBB4BB5911AB10C442 /* Pods-SodaLive-dev.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SodaLive-dev.debug.xcconfig"; path = "Target Support Files/Pods-SodaLive-dev/Pods-SodaLive-dev.debug.xcconfig"; sourceTree = "<group>"; };
9C7923ABAD25E9CC4840EC94 /* CreatorChannelFanTalkTabView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorChannelFanTalkTabView.swift; sourceTree = "<group>"; }; 9C7923ABAD25E9CC4840EC94 /* CreatorChannelFanTalkTabView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorChannelFanTalkTabView.swift; sourceTree = "<group>"; };
A4AAC943F77B6FE089696909 /* UpdateContentPreferenceResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UpdateContentPreferenceResponse.swift; sourceTree = "<group>"; }; A4AAC943F77B6FE089696909 /* UpdateContentPreferenceResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UpdateContentPreferenceResponse.swift; sourceTree = "<group>"; };
A4F100000000000000000005 /* LottieRefreshIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieRefreshIndicator.swift; sourceTree = "<group>"; };
A4F100000000000000000006 /* LottieRefreshableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieRefreshableScrollView.swift; sourceTree = "<group>"; };
A575789542808C0C87AC6D82 /* AiCharacterCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AiCharacterCard.swift; sourceTree = "<group>"; }; A575789542808C0C87AC6D82 /* AiCharacterCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AiCharacterCard.swift; sourceTree = "<group>"; };
AB63E7E31B75FF2E6F1C9073 /* LanguageSettingsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LanguageSettingsView.swift; sourceTree = "<group>"; }; AB63E7E31B75FF2E6F1C9073 /* LanguageSettingsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LanguageSettingsView.swift; sourceTree = "<group>"; };
B108032F3DC7110CBFD04130 /* Pods-SodaLive-dev.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SodaLive-dev.release.xcconfig"; path = "Target Support Files/Pods-SodaLive-dev/Pods-SodaLive-dev.release.xcconfig"; sourceTree = "<group>"; }; B108032F3DC7110CBFD04130 /* Pods-SodaLive-dev.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SodaLive-dev.release.xcconfig"; path = "Target Support Files/Pods-SodaLive-dev/Pods-SodaLive-dev.release.xcconfig"; sourceTree = "<group>"; };
@@ -3141,6 +3152,7 @@
753741799DEF278DC4C515AA /* KakaoSDKAuth in Frameworks */, 753741799DEF278DC4C515AA /* KakaoSDKAuth in Frameworks */,
94E04DD7ABB0BBF450352E21 /* KakaoSDKUser in Frameworks */, 94E04DD7ABB0BBF450352E21 /* KakaoSDKUser in Frameworks */,
DD202E48D61F1CE8108AD164 /* LineSDK in Frameworks */, DD202E48D61F1CE8108AD164 /* LineSDK in Frameworks */,
405EC8BB303D97F30037B269 /* Lottie in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -3171,6 +3183,7 @@
3F829933ED2755E9F0BBC5FE /* KakaoSDKAuth in Frameworks */, 3F829933ED2755E9F0BBC5FE /* KakaoSDKAuth in Frameworks */,
C8B170409F057CA2B8501362 /* KakaoSDKUser in Frameworks */, C8B170409F057CA2B8501362 /* KakaoSDKUser in Frameworks */,
A1465C799E10028802BD2A68 /* LineSDK in Frameworks */, A1465C799E10028802BD2A68 /* LineSDK in Frameworks */,
405EC8BD303D98100037B269 /* Lottie in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -5481,6 +5494,7 @@
40FE0E962A83622D00252293 /* Resources */ = { 40FE0E962A83622D00252293 /* Resources */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
405EC8BE303D99D20037B269 /* pull-to-refresh.json */,
402830D62E8ABF2E001A6577 /* payverse_starter.html */, 402830D62E8ABF2E001A6577 /* payverse_starter.html */,
402830D72E8ABF2E001A6577 /* payverse_starter_debug.html */, 402830D72E8ABF2E001A6577 /* payverse_starter_debug.html */,
40FE0EAD2A83642C00252293 /* Debug */, 40FE0EAD2A83642C00252293 /* Debug */,
@@ -5642,6 +5656,7 @@
F7F1421DBAB56E2F6891625E /* DefaultTitleBar.swift */, F7F1421DBAB56E2F6891625E /* DefaultTitleBar.swift */,
DAA81E8424A575A0F8546D31 /* HomeTitleBar.swift */, DAA81E8424A575A0F8546D31 /* HomeTitleBar.swift */,
25C5D0734F54C0ED7B7BD462 /* Modal */, 25C5D0734F54C0ED7B7BD462 /* Modal */,
A4F100000000000000000007 /* Refresh */,
D16C12DFB83FE164B38CC066 /* SectionTitle.swift */, D16C12DFB83FE164B38CC066 /* SectionTitle.swift */,
0FD267162A809D893E2E3B3F /* Text */, 0FD267162A809D893E2E3B3F /* Text */,
E61608194B084224DB6B78B5 /* TextTabBar.swift */, E61608194B084224DB6B78B5 /* TextTabBar.swift */,
@@ -5650,6 +5665,15 @@
path = Component; path = Component;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A4F100000000000000000007 /* Refresh */ = {
isa = PBXGroup;
children = (
A4F100000000000000000005 /* LottieRefreshIndicator.swift */,
A4F100000000000000000006 /* LottieRefreshableScrollView.swift */,
);
path = Refresh;
sourceTree = "<group>";
};
AA4588B91C0F73344C3DC4A4 /* FanTalk */ = { AA4588B91C0F73344C3DC4A4 /* FanTalk */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -6413,6 +6437,7 @@
8069DA64D0A280105DF612B6 /* KakaoSDKAuth */, 8069DA64D0A280105DF612B6 /* KakaoSDKAuth */,
061FD682D7FE09325C4B460A /* KakaoSDKUser */, 061FD682D7FE09325C4B460A /* KakaoSDKUser */,
6DBA0C325ACEC3FCDB22490D /* LineSDK */, 6DBA0C325ACEC3FCDB22490D /* LineSDK */,
405EC8BA303D97F30037B269 /* Lottie */,
); );
productName = SodaLive; productName = SodaLive;
productReference = 40FE0E842A8361DF00252293 /* SodaLive.app */; productReference = 40FE0E842A8361DF00252293 /* SodaLive.app */;
@@ -6456,6 +6481,7 @@
E9CD5F29965B148AB360C353 /* KakaoSDKAuth */, E9CD5F29965B148AB360C353 /* KakaoSDKAuth */,
927A707B5035B0A77C0F5EA6 /* KakaoSDKUser */, 927A707B5035B0A77C0F5EA6 /* KakaoSDKUser */,
867D78B93CBC5738B7D43CEA /* LineSDK */, 867D78B93CBC5738B7D43CEA /* LineSDK */,
405EC8BC303D98100037B269 /* Lottie */,
); );
productName = SodaLive; productName = SodaLive;
productReference = 40FE0EAC2A8362A200252293 /* SodaLive-dev.app */; productReference = 40FE0EAC2A8362A200252293 /* SodaLive-dev.app */;
@@ -6504,6 +6530,7 @@
404DCB3D2D9A6FCC000935C5 /* XCRemoteSwiftPackageReference "objectbox-swift-spm" */, 404DCB3D2D9A6FCC000935C5 /* XCRemoteSwiftPackageReference "objectbox-swift-spm" */,
534A307BA769D6BC2D6343CB /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */, 534A307BA769D6BC2D6343CB /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */,
7D0E5FAF7AD538D1C2FED682 /* XCRemoteSwiftPackageReference "line-sdk-ios-swift" */, 7D0E5FAF7AD538D1C2FED682 /* XCRemoteSwiftPackageReference "line-sdk-ios-swift" */,
405EC8B9303D97F30037B269 /* XCRemoteSwiftPackageReference "lottie-spm" */,
); );
productRefGroup = 40FE0E852A8361DF00252293 /* Products */; productRefGroup = 40FE0E852A8361DF00252293 /* Products */;
projectDirPath = ""; projectDirPath = "";
@@ -6533,6 +6560,7 @@
40B640742E20BA6300D0E4E4 /* Pretendard-Light.otf in Resources */, 40B640742E20BA6300D0E4E4 /* Pretendard-Light.otf in Resources */,
40B640752E20BA6300D0E4E4 /* Pretendard-Bold.otf in Resources */, 40B640752E20BA6300D0E4E4 /* Pretendard-Bold.otf in Resources */,
402830D82E8ABF2E001A6577 /* payverse_starter_debug.html in Resources */, 402830D82E8ABF2E001A6577 /* payverse_starter_debug.html in Resources */,
405EC8BF303D99D20037B269 /* pull-to-refresh.json in Resources */,
402830D92E8ABF2E001A6577 /* payverse_starter.html in Resources */, 402830D92E8ABF2E001A6577 /* payverse_starter.html in Resources */,
40FE0E8C2A8361E100252293 /* Assets.xcassets in Resources */, 40FE0E8C2A8361E100252293 /* Assets.xcassets in Resources */,
40A5BA882A8B38CC004A4635 /* GoogleService-Info.plist in Resources */, 40A5BA882A8B38CC004A4635 /* GoogleService-Info.plist in Resources */,
@@ -6556,6 +6584,7 @@
40B640702E20BA6300D0E4E4 /* Pretendard-Light.otf in Resources */, 40B640702E20BA6300D0E4E4 /* Pretendard-Light.otf in Resources */,
40B640712E20BA6300D0E4E4 /* Pretendard-Bold.otf in Resources */, 40B640712E20BA6300D0E4E4 /* Pretendard-Bold.otf in Resources */,
402830DA2E8ABF2E001A6577 /* payverse_starter_debug.html in Resources */, 402830DA2E8ABF2E001A6577 /* payverse_starter_debug.html in Resources */,
405EC8C0303D99D20037B269 /* pull-to-refresh.json in Resources */,
402830DB2E8ABF2E001A6577 /* payverse_starter.html in Resources */, 402830DB2E8ABF2E001A6577 /* payverse_starter.html in Resources */,
40FE0EA62A8362A200252293 /* Assets.xcassets in Resources */, 40FE0EA62A8362A200252293 /* Assets.xcassets in Resources */,
40728D522D79780A00D2C580 /* GoogleService-Info.plist in Resources */, 40728D522D79780A00D2C580 /* GoogleService-Info.plist in Resources */,
@@ -6722,6 +6751,8 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A4F100000000000000000001 /* LottieRefreshIndicator.swift in Sources */,
A4F100000000000000000003 /* LottieRefreshableScrollView.swift in Sources */,
400EDE592B31D2D7008E37B4 /* CreatorCommunityApi.swift in Sources */, 400EDE592B31D2D7008E37B4 /* CreatorCommunityApi.swift in Sources */,
CC0101012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift in Sources */, CC0101012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift in Sources */,
CC0101032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift in Sources */, CC0101032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift in Sources */,
@@ -7822,6 +7853,8 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A4F100000000000000000002 /* LottieRefreshIndicator.swift in Sources */,
A4F100000000000000000004 /* LottieRefreshableScrollView.swift in Sources */,
400EDE5A2B31D2D7008E37B4 /* CreatorCommunityApi.swift in Sources */, 400EDE5A2B31D2D7008E37B4 /* CreatorCommunityApi.swift in Sources */,
409E35092B0759DE00CCB985 /* MoyaProviderExtension.swift in Sources */, 409E35092B0759DE00CCB985 /* MoyaProviderExtension.swift in Sources */,
40DDBD782B2B4FD700A2E8DA /* CreatorCommunityMoreItemView.swift in Sources */, 40DDBD782B2B4FD700A2E8DA /* CreatorCommunityMoreItemView.swift in Sources */,
@@ -8912,13 +8945,13 @@
CODE_SIGN_ENTITLEMENTS = SodaLive/SodaLive.entitlements; CODE_SIGN_ENTITLEMENTS = SodaLive/SodaLive.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_ASSET_PATHS = "\"SodaLive/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"SodaLive/Preview Content\"";
DEVELOPMENT_TEAM = 3A8QGPMRXM; DEVELOPMENT_TEAM = 3A8QGPMRXM;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SodaLive/Resources/Info.plist; INFOPLIST_FILE = SodaLive/Resources/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "보이스온"; INFOPLIST_KEY_CFBundleDisplayName = "소다라이브";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment";
INFOPLIST_KEY_NSCameraUsageDescription = "프로필 이미지 / 라이브 커버이미지 촬영을 위해 카메라 접근 권한이 필요합니다."; INFOPLIST_KEY_NSCameraUsageDescription = "프로필 이미지 / 라이브 커버이미지 촬영을 위해 카메라 접근 권한이 필요합니다.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "라이브 / 음성메시지 / 오디션 녹음을 위해 마이크 권한이 필요합니다."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "라이브 / 음성메시지 / 오디션 녹음을 위해 마이크 권한이 필요합니다.";
@@ -8934,7 +8967,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.56.0; MARKETING_VERSION = 1.56.1;
PRODUCT_BUNDLE_IDENTIFIER = kr.co.vividnext.sodalive; PRODUCT_BUNDLE_IDENTIFIER = kr.co.vividnext.sodalive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -8953,13 +8986,13 @@
CODE_SIGN_ENTITLEMENTS = SodaLive/SodaLive.entitlements; CODE_SIGN_ENTITLEMENTS = SodaLive/SodaLive.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_ASSET_PATHS = "\"SodaLive/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"SodaLive/Preview Content\"";
DEVELOPMENT_TEAM = 3A8QGPMRXM; DEVELOPMENT_TEAM = 3A8QGPMRXM;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SodaLive/Resources/Info.plist; INFOPLIST_FILE = SodaLive/Resources/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "보이스온"; INFOPLIST_KEY_CFBundleDisplayName = "소다라이브";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment";
INFOPLIST_KEY_NSCameraUsageDescription = "프로필 이미지 / 라이브 커버이미지 촬영을 위해 카메라 접근 권한이 필요합니다."; INFOPLIST_KEY_NSCameraUsageDescription = "프로필 이미지 / 라이브 커버이미지 촬영을 위해 카메라 접근 권한이 필요합니다.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "라이브 / 음성메시지 / 오디션 녹음을 위해 마이크 권한이 필요합니다."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "라이브 / 음성메시지 / 오디션 녹음을 위해 마이크 권한이 필요합니다.";
@@ -8975,7 +9008,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.56.0; MARKETING_VERSION = 1.56.1;
PRODUCT_BUNDLE_IDENTIFIER = kr.co.vividnext.sodalive; PRODUCT_BUNDLE_IDENTIFIER = kr.co.vividnext.sodalive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -8999,7 +9032,7 @@
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "SodaLive/Resources/Debug/SodaLive-dev-Info.plist"; INFOPLIST_FILE = "SodaLive/Resources/Debug/SodaLive-dev-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "보이스온-테스트"; INFOPLIST_KEY_CFBundleDisplayName = "소다라이브-테스트";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment";
INFOPLIST_KEY_NSCameraUsageDescription = "프로필 이미지 / 라이브 커버이미지 촬영을 위해 카메라 접근 권한이 필요합니다."; INFOPLIST_KEY_NSCameraUsageDescription = "프로필 이미지 / 라이브 커버이미지 촬영을 위해 카메라 접근 권한이 필요합니다.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "라이브 / 음성메시지 / 오디션 녹음을 위해 마이크 권한이 필요합니다."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "라이브 / 음성메시지 / 오디션 녹음을 위해 마이크 권한이 필요합니다.";
@@ -9039,7 +9072,7 @@
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "SodaLive/Resources/Debug/SodaLive-dev-Info.plist"; INFOPLIST_FILE = "SodaLive/Resources/Debug/SodaLive-dev-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "보이스온-테스트"; INFOPLIST_KEY_CFBundleDisplayName = "소다라이브-테스트";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment";
INFOPLIST_KEY_NSCameraUsageDescription = "프로필 이미지 / 라이브 커버이미지 촬영을 위해 카메라 접근 권한이 필요합니다."; INFOPLIST_KEY_NSCameraUsageDescription = "프로필 이미지 / 라이브 커버이미지 촬영을 위해 카메라 접근 권한이 필요합니다.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "라이브 / 음성메시지 / 오디션 녹음을 위해 마이크 권한이 필요합니다."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "라이브 / 음성메시지 / 오디션 녹음을 위해 마이크 권한이 필요합니다.";
@@ -9130,6 +9163,14 @@
minimumVersion = 6.16.1; minimumVersion = 6.16.1;
}; };
}; };
405EC8B9303D97F30037B269 /* XCRemoteSwiftPackageReference "lottie-spm" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/airbnb/lottie-spm.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 4.6.1;
};
};
406AEAA12D6D9CCE00D1AEFE /* XCRemoteSwiftPackageReference "facebook-ios-sdk" */ = { 406AEAA12D6D9CCE00D1AEFE /* XCRemoteSwiftPackageReference "facebook-ios-sdk" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/facebook/facebook-ios-sdk.git"; repositoryURL = "https://github.com/facebook/facebook-ios-sdk.git";
@@ -9279,6 +9320,16 @@
package = 405C3FB12D78AB7900A1551A /* XCRemoteSwiftPackageReference "AppsFlyerFramework-Static" */; package = 405C3FB12D78AB7900A1551A /* XCRemoteSwiftPackageReference "AppsFlyerFramework-Static" */;
productName = "AppsFlyerLib-Static"; productName = "AppsFlyerLib-Static";
}; };
405EC8BA303D97F30037B269 /* Lottie */ = {
isa = XCSwiftPackageProductDependency;
package = 405EC8B9303D97F30037B269 /* XCRemoteSwiftPackageReference "lottie-spm" */;
productName = Lottie;
};
405EC8BC303D98100037B269 /* Lottie */ = {
isa = XCSwiftPackageProductDependency;
package = 405EC8B9303D97F30037B269 /* XCRemoteSwiftPackageReference "lottie-spm" */;
productName = Lottie;
};
406AEAA22D6D9CCE00D1AEFE /* FacebookCore */ = { 406AEAA22D6D9CCE00D1AEFE /* FacebookCore */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = 406AEAA12D6D9CCE00D1AEFE /* XCRemoteSwiftPackageReference "facebook-ios-sdk" */; package = 406AEAA12D6D9CCE00D1AEFE /* XCRemoteSwiftPackageReference "facebook-ios-sdk" */;
@@ -1,5 +1,5 @@
{ {
"originHash": "1f28da3687662a2a9efe60ffc2ca2499be411b5b0a1e07f72559059c40728121", "originHash" : "879f32da6c663e9c4a413e005f26682b94e0dfa4995d4a5b8119800465a10127",
"pins" : [ "pins" : [
{ {
"identity" : "abseil-cpp-binary", "identity" : "abseil-cpp-binary",
@@ -163,6 +163,15 @@
"version" : "5.14.0" "version" : "5.14.0"
} }
}, },
{
"identity" : "lottie-spm",
"kind" : "remoteSourceControl",
"location" : "https://github.com/airbnb/lottie-spm.git",
"state" : {
"revision" : "0aff16288f8dc1e3dab35cbf5cf45979673fd3df",
"version" : "4.6.1"
}
},
{ {
"identity" : "moya", "identity" : "moya",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "1024x1024.jpg", "filename" : "iOS_1024_appstore.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -1,15 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "img_apply_creator.png", "filename" : "img_apply_creator_kr.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal", "idiom" : "universal",
"scale" : "3x" "scale" : "3x"
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "img_apply_creator_ja.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "img_introduce_sodalive_kr.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "img_introduce_sodalive_ja.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

@@ -1,21 +0,0 @@
{
"images" : [
{
"filename" : "img_introduce_voiceon.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

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

Before

Width:  |  Height:  |  Size: 549 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 B

After

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 B

After

Width:  |  Height:  |  Size: 394 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 556 B

After

Width:  |  Height:  |  Size: 505 B

@@ -1,6 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "ic_new_following_white.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
}, },
@@ -9,7 +10,6 @@
"scale" : "2x" "scale" : "2x"
}, },
{ {
"filename" : "splash_bg.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "3x" "scale" : "3x"
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

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

Before

Width:  |  Height:  |  Size: 603 B

After

Width:  |  Height:  |  Size: 847 B

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

Before

Width:  |  Height:  |  Size: 482 B

After

Width:  |  Height:  |  Size: 1.2 KiB

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

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -1,21 +0,0 @@
{
"images" : [
{
"filename" : "img_text_logo_v2.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

@@ -5,7 +5,7 @@
Created by klaus on 12/5/25. Created by klaus on 12/5/25.
*/ */
"CFBundleDisplayName" = "VoiceOn-Test"; "CFBundleDisplayName" = "SodaLive-Test";
"NSPhotoLibraryUsageDescription" = "Allow access to your photo library to select photos."; "NSPhotoLibraryUsageDescription" = "Allow access to your photo library to select photos.";
"NSMicrophoneUsageDescription" = "Allow access to your microphone to record audio."; "NSMicrophoneUsageDescription" = "Allow access to your microphone to record audio.";
"NSUserTrackingUsageDescription" = "Allow tracking to deliver personalized ads."; "NSUserTrackingUsageDescription" = "Allow tracking to deliver personalized ads.";
@@ -5,7 +5,7 @@
Created by klaus on 12/5/25. Created by klaus on 12/5/25.
*/ */
"CFBundleDisplayName" = "ボイスオン-テスト"; "CFBundleDisplayName" = "ソーダライブ-テスト";
"NSPhotoLibraryUsageDescription" = "写真を選択するために写真ライブラリへのアクセスが必要です。"; "NSPhotoLibraryUsageDescription" = "写真を選択するために写真ライブラリへのアクセスが必要です。";
"NSMicrophoneUsageDescription" = "音声を録音するためにマイクへのアクセスが必要です。"; "NSMicrophoneUsageDescription" = "音声を録音するためにマイクへのアクセスが必要です。";
"NSUserTrackingUsageDescription" = "パーソナライズされた広告のためにトラッキングを許可してください。"; "NSUserTrackingUsageDescription" = "パーソナライズされた広告のためにトラッキングを許可してください。";
@@ -5,7 +5,7 @@
Created by klaus on 12/5/25. Created by klaus on 12/5/25.
*/ */
"CFBundleDisplayName" = "보이스온-테스트"; "CFBundleDisplayName" = "소다라이브-테스트";
"NSPhotoLibraryUsageDescription" = "사진을 선택하려면 사진 라이브러리 접근이 필요합니다."; "NSPhotoLibraryUsageDescription" = "사진을 선택하려면 사진 라이브러리 접근이 필요합니다.";
"NSMicrophoneUsageDescription" = "음성 녹음을 위해 마이크 접근이 필요합니다."; "NSMicrophoneUsageDescription" = "음성 녹음을 위해 마이크 접근이 필요합니다.";
"NSUserTrackingUsageDescription" = "맞춤형 광고 제공을 위해 추적을 허용해 주세요."; "NSUserTrackingUsageDescription" = "맞춤형 광고 제공을 위해 추적을 허용해 주세요.";
@@ -5,7 +5,7 @@
Created by klaus on 12/5/25. Created by klaus on 12/5/25.
*/ */
"CFBundleDisplayName" = "VoiceOn"; "CFBundleDisplayName" = "SodaLive";
"NSPhotoLibraryUsageDescription" = "Allow access to your photo library to select photos."; "NSPhotoLibraryUsageDescription" = "Allow access to your photo library to select photos.";
"NSMicrophoneUsageDescription" = "Allow access to your microphone to record audio."; "NSMicrophoneUsageDescription" = "Allow access to your microphone to record audio.";
"NSUserTrackingUsageDescription" = "Allow tracking to deliver personalized ads."; "NSUserTrackingUsageDescription" = "Allow tracking to deliver personalized ads.";
@@ -5,7 +5,7 @@
Created by klaus on 12/5/25. Created by klaus on 12/5/25.
*/ */
"CFBundleDisplayName" = "ボイスオン"; "CFBundleDisplayName" = "ソーダライブ";
"NSPhotoLibraryUsageDescription" = "写真を選択するために写真ライブラリへのアクセスが必要です。"; "NSPhotoLibraryUsageDescription" = "写真を選択するために写真ライブラリへのアクセスが必要です。";
"NSMicrophoneUsageDescription" = "音声を録音するためにマイクへのアクセスが必要です。"; "NSMicrophoneUsageDescription" = "音声を録音するためにマイクへのアクセスが必要です。";
"NSUserTrackingUsageDescription" = "パーソナライズされた広告のためにトラッキングを許可してください。"; "NSUserTrackingUsageDescription" = "パーソナライズされた広告のためにトラッキングを許可してください。";
@@ -5,7 +5,7 @@
Created by klaus on 12/5/25. Created by klaus on 12/5/25.
*/ */
"CFBundleDisplayName" = "보이스온"; "CFBundleDisplayName" = "소다라이브";
"NSPhotoLibraryUsageDescription" = "사진을 선택하려면 사진 라이브러리 접근이 필요합니다."; "NSPhotoLibraryUsageDescription" = "사진을 선택하려면 사진 라이브러리 접근이 필요합니다.";
"NSMicrophoneUsageDescription" = "음성 녹음을 위해 마이크 접근이 필요합니다."; "NSMicrophoneUsageDescription" = "음성 녹음을 위해 마이크 접근이 필요합니다.";
"NSUserTrackingUsageDescription" = "맞춤형 광고 제공을 위해 추적을 허용해 주세요."; "NSUserTrackingUsageDescription" = "맞춤형 광고 제공을 위해 추적을 허용해 주세요.";
+213
View File
@@ -0,0 +1,213 @@
{
"v": "5.9.0",
"fr": 30,
"ip": 0,
"op": 45,
"w": 150,
"h": 150,
"nm": "loading",
"ddd": 0,
"assets": [],
"layers": [
{
"ddd": 0,
"ind": 1,
"ty": 4,
"nm": "loading",
"sr": 1,
"ks": {
"o": {
"a": 0,
"k": 100,
"ix": 11
},
"r": {
"a": 1,
"k": [
{
"i": { "x": [0.667], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 0,
"s": [-90]
},
{
"t": 45,
"s": [270]
}
],
"ix": 10
},
"p": {
"a": 0,
"k": [75, 75, 0],
"ix": 2,
"l": 2
},
"a": {
"a": 0,
"k": [0, 0, 0],
"ix": 1,
"l": 2
},
"s": {
"a": 0,
"k": [100, 100, 100],
"ix": 6,
"l": 2
}
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"d": 1,
"ty": "el",
"s": {
"a": 0,
"k": [100, 100],
"ix": 2
},
"p": {
"a": 0,
"k": [0, 0],
"ix": 3
},
"nm": "타원 패스 1",
"mn": "ADBE Vector Shape - Ellipse",
"hd": false
},
{
"ty": "st",
"c": {
"a": 0,
"k": [1, 1, 1, 1],
"ix": 3
},
"o": {
"a": 0,
"k": 100,
"ix": 4
},
"w": {
"a": 0,
"k": 20,
"ix": 5
},
"lc": 2,
"lj": 1,
"ml": 4,
"bm": 0,
"nm": "선 1",
"mn": "ADBE Vector Graphic - Stroke",
"hd": false
},
{
"ty": "tr",
"p": {
"a": 0,
"k": [0, 0],
"ix": 2
},
"a": {
"a": 0,
"k": [0, 0],
"ix": 1
},
"s": {
"a": 0,
"k": [100, 100],
"ix": 3
},
"r": {
"a": 0,
"k": 0,
"ix": 6
},
"o": {
"a": 0,
"k": 100,
"ix": 7
},
"sk": {
"a": 0,
"k": 0,
"ix": 4
},
"sa": {
"a": 0,
"k": 0,
"ix": 5
},
"nm": "변형"
}
],
"nm": "타원 1",
"np": 3,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
},
{
"ty": "tm",
"s": {
"a": 1,
"k": [
{
"i": { "x": [0.667], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 19.78,
"s": [3]
},
{
"t": 45,
"s": [97]
}
],
"ix": 1
},
"e": {
"a": 1,
"k": [
{
"i": { "x": [0.667], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 0,
"s": [3]
},
{
"i": { "x": [0.833], "y": [1] },
"o": { "x": [0.167], "y": [0] },
"t": 26.372,
"s": [100]
},
{
"t": 45,
"s": [97]
}
],
"ix": 2
},
"o": {
"a": 0,
"k": 0,
"ix": 3
},
"m": 1,
"ix": 2,
"nm": "패스 다듬어 자르기 1",
"mn": "ADBE Vector Filter - Trim",
"hd": false
}
],
"ip": 0,
"op": 45,
"st": 0,
"bm": 0
}
],
"markers": []
}
@@ -95,7 +95,7 @@ struct OriginalWorkDetailHeaderView: View {
contentType: "웹소설", contentType: "웹소설",
category: "로맨스", category: "로맨스",
isAdult: true, isAdult: true,
description: "작품설명입니다.보이스온의 오픈월드 캐릭터톡은 청소년 보호를 위해 본인인증한성인만 이용이 가능합니다.캐릭터톡 서비스를 이용하시려면 본인인증을 하고 이용해주세요.", description: "작품설명입니다.소다라이브의 오픈월드 캐릭터톡은 청소년 보호를 위해 본인인증한성인만 이용이 가능합니다.캐릭터톡 서비스를 이용하시려면 본인인증을 하고 이용해주세요.",
originalWork: nil, originalWork: nil,
originalLink: "https://apple.com", originalLink: "https://apple.com",
writer: nil, writer: nil,
@@ -25,7 +25,7 @@ struct SeriesListAllView: View {
if isCompleted { if isCompleted {
DetailNavigationBar(title: I18n.Series.completedSectionTitle) DetailNavigationBar(title: I18n.Series.completedSectionTitle)
} else if isOriginal { } else if isOriginal {
DetailNavigationBar(title: I18n.Series.voiceOnOnlyTitle) DetailNavigationBar(title: I18n.Series.sodaLiveOnlyTitle)
} else { } else {
DetailNavigationBar(title: I18n.Series.viewAllByCreator(creatorNickname ?? "")) DetailNavigationBar(title: I18n.Series.viewAllByCreator(creatorNickname ?? ""))
} }
@@ -13,6 +13,19 @@ struct FollowCreatorItemView: View {
let creator: GetCreatorFollowingAllListItem let creator: GetCreatorFollowingAllListItem
let onClickFollow: (Int) -> Void let onClickFollow: (Int) -> Void
let showCreatorFollowNotifyDialog: (Int) -> Void let showCreatorFollowNotifyDialog: (Int) -> Void
let onTapUnfollow: ((Int) -> Void)?
init(
creator: GetCreatorFollowingAllListItem,
onClickFollow: @escaping (Int) -> Void,
showCreatorFollowNotifyDialog: @escaping (Int) -> Void,
onTapUnfollow: ((Int) -> Void)? = nil
) {
self.creator = creator
self.onClickFollow = onClickFollow
self.showCreatorFollowNotifyDialog = showCreatorFollowNotifyDialog
self.onTapUnfollow = onTapUnfollow
}
var body: some View { var body: some View {
VStack(spacing: 13.3) { VStack(spacing: 13.3) {
@@ -36,6 +49,28 @@ struct FollowCreatorItemView: View {
Spacer() Spacer()
followButton
}
Rectangle()
.foregroundColor(Color.gray59)
.frame(height: 0.5)
}
}
@ViewBuilder
private var followButton: some View {
if creator.isFollow, let onTapUnfollow {
Button(action: { onTapUnfollow(creator.creatorId) }) {
Image("ic_new_following_white")
.resizable()
.frame(width: 20, height: 20)
.frame(width: 36, height: 36)
.background(Color.gray800)
.clipShape(Circle())
}
.buttonStyle(.plain)
} else {
let asset = FollowButtonImageAsset( let asset = FollowButtonImageAsset(
type: creator.isFollow type: creator.isFollow
? (creator.isNotify ? .following : .followingNoAlarm) ? (creator.isNotify ? .following : .followingNoAlarm)
@@ -50,10 +85,5 @@ struct FollowCreatorItemView: View {
} }
} }
} }
Rectangle()
.foregroundColor(Color.gray59)
.frame(height: 0.5)
}
} }
} }
+41 -36
View File
@@ -11,16 +11,17 @@ struct FollowCreatorView: View {
@StateObject var viewModel = FollowCreatorViewModel() @StateObject var viewModel = FollowCreatorViewModel()
@State private var isShowFollowNotifyDialog: Bool = false @State private var isShowUnfollowConfirmDialog: Bool = false
@State private var creatorId: Int = 0 @State private var creatorId: Int = 0
@State private var selectedItemIndex: Int = 0 @State private var selectedItemIndex: Int = 0
@State private var creatorName: String = ""
var body: some View { var body: some View {
BaseView(isLoading: $viewModel.isLoading) { BaseView(isLoading: $viewModel.isLoading) {
VStack(spacing: 0) { VStack(spacing: 0) {
DetailNavigationBar(title: I18n.Follow.followingListTitle) DetailNavigationBar(title: I18n.Follow.followingListTitle)
HStack(spacing: 0) { HStack(spacing: 4) {
Text(I18n.Follow.totalPrefix) Text(I18n.Follow.totalPrefix)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
@@ -29,10 +30,6 @@ struct FollowCreatorView: View {
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.mainRed3) .foregroundColor(Color.mainRed3)
Text(I18n.Follow.personUnit)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayee)
Spacer() Spacer()
} }
.padding(.horizontal, 13.3) .padding(.horizontal, 13.3)
@@ -52,7 +49,12 @@ struct FollowCreatorView: View {
showCreatorFollowNotifyDialog: { showCreatorFollowNotifyDialog: {
creatorId = $0 creatorId = $0
selectedItemIndex = index selectedItemIndex = index
isShowFollowNotifyDialog = true },
onTapUnfollow: {
creatorId = $0
creatorName = creator.nickname
selectedItemIndex = index
isShowUnfollowConfirmDialog = true
} }
) )
.padding(.horizontal, 20) .padding(.horizontal, 20)
@@ -81,41 +83,44 @@ struct FollowCreatorView: View {
} }
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2) .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
if isShowFollowNotifyDialog { if isShowUnfollowConfirmDialog {
CreatorFollowNotifyDialog( unfollowConfirmDialog
isShowing: $isShowFollowNotifyDialog, }
onClickNotifyAll: { }
viewModel.creatorFollow( }
creatorId: creatorId,
index: selectedItemIndex, private var unfollowConfirmDialog: some View {
follow: true, SodaV2ActionModal(
notify: true title: I18n.MemberChannel.unfollowActionModalTitle,
message: I18n.MemberChannel.unfollowActionModalMessage(creatorName),
button1: SodaV2ActionModalButton(
label: I18n.MemberChannel.unfollowActionModalConfirm,
action: confirmUnfollow
),
button2: SodaV2ActionModalButton(
label: I18n.MemberChannel.unfollowActionModalCancel,
action: dismissUnfollowConfirmDialog
),
onDimmedTap: dismissUnfollowConfirmDialog
) )
creatorId = 0 }
selectedItemIndex = -1
}, private func confirmUnfollow() {
onClickNotifyNone: { let targetCreatorId = creatorId
let targetIndex = selectedItemIndex
dismissUnfollowConfirmDialog()
viewModel.creatorFollow( viewModel.creatorFollow(
creatorId: creatorId, creatorId: targetCreatorId,
index: selectedItemIndex, index: targetIndex,
follow: true,
notify: false
)
creatorId = 0
selectedItemIndex = -1
},
onClickUnFollow: {
viewModel.creatorFollow(
creatorId: creatorId,
index: selectedItemIndex,
follow: false, follow: false,
notify: false notify: false
) )
}
private func dismissUnfollowConfirmDialog() {
isShowUnfollowConfirmDialog = false
creatorId = 0 creatorId = 0
selectedItemIndex = -1 selectedItemIndex = -1
} creatorName = ""
)
}
}
} }
} }
+1 -1
View File
@@ -212,7 +212,7 @@ struct HomeTabView: View {
if !viewModel.originalAudioDramaList.isEmpty { if !viewModel.originalAudioDramaList.isEmpty {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 0) { HStack(spacing: 0) {
Text(I18n.Home.onlyOnVoiceOnSectionTitle) Text(I18n.Home.onlyOnSodaLiveSectionTitle)
.appFont(size: 24, weight: .bold) .appFont(size: 24, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
+166 -122
View File
@@ -90,9 +90,9 @@ enum I18n {
static var dialogDescription: String { static var dialogDescription: String {
pick( pick(
ko: "보이스온의 오픈월드 캐릭터톡은\n청소년 보호를 위해 본인인증한\n성인만 이용이 가능합니다.\n캐릭터톡 서비스를 이용하시려면\n본인인증을 하고 이용해주세요.", ko: "소다라이브의 오픈월드 캐릭터톡은\n청소년 보호를 위해 본인인증한\n성인만 이용이 가능합니다.\n캐릭터톡 서비스를 이용하시려면\n본인인증을 하고 이용해주세요.",
en: "VoiceOn Open World Character Talk is available only to adults who complete identity verification for youth protection.\nPlease complete identity verification to use Character Talk.", en: "SodaLive Open World Character Talk is available only to adults who complete identity verification for youth protection.\nPlease complete identity verification to use Character Talk.",
ja: "VoiceOnオープンワールドキャラクタートークは、青少年保護のため本人認証を完了した成人のみ利用できます。\nキャラクタートークを利用するには本人認証を完了してください。" ja: "SodaLiveオープンワールドキャラクタートークは、青少年保護のため本人認証を完了した成人のみ利用できます。\nキャラクタートークを利用するには本人認証を完了してください。"
) )
} }
@@ -164,9 +164,9 @@ enum I18n {
static var detailConversationGuideDescription1: String { static var detailConversationGuideDescription1: String {
pick( pick(
ko: "보이스온의 오픈월드 캐릭터톡은 대화의 자유도가 높아 대화에 참여하는 당신은 누구든 될 수 있습니다. 세계관 속 연관 캐릭터가 되어 대화를 하거나 완전히 새로운 인물이 되어 캐릭터와 당신만의 스토리를 만들어 갈 수 있습니다.", ko: "소다라이브의 오픈월드 캐릭터톡은 대화의 자유도가 높아 대화에 참여하는 당신은 누구든 될 수 있습니다. 세계관 속 연관 캐릭터가 되어 대화를 하거나 완전히 새로운 인물이 되어 캐릭터와 당신만의 스토리를 만들어 갈 수 있습니다.",
en: "VoiceOn Open World Character Talk gives you high conversational freedom. You can become a related character in the world or a completely new persona and build your own story with the character.", en: "SodaLive Open World Character Talk gives you high conversational freedom. You can become a related character in the world or a completely new persona and build your own story with the character.",
ja: "VoiceOnオープンワールドキャラクタートークは会話の自由度が高く、世界観の関連キャラクターとして会話したり、まったく新しい人物になってキャラクターとあなただけの物語を作れます。" ja: "SodaLiveオープンワールドキャラクタートークは会話の自由度が高く、世界観の関連キャラクターとして会話したり、まったく新しい人物になってキャラクターとあなただけの物語を作れます。"
) )
} }
@@ -179,7 +179,7 @@ enum I18n {
} }
static var detailChatButton: String { static var detailChatButton: String {
pick(ko: "대화하기", en: "Start chat", ja: "会話する") pick(ko: "대화하기", en: "Start chat", ja: "トークする")
} }
static var detailCollapse: String { static var detailCollapse: String {
@@ -196,7 +196,7 @@ enum I18n {
} }
static var totalPrefix: String { static var totalPrefix: String {
pick(ko: "전체", en: "Total", ja: "全体") pick(ko: "전체", en: "Total", ja: "すべて")
} }
static var countUnit: String { static var countUnit: String {
@@ -254,9 +254,9 @@ enum I18n {
enum Room { enum Room {
static var noticeForCharacter: String { static var noticeForCharacter: String {
pick( pick(
ko: "보이스온 AI캐릭터톡은 대화의 자유도가 높아 대화에 참여하는 당신은 누구든 될 수 있습니다.\n세계관 속 캐릭터로 대화를 하거나 새로운 인물로 캐릭터와 당신만의 스토리를 만들어보세요.\n※ AI캐릭터톡은 오픈베타 서비스 중이며, 캐릭터의 대화가 어색하거나 불완전할 수 있습니다.", ko: "소다라이브 AI캐릭터톡은 대화의 자유도가 높아 대화에 참여하는 당신은 누구든 될 수 있습니다.\n세계관 속 캐릭터로 대화를 하거나 새로운 인물로 캐릭터와 당신만의 스토리를 만들어보세요.\n※ AI캐릭터톡은 오픈베타 서비스 중이며, 캐릭터의 대화가 어색하거나 불완전할 수 있습니다.",
en: "VoiceOn AI Character Talk offers a high degree of conversational freedom.\nTalk as a character in the world or as a new persona and create your own story with the character.\n※ AI Character Talk is in open beta, so responses may be awkward or incomplete.", en: "SodaLive AI Character Talk offers a high degree of conversational freedom.\nTalk as a character in the world or as a new persona and create your own story with the character.\n※ AI Character Talk is in open beta, so responses may be awkward or incomplete.",
ja: "VoiceOn AIキャラクタートークは会話の自由度が高く、会話に参加するあなたは誰にでもなれます。\n世界観のキャラクターとして会話したり、新しい人物としてキャラクターとあなただけの物語を作ってみてください。\n※ AIキャラクタートークはオープンベータ中のため、会話が不自然または不完全な場合があります。" ja: "SodaLive AIキャラクタートークは会話の自由度が高く、会話に参加するあなたは誰にでもなれます。\n世界観のキャラクターとして会話したり、新しい人物としてキャラクターとあなただけの物語を作ってみてください。\n※ AIキャラクタートークはオープンベータ中のため、会話が不自然または不完全な場合があります。"
) )
} }
@@ -505,7 +505,7 @@ enum I18n {
} }
static var donation: String { static var donation: String {
pick(ko: "후원", en: "Donate", ja: "後援") pick(ko: "후원", en: "Donate", ja: "サポート")
} }
static var previewAlertMessage: String { static var previewAlertMessage: String {
@@ -643,7 +643,7 @@ enum I18n {
} }
enum DonationDialog { enum DonationDialog {
static var title: String { pick(ko: "후원하기", en: "Donate", ja: "後援する") } static var title: String { pick(ko: "후원하기", en: "Donate", ja: "サポートする") }
static var charge: String { pick(ko: "충전", en: "Charge", ja: "チャージ") } static var charge: String { pick(ko: "충전", en: "Charge", ja: "チャージ") }
static var minimumOneCanPlaceholder: String { pick(ko: "1캔 이상 입력하세요", en: "Enter 1 can or more", ja: "1can以上入力してください") } static var minimumOneCanPlaceholder: String { pick(ko: "1캔 이상 입력하세요", en: "Enter 1 can or more", ja: "1can以上入力してください") }
static var minimumTenCanPlaceholder: String { pick(ko: "10캔 이상 입력하세요", en: "Enter 10 cans or more", ja: "10can以上入力してください") } static var minimumTenCanPlaceholder: String { pick(ko: "10캔 이상 입력하세요", en: "Enter 10 cans or more", ja: "10can以上入力してください") }
@@ -658,7 +658,7 @@ enum I18n {
ja: "一緒に送る\(jaPrefix)メッセージを入力(最大\(limit)文字)" ja: "一緒に送る\(jaPrefix)メッセージを入力(最大\(limit)文字)"
) )
} }
static var donateAction: String { pick(ko: "후원하기", en: "Donate", ja: "後援する") } static var donateAction: String { pick(ko: "후원하기", en: "Donate", ja: "サポートする") }
} }
} }
enum ContentBox { enum ContentBox {
@@ -734,7 +734,7 @@ enum I18n {
enum Sort { enum Sort {
static var newest: String { static var newest: String {
pick(ko: "최신순", en: "Newest", ja: "新着順") pick(ko: "최신순", en: "Newest", ja: "最新順")
} }
static var popularity: String { static var popularity: String {
@@ -742,17 +742,17 @@ enum I18n {
} }
static var priceHigh: String { static var priceHigh: String {
pick(ko: "높은 가격순", en: "Highest price", ja: "価格の高い順") pick(ko: "높은 가격순", en: "Highest price", ja: "高い順")
} }
static var priceLow: String { static var priceLow: String {
pick(ko: "낮은 가격순", en: "Lowest price", ja: "価格の低い順") pick(ko: "낮은 가격순", en: "Lowest price", ja: "安い順")
} }
} }
enum Count { enum Count {
static var totalPrefix: String { static var totalPrefix: String {
pick(ko: "전체", en: "Total", ja: "全体") pick(ko: "전체", en: "Total", ja: "すべて")
} }
static var countUnit: String { static var countUnit: String {
@@ -762,7 +762,7 @@ enum I18n {
enum Status { enum Status {
static var owned: String { static var owned: String {
pick(ko: "소장중", en: "Owned", ja: "所持中") pick(ko: "소장중", en: "Owned", ja: "保有中")
} }
static var rented: String { static var rented: String {
@@ -798,9 +798,9 @@ enum I18n {
static var createNewAction: String { pick(ko: "+ 새 재생목록 만들기", en: "+ Create new playlist", ja: "+ 新しいプレイリストを作成") } static var createNewAction: String { pick(ko: "+ 새 재생목록 만들기", en: "+ Create new playlist", ja: "+ 新しいプレイリストを作成") }
static var emptyTitle: String { pick(ko: "재생목록이 비어있습니다.", en: "Your playlist is empty.", ja: "プレイリストが空です。") } static var emptyTitle: String { pick(ko: "재생목록이 비어있습니다.", en: "Your playlist is empty.", ja: "プレイリストが空です。") }
static var emptyDescription: String { pick(ko: "자주 듣는 콘텐츠를\n재생목록으로 만들어 보세요.", en: "Create a playlist with\nyour frequently listened content.", ja: "よく聴くコンテンツで\nプレイリストを作ってみましょう。") } static var emptyDescription: String { pick(ko: "자주 듣는 콘텐츠를\n재생목록으로 만들어 보세요.", en: "Create a playlist with\nyour frequently listened content.", ja: "よく聴くコンテンツで\nプレイリストを作ってみましょう。") }
static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") } static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "すべて") }
static func totalCount(_ count: Int) -> String { pick(ko: "총 \(count)개", en: "Total \(count)", ja: "全\(count)件") } static func totalCount(_ count: Int) -> String { pick(ko: "전체 \(count)개", en: "Total \(count)", ja: "すべて\(count)件") }
static func itemCount(_ count: Int) -> String { pick(ko: "총 \(count)개", en: "Total \(count)", ja: "全\(count)件") } static func itemCount(_ count: Int) -> String { pick(ko: "전체 \(count)개", en: "Total \(count)", ja: "すべて\(count)件") }
static var createTitle: String { pick(ko: "새 재생목록 만들기", en: "Create new playlist", ja: "新しいプレイリストを作成") } static var createTitle: String { pick(ko: "새 재생목록 만들기", en: "Create new playlist", ja: "新しいプレイリストを作成") }
static var createSave: String { pick(ko: "저장", en: "Save", ja: "保存") } static var createSave: String { pick(ko: "저장", en: "Save", ja: "保存") }
static var modifyTitle: String { pick(ko: "재생목록 수정", en: "Edit playlist", ja: "プレイリストを編集") } static var modifyTitle: String { pick(ko: "재생목록 수정", en: "Edit playlist", ja: "プレイリストを編集") }
@@ -954,15 +954,15 @@ enum I18n {
} }
static var defaultTitle: String { static var defaultTitle: String {
pick(ko: "보이스온", en: "VoiceOn", ja: "ボイスオン") pick(ko: "소다라이브", en: "SodaLive", ja: "ソーダライブ")
} }
enum List { enum List {
static var usageGuide: String { static var usageGuide: String {
pick( pick(
ko: "보이스온 오디션 이용방법", ko: "소다라이브 오디션 이용방법",
en: "How to use VoiceOn Audition", en: "How to use SodaLive Audition",
ja: "ボイスオンオーディション利用方法" ja: "ソーダライブオーディション利用方法"
) )
} }
@@ -1036,9 +1036,9 @@ enum I18n {
static var privacyAgreement: String { static var privacyAgreement: String {
pick( pick(
ko: "보이스온 오디오 드라마 오디션 합격시 개인 연락을 위한 개인 정보(연락처) 수집 및 활용에 동의합니다.\n오디션 지원자는 개인정보 수집 및 활용 동의에 거부할 권리가 있으며 비동의시 오디션 지원은 취소 됩니다.", ko: "소다라이브 오디오 드라마 오디션 합격시 개인 연락을 위한 개인 정보(연락처) 수집 및 활용에 동의합니다.\n오디션 지원자는 개인정보 수집 및 활용 동의에 거부할 권리가 있으며 비동의시 오디션 지원은 취소 됩니다.",
en: "I agree to the collection and use of personal information (contact) for personal contact if selected for the VoiceOn audio drama audition.\nApplicants have the right to refuse consent to the collection and use of personal information, and if they do not consent, the audition application will be canceled.", en: "I agree to the collection and use of personal information (contact) for personal contact if selected for the SodaLive audio drama audition.\nApplicants have the right to refuse consent to the collection and use of personal information, and if they do not consent, the audition application will be canceled.",
ja: "ボイスオンオーディオドラマオーディション合格時の個別連絡のため、個人情報(連絡先)の収集および利用に同意します。\n応募者は個人情報の収集および利用への同意を拒否する権利があり、同意しない場合はオーディション応募が取り消されます。" ja: "ソーダライブオーディオドラマオーディション合格時の個別連絡のため、個人情報(連絡先)の収集および利用に同意します。\n応募者は個人情報の収集および利用への同意を拒否する権利があり、同意しない場合はオーディション応募が取り消されます。"
) )
} }
@@ -1175,7 +1175,7 @@ enum I18n {
} }
static var sortNewest: String { static var sortNewest: String {
pick(ko: "최신순", en: "Newest", ja: "新着順") pick(ko: "최신순", en: "Newest", ja: "最新順")
} }
static var sortLikes: String { static var sortLikes: String {
@@ -1246,7 +1246,7 @@ enum I18n {
} }
static var reportAction: String { static var reportAction: String {
pick(ko: "신고", en: "Report", ja: "報告する") pick(ko: "신고", en: "Report", ja: "通報")
} }
static var cheersReasons: [String] { static var cheersReasons: [String] {
@@ -1616,7 +1616,7 @@ enum I18n {
} }
static var all: String { static var all: String {
pick(ko: "전체", en: "All", ja: "全体") pick(ko: "전체", en: "All", ja: "すべて")
} }
static var maleOriented: String { static var maleOriented: String {
@@ -1708,7 +1708,7 @@ enum I18n {
} }
static var totalPrefix: String { static var totalPrefix: String {
pick(ko: "총", en: "Total", ja: "合計") pick(ko: "전체", en: "Total", ja: "すべて")
} }
static var countUnit: String { static var countUnit: String {
@@ -2041,7 +2041,7 @@ enum I18n {
// 성별 제한 // 성별 제한
static var genderRestrictionTitle: String { pick(ko: "성별 제한", en: "Gender restriction", ja: "性別制限") } static var genderRestrictionTitle: String { pick(ko: "성별 제한", en: "Gender restriction", ja: "性別制限") }
static var genderAll: String { pick(ko: "전체", en: "All", ja: "全体") } static var genderAll: String { pick(ko: "전체", en: "All", ja: "すべて") }
static var genderMaleOnly: String { pick(ko: "남자만", en: "Male only", ja: "男性のみ") } static var genderMaleOnly: String { pick(ko: "남자만", en: "Male only", ja: "男性のみ") }
static var genderFemaleOnly: String { pick(ko: "여자만", en: "Female only", ja: "女性のみ") } static var genderFemaleOnly: String { pick(ko: "여자만", en: "Female only", ja: "女性のみ") }
@@ -2106,7 +2106,7 @@ enum I18n {
static var menuSavedMessage: String { pick(ko: "저장되었습니다.", en: "Saved.", ja: "保存されました。") } static var menuSavedMessage: String { pick(ko: "저장되었습니다.", en: "Saved.", ja: "保存されました。") }
static var editLiveTitle: String { pick(ko: "라이브 수정", en: "Edit live", ja: "ライブ編集") } static var editLiveTitle: String { pick(ko: "라이브 수정", en: "Edit live", ja: "ライブ編集") }
static var editAction: String { pick(ko: "수정하기", en: "Edit", ja: "編集する") } static var editAction: String { pick(ko: "수정하기", en: "Edit", ja: "編集") }
static var entryMessageLabel: String { pick(ko: "입장메시지", en: "Entry message", ja: "入場メッセージ") } static var entryMessageLabel: String { pick(ko: "입장메시지", en: "Entry message", ja: "入場メッセージ") }
static var noChangesMessage: String { pick(ko: "변경사항이 없습니다.", en: "No changes.", ja: "変更事項がありません。") } static var noChangesMessage: String { pick(ko: "변경사항이 없습니다.", en: "No changes.", ja: "変更事項がありません。") }
static var editSuccessMessage: String { pick(ko: "라이브 정보가 수정되었습니다.", en: "Live info has been updated.", ja: "ライブ情報を更新しました。") } static var editSuccessMessage: String { pick(ko: "라이브 정보가 수정되었습니다.", en: "Live info has been updated.", ja: "ライブ情報を更新しました。") }
@@ -2125,8 +2125,7 @@ enum I18n {
static var donationRankingTitle: String { pick(ko: "현재 라이브 후원랭킹", en: "Current live donation ranking", ja: "現在のライブ後援ランキング") } static var donationRankingTitle: String { pick(ko: "현재 라이브 후원랭킹", en: "Current live donation ranking", ja: "現在のライブ後援ランキング") }
static var heartRankingTitle: String { pick(ko: "현재 라이브 하트랭킹", en: "Current live heart ranking", ja: "現在のライブハートランキング") } static var heartRankingTitle: String { pick(ko: "현재 라이브 하트랭킹", en: "Current live heart ranking", ja: "現在のライブハートランキング") }
static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") } static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "すべて") }
static var peopleUnit: String { pick(ko: "명", en: "people", ja: "人") }
static var sumLabel: String { pick(ko: "합계", en: "Sum", ja: "合計") } static var sumLabel: String { pick(ko: "합계", en: "Sum", ja: "合計") }
static var normalLabel: String { pick(ko: "일반", en: "Normal", ja: "一般") } static var normalLabel: String { pick(ko: "일반", en: "Normal", ja: "一般") }
static var secretLabel: String { pick(ko: "비밀", en: "Secret", ja: "秘密") } static var secretLabel: String { pick(ko: "비밀", en: "Secret", ja: "秘密") }
@@ -2317,16 +2316,16 @@ enum I18n {
static var releasedStaffByHostMessage: String { pick(ko: "방장님이 스탭에서 해제했습니다.", en: "Host removed your staff role.", ja: "配信者があなたのスタッフ権限を解除しました。") } static var releasedStaffByHostMessage: String { pick(ko: "방장님이 스탭에서 해제했습니다.", en: "Host removed your staff role.", ja: "配信者があなたのスタッフ権限を解除しました。") }
static func invitePrivateLiveMessage(_ nickname: String, _ shareUrl: String, _ password: String) -> String { static func invitePrivateLiveMessage(_ nickname: String, _ shareUrl: String, _ password: String) -> String {
pick( pick(
ko: "\(nickname)님이 귀하를 보이스온 비공개라이브에 초대하였습니다.\n※ 라이브 참여: \(shareUrl)\n(입장 비밀번호: \(password))", ko: "\(nickname)님이 귀하를 소다라이브 비공개라이브에 초대하였습니다.\n※ 라이브 참여: \(shareUrl)\n(입장 비밀번호: \(password))",
en: "\(nickname) invited you to a private VoiceOn live.\n※ Join live: \(shareUrl)\n(Entry password: \(password))", en: "\(nickname) invited you to a private SodaLive live.\n※ Join live: \(shareUrl)\n(Entry password: \(password))",
ja: "\(nickname)さんがあなたをVoiceOn非公開ライブに招待しました。\n※ ライブ参加: \(shareUrl)\n(入場パスワード: \(password))" ja: "\(nickname)さんがあなたをSodaLive非公開ライブに招待しました。\n※ ライブ参加: \(shareUrl)\n(入場パスワード: \(password))"
) )
} }
static func invitePublicLiveMessage(_ nickname: String, _ shareUrl: String) -> String { static func invitePublicLiveMessage(_ nickname: String, _ shareUrl: String) -> String {
pick( pick(
ko: "\(nickname)님이 귀하를 보이스온 공개라이브에 초대하였습니다.\n※ 라이브 참여: \(shareUrl)", ko: "\(nickname)님이 귀하를 소다라이브 공개라이브에 초대하였습니다.\n※ 라이브 참여: \(shareUrl)",
en: "\(nickname) invited you to a public VoiceOn live.\n※ Join live: \(shareUrl)", en: "\(nickname) invited you to a public SodaLive live.\n※ Join live: \(shareUrl)",
ja: "\(nickname)さんがあなたをVoiceOn公開ライブに招待しました。\n※ ライブ参加: \(shareUrl)" ja: "\(nickname)さんがあなたをSodaLive公開ライブに招待しました。\n※ ライブ参加: \(shareUrl)"
) )
} }
@@ -2701,13 +2700,13 @@ enum I18n {
} }
enum Category { enum Category {
static var all: String { pick(ko: "전체", en: "All", ja: "全") } static var all: String { pick(ko: "전체", en: "All", ja: "すべて") }
} }
enum RankingSort { enum RankingSort {
// 분석/지표 등 // 분석/지표 등
static var revenue: String { pick(ko: "매출", en: "Revenue", ja: "売上高") } static var revenue: String { pick(ko: "매출", en: "Revenue", ja: "売上") }
static var salesVolume: String { pick(ko: "판매량", en: "Units", ja: "販売量") } static var salesVolume: String { pick(ko: "판매량", en: "Units", ja: "販売数") }
static var comments: String { pick(ko: "댓글", en: "Comments", ja: "コメント") } static var comments: String { pick(ko: "댓글", en: "Comments", ja: "コメント") }
static var likes: String { pick(ko: "좋아요", en: "Likes", ja: "いいね") } static var likes: String { pick(ko: "좋아요", en: "Likes", ja: "いいね") }
} }
@@ -2741,6 +2740,16 @@ enum I18n {
ja: "\(nickname)さんのフォローを解除しますか?" ja: "\(nickname)さんのフォローを解除しますか?"
) )
} }
static var unfollowActionModalTitle: String { pick(ko: "팔로우 해제", en: "Unfollow", ja: "フォロー解除") }
static func unfollowActionModalMessage(_ nickname: String) -> String {
pick(
ko: "팔로우를 해제할까요?\n\(nickname)의 소식을 받을 수 없어요.",
en: "You won't receive updates from \(nickname).\nUnfollow this creator?",
ja: "\(nickname)からの最新情報を受け取れなくなります。\nフォローを解除しますか?"
)
}
static var unfollowActionModalCancel: String { pick(ko: "취소", en: "Cancel", ja: "キャンセル") }
static var unfollowActionModalConfirm: String { pick(ko: "해제하기", en: "Unfollow", ja: "解除する") }
static var liveOnNow: String { pick(ko: "현재 라이브 중입니다.", en: "Live is currently ongoing.", ja: "現在ライブ配信中です。") } static var liveOnNow: String { pick(ko: "현재 라이브 중입니다.", en: "Live is currently ongoing.", ja: "現在ライブ配信中です。") }
static var cannotReserveOwnLive: String { pick(ko: "내가 만든 라이브는 예약할 수 없습니다.", en: "reserve a live you created is required.", ja: "自分が作ったライブは予約できません。") } static var cannotReserveOwnLive: String { pick(ko: "내가 만든 라이브는 예약할 수 없습니다.", en: "reserve a live you created is required.", ja: "自分が作ったライブは予約できません。") }
@@ -2842,7 +2851,7 @@ If you block this user, the following features will be restricted.
} }
static var channelDonationEmpty: String { pick(ko: "채널 후원이 없습니다.", en: "No channel donations.", ja: "チャンネル支援はありません。") } static var channelDonationEmpty: String { pick(ko: "채널 후원이 없습니다.", en: "No channel donations.", ja: "チャンネル支援はありません。") }
static var channelDonationAllTitle: String { pick(ko: "채널 후원 전체보기", en: "All Channel Donations", ja: "チャンネル支援一覧") } static var channelDonationAllTitle: String { pick(ko: "채널 후원 전체보기", en: "All Channel Donations", ja: "チャンネル支援一覧") }
static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") } static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "すべて") }
static var countUnit: String { pick(ko: "개", en: "items", ja: "件") } static var countUnit: String { pick(ko: "개", en: "items", ja: "件") }
static var channelDonationDefaultMessage: String { pick(ko: "을 후원했습니다.", en: " donated.", ja: "を支援しました。") } static var channelDonationDefaultMessage: String { pick(ko: "을 후원했습니다.", en: " donated.", ja: "を支援しました。") }
@@ -2860,7 +2869,7 @@ If you block this user, the following features will be restricted.
static var creatorDetailDebut: String { pick(ko: "데뷔", en: "Debut", ja: "デビュー") } static var creatorDetailDebut: String { pick(ko: "데뷔", en: "Debut", ja: "デビュー") }
static var creatorDetailTotalLiveCount: String { pick(ko: "라이브 총 횟수", en: "Total live sessions", ja: "ライブ総回数") } static var creatorDetailTotalLiveCount: String { pick(ko: "라이브 총 횟수", en: "Total live sessions", ja: "ライブ総回数") }
static var creatorDetailAccumulatedLiveTime: String { pick(ko: "라이브 누적 시간", en: "Total live time", ja: "ライブ累積時間") } static var creatorDetailAccumulatedLiveTime: String { pick(ko: "라이브 누적 시간", en: "Total live time", ja: "ライブ累積時間") }
static var creatorDetailAccumulatedParticipants: String { pick(ko: "라이브 누적 참여자", en: "Total live participants", ja: "ライブ累積参加者") } static var creatorDetailAccumulatedParticipants: String { pick(ko: "라이브 누적 참여자", en: "Total live participants", ja: "ライブ累計参加者数") }
static var creatorDetailRegisteredContentCount: String { pick(ko: "등록 콘텐츠 수", en: "Registered contents", ja: "登録コンテンツ数") } static var creatorDetailRegisteredContentCount: String { pick(ko: "등록 콘텐츠 수", en: "Registered contents", ja: "登録コンテンツ数") }
static var creatorDetailSns: String { pick(ko: "SNS", en: "SNS", ja: "SNS") } static var creatorDetailSns: String { pick(ko: "SNS", en: "SNS", ja: "SNS") }
static var preDebut: String { pick(ko: "데뷔전", en: "Pre-debut", ja: "デビュー前") } static var preDebut: String { pick(ko: "데뷔전", en: "Pre-debut", ja: "デビュー前") }
@@ -2870,7 +2879,7 @@ If you block this user, the following features will be restricted.
} }
static func shareChannelMessage(_ nickname: String) -> String { static func shareChannelMessage(_ nickname: String) -> String {
pick(ko: "보이스온 \(nickname)님의 채널입니다.", en: "This is \(nickname)'s channel on VoiceOn.", ja: "ボイスオンの\(nickname)さんのチャンネルです。") pick(ko: "소다라이브 \(nickname)님의 채널입니다.", en: "This is \(nickname)'s channel on SodaLive.", ja: "ソーダライブの\(nickname)さんのチャンネルです。")
} }
static func reserveWithCansTitle(_ price: Int) -> String { static func reserveWithCansTitle(_ price: Int) -> String {
@@ -2966,11 +2975,7 @@ If you block this user, the following features will be restricted.
} }
static var totalPrefix: String { static var totalPrefix: String {
pick(ko: "총", en: "Total", ja: "合計") pick(ko: "전체", en: "Total", ja: "すべて")
}
static var personUnit: String {
pick(ko: "명", en: "people", ja: "人")
} }
static var emptyFollowingChannels: String { static var emptyFollowingChannels: String {
@@ -3036,7 +3041,7 @@ If you block this user, the following features will be restricted.
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: "Talks", 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: "マイページ") }
} }
enum Auth { enum Auth {
@@ -3095,8 +3100,8 @@ If you block this user, the following features will be restricted.
pick(ko: "New&Hot", en: "New&Hot", ja: "New&Hot") pick(ko: "New&Hot", en: "New&Hot", ja: "New&Hot")
} }
static var voiceOnOnlySectionTitle: String { static var sodaLiveOnlySectionTitle: String {
pick(ko: "오직 보이스온에서만!", en: "Only on VoiceOn!", ja: "VoiceOnだけで!") pick(ko: "오직 소다라이브에서만!", en: "Only on SodaLive!", ja: "SODALIVE限定")
} }
static var freeAudioSectionTitle: String { static var freeAudioSectionTitle: String {
@@ -3127,7 +3132,7 @@ If you block this user, the following features will be restricted.
} }
static var risingTabTitle: String { static var risingTabTitle: String {
pick(ko: "지금 뜨는 중", en: "Rising now", ja: "今話題") pick(ko: "지금 뜨는 중", en: "Rising now", ja: "急上昇")
} }
static var revenueTabTitle: String { static var revenueTabTitle: String {
@@ -3217,8 +3222,8 @@ If you block this user, the following features will be restricted.
pick(ko: "인기 크리에이터", en: "Popular creators", ja: "人気クリエイター") pick(ko: "인기 크리에이터", en: "Popular creators", ja: "人気クリエイター")
} }
static var onlyOnVoiceOnSectionTitle: String { static var onlyOnSodaLiveSectionTitle: String {
pick(ko: "오직 보이스온에서만", en: "Only on VoiceOn", ja: "VoiceOnだけで") pick(ko: "오직 소다라이브에서만", en: "Only on SodaLive", ja: "SodaLiveだけで")
} }
static var popularCharacterChatSectionTitle: String { static var popularCharacterChatSectionTitle: String {
@@ -3242,7 +3247,7 @@ If you block this user, the following features will be restricted.
} }
static var weeklyChartSectionTitle: String { static var weeklyChartSectionTitle: String {
pick(ko: "보온 주간 차트", en: "VoiceOn weekly chart", ja: "ボイスオン週間チャート") pick(ko: "보온 주간 차트", en: "SodaLive weekly chart", ja: "ソーダライブ週間チャート")
} }
enum RecommendChannel { enum RecommendChannel {
@@ -3255,9 +3260,9 @@ If you block this user, the following features will be restricted.
enum HomeFollowing { enum HomeFollowing {
static var emptyStateMessage: String { static var emptyStateMessage: String {
pick( pick(
ko: "아직 팔로잉 소식이 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.", ko: "아직 팔로우한 크리에이터가 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.",
en: "There are no following updates yet.\nFollow creators you're interested in.", en: "There are no following updates yet.\nFollow creators you're interested in.",
ja: "まだフォロー中の新着情報がありません。\n気になるクリエイターをフォローしてみましょう。" ja: "まだフォローしているクリエイターがいません。\n気になるクリエイターをフォローしてみましょう。"
) )
} }
@@ -3334,14 +3339,14 @@ If you block this user, the following features will be restricted.
} }
static var recentNewsTitle: String { static var recentNewsTitle: String {
pick(ko: "최근 소식", en: "Recent news", ja: "最近のニュース") pick(ko: "최근 소식", en: "Recent news", ja: "最新情報")
} }
static func rankingMessagePrefix(_ title: String) -> String { static func rankingMessagePrefix(_ title: String) -> String {
pick( pick(
ko: "\(title)님이 이번 주 랭킹 ", ko: "\(title)님이 이번 주 랭킹 ",
en: "\(title) reached ", en: "\(title) reached ",
ja: "\(title)さんが今週のランキング" ja: "\(title)が今週のランキングで"
) )
} }
@@ -3350,7 +3355,7 @@ If you block this user, the following features will be restricted.
} }
static var rankingMessageSuffix: String { static var rankingMessageSuffix: String {
pick(ko: "에 올랐어요!", en: " in this week's ranking!", ja: "に入りました!") pick(ko: "에 올랐어요!", en: " in this week's ranking!", ja: "にランクイン!")
} }
static var audioTag: String { static var audioTag: String {
@@ -3373,7 +3378,7 @@ If you block this user, the following features will be restricted.
} }
enum CreatorChannelLive { enum CreatorChannelLive {
static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") } static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "すべて") }
static var startLive: String { pick(ko: "라이브 시작하기", en: "Start live", ja: "ライブを開始") } static var startLive: String { pick(ko: "라이브 시작하기", en: "Start live", ja: "ライブを開始") }
static var emptyMessage: String { static var emptyMessage: String {
pick( pick(
@@ -3386,25 +3391,25 @@ If you block this user, the following features will be restricted.
enum Sort { enum Sort {
static var latest: String { pick(ko: "최신순", en: "Latest", ja: "最新順") } static var latest: String { pick(ko: "최신순", en: "Latest", ja: "最新順") }
static var popular: String { pick(ko: "인기순", en: "Popular", ja: "人気順") } static var popular: String { pick(ko: "인기순", en: "Popular", ja: "人気順") }
static var priceHigh: String { pick(ko: "높은 가격순", en: "Highest price", ja: "価格が高い順") } static var priceHigh: String { pick(ko: "높은 가격순", en: "Highest price", ja: "高い順") }
static var priceLow: String { pick(ko: "낮은 가격순", en: "Lowest price", ja: "価格が低い順") } static var priceLow: String { pick(ko: "낮은 가격순", en: "Lowest price", ja: "安い順") }
} }
} }
enum CreatorChannelAudio { enum CreatorChannelAudio {
static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") } static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "すべて") }
static var countUnit: String { pick(ko: "개", en: "", ja: "件") } static var countUnit: String { pick(ko: "개", en: "", ja: "件") }
static var ownershipPrefix: String { static var ownershipPrefix: String {
pick(ko: "전체 오디오의 ", en: "You own ", ja: "全オーディオの") pick(ko: "전체 오디오의 ", en: "You own ", ja: "全オーディオの")
} }
static var ownershipSuffix: String { static var ownershipSuffix: String {
pick(ko: "를 소장하고 있어요.", en: " of all audio.", ja: "を保有中です。") pick(ko: "를 소장하고 있어요.", en: " of all audio.", ja: "を所持しています。")
} }
static var emptyMessage: String { static var emptyMessage: String {
pick( pick(
ko: "크리에이터가 오디오를 준비 중입니다.\n기대해 주세요!", ko: "크리에이터가 오디오를 준비 중입니다.\n기대해 주세요!",
en: "The creator is preparing audio.\nPlease stay tuned!", en: "The creator is preparing audio.\nPlease stay tuned!",
ja: "クリエイターがオーディオを準備中です。\nお楽しみに!" ja: "クリエイターがオーディオを準備しています。\nお楽しみに!"
) )
} }
} }
@@ -3417,42 +3422,58 @@ If you block this user, the following features will be restricted.
pick( pick(
ko: "크리에이터가 시리즈를 준비 중입니다.\n기대해 주세요!", ko: "크리에이터가 시리즈를 준비 중입니다.\n기대해 주세요!",
en: "The creator is preparing series.\nPlease stay tuned!", en: "The creator is preparing series.\nPlease stay tuned!",
ja: "クリエイターがシリーズを準備中です。\nお楽しみに!" ja: "クリエイターがシリーズを準備しています。\nお楽しみに!"
) )
} }
static func totalContentCount(_ count: Int) -> String { static func totalContentCount(_ count: Int) -> String {
pick(ko: "총 \(count)화", en: "Total \(count) episodes", ja: "全\(count)話") pick(ko: "전체 \(count)화", en: "Total \(count) episodes", ja: "すべて\(count)話")
} }
} }
enum CreatorChannelHome { enum CreatorChannelHome {
static var home: String { pick(ko: "홈", en: "Home", ja: "ホーム") }
static var live: String { pick(ko: "라이브", en: "Live", ja: "ライブ") }
static var currentLive: String { pick(ko: "현재 라이브", en: "Live now", ja: "現在ライブ") } static var currentLive: String { pick(ko: "현재 라이브", en: "Live now", ja: "現在ライブ") }
static var latestAudio: String { pick(ko: "최신 오디오 콘텐츠", en: "Latest audio", ja: "最新オーディオ") } static var latestAudio: String { pick(ko: "최신 오디오 콘텐츠", en: "Latest audio", ja: "最新オーディオ") }
static var donation: String { pick(ko: "후원", en: "Donations", ja: "応援") } static var donation: String { pick(ko: "후원", en: "Donations", ja: "サポート") }
static var donationEmptyMessage: String {
pick(
ko: "처음으로 크리에이터를\n후원해 보세요!",
en: "Be the first to\nsupport this creator!",
ja: "最初のサポートをしてみましょう!"
)
}
static var notice: String { pick(ko: "공지", en: "Notice", ja: "お知らせ") } static var notice: String { pick(ko: "공지", en: "Notice", ja: "お知らせ") }
static var schedule: String { pick(ko: "스케줄", en: "Schedule", ja: "スケジュール") } static var schedule: String { pick(ko: "스케줄", en: "Schedule", ja: "スケジュール") }
static var audio: String { pick(ko: "오디오", en: "Audio", ja: "オーディオ") } static var audio: String { pick(ko: "오디오", en: "Audio", ja: "オーディオ") }
static var series: String { pick(ko: "시리즈", en: "Series", ja: "シリーズ") } static var series: String { pick(ko: "시리즈", en: "Series", ja: "シリーズ") }
static var community: String { pick(ko: "커뮤니티", en: "Community", ja: "コミュニティ") } static var community: String { pick(ko: "커뮤니티", en: "Community", ja: "コミュニティ") }
static var fanTalk: String { pick(ko: "팬Talk", en: "Fan Talk", ja: "ファンTalk") } static var fanTalk: String { pick(ko: "팬레터", en: "Fan Letter", ja: "ファンレター") }
static var fanTalkEmptyMessage: String {
pick(
ko: "당신의 첫 응원을\n기다리고 있어요!",
en: "We're waiting for\nyour first fan letter!",
ja: "あなたの最初のファンレターを待っています!"
)
}
static var introduce: String { pick(ko: "소개", en: "Introduction", ja: "紹介") } static var introduce: String { pick(ko: "소개", en: "Introduction", ja: "紹介") }
static var activity: String { pick(ko: "활동", en: "Activity", ja: "活動") } static var activity: String { pick(ko: "활동", en: "Activity", ja: "アクティビティ") }
static var sns: String { pick(ko: "SNS", en: "SNS", ja: "SNS") } static var sns: String { pick(ko: "SNS", en: "SNS", ja: "SNS") }
static var follow: String { pick(ko: "팔로우", en: "Follow", ja: "フォロー") } static var follow: String { pick(ko: "팔로우", en: "Follow", ja: "フォロー") }
static var following: String { pick(ko: "팔로잉", en: "Following", ja: "フォロー中") } static var following: String { pick(ko: "팔로잉", en: "Following", ja: "フォロー中") }
static var talk: String { pick(ko: "대화하기", en: "Start chat", ja: "会話する") } static var talk: String { pick(ko: "대화하기", en: "Start chat", ja: "トークする") }
static var dm: String { pick(ko: "DM 보내기", en: "Send DM", ja: "DMを送る") } static var dm: String { pick(ko: "DM 보내기", en: "Send DM", ja: "DMを送る") }
static var checkDm: String { pick(ko: "DM 확인하기", en: "Check DM", ja: "DMを確認") } static var checkDm: String { pick(ko: "DM 확인하기", en: "Check DM", ja: "DMを確認") }
static var uploadCommunityPost: String { pick(ko: "커뮤니티 글 올리기", en: "Upload community post", ja: "コミュニティ投稿を作成") } static var uploadCommunityPost: String { pick(ko: "커뮤니티 글 올리기", en: "Upload community post", ja: "コミュニティ投稿") }
static var uploadAudioContent: String { pick(ko: "오디오 콘텐츠 올리기", en: "Upload audio content", ja: "オーディオコンテンツを投稿") } static var uploadAudioContent: String { pick(ko: "오디오 콘텐츠 올리기", en: "Upload audio content", ja: "オーディオコンテンツを投稿") }
static var createLive: String { pick(ko: "라이브 만들기", en: "Create live", ja: "ライブを作成") } static var createLive: String { pick(ko: "라이브 만들기", en: "Create live", ja: "ライブを作成") }
static var closeFloatingMenu: String { pick(ko: "닫기", en: "Close", ja: "閉じる") } static var closeFloatingMenu: String { pick(ko: "닫기", en: "Close", ja: "閉じる") }
static var viewAll: String { pick(ko: "전체보기", en: "View all", ja: "すべて見る") } static var viewAll: String { pick(ko: "전체보기", en: "View all", ja: "すべて見る") }
static var debut: String { pick(ko: "데뷔", en: "Debut", ja: "デビュー") } static var debut: String { pick(ko: "데뷔", en: "Debut", ja: "デビュー") }
static var liveCount: String { pick(ko: "라이브 총 진행 수", en: "Total live count", 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 liveDurationHours: String { pick(ko: "라이브 누적 진행 시간", en: "Total live hours", ja: "ライブ累計配信時間") }
static var liveContributorCount: String { pick(ko: "라이브 누적 참여자", en: "Total live participants", ja: "ライブ累計参加者") } static var liveContributorCount: String { pick(ko: "라이브 누적 참여자", en: "Total live participants", ja: "ライブ累計参加者数") }
static var audioContentCount: String { pick(ko: "오디오", en: "Audio", ja: "オーディオ") } static var audioContentCount: String { pick(ko: "오디오", en: "Audio", ja: "オーディオ") }
static var seriesCount: String { pick(ko: "시리즈", en: "Series", ja: "シリーズ") } static var seriesCount: String { pick(ko: "시리즈", en: "Series", ja: "シリーズ") }
static var placeholderMessage: String { static var placeholderMessage: String {
@@ -3489,15 +3510,15 @@ If you block this user, the following features will be restricted.
} }
enum CreatorChannelCommunity { enum CreatorChannelCommunity {
static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") } static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "すべて") }
static var listView: String { pick(ko: "리스트형", en: "List", ja: "リスト型") } static var listView: String { pick(ko: "리스트형", en: "List", ja: "リスト") }
static var thumbnailView: String { pick(ko: "썸네일형", en: "Thumbnail", ja: "サムネイル型") } static var thumbnailView: String { pick(ko: "썸네일형", en: "Thumbnail", ja: "グリッド") }
static var commentPlaceholder: String { pick(ko: "댓글을 입력하세요", en: "Enter a comment", ja: "コメントを入力してください") } static var commentPlaceholder: String { pick(ko: "댓글을 입력하세요", en: "Enter a comment", ja: "コメントを入力") }
static var communityEmpty: String { static var communityEmpty: String {
pick( pick(
ko: "크리에이터가 커뮤니티를 준비 중입니다.\n기대해 주세요!", ko: "크리에이터가 커뮤니티를 준비 중입니다.\n기대해 주세요!",
en: "The creator is preparing the community.\nPlease look forward to it!", en: "The creator is preparing the community.\nPlease look forward to it!",
ja: "クリエイターがコミュニティを準備中です。\nお楽しみに!" ja: "クリエイターがコミュニティを準備しています。\nお楽しみに!"
) )
} }
} }
@@ -3505,7 +3526,7 @@ If you block this user, the following features will be restricted.
enum CreatorChannelReplyDetail { enum CreatorChannelReplyDetail {
static var replyPlaceholder: String { static var replyPlaceholder: String {
pick(ko: "답글을 입력하세요", en: "Enter a reply", ja: "返信を入力してください") pick(ko: "답글을 입력하세요", en: "Enter a reply", ja: "返信を入力")
} }
static var replaceReplyTitle: String { static var replaceReplyTitle: String {
@@ -3517,16 +3538,43 @@ If you block this user, the following features will be restricted.
} }
static var replaceReplyConfirm: String { static var replaceReplyConfirm: String {
pick(ko: "수정하기", en: "Update", ja: "修正する") pick(ko: "수정하기", en: "Update", ja: "編集")
} }
} }
enum CreatorChannelFanTalk { enum CreatorChannelFanTalk {
static var emptyMessage: String {
pick(
ko: "아직 응원이 없습니다.\n처음으로 크리에이터를 응원해 보세요!",
en: "No fan letters yet.\nBe the first to send one!",
ja: "まだファンレターがありません。\n最初のファンレターを送ってみましょう!"
)
}
static var writeAction: String { pick(ko: "응원남기기", en: "Send a fan letter", ja: "ファンレターを送る") }
static var writePlaceholder: String { pick(ko: "응원 메시지를 입력해 주세요.", en: "Enter a message of support.", ja: "応援メッセージを入力してください。") }
static var send: String { pick(ko: "보내기", en: "Send", ja: "送信") } static var send: String { pick(ko: "보내기", en: "Send", ja: "送信") }
static var exitModalTitle: String { pick(ko: "입력 종료", en: "Discard input", 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 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 exitModalExit: String { pick(ko: "종료", en: "Discard", ja: "終了") }
static var exitModalContinue: String { pick(ko: "계속 작성", en: "Keep writing", ja: "続けて書く") } static var exitModalContinue: String { pick(ko: "계속 작성", en: "Keep writing", ja: "続ける") }
}
enum CreatorChannelDonation {
static var emptyMessage: String {
pick(
ko: "아직 후원이 없습니다.\n처음으로 크리에이터를 후원해 보세요!",
en: "No support yet.\nBe the first to support this creator!",
ja: "まだサポートがありません。\n最初のサポートをしてみましょう!"
)
}
static func defaultMessage(_ can: Int) -> String {
pick(
ko: "\(can)캔을 후원하였습니다",
en: "Donated \(can) cans.",
ja: "\(can)canをサポートしました。"
)
}
} }
enum HomeRecommendation { enum HomeRecommendation {
@@ -3555,7 +3603,7 @@ If you block this user, the following features will be restricted.
} }
static var recentDebutCreatorSectionTitle: String { static var recentDebutCreatorSectionTitle: String {
pick(ko: "최근 데뷔한 크리에이터", en: "Recently debuted creators", ja: "最近デビューしたクリエイター") pick(ko: "최근 데뷔한 크리에이터", en: "Recently debuted creators", ja: "新着クリエイター")
} }
static var firstAudioContentSectionTitle: String { static var firstAudioContentSectionTitle: String {
@@ -3563,7 +3611,7 @@ If you block this user, the following features will be restricted.
} }
static var aiCharacterSectionTitle: String { static var aiCharacterSectionTitle: String {
pick(ko: "크리에이터와 이야기를 나눠요!", en: "AI characters", ja: "AIキャラクター") pick(ko: "크리에이터와 이야기를 나눠요!", en: "AI characters", ja: "クリエイターと話す")
} }
static func compactChatCount(_ count: Int) -> String { static func compactChatCount(_ count: Int) -> String {
@@ -3622,7 +3670,7 @@ If you block this user, the following features will be restricted.
} }
static var cheerCreatorSectionTitle: String { static var cheerCreatorSectionTitle: String {
pick(ko: "최근 응원이 많은 크리에이터", en: "Recently cheered creators", ja: "最近応援が多いクリエイター") pick(ko: "최근 응원이 많은 크리에이터", en: "Recently cheered creators", ja: "最近応援の多いクリエイター")
} }
static var popularCommunitySectionTitle: String { static var popularCommunitySectionTitle: String {
@@ -3638,11 +3686,11 @@ If you block this user, the following features will be restricted.
} }
static var followAllConfirmTitle: String { static var followAllConfirmTitle: String {
pick(ko: "모두 팔로우할까요?", en: "Follow all creators?", ja: "すべてのクリエイターをフォローしますか?") pick(ko: "모두 팔로우 하기", en: "Follow all", ja: "すべてフォロー")
} }
static var followAllConfirmDescription: String { static var followAllConfirmDescription: String {
pick(ko: "추천된 크리에이터를 모두 팔로우합니다.", en: "You’ll follow all recommended creators.", ja: "おすすめのクリエイターをすべてフォローします。") pick(ko: "추천 크리에이터를 모두 팔로우하고,\n소식을 받아보세요!", en: "Follow all recommended creators\nand get their updates!", ja: "おすすめのクリエイターをすべてフォローして、\n最新情報を受け取りましょう!")
} }
static var followAllConfirmAction: String { static var followAllConfirmAction: String {
@@ -3708,11 +3756,11 @@ If you block this user, the following features will be restricted.
} }
static var editAction: String { static var editAction: String {
pick(ko: "수정하기", en: "Edit", ja: "編集する") pick(ko: "수정하기", en: "Edit", ja: "編集")
} }
static var deleteAction: String { static var deleteAction: String {
pick(ko: "삭제하기", en: "Delete", ja: "削除する") pick(ko: "삭제하기", en: "Delete", ja: "削除")
} }
static var report: String { static var report: String {
@@ -3760,7 +3808,7 @@ If you block this user, the following features will be restricted.
} }
static var pinRelease: String { static var pinRelease: String {
pick(ko: "고정 해제", en: "Unpin", ja: "固定解除") pick(ko: "고정 해제", en: "Unpin", ja: "固定を解除")
} }
static var pinToTop: String { static var pinToTop: String {
@@ -3864,11 +3912,11 @@ If you block this user, the following features will be restricted.
} }
static var fanTalkTitle: String { static var fanTalkTitle: String {
pick(ko: "팬 Talk", en: "Fan Talk", ja: "ファントーク") pick(ko: "팬레터", en: "Fan Letter", ja: "ファンレター")
} }
static var fanTalkAllTitle: String { static var fanTalkAllTitle: String {
pick(ko: "팬 Talk 전체보기", en: "All Fan Talk", ja: "ファントーク一覧") pick(ko: "팬레터 전체보기", en: "All Fan Letters", ja: "ファンレター一覧")
} }
static var cheerTitle: String { static var cheerTitle: String {
@@ -3992,9 +4040,9 @@ If you block this user, the following features will be restricted.
enum ApplyAuditionComplete { enum ApplyAuditionComplete {
static var thankYouDescription: String { static var thankYouDescription: String {
pick( pick(
ko: "보이스온 오디션에 지원해 주셔서 감사합니다.", ko: "소다라이브 오디션에 지원해 주셔서 감사합니다.",
en: "Thank you for applying to the VoiceOn audition.", en: "Thank you for applying to the SodaLive audition.",
ja: "VoiceOnオーディションにご応募いただきありがとうございます。" ja: "SodaLiveオーディションにご応募いただきありがとうございます。"
) )
} }
} }
@@ -4117,7 +4165,7 @@ If you block this user, the following features will be restricted.
} }
static var archive: String { static var archive: String {
pick(ko: "보관함", en: "Archived", ja: "保管済み") pick(ko: "보관함", en: "Archived", ja: "ライブラリ")
} }
} }
@@ -4304,7 +4352,7 @@ If you block this user, the following features will be restricted.
static var completedSectionTitle: String { pick(ko: "완결 시리즈", en: "Completed series", ja: "完結シリーズ") } static var completedSectionTitle: String { pick(ko: "완결 시리즈", en: "Completed series", ja: "完結シリーズ") }
static var recommendedSectionTitle: String { pick(ko: "추천 시리즈", en: "Recommended series", ja: "おすすめシリーズ") } static var recommendedSectionTitle: String { pick(ko: "추천 시리즈", en: "Recommended series", ja: "おすすめシリーズ") }
static var byDaySectionTitle: String { pick(ko: "요일별 시리즈", en: "Series by day", ja: "曜日別シリーズ") } static var byDaySectionTitle: String { pick(ko: "요일별 시리즈", en: "Series by day", ja: "曜日別シリーズ") }
static var voiceOnOnlyTitle: String { pick(ko: "오직 보이스온에서만", en: "Only on VoiceOn", ja: "VoiceOn限定") } static var sodaLiveOnlyTitle: String { pick(ko: "오직 소다라이브에서만", en: "Only on SodaLive", ja: "SodaLive限定") }
static var allEpisodesListen: String { pick(ko: "전체회차 듣기", en: "Listen to all episodes", ja: "全話を聴く") } static var allEpisodesListen: String { pick(ko: "전체회차 듣기", en: "Listen to all episodes", ja: "全話を聴く") }
static func allEpisodesTitle(_ seriesTitle: String) -> String { pick(ko: "\(seriesTitle) - 전체회차 듣기", en: "\(seriesTitle) - All episodes", ja: "\(seriesTitle) - 全話を聴く") } static func allEpisodesTitle(_ seriesTitle: String) -> String { pick(ko: "\(seriesTitle) - 전체회차 듣기", en: "\(seriesTitle) - All episodes", ja: "\(seriesTitle) - 全話を聴く") }
static var registeredOrder: String { pick(ko: "등록순", en: "Oldest", ja: "登録順") } static var registeredOrder: String { pick(ko: "등록순", en: "Oldest", ja: "登録順") }
@@ -4321,7 +4369,7 @@ If you block this user, the following features will be restricted.
static var age19Badge: String { pick(ko: "19세", en: "19+", ja: "19+") } static var age19Badge: String { pick(ko: "19세", en: "19+", ja: "19+") }
static func publishing(_ days: String) -> String { pick(ko: "\(days) 연재", en: "\(days)", ja: "\(days)連載") } static func publishing(_ days: String) -> String { pick(ko: "\(days) 연재", en: "\(days)", ja: "\(days)連載") }
static var totalEpisodes: (Int) -> String = { count in static var totalEpisodes: (Int) -> String = { count in
pick(ko: "총 \(count)화", en: "Total \(count) episodes", ja: "全\(count)話") pick(ko: "전체 \(count)화", en: "Total \(count) episodes", ja: "すべて\(count)話")
} }
// 시리즈 전체보기(크리에이터 기준) 타이틀 // 시리즈 전체보기(크리에이터 기준) 타이틀
@@ -4526,11 +4574,7 @@ If you block this user, the following features will be restricted.
enum MyPage { enum MyPage {
enum Common { enum Common {
static var totalPrefix: String { static var totalPrefix: String {
pick(ko: "총", en: "Total", ja: "合計") pick(ko: "전체", en: "Total", ja: "すべて")
}
static var personUnit: String {
pick(ko: "명", en: "people", ja: "人")
} }
static var countUnit: String { static var countUnit: String {
@@ -4584,7 +4628,7 @@ If you block this user, the following features will be restricted.
} }
static var chargeAction: String { static var chargeAction: String {
pick(ko: "충전하기", en: "Charge", ja: "チャージする") pick(ko: "충전하기", en: "Charge", ja: "チャージ")
} }
static var chargeShort: String { static var chargeShort: String {
@@ -4686,9 +4730,9 @@ If you block this user, the following features will be restricted.
static var noticeTermsReference: String { static var noticeTermsReference: String {
pick( pick(
ko: "자세한 내용은 보이스온 이용약관에서 확인할 수 있습니다.", ko: "자세한 내용은 소다라이브 이용약관에서 확인할 수 있습니다.",
en: "For details, please check the VoiceOn Terms of Service.", en: "For details, please check the SodaLive Terms of Service.",
ja: "詳細はVoiceOn利用規約をご確認ください。" ja: "詳細はSodaLive利用規約をご確認ください。"
) )
} }
@@ -4782,7 +4826,7 @@ If you block this user, the following features will be restricted.
enum Category { enum Category {
static var storage: String { static var storage: String {
pick(ko: "보관함", en: "Library", ja: "保管庫") pick(ko: "보관함", en: "Library", ja: "ライブラリ")
} }
static var blockList: String { static var blockList: String {
@@ -4816,7 +4860,7 @@ If you block this user, the following features will be restricted.
} }
static var owned: String { static var owned: String {
pick(ko: "소장중", en: "Owned", ja: "所持中") pick(ko: "소장중", en: "Owned", ja: "保有中")
} }
} }
@@ -5056,7 +5100,7 @@ If you block this user, the following features will be restricted.
enum ServiceCenter { enum ServiceCenter {
static var buttonTitle: String { static var buttonTitle: String {
pick(ko: "보이스온 고객센터", en: "VoiceOn customer center", ja: "VoiceOnカスタマーセンター") pick(ko: "소다라이브 고객센터", en: "SodaLive customer center", ja: "SodaLiveカスタマーセンター")
} }
static var talkInquiry: String { static var talkInquiry: String {
@@ -5079,11 +5123,11 @@ If you block this user, the following features will be restricted.
enum User { enum User {
static var emailTitle: String { static var emailTitle: String {
pick(ko: "이메일", en: "Email", ja: "メール") pick(ko: "이메일", en: "Email", ja: "メールアドレス")
} }
static var emailPlaceholder: String { static var emailPlaceholder: String {
pick(ko: "이메일", en: "Email", ja: "メール") pick(ko: "이메일", en: "Email", ja: "メールアドレス")
} }
static var passwordTitle: String { static var passwordTitle: String {
@@ -5137,7 +5181,7 @@ If you block this user, the following features will be restricted.
} }
static var privacyPolicy: String { static var privacyPolicy: String {
pick(ko: "개인정보수집 및 이용동의", en: "Consent to collect and use personal information", ja: "個人情報の収集および利用への同意") pick(ko: "개인정보수집 및 이용동의", en: "Consent to collect and use personal information", ja: "個人情報の収集・利用への同意")
} }
static var required: String { static var required: String {
@@ -5201,7 +5245,7 @@ If you block this user, the following features will be restricted.
} }
static var signUpPrompt: String { static var signUpPrompt: String {
pick(ko: "보이스온 회원이 아닌가요? 지금 가입하세요.", en: "Not a VoiceOn member? Sign up now.", ja: "VoiceOnの会員ではありませんか?今すぐ登録してください。") pick(ko: "소다라이브 회원이 아닌가요? 지금 가입하세요.", en: "Not a SodaLive member? Sign up now.", ja: "SodaLiveの会員ではありませんか?今すぐ登録してください。")
} }
static var appleAuthorizationFailed: String { static var appleAuthorizationFailed: String {
@@ -32,7 +32,7 @@ struct LiveRoomDonationRankingDialog: View {
LiveRoomDonationRankingTotalCanView(totalCan: donationStatus.totalCan, totalSecretCan: donationStatus.totalSecretCan) LiveRoomDonationRankingTotalCanView(totalCan: donationStatus.totalCan, totalSecretCan: donationStatus.totalSecretCan)
.padding(.top, 25) .padding(.top, 25)
HStack(spacing: 0) { HStack(spacing: 4) {
Text(I18n.CreateLive.totalLabel) Text(I18n.CreateLive.totalLabel)
.appFont(size: 14.7, weight: .medium) .appFont(size: 14.7, weight: .medium)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
@@ -42,10 +42,6 @@ struct LiveRoomDonationRankingDialog: View {
.foregroundColor(Color.button) .foregroundColor(Color.button)
.padding(.leading, 6.7) .padding(.leading, 6.7)
Text(I18n.CreateLive.peopleUnit)
.appFont(size: 12, weight: .medium)
.foregroundColor(Color.gray77)
Spacer() Spacer()
} }
.padding(.top, 13.3) .padding(.top, 13.3)
@@ -55,7 +55,7 @@ struct LiveRoomHeartRankingDialog: View {
.cornerRadius(8) .cornerRadius(8)
.padding(.top, 25) .padding(.top, 25)
HStack(spacing: 0) { HStack(spacing: 4) {
Text(I18n.CreateLive.totalLabel) Text(I18n.CreateLive.totalLabel)
.appFont(size: 14.7, weight: .medium) .appFont(size: 14.7, weight: .medium)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
@@ -65,10 +65,6 @@ struct LiveRoomHeartRankingDialog: View {
.foregroundColor(Color.button) .foregroundColor(Color.button)
.padding(.leading, 6.7) .padding(.leading, 6.7)
Text(I18n.CreateLive.peopleUnit)
.appFont(size: 12, weight: .medium)
.foregroundColor(Color.gray77)
Spacer() Spacer()
} }
.padding(.top, 13.3) .padding(.top, 13.3)
@@ -16,7 +16,7 @@ struct BlockMemberListView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
DetailNavigationBar(title: I18n.MyPage.Block.listTitle) DetailNavigationBar(title: I18n.MyPage.Block.listTitle)
HStack(spacing: 0) { HStack(spacing: 4) {
Text(I18n.MyPage.Common.totalPrefix) Text(I18n.MyPage.Common.totalPrefix)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
@@ -25,10 +25,6 @@ struct BlockMemberListView: View {
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.mainRed3) .foregroundColor(Color.mainRed3)
Text(I18n.MyPage.Common.personUnit)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayee)
Spacer() Spacer()
} }
.padding(.horizontal, 13.3) .padding(.horizontal, 13.3)
+8 -4
View File
@@ -14,6 +14,8 @@ import RefreshableScrollView
struct MyPageView: View { struct MyPageView: View {
@Environment(\.locale) private var locale
@StateObject var viewModel = MyPageViewModel() @StateObject var viewModel = MyPageViewModel()
@StateObject var recentContentViewModel = RecentContentViewModel() @StateObject var recentContentViewModel = RecentContentViewModel()
@@ -136,9 +138,10 @@ struct MyPageView: View {
if let url = URL(string: "https://bit.ly/4h7pVks"), if let url = URL(string: "https://bit.ly/4h7pVks"),
UIApplication.shared.canOpenURL(url) { UIApplication.shared.canOpenURL(url) {
// Voice On Banner // SodaLive Banner
Image("img_apply_creator") Image(locale.identifier.hasPrefix("ja") ? "img_apply_creator_ja" : "img_apply_creator")
.resizable() .resizable()
.scaledToFit()
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.horizontal, 24) .padding(.horizontal, 24)
.onTapGesture { .onTapGesture {
@@ -148,9 +151,10 @@ struct MyPageView: View {
if let url = URL(string: "https://blog.naver.com/sodalive_official"), if let url = URL(string: "https://blog.naver.com/sodalive_official"),
UIApplication.shared.canOpenURL(url) { UIApplication.shared.canOpenURL(url) {
// Voice On Banner // SodaLive Banner
Image("img_introduce_voiceon") Image(locale.identifier.hasPrefix("ja") ? "img_introduce_sodalive_ja" : "img_introduce_sodalive")
.resizable() .resizable()
.scaledToFit()
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.horizontal, 24) .padding(.horizontal, 24)
.onTapGesture { .onTapGesture {
@@ -11,6 +11,7 @@ import Kingfisher
struct EventListView: View { struct EventListView: View {
@ObservedObject var viewModel = EventListViewModel() @ObservedObject var viewModel = EventListViewModel()
@State private var hasLoadedEvents = false
var body: some View { var body: some View {
BaseView(isLoading: $viewModel.isLoading) { BaseView(isLoading: $viewModel.isLoading) {
@@ -50,6 +51,8 @@ struct EventListView: View {
} }
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2) .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
.onAppear { .onAppear {
guard !hasLoadedEvents else { return }
hasLoadedEvents = true
viewModel.getEvents() viewModel.getEvents()
} }
} }
+5 -6
View File
@@ -20,15 +20,14 @@ struct SplashView: View {
Color.black Color.black
.ignoresSafeArea() .ignoresSafeArea()
VStack(spacing: 0) { VStack(spacing: 8) {
Spacer() Spacer()
Text(I18n.Splash.tagline)
.appFont(size: 23, weight: .bold)
.foregroundColor(.white)
Image("splash_text_logo") Image("splash_text_logo")
.padding(.top, 10)
Text(I18n.Splash.tagline)
.appFont(.heading4)
.foregroundColor(.white)
Spacer() Spacer()
} }
@@ -42,16 +42,16 @@ private struct CapsuleTabBarItem: View {
} label: { } label: {
Text(title) Text(title)
.appFont(.body5) .appFont(.body5)
.foregroundColor(Color.white) .foregroundColor(isSelected ? Color.black : Color.white)
.lineLimit(1) .lineLimit(1)
.padding(.horizontal, SodaSpacing.s12) .padding(.horizontal, SodaSpacing.s12)
.padding(.vertical, SodaSpacing.s8) .padding(.vertical, SodaSpacing.s8)
.frame(height: 34) .frame(height: 34)
.background(isSelected ? Color.soda400 : Color.black) .background(isSelected ? Color.white : Color.black)
.clipShape(Capsule()) .clipShape(Capsule())
.overlay( .overlay(
Capsule() Capsule()
.stroke(isSelected ? Color.soda400 : Color.gray700, lineWidth: 1) .stroke(isSelected ? Color.white : Color.gray700, lineWidth: 1)
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -14,7 +14,7 @@ struct HomeTitleBar<Menu: View>: View {
var body: some View { var body: some View {
TitleBar { TitleBar {
Image("img_text_logo_v2") Image("img_text_logo")
} trailing: { } trailing: {
menu menu
} }
@@ -0,0 +1,27 @@
import SwiftUI
import Lottie
struct LottieRefreshIndicator: View {
let isAnimating: Bool
@Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion
var body: some View {
let animation = LottieView(animation: .named("pull-to-refresh"))
.configure(\.contentMode, to: .scaleAspectFit)
.resizable()
Group {
if accessibilityReduceMotion {
animation.currentFrame(1)
} else if isAnimating {
animation.looping()
} else {
animation.currentFrame(0)
}
}
.frame(maxWidth: SodaSpacing.s48, maxHeight: SodaSpacing.s48)
.accessibilityHidden(true)
}
}
@@ -0,0 +1,119 @@
import SwiftUI
struct LottieRefreshableScrollView<Content: View>: View {
private let action: () async -> Void
private let content: () -> Content
private let refreshRegionHeight = SodaSpacing.s48 + (SodaSpacing.s8 * 2)
@Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion
@State private var pullOffset: CGFloat = 0
@State private var isRefreshArmed = false
@State private var isRefreshing = false
init(
action: @escaping () async -> Void,
@ViewBuilder content: @escaping () -> Content
) {
self.action = action
self.content = content
}
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
pullOffsetReader
refreshRegion
content()
}
}
.scrollBounceBehavior(.always, axes: .vertical)
.background(PullOffsetPositionReader(type: .fixed))
.onPreferenceChange(PullOffsetPreferenceKey.self) { positions in
guard
let movingMinY = positions.first(where: { $0.type == .moving })?.minY,
let fixedMinY = positions.first(where: { $0.type == .fixed })?.minY
else { return }
let offset = max(0, movingMinY - fixedMinY)
pullOffset = offset
guard !isRefreshing else { return }
if offset >= refreshRegionHeight {
isRefreshArmed = true
} else if isRefreshArmed {
isRefreshArmed = false
refresh()
}
}
.accessibilityAction(named: Text(I18n.LiveNow.refreshButton)) {
refresh()
}
}
private var pullOffsetReader: some View {
PullOffsetPositionReader(type: .moving)
.frame(height: 0)
}
private var refreshRegion: some View {
Color.clear
.frame(height: isRefreshing ? refreshRegionHeight : 0)
.overlay(alignment: .top) {
if pullOffset > 0 || isRefreshing {
LottieRefreshIndicator(isAnimating: true)
.padding(.vertical, SodaSpacing.s8)
.frame(maxWidth: .infinity)
.frame(height: refreshRegionHeight)
.offset(y: isRefreshing ? 0 : -refreshRegionHeight)
}
}
}
private func refresh() {
isRefreshArmed = false
guard !isRefreshing else { return }
withAnimation(accessibilityReduceMotion ? nil : .easeOut(duration: 0.2)) {
isRefreshing = true
}
Task { @MainActor in
await action()
withAnimation(accessibilityReduceMotion ? nil : .easeOut(duration: 0.2)) {
pullOffset = 0
isRefreshing = false
}
}
}
}
private enum PullOffsetPositionType {
case fixed
case moving
}
private struct PullOffsetPosition: Equatable {
let type: PullOffsetPositionType
let minY: CGFloat
}
private struct PullOffsetPreferenceKey: PreferenceKey {
static var defaultValue: [PullOffsetPosition] = []
static func reduce(value: inout [PullOffsetPosition], nextValue: () -> [PullOffsetPosition]) {
value.append(contentsOf: nextValue())
}
}
private struct PullOffsetPositionReader: View {
let type: PullOffsetPositionType
var body: some View {
GeometryReader { proxy in
Color.clear.preference(
key: PullOffsetPreferenceKey.self,
value: [PullOffsetPosition(type: type, minY: proxy.frame(in: .global).minY)]
)
}
}
}
@@ -11,6 +11,7 @@ struct TextTabBar<Item: Hashable>: View {
let title: (Item) -> String let title: (Item) -> String
var body: some View { var body: some View {
ScrollView(.horizontal) {
HStack(alignment: .center, spacing: SodaSpacing.s20) { HStack(alignment: .center, spacing: SodaSpacing.s20) {
ForEach(items.prefix(3), id: \.self) { item in ForEach(items.prefix(3), id: \.self) { item in
Button { Button {
@@ -19,15 +20,16 @@ struct TextTabBar<Item: Hashable>: View {
Text(title(item)) Text(title(item))
.appFont(.heading3) .appFont(.heading3)
.foregroundColor(selectedItem == item ? Color.white : Color.gray600) .foregroundColor(selectedItem == item ? Color.white : Color.gray600)
.fixedSize(horizontal: true, vertical: false)
.frame(height: 52, alignment: .center) .frame(height: 52, alignment: .center)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
Spacer()
} }
.padding(.horizontal, SodaSpacing.s20) .padding(.horizontal, SodaSpacing.s20)
}
.scrollIndicators(.hidden)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 52, alignment: .leading) .frame(height: 52, alignment: .leading)
.background(Color.black) .background(Color.black)
@@ -3,26 +3,22 @@ import SwiftUI
struct CreatorChannelCommunityGridItem: View { struct CreatorChannelCommunityGridItem: View {
let post: CreatorChannelCommunityPostItem let post: CreatorChannelCommunityPostItem
let isOwnPost: Bool let isOwnPost: Bool
let onTapPurchase: () -> Void
let onTapDetail: () -> Void let onTapDetail: () -> Void
var body: some View { var body: some View {
GeometryReader { proxy in GeometryReader { proxy in
ZStack { ZStack {
if isPaidLocked { if isPaidLocked {
Button(action: onTapPurchase) { Color.gray800
Color.gray400
.overlay(paidPriceOverlay) .overlay(paidPriceOverlay)
}
.buttonStyle(.plain)
} else { } else {
tileContent(proxy: proxy) tileContent(proxy: proxy)
.contentShape(Rectangle())
.onTapGesture(perform: onTapDetail)
} }
} }
.frame(width: proxy.size.width, height: proxy.size.width) .frame(width: proxy.size.width, height: proxy.size.width)
.clipped() .clipped()
.contentShape(Rectangle())
.onTapGesture(perform: onTapDetail)
.overlay(alignment: .topTrailing) { .overlay(alignment: .topTrailing) {
if post.isPinned { if post.isPinned {
Image("ic_pin") Image("ic_pin")
@@ -92,3 +88,32 @@ struct CreatorChannelCommunityGridItem: View {
return String(content.prefix(18)) return String(content.prefix(18))
} }
} }
struct CreatorChannelCommunityGridItem_Previews: PreviewProvider {
static var previews: some View {
CreatorChannelCommunityGridItem(
post: CreatorChannelCommunityPostItem(
postId: 1,
creatorId: 10,
creatorNickname: "크리에이터 이름",
creatorProfileUrl: "https://picsum.photos/600",
createdAtUtc: "2026-08-10T00:00:00Z",
content: "유료 커뮤니티 게시글",
imageUrl: nil,
audioUrl: nil,
price: 30,
isCommentAvailable: true,
likeCount: 5,
commentCount: 2,
isPinned: true,
existOrdered: false,
isLiked: false
),
isOwnPost: false,
onTapDetail: {}
)
.frame(width: 134, height: 134)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}
@@ -9,7 +9,6 @@ struct CreatorChannelCommunityListItem: View {
let isOwnCreatorChannel: Bool let isOwnCreatorChannel: Bool
let onTapLike: () -> Void let onTapLike: () -> Void
let onTapMore: () -> Void let onTapMore: () -> Void
let onTapPurchase: () -> Void
let onTapDetail: () -> Void let onTapDetail: () -> Void
@State private var localIsLike: Bool @State private var localIsLike: Bool
@@ -23,7 +22,6 @@ struct CreatorChannelCommunityListItem: View {
isOwnCreatorChannel: Bool, isOwnCreatorChannel: Bool,
onTapLike: @escaping () -> Void, onTapLike: @escaping () -> Void,
onTapMore: @escaping () -> Void, onTapMore: @escaping () -> Void,
onTapPurchase: @escaping () -> Void,
onTapDetail: @escaping () -> Void onTapDetail: @escaping () -> Void
) { ) {
self.post = post self.post = post
@@ -31,7 +29,6 @@ struct CreatorChannelCommunityListItem: View {
self.isOwnCreatorChannel = isOwnCreatorChannel self.isOwnCreatorChannel = isOwnCreatorChannel
self.onTapLike = onTapLike self.onTapLike = onTapLike
self.onTapMore = onTapMore self.onTapMore = onTapMore
self.onTapPurchase = onTapPurchase
self.onTapDetail = onTapDetail self.onTapDetail = onTapDetail
_localIsLike = State(initialValue: post.isLiked ?? false) _localIsLike = State(initialValue: post.isLiked ?? false)
_localLikeCount = State(initialValue: post.likeCount) _localLikeCount = State(initialValue: post.likeCount)
@@ -53,8 +50,10 @@ struct CreatorChannelCommunityListItem: View {
imageContent(imageUrl: imageUrl) imageContent(imageUrl: imageUrl)
} }
if !isPaidLocked {
reactionBar reactionBar
} }
}
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(SodaSpacing.s14) .padding(SodaSpacing.s14)
.background(Color.gray900) .background(Color.gray900)
@@ -113,7 +112,7 @@ struct CreatorChannelCommunityListItem: View {
moreButton moreButton
} }
} else if !isOwnPost { } else if !isPaidLocked {
moreButton moreButton
} }
} }
@@ -172,15 +171,12 @@ struct CreatorChannelCommunityListItem: View {
} }
private var paidLockedImage: some View { private var paidLockedImage: some View {
Button(action: onTapPurchase) {
RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous) RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)
.fill(Color.gray400) .fill(Color.gray800)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 236) .frame(height: 236)
.overlay(paidPriceOverlay) .overlay(paidPriceOverlay)
} }
.buttonStyle(.plain)
}
private var paidPriceOverlay: some View { private var paidPriceOverlay: some View {
VStack(spacing: SodaSpacing.s4) { VStack(spacing: SodaSpacing.s4) {
@@ -219,7 +215,7 @@ struct CreatorChannelCommunityListItem: View {
private var reactionBar: some View { private var reactionBar: some View {
HStack(spacing: 15) { HStack(spacing: 15) {
if post.isCommentAvailable && !isPaidLocked { if post.isCommentAvailable {
HStack(spacing: SodaSpacing.s4) { HStack(spacing: SodaSpacing.s4) {
Image("ic_feed_community_reply") Image("ic_feed_community_reply")
.resizable() .resizable()
@@ -231,7 +227,6 @@ struct CreatorChannelCommunityListItem: View {
} }
} }
if !isPaidLocked {
Button { Button {
localIsLike.toggle() localIsLike.toggle()
localLikeCount += localIsLike ? 1 : -1 localLikeCount += localIsLike ? 1 : -1
@@ -250,7 +245,6 @@ struct CreatorChannelCommunityListItem: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
}
.frame(height: 24) .frame(height: 24)
} }
@@ -21,9 +21,11 @@ struct CreatorChannelCommunityViewModeBar: View {
Button(action: onTapToggle) { Button(action: onTapToggle) {
HStack(spacing: SodaSpacing.s4) { HStack(spacing: SodaSpacing.s4) {
if LanguageHeaderProvider.current != "ja" {
Text(viewModeTitle) Text(viewModeTitle)
.appFont(size: 16, weight: .regular) .appFont(size: 16, weight: .regular)
.foregroundColor(Color.gray400) .foregroundColor(Color.gray400)
}
Image(viewModeIconName) Image(viewModeIconName)
.resizable() .resizable()
@@ -4,7 +4,7 @@ struct CreatorChannelCommunityTabView: View {
let creatorId: Int let creatorId: Int
let isOwnCreatorChannel: Bool let isOwnCreatorChannel: Bool
@ObservedObject var viewModel: CreatorChannelCommunityViewModel @ObservedObject var viewModel: CreatorChannelCommunityViewModel
let onTapDetail: (CreatorChannelCommunityPostItem) -> Void let onTapDetail: (Int) -> Void
private let gridColumns = [ private let gridColumns = [
GridItem(.flexible(), spacing: 0), GridItem(.flexible(), spacing: 0),
@@ -69,11 +69,8 @@ struct CreatorChannelCommunityTabView: View {
onTapMore: { onTapMore: {
viewModel.openReportMenu(post: post) viewModel.openReportMenu(post: post)
}, },
onTapPurchase: {
viewModel.openPurchaseDialog(post: post)
},
onTapDetail: { onTapDetail: {
onTapDetail(post) onTapDetail(post.postId)
} }
) )
.onAppear { .onAppear {
@@ -95,11 +92,8 @@ struct CreatorChannelCommunityTabView: View {
CreatorChannelCommunityGridItem( CreatorChannelCommunityGridItem(
post: post, post: post,
isOwnPost: isOwnPost, isOwnPost: isOwnPost,
onTapPurchase: {
viewModel.openPurchaseDialog(post: post)
},
onTapDetail: { onTapDetail: {
onTapDetail(post) onTapDetail(post.postId)
} }
) )
.onAppear { .onAppear {
@@ -50,16 +50,7 @@ final class CreatorChannelCommunityViewModel: ObservableObject {
} }
} }
} }
@Published var isShowPostPurchaseView = false {
didSet {
if !isShowPostPurchaseView {
selectedPostId = 0
selectedPostPrice = 0
}
}
}
@Published var selectedPostId = 0 @Published var selectedPostId = 0
@Published var selectedPostPrice = 0
@Published var selectedPostIsPinned = false @Published var selectedPostIsPinned = false
@Published var isShowSecret = false @Published var isShowSecret = false
@@ -94,17 +85,10 @@ final class CreatorChannelCommunityViewModel: ObservableObject {
func openReportMenu(post: CreatorChannelCommunityPostItem) { func openReportMenu(post: CreatorChannelCommunityPostItem) {
selectedPostId = post.postId selectedPostId = post.postId
selectedPostPrice = post.price
selectedPostIsPinned = post.isPinned selectedPostIsPinned = post.isPinned
isShowReportMenu = true isShowReportMenu = true
} }
func openPurchaseDialog(post: CreatorChannelCommunityPostItem) {
selectedPostId = post.postId
selectedPostPrice = post.price
isShowPostPurchaseView = true
}
func communityPostLike(postId: Int) { func communityPostLike(postId: Int) {
communityRepository.communityPostLike(postId: postId) communityRepository.communityPostLike(postId: postId)
.sink { result in .sink { result in
@@ -153,7 +137,7 @@ final class CreatorChannelCommunityViewModel: ObservableObject {
.store(in: &subscription) .store(in: &subscription)
} }
func deleteCommunityPost(creatorId: Int) { func deleteCommunityPost(onSuccess: @escaping () -> Void) {
guard selectedPostId > 0 else { return } guard selectedPostId > 0 else { return }
isLoading = true isLoading = true
@@ -190,7 +174,7 @@ final class CreatorChannelCommunityViewModel: ObservableObject {
if decoded.success { if decoded.success {
self.errorMessage = I18n.Explorer.deleted self.errorMessage = I18n.Explorer.deleted
self.isShowPopup = true self.isShowPopup = true
self.fetchFirstPage(creatorId: creatorId) onSuccess()
} else { } else {
self.errorMessage = decoded.message ?? I18n.Common.commonError self.errorMessage = decoded.message ?? I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
@@ -204,7 +188,7 @@ final class CreatorChannelCommunityViewModel: ObservableObject {
.store(in: &subscription) .store(in: &subscription)
} }
func updateCommunityPostFixed(creatorId: Int) { func updateCommunityPostFixed(onSuccess: @escaping () -> Void) {
guard selectedPostId > 0 else { return } guard selectedPostId > 0 else { return }
isLoading = true isLoading = true
@@ -228,7 +212,7 @@ final class CreatorChannelCommunityViewModel: ObservableObject {
do { do {
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data) let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
if decoded.success { if decoded.success {
self.fetchFirstPage(creatorId: creatorId) onSuccess()
} else { } else {
self.errorMessage = decoded.message ?? I18n.Common.commonError self.errorMessage = decoded.message ?? I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
@@ -242,43 +226,6 @@ final class CreatorChannelCommunityViewModel: ObservableObject {
.store(in: &subscription) .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) { private func fetchCommunity(creatorId: Int, page requestPage: Int, isNextPage: Bool) {
latestRequestId += 1 latestRequestId += 1
let requestId = latestRequestId let requestId = latestRequestId
@@ -25,8 +25,10 @@ struct CreatorChannelCommunityPostDetailContentView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
mediaContent mediaContent
if !isPaidLocked {
reactionBar reactionBar
} }
}
.padding(SodaSpacing.s14) .padding(SodaSpacing.s14)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(Color.black) .background(Color.black)
@@ -65,10 +67,6 @@ struct CreatorChannelCommunityPostDetailContentView: View {
} }
Spacer(minLength: 0) Spacer(minLength: 0)
if detail.price > 0 && detail.existOrdered && isOwnPost == false {
purchaseCompleteTag
}
} }
} }
@@ -142,19 +140,8 @@ struct CreatorChannelCommunityPostDetailContentView: View {
} }
} }
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 { private var reactionBar: some View {
HStack(spacing: 15) { HStack(spacing: 15) {
if isPaidLocked == false {
if detail.isCommentAvailable { if detail.isCommentAvailable {
HStack(spacing: SodaSpacing.s4) { HStack(spacing: SodaSpacing.s4) {
Image("ic_feed_community_reply") Image("ic_feed_community_reply")
@@ -180,7 +167,6 @@ struct CreatorChannelCommunityPostDetailContentView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
}
.frame(height: 24) .frame(height: 24)
} }
@@ -19,10 +19,10 @@ struct CreatorChannelCommunityPostDetailView: View {
CreatorChannelCommunityPostDetailContentView( CreatorChannelCommunityPostDetailContentView(
detail: detail, detail: detail,
onTapLike: viewModel.toggleLike, onTapLike: viewModel.toggleLike,
onTapPurchase: showPurchaseError onTapPurchase: viewModel.presentPurchaseDialog
) )
if detail.isCommentAvailable { if viewModel.isCommentInteractionAvailable {
Rectangle() Rectangle()
.fill(Color.gray800) .fill(Color.gray800)
.frame(height: 1) .frame(height: 1)
@@ -69,13 +69,21 @@ struct CreatorChannelCommunityPostDetailView: View {
onDimmedTap: dismissDeleteDialog onDimmedTap: dismissDeleteDialog
) )
} }
if viewModel.isShowPurchaseDialog, let detail = viewModel.detail {
CommunityPostPurchaseDialog(
isShowing: $viewModel.isShowPurchaseDialog,
can: detail.price,
confirmAction: viewModel.purchaseCommunityPost
)
}
} }
.navigationBarBackButtonHidden(true) .navigationBarBackButtonHidden(true)
.onAppear { .onAppear {
viewModel.configure(postId: postId, onCommunityRefresh: onCommunityRefresh) viewModel.configure(postId: postId, onCommunityRefresh: onCommunityRefresh)
} }
.safeAreaInset(edge: .bottom) { .safeAreaInset(edge: .bottom) {
if viewModel.detail?.isCommentAvailable == true { if viewModel.isCommentInteractionAvailable {
CreatorChannelCommunityPostDetailInputBar( CreatorChannelCommunityPostDetailInputBar(
text: $viewModel.commentText, text: $viewModel.commentText,
isSecret: $viewModel.isSecret, isSecret: $viewModel.isSecret,
@@ -112,9 +120,4 @@ struct CreatorChannelCommunityPostDetailView: View {
) )
} }
private func showPurchaseError() {
viewModel.errorMessage = I18n.Common.commonError
viewModel.isShowPopup = true
}
} }
@@ -24,6 +24,7 @@ final class CreatorChannelCommunityPostDetailViewModel: ObservableObject {
@Published var editingComment: CreatorChannelCommunityCommentResponse? @Published var editingComment: CreatorChannelCommunityCommentResponse?
@Published var deletingComment: CreatorChannelCommunityCommentResponse? @Published var deletingComment: CreatorChannelCommunityCommentResponse?
@Published var isShowDeleteDialog = false @Published var isShowDeleteDialog = false
@Published var isShowPurchaseDialog = false
@Published var page = 0 @Published var page = 0
@Published var size = 20 @Published var size = 20
@Published var hasNext = false @Published var hasNext = false
@@ -32,13 +33,18 @@ final class CreatorChannelCommunityPostDetailViewModel: ObservableObject {
detail?.creatorId == UserDefaults.int(forKey: .userId) detail?.creatorId == UserDefaults.int(forKey: .userId)
} }
var isCommentInteractionAvailable: Bool {
guard let detail, detail.isCommentAvailable else { return false }
return detail.price <= 0 || detail.existOrdered || isOwnPost
}
var isShowSecret: Bool { var isShowSecret: Bool {
guard let detail else { return false } guard let detail else { return false }
return detail.price > 0 && detail.existOrdered && detail.creatorId != UserDefaults.int(forKey: .userId) return detail.price > 0 && detail.existOrdered && detail.creatorId != UserDefaults.int(forKey: .userId)
} }
var isCommentSendEnabled: Bool { var isCommentSendEnabled: Bool {
guard detail?.isCommentAvailable == true else { return false } guard isCommentInteractionAvailable else { return false }
return commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false && isLoading == false return commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false && isLoading == false
} }
@@ -101,7 +107,7 @@ final class CreatorChannelCommunityPostDetailViewModel: ObservableObject {
func fetchNextCommentsIfNeeded(currentComment: CreatorChannelCommunityCommentResponse) { func fetchNextCommentsIfNeeded(currentComment: CreatorChannelCommunityCommentResponse) {
guard comments.last?.commentId == currentComment.commentId else { return } guard comments.last?.commentId == currentComment.commentId else { return }
guard detail?.isCommentAvailable == true else { return } guard isCommentInteractionAvailable else { return }
guard hasNext, isLoadingNextPage == false, isLoading == false else { return } guard hasNext, isLoadingNextPage == false, isLoading == false else { return }
guard postId > 0 else { return } guard postId > 0 else { return }
@@ -182,8 +188,51 @@ final class CreatorChannelCommunityPostDetailViewModel: ObservableObject {
.store(in: &subscription) .store(in: &subscription)
} }
func presentPurchaseDialog() {
guard let detail, detail.price > 0, detail.existOrdered == false, isOwnPost == false else { return }
isShowPurchaseDialog = true
}
func purchaseCommunityPost() {
guard postId > 0, isLoading == false else { return }
isLoading = true
communityRepository.purchaseCommunityPost(postId: postId)
.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.onCommunityRefresh?()
self.fetchDetail(postId: self.postId)
} 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 sendComment() { func sendComment() {
guard isCommentSendEnabled else { return } guard isCommentInteractionAvailable, isCommentSendEnabled else { return }
if let editingComment { if let editingComment {
modify(comment: editingComment) modify(comment: editingComment)
@@ -209,7 +258,7 @@ final class CreatorChannelCommunityPostDetailViewModel: ObservableObject {
} }
func beginEditing(_ comment: CreatorChannelCommunityCommentResponse) { func beginEditing(_ comment: CreatorChannelCommunityCommentResponse) {
guard canEdit(comment) else { return } guard isCommentInteractionAvailable, canEdit(comment) else { return }
selectedComment = nil selectedComment = nil
editingComment = comment editingComment = comment
commentText = comment.content commentText = comment.content
@@ -221,14 +270,14 @@ final class CreatorChannelCommunityPostDetailViewModel: ObservableObject {
} }
func presentDeleteDialog(_ comment: CreatorChannelCommunityCommentResponse) { func presentDeleteDialog(_ comment: CreatorChannelCommunityCommentResponse) {
guard canDelete(comment) else { return } guard isCommentInteractionAvailable, canDelete(comment) else { return }
selectedComment = nil selectedComment = nil
deletingComment = comment deletingComment = comment
isShowDeleteDialog = true isShowDeleteDialog = true
} }
func confirmDelete() { func confirmDelete() {
guard let deletingComment, isLoading == false else { return } guard isCommentInteractionAvailable, let deletingComment, isLoading == false else { return }
isShowDeleteDialog = false isShowDeleteDialog = false
delete(comment: deletingComment) delete(comment: deletingComment)
} }
@@ -240,7 +289,7 @@ final class CreatorChannelCommunityPostDetailViewModel: ObservableObject {
hasNext = data.comments.hasNext hasNext = data.comments.hasNext
hasLoaded = true hasLoaded = true
if data.isCommentAvailable { if isCommentInteractionAvailable {
comments = data.comments.comments comments = data.comments.comments
} else { } else {
comments = [] comments = []
@@ -29,6 +29,9 @@ struct CreatorChannelTabBar: View {
} }
} }
} }
.onAppear {
proxy.scrollTo(selectedTab, anchor: .center)
}
.onChange(of: selectedTab) { tab in .onChange(of: selectedTab) { tab in
withAnimation { withAnimation {
proxy.scrollTo(tab, anchor: .center) proxy.scrollTo(tab, anchor: .center)
@@ -19,6 +19,11 @@ struct CreatorChannelView: View {
@State private var isShowChannelDonationDialog = false @State private var isShowChannelDonationDialog = false
@State private var isShowAuthView = false @State private var isShowAuthView = false
@State private var isShowAuthConfirmView = false @State private var isShowAuthConfirmView = false
@State private var isShowReportMenu = false
@State private var isShowUserBlockConfirm = false
@State private var isShowUserReport = false
@State private var isShowProfileReport = false
@State private var isShowUnfollowConfirmDialog = false
@State private var isCreatorActionMenuPresented = false @State private var isCreatorActionMenuPresented = false
@State private var pendingAction: (() -> Void)? = nil @State private var pendingAction: (() -> Void)? = nil
@State private var payload = Payload() @State private var payload = Payload()
@@ -160,6 +165,10 @@ struct CreatorChannelView: View {
fanTalkDeleteDialog fanTalkDeleteDialog
} }
if isShowUnfollowConfirmDialog {
unfollowConfirmDialog
}
if isShowChannelDonationDialog { if isShowChannelDonationDialog {
LiveRoomDonationDialogView( LiveRoomDonationDialogView(
isShowing: $isShowChannelDonationDialog, isShowing: $isShowChannelDonationDialog,
@@ -183,6 +192,56 @@ struct CreatorChannelView: View {
) )
} }
if viewModel.response != nil && isShowReportMenu {
VStack(spacing: 0) {
ProfileReportMenuView(
isShowing: $isShowReportMenu,
isBlockedUser: false,
userBlockAction: { isShowUserBlockConfirm = true },
userUnBlockAction: {},
userReportAction: { isShowUserReport = true },
profileReportAction: { isShowProfileReport = true }
)
if proxy.safeAreaInsets.bottom > 0 {
Rectangle()
.foregroundColor(Color(hex: "222222"))
.frame(width: proxy.size.width, height: 15.3)
}
}
.ignoresSafeArea()
}
if let creator = viewModel.response?.creator, isShowUserBlockConfirm {
UserBlockConfirmDialogView(
isShowing: $isShowUserBlockConfirm,
nickname: creator.nickname,
confirmAction: {
viewModel.userBlock(userId: creator.creatorId) {
dismiss()
}
}
)
}
if isShowUserReport {
UserReportDialogView(
isShowing: $isShowUserReport,
confirmAction: { reason in
viewModel.report(type: .USER, userId: creatorId, reason: reason)
}
)
}
if isShowProfileReport {
ProfileReportDialogView(
isShowing: $isShowProfileReport,
confirmAction: {
viewModel.report(type: .PROFILE, userId: creatorId)
}
)
}
communityOverlay(proxy: proxy) communityOverlay(proxy: proxy)
} }
} }
@@ -219,7 +278,7 @@ struct CreatorChannelView: View {
viewModel.creatorFollow(follow: true, notify: true) viewModel.creatorFollow(follow: true, notify: true)
}, },
onTapUnfollow: { onTapUnfollow: {
viewModel.creatorFollow(follow: false, notify: false) isShowUnfollowConfirmDialog = true
}, },
onTapNotify: { onTapNotify: {
viewModel.creatorFollow(follow: true, notify: true) viewModel.creatorFollow(follow: true, notify: true)
@@ -227,7 +286,9 @@ struct CreatorChannelView: View {
onTapUnnotify: { onTapUnnotify: {
viewModel.creatorFollow(follow: true, notify: false) viewModel.creatorFollow(follow: true, notify: false)
}, },
onTapMore: {} onTapMore: {
isShowReportMenu = true
}
) )
} }
@@ -321,13 +382,17 @@ struct CreatorChannelView: View {
} else if let response = viewModel.response { } else if let response = viewModel.response {
CreatorChannelHomeView( CreatorChannelHomeView(
response: response, response: response,
isOwnCreatorChannel: isOwnCreatorChannel,
onSelectTab: selectTab, onSelectTab: selectTab,
onTapLive: showLiveDetail, onTapLive: showLiveDetail,
onTapContent: showContentDetail, onTapContent: showContentDetail,
onTapDonate: showDonationDialog, onTapDonate: showDonationDialog,
onTapFanTalkWrite: showFanTalkWriteView,
onTapSchedule: handleScheduleTap, onTapSchedule: handleScheduleTap,
onTapSeries: showSeriesDetail, onTapSeries: showSeriesDetail,
onTapCommunityLike: viewModel.likeCommunityPost onTapCommunityLike: viewModel.likeCommunityPost,
onTapCommunityMore: communityViewModel.openReportMenu,
onTapCommunityDetail: showCommunityPostDetail
) )
} else { } else {
EmptyView() EmptyView()
@@ -383,6 +448,31 @@ struct CreatorChannelView: View {
fanTalkViewModel.selectedFanTalkId = 0 fanTalkViewModel.selectedFanTalkId = 0
} }
private var unfollowConfirmDialog: some View {
SodaV2ActionModal(
title: I18n.MemberChannel.unfollowActionModalTitle,
message: I18n.MemberChannel.unfollowActionModalMessage(viewModel.response?.creator.nickname ?? ""),
button1: SodaV2ActionModalButton(
label: I18n.MemberChannel.unfollowActionModalConfirm,
action: confirmUnfollow
),
button2: SodaV2ActionModalButton(
label: I18n.MemberChannel.unfollowActionModalCancel,
action: dismissUnfollowConfirmDialog
),
onDimmedTap: dismissUnfollowConfirmDialog
)
}
private func confirmUnfollow() {
isShowUnfollowConfirmDialog = false
viewModel.creatorFollow(follow: false, notify: false)
}
private func dismissUnfollowConfirmDialog() {
isShowUnfollowConfirmDialog = false
}
private var authView: some View { private var authView: some View {
BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION) BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION)
.onConfirm { _ in true } .onConfirm { _ in true }
@@ -474,17 +564,22 @@ struct CreatorChannelView: View {
) )
} }
private func showCommunityPostDetail(_ post: CreatorChannelCommunityPostItem) { private func showCommunityPostDetail(_ postId: Int) {
AppState.shared.setAppStep( AppState.shared.setAppStep(
step: .creatorChannelCommunityPostDetail( step: .creatorChannelCommunityPostDetail(
postId: post.postId, postId: postId,
onCommunityRefresh: { onCommunityRefresh: {
communityViewModel.fetchFirstPage(creatorId: creatorId) refreshCommunityFeeds()
} }
) )
) )
} }
private func refreshCommunityFeeds() {
viewModel.fetchHome(creatorId: creatorId)
communityViewModel.fetchFirstPage(creatorId: creatorId)
}
private func showAudioContentUpload() { private func showAudioContentUpload() {
isCreatorActionMenuPresented = false isCreatorActionMenuPresented = false
AppState.shared.setAppStep( AppState.shared.setAppStep(
@@ -595,7 +690,7 @@ struct CreatorChannelView: View {
@ViewBuilder @ViewBuilder
private func communityOverlay(proxy: GeometryProxy) -> some View { private func communityOverlay(proxy: GeometryProxy) -> some View {
if viewModel.selectedTab == .community { if viewModel.selectedTab == .home || viewModel.selectedTab == .community {
if communityViewModel.isShowReportMenu { if communityViewModel.isShowReportMenu {
VStack(spacing: 0) { VStack(spacing: 0) {
CreatorCommunityMenuView( CreatorCommunityMenuView(
@@ -603,7 +698,7 @@ struct CreatorChannelView: View {
isShowCreatorMenu: isOwnCreatorChannel, isShowCreatorMenu: isOwnCreatorChannel,
isFixed: communityViewModel.selectedPostIsPinned, isFixed: communityViewModel.selectedPostIsPinned,
fixedAction: { fixedAction: {
communityViewModel.updateCommunityPostFixed(creatorId: creatorId) communityViewModel.updateCommunityPostFixed(onSuccess: refreshCommunityFeeds)
}, },
modifyAction: showCommunityModify, modifyAction: showCommunityModify,
deleteAction: { deleteAction: {
@@ -638,7 +733,7 @@ struct CreatorChannelView: View {
button1: SodaV2ActionModalButton( button1: SodaV2ActionModalButton(
label: I18n.Common.delete, label: I18n.Common.delete,
action: { action: {
communityViewModel.deleteCommunityPost(creatorId: creatorId) communityViewModel.deleteCommunityPost(onSuccess: refreshCommunityFeeds)
communityViewModel.isShowDeleteConfirm = false communityViewModel.isShowDeleteConfirm = false
} }
), ),
@@ -650,16 +745,6 @@ struct CreatorChannelView: View {
) )
} }
if communityViewModel.isShowPostPurchaseView {
CommunityPostPurchaseDialog(
isShowing: $communityViewModel.isShowPostPurchaseView,
can: communityViewModel.selectedPostPrice,
confirmAction: {
communityViewModel.purchaseCommunityPost(creatorId: creatorId)
}
)
}
if communityViewModel.isLoading { if communityViewModel.isLoading {
LoadingView() LoadingView()
} }
@@ -674,7 +759,7 @@ struct CreatorChannelView: View {
step: .creatorCommunityModify( step: .creatorCommunityModify(
postId: postId, postId: postId,
onSuccess: { onSuccess: {
communityViewModel.fetchFirstPage(creatorId: creatorId) refreshCommunityFeeds()
} }
) )
) )
@@ -4,6 +4,7 @@ import Combine
final class CreatorChannelViewModel: ObservableObject { final class CreatorChannelViewModel: ObservableObject {
private let repository = CreatorChannelHomeRepository() private let repository = CreatorChannelHomeRepository()
private let userRepository = UserRepository() private let userRepository = UserRepository()
private let reportRepository = ReportRepository()
private let communityRepository = CreatorCommunityRepository() private let communityRepository = CreatorCommunityRepository()
private var subscription = Set<AnyCancellable>() private var subscription = Set<AnyCancellable>()
@@ -156,6 +157,75 @@ final class CreatorChannelViewModel: ObservableObject {
.store(in: &subscription) .store(in: &subscription)
} }
func userBlock(userId: Int, onSuccess: @escaping () -> Void) {
isLoading = true
userRepository.memberBlock(userId: userId)
.sink { [weak self] result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self?.isLoading = false
}
} receiveValue: { [weak self] response in
guard let self else { return }
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: response.data)
if decoded.success {
onSuccess()
} 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
}
.store(in: &subscription)
}
func report(type: ReportType, userId: Int? = nil, reason: String = I18n.Dialog.MemberProfile.reportProfile) {
isLoading = true
let request = ReportRequest(type: type, reason: reason, reportedMemberId: userId)
reportRepository.report(request: request)
.sink { [weak self] result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self?.isLoading = false
}
} receiveValue: { [weak self] response in
guard let self else { return }
do {
let jsonDecoder = JSONDecoder()
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
}
self.isLoading = false
}
.store(in: &subscription)
}
private func applyApiFailedPlaceholderState() { private func applyApiFailedPlaceholderState() {
response = nil response = nil
isApiFailedPlaceholderVisible = true isApiFailedPlaceholderVisible = true
@@ -57,7 +57,7 @@ struct CreatorChannelDonationCard: View {
.scaledToFit() .scaledToFit()
.frame(width: 18, height: 18) .frame(width: 18, height: 18)
Text("\(donation.can)캔") Text("\(donation.can)")
.appFont(size: 14, weight: .regular) .appFont(size: 14, weight: .regular)
.foregroundColor(.white) .foregroundColor(.white)
.lineLimit(1) .lineLimit(1)
@@ -72,7 +72,7 @@ struct CreatorChannelDonationCard: View {
extension CreatorChannelDonationResponse { extension CreatorChannelDonationResponse {
var messageText: String { var messageText: String {
let trimmedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedMessage.isEmpty ? "\(can)캔을 후원하였습니다" : trimmedMessage return trimmedMessage.isEmpty ? I18n.CreatorChannelDonation.defaultMessage(can) : trimmedMessage
} }
func relativeTimeText(now: Date = Date()) -> String { func relativeTimeText(now: Date = Date()) -> String {
@@ -5,7 +5,7 @@ struct CreatorChannelDonationCountBar: View {
var body: some View { var body: some View {
HStack(spacing: SodaSpacing.s4) { HStack(spacing: SodaSpacing.s4) {
Text("전체") Text(I18n.CreatorChannelCommunity.totalLabel)
.appFont(size: 16, weight: .medium) .appFont(size: 16, weight: .medium)
.foregroundColor(.white) .foregroundColor(.white)
@@ -6,7 +6,7 @@ struct CreatorChannelDonationEmptyView: View {
var body: some View { var body: some View {
VStack(spacing: SodaSpacing.s20) { VStack(spacing: SodaSpacing.s20) {
Text("아직 후원이 없습니다.\n처음으로 크리에이터를 후원해 보세요!") Text(I18n.CreatorChannelDonation.emptyMessage)
.appFont(size: 16, weight: .medium) .appFont(size: 16, weight: .medium)
.foregroundColor(Color.gray500) .foregroundColor(Color.gray500)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@@ -22,7 +22,7 @@ struct CreatorChannelDonationEmptyView: View {
.scaledToFit() .scaledToFit()
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
Text("후원하기") Text(I18n.ContentDetail.DonationDialog.title)
.appFont(size: 16, weight: .medium) .appFont(size: 16, weight: .medium)
.foregroundColor(.white) .foregroundColor(.white)
.lineLimit(1) .lineLimit(1)
@@ -7,7 +7,7 @@ struct CreatorChannelDonationRankingSection: View {
var body: some View { var body: some View {
if rankings.isEmpty == false { if rankings.isEmpty == false {
VStack(alignment: .leading, spacing: SodaSpacing.s14) { VStack(alignment: .leading, spacing: SodaSpacing.s14) {
Text("후원 랭킹") Text(I18n.Explorer.donationRankingTitle)
.appFont(size: 20, weight: .bold) .appFont(size: 20, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -19,7 +19,7 @@ struct CreatorChannelDonationRankingSection: View {
} }
Button(action: onTapViewAll) { Button(action: onTapViewAll) {
Text("전체보기") Text(I18n.CreatorChannelHome.viewAll)
.appFont(size: 16, weight: .medium) .appFont(size: 16, weight: .medium)
.foregroundColor(.white) .foregroundColor(.white)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -20,7 +20,9 @@ struct CreatorChannelDonationTabView: View {
private var content: some View { private var content: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
if viewModel.donationCount > 0 || viewModel.rankings.isEmpty == false {
CreatorChannelDonationCountBar(donationCount: viewModel.donationCount) CreatorChannelDonationCountBar(donationCount: viewModel.donationCount)
}
if isEmptyState { if isEmptyState {
CreatorChannelDonationEmptyView( CreatorChannelDonationEmptyView(
@@ -5,7 +5,7 @@ struct CreatorChannelFanTalkCountBar: View {
var body: some View { var body: some View {
HStack(spacing: SodaSpacing.s4) { HStack(spacing: SodaSpacing.s4) {
Text("전체") Text(I18n.CreatorChannelCommunity.totalLabel)
.appFont(size: 16, weight: .medium) .appFont(size: 16, weight: .medium)
.foregroundColor(.white) .foregroundColor(.white)
@@ -6,7 +6,7 @@ struct CreatorChannelFanTalkEmptyView: View {
var body: some View { var body: some View {
VStack(spacing: SodaSpacing.s20) { VStack(spacing: SodaSpacing.s20) {
Text("아직 응원이 없습니다.\n처음으로 크리에이터를 응원해 보세요!") Text(I18n.CreatorChannelFanTalk.emptyMessage)
.appFont(size: 16, weight: .medium) .appFont(size: 16, weight: .medium)
.foregroundColor(Color.gray500) .foregroundColor(Color.gray500)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@@ -22,14 +22,14 @@ struct CreatorChannelFanTalkEmptyView: View {
.scaledToFit() .scaledToFit()
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
Text("응원 남기기") Text(I18n.CreatorChannelFanTalk.writeAction)
.appFont(size: 16, weight: .medium) .appFont(size: 16, weight: .medium)
.foregroundColor(.white) .foregroundColor(.white)
.lineLimit(1) .lineLimit(1)
} }
.padding(.horizontal, SodaSpacing.s20) .padding(.horizontal, SodaSpacing.s20)
.frame(height: 44) .frame(height: 44)
.background(Color.soda400) .background(Color.gray800)
.clipShape(Capsule()) .clipShape(Capsule())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -84,7 +84,7 @@ struct CreatorChannelFanTalkListItem: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} else { } else {
Button(action: onTapReport) { Button(action: onTapReport) {
Text("신고") Text(I18n.Explorer.report)
.appFont(size: 13, weight: .medium) .appFont(size: 13, weight: .medium)
.foregroundColor(Color.gray500) .foregroundColor(Color.gray500)
} }
@@ -34,7 +34,9 @@ struct CreatorChannelFanTalkTabView: View {
private var content: some View { private var content: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
if viewModel.fanTalkCount > 0 {
CreatorChannelFanTalkCountBar(fanTalkCount: viewModel.fanTalkCount) CreatorChannelFanTalkCountBar(fanTalkCount: viewModel.fanTalkCount)
}
if isEmptyState { if isEmptyState {
CreatorChannelFanTalkEmptyView( CreatorChannelFanTalkEmptyView(
@@ -25,12 +25,27 @@ struct CreatorChannelFanTalkWriteView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
navigationBar navigationBar
TextEditor(text: $viewModel.content) TextEditor(text: Binding(
get: { viewModel.content },
set: { newValue in
viewModel.content = String(newValue.prefix(viewModel.maxContentCount))
}
))
.appFont(size: 16, weight: .regular) .appFont(size: 16, weight: .regular)
.foregroundColor(Color.white) .foregroundColor(viewModel.isContentOverLimit ? Color.red400 : Color.white)
.accentColor(Color.soda400) .accentColor(Color.soda400)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.black) .background(Color.black)
.overlay(alignment: .topLeading) {
if viewModel.content.isEmpty {
Text(I18n.CreatorChannelFanTalk.writePlaceholder)
.appFont(size: 16, weight: .regular)
.foregroundColor(Color.gray500)
.padding(.horizontal, SodaSpacing.s4)
.padding(.vertical, SodaSpacing.s8)
.allowsHitTesting(false)
}
}
.focused($isFocused) .focused($isFocused)
.padding(.horizontal, SodaSpacing.s14) .padding(.horizontal, SodaSpacing.s14)
.padding(.top, SodaSpacing.s20) .padding(.top, SodaSpacing.s20)
@@ -56,7 +71,6 @@ struct CreatorChannelFanTalkWriteView: View {
Text(I18n.Common.cancel) Text(I18n.Common.cancel)
.appFont(size: 16, weight: .regular) .appFont(size: 16, weight: .regular)
.foregroundColor(Color.white) .foregroundColor(Color.white)
.frame(width: 64, height: 56, alignment: .leading)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -85,8 +99,8 @@ struct CreatorChannelFanTalkWriteView: View {
Text("\(viewModel.content.count)") Text("\(viewModel.content.count)")
.appFont(size: 16, weight: .medium) .appFont(size: 16, weight: .medium)
.foregroundColor(Color.white) .foregroundColor(viewModel.isContentOverLimit ? Color.red400 : Color.white)
Text("/\(viewModel.maxContentCount)자") Text("/\(viewModel.maxContentCount)")
.appFont(size: 16, weight: .medium) .appFont(size: 16, weight: .medium)
.foregroundColor(Color.gray500) .foregroundColor(Color.gray500)
} }
@@ -29,7 +29,15 @@ final class CreatorChannelFanTalkWriteViewModel: ObservableObject {
let maxContentCount = 500 let maxContentCount = 500
var isContentOverLimit: Bool {
content.count > maxContentCount
}
var canSend: Bool { var canSend: Bool {
hasContent && isContentOverLimit == false
}
private var hasContent: Bool {
content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
} }
@@ -40,7 +48,7 @@ final class CreatorChannelFanTalkWriteViewModel: ObservableObject {
} }
func handleCancel() -> Bool { func handleCancel() -> Bool {
guard canSend else { return true } guard hasContent else { return true }
shouldShowExitModal = true shouldShowExitModal = true
return false return false
@@ -2,25 +2,28 @@ import SwiftUI
struct CreatorChannelCommunitySection: View { struct CreatorChannelCommunitySection: View {
let communities: [CreatorChannelCommunityPostResponse] let communities: [CreatorChannelCommunityPostResponse]
let isOwnCreatorChannel: Bool
let onSelectTab: (CreatorChannelTab) -> Void let onSelectTab: (CreatorChannelTab) -> Void
let onTapLike: (Int) -> Void let onTapLike: (Int) -> Void
let onTapComment: (Int) -> Void let onTapMore: (CreatorChannelCommunityPostItem) -> Void
let onTapPurchase: (CreatorChannelCommunityPostResponse) -> Void let onTapDetail: (Int) -> Void
private let homeVisibleCount = 3 private let homeVisibleCount = 3
init( init(
communities: [CreatorChannelCommunityPostResponse], communities: [CreatorChannelCommunityPostResponse],
isOwnCreatorChannel: Bool,
onSelectTab: @escaping (CreatorChannelTab) -> Void, onSelectTab: @escaping (CreatorChannelTab) -> Void,
onTapLike: @escaping (Int) -> Void = { _ in }, onTapLike: @escaping (Int) -> Void = { _ in },
onTapComment: @escaping (Int) -> Void = { _ in }, onTapMore: @escaping (CreatorChannelCommunityPostItem) -> Void = { _ in },
onTapPurchase: @escaping (CreatorChannelCommunityPostResponse) -> Void = { _ in } onTapDetail: @escaping (Int) -> Void = { _ in }
) { ) {
self.communities = communities self.communities = communities
self.isOwnCreatorChannel = isOwnCreatorChannel
self.onSelectTab = onSelectTab self.onSelectTab = onSelectTab
self.onTapLike = onTapLike self.onTapLike = onTapLike
self.onTapComment = onTapComment self.onTapMore = onTapMore
self.onTapPurchase = onTapPurchase self.onTapDetail = onTapDetail
} }
var body: some View { var body: some View {
@@ -30,27 +33,21 @@ struct CreatorChannelCommunitySection: View {
VStack(alignment: .leading, spacing: SodaSpacing.s8) { VStack(alignment: .leading, spacing: SodaSpacing.s8) {
ForEach(visibleCommunities) { community in ForEach(visibleCommunities) { community in
CommunityPostCard( let post = community.communityPostItem
postId: community.postId, let isOwnPost = post.creatorId == UserDefaults.int(forKey: .userId)
creatorNickname: community.creatorNickname,
creatorProfileImageUrl: community.creatorProfileUrl, CreatorChannelCommunityListItem(
content: community.content, post: post,
imageUrl: community.imageUrl, isOwnPost: isOwnPost,
audioUrl: community.audioUrl, isOwnCreatorChannel: isOwnCreatorChannel,
price: community.price,
existOrdered: community.existOrdered,
createdAt: community.relativeTimeText(),
isLike: community.isLiked,
likeCount: community.likeCount,
commentCount: community.commentCount,
onTapLike: { onTapLike: {
onTapLike(community.postId) onTapLike(post.postId)
}, },
onTapComment: { onTapMore: {
onTapComment(community.postId) onTapMore(post)
}, },
onTapPurchase: { onTapDetail: {
onTapPurchase(community) onTapDetail(post.postId)
} }
) )
} }
@@ -71,6 +68,7 @@ struct CreatorChannelCommunitySection: View {
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.contentShape(Rectangle())
.padding(.horizontal, SodaSpacing.s14) .padding(.horizontal, SodaSpacing.s14)
} }
.padding(.top, SodaSpacing.s20) .padding(.top, SodaSpacing.s20)
@@ -83,8 +81,24 @@ struct CreatorChannelCommunitySection: View {
} }
private extension CreatorChannelCommunityPostResponse { private extension CreatorChannelCommunityPostResponse {
func relativeTimeText(now: Date = Date()) -> String { var communityPostItem: CreatorChannelCommunityPostItem {
DateParser.relativeTimeText(fromUTC: dateUtc, fallback: dateUtc, now: now) CreatorChannelCommunityPostItem(
postId: postId,
creatorId: creatorId,
creatorNickname: creatorNickname,
creatorProfileUrl: creatorProfileUrl,
createdAtUtc: dateUtc,
content: content,
imageUrl: imageUrl,
audioUrl: audioUrl,
price: price,
isCommentAvailable: isCommentAvailable,
likeCount: likeCount,
commentCount: commentCount,
isPinned: isPinned,
existOrdered: existOrdered,
isLiked: isLiked
)
} }
} }
@@ -107,10 +121,15 @@ struct CreatorChannelCommunitySection_Previews: PreviewProvider {
existOrdered: false, existOrdered: false,
likeCount: 5, likeCount: 5,
commentCount: 6, commentCount: 6,
isLiked: false isLiked: false,
isCommentAvailable: index != 3,
isPinned: index == 1
) )
}, },
onSelectTab: { _ in } isOwnCreatorChannel: true,
onSelectTab: { _ in },
onTapMore: { _ in },
onTapDetail: { _ in }
) )
.background(Color.black) .background(Color.black)
.previewLayout(.sizeThatFits) .previewLayout(.sizeThatFits)
@@ -78,10 +78,15 @@ private struct CreatorChannelDonationEmptyCard: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text("처음으로 크리에이터를\n후원해 보세요!") Text(I18n.CreatorChannelHome.donationEmptyMessage)
.appFont(size: 24, weight: .bold) .appFont(size: 24, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
.lineSpacing(0) .lineSpacing(0)
.lineLimit(2)
.frame(
maxWidth: LanguageHeaderProvider.current == "ja" ? 210 : .infinity,
alignment: .leading
)
.padding(.top, SodaSpacing.s20) .padding(.top, SodaSpacing.s20)
.padding(.leading, SodaSpacing.s20) .padding(.leading, SodaSpacing.s20)
@@ -2,24 +2,79 @@ import SwiftUI
struct CreatorChannelFanTalkSection: View { struct CreatorChannelFanTalkSection: View {
let fanTalk: CreatorChannelFanTalkSummaryResponse let fanTalk: CreatorChannelFanTalkSummaryResponse
let showsWriteButton: Bool
let onSelectTab: (CreatorChannelTab) -> Void let onSelectTab: (CreatorChannelTab) -> Void
let onTapWrite: () -> Void
var body: some View { var body: some View {
if let latestFanTalk = fanTalk.latestFanTalk {
VStack(alignment: .leading, spacing: SodaSpacing.s14) { VStack(alignment: .leading, spacing: SodaSpacing.s14) {
SectionTitle(title: I18n.CreatorChannelHome.fanTalk) { SectionTitle(title: I18n.CreatorChannelHome.fanTalk) {
onSelectTab(.fanTalk) onSelectTab(.fanTalk)
} }
if let latestFanTalk = fanTalk.latestFanTalk {
CreatorChannelFanTalkCard( CreatorChannelFanTalkCard(
totalCount: fanTalk.totalCount, totalCount: fanTalk.totalCount,
latestFanTalk: latestFanTalk latestFanTalk: latestFanTalk
) )
.padding(.horizontal, SodaSpacing.s14) .padding(.horizontal, SodaSpacing.s14)
} else {
CreatorChannelFanTalkEmptyCard(
showsWriteButton: showsWriteButton,
onTapWrite: onTapWrite
)
.padding(.horizontal, SodaSpacing.s14)
}
} }
.padding(.top, SodaSpacing.s20) .padding(.top, SodaSpacing.s20)
} }
} }
private struct CreatorChannelFanTalkEmptyCard: View {
let showsWriteButton: Bool
let onTapWrite: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text(I18n.CreatorChannelHome.fanTalkEmptyMessage)
.appFont(size: 24, weight: .bold)
.foregroundColor(.white)
.lineSpacing(0)
.padding(.top, SodaSpacing.s20)
.padding(.leading, SodaSpacing.s20)
Spacer(minLength: 0)
if showsWriteButton {
Button(action: onTapWrite) {
HStack(spacing: SodaSpacing.s6) {
Image("ic_plus_no_bg")
.resizable()
.renderingMode(.template)
.foregroundColor(.black)
.scaledToFit()
.frame(width: 20, height: 20)
Text(I18n.CreatorChannelFanTalk.writeAction)
.appFont(size: 16, weight: .medium)
.foregroundColor(.black)
.lineLimit(1)
}
.frame(maxWidth: .infinity)
.padding(SodaSpacing.s12)
.background(Color.white)
.clipShape(Capsule())
}
.buttonStyle(.plain)
.padding(.horizontal, SodaSpacing.s20)
.padding(.bottom, SodaSpacing.s20)
}
}
.frame(height: 196)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
} }
private struct CreatorChannelFanTalkCard: View { private struct CreatorChannelFanTalkCard: View {
@@ -77,7 +132,9 @@ struct CreatorChannelFanTalkSection_Previews: PreviewProvider {
createdAtUtc: "2026-07-03T03:44:00Z" createdAtUtc: "2026-07-03T03:44:00Z"
) )
), ),
onSelectTab: { _ in } showsWriteButton: true,
onSelectTab: { _ in },
onTapWrite: {}
) )
.background(Color.black) .background(Color.black)
.previewLayout(.sizeThatFits) .previewLayout(.sizeThatFits)
@@ -2,20 +2,14 @@ import SwiftUI
struct CreatorChannelNoticeSection: View { struct CreatorChannelNoticeSection: View {
let notices: [CreatorChannelCommunityPostResponse] let notices: [CreatorChannelCommunityPostResponse]
let onTapNotice: (Int) -> Void let onTapDetail: (Int) -> Void
let onTapComment: (Int) -> Void
let onTapPurchase: (CreatorChannelCommunityPostResponse) -> Void
init( init(
notices: [CreatorChannelCommunityPostResponse], notices: [CreatorChannelCommunityPostResponse],
onTapNotice: @escaping (Int) -> Void = { _ in }, onTapDetail: @escaping (Int) -> Void = { _ in }
onTapComment: @escaping (Int) -> Void = { _ in },
onTapPurchase: @escaping (CreatorChannelCommunityPostResponse) -> Void = { _ in }
) { ) {
self.notices = notices self.notices = notices
self.onTapNotice = onTapNotice self.onTapDetail = onTapDetail
self.onTapComment = onTapComment
self.onTapPurchase = onTapPurchase
} }
var body: some View { var body: some View {
@@ -28,14 +22,8 @@ struct CreatorChannelNoticeSection: View {
ForEach(notices) { notice in ForEach(notices) { notice in
CreatorChannelNoticeCard( CreatorChannelNoticeCard(
notice: notice, notice: notice,
onTapNotice: { onTapDetail: {
onTapNotice(notice.postId) onTapDetail(notice.postId)
},
onTapComment: {
onTapComment(notice.postId)
},
onTapPurchase: {
onTapPurchase(notice)
} }
) )
.frame(width: 374) .frame(width: 374)
@@ -51,9 +39,7 @@ struct CreatorChannelNoticeSection: View {
private struct CreatorChannelNoticeCard: View { private struct CreatorChannelNoticeCard: View {
let notice: CreatorChannelCommunityPostResponse let notice: CreatorChannelCommunityPostResponse
let onTapNotice: () -> Void let onTapDetail: () -> Void
let onTapComment: () -> Void
let onTapPurchase: () -> Void
@StateObject private var playManager = CreatorCommunityMediaPlayerManager.shared @StateObject private var playManager = CreatorCommunityMediaPlayerManager.shared
@StateObject private var contentPlayManager = ContentPlayManager.shared @StateObject private var contentPlayManager = ContentPlayManager.shared
@@ -69,7 +55,7 @@ private struct CreatorChannelNoticeCard: View {
.background(Color.gray900) .background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture(perform: onTapNotice) .onTapGesture(perform: onTapDetail)
} }
private var noticeLabel: some View { private var noticeLabel: some View {
@@ -164,7 +150,6 @@ private struct CreatorChannelNoticeCard: View {
} }
private var paidLockOverlay: some View { private var paidLockOverlay: some View {
Button(action: onTapPurchase) {
ZStack { ZStack {
Color.gray700.opacity(0.35) Color.gray700.opacity(0.35)
@@ -189,15 +174,13 @@ private struct CreatorChannelNoticeCard: View {
.clipShape(Capsule()) .clipShape(Capsule())
} }
} }
}
.buttonStyle(.plain)
.frame(width: 92, height: 92) .frame(width: 92, height: 92)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
} }
private var reactionBar: some View { private var reactionBar: some View {
HStack(spacing: 15) { HStack(spacing: 15) {
Button(action: onTapComment) { Button(action: onTapDetail) {
HStack(spacing: SodaSpacing.s4) { HStack(spacing: SodaSpacing.s4) {
Image("ic_feed_community_reply") Image("ic_feed_community_reply")
.resizable() .resizable()
@@ -264,7 +247,9 @@ struct CreatorChannelNoticeSection_Previews: PreviewProvider {
existOrdered: true, existOrdered: true,
likeCount: 5, likeCount: 5,
commentCount: 2, commentCount: 2,
isLiked: false isLiked: false,
isCommentAvailable: true,
isPinned: true
), ),
CreatorChannelCommunityPostResponse( CreatorChannelCommunityPostResponse(
postId: 2, postId: 2,
@@ -279,7 +264,9 @@ struct CreatorChannelNoticeSection_Previews: PreviewProvider {
existOrdered: false, existOrdered: false,
likeCount: 3, likeCount: 3,
commentCount: 1, commentCount: 1,
isLiked: false isLiked: false,
isCommentAvailable: true,
isPinned: false
) )
] ]
) )
@@ -2,32 +2,44 @@ import SwiftUI
struct CreatorChannelHomeView: View { struct CreatorChannelHomeView: View {
let response: CreatorChannelHomeResponse let response: CreatorChannelHomeResponse
let isOwnCreatorChannel: Bool
let onSelectTab: (CreatorChannelTab) -> Void let onSelectTab: (CreatorChannelTab) -> Void
let onTapLive: (Int) -> Void let onTapLive: (Int) -> Void
let onTapContent: (Int) -> Void let onTapContent: (Int) -> Void
let onTapDonate: () -> Void let onTapDonate: () -> Void
let onTapFanTalkWrite: () -> Void
let onTapSchedule: (CreatorActivityType, Int) -> Void let onTapSchedule: (CreatorActivityType, Int) -> Void
let onTapSeries: (Int) -> Void let onTapSeries: (Int) -> Void
let onTapCommunityLike: (Int) -> Void let onTapCommunityLike: (Int) -> Void
let onTapCommunityMore: (CreatorChannelCommunityPostItem) -> Void
let onTapCommunityDetail: (Int) -> Void
init( init(
response: CreatorChannelHomeResponse, response: CreatorChannelHomeResponse,
isOwnCreatorChannel: Bool,
onSelectTab: @escaping (CreatorChannelTab) -> Void, onSelectTab: @escaping (CreatorChannelTab) -> Void,
onTapLive: @escaping (Int) -> Void = { _ in }, onTapLive: @escaping (Int) -> Void = { _ in },
onTapContent: @escaping (Int) -> Void = { _ in }, onTapContent: @escaping (Int) -> Void = { _ in },
onTapDonate: @escaping () -> Void = {}, onTapDonate: @escaping () -> Void = {},
onTapFanTalkWrite: @escaping () -> Void = {},
onTapSchedule: @escaping (CreatorActivityType, Int) -> Void = { _, _ in }, onTapSchedule: @escaping (CreatorActivityType, Int) -> Void = { _, _ in },
onTapSeries: @escaping (Int) -> Void = { _ in }, onTapSeries: @escaping (Int) -> Void = { _ in },
onTapCommunityLike: @escaping (Int) -> Void = { _ in } onTapCommunityLike: @escaping (Int) -> Void = { _ in },
onTapCommunityMore: @escaping (CreatorChannelCommunityPostItem) -> Void = { _ in },
onTapCommunityDetail: @escaping (Int) -> Void = { _ in }
) { ) {
self.response = response self.response = response
self.isOwnCreatorChannel = isOwnCreatorChannel
self.onSelectTab = onSelectTab self.onSelectTab = onSelectTab
self.onTapLive = onTapLive self.onTapLive = onTapLive
self.onTapContent = onTapContent self.onTapContent = onTapContent
self.onTapDonate = onTapDonate self.onTapDonate = onTapDonate
self.onTapFanTalkWrite = onTapFanTalkWrite
self.onTapSchedule = onTapSchedule self.onTapSchedule = onTapSchedule
self.onTapSeries = onTapSeries self.onTapSeries = onTapSeries
self.onTapCommunityLike = onTapCommunityLike self.onTapCommunityLike = onTapCommunityLike
self.onTapCommunityMore = onTapCommunityMore
self.onTapCommunityDetail = onTapCommunityDetail
} }
var body: some View { var body: some View {
@@ -48,7 +60,10 @@ struct CreatorChannelHomeView: View {
onTapDonate: onTapDonate onTapDonate: onTapDonate
) )
CreatorChannelNoticeSection(notices: response.notices) CreatorChannelNoticeSection(
notices: response.notices,
onTapDetail: onTapCommunityDetail
)
CreatorChannelScheduleSection( CreatorChannelScheduleSection(
schedules: response.schedules, schedules: response.schedules,
@@ -69,13 +84,18 @@ struct CreatorChannelHomeView: View {
CreatorChannelCommunitySection( CreatorChannelCommunitySection(
communities: response.communities, communities: response.communities,
isOwnCreatorChannel: isOwnCreatorChannel,
onSelectTab: onSelectTab, onSelectTab: onSelectTab,
onTapLike: onTapCommunityLike onTapLike: onTapCommunityLike,
onTapMore: onTapCommunityMore,
onTapDetail: onTapCommunityDetail
) )
CreatorChannelFanTalkSection( CreatorChannelFanTalkSection(
fanTalk: response.fanTalk, fanTalk: response.fanTalk,
onSelectTab: onSelectTab showsWriteButton: isOwnCreatorChannel == false,
onSelectTab: onSelectTab,
onTapWrite: onTapFanTalkWrite
) )
CreatorChannelIntroduceSection(introduce: response.introduce) CreatorChannelIntroduceSection(introduce: response.introduce)
@@ -98,6 +98,8 @@ struct CreatorChannelCommunityPostResponse: Decodable, Identifiable {
let likeCount: Int let likeCount: Int
let commentCount: Int let commentCount: Int
let isLiked: Bool let isLiked: Bool
let isCommentAvailable: Bool
let isPinned: Bool
var id: Int { postId } var id: Int { postId }
} }
@@ -12,19 +12,19 @@ enum CreatorChannelTab: CaseIterable, Hashable {
var title: String { var title: String {
switch self { switch self {
case .home: case .home:
return "홈" return I18n.CreatorChannelHome.home
case .live: case .live:
return "라이브" return I18n.CreatorChannelHome.live
case .audio: case .audio:
return "오디오" return I18n.CreatorChannelHome.audio
case .series: case .series:
return "시리즈" return I18n.CreatorChannelHome.series
case .community: case .community:
return "커뮤니티" return I18n.CreatorChannelHome.community
case .fanTalk: case .fanTalk:
return "팬Talk" return I18n.CreatorChannelHome.fanTalk
case .donation: case .donation:
return "후원" return I18n.CreatorChannelHome.donation
} }
} }
} }
@@ -54,18 +54,14 @@ struct MainChatView: View {
@ViewBuilder @ViewBuilder
private var chatListContent: some View { private var chatListContent: some View {
if viewModel.isLoading && viewModel.rooms.isEmpty { if viewModel.isLoading && !viewModel.hasLoaded {
ProgressView() loadingContent
.progressViewStyle(.circular)
.tint(Color.soda400)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if viewModel.shouldShowEmptyState { } else if viewModel.shouldShowEmptyState {
Text(I18n.MainChat.emptyStateMessage) emptyContent
.appFont(size: 16, weight: .medium)
.foregroundColor(Color.gray500)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else { } else {
ScrollView(showsIndicators: false) { LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
ForEach(viewModel.rooms) { room in ForEach(viewModel.rooms) { room in
MainChatRoomListItem(room: room) { roomId, chatType in MainChatRoomListItem(room: room) { roomId, chatType in
@@ -88,6 +84,34 @@ struct MainChatView: View {
} }
} }
private var loadingContent: some View {
GeometryReader { geometry in
LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
ProgressView()
.progressViewStyle(.circular)
.tint(Color.soda400)
.frame(maxWidth: .infinity)
.frame(minHeight: geometry.size.height)
}
}
}
private var emptyContent: some View {
GeometryReader { geometry in
LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
Text(I18n.MainChat.emptyStateMessage)
.appFont(size: 16, weight: .medium)
.foregroundColor(Color.gray500)
.frame(maxWidth: .infinity)
.frame(minHeight: geometry.size.height)
}
}
}
private func handleChatRoomTap(roomId: Int, chatType: String) { private func handleChatRoomTap(roomId: Int, chatType: String) {
guard roomId > 0 else { return } guard roomId > 0 else { return }
@@ -31,6 +31,16 @@ final class MainChatViewModel: ObservableObject {
fetchChatRooms(filter: filter, cursor: nil, isNextPage: false) fetchChatRooms(filter: filter, cursor: nil, isNextPage: false)
} }
func refresh() async {
if !isLoading {
fetchChatRooms(filter: selectedFilter, cursor: nil, isNextPage: false)
}
for await isLoading in $isLoading.values {
if !isLoading { return }
}
}
func applyFilter(_ filter: MainChatFilter) { func applyFilter(_ filter: MainChatFilter) {
guard selectedFilter != filter else { return } guard selectedFilter != filter else { return }
fetchFirstPage(filter: filter) fetchFirstPage(filter: filter)
@@ -42,7 +52,11 @@ final class MainChatViewModel: ObservableObject {
fetchChatRooms(filter: selectedFilter, cursor: nextCursor, isNextPage: true) fetchChatRooms(filter: selectedFilter, cursor: nextCursor, isNextPage: true)
} }
private func fetchChatRooms(filter: MainChatFilter, cursor: String?, isNextPage: Bool) { private func fetchChatRooms(
filter: MainChatFilter,
cursor: String?,
isNextPage: Bool
) {
latestRequestId += 1 latestRequestId += 1
let requestId = latestRequestId let requestId = latestRequestId
@@ -70,13 +70,13 @@ struct MainContentAllView: View {
sortBar sortBar
if viewModel.isLoading && viewModel.hasLoaded == false { if viewModel.isLoading && viewModel.hasLoaded == false {
ProgressView() loadingContent
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if viewModel.shouldShowEmptyState { } else if viewModel.shouldShowEmptyState {
MainContentAudioEmptyStateView(message: I18n.MainContentAll.emptyStateMessage) emptyContent
} else { } else {
ScrollView(showsIndicators: false) { LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
gridContent gridContent
.padding(.horizontal, SodaSpacing.s14) .padding(.horizontal, SodaSpacing.s14)
.padding(.top, SodaSpacing.s8) .padding(.top, SodaSpacing.s8)
@@ -92,6 +92,30 @@ struct MainContentAllView: View {
} }
} }
private var loadingContent: some View {
GeometryReader { geometry in
LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(maxWidth: .infinity)
.frame(minHeight: geometry.size.height)
}
}
}
private var emptyContent: some View {
GeometryReader { geometry in
LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
MainContentAudioEmptyStateView(message: I18n.MainContentAll.emptyStateMessage)
.frame(minHeight: geometry.size.height)
}
}
}
private var typeFilter: some View { private var typeFilter: some View {
CapsuleTabBar( CapsuleTabBar(
items: MainContentAllType.allCases, items: MainContentAllType.allCases,
@@ -197,4 +221,3 @@ struct MainContentAllView_Previews: PreviewProvider {
) )
} }
} }
@@ -32,7 +32,7 @@ final class MainContentAllViewModel: ObservableObject {
} }
func fetchFirstPageIfNeeded() { func fetchFirstPageIfNeeded() {
guard hasLoaded == false else { return } guard hasLoaded == false, isLoading == false else { return }
fetchFirstPage() fetchFirstPage()
} }
@@ -40,6 +40,16 @@ final class MainContentAllViewModel: ObservableObject {
fetchContents(page: 0, isNextPage: false) fetchContents(page: 0, isNextPage: false)
} }
func refresh() async {
if !isLoading {
fetchFirstPage()
}
for await isLoading in $isLoading.values {
if !isLoading { return }
}
}
func apply(type: MainContentAllType, sort: ContentSort) { func apply(type: MainContentAllType, sort: ContentSort) {
guard allowedSorts(for: type).contains(sort) else { return } guard allowedSorts(for: type).contains(sort) else { return }
@@ -247,4 +257,3 @@ extension SeriesPublishedDaysOfWeek {
} }
} }
} }
@@ -49,7 +49,7 @@ struct MainContentView: View {
onTapSeries: onTapSeries, onTapSeries: onTapSeries,
onTapLatestAudioAll: { applyAllTab(type: .audio, sort: .latest) }, onTapLatestAudioAll: { applyAllTab(type: .audio, sort: .latest) },
onTapNewAndHotAll: onTapNewAndHotAll, onTapNewAndHotAll: onTapNewAndHotAll,
onTapVoiceOnOnlyAll: { applyAllTab(type: .original, sort: .latest) }, onTapSodaLiveOnlyAll: { applyAllTab(type: .original, sort: .latest) },
onTapFreeAudioAll: { applyAllTab(type: .free, sort: .popular) }, onTapFreeAudioAll: { applyAllTab(type: .free, sort: .popular) },
onTapPointAudioAll: { applyAllTab(type: .point, sort: .popular) } onTapPointAudioAll: { applyAllTab(type: .point, sort: .popular) }
) )
@@ -22,12 +22,9 @@ struct MainContentRankingView: View {
) )
if viewModel.isLoading && !viewModel.hasLoaded { if viewModel.isLoading && !viewModel.hasLoaded {
ProgressView() loadingContent
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if viewModel.shouldShowEmptyState { } else if viewModel.shouldShowEmptyState {
MainContentAudioRankingEmptyStateView(message: viewModel.emptyStateMessage) emptyContent
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else { } else {
rankingContent rankingContent
} }
@@ -41,8 +38,23 @@ struct MainContentRankingView: View {
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2) .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
} }
private var loadingContent: some View {
GeometryReader { geometry in
LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(maxWidth: .infinity)
.frame(minHeight: geometry.size.height)
}
}
}
private var rankingContent: some View { private var rankingContent: some View {
ScrollView(showsIndicators: false) { LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
VStack(alignment: .leading, spacing: SodaSpacing.s8) { VStack(alignment: .leading, spacing: SodaSpacing.s8) {
if let topItem { if let topItem {
MainContentAudioRankingTopCard( MainContentAudioRankingTopCard(
@@ -95,6 +107,17 @@ struct MainContentRankingView: View {
} }
} }
private var emptyContent: some View {
GeometryReader { geometry in
LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
MainContentAudioRankingEmptyStateView(message: viewModel.emptyStateMessage)
.frame(minHeight: geometry.size.height)
}
}
}
private var twoColumns: [GridItem] { private var twoColumns: [GridItem] {
[GridItem(.flexible()), GridItem(.flexible())] [GridItem(.flexible()), GridItem(.flexible())]
} }
@@ -32,19 +32,17 @@ final class MainContentRankingViewModel: ObservableObject {
repository.getAudioRankings(type: selectedType) repository.getAudioRankings(type: selectedType)
.sink { [weak self] result in .sink { [weak self] result in
guard let self else { return }
switch result { switch result {
case .finished: case .finished:
DEBUG_LOG("finish") DEBUG_LOG("finish")
case .failure(let error): case .failure(let error):
ERROR_LOG(error.localizedDescription) ERROR_LOG(error.localizedDescription)
self.items = [] self?.items = []
self.showRankChange = false self?.showRankChange = false
self.errorMessage = I18n.MainContentRanking.loadFailedMessage self?.errorMessage = I18n.MainContentRanking.loadFailedMessage
self.isShowPopup = true self?.isShowPopup = true
self.isLoading = false self?.isLoading = false
self.hasLoaded = true self?.hasLoaded = true
} }
} receiveValue: { [weak self] response in } receiveValue: { [weak self] response in
guard let self else { return } guard let self else { return }
@@ -79,6 +77,16 @@ final class MainContentRankingViewModel: ObservableObject {
.store(in: &subscription) .store(in: &subscription)
} }
func refresh() async {
if !isLoading {
fetchRankings()
}
for await isLoading in $isLoading.values {
if !isLoading { return }
}
}
func selectType(_ type: AudioRankingType) { func selectType(_ type: AudioRankingType) {
guard selectedType != type else { return } guard selectedType != type else { return }
@@ -6,7 +6,7 @@ struct MainContentRecommendationView: View {
let onTapSeries: (Int) -> Void let onTapSeries: (Int) -> Void
let onTapLatestAudioAll: () -> Void let onTapLatestAudioAll: () -> Void
let onTapNewAndHotAll: () -> Void let onTapNewAndHotAll: () -> Void
let onTapVoiceOnOnlyAll: () -> Void let onTapSodaLiveOnlyAll: () -> Void
let onTapFreeAudioAll: () -> Void let onTapFreeAudioAll: () -> Void
let onTapPointAudioAll: () -> Void let onTapPointAudioAll: () -> Void
@@ -17,13 +17,14 @@ struct MainContentRecommendationView: View {
Color.black Color.black
.ignoresSafeArea() .ignoresSafeArea()
if viewModel.isLoading && viewModel.recommendations == nil { if viewModel.isLoading && !viewModel.hasLoaded {
ProgressView() loadingContent
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else if viewModel.shouldShowEmptyState { } else if viewModel.shouldShowEmptyState {
MainContentAudioEmptyStateView(message: viewModel.emptyStateMessage) emptyContent
} else { } else {
ScrollView(showsIndicators: false) { LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
VStack(alignment: .leading, spacing: SodaSpacing.s24) { VStack(alignment: .leading, spacing: SodaSpacing.s24) {
if let recommendations = viewModel.recommendations { if let recommendations = viewModel.recommendations {
MainContentAudioBannerSection( MainContentAudioBannerSection(
@@ -32,9 +33,9 @@ struct MainContentRecommendationView: View {
) )
MainContentVoiceOnOnlySection( MainContentVoiceOnOnlySection(
title: I18n.MainContentRecommendation.voiceOnOnlySectionTitle, title: I18n.MainContentRecommendation.sodaLiveOnlySectionTitle,
items: recommendations.originalSeries, items: recommendations.originalSeries,
action: onTapVoiceOnOnlyAll, action: onTapSodaLiveOnlyAll,
onTapSeries: onTapSeries onTapSeries: onTapSeries
) )
@@ -94,4 +95,28 @@ struct MainContentRecommendationView: View {
autohideIn: 2 autohideIn: 2
) )
} }
private var loadingContent: some View {
GeometryReader { geometry in
LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(maxWidth: .infinity)
.frame(minHeight: geometry.size.height)
}
}
}
private var emptyContent: some View {
GeometryReader { geometry in
LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
MainContentAudioEmptyStateView(message: viewModel.emptyStateMessage)
.frame(minHeight: geometry.size.height)
}
}
}
} }
@@ -30,17 +30,15 @@ final class MainContentRecommendationViewModel: ObservableObject {
repository.getAudioRecommendations() repository.getAudioRecommendations()
.sink { [weak self] result in .sink { [weak self] result in
guard let self else { return }
switch result { switch result {
case .finished: case .finished:
DEBUG_LOG("finish") DEBUG_LOG("finish")
case .failure(let error): case .failure(let error):
ERROR_LOG(error.localizedDescription) ERROR_LOG(error.localizedDescription)
self.errorMessage = I18n.MainContentRecommendation.loadFailedMessage self?.errorMessage = I18n.MainContentRecommendation.loadFailedMessage
self.isShowPopup = true self?.isShowPopup = true
self.isLoading = false self?.isLoading = false
self.hasLoaded = true self?.hasLoaded = true
} }
} receiveValue: { [weak self] response in } receiveValue: { [weak self] response in
guard let self else { return } guard let self else { return }
@@ -69,4 +67,14 @@ final class MainContentRecommendationViewModel: ObservableObject {
} }
.store(in: &subscription) .store(in: &subscription)
} }
func refresh() async {
if !isLoading {
fetchRecommendations()
}
for await isLoading in $isLoading.values {
if !isLoading { return }
}
}
} }
@@ -16,6 +16,7 @@ struct MainHomeFollowingCreatorSection: View {
} }
var body: some View { var body: some View {
HStack(alignment: .top, spacing: SodaSpacing.s12) {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(alignment: .top, spacing: SodaSpacing.s12) { LazyHStack(alignment: .top, spacing: SodaSpacing.s12) {
ForEach(followingCreators) { creator in ForEach(followingCreators) { creator in
@@ -29,23 +30,22 @@ struct MainHomeFollowingCreatorSection: View {
} }
.frame(width: 72) .frame(width: 72)
} }
}
.padding(.leading, SodaSpacing.s20)
}
.frame(maxWidth: .infinity, alignment: .leading)
Button(action: onTapAll) { Button(action: onTapAll) {
Text(I18n.HomeFollowing.allButtonTitle) Text(I18n.HomeFollowing.allButtonTitle)
.appFont(.body4) .appFont(.body5)
.foregroundColor(Color.soda400) .foregroundColor(Color.soda400)
.padding(.horizontal, 16) .frame(width: 70, height: 102, alignment: .center)
.frame(height: allButtonHeight)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
.padding(.horizontal, SodaSpacing.s20)
} }
} }
private var allButtonHeight: CGFloat { 72 + SodaSpacing.s8 + 20 }
}
struct MainHomeFollowingCreatorSection_Previews: PreviewProvider { struct MainHomeFollowingCreatorSection_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
VStack(spacing: SodaSpacing.s20) { VStack(spacing: SodaSpacing.s20) {
@@ -46,20 +46,19 @@ struct MainHomeFollowingView: View {
@ViewBuilder @ViewBuilder
private var content: some View { private var content: some View {
if viewModel.isLoading { if viewModel.isLoading && !viewModel.hasLoaded {
ProgressView() loadingContent
.tint(.white)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
} else if viewModel.isLoginRequired { } else if viewModel.isLoginRequired {
MainHomeFollowingEmptyStateView( emptyContent(
message: I18n.HomeFollowing.loginRequiredMessage, message: I18n.HomeFollowing.loginRequiredMessage,
loginAction: onTapLogin loginAction: onTapLogin
) )
} else if !viewModel.message.isEmpty { } else if !viewModel.message.isEmpty {
MainHomeFollowingEmptyStateView(message: viewModel.message) emptyContent(message: viewModel.message)
} else if let response = viewModel.response { } else if let response = viewModel.response {
ScrollView(.vertical, showsIndicators: false) { LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
VStack(spacing: 0) { VStack(spacing: 0) {
if !response.followingCreators.isEmpty { if !response.followingCreators.isEmpty {
MainHomeFollowingCreatorSection( MainHomeFollowingCreatorSection(
@@ -108,7 +107,35 @@ struct MainHomeFollowingView: View {
} }
.background(Color.black) .background(Color.black)
} else { } else {
MainHomeFollowingEmptyStateView(message: I18n.HomeFollowing.emptyStateMessage) emptyContent(message: I18n.HomeFollowing.emptyStateMessage)
}
}
private var loadingContent: some View {
GeometryReader { geometry in
LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
ProgressView()
.tint(.white)
.frame(maxWidth: .infinity)
.frame(minHeight: geometry.size.height)
}
.background(Color.black)
}
}
private func emptyContent(message: String, loginAction: (() -> Void)? = nil) -> some View {
GeometryReader { geometry in
LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
MainHomeFollowingEmptyStateView(
message: message,
loginAction: loginAction
)
.frame(minHeight: geometry.size.height)
}
} }
} }
} }
@@ -17,18 +17,16 @@ final class MainHomeFollowingViewModel: ObservableObject {
repository.getFollowing() repository.getFollowing()
.sink { [weak self] result in .sink { [weak self] result in
guard let self else { return }
switch result { switch result {
case .finished: case .finished:
DEBUG_LOG("finish") DEBUG_LOG("finish")
case .failure(let error): case .failure(let error):
ERROR_LOG(error.localizedDescription) ERROR_LOG(error.localizedDescription)
self.response = nil self?.response = nil
self.isLoginRequired = false self?.isLoginRequired = false
self.message = I18n.HomeFollowing.loadFailedMessage self?.message = I18n.HomeFollowing.loadFailedMessage
self.hasLoaded = true self?.hasLoaded = true
self.isLoading = false self?.isLoading = false
} }
} receiveValue: { [weak self] response in } receiveValue: { [weak self] response in
guard let self else { return } guard let self else { return }
@@ -60,6 +58,16 @@ final class MainHomeFollowingViewModel: ObservableObject {
} }
.store(in: &subscription) .store(in: &subscription)
} }
func refresh() async {
if !isLoading {
fetchFollowing()
}
for await isLoading in $isLoading.values {
if !isLoading { return }
}
}
} }
private extension HomeFollowingTabResponse { private extension HomeFollowingTabResponse {
@@ -15,10 +15,9 @@ struct MainHomeRankingView: View {
.ignoresSafeArea() .ignoresSafeArea()
if viewModel.isLoading && !viewModel.hasLoaded { if viewModel.isLoading && !viewModel.hasLoaded {
ProgressView() loadingContent
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else if viewModel.items.isEmpty { } else if viewModel.items.isEmpty {
MainHomeRankingEmptyStateView() emptyContent
} else { } else {
rankingContent rankingContent
} }
@@ -30,8 +29,23 @@ struct MainHomeRankingView: View {
} }
} }
private var loadingContent: some View {
GeometryReader { geometry in
LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(maxWidth: .infinity)
.frame(minHeight: geometry.size.height)
}
}
}
private var rankingContent: some View { private var rankingContent: some View {
ScrollView(showsIndicators: false) { LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
VStack(alignment: .leading, spacing: SodaSpacing.s8) { VStack(alignment: .leading, spacing: SodaSpacing.s8) {
ForEach(firstRankItems) { item in ForEach(firstRankItems) { item in
MainHomeRankingCard( MainHomeRankingCard(
@@ -85,6 +99,17 @@ struct MainHomeRankingView: View {
} }
} }
private var emptyContent: some View {
GeometryReader { geometry in
LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
MainHomeRankingEmptyStateView()
.frame(minHeight: geometry.size.height)
}
}
}
private var twoColumns: [GridItem] { private var twoColumns: [GridItem] {
[GridItem(.flexible()), GridItem(.flexible())] [GridItem(.flexible()), GridItem(.flexible())]
} }

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