Compare commits
16 Commits
8d6e32f5fb
...
a8680931fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8680931fe | ||
|
|
56e0c3dc27 | ||
|
|
339ca78793 | ||
|
|
20601f40cf | ||
|
|
007d63c0cd | ||
|
|
2727cdc7cb | ||
|
|
561e991050 | ||
|
|
638aa26789 | ||
|
|
c2f358ee50 | ||
|
|
3ed763af4e | ||
|
|
0e31c69be8 | ||
|
|
c441fb0b02 | ||
|
|
e67efeb30f | ||
|
|
394e7ebc34 | ||
|
|
5757705b12 | ||
|
|
6b5176eba7 |
@@ -3,12 +3,11 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 56;
|
objectVersion = 70;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
00C796627A6758D271215EC5 /* CreatorProfileItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26881ED30292E28D2E5F84CD /* CreatorProfileItem.swift */; };
|
00C796627A6758D271215EC5 /* CreatorProfileItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26881ED30292E28D2E5F84CD /* CreatorProfileItem.swift */; };
|
||||||
040031FA8CF08BDE260ECB52 /* RecommendedActivityType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36E28A8DFC2D34DA99AE7209 /* RecommendedActivityType.swift */; };
|
|
||||||
042BEE0C973700FB5F4540CD /* I18n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 826D626B7714E5C28ED31074 /* I18n.swift */; };
|
042BEE0C973700FB5F4540CD /* I18n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 826D626B7714E5C28ED31074 /* I18n.swift */; };
|
||||||
0470E68C6F302A131264E3AF /* ChannelDonationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF2BFD2E09F509C44C92214 /* ChannelDonationViewModel.swift */; };
|
0470E68C6F302A131264E3AF /* ChannelDonationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF2BFD2E09F509C44C92214 /* ChannelDonationViewModel.swift */; };
|
||||||
109DD8670CD0F25C0F436455 /* V2vRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = E56C8151F551B091940F16C4 /* V2vRepository.swift */; };
|
109DD8670CD0F25C0F436455 /* V2vRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = E56C8151F551B091940F16C4 /* V2vRepository.swift */; };
|
||||||
@@ -16,7 +15,6 @@
|
|||||||
14A88B232976EAD54B876195 /* LanguageSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB63E7E31B75FF2E6F1C9073 /* LanguageSettingsView.swift */; };
|
14A88B232976EAD54B876195 /* LanguageSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB63E7E31B75FF2E6F1C9073 /* LanguageSettingsView.swift */; };
|
||||||
1621CE301E98653402C6F553 /* LanguageSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 716260E8214A329EE07CAAFA /* LanguageSettingsViewModel.swift */; };
|
1621CE301E98653402C6F553 /* LanguageSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 716260E8214A329EE07CAAFA /* LanguageSettingsViewModel.swift */; };
|
||||||
163579EB669F557DEA367556 /* AcceptLanguagePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C076E80A2C92FEFFA5AB752 /* AcceptLanguagePlugin.swift */; };
|
163579EB669F557DEA367556 /* AcceptLanguagePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C076E80A2C92FEFFA5AB752 /* AcceptLanguagePlugin.swift */; };
|
||||||
164177B97BCE9CB0C1042E23 /* MainHomeRecommendationResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF693ADD4B6C386882D5DD53 /* MainHomeRecommendationResponse.swift */; };
|
|
||||||
1690309E86928580115E94E2 /* CapsuleTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74990A0798E64A97237DD113 /* CapsuleTabBar.swift */; };
|
1690309E86928580115E94E2 /* CapsuleTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74990A0798E64A97237DD113 /* CapsuleTabBar.swift */; };
|
||||||
176AE05194A71107EA461B7B /* AiCharacterCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A575789542808C0C87AC6D82 /* AiCharacterCard.swift */; };
|
176AE05194A71107EA461B7B /* AiCharacterCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A575789542808C0C87AC6D82 /* AiCharacterCard.swift */; };
|
||||||
1DD00C45C183A84D0CD5553F /* CreatorProfileItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26881ED30292E28D2E5F84CD /* CreatorProfileItem.swift */; };
|
1DD00C45C183A84D0CD5553F /* CreatorProfileItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26881ED30292E28D2E5F84CD /* CreatorProfileItem.swift */; };
|
||||||
@@ -192,6 +190,53 @@
|
|||||||
401A3B312FEE3E2D00A5BBA3 /* NotoSansJP-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 401A3B252FEE3E2D00A5BBA3 /* NotoSansJP-Regular.ttf */; };
|
401A3B312FEE3E2D00A5BBA3 /* NotoSansJP-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 401A3B252FEE3E2D00A5BBA3 /* NotoSansJP-Regular.ttf */; };
|
||||||
401A3B322FEE3E2D00A5BBA3 /* Phosphate-Solid.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 401A3B272FEE3E2D00A5BBA3 /* Phosphate-Solid.ttf */; };
|
401A3B322FEE3E2D00A5BBA3 /* Phosphate-Solid.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 401A3B272FEE3E2D00A5BBA3 /* Phosphate-Solid.ttf */; };
|
||||||
401A3B332FEE3E2D00A5BBA3 /* NotoSansJP-Light.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 401A3B232FEE3E2D00A5BBA3 /* NotoSansJP-Light.ttf */; };
|
401A3B332FEE3E2D00A5BBA3 /* NotoSansJP-Light.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 401A3B232FEE3E2D00A5BBA3 /* NotoSansJP-Light.ttf */; };
|
||||||
|
401A3F9E2FEEAFA000A5BBA3 /* MainHomeRecommendationApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3F9A2FEEAFA000A5BBA3 /* MainHomeRecommendationApi.swift */; };
|
||||||
|
401A3F9F2FEEAFA000A5BBA3 /* MainHomeRecommendationResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3F972FEEAFA000A5BBA3 /* MainHomeRecommendationResponse.swift */; };
|
||||||
|
401A3FA02FEEAFA000A5BBA3 /* FollowRecommendedCreatorsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3F962FEEAFA000A5BBA3 /* FollowRecommendedCreatorsRequest.swift */; };
|
||||||
|
401A3FA12FEEAFA000A5BBA3 /* MainHomeRecommendationRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3F9B2FEEAFA000A5BBA3 /* MainHomeRecommendationRepository.swift */; };
|
||||||
|
401A3FA22FEEAFA000A5BBA3 /* RecommendedActivityType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3F982FEEAFA000A5BBA3 /* RecommendedActivityType.swift */; };
|
||||||
|
401A3FA32FEEAFA000A5BBA3 /* MainHomeRecommendationApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3F9A2FEEAFA000A5BBA3 /* MainHomeRecommendationApi.swift */; };
|
||||||
|
401A3FA42FEEAFA000A5BBA3 /* MainHomeRecommendationResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3F972FEEAFA000A5BBA3 /* MainHomeRecommendationResponse.swift */; };
|
||||||
|
401A3FA52FEEAFA000A5BBA3 /* FollowRecommendedCreatorsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3F962FEEAFA000A5BBA3 /* FollowRecommendedCreatorsRequest.swift */; };
|
||||||
|
401A3FA62FEEAFA000A5BBA3 /* MainHomeRecommendationRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3F9B2FEEAFA000A5BBA3 /* MainHomeRecommendationRepository.swift */; };
|
||||||
|
401A3FA72FEEAFA000A5BBA3 /* RecommendedActivityType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3F982FEEAFA000A5BBA3 /* RecommendedActivityType.swift */; };
|
||||||
|
401A3FA82FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB42FEEAFA000A5BBA3 /* MainHomeView.swift */; };
|
||||||
|
401A3FA92FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB52FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift */; };
|
||||||
|
401A3FAA2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */; };
|
||||||
|
401A3FAB2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */; };
|
||||||
|
401A3FAC2FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB42FEEAFA000A5BBA3 /* MainHomeView.swift */; };
|
||||||
|
401A3FAD2FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB52FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift */; };
|
||||||
|
401A3FAE2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */; };
|
||||||
|
401A3FAF2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */; };
|
||||||
|
401A3FB02FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB42FEEAFA000A5BBA3 /* MainHomeView.swift */; };
|
||||||
|
401A3FB12FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB52FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift */; };
|
||||||
|
401A3FB22FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */; };
|
||||||
|
401A3FB32FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */; };
|
||||||
|
401A3FBA2FEEAFA000A5BBA3 /* MainHomeRecommendationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76506E56D017236824FE9B8F /* MainHomeRecommendationViewModel.swift */; };
|
||||||
|
401A3FC22FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */; };
|
||||||
|
401A3FC32FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */; };
|
||||||
|
401A3FC42FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */; };
|
||||||
|
401A3FC62FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */; };
|
||||||
|
401A3FC72FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */; };
|
||||||
|
401A3FC82FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */; };
|
||||||
|
401A3FC92FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FCC2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift */; };
|
||||||
|
401A3FCA2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FCC2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift */; };
|
||||||
|
401A3FCB2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FCC2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift */; };
|
||||||
|
401A3FCE2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FCD2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift */; };
|
||||||
|
401A3FCF2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FCD2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift */; };
|
||||||
|
401A3FD02FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FCD2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift */; };
|
||||||
|
401A3FD12FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FD42FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift */; };
|
||||||
|
401A3FD22FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FD42FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift */; };
|
||||||
|
401A3FD32FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FD42FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift */; };
|
||||||
|
401A3FD72FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FD52FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift */; };
|
||||||
|
401A3FD82FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FD52FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift */; };
|
||||||
|
401A3FD92FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FDB2FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift */; };
|
||||||
|
401A3FDA2FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FDB2FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift */; };
|
||||||
|
401A3FDD2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FDC2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift */; };
|
||||||
|
401A3FDE2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FDC2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift */; };
|
||||||
|
401A3FE02FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FDF2FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift */; };
|
||||||
|
401A3FE12FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FDF2FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift */; };
|
||||||
|
401A3FE22FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FDF2FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift */; };
|
||||||
40235DED2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */; };
|
40235DED2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */; };
|
||||||
40235DEE2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */; };
|
40235DEE2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */; };
|
||||||
40235DF02E37983D00B09A87 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEF2E37983D00B09A87 /* PersistenceController.swift */; };
|
40235DF02E37983D00B09A87 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEF2E37983D00B09A87 /* PersistenceController.swift */; };
|
||||||
@@ -1500,10 +1545,8 @@
|
|||||||
45436503B5AA5530DBBD3481 /* KakaoSDKCommon in Frameworks */ = {isa = PBXBuildFile; productRef = 00EE224A913C7F8ADC48C15E /* KakaoSDKCommon */; };
|
45436503B5AA5530DBBD3481 /* KakaoSDKCommon in Frameworks */ = {isa = PBXBuildFile; productRef = 00EE224A913C7F8ADC48C15E /* KakaoSDKCommon */; };
|
||||||
4572B5004B70CED10BDDB4C3 /* FollowAllButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 348933CFF7DC1B9DB6E5D1A1 /* FollowAllButton.swift */; };
|
4572B5004B70CED10BDDB4C3 /* FollowAllButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 348933CFF7DC1B9DB6E5D1A1 /* FollowAllButton.swift */; };
|
||||||
4A22DB69D003B207E35CFF51 /* FontModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25D89184D67BD63E46E90281 /* FontModifier.swift */; };
|
4A22DB69D003B207E35CFF51 /* FontModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25D89184D67BD63E46E90281 /* FontModifier.swift */; };
|
||||||
4A5CAD092A52205860D9AF5B /* MainHomeApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BD0F6AD90B449C2FE021496 /* MainHomeApi.swift */; };
|
|
||||||
4A951EA6E07524A0B650F4BF /* DateParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375C41DF9D62AF5B9C3C1FAC /* DateParser.swift */; };
|
4A951EA6E07524A0B650F4BF /* DateParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375C41DF9D62AF5B9C3C1FAC /* DateParser.swift */; };
|
||||||
4D1649A808884B53416FCC0E /* UpdateContentPreferenceResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4AAC943F77B6FE089696909 /* UpdateContentPreferenceResponse.swift */; };
|
4D1649A808884B53416FCC0E /* UpdateContentPreferenceResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4AAC943F77B6FE089696909 /* UpdateContentPreferenceResponse.swift */; };
|
||||||
4ECF7D9BF3A958E9FDE3F5E0 /* RecommendedActivityType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36E28A8DFC2D34DA99AE7209 /* RecommendedActivityType.swift */; };
|
|
||||||
508015CDE6EB31076EBF695A /* CommunityPostCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE82912F2F111593BE42E33C /* CommunityPostCard.swift */; };
|
508015CDE6EB31076EBF695A /* CommunityPostCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE82912F2F111593BE42E33C /* CommunityPostCard.swift */; };
|
||||||
50958256B935286787BB9011 /* V2vModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4AF495E30F2DB00C2FCB36 /* V2vModels.swift */; };
|
50958256B935286787BB9011 /* V2vModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4AF495E30F2DB00C2FCB36 /* V2vModels.swift */; };
|
||||||
53E20A90F5D084820C7166E2 /* ChannelDonationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF2BFD2E09F509C44C92214 /* ChannelDonationViewModel.swift */; };
|
53E20A90F5D084820C7166E2 /* ChannelDonationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF2BFD2E09F509C44C92214 /* ChannelDonationViewModel.swift */; };
|
||||||
@@ -1534,7 +1577,6 @@
|
|||||||
843C3C5366C9D9DEE37878BF /* ChannelDonationItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A7650B281CE20A6F29F6253 /* ChannelDonationItemView.swift */; };
|
843C3C5366C9D9DEE37878BF /* ChannelDonationItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A7650B281CE20A6F29F6253 /* ChannelDonationItemView.swift */; };
|
||||||
85934EA071477BC71BFCDF5E /* Pods_SodaLive.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 19E2FD56B42EBD353B9D2016 /* Pods_SodaLive.framework */; };
|
85934EA071477BC71BFCDF5E /* Pods_SodaLive.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 19E2FD56B42EBD353B9D2016 /* Pods_SodaLive.framework */; };
|
||||||
895286621DBE8EC8C825A9B5 /* GetPushNotificationCategoryResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54EBFBCC66E5AC79197E1FC /* GetPushNotificationCategoryResponse.swift */; };
|
895286621DBE8EC8C825A9B5 /* GetPushNotificationCategoryResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54EBFBCC66E5AC79197E1FC /* GetPushNotificationCategoryResponse.swift */; };
|
||||||
89816791946CE71177F4BC2A /* FollowRecommendedCreatorsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 896A4A8EF6F7D15071F74D6D /* FollowRecommendedCreatorsRequest.swift */; };
|
|
||||||
89AF41E6A46CD84BA469F272 /* YandexAdSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F064EC9D5711B91979F45C05 /* YandexAdSupport.swift */; };
|
89AF41E6A46CD84BA469F272 /* YandexAdSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F064EC9D5711B91979F45C05 /* YandexAdSupport.swift */; };
|
||||||
89BA3654EB2D35AC48F99472 /* LanguageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7132AA3D131A8DF7B8D783BE /* LanguageService.swift */; };
|
89BA3654EB2D35AC48F99472 /* LanguageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7132AA3D131A8DF7B8D783BE /* LanguageService.swift */; };
|
||||||
8A3E98E8F5E3862EAED3B5F9 /* FontModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25D89184D67BD63E46E90281 /* FontModifier.swift */; };
|
8A3E98E8F5E3862EAED3B5F9 /* FontModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25D89184D67BD63E46E90281 /* FontModifier.swift */; };
|
||||||
@@ -1560,16 +1602,15 @@
|
|||||||
A376733CDBF21EBAF81C8BE2 /* SocialLoginRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0050039EBD24D2FF6D0BD0 /* SocialLoginRequest.swift */; };
|
A376733CDBF21EBAF81C8BE2 /* SocialLoginRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0050039EBD24D2FF6D0BD0 /* SocialLoginRequest.swift */; };
|
||||||
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 /* MainHomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76506E56D017236824FE9B8F /* MainHomeViewModel.swift */; };
|
A4EB669DFFFB64681E045EF3 /* MainHomeRecommendationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76506E56D017236824FE9B8F /* MainHomeRecommendationViewModel.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 */; };
|
||||||
AEFFD4DC842837ACF98BE697 /* PostChannelDonationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BBDC3ECBBE049EAA4057D3A /* PostChannelDonationRequest.swift */; };
|
AEFFD4DC842837ACF98BE697 /* PostChannelDonationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BBDC3ECBBE049EAA4057D3A /* PostChannelDonationRequest.swift */; };
|
||||||
B11681AC02BF51C6E2FA6FF3 /* MainTabBarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F49E8BB9BACFCA8878B70D /* MainTabBarButton.swift */; };
|
B11681AC02BF51C6E2FA6FF3 /* MainTabBarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F49E8BB9BACFCA8878B70D /* MainTabBarButton.swift */; };
|
||||||
B3AF650899B8FC84E0C0CDA0 /* MainPlaceholderTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7900EA9FD1150BABE64CF1B3 /* MainPlaceholderTabView.swift */; };
|
B3AF650899B8FC84E0C0CDA0 /* MainPlaceholderTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7900EA9FD1150BABE64CF1B3 /* MainPlaceholderTabView.swift */; };
|
||||||
B5E5018F6144BF60C528BB8C /* MainHomeApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BD0F6AD90B449C2FE021496 /* MainHomeApi.swift */; };
|
|
||||||
B5E9FA82CF4238F640702D4A /* CreatorDetailDialogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7406AE69F7667FAA2E6DD23 /* CreatorDetailDialogView.swift */; };
|
B5E9FA82CF4238F640702D4A /* CreatorDetailDialogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7406AE69F7667FAA2E6DD23 /* CreatorDetailDialogView.swift */; };
|
||||||
BA2B76FDCAF81D15E2D4E258 /* MainHomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76506E56D017236824FE9B8F /* MainHomeViewModel.swift */; };
|
BA2B76FDCAF81D15E2D4E258 /* MainHomeRecommendationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76506E56D017236824FE9B8F /* MainHomeRecommendationViewModel.swift */; };
|
||||||
BA511503C1E037162697D771 /* DonationRankingPeriod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44A42B63A1021DD9F8AA3F30 /* DonationRankingPeriod.swift */; };
|
BA511503C1E037162697D771 /* DonationRankingPeriod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44A42B63A1021DD9F8AA3F30 /* DonationRankingPeriod.swift */; };
|
||||||
BCA6CD9E57528C5F4203F1A5 /* CapsuleTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74990A0798E64A97237DD113 /* CapsuleTabBar.swift */; };
|
BCA6CD9E57528C5F4203F1A5 /* CapsuleTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74990A0798E64A97237DD113 /* CapsuleTabBar.swift */; };
|
||||||
BEAAA523CAD3198065A557BC /* EntityInfo-SodaLive-dev.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0ED95AC08C6481751AE9D97 /* EntityInfo-SodaLive-dev.generated.swift */; };
|
BEAAA523CAD3198065A557BC /* EntityInfo-SodaLive-dev.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0ED95AC08C6481751AE9D97 /* EntityInfo-SodaLive-dev.generated.swift */; };
|
||||||
@@ -1588,20 +1629,16 @@
|
|||||||
CAE33B321E81B312063B11E0 /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5420BBA14C00EC0D927BDAB /* MainViewModel.swift */; };
|
CAE33B321E81B312063B11E0 /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5420BBA14C00EC0D927BDAB /* MainViewModel.swift */; };
|
||||||
D1C65856889B16DFDB69A004 /* PushNotificationRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FDDF6BBD0192819B1F5B3FE /* PushNotificationRepository.swift */; };
|
D1C65856889B16DFDB69A004 /* PushNotificationRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FDDF6BBD0192819B1F5B3FE /* PushNotificationRepository.swift */; };
|
||||||
D2A268C7346E84E6B455E25A /* Pods_SodaLive_dev.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F4E5BB7961B7E6BB68C3379 /* Pods_SodaLive_dev.framework */; };
|
D2A268C7346E84E6B455E25A /* Pods_SodaLive_dev.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F4E5BB7961B7E6BB68C3379 /* Pods_SodaLive_dev.framework */; };
|
||||||
D81E265AB5DD18BF2AE18F22 /* FollowRecommendedCreatorsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 896A4A8EF6F7D15071F74D6D /* FollowRecommendedCreatorsRequest.swift */; };
|
|
||||||
DD202E48D61F1CE8108AD164 /* LineSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 6DBA0C325ACEC3FCDB22490D /* LineSDK */; };
|
DD202E48D61F1CE8108AD164 /* LineSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 6DBA0C325ACEC3FCDB22490D /* LineSDK */; };
|
||||||
DD2B6C23087BCA5591CD4D68 /* UpdateContentPreferenceResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4AAC943F77B6FE089696909 /* UpdateContentPreferenceResponse.swift */; };
|
DD2B6C23087BCA5591CD4D68 /* UpdateContentPreferenceResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4AAC943F77B6FE089696909 /* UpdateContentPreferenceResponse.swift */; };
|
||||||
DF27817A4DE429C45EA028A6 /* MainHomeRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E934A5940340AE1E8A5FFFC /* MainHomeRepository.swift */; };
|
|
||||||
E260227D30A9E44894BE3B27 /* V2vApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 613FD451377C66254773CF47 /* V2vApi.swift */; };
|
E260227D30A9E44894BE3B27 /* V2vApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 613FD451377C66254773CF47 /* V2vApi.swift */; };
|
||||||
E2AA285902E7E95C792E164E /* I18n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 826D626B7714E5C28ED31074 /* I18n.swift */; };
|
E2AA285902E7E95C792E164E /* I18n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 826D626B7714E5C28ED31074 /* I18n.swift */; };
|
||||||
E2C133B589E2DA7596B3BAEF /* ChannelDonationAllView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 872F4F3506F36E0EAAE312ED /* ChannelDonationAllView.swift */; };
|
E2C133B589E2DA7596B3BAEF /* ChannelDonationAllView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 872F4F3506F36E0EAAE312ED /* ChannelDonationAllView.swift */; };
|
||||||
E316EBA3E7125AFDA6CBE68D /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CD656CAEE376701F2B563E /* MainView.swift */; };
|
E316EBA3E7125AFDA6CBE68D /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CD656CAEE376701F2B563E /* MainView.swift */; };
|
||||||
E45131345D75F0F038D138D1 /* MainHomeRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E934A5940340AE1E8A5FFFC /* MainHomeRepository.swift */; };
|
|
||||||
E71A5D3008A7706B4F425CA6 /* DefaultTitleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7F1421DBAB56E2F6891625E /* DefaultTitleBar.swift */; };
|
E71A5D3008A7706B4F425CA6 /* DefaultTitleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7F1421DBAB56E2F6891625E /* DefaultTitleBar.swift */; };
|
||||||
F0F1F71CCA586A0CF5194FAB /* ChannelDonationItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A7650B281CE20A6F29F6253 /* ChannelDonationItemView.swift */; };
|
F0F1F71CCA586A0CF5194FAB /* ChannelDonationItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A7650B281CE20A6F29F6253 /* ChannelDonationItemView.swift */; };
|
||||||
F357D803F7BEC1DBF3F76493 /* PushNotificationListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA7A276BF4A47206ED702A7 /* PushNotificationListItemView.swift */; };
|
F357D803F7BEC1DBF3F76493 /* PushNotificationListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA7A276BF4A47206ED702A7 /* PushNotificationListItemView.swift */; };
|
||||||
F6DA2AAF5186543C8486A3F9 /* UserProfileChannelDonationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 214C32BF44DF384FD6925E0B /* UserProfileChannelDonationView.swift */; };
|
F6DA2AAF5186543C8486A3F9 /* UserProfileChannelDonationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 214C32BF44DF384FD6925E0B /* UserProfileChannelDonationView.swift */; };
|
||||||
F75DBAD90E73E65C084DB474 /* MainHomeRecommendationResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF693ADD4B6C386882D5DD53 /* MainHomeRecommendationResponse.swift */; };
|
|
||||||
F8DB0226F6F44A7DBEF8B4CF /* FollowButtonImageAsset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC033E531A9420363D017CE /* FollowButtonImageAsset.swift */; };
|
F8DB0226F6F44A7DBEF8B4CF /* FollowButtonImageAsset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC033E531A9420363D017CE /* FollowButtonImageAsset.swift */; };
|
||||||
FA5F18B3EE4CBD02D3807BD1 /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5420BBA14C00EC0D927BDAB /* MainViewModel.swift */; };
|
FA5F18B3EE4CBD02D3807BD1 /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5420BBA14C00EC0D927BDAB /* MainViewModel.swift */; };
|
||||||
FB404343AB7668FB7D020C02 /* PushNotificationRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FDDF6BBD0192819B1F5B3FE /* PushNotificationRepository.swift */; };
|
FB404343AB7668FB7D020C02 /* PushNotificationRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FDDF6BBD0192819B1F5B3FE /* PushNotificationRepository.swift */; };
|
||||||
@@ -1617,7 +1654,6 @@
|
|||||||
189350EA101CF12EEBA6EC1D /* MainTabBarView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTabBarView.swift; sourceTree = "<group>"; };
|
189350EA101CF12EEBA6EC1D /* MainTabBarView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTabBarView.swift; sourceTree = "<group>"; };
|
||||||
19E2FD56B42EBD353B9D2016 /* Pods_SodaLive.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SodaLive.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
19E2FD56B42EBD353B9D2016 /* Pods_SodaLive.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SodaLive.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
1BB17B6A2E9D9CAEA3B185D8 /* PushNotificationListViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushNotificationListViewModel.swift; sourceTree = "<group>"; };
|
1BB17B6A2E9D9CAEA3B185D8 /* PushNotificationListViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushNotificationListViewModel.swift; sourceTree = "<group>"; };
|
||||||
1BD0F6AD90B449C2FE021496 /* MainHomeApi.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainHomeApi.swift; sourceTree = "<group>"; };
|
|
||||||
1DF2BFD2E09F509C44C92214 /* ChannelDonationViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChannelDonationViewModel.swift; sourceTree = "<group>"; };
|
1DF2BFD2E09F509C44C92214 /* ChannelDonationViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChannelDonationViewModel.swift; sourceTree = "<group>"; };
|
||||||
1FDDF6BBD0192819B1F5B3FE /* PushNotificationRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushNotificationRepository.swift; sourceTree = "<group>"; };
|
1FDDF6BBD0192819B1F5B3FE /* PushNotificationRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushNotificationRepository.swift; sourceTree = "<group>"; };
|
||||||
214C32BF44DF384FD6925E0B /* UserProfileChannelDonationView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserProfileChannelDonationView.swift; sourceTree = "<group>"; };
|
214C32BF44DF384FD6925E0B /* UserProfileChannelDonationView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserProfileChannelDonationView.swift; sourceTree = "<group>"; };
|
||||||
@@ -1625,7 +1661,6 @@
|
|||||||
25D89184D67BD63E46E90281 /* FontModifier.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FontModifier.swift; sourceTree = "<group>"; };
|
25D89184D67BD63E46E90281 /* FontModifier.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FontModifier.swift; sourceTree = "<group>"; };
|
||||||
26881ED30292E28D2E5F84CD /* CreatorProfileItem.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorProfileItem.swift; sourceTree = "<group>"; };
|
26881ED30292E28D2E5F84CD /* CreatorProfileItem.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorProfileItem.swift; sourceTree = "<group>"; };
|
||||||
348933CFF7DC1B9DB6E5D1A1 /* FollowAllButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FollowAllButton.swift; sourceTree = "<group>"; };
|
348933CFF7DC1B9DB6E5D1A1 /* FollowAllButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FollowAllButton.swift; sourceTree = "<group>"; };
|
||||||
36E28A8DFC2D34DA99AE7209 /* RecommendedActivityType.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecommendedActivityType.swift; sourceTree = "<group>"; };
|
|
||||||
375C41DF9D62AF5B9C3C1FAC /* DateParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DateParser.swift; sourceTree = "<group>"; };
|
375C41DF9D62AF5B9C3C1FAC /* DateParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DateParser.swift; sourceTree = "<group>"; };
|
||||||
3FA7A276BF4A47206ED702A7 /* PushNotificationListItemView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushNotificationListItemView.swift; sourceTree = "<group>"; };
|
3FA7A276BF4A47206ED702A7 /* PushNotificationListItemView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushNotificationListItemView.swift; sourceTree = "<group>"; };
|
||||||
4004671E2CDE08E6004DB9B5 /* PurchaseOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseOption.swift; sourceTree = "<group>"; };
|
4004671E2CDE08E6004DB9B5 /* PurchaseOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseOption.swift; sourceTree = "<group>"; };
|
||||||
@@ -1706,6 +1741,24 @@
|
|||||||
401A3B252FEE3E2D00A5BBA3 /* NotoSansJP-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "NotoSansJP-Regular.ttf"; sourceTree = "<group>"; };
|
401A3B252FEE3E2D00A5BBA3 /* NotoSansJP-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "NotoSansJP-Regular.ttf"; sourceTree = "<group>"; };
|
||||||
401A3B262FEE3E2D00A5BBA3 /* Pattaya-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Pattaya-Regular.ttf"; sourceTree = "<group>"; };
|
401A3B262FEE3E2D00A5BBA3 /* Pattaya-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Pattaya-Regular.ttf"; sourceTree = "<group>"; };
|
||||||
401A3B272FEE3E2D00A5BBA3 /* Phosphate-Solid.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Phosphate-Solid.ttf"; sourceTree = "<group>"; };
|
401A3B272FEE3E2D00A5BBA3 /* Phosphate-Solid.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Phosphate-Solid.ttf"; sourceTree = "<group>"; };
|
||||||
|
401A3F962FEEAFA000A5BBA3 /* FollowRecommendedCreatorsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowRecommendedCreatorsRequest.swift; sourceTree = "<group>"; };
|
||||||
|
401A3F972FEEAFA000A5BBA3 /* MainHomeRecommendationResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRecommendationResponse.swift; sourceTree = "<group>"; };
|
||||||
|
401A3F982FEEAFA000A5BBA3 /* RecommendedActivityType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendedActivityType.swift; sourceTree = "<group>"; };
|
||||||
|
401A3F9A2FEEAFA000A5BBA3 /* MainHomeRecommendationApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRecommendationApi.swift; sourceTree = "<group>"; };
|
||||||
|
401A3F9B2FEEAFA000A5BBA3 /* MainHomeRecommendationRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRecommendationRepository.swift; sourceTree = "<group>"; };
|
||||||
|
401A3FB42FEEAFA000A5BBA3 /* MainHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeView.swift; sourceTree = "<group>"; };
|
||||||
|
401A3FB52FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRecommendationView.swift; sourceTree = "<group>"; };
|
||||||
|
401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingView.swift; sourceTree = "<group>"; };
|
||||||
|
401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingView.swift; sourceTree = "<group>"; };
|
||||||
|
401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeLiveSection.swift; sourceTree = "<group>"; };
|
||||||
|
401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeBannerSection.swift; sourceTree = "<group>"; };
|
||||||
|
401A3FCC2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeActiveCreatorSection.swift; sourceTree = "<group>"; };
|
||||||
|
401A3FCD2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRecentDebutCreatorSection.swift; sourceTree = "<group>"; };
|
||||||
|
401A3FD42FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeAiCharacterSection.swift; sourceTree = "<group>"; };
|
||||||
|
401A3FD52FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeGenreCreatorSection.swift; sourceTree = "<group>"; };
|
||||||
|
401A3FDB2FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeCheerCreatorSection.swift; sourceTree = "<group>"; };
|
||||||
|
401A3FDC2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomePopularCommunitySection.swift; sourceTree = "<group>"; };
|
||||||
|
401A3FDF2FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeBusinessInfoSection.swift; sourceTree = "<group>"; };
|
||||||
40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecentContent+CoreDataClass.swift"; sourceTree = "<group>"; };
|
40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecentContent+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||||
40235DEF2E37983D00B09A87 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
|
40235DEF2E37983D00B09A87 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
|
||||||
40235DF22E37987300B09A87 /* RecentContentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentContentService.swift; sourceTree = "<group>"; };
|
40235DF22E37987300B09A87 /* RecentContentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentContentService.swift; sourceTree = "<group>"; };
|
||||||
@@ -2366,14 +2419,12 @@
|
|||||||
716260E8214A329EE07CAAFA /* LanguageSettingsViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LanguageSettingsViewModel.swift; sourceTree = "<group>"; };
|
716260E8214A329EE07CAAFA /* LanguageSettingsViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LanguageSettingsViewModel.swift; sourceTree = "<group>"; };
|
||||||
72CD656CAEE376701F2B563E /* MainView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
|
72CD656CAEE376701F2B563E /* MainView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
|
||||||
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 /* MainHomeViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainHomeViewModel.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>"; };
|
||||||
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>"; };
|
||||||
7E934A5940340AE1E8A5FFFC /* MainHomeRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainHomeRepository.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>"; };
|
||||||
872F4F3506F36E0EAAE312ED /* ChannelDonationAllView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChannelDonationAllView.swift; sourceTree = "<group>"; };
|
872F4F3506F36E0EAAE312ED /* ChannelDonationAllView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChannelDonationAllView.swift; sourceTree = "<group>"; };
|
||||||
896A4A8EF6F7D15071F74D6D /* FollowRecommendedCreatorsRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FollowRecommendedCreatorsRequest.swift; sourceTree = "<group>"; };
|
|
||||||
8A7650B281CE20A6F29F6253 /* ChannelDonationItemView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChannelDonationItemView.swift; sourceTree = "<group>"; };
|
8A7650B281CE20A6F29F6253 /* ChannelDonationItemView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChannelDonationItemView.swift; sourceTree = "<group>"; };
|
||||||
8E138A248EE3AFAFAF01E245 /* EntityInfo-SodaLive.generated.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "EntityInfo-SodaLive.generated.swift"; sourceTree = "<group>"; };
|
8E138A248EE3AFAFAF01E245 /* EntityInfo-SodaLive.generated.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "EntityInfo-SodaLive.generated.swift"; 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>"; };
|
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>"; };
|
||||||
@@ -2401,7 +2452,6 @@
|
|||||||
E56C8151F551B091940F16C4 /* V2vRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = V2vRepository.swift; sourceTree = "<group>"; };
|
E56C8151F551B091940F16C4 /* V2vRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = V2vRepository.swift; sourceTree = "<group>"; };
|
||||||
E61608194B084224DB6B78B5 /* TextTabBar.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TextTabBar.swift; sourceTree = "<group>"; };
|
E61608194B084224DB6B78B5 /* TextTabBar.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TextTabBar.swift; sourceTree = "<group>"; };
|
||||||
E92246F5C1254CBED72AD9AD /* AudioContentCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AudioContentCard.swift; sourceTree = "<group>"; };
|
E92246F5C1254CBED72AD9AD /* AudioContentCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AudioContentCard.swift; sourceTree = "<group>"; };
|
||||||
EF693ADD4B6C386882D5DD53 /* MainHomeRecommendationResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainHomeRecommendationResponse.swift; sourceTree = "<group>"; };
|
|
||||||
F064EC9D5711B91979F45C05 /* YandexAdSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = YandexAdSupport.swift; sourceTree = "<group>"; };
|
F064EC9D5711B91979F45C05 /* YandexAdSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = YandexAdSupport.swift; sourceTree = "<group>"; };
|
||||||
F23D0834690CFE9FBDC10510 /* GetPushNotificationListResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GetPushNotificationListResponse.swift; sourceTree = "<group>"; };
|
F23D0834690CFE9FBDC10510 /* GetPushNotificationListResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GetPushNotificationListResponse.swift; sourceTree = "<group>"; };
|
||||||
F26CB443DA0ED4C45A18B258 /* PushNotificationApi.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushNotificationApi.swift; sourceTree = "<group>"; };
|
F26CB443DA0ED4C45A18B258 /* PushNotificationApi.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushNotificationApi.swift; sourceTree = "<group>"; };
|
||||||
@@ -2409,6 +2459,10 @@
|
|||||||
F7F1421DBAB56E2F6891625E /* DefaultTitleBar.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DefaultTitleBar.swift; sourceTree = "<group>"; };
|
F7F1421DBAB56E2F6891625E /* DefaultTitleBar.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DefaultTitleBar.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
401A3B942FEE61E000A5BBA3 /* docs */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = docs; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
40FE0E812A8361DF00252293 /* Frameworks */ = {
|
40FE0E812A8361DF00252293 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
@@ -2511,23 +2565,14 @@
|
|||||||
1BA796BAC773D01D80F8F9CE /* Home */ = {
|
1BA796BAC773D01D80F8F9CE /* Home */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
76506E56D017236824FE9B8F /* MainHomeViewModel.swift */,
|
401A3F9D2FEEAFA000A5BBA3 /* Recommendation */,
|
||||||
235E44A77529D0BA4F758812 /* Models */,
|
401A3FB82FEEAFA000A5BBA3 /* Ranking */,
|
||||||
B4BF53CF51C8A4AE001B2A49 /* Repository */,
|
401A3FB92FEEAFA000A5BBA3 /* Following */,
|
||||||
|
401A3FB42FEEAFA000A5BBA3 /* MainHomeView.swift */,
|
||||||
);
|
);
|
||||||
path = Home;
|
path = Home;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
235E44A77529D0BA4F758812 /* Models */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
896A4A8EF6F7D15071F74D6D /* FollowRecommendedCreatorsRequest.swift */,
|
|
||||||
EF693ADD4B6C386882D5DD53 /* MainHomeRecommendationResponse.swift */,
|
|
||||||
36E28A8DFC2D34DA99AE7209 /* RecommendedActivityType.swift */,
|
|
||||||
);
|
|
||||||
path = Models;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
2D9C7222B946512FD4C4B499 /* Banner */ = {
|
2D9C7222B946512FD4C4B499 /* Banner */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -2663,6 +2708,69 @@
|
|||||||
path = Like;
|
path = Like;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
401A3F992FEEAFA000A5BBA3 /* Models */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
401A3F962FEEAFA000A5BBA3 /* FollowRecommendedCreatorsRequest.swift */,
|
||||||
|
401A3F972FEEAFA000A5BBA3 /* MainHomeRecommendationResponse.swift */,
|
||||||
|
401A3F982FEEAFA000A5BBA3 /* RecommendedActivityType.swift */,
|
||||||
|
);
|
||||||
|
path = Models;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
401A3F9C2FEEAFA000A5BBA3 /* Repository */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
401A3F9A2FEEAFA000A5BBA3 /* MainHomeRecommendationApi.swift */,
|
||||||
|
401A3F9B2FEEAFA000A5BBA3 /* MainHomeRecommendationRepository.swift */,
|
||||||
|
);
|
||||||
|
path = Repository;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
401A3F9D2FEEAFA000A5BBA3 /* Recommendation */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
401A3FC02FEEB00100A5BBA3 /* Components */,
|
||||||
|
401A3F992FEEAFA000A5BBA3 /* Models */,
|
||||||
|
401A3F9C2FEEAFA000A5BBA3 /* Repository */,
|
||||||
|
401A3FB52FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift */,
|
||||||
|
76506E56D017236824FE9B8F /* MainHomeRecommendationViewModel.swift */,
|
||||||
|
);
|
||||||
|
path = Recommendation;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
401A3FB82FEEAFA000A5BBA3 /* Ranking */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */,
|
||||||
|
);
|
||||||
|
path = Ranking;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
401A3FB92FEEAFA000A5BBA3 /* Following */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */,
|
||||||
|
);
|
||||||
|
path = Following;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
401A3FC02FEEB00100A5BBA3 /* Components */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */,
|
||||||
|
401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */,
|
||||||
|
401A3FCC2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift */,
|
||||||
|
401A3FCD2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift */,
|
||||||
|
401A3FD42FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift */,
|
||||||
|
401A3FD52FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift */,
|
||||||
|
401A3FDB2FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift */,
|
||||||
|
401A3FDC2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift */,
|
||||||
|
401A3FDF2FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift */,
|
||||||
|
);
|
||||||
|
path = Components;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
40235DEA2E3797B000B09A87 /* Recent */ = {
|
40235DEA2E3797B000B09A87 /* Recent */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -4542,6 +4650,7 @@
|
|||||||
40FE0E7B2A8361DF00252293 = {
|
40FE0E7B2A8361DF00252293 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
401A3B942FEE61E000A5BBA3 /* docs */,
|
||||||
40FE0EAF2A83648600252293 /* SodaLive-dev.entitlements */,
|
40FE0EAF2A83648600252293 /* SodaLive-dev.entitlements */,
|
||||||
40FE0E862A8361DF00252293 /* SodaLive */,
|
40FE0E862A8361DF00252293 /* SodaLive */,
|
||||||
B3B6DC93A319880FD916CE87 /* generated */,
|
B3B6DC93A319880FD916CE87 /* generated */,
|
||||||
@@ -4792,15 +4901,6 @@
|
|||||||
path = generated;
|
path = generated;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
B4BF53CF51C8A4AE001B2A49 /* Repository */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
1BD0F6AD90B449C2FE021496 /* MainHomeApi.swift */,
|
|
||||||
7E934A5940340AE1E8A5FFFC /* MainHomeRepository.swift */,
|
|
||||||
);
|
|
||||||
path = Repository;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
B67DE694AAC1DD7D0A3C5761 /* Services */ = {
|
B67DE694AAC1DD7D0A3C5761 /* Services */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -4909,6 +5009,9 @@
|
|||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
);
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
401A3B942FEE61E000A5BBA3 /* docs */,
|
||||||
|
);
|
||||||
name = SodaLive;
|
name = SodaLive;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
40B28A1F2A83775F008BE463 /* FirebaseAnalytics */,
|
40B28A1F2A83775F008BE463 /* FirebaseAnalytics */,
|
||||||
@@ -4952,6 +5055,9 @@
|
|||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
);
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
401A3B942FEE61E000A5BBA3 /* docs */,
|
||||||
|
);
|
||||||
name = "SodaLive-dev";
|
name = "SodaLive-dev";
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
40B28A292A83779E008BE463 /* FirebaseAnalytics */,
|
40B28A292A83779E008BE463 /* FirebaseAnalytics */,
|
||||||
@@ -5250,6 +5356,22 @@
|
|||||||
40B37EEC2A90A3CF000DE782 /* ProfileUpdateViewModel.swift in Sources */,
|
40B37EEC2A90A3CF000DE782 /* ProfileUpdateViewModel.swift in Sources */,
|
||||||
407EF21A2B4532BF00D7118D /* CanCouponViewModel.swift in Sources */,
|
407EF21A2B4532BF00D7118D /* CanCouponViewModel.swift in Sources */,
|
||||||
400EA78F2A89F119004992CF /* LiveCancelDialog.swift in Sources */,
|
400EA78F2A89F119004992CF /* LiveCancelDialog.swift in Sources */,
|
||||||
|
401A3F9E2FEEAFA000A5BBA3 /* MainHomeRecommendationApi.swift in Sources */,
|
||||||
|
401A3F9F2FEEAFA000A5BBA3 /* MainHomeRecommendationResponse.swift in Sources */,
|
||||||
|
401A3FA02FEEAFA000A5BBA3 /* FollowRecommendedCreatorsRequest.swift in Sources */,
|
||||||
|
401A3FA12FEEAFA000A5BBA3 /* MainHomeRecommendationRepository.swift in Sources */,
|
||||||
|
401A3FA22FEEAFA000A5BBA3 /* RecommendedActivityType.swift in Sources */,
|
||||||
|
401A3FBA2FEEAFA000A5BBA3 /* MainHomeRecommendationViewModel.swift in Sources */,
|
||||||
|
401A3FA82FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */,
|
||||||
|
401A3FA92FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */,
|
||||||
|
401A3FC22FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */,
|
||||||
|
401A3FC62FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */,
|
||||||
|
401A3FC92FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift in Sources */,
|
||||||
|
401A3FCE2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift in Sources */,
|
||||||
|
401A3FD12FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */,
|
||||||
|
401A3FE02FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */,
|
||||||
|
401A3FAA2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */,
|
||||||
|
401A3FAB2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */,
|
||||||
409143302A85652F00EEBFB1 /* CanChargeView.swift in Sources */,
|
409143302A85652F00EEBFB1 /* CanChargeView.swift in Sources */,
|
||||||
40975CA32B572298004E2F1B /* LiveRoomOverlayStrokeTextButton.swift in Sources */,
|
40975CA32B572298004E2F1B /* LiveRoomOverlayStrokeTextButton.swift in Sources */,
|
||||||
40235DED2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */,
|
40235DED2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */,
|
||||||
@@ -5991,12 +6113,20 @@
|
|||||||
AD6424211AA898794BBE8470 /* SectionTitle.swift in Sources */,
|
AD6424211AA898794BBE8470 /* SectionTitle.swift in Sources */,
|
||||||
38D73A7B6BFB9A984834984B /* TextTabBar.swift in Sources */,
|
38D73A7B6BFB9A984834984B /* TextTabBar.swift in Sources */,
|
||||||
372837FE9857C16B6B5087FF /* TitleBar.swift in Sources */,
|
372837FE9857C16B6B5087FF /* TitleBar.swift in Sources */,
|
||||||
BA2B76FDCAF81D15E2D4E258 /* MainHomeViewModel.swift in Sources */,
|
BA2B76FDCAF81D15E2D4E258 /* MainHomeRecommendationViewModel.swift in Sources */,
|
||||||
89816791946CE71177F4BC2A /* FollowRecommendedCreatorsRequest.swift in Sources */,
|
401A3FAC2FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */,
|
||||||
164177B97BCE9CB0C1042E23 /* MainHomeRecommendationResponse.swift in Sources */,
|
401A3FAD2FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */,
|
||||||
040031FA8CF08BDE260ECB52 /* RecommendedActivityType.swift in Sources */,
|
401A3FC32FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */,
|
||||||
4A5CAD092A52205860D9AF5B /* MainHomeApi.swift in Sources */,
|
401A3FC72FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */,
|
||||||
E45131345D75F0F038D138D1 /* MainHomeRepository.swift in Sources */,
|
401A3FCA2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift in Sources */,
|
||||||
|
401A3FCF2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift in Sources */,
|
||||||
|
401A3FD22FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */,
|
||||||
|
401A3FD72FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift in Sources */,
|
||||||
|
401A3FD92FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift in Sources */,
|
||||||
|
401A3FDD2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */,
|
||||||
|
401A3FE12FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */,
|
||||||
|
401A3FAE2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */,
|
||||||
|
401A3FAF2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */,
|
||||||
B3AF650899B8FC84E0C0CDA0 /* MainPlaceholderTabView.swift in Sources */,
|
B3AF650899B8FC84E0C0CDA0 /* MainPlaceholderTabView.swift in Sources */,
|
||||||
CA65AD0D677B4A8E9CAC0E15 /* MainTab.swift in Sources */,
|
CA65AD0D677B4A8E9CAC0E15 /* MainTab.swift in Sources */,
|
||||||
A0643C829BE7377DC09112CB /* MainTabBarButton.swift in Sources */,
|
A0643C829BE7377DC09112CB /* MainTabBarButton.swift in Sources */,
|
||||||
@@ -6019,6 +6149,11 @@
|
|||||||
40B37EED2A90A3CF000DE782 /* ProfileUpdateViewModel.swift in Sources */,
|
40B37EED2A90A3CF000DE782 /* ProfileUpdateViewModel.swift in Sources */,
|
||||||
407EF21B2B4532BF00D7118D /* CanCouponViewModel.swift in Sources */,
|
407EF21B2B4532BF00D7118D /* CanCouponViewModel.swift in Sources */,
|
||||||
400EA7902A89F119004992CF /* LiveCancelDialog.swift in Sources */,
|
400EA7902A89F119004992CF /* LiveCancelDialog.swift in Sources */,
|
||||||
|
401A3FA32FEEAFA000A5BBA3 /* MainHomeRecommendationApi.swift in Sources */,
|
||||||
|
401A3FA42FEEAFA000A5BBA3 /* MainHomeRecommendationResponse.swift in Sources */,
|
||||||
|
401A3FA52FEEAFA000A5BBA3 /* FollowRecommendedCreatorsRequest.swift in Sources */,
|
||||||
|
401A3FA62FEEAFA000A5BBA3 /* MainHomeRecommendationRepository.swift in Sources */,
|
||||||
|
401A3FA72FEEAFA000A5BBA3 /* RecommendedActivityType.swift in Sources */,
|
||||||
409143312A85652F00EEBFB1 /* CanChargeView.swift in Sources */,
|
409143312A85652F00EEBFB1 /* CanChargeView.swift in Sources */,
|
||||||
40975CA42B572298004E2F1B /* LiveRoomOverlayStrokeTextButton.swift in Sources */,
|
40975CA42B572298004E2F1B /* LiveRoomOverlayStrokeTextButton.swift in Sources */,
|
||||||
40235DEE2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */,
|
40235DEE2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */,
|
||||||
@@ -6760,12 +6895,20 @@
|
|||||||
32595EDE75871A7B2FDCC5AB /* SectionTitle.swift in Sources */,
|
32595EDE75871A7B2FDCC5AB /* SectionTitle.swift in Sources */,
|
||||||
7A1DDAAE227BF176BDEF1CB9 /* TextTabBar.swift in Sources */,
|
7A1DDAAE227BF176BDEF1CB9 /* TextTabBar.swift in Sources */,
|
||||||
5EEE98FE0EC4039761C604EE /* TitleBar.swift in Sources */,
|
5EEE98FE0EC4039761C604EE /* TitleBar.swift in Sources */,
|
||||||
A4EB669DFFFB64681E045EF3 /* MainHomeViewModel.swift in Sources */,
|
A4EB669DFFFB64681E045EF3 /* MainHomeRecommendationViewModel.swift in Sources */,
|
||||||
D81E265AB5DD18BF2AE18F22 /* FollowRecommendedCreatorsRequest.swift in Sources */,
|
401A3FB02FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */,
|
||||||
F75DBAD90E73E65C084DB474 /* MainHomeRecommendationResponse.swift in Sources */,
|
401A3FB12FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */,
|
||||||
4ECF7D9BF3A958E9FDE3F5E0 /* RecommendedActivityType.swift in Sources */,
|
401A3FC42FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */,
|
||||||
B5E5018F6144BF60C528BB8C /* MainHomeApi.swift in Sources */,
|
401A3FC82FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */,
|
||||||
DF27817A4DE429C45EA028A6 /* MainHomeRepository.swift in Sources */,
|
401A3FCB2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift in Sources */,
|
||||||
|
401A3FD02FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift in Sources */,
|
||||||
|
401A3FD32FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */,
|
||||||
|
401A3FD82FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift in Sources */,
|
||||||
|
401A3FDA2FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift in Sources */,
|
||||||
|
401A3FDE2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */,
|
||||||
|
401A3FE22FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */,
|
||||||
|
401A3FB22FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */,
|
||||||
|
401A3FB32FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */,
|
||||||
ACD71056E97E13E48941ABBB /* MainPlaceholderTabView.swift in Sources */,
|
ACD71056E97E13E48941ABBB /* MainPlaceholderTabView.swift in Sources */,
|
||||||
33BA3AB731849284E62AE04B /* MainTab.swift in Sources */,
|
33BA3AB731849284E62AE04B /* MainTab.swift in Sources */,
|
||||||
B11681AC02BF51C6E2FA6FF3 /* MainTabBarButton.swift in Sources */,
|
B11681AC02BF51C6E2FA6FF3 /* MainTabBarButton.swift in Sources */,
|
||||||
@@ -6915,7 +7058,7 @@
|
|||||||
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 = 2;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"SodaLive/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"SodaLive/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 3A8QGPMRXM;
|
DEVELOPMENT_TEAM = 3A8QGPMRXM;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -6937,7 +7080,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.46.0;
|
MARKETING_VERSION = 1.55.0;
|
||||||
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 = "";
|
||||||
@@ -6956,7 +7099,7 @@
|
|||||||
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 = 2;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"SodaLive/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"SodaLive/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 3A8QGPMRXM;
|
DEVELOPMENT_TEAM = 3A8QGPMRXM;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -6978,7 +7121,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.46.0;
|
MARKETING_VERSION = 1.55.0;
|
||||||
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 = "";
|
||||||
|
|||||||
21
SodaLive/Resources/Assets.xcassets/v2/ic_feed_community_heart.imageset/Contents.json
vendored
Normal file
21
SodaLive/Resources/Assets.xcassets/v2/ic_feed_community_heart.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "ic_feed_community_heart.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_feed_community_heart.imageset/ic_feed_community_heart.png
vendored
Normal file
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_feed_community_heart.imageset/ic_feed_community_heart.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 457 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_feed_community_heart_fill.imageset/Contents.json
vendored
Normal file
21
SodaLive/Resources/Assets.xcassets/v2/ic_feed_community_heart_fill.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "ic_feed_community_heart_fill.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 401 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_feed_community_reply.imageset/Contents.json
vendored
Normal file
21
SodaLive/Resources/Assets.xcassets/v2/ic_feed_community_reply.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "ic_feed_community_reply.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_feed_community_reply.imageset/ic_feed_community_reply.png
vendored
Normal file
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_feed_community_reply.imageset/ic_feed_community_reply.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 447 B |
@@ -15,6 +15,38 @@ enum DateParser {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func relativeTimeText(fromUTC text: String, fallback: String, now: Date = Date()) -> String {
|
||||||
|
guard let date = parse(text) else {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
let interval = max(0, now.timeIntervalSince(date))
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let yearMonth = calendar.dateComponents([.year, .month], from: date, to: now)
|
||||||
|
|
||||||
|
if let years = yearMonth.year, years >= 1 {
|
||||||
|
return I18n.Time.yearsAgo(years)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let months = yearMonth.month, months >= 1 {
|
||||||
|
return I18n.Time.monthsAgo(months)
|
||||||
|
}
|
||||||
|
|
||||||
|
if interval < 60 {
|
||||||
|
return I18n.Time.justNow
|
||||||
|
}
|
||||||
|
|
||||||
|
if interval < 3600 {
|
||||||
|
return I18n.Time.minutesAgo(max(1, Int(interval / 60)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if interval < 86_400 {
|
||||||
|
return I18n.Time.hoursAgo(max(1, Int(interval / 3600)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return I18n.Time.daysAgo(max(1, Int(interval / 86_400)))
|
||||||
|
}
|
||||||
|
|
||||||
// 시도 순서: ISO8601(소수초 포함) → ISO8601 → RFC3339 유사 → 공백 구분 기본
|
// 시도 순서: ISO8601(소수초 포함) → ISO8601 → RFC3339 유사 → 공백 구분 기본
|
||||||
private static let parsers: [(String) -> Date?] = [
|
private static let parsers: [(String) -> Date?] = [
|
||||||
|
|||||||
@@ -34,37 +34,6 @@ extension GetCommunityPostListResponse {
|
|||||||
/// 상대 시간(방금 전/분/시간/일/개월/년 전)을 반환합니다.
|
/// 상대 시간(방금 전/분/시간/일/개월/년 전)을 반환합니다.
|
||||||
/// 파싱 실패 시, `date`를 그대로 반환합니다.
|
/// 파싱 실패 시, `date`를 그대로 반환합니다.
|
||||||
func relativeTimeText(now: Date = Date()) -> String {
|
func relativeTimeText(now: Date = Date()) -> String {
|
||||||
guard let createdAt = DateParser.parse(dateUtc) else {
|
DateParser.relativeTimeText(fromUTC: dateUtc, fallback: date, now: now)
|
||||||
return date
|
|
||||||
}
|
|
||||||
|
|
||||||
let nowDate = now
|
|
||||||
let interval = max(0, nowDate.timeIntervalSince(createdAt))
|
|
||||||
|
|
||||||
// 연/월 차이는 달력 기준으로 계산(윤년/월 길이 반영)
|
|
||||||
let calendar = Calendar.current
|
|
||||||
let ym = calendar.dateComponents([.year, .month],
|
|
||||||
from: createdAt,
|
|
||||||
to: nowDate)
|
|
||||||
if let years = ym.year, years >= 1 {
|
|
||||||
return I18n.Time.yearsAgo(years)
|
|
||||||
}
|
|
||||||
if let months = ym.month, months >= 1 {
|
|
||||||
return I18n.Time.monthsAgo(months)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 일/시간/분은 초 기반으로 간단 처리
|
|
||||||
if interval < 60 {
|
|
||||||
return I18n.Time.justNow
|
|
||||||
} else if interval < 3600 {
|
|
||||||
let minutes = Int(interval / 60)
|
|
||||||
return I18n.Time.minutesAgo(max(1, minutes))
|
|
||||||
} else if interval < 86_400 {
|
|
||||||
let hours = Int(interval / 3600)
|
|
||||||
return I18n.Time.hoursAgo(max(1, hours))
|
|
||||||
} else {
|
|
||||||
let days = Int(interval / 86_400)
|
|
||||||
return I18n.Time.daysAgo(max(1, days))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1507,47 +1507,29 @@ enum I18n {
|
|||||||
pick(
|
pick(
|
||||||
ko: """
|
ko: """
|
||||||
- 회사명 : 주식회사 소다라이브
|
- 회사명 : 주식회사 소다라이브
|
||||||
|
|
||||||
- 대표자 : 이재형
|
- 대표자 : 이재형
|
||||||
|
|
||||||
- 주소 : 경기도 성남시 분당구 황새울로335번길 10, 5층 563A호
|
- 주소 : 경기도 성남시 분당구 황새울로335번길 10, 5층 563A호
|
||||||
|
|
||||||
- 사업자등록번호 : 870-81-03220
|
- 사업자등록번호 : 870-81-03220
|
||||||
|
|
||||||
- 통신판매업신고 : 제2024-성남분당B-1012호
|
- 통신판매업신고 : 제2024-성남분당B-1012호
|
||||||
|
|
||||||
- 고객센터 : 02.2055.1477 (이용시간 10:00~19:00)
|
- 고객센터 : 02.2055.1477 (이용시간 10:00~19:00)
|
||||||
|
|
||||||
- 대표 이메일 : sodalive.official@gmail.com
|
- 대표 이메일 : sodalive.official@gmail.com
|
||||||
""",
|
""",
|
||||||
en: """
|
en: """
|
||||||
- Company: SodaLive Co., Ltd.
|
- Company: SodaLive Co., Ltd.
|
||||||
|
|
||||||
- CEO: Jaehyung Lee
|
- CEO: Jaehyung Lee
|
||||||
|
|
||||||
- Address: 5F 563A, 10, Hwangsaeul-ro335beon-gil, Bundang-gu, Seongnam-si, Gyeonggi-do
|
- Address: 5F 563A, 10, Hwangsaeul-ro335beon-gil, Bundang-gu, Seongnam-si, Gyeonggi-do
|
||||||
|
|
||||||
- Business Registration No.: 870-81-03220
|
- Business Registration No.: 870-81-03220
|
||||||
|
|
||||||
- Mail-order business report No.: 2024-SeongnamBundangB-1012
|
- Mail-order business report No.: 2024-SeongnamBundangB-1012
|
||||||
|
|
||||||
- Customer center: +82-2-2055-1477 (Hours 10:00~19:00)
|
- Customer center: +82-2-2055-1477 (Hours 10:00~19:00)
|
||||||
|
|
||||||
- Email: sodalive.official@gmail.com
|
- Email: sodalive.official@gmail.com
|
||||||
""",
|
""",
|
||||||
ja: """
|
ja: """
|
||||||
- 会社名:株式会社SodaLive
|
- 会社名:株式会社SodaLive
|
||||||
|
|
||||||
- 代表者:イ・ジェヒョン
|
- 代表者:イ・ジェヒョン
|
||||||
|
|
||||||
- 住所:京畿道 城南市 盆唐区 黄새울路335番ギル10、5階563A号
|
- 住所:京畿道 城南市 盆唐区 黄새울路335番ギル10、5階563A号
|
||||||
|
|
||||||
- 事業者登録番号:870-81-03220
|
- 事業者登録番号:870-81-03220
|
||||||
|
|
||||||
- 通信販売業届出:第2024-城南盆唐B-1012号
|
- 通信販売業届出:第2024-城南盆唐B-1012号
|
||||||
|
|
||||||
- カスタマーセンター:02.2055.1477(利用時間 10:00~19:00)
|
- カスタマーセンター:02.2055.1477(利用時間 10:00~19:00)
|
||||||
|
|
||||||
- 代表メール:sodalive.official@gmail.com
|
- 代表メール:sodalive.official@gmail.com
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -3090,7 +3072,58 @@ If you block this user, the following features will be restricted.
|
|||||||
}
|
}
|
||||||
|
|
||||||
static var aiCharacterSectionTitle: String {
|
static var aiCharacterSectionTitle: String {
|
||||||
pick(ko: "AI 캐릭터", en: "AI characters", ja: "AIキャラクター")
|
pick(ko: "크리에이터와 이야기를 나눠요!", en: "AI characters", ja: "AIキャラクター")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func compactChatCount(_ count: Int) -> String {
|
||||||
|
pick(
|
||||||
|
ko: compactEastAsianCount(count, tenMillionUnit: "억", tenThousandUnit: "만", thousandUnit: "천"),
|
||||||
|
en: compactEnglishCount(count),
|
||||||
|
ja: compactEastAsianCount(count, tenMillionUnit: "億", tenThousandUnit: "万", thousandUnit: "千")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func compactEastAsianCount(
|
||||||
|
_ count: Int,
|
||||||
|
tenMillionUnit: String,
|
||||||
|
tenThousandUnit: String,
|
||||||
|
thousandUnit: String
|
||||||
|
) -> String {
|
||||||
|
if count >= 100_000_000 {
|
||||||
|
return compactCountValue(count, divisor: 100_000_000, unit: tenMillionUnit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count >= 10_000 {
|
||||||
|
return compactCountValue(count, divisor: 10_000, unit: tenThousandUnit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count >= 1_000 {
|
||||||
|
return compactCountValue(count, divisor: 1_000, unit: thousandUnit)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count.comma()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func compactEnglishCount(_ count: Int) -> String {
|
||||||
|
if count >= 1_000_000_000 {
|
||||||
|
return compactCountValue(count, divisor: 1_000_000_000, unit: "B")
|
||||||
|
}
|
||||||
|
|
||||||
|
if count >= 1_000_000 {
|
||||||
|
return compactCountValue(count, divisor: 1_000_000, unit: "M")
|
||||||
|
}
|
||||||
|
|
||||||
|
if count >= 1_000 {
|
||||||
|
return compactCountValue(count, divisor: 1_000, unit: "K")
|
||||||
|
}
|
||||||
|
|
||||||
|
return count.comma()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func compactCountValue(_ count: Int, divisor: Int, unit: String) -> String {
|
||||||
|
let value = floor((Double(count) / Double(divisor)) * 10) / 10
|
||||||
|
let format = value.truncatingRemainder(dividingBy: 1) == 0 ? "%.0f" : "%.1f"
|
||||||
|
return String(format: format, locale: Locale(identifier: "en_US_POSIX"), value) + unit
|
||||||
}
|
}
|
||||||
|
|
||||||
static var genreCreatorSectionTitle: String {
|
static var genreCreatorSectionTitle: String {
|
||||||
|
|||||||
@@ -11,48 +11,205 @@ struct BannerCarouselItem: Identifiable, Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct BannerCarousel: View {
|
struct BannerCarousel: View {
|
||||||
|
private static let maxItemCount = 20
|
||||||
|
private static let autoScrollInterval: TimeInterval = 5
|
||||||
|
private static let transitionDuration: TimeInterval = 0.35
|
||||||
|
|
||||||
let items: [BannerCarouselItem]
|
let items: [BannerCarouselItem]
|
||||||
let height: CGFloat
|
|
||||||
let action: (BannerCarouselItem) -> Void
|
let action: (BannerCarouselItem) -> Void
|
||||||
|
|
||||||
|
@State private var displayIndex = 1
|
||||||
|
@GestureState private var dragOffset: CGFloat = 0
|
||||||
|
@State private var autoScrollTimer: Timer?
|
||||||
|
@State private var isSwiping = false
|
||||||
|
|
||||||
init(
|
init(
|
||||||
items: [BannerCarouselItem],
|
items: [BannerCarouselItem],
|
||||||
height: CGFloat = 120,
|
height: CGFloat = 120,
|
||||||
action: @escaping (BannerCarouselItem) -> Void = { _ in }
|
action: @escaping (BannerCarouselItem) -> Void = { _ in }
|
||||||
) {
|
) {
|
||||||
self.items = items
|
self.items = Array(items.prefix(BannerCarousel.maxItemCount))
|
||||||
self.height = height
|
|
||||||
self.action = action
|
self.action = action
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if !items.isEmpty {
|
if !items.isEmpty {
|
||||||
TabView {
|
GeometryReader { proxy in
|
||||||
ForEach(items) { item in
|
let itemWidth = proxy.size.width - (SodaSpacing.s20 * 2)
|
||||||
Button {
|
let itemHeight = itemWidth
|
||||||
action(item)
|
let step = itemWidth + SodaSpacing.s8
|
||||||
} label: {
|
let leadingInset = (proxy.size.width - itemWidth) / 2
|
||||||
DownsampledKFImage(
|
let effectiveIndex = items.count > 1 ? displayIndex : 0
|
||||||
url: URL(string: item.imageUrl ?? ""),
|
let offset = leadingInset - (CGFloat(effectiveIndex) * step) + dragOffset
|
||||||
size: CGSize(width: UIScreen.main.bounds.width - (SodaSpacing.s20 * 2), height: height)
|
|
||||||
)
|
HStack(spacing: SodaSpacing.s8) {
|
||||||
.background(Color.gray800)
|
ForEach(Array(visibleItems.enumerated()), id: \.offset) { _, item in
|
||||||
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
Button {
|
||||||
|
guard !isSwiping else { return }
|
||||||
|
action(item)
|
||||||
|
} label: {
|
||||||
|
DownsampledKFImage(
|
||||||
|
url: URL(string: item.imageUrl ?? ""),
|
||||||
|
size: CGSize(width: itemWidth, height: itemHeight)
|
||||||
|
)
|
||||||
|
.background(Color.gray800)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.frame(width: itemWidth, height: itemHeight)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
}
|
||||||
.padding(.horizontal, SodaSpacing.s20)
|
.offset(x: offset)
|
||||||
|
.frame(width: proxy.size.width, height: itemHeight, alignment: .leading)
|
||||||
|
.animation(.easeOut(duration: BannerCarousel.transitionDuration), value: displayIndex)
|
||||||
|
.clipped()
|
||||||
|
.overlay(alignment: .topTrailing) {
|
||||||
|
pageIndicator
|
||||||
|
.padding(.top, SodaSpacing.s14)
|
||||||
|
.padding(.trailing, SodaSpacing.s20 + SodaSpacing.s14)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.simultaneousGesture(dragGesture(step: step))
|
||||||
|
.onChange(of: items) { _ in
|
||||||
|
displayIndex = items.count > 1 ? 1 : 0
|
||||||
|
restartAutoScrollTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: UIScreen.main.bounds.width - (SodaSpacing.s20 * 2))
|
||||||
|
.onAppear {
|
||||||
|
displayIndex = items.count > 1 ? 1 : 0
|
||||||
|
restartAutoScrollTimer()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
stopAutoScrollTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var visibleItems: [BannerCarouselItem] {
|
||||||
|
guard items.count > 1, let first = items.first, let last = items.last else {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
return [last] + items + [first]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentPage: Int {
|
||||||
|
guard items.count > 1 else { return items.isEmpty ? 0 : 1 }
|
||||||
|
|
||||||
|
if displayIndex == 0 {
|
||||||
|
return items.count
|
||||||
|
}
|
||||||
|
|
||||||
|
if displayIndex == items.count + 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return displayIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pageIndicator: some View {
|
||||||
|
HStack(alignment: .center, spacing: 1) {
|
||||||
|
Text(String(format: "%02d", currentPage))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 20)
|
||||||
|
|
||||||
|
Text("/")
|
||||||
|
.foregroundColor(.gray400)
|
||||||
|
|
||||||
|
Text(String(format: "%02d", items.count))
|
||||||
|
.foregroundColor(.gray400)
|
||||||
|
.frame(width: 20)
|
||||||
|
}
|
||||||
|
.appFont(.body3)
|
||||||
|
.padding(.horizontal, SodaSpacing.s6)
|
||||||
|
.padding(.vertical, SodaSpacing.s4)
|
||||||
|
.background(Color.black.opacity(0.7))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dragGesture(step: CGFloat) -> some Gesture {
|
||||||
|
DragGesture(minimumDistance: 10)
|
||||||
|
.updating($dragOffset) { value, state, _ in
|
||||||
|
state = value.translation.width
|
||||||
|
}
|
||||||
|
.onChanged { _ in
|
||||||
|
if !isSwiping {
|
||||||
|
isSwiping = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onEnded { value in
|
||||||
|
defer {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
isSwiping = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard items.count > 1 else { return }
|
||||||
|
|
||||||
|
let threshold = step * 0.2
|
||||||
|
if value.translation.width < -threshold {
|
||||||
|
displayIndex += 1
|
||||||
|
} else if value.translation.width > threshold {
|
||||||
|
displayIndex -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapIfNeeded()
|
||||||
|
restartAutoScrollTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restartAutoScrollTimer() {
|
||||||
|
stopAutoScrollTimer()
|
||||||
|
guard items.count > 1 else { return }
|
||||||
|
|
||||||
|
autoScrollTimer = Timer.scheduledTimer(
|
||||||
|
withTimeInterval: BannerCarousel.autoScrollInterval,
|
||||||
|
repeats: true
|
||||||
|
) { _ in
|
||||||
|
displayIndex += 1
|
||||||
|
wrapIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopAutoScrollTimer() {
|
||||||
|
autoScrollTimer?.invalidate()
|
||||||
|
autoScrollTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func wrapIfNeeded() {
|
||||||
|
guard items.count > 1 else { return }
|
||||||
|
|
||||||
|
if displayIndex == 0 {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + BannerCarousel.transitionDuration) {
|
||||||
|
var transaction = Transaction()
|
||||||
|
transaction.disablesAnimations = true
|
||||||
|
withTransaction(transaction) {
|
||||||
|
displayIndex = items.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if displayIndex == items.count + 1 {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + BannerCarousel.transitionDuration) {
|
||||||
|
var transaction = Transaction()
|
||||||
|
transaction.disablesAnimations = true
|
||||||
|
withTransaction(transaction) {
|
||||||
|
displayIndex = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: height)
|
|
||||||
.tabViewStyle(.page(indexDisplayMode: items.count > 1 ? .automatic : .never))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BannerCarousel_Previews: PreviewProvider {
|
struct BannerCarousel_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
BannerCarousel(items: [BannerCarouselItem(id: "1", imageUrl: nil)])
|
BannerCarousel(
|
||||||
.background(Color.black)
|
items: [
|
||||||
.previewLayout(.sizeThatFits)
|
BannerCarouselItem(id: "1", imageUrl: "https://picsum.photos/500/500"),
|
||||||
|
BannerCarouselItem(id: "2", imageUrl: "https://picsum.photos/500/500"),
|
||||||
|
BannerCarouselItem(id: "3", imageUrl: "https://picsum.photos/500/500")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.background(Color.black)
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,19 +24,31 @@ struct FollowAllButton: View {
|
|||||||
HStack(spacing: SodaSpacing.s6) {
|
HStack(spacing: SodaSpacing.s6) {
|
||||||
if isCompleted {
|
if isCompleted {
|
||||||
Image("ic_new_following")
|
Image("ic_new_following")
|
||||||
|
.resizable()
|
||||||
|
.renderingMode(.template)
|
||||||
|
.foregroundColor(.black)
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
} else {
|
||||||
|
Image("ic_new_follow")
|
||||||
.resizable()
|
.resizable()
|
||||||
.renderingMode(.template)
|
.renderingMode(.template)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.frame(width: 16, height: 16)
|
.frame(width: 20, height: 20)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(isCompleted ? I18n.HomeRecommendation.followAllCompleted : I18n.HomeRecommendation.followAll)
|
Text(isCompleted ? I18n.HomeRecommendation.followAllCompleted : I18n.HomeRecommendation.followAll)
|
||||||
.appFont(.body5)
|
.appFont(.body5)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(isCompleted ? .black : .white)
|
||||||
|
}
|
||||||
|
.padding(SodaSpacing.s12)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(isCompleted ? Color.white : Color.clear)
|
||||||
|
.overlay {
|
||||||
|
if !isCompleted {
|
||||||
|
Capsule()
|
||||||
|
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, SodaSpacing.s16)
|
|
||||||
.frame(height: 36)
|
|
||||||
.background(isCompleted ? Color.gray700 : Color.button)
|
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
.opacity(isLoading ? 0.6 : 1)
|
.opacity(isLoading ? 0.6 : 1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct AiCharacterCard: View {
|
struct AiCharacterCard: View {
|
||||||
|
static let baseDeviceWidth: CGFloat = 402
|
||||||
|
static let maxCardWidth: CGFloat = 185
|
||||||
|
static let maxCardHeight: CGFloat = 278
|
||||||
|
|
||||||
let name: String
|
let name: String
|
||||||
let description: String
|
let description: String
|
||||||
let profileImageUrl: String?
|
let profileImageUrl: String?
|
||||||
@@ -8,57 +12,100 @@ struct AiCharacterCard: View {
|
|||||||
let originalTitle: String?
|
let originalTitle: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: SodaSpacing.s12) {
|
let cardSize = Self.responsiveSize()
|
||||||
DownsampledKFImage(url: URL(string: profileImageUrl ?? ""), size: CGSize(width: 72, height: 72))
|
|
||||||
.background(Color.gray800)
|
ZStack(alignment: .top) {
|
||||||
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
Color.gray900
|
||||||
|
|
||||||
|
DownsampledKFImage(
|
||||||
|
url: URL(string: profileImageUrl ?? ""),
|
||||||
|
size: CGSize(width: cardSize.width, height: cardSize.width)
|
||||||
|
)
|
||||||
|
.frame(width: cardSize.width, height: cardSize.width)
|
||||||
|
.background(Color.gray800)
|
||||||
|
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.gray900.opacity(0), Color.gray900],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
.frame(width: cardSize.width, height: cardSize.width)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: SodaSpacing.s6) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: SodaSpacing.s4) {
|
|
||||||
Text(name)
|
Text(name)
|
||||||
.appFont(.heading4)
|
.appFont(.heading3)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
|
|
||||||
Text(description)
|
Text(description)
|
||||||
.appFont(.body5)
|
.appFont(.body5)
|
||||||
.foregroundColor(Color.gray500)
|
.foregroundColor(.white)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
|
|
||||||
HStack(spacing: SodaSpacing.s8) {
|
if let originalTitle, !originalTitle.isEmpty {
|
||||||
if let chatCount {
|
Text(originalTitle)
|
||||||
Text("Chat \(chatCount)")
|
.appFont(.caption2)
|
||||||
.appFont(.caption2)
|
.foregroundColor(Color.soda300)
|
||||||
.foregroundColor(Color.gray500)
|
.lineLimit(1)
|
||||||
}
|
.truncationMode(.tail)
|
||||||
|
|
||||||
if let originalTitle, !originalTitle.isEmpty {
|
|
||||||
Text(originalTitle)
|
|
||||||
.appFont(.caption2)
|
|
||||||
.foregroundColor(Color.gray500)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
|
||||||
|
.padding(.horizontal, SodaSpacing.s12)
|
||||||
|
.padding(.bottom, SodaSpacing.s20)
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
if let chatCount {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Image(systemName: "bubble.fill")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
|
||||||
|
Text(I18n.HomeRecommendation.compactChatCount(chatCount))
|
||||||
|
.appFont(.body5)
|
||||||
|
.foregroundColor(Color.gray100)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, SodaSpacing.s4)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.black.opacity(0.6))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s4, style: .continuous))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
.padding(.top, SodaSpacing.s6)
|
||||||
|
.padding(.trailing, SodaSpacing.s6)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(SodaSpacing.s12)
|
.frame(width: cardSize.width, height: cardSize.height)
|
||||||
.background(Color.gray900)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func responsiveSize(deviceWidth: CGFloat = UIScreen.main.bounds.width) -> CGSize {
|
||||||
|
let scale = min(deviceWidth / baseDeviceWidth, 1)
|
||||||
|
return CGSize(width: maxCardWidth * scale, height: maxCardHeight * scale)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AiCharacterCard_Previews: PreviewProvider {
|
struct AiCharacterCard_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
AiCharacterCard(
|
VStack(spacing: SodaSpacing.s20) {
|
||||||
name: "AI 캐릭터",
|
AiCharacterCard(
|
||||||
description: "캐릭터 설명이 표시됩니다.",
|
name: "캐릭터 이름",
|
||||||
profileImageUrl: nil,
|
description: "캐릭터 소개가 들어가는 부분입니다 넘어가는 경우 ...처리",
|
||||||
chatCount: 128,
|
profileImageUrl: "https://picsum.photos/500/500",
|
||||||
originalTitle: "원작"
|
chatCount: 14_000,
|
||||||
)
|
originalTitle: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
AiCharacterCard(
|
||||||
|
name: "캐릭터 이름",
|
||||||
|
description: "캐릭터 소개가 들어가는 부분입니다 넘어가는 경우 ...처리",
|
||||||
|
profileImageUrl: nil,
|
||||||
|
chatCount: 128,
|
||||||
|
originalTitle: "작품 이름"
|
||||||
|
)
|
||||||
|
}
|
||||||
.padding(SodaSpacing.s20)
|
.padding(SodaSpacing.s20)
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
.previewLayout(.sizeThatFits)
|
.previewLayout(.sizeThatFits)
|
||||||
|
|||||||
@@ -1,129 +1,273 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CommunityPostCard: View {
|
struct CommunityPostCard: View {
|
||||||
|
let postId: Int
|
||||||
let creatorNickname: String
|
let creatorNickname: String
|
||||||
let creatorProfileImageUrl: String?
|
let creatorProfileImageUrl: String?
|
||||||
let content: String
|
let content: String
|
||||||
let imageUrl: String?
|
let imageUrl: String?
|
||||||
let price: Int?
|
let audioUrl: String?
|
||||||
|
let price: Int
|
||||||
let existOrdered: Bool
|
let existOrdered: Bool
|
||||||
let createdAt: String?
|
let createdAt: String
|
||||||
let likeCount: Int?
|
let isLike: Bool
|
||||||
let commentCount: Int?
|
let likeCount: Int
|
||||||
|
let commentCount: Int
|
||||||
|
let onTapLike: () -> Void
|
||||||
|
let onTapComment: () -> Void
|
||||||
|
let onTapPurchase: () -> Void
|
||||||
|
|
||||||
|
@State private var localIsLike: Bool
|
||||||
|
@State private var localLikeCount: Int
|
||||||
|
@StateObject private var playManager = CreatorCommunityMediaPlayerManager.shared
|
||||||
|
@StateObject private var contentPlayManager = ContentPlayManager.shared
|
||||||
|
|
||||||
|
init(
|
||||||
|
postId: Int,
|
||||||
|
creatorNickname: String,
|
||||||
|
creatorProfileImageUrl: String?,
|
||||||
|
content: String,
|
||||||
|
imageUrl: String?,
|
||||||
|
audioUrl: String?,
|
||||||
|
price: Int,
|
||||||
|
existOrdered: Bool,
|
||||||
|
createdAt: String,
|
||||||
|
isLike: Bool,
|
||||||
|
likeCount: Int,
|
||||||
|
commentCount: Int,
|
||||||
|
onTapLike: @escaping () -> Void = {},
|
||||||
|
onTapComment: @escaping () -> Void = {},
|
||||||
|
onTapPurchase: @escaping () -> Void = {}
|
||||||
|
) {
|
||||||
|
self.postId = postId
|
||||||
|
self.creatorNickname = creatorNickname
|
||||||
|
self.creatorProfileImageUrl = creatorProfileImageUrl
|
||||||
|
self.content = content
|
||||||
|
self.imageUrl = imageUrl
|
||||||
|
self.audioUrl = audioUrl
|
||||||
|
self.price = price
|
||||||
|
self.existOrdered = existOrdered
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.isLike = isLike
|
||||||
|
self.likeCount = likeCount
|
||||||
|
self.commentCount = commentCount
|
||||||
|
self.onTapLike = onTapLike
|
||||||
|
self.onTapComment = onTapComment
|
||||||
|
self.onTapPurchase = onTapPurchase
|
||||||
|
_localIsLike = State(initialValue: isLike)
|
||||||
|
_localLikeCount = State(initialValue: likeCount)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
|
VStack(alignment: .leading, spacing: SodaSpacing.s16) {
|
||||||
header
|
header
|
||||||
|
contentText
|
||||||
Text(content)
|
|
||||||
.appFont(.body4)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.lineLimit(3)
|
|
||||||
.truncationMode(.tail)
|
|
||||||
|
|
||||||
if hasImage {
|
if hasImage {
|
||||||
imageContent
|
imageContent
|
||||||
}
|
}
|
||||||
|
|
||||||
footer
|
reactionBar
|
||||||
}
|
}
|
||||||
.padding(SodaSpacing.s12)
|
.padding(SodaSpacing.s14)
|
||||||
.background(Color.gray900)
|
.background(Color.gray900)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||||
|
.onChange(of: isLike) { newValue in
|
||||||
|
localIsLike = newValue
|
||||||
|
}
|
||||||
|
.onChange(of: likeCount) { newValue in
|
||||||
|
localLikeCount = newValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
HStack(spacing: SodaSpacing.s8) {
|
HStack(spacing: SodaSpacing.s8) {
|
||||||
DownsampledKFImage(url: URL(string: creatorProfileImageUrl ?? ""), size: CGSize(width: 32, height: 32))
|
DownsampledKFImage(url: URL(string: creatorProfileImageUrl ?? ""), size: CGSize(width: 42, height: 42))
|
||||||
.background(Color.gray800)
|
.background(Color.gray800)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
|
.frame(width: 42, height: 42)
|
||||||
|
|
||||||
Text(creatorNickname)
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
.appFont(.body5)
|
Text(creatorNickname)
|
||||||
.foregroundColor(.white)
|
.appFont(.body4)
|
||||||
.lineLimit(1)
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(createdAt)
|
||||||
|
.appFont(.body5)
|
||||||
|
.foregroundColor(Color.gray500)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var imageContent: some View {
|
private var contentText: some View {
|
||||||
ZStack {
|
Text(content)
|
||||||
DownsampledKFImage(url: URL(string: imageUrl ?? ""), size: CGSize(width: 240, height: 150))
|
.appFont(.body2)
|
||||||
.background(Color.gray800)
|
.foregroundColor(.white)
|
||||||
.frame(maxWidth: .infinity)
|
.lineLimit(5)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s12, style: .continuous))
|
.truncationMode(.tail)
|
||||||
.blur(radius: isLocked ? 8 : 0)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
if isLocked {
|
private var imageContent: some View {
|
||||||
VStack(spacing: SodaSpacing.s6) {
|
GeometryReader { proxy in
|
||||||
|
ZStack {
|
||||||
|
DownsampledKFImage(
|
||||||
|
url: URL(string: imageUrl ?? ""),
|
||||||
|
size: CGSize(width: proxy.size.width, height: proxy.size.height)
|
||||||
|
)
|
||||||
|
.background(Color.gray800)
|
||||||
|
.frame(width: proxy.size.width, height: proxy.size.height)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||||
|
.blur(radius: isLocked ? 12 : 0)
|
||||||
|
|
||||||
|
if isLocked {
|
||||||
|
paidLockOverlay
|
||||||
|
} else if hasAudio {
|
||||||
|
audioPlayButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.aspectRatio(346.0 / 236.0, contentMode: .fit)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var paidLockOverlay: some View {
|
||||||
|
Button(action: onTapPurchase) {
|
||||||
|
ZStack {
|
||||||
|
Color.gray700.opacity(0.35)
|
||||||
|
|
||||||
|
VStack(spacing: SodaSpacing.s4) {
|
||||||
Image("ic_new_community_lock")
|
Image("ic_new_community_lock")
|
||||||
.resizable()
|
.resizable()
|
||||||
.renderingMode(.template)
|
.renderingMode(.template)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.frame(width: 24, height: 24)
|
.frame(width: 24, height: 24)
|
||||||
|
|
||||||
if let price {
|
HStack(spacing: SodaSpacing.s6) {
|
||||||
Text("\(price) can")
|
Image("ic_can")
|
||||||
.appFont(.caption2)
|
.resizable()
|
||||||
.foregroundColor(Color.button)
|
.frame(width: 20, height: 20)
|
||||||
.padding(.horizontal, SodaSpacing.s12)
|
|
||||||
.padding(.vertical, SodaSpacing.s6)
|
Text("\(price)")
|
||||||
.overlay(
|
.appFont(.body2)
|
||||||
Capsule()
|
.foregroundColor(.black)
|
||||||
.stroke(Color.button, lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
.padding(SodaSpacing.s8)
|
||||||
|
.background(Color.white)
|
||||||
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var footer: some View {
|
private var audioPlayButton: some View {
|
||||||
HStack(spacing: SodaSpacing.s12) {
|
Button {
|
||||||
if let createdAt, !createdAt.isEmpty {
|
guard let audioUrl, !audioUrl.isEmpty else { return }
|
||||||
Text(createdAt)
|
|
||||||
.appFont(.caption2)
|
|
||||||
.foregroundColor(Color.gray500)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let likeCount {
|
contentPlayManager.pauseAudio()
|
||||||
Text("Like \(likeCount)")
|
playManager.toggleContent(item: CreatorCommunityContentItem(contentId: postId, url: audioUrl))
|
||||||
.appFont(.caption2)
|
} label: {
|
||||||
.foregroundColor(Color.gray500)
|
Image(playManager.isPlaying && playManager.currentPlayingContentId == postId ? "btn_audio_content_pause" : "btn_audio_content_play")
|
||||||
}
|
.resizable()
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
if let commentCount {
|
|
||||||
Text("Comment \(commentCount)")
|
|
||||||
.appFont(.caption2)
|
|
||||||
.foregroundColor(Color.gray500)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var reactionBar: some View {
|
||||||
|
HStack(spacing: 15) {
|
||||||
|
Button {
|
||||||
|
toggleLike()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: SodaSpacing.s4) {
|
||||||
|
Image(localIsLike ? "ic_feed_community_heart_fill" : "ic_feed_community_heart")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
|
||||||
|
Text("\(localLikeCount)")
|
||||||
|
.appFont(.body2)
|
||||||
|
.foregroundColor(Color.gray400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Button(action: onTapComment) {
|
||||||
|
HStack(spacing: SodaSpacing.s4) {
|
||||||
|
Image("ic_feed_community_reply")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
|
||||||
|
Text("\(commentCount)")
|
||||||
|
.appFont(.body2)
|
||||||
|
.foregroundColor(Color.gray400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.frame(height: 24)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var hasImage: Bool {
|
private var hasImage: Bool {
|
||||||
!(imageUrl ?? "").isEmpty
|
!(imageUrl ?? "").isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var hasAudio: Bool {
|
||||||
|
!(audioUrl ?? "").isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
private var isLocked: Bool {
|
private var isLocked: Bool {
|
||||||
(price ?? 0) > 0 && !existOrdered
|
price > 0 && !existOrdered
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleLike() {
|
||||||
|
localIsLike.toggle()
|
||||||
|
localLikeCount += localIsLike ? 1 : -1
|
||||||
|
localLikeCount = max(0, localLikeCount)
|
||||||
|
onTapLike()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CommunityPostCard_Previews: PreviewProvider {
|
struct CommunityPostCard_Previews: PreviewProvider {
|
||||||
|
private static let previewImageUrl = "https://picsum.photos/500/500"
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
CommunityPostCard(
|
VStack(spacing: SodaSpacing.s8) {
|
||||||
creatorNickname: "크리에이터",
|
CommunityPostCard(
|
||||||
creatorProfileImageUrl: nil,
|
postId: 1,
|
||||||
content: "커뮤니티 본문입니다.",
|
creatorNickname: "크리에이터 이름",
|
||||||
imageUrl: nil,
|
creatorProfileImageUrl: previewImageUrl,
|
||||||
price: nil,
|
content: "크리에이터가 커뮤니티에 올린 글이 보이는 부분 크리에이터가 커뮤니티에 올린 글이 보이는 부분",
|
||||||
existOrdered: false,
|
imageUrl: nil,
|
||||||
createdAt: "방금 전",
|
audioUrl: nil,
|
||||||
likeCount: 10,
|
price: 0,
|
||||||
commentCount: 2
|
existOrdered: false,
|
||||||
)
|
createdAt: "2분 전",
|
||||||
.padding(SodaSpacing.s20)
|
isLike: false,
|
||||||
|
likeCount: 5,
|
||||||
|
commentCount: 6
|
||||||
|
)
|
||||||
|
|
||||||
|
CommunityPostCard(
|
||||||
|
postId: 2,
|
||||||
|
creatorNickname: "크리에이터 이름",
|
||||||
|
creatorProfileImageUrl: previewImageUrl,
|
||||||
|
content: "이미지가 있는 커뮤니티 게시글입니다.",
|
||||||
|
imageUrl: previewImageUrl,
|
||||||
|
audioUrl: "https://example.com/audio.mp3",
|
||||||
|
price: 30,
|
||||||
|
existOrdered: false,
|
||||||
|
createdAt: "2분 전",
|
||||||
|
isLike: true,
|
||||||
|
likeCount: 5,
|
||||||
|
commentCount: 6
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(SodaSpacing.s14)
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
.previewLayout(.sizeThatFits)
|
.previewLayout(.sizeThatFits)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,23 @@ struct CreatorProfileItem: View {
|
|||||||
let imageUrl: String?
|
let imageUrl: String?
|
||||||
let name: String
|
let name: String
|
||||||
let subtitle: String?
|
let subtitle: String?
|
||||||
|
let imageSize: CGSize
|
||||||
|
let spacing: CGFloat
|
||||||
let action: (() -> Void)?
|
let action: (() -> Void)?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
imageUrl: String?,
|
imageUrl: String?,
|
||||||
name: String,
|
name: String,
|
||||||
subtitle: String? = nil,
|
subtitle: String? = nil,
|
||||||
|
imageSize: CGSize = CGSize(width: 72, height: 72),
|
||||||
|
spacing: CGFloat = SodaSpacing.s8,
|
||||||
action: (() -> Void)? = nil
|
action: (() -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
self.imageUrl = imageUrl
|
self.imageUrl = imageUrl
|
||||||
self.name = name
|
self.name = name
|
||||||
self.subtitle = subtitle
|
self.subtitle = subtitle
|
||||||
|
self.imageSize = imageSize
|
||||||
|
self.spacing = spacing
|
||||||
self.action = action
|
self.action = action
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +28,7 @@ struct CreatorProfileItem: View {
|
|||||||
Button {
|
Button {
|
||||||
action?()
|
action?()
|
||||||
} label: {
|
} label: {
|
||||||
VStack(alignment: .center, spacing: SodaSpacing.s8) {
|
VStack(alignment: .center, spacing: spacing) {
|
||||||
profileImage
|
profileImage
|
||||||
|
|
||||||
VStack(alignment: .center, spacing: 2) {
|
VStack(alignment: .center, spacing: 2) {
|
||||||
@@ -48,7 +54,7 @@ struct CreatorProfileItem: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var profileImage: some View {
|
private var profileImage: some View {
|
||||||
DownsampledKFImage(url: URL(string: imageUrl ?? ""), size: CGSize(width: 72, height: 72))
|
DownsampledKFImage(url: URL(string: imageUrl ?? ""), size: imageSize)
|
||||||
.background(Color.gray800)
|
.background(Color.gray800)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ struct HomeTitleBar<Menu: View>: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
TitleBar {
|
TitleBar {
|
||||||
Image("img_text_logo_v2")
|
Image("img_text_logo_v2")
|
||||||
.resizable()
|
|
||||||
.scaledToFit()
|
|
||||||
} trailing: {
|
} trailing: {
|
||||||
menu
|
menu
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainHomeFollowingView: View {
|
||||||
|
var body: some View {
|
||||||
|
MainPlaceholderTabView(title: "팔로잉")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainHomeFollowingView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
MainHomeFollowingView()
|
||||||
|
}
|
||||||
|
}
|
||||||
97
SodaLive/Sources/V2/Main/Home/MainHomeView.swift
Normal file
97
SodaLive/Sources/V2/Main/Home/MainHomeView.swift
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainHomeView: View {
|
||||||
|
let onTapCanCharge: () -> Void
|
||||||
|
let onTapSearch: () -> Void
|
||||||
|
let onTapNotificationList: () -> Void
|
||||||
|
let onTapLive: (Int) -> Void
|
||||||
|
let onTapCreator: (Int) -> Void
|
||||||
|
let onTapContent: (Int) -> Void
|
||||||
|
let onTapCharacter: (Int) -> Void
|
||||||
|
let onTapCommunity: (Int) -> Void
|
||||||
|
let onTapBanner: (RecommendationBannerResponse) -> Void
|
||||||
|
let onTapFollowAll: (@escaping () -> Void) -> Void
|
||||||
|
|
||||||
|
@State private var selectedTab: MainHomeTab = .recommendation
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HomeTitleBar {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image("ic_bar_cash")
|
||||||
|
.onTapGesture { onTapCanCharge() }
|
||||||
|
|
||||||
|
Image("ic_bar_search")
|
||||||
|
.onTapGesture { onTapSearch() }
|
||||||
|
|
||||||
|
Image("ic_bar_bell")
|
||||||
|
.onTapGesture { onTapNotificationList() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextTabBar(
|
||||||
|
items: MainHomeTab.allCases,
|
||||||
|
selectedItem: $selectedTab,
|
||||||
|
title: { $0.title }
|
||||||
|
)
|
||||||
|
|
||||||
|
tabContent
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
.background(Color.black.ignoresSafeArea())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var tabContent: some View {
|
||||||
|
switch selectedTab {
|
||||||
|
case .recommendation:
|
||||||
|
MainHomeRecommendationView(
|
||||||
|
onTapLive: onTapLive,
|
||||||
|
onTapCreator: onTapCreator,
|
||||||
|
onTapContent: onTapContent,
|
||||||
|
onTapCharacter: onTapCharacter,
|
||||||
|
onTapCommunity: onTapCommunity,
|
||||||
|
onTapBanner: onTapBanner,
|
||||||
|
onTapFollowAll: onTapFollowAll
|
||||||
|
)
|
||||||
|
case .ranking:
|
||||||
|
MainHomeRankingView()
|
||||||
|
case .following:
|
||||||
|
MainHomeFollowingView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum MainHomeTab: CaseIterable, Hashable {
|
||||||
|
case recommendation
|
||||||
|
case ranking
|
||||||
|
case following
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .recommendation:
|
||||||
|
return "추천"
|
||||||
|
case .ranking:
|
||||||
|
return "랭킹"
|
||||||
|
case .following:
|
||||||
|
return "팔로잉"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainHomeView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
MainHomeView(
|
||||||
|
onTapCanCharge: {},
|
||||||
|
onTapSearch: {},
|
||||||
|
onTapNotificationList: {},
|
||||||
|
onTapLive: { _ in },
|
||||||
|
onTapCreator: { _ in },
|
||||||
|
onTapContent: { _ in },
|
||||||
|
onTapCharacter: { _ in },
|
||||||
|
onTapCommunity: { _ in },
|
||||||
|
onTapBanner: { _ in },
|
||||||
|
onTapFollowAll: { action in action() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
struct HomeRecommendationResponse: Decodable {
|
|
||||||
let lives: [HomeLiveItem]
|
|
||||||
let banners: [HomeBannerItem]
|
|
||||||
let recentlyActiveCreators: [HomeActiveCreatorItem]
|
|
||||||
let recentDebutCreators: [HomeCreatorItem]
|
|
||||||
let firstAudioContents: [HomeFirstAudioContentItem]
|
|
||||||
let aiCharacters: [HomeAiCharacterItem]
|
|
||||||
let genreCreators: [HomeGenreCreatorGroupItem]
|
|
||||||
let cheerCreators: [HomeCreatorItem]
|
|
||||||
let popularCommunityPosts: [HomePopularCommunityPostItem]
|
|
||||||
}
|
|
||||||
|
|
||||||
struct HomeLiveItem: Decodable, Hashable {
|
|
||||||
let liveRoomId: Int?
|
|
||||||
let roomId: Int?
|
|
||||||
let creatorId: Int?
|
|
||||||
let creatorNickname: String?
|
|
||||||
let title: String?
|
|
||||||
let profileImageUrl: String?
|
|
||||||
let thumbnailUrl: String?
|
|
||||||
let imageUrl: String?
|
|
||||||
let beginDateTime: String?
|
|
||||||
let participantCount: Int?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct HomeBannerItem: Decodable, Hashable {
|
|
||||||
let bannerId: Int?
|
|
||||||
let type: String?
|
|
||||||
let title: String?
|
|
||||||
let imageUrl: String?
|
|
||||||
let link: String?
|
|
||||||
let eventId: Int?
|
|
||||||
let creatorId: Int?
|
|
||||||
let seriesId: Int?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct HomeActiveCreatorItem: Decodable, Hashable {
|
|
||||||
let creatorId: Int?
|
|
||||||
let creatorNickname: String?
|
|
||||||
let profileImageUrl: String?
|
|
||||||
let activityType: RecommendedActivityType?
|
|
||||||
let activityAt: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct HomeCreatorItem: Decodable, Hashable {
|
|
||||||
let creatorId: Int?
|
|
||||||
let creatorNickname: String?
|
|
||||||
let nickname: String?
|
|
||||||
let profileImageUrl: String?
|
|
||||||
let imageUrl: String?
|
|
||||||
let description: String?
|
|
||||||
let followerCount: Int?
|
|
||||||
let cheerCount: Int?
|
|
||||||
let debutDate: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct HomeFirstAudioContentItem: Decodable, Hashable {
|
|
||||||
let contentId: Int?
|
|
||||||
let creatorId: Int?
|
|
||||||
let creatorNickname: String?
|
|
||||||
let title: String?
|
|
||||||
let imageUrl: String?
|
|
||||||
let coverImageUrl: String?
|
|
||||||
let releaseDate: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct HomeAiCharacterItem: Decodable, Hashable {
|
|
||||||
let characterId: Int?
|
|
||||||
let name: String?
|
|
||||||
let description: String?
|
|
||||||
let profileImageUrl: String?
|
|
||||||
let imageUrl: String?
|
|
||||||
let chatCount: Int?
|
|
||||||
let originalTitle: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct HomeGenreCreatorGroupItem: Decodable, Hashable {
|
|
||||||
let genreId: Int?
|
|
||||||
let genreName: String?
|
|
||||||
let title: String?
|
|
||||||
let creators: [HomeCreatorItem]?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct HomePopularCommunityPostItem: Decodable, Hashable {
|
|
||||||
let postId: Int?
|
|
||||||
let creatorId: Int?
|
|
||||||
let creatorNickname: String?
|
|
||||||
let creatorProfileImageUrl: String?
|
|
||||||
let content: String?
|
|
||||||
let imageUrl: String?
|
|
||||||
let price: Int?
|
|
||||||
let existOrdered: Bool?
|
|
||||||
let likeCount: Int?
|
|
||||||
let commentCount: Int?
|
|
||||||
let createdAt: String?
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainHomeRankingView: View {
|
||||||
|
var body: some View {
|
||||||
|
MainPlaceholderTabView(title: "랭킹")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainHomeRankingView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
MainHomeRankingView()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainHomeActiveCreatorSection: View {
|
||||||
|
let items: [HomeActiveCreatorItem]
|
||||||
|
let onTapLive: (Int) -> Void
|
||||||
|
let onTapContent: (Int) -> Void
|
||||||
|
let onTapCommunity: (Int) -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
items: [HomeActiveCreatorItem],
|
||||||
|
onTapLive: @escaping (Int) -> Void = { _ in },
|
||||||
|
onTapContent: @escaping (Int) -> Void = { _ in },
|
||||||
|
onTapCommunity: @escaping (Int) -> Void = { _ in }
|
||||||
|
) {
|
||||||
|
self.items = items
|
||||||
|
self.onTapLive = onTapLive
|
||||||
|
self.onTapContent = onTapContent
|
||||||
|
self.onTapCommunity = onTapCommunity
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !items.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: SodaSpacing.s14) {
|
||||||
|
SectionTitle(title: I18n.HomeRecommendation.activeCreatorSectionTitle)
|
||||||
|
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
LazyHStack(alignment: .center, spacing: SodaSpacing.s8) {
|
||||||
|
ForEach(items, id: \.self) { item in
|
||||||
|
MainHomeActiveCreatorItemView(item: item) {
|
||||||
|
handleTap(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, SodaSpacing.s14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleTap(_ item: HomeActiveCreatorItem) {
|
||||||
|
guard let targetId = item.targetId else { return }
|
||||||
|
|
||||||
|
switch item.activityType {
|
||||||
|
case .live:
|
||||||
|
onTapLive(targetId)
|
||||||
|
case .audio:
|
||||||
|
onTapContent(targetId)
|
||||||
|
case .community:
|
||||||
|
onTapCommunity(targetId)
|
||||||
|
case .unknown:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MainHomeActiveCreatorItemView: View {
|
||||||
|
let item: HomeActiveCreatorItem
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(alignment: .center, spacing: SodaSpacing.s8) {
|
||||||
|
DownsampledKFImage(url: URL(string: item.creatorProfileImage), size: CGSize(width: 52, height: 52))
|
||||||
|
.background(Color.gray800)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.frame(width: 52, height: 52)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: SodaSpacing.s6) {
|
||||||
|
Text(item.creatorNickname)
|
||||||
|
.appFont(.heading4)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
.frame(width: 147, alignment: .leading)
|
||||||
|
|
||||||
|
HStack(alignment: .center, spacing: SodaSpacing.s8) {
|
||||||
|
if !item.activityType.title.isEmpty {
|
||||||
|
Text(item.activityType.title)
|
||||||
|
.appFont(.body5)
|
||||||
|
.foregroundColor(Color.gray100)
|
||||||
|
.padding(.horizontal, SodaSpacing.s4)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.gray700)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s4, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(item.relativeActivityAtText())
|
||||||
|
.appFont(.body4)
|
||||||
|
.foregroundColor(Color.gray500)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 159, alignment: .leading)
|
||||||
|
}
|
||||||
|
.padding(SodaSpacing.s12)
|
||||||
|
.background(Color.gray900)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension HomeActiveCreatorItem {
|
||||||
|
func relativeActivityAtText(now: Date = Date()) -> String {
|
||||||
|
guard let activityDate = DateParser.parse(activityAt) else {
|
||||||
|
return activityAt
|
||||||
|
}
|
||||||
|
|
||||||
|
let interval = max(0, now.timeIntervalSince(activityDate))
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let yearMonth = calendar.dateComponents([.year, .month], from: activityDate, to: now)
|
||||||
|
|
||||||
|
if let years = yearMonth.year, years >= 1 {
|
||||||
|
return I18n.Time.yearsAgo(years)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let months = yearMonth.month, months >= 1 {
|
||||||
|
return I18n.Time.monthsAgo(months)
|
||||||
|
}
|
||||||
|
|
||||||
|
if interval < 60 {
|
||||||
|
return I18n.Time.justNow
|
||||||
|
}
|
||||||
|
|
||||||
|
if interval < 3600 {
|
||||||
|
return I18n.Time.minutesAgo(max(1, Int(interval / 60)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if interval < 86_400 {
|
||||||
|
return I18n.Time.hoursAgo(max(1, Int(interval / 3600)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return I18n.Time.daysAgo(max(1, Int(interval / 86_400)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainHomeActiveCreatorSection_Previews: PreviewProvider {
|
||||||
|
private static let previewImageUrl = "https://picsum.photos/500/500"
|
||||||
|
|
||||||
|
static var previews: some View {
|
||||||
|
MainHomeActiveCreatorSection(
|
||||||
|
items: [
|
||||||
|
HomeActiveCreatorItem(
|
||||||
|
creatorNickname: "크리에이터이름",
|
||||||
|
creatorProfileImage: previewImageUrl,
|
||||||
|
activityType: .live,
|
||||||
|
activityAt: "2026-06-29T06:39:00Z",
|
||||||
|
targetId: 1
|
||||||
|
),
|
||||||
|
HomeActiveCreatorItem(
|
||||||
|
creatorNickname: "크리에이터이름",
|
||||||
|
creatorProfileImage: previewImageUrl,
|
||||||
|
activityType: .audio,
|
||||||
|
activityAt: "2026-06-29T06:39:00Z",
|
||||||
|
targetId: 2
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.padding(.vertical, SodaSpacing.s20)
|
||||||
|
.background(Color.black)
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainHomeAiCharacterSection: View {
|
||||||
|
let items: [HomeAiCharacterItem]
|
||||||
|
let onTapCharacter: (Int) -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
items: [HomeAiCharacterItem],
|
||||||
|
onTapCharacter: @escaping (Int) -> Void = { _ in }
|
||||||
|
) {
|
||||||
|
self.items = items
|
||||||
|
self.onTapCharacter = onTapCharacter
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !items.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: SodaSpacing.s14) {
|
||||||
|
SectionTitle(title: I18n.HomeRecommendation.aiCharacterSectionTitle)
|
||||||
|
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
LazyHStack(alignment: .center, spacing: SodaSpacing.s4) {
|
||||||
|
ForEach(items, id: \.self) { item in
|
||||||
|
Button {
|
||||||
|
onTapCharacter(item.characterId)
|
||||||
|
} label: {
|
||||||
|
AiCharacterCard(
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
profileImageUrl: item.profileImage,
|
||||||
|
chatCount: item.totalChatCount,
|
||||||
|
originalTitle: item.originalWorkTitle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, SodaSpacing.s14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainHomeAiCharacterSection_Previews: PreviewProvider {
|
||||||
|
private static let previewImageUrl = "https://picsum.photos/500/500"
|
||||||
|
|
||||||
|
static var previews: some View {
|
||||||
|
MainHomeAiCharacterSection(
|
||||||
|
items: [
|
||||||
|
HomeAiCharacterItem(
|
||||||
|
characterId: 1,
|
||||||
|
creatorId: 10,
|
||||||
|
name: "캐릭터 이름",
|
||||||
|
description: "캐릭터 소개가 들어가는 부분입니다 넘어가는 경우 ...처리",
|
||||||
|
profileImage: previewImageUrl,
|
||||||
|
totalChatCount: 14_000,
|
||||||
|
originalWorkTitle: nil
|
||||||
|
),
|
||||||
|
HomeAiCharacterItem(
|
||||||
|
characterId: 2,
|
||||||
|
creatorId: 11,
|
||||||
|
name: "캐릭터 이름",
|
||||||
|
description: "캐릭터 소개가 들어가는 부분입니다 넘어가는 경우 ...처리",
|
||||||
|
profileImage: previewImageUrl,
|
||||||
|
totalChatCount: 128,
|
||||||
|
originalWorkTitle: "작품 이름"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.padding(.vertical, SodaSpacing.s20)
|
||||||
|
.background(Color.black)
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainHomeBannerSection: View {
|
||||||
|
let items: [RecommendationBannerResponse]
|
||||||
|
let onTapBanner: (RecommendationBannerResponse) -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
items: [RecommendationBannerResponse],
|
||||||
|
onTapBanner: @escaping (RecommendationBannerResponse) -> Void = { _ in }
|
||||||
|
) {
|
||||||
|
self.items = items
|
||||||
|
self.onTapBanner = onTapBanner
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !items.isEmpty {
|
||||||
|
BannerCarousel(items: carouselItems) { item in
|
||||||
|
guard let banner = banner(for: item) else { return }
|
||||||
|
onTapBanner(banner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var carouselItems: [BannerCarouselItem] {
|
||||||
|
items.enumerated().map { index, item in
|
||||||
|
BannerCarouselItem(
|
||||||
|
id: fallbackId(for: item, index: index),
|
||||||
|
imageUrl: item.imageUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func banner(for carouselItem: BannerCarouselItem) -> RecommendationBannerResponse? {
|
||||||
|
items.enumerated().first { index, item in
|
||||||
|
fallbackId(for: item, index: index) == carouselItem.id
|
||||||
|
}?.element
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fallbackId(for item: RecommendationBannerResponse, index: Int) -> String {
|
||||||
|
if let eventItem = item.eventItem {
|
||||||
|
return "banner-\(index)-event-\(eventItem.id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if let creatorId = item.creatorId {
|
||||||
|
return "banner-\(index)-creator-\(creatorId)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if let seriesId = item.seriesId {
|
||||||
|
return "banner-\(index)-series-\(seriesId)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if let link = item.link, !link.isEmpty {
|
||||||
|
return "banner-\(index)-link-\(link)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "banner-\(index)-image-\(item.imageUrl)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainHomeBannerSection_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
MainHomeBannerSection(
|
||||||
|
items: [
|
||||||
|
RecommendationBannerResponse(
|
||||||
|
imageUrl: "https://picsum.photos/500/500",
|
||||||
|
eventItem: nil,
|
||||||
|
creatorId: nil,
|
||||||
|
seriesId: nil,
|
||||||
|
link: nil
|
||||||
|
),
|
||||||
|
RecommendationBannerResponse(
|
||||||
|
imageUrl: "https://picsum.photos/500/500",
|
||||||
|
eventItem: nil,
|
||||||
|
creatorId: 1,
|
||||||
|
seriesId: nil,
|
||||||
|
link: nil
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.background(Color.black)
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainHomeBusinessInfoSection: View {
|
||||||
|
var body: some View {
|
||||||
|
BusinessInfoExpandableTextView(
|
||||||
|
text: I18n.Settings.companyInfo,
|
||||||
|
lineLimit: 3,
|
||||||
|
moreTitle: I18n.HomeRecommendation.more,
|
||||||
|
collapseTitle: I18n.HomeRecommendation.collapse,
|
||||||
|
font: .body5,
|
||||||
|
foregroundColor: Color.gray500
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, SodaSpacing.s14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct BusinessInfoExpandableTextView: View {
|
||||||
|
let text: String
|
||||||
|
let lineLimit: Int
|
||||||
|
let moreTitle: String
|
||||||
|
let collapseTitle: String
|
||||||
|
let font: SodaTypography
|
||||||
|
let foregroundColor: Color
|
||||||
|
|
||||||
|
@State private var isExpanded = false
|
||||||
|
@State private var fullTextHeight: CGFloat = 0
|
||||||
|
@State private var limitedTextHeight: CGFloat = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: SodaSpacing.s8) {
|
||||||
|
Text(text)
|
||||||
|
.appFont(font)
|
||||||
|
.foregroundColor(foregroundColor)
|
||||||
|
.lineLimit(isExpanded ? nil : lineLimit)
|
||||||
|
.background(measuringText(lineLimit: nil, height: $fullTextHeight))
|
||||||
|
.background(measuringText(lineLimit: lineLimit, height: $limitedTextHeight))
|
||||||
|
|
||||||
|
if fullTextHeight > limitedTextHeight + 1 {
|
||||||
|
Button {
|
||||||
|
isExpanded.toggle()
|
||||||
|
} label: {
|
||||||
|
Text(isExpanded ? collapseTitle : moreTitle)
|
||||||
|
.appFont(.body5)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func measuringText(lineLimit: Int?, height: Binding<CGFloat>) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.appFont(font)
|
||||||
|
.lineLimit(lineLimit)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.hidden()
|
||||||
|
.background(
|
||||||
|
GeometryReader { proxy in
|
||||||
|
Color.clear
|
||||||
|
.onAppear { height.wrappedValue = proxy.size.height }
|
||||||
|
.onChange(of: proxy.size.height) { newHeight in height.wrappedValue = newHeight }
|
||||||
|
.onChange(of: text) { _ in height.wrappedValue = proxy.size.height }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainHomeBusinessInfoSection_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
MainHomeBusinessInfoSection()
|
||||||
|
.padding(.vertical, SodaSpacing.s20)
|
||||||
|
.background(Color.black)
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainHomeCheerCreatorSection: View {
|
||||||
|
let items: [HomeCreatorItem]
|
||||||
|
let isFollowAllCompleted: Bool
|
||||||
|
let isFollowAllLoading: Bool
|
||||||
|
let onTapCreator: (Int) -> Void
|
||||||
|
let onTapFollowAll: () -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
items: [HomeCreatorItem],
|
||||||
|
isFollowAllCompleted: Bool = false,
|
||||||
|
isFollowAllLoading: Bool = false,
|
||||||
|
onTapCreator: @escaping (Int) -> Void = { _ in },
|
||||||
|
onTapFollowAll: @escaping () -> Void = {}
|
||||||
|
) {
|
||||||
|
self.items = items
|
||||||
|
self.isFollowAllCompleted = isFollowAllCompleted
|
||||||
|
self.isFollowAllLoading = isFollowAllLoading
|
||||||
|
self.onTapCreator = onTapCreator
|
||||||
|
self.onTapFollowAll = onTapFollowAll
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !items.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: SodaSpacing.s14) {
|
||||||
|
SectionTitle(title: I18n.HomeRecommendation.cheerCreatorSectionTitle)
|
||||||
|
|
||||||
|
MainHomeCreatorGroupSection(
|
||||||
|
title: I18n.HomeRecommendation.cheerCreatorSectionTitle,
|
||||||
|
showTitle: false,
|
||||||
|
creators: items,
|
||||||
|
isFollowAllCompleted: isFollowAllCompleted,
|
||||||
|
isFollowAllLoading: isFollowAllLoading,
|
||||||
|
onTapCreator: onTapCreator,
|
||||||
|
onTapFollowAll: onTapFollowAll
|
||||||
|
)
|
||||||
|
.padding(.horizontal, SodaSpacing.s14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainHomeCheerCreatorSection_Previews: PreviewProvider {
|
||||||
|
private static let previewImageUrl = "https://picsum.photos/200/200"
|
||||||
|
|
||||||
|
static var previews: some View {
|
||||||
|
MainHomeCheerCreatorSection(
|
||||||
|
items: (1...8).map {
|
||||||
|
HomeCreatorItem(
|
||||||
|
creatorId: $0,
|
||||||
|
creatorNickname: "크리에이터이름",
|
||||||
|
creatorProfileImage: previewImageUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.padding(.vertical, SodaSpacing.s20)
|
||||||
|
.background(Color.black)
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainHomeGenreCreatorSection: View {
|
||||||
|
let items: [HomeGenreCreatorGroupItem]
|
||||||
|
let isFollowAllCompleted: (String) -> Bool
|
||||||
|
let isFollowAllLoading: (String) -> Bool
|
||||||
|
let onTapCreator: (Int) -> Void
|
||||||
|
let onTapFollowAll: ([Int], String) -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
items: [HomeGenreCreatorGroupItem],
|
||||||
|
isFollowAllCompleted: @escaping (String) -> Bool = { _ in false },
|
||||||
|
isFollowAllLoading: @escaping (String) -> Bool = { _ in false },
|
||||||
|
onTapCreator: @escaping (Int) -> Void = { _ in },
|
||||||
|
onTapFollowAll: @escaping ([Int], String) -> Void = { _, _ in }
|
||||||
|
) {
|
||||||
|
self.items = items
|
||||||
|
self.isFollowAllCompleted = isFollowAllCompleted
|
||||||
|
self.isFollowAllLoading = isFollowAllLoading
|
||||||
|
self.onTapCreator = onTapCreator
|
||||||
|
self.onTapFollowAll = onTapFollowAll
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !items.isEmpty {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
LazyHStack(alignment: .top, spacing: SodaSpacing.s4) {
|
||||||
|
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
|
||||||
|
let followKey = "genre-\(index)-\(item.genreName)"
|
||||||
|
|
||||||
|
MainHomeCreatorGroupSection(
|
||||||
|
title: item.genreName,
|
||||||
|
creators: item.creators,
|
||||||
|
isFollowAllCompleted: isFollowAllCompleted(followKey),
|
||||||
|
isFollowAllLoading: isFollowAllLoading(followKey),
|
||||||
|
onTapCreator: onTapCreator,
|
||||||
|
onTapFollowAll: {
|
||||||
|
onTapFollowAll(item.creators.map(\.creatorId), followKey)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.leading, SodaSpacing.s14)
|
||||||
|
.padding(.trailing, SodaSpacing.s20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainHomeCreatorGroupSection: View {
|
||||||
|
let title: String
|
||||||
|
let showTitle: Bool
|
||||||
|
let creators: [HomeCreatorItem]
|
||||||
|
let isFollowAllCompleted: Bool
|
||||||
|
let isFollowAllLoading: Bool
|
||||||
|
let onTapCreator: (Int) -> Void
|
||||||
|
let onTapFollowAll: () -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
title: String,
|
||||||
|
showTitle: Bool = true,
|
||||||
|
creators: [HomeCreatorItem],
|
||||||
|
isFollowAllCompleted: Bool = false,
|
||||||
|
isFollowAllLoading: Bool = false,
|
||||||
|
onTapCreator: @escaping (Int) -> Void = { _ in },
|
||||||
|
onTapFollowAll: @escaping () -> Void = {}
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.showTitle = showTitle
|
||||||
|
self.creators = creators
|
||||||
|
self.isFollowAllCompleted = isFollowAllCompleted
|
||||||
|
self.isFollowAllLoading = isFollowAllLoading
|
||||||
|
self.onTapCreator = onTapCreator
|
||||||
|
self.onTapFollowAll = onTapFollowAll
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: SodaSpacing.s14) {
|
||||||
|
if showTitle {
|
||||||
|
Text(title)
|
||||||
|
.appFont(.heading3)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyVGrid(columns: columns, alignment: .center, spacing: SodaSpacing.s14) {
|
||||||
|
ForEach(creators.prefix(8), id: \.self) { creator in
|
||||||
|
CreatorProfileItem(
|
||||||
|
imageUrl: creator.creatorProfileImage,
|
||||||
|
name: creator.creatorNickname,
|
||||||
|
imageSize: CGSize(width: 75, height: 75),
|
||||||
|
spacing: SodaSpacing.s6
|
||||||
|
) {
|
||||||
|
onTapCreator(creator.creatorId)
|
||||||
|
}
|
||||||
|
.frame(width: 75)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FollowAllButton(
|
||||||
|
isCompleted: isFollowAllCompleted,
|
||||||
|
isLoading: isFollowAllLoading,
|
||||||
|
action: onTapFollowAll
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(SodaSpacing.s14)
|
||||||
|
.frame(width: 374)
|
||||||
|
.background(cardBackground)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var columns: [GridItem] {
|
||||||
|
Array(repeating: GridItem(.fixed(75), spacing: SodaSpacing.s14), count: 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cardBackground: some View {
|
||||||
|
LinearGradient(
|
||||||
|
stops: [
|
||||||
|
.init(color: Color.gray900, location: 0),
|
||||||
|
.init(color: Color.gray900, location: 0.29327),
|
||||||
|
.init(color: Color.soda700, location: 1)
|
||||||
|
],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainHomeGenreCreatorSection_Previews: PreviewProvider {
|
||||||
|
private static let previewImageUrl = "https://picsum.photos/200/200"
|
||||||
|
|
||||||
|
static var previews: some View {
|
||||||
|
MainHomeGenreCreatorSection(
|
||||||
|
items: [
|
||||||
|
HomeGenreCreatorGroupItem(
|
||||||
|
genreName: I18n.HomeRecommendation.genreCreatorSectionTitle,
|
||||||
|
creators: (1...8).map {
|
||||||
|
HomeCreatorItem(
|
||||||
|
creatorId: $0,
|
||||||
|
creatorNickname: "크리에이터이름",
|
||||||
|
creatorProfileImage: previewImageUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.padding(.vertical, SodaSpacing.s20)
|
||||||
|
.background(Color.black)
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainHomeLiveSection: View {
|
||||||
|
let items: [HomeLiveItem]
|
||||||
|
let onTapLive: (Int) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !items.isEmpty {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
LazyHStack(alignment: .top, spacing: SodaSpacing.s12) {
|
||||||
|
ForEach(items, id: \.self) { item in
|
||||||
|
MainHomeLiveItemView(item: item, onTapLive: onTapLive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, SodaSpacing.s20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MainHomeLiveItemView: View {
|
||||||
|
let item: HomeLiveItem
|
||||||
|
let onTapLive: (Int) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
onTapLive(item.roomId)
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .center, spacing: SodaSpacing.s6) {
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
DownsampledKFImage(url: URL(string: item.creatorProfileImage), size: CGSize(width: 62, height: 62))
|
||||||
|
.background(Color.gray800)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.padding(SodaSpacing.s4)
|
||||||
|
.overlay {
|
||||||
|
Circle()
|
||||||
|
.strokeBorder(Color.button, lineWidth: 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
Image("img_live")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 50, height: 18)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(item.creatorNickname)
|
||||||
|
.appFont(.body4)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
.frame(width: 70, height: 102, alignment: .top)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainHomeLiveSection_Previews: PreviewProvider {
|
||||||
|
private static let previewImageUrl = "https://picsum.photos/500/500"
|
||||||
|
|
||||||
|
static var previews: some View {
|
||||||
|
Group {
|
||||||
|
MainHomeLiveItemView(
|
||||||
|
item: HomeLiveItem(roomId: 1, creatorNickname: "크리에이...", creatorProfileImage: previewImageUrl),
|
||||||
|
onTapLive: { _ in }
|
||||||
|
)
|
||||||
|
.background(Color.black)
|
||||||
|
.previewLayout(.fixed(width: 70, height: 102))
|
||||||
|
.previewDisplayName("Live Item")
|
||||||
|
|
||||||
|
MainHomeLiveSection(
|
||||||
|
items: [
|
||||||
|
HomeLiveItem(roomId: 1, creatorNickname: "설린", creatorProfileImage: previewImageUrl),
|
||||||
|
HomeLiveItem(roomId: 2, creatorNickname: "크리에이터", creatorProfileImage: previewImageUrl)
|
||||||
|
],
|
||||||
|
onTapLive: { _ in }
|
||||||
|
)
|
||||||
|
.padding(.vertical, SodaSpacing.s20)
|
||||||
|
.background(Color.black)
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
|
.previewDisplayName("Live Section")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainHomePopularCommunitySection: View {
|
||||||
|
let items: [HomePopularCommunityPostItem]
|
||||||
|
let onTapCommunity: (Int) -> Void
|
||||||
|
let onTapLike: (Int) -> Void
|
||||||
|
let onTapPurchase: (HomePopularCommunityPostItem) -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
items: [HomePopularCommunityPostItem],
|
||||||
|
onTapCommunity: @escaping (Int) -> Void = { _ in },
|
||||||
|
onTapLike: @escaping (Int) -> Void = { _ in },
|
||||||
|
onTapPurchase: @escaping (HomePopularCommunityPostItem) -> Void = { _ in }
|
||||||
|
) {
|
||||||
|
self.items = items
|
||||||
|
self.onTapCommunity = onTapCommunity
|
||||||
|
self.onTapLike = onTapLike
|
||||||
|
self.onTapPurchase = onTapPurchase
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !items.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: SodaSpacing.s14) {
|
||||||
|
SectionTitle(title: I18n.HomeRecommendation.popularCommunitySectionTitle)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: SodaSpacing.s8) {
|
||||||
|
ForEach(items, id: \.postId) { item in
|
||||||
|
CommunityPostCard(
|
||||||
|
postId: item.postId,
|
||||||
|
creatorNickname: item.creatorNickname,
|
||||||
|
creatorProfileImageUrl: item.creatorProfileImage,
|
||||||
|
content: item.content,
|
||||||
|
imageUrl: item.imageUrl,
|
||||||
|
audioUrl: item.audioUrl,
|
||||||
|
price: item.price,
|
||||||
|
existOrdered: item.existOrdered,
|
||||||
|
createdAt: item.relativeCreatedAtText(),
|
||||||
|
isLike: item.isLiked,
|
||||||
|
likeCount: item.likeCount,
|
||||||
|
commentCount: item.commentCount,
|
||||||
|
onTapLike: {
|
||||||
|
onTapLike(item.postId)
|
||||||
|
},
|
||||||
|
onTapComment: {
|
||||||
|
onTapCommunity(item.creatorId)
|
||||||
|
},
|
||||||
|
onTapPurchase: {
|
||||||
|
onTapPurchase(item)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, SodaSpacing.s14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainHomePopularCommunitySection_Previews: PreviewProvider {
|
||||||
|
private static let previewImageUrl = "https://picsum.photos/500/500"
|
||||||
|
|
||||||
|
static var previews: some View {
|
||||||
|
MainHomePopularCommunitySection(
|
||||||
|
items: [
|
||||||
|
HomePopularCommunityPostItem(
|
||||||
|
postId: 1,
|
||||||
|
creatorId: 10,
|
||||||
|
creatorNickname: "크리에이터 이름",
|
||||||
|
creatorProfileImage: previewImageUrl,
|
||||||
|
content: "크리에이터가 커뮤니티에 올린 글이 보이는 부분 크리에이터가 커뮤니티에 올린 글이 보이는 부분",
|
||||||
|
imageUrl: nil,
|
||||||
|
audioUrl: nil,
|
||||||
|
price: 0,
|
||||||
|
existOrdered: false,
|
||||||
|
likeCount: 5,
|
||||||
|
commentCount: 6,
|
||||||
|
createdAt: "2026-06-29T12:00:00Z",
|
||||||
|
isLiked: false
|
||||||
|
),
|
||||||
|
HomePopularCommunityPostItem(
|
||||||
|
postId: 2,
|
||||||
|
creatorId: 11,
|
||||||
|
creatorNickname: "크리에이터 이름",
|
||||||
|
creatorProfileImage: previewImageUrl,
|
||||||
|
content: "이미지가 있는 커뮤니티 게시글입니다.",
|
||||||
|
imageUrl: previewImageUrl,
|
||||||
|
audioUrl: "https://example.com/audio.mp3",
|
||||||
|
price: 30,
|
||||||
|
existOrdered: false,
|
||||||
|
likeCount: 5,
|
||||||
|
commentCount: 6,
|
||||||
|
createdAt: "2026-06-29T12:00:00Z",
|
||||||
|
isLiked: true
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.padding(.vertical, SodaSpacing.s20)
|
||||||
|
.background(Color.black)
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainHomeRecentDebutCreatorSection: View {
|
||||||
|
let items: [HomeCreatorItem]
|
||||||
|
let onTapCreator: (Int) -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
items: [HomeCreatorItem],
|
||||||
|
onTapCreator: @escaping (Int) -> Void = { _ in }
|
||||||
|
) {
|
||||||
|
self.items = items
|
||||||
|
self.onTapCreator = onTapCreator
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !items.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: SodaSpacing.s14) {
|
||||||
|
SectionTitle(title: I18n.HomeRecommendation.recentDebutCreatorSectionTitle)
|
||||||
|
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
LazyHStack(alignment: .center, spacing: SodaSpacing.s4) {
|
||||||
|
ForEach(items, id: \.self) { item in
|
||||||
|
MainHomeRecentDebutCreatorItemView(item: item) {
|
||||||
|
onTapCreator(item.creatorId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, SodaSpacing.s14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MainHomeRecentDebutCreatorItemView: View {
|
||||||
|
let item: HomeCreatorItem
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let itemSize = AiCharacterCard.responsiveSize()
|
||||||
|
|
||||||
|
Button(action: action) {
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
DownsampledKFImage(
|
||||||
|
url: URL(string: item.creatorProfileImage),
|
||||||
|
size: itemSize
|
||||||
|
)
|
||||||
|
.frame(width: itemSize.width, height: itemSize.height)
|
||||||
|
.background(Color.gray800)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||||
|
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.black.opacity(0), Color.black.opacity(0.7)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
.frame(height: min(80, itemSize.height))
|
||||||
|
|
||||||
|
Text(item.creatorNickname)
|
||||||
|
.appFont(.heading4)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
.padding(.horizontal, SodaSpacing.s12)
|
||||||
|
.padding(.bottom, SodaSpacing.s20)
|
||||||
|
}
|
||||||
|
.frame(width: itemSize.width, height: itemSize.height)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainHomeRecentDebutCreatorSection_Previews: PreviewProvider {
|
||||||
|
private static let previewImageUrl = "https://picsum.photos/500/700"
|
||||||
|
|
||||||
|
static var previews: some View {
|
||||||
|
MainHomeRecentDebutCreatorSection(
|
||||||
|
items: [
|
||||||
|
HomeCreatorItem(
|
||||||
|
creatorId: 1,
|
||||||
|
creatorNickname: "크리에이터 이름",
|
||||||
|
creatorProfileImage: previewImageUrl
|
||||||
|
),
|
||||||
|
HomeCreatorItem(
|
||||||
|
creatorId: 2,
|
||||||
|
creatorNickname: "크리에이터 이름",
|
||||||
|
creatorProfileImage: previewImageUrl
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.padding(.vertical, SodaSpacing.s20)
|
||||||
|
.background(Color.black)
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainHomeRecommendationView: View {
|
||||||
|
let onTapLive: (Int) -> Void
|
||||||
|
let onTapCreator: (Int) -> Void
|
||||||
|
let onTapContent: (Int) -> Void
|
||||||
|
let onTapCharacter: (Int) -> Void
|
||||||
|
let onTapCommunity: (Int) -> Void
|
||||||
|
let onTapBanner: (RecommendationBannerResponse) -> Void
|
||||||
|
let onTapFollowAll: (@escaping () -> Void) -> Void
|
||||||
|
|
||||||
|
@StateObject private var viewModel = MainHomeRecommendationViewModel()
|
||||||
|
@StateObject private var communityMediaPlayer = CreatorCommunityMediaPlayerManager.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
if viewModel.isLoading && viewModel.recommendations == nil {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
} else {
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
VStack(alignment: .leading, spacing: SodaSpacing.s24) {
|
||||||
|
MainHomeLiveSection(
|
||||||
|
items: viewModel.recommendations?.lives ?? [],
|
||||||
|
onTapLive: onTapLive
|
||||||
|
)
|
||||||
|
|
||||||
|
MainHomeBannerSection(
|
||||||
|
items: viewModel.recommendations?.banners ?? [],
|
||||||
|
onTapBanner: onTapBanner
|
||||||
|
)
|
||||||
|
|
||||||
|
MainHomeActiveCreatorSection(
|
||||||
|
items: viewModel.recommendations?.recentlyActiveCreators ?? [],
|
||||||
|
onTapLive: onTapLive,
|
||||||
|
onTapContent: onTapContent,
|
||||||
|
onTapCommunity: onTapCommunity
|
||||||
|
)
|
||||||
|
|
||||||
|
MainHomeRecentDebutCreatorSection(
|
||||||
|
items: viewModel.recommendations?.recentDebutCreators ?? [],
|
||||||
|
onTapCreator: onTapCreator
|
||||||
|
)
|
||||||
|
|
||||||
|
MainHomeAiCharacterSection(
|
||||||
|
items: viewModel.recommendations?.aiCharacters ?? [],
|
||||||
|
onTapCharacter: onTapCharacter
|
||||||
|
)
|
||||||
|
|
||||||
|
MainHomeGenreCreatorSection(
|
||||||
|
items: viewModel.recommendations?.genreCreators ?? [],
|
||||||
|
isFollowAllCompleted: viewModel.isFollowAllCompleted,
|
||||||
|
isFollowAllLoading: viewModel.isFollowAllLoading,
|
||||||
|
onTapCreator: onTapCreator,
|
||||||
|
onTapFollowAll: { creatorIds, completionKey in
|
||||||
|
onTapFollowAll {
|
||||||
|
viewModel.followAll(creatorIds: creatorIds, completionKey: completionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
MainHomeCheerCreatorSection(
|
||||||
|
items: viewModel.recommendations?.cheerCreators ?? [],
|
||||||
|
isFollowAllCompleted: viewModel.isFollowAllCompleted("cheer"),
|
||||||
|
isFollowAllLoading: viewModel.isFollowAllLoading("cheer"),
|
||||||
|
onTapCreator: onTapCreator,
|
||||||
|
onTapFollowAll: {
|
||||||
|
onTapFollowAll {
|
||||||
|
viewModel.followAll(
|
||||||
|
creatorIds: viewModel.recommendations?.cheerCreators.map(\.creatorId) ?? [],
|
||||||
|
completionKey: "cheer"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
MainHomePopularCommunitySection(
|
||||||
|
items: viewModel.recommendations?.popularCommunityPosts ?? [],
|
||||||
|
onTapCommunity: onTapCommunity,
|
||||||
|
onTapLike: { postId in
|
||||||
|
viewModel.likeCommunityPost(postId: postId)
|
||||||
|
},
|
||||||
|
onTapPurchase: { item in
|
||||||
|
viewModel.openCommunityPostPurchase(postId: item.postId, price: item.price)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
MainHomeBusinessInfoSection()
|
||||||
|
}
|
||||||
|
.padding(.vertical, SodaSpacing.s20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if viewModel.recommendations == nil {
|
||||||
|
viewModel.fetchRecommendations()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sodaToast(
|
||||||
|
isPresented: $viewModel.isShowPopup,
|
||||||
|
message: viewModel.errorMessage,
|
||||||
|
autohideIn: 2
|
||||||
|
)
|
||||||
|
.sodaToast(
|
||||||
|
isPresented: $communityMediaPlayer.isShowPopup,
|
||||||
|
message: communityMediaPlayer.errorMessage,
|
||||||
|
autohideIn: 2
|
||||||
|
)
|
||||||
|
.overlay {
|
||||||
|
if viewModel.isShowCommunityPostPurchaseDialog {
|
||||||
|
CommunityPostPurchaseDialog(
|
||||||
|
isShowing: $viewModel.isShowCommunityPostPurchaseDialog,
|
||||||
|
can: viewModel.purchasingCommunityPostPrice,
|
||||||
|
confirmAction: viewModel.purchaseCommunityPost
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if communityMediaPlayer.isLoading {
|
||||||
|
LoadingView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainHomeRecommendationView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
MainHomeRecommendationView(
|
||||||
|
onTapLive: { _ in },
|
||||||
|
onTapCreator: { _ in },
|
||||||
|
onTapContent: { _ in },
|
||||||
|
onTapCharacter: { _ in },
|
||||||
|
onTapCommunity: { _ in },
|
||||||
|
onTapBanner: { _ in },
|
||||||
|
onTapFollowAll: { action in action() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
final class MainHomeViewModel: ObservableObject {
|
final class MainHomeRecommendationViewModel: ObservableObject {
|
||||||
private let repository = MainHomeRepository()
|
private let repository = MainHomeRecommendationRepository()
|
||||||
|
private let communityRepository = CreatorCommunityRepository()
|
||||||
private var subscription = Set<AnyCancellable>()
|
private var subscription = Set<AnyCancellable>()
|
||||||
|
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var isShowPopup = false
|
@Published var isShowPopup = false
|
||||||
@Published var errorMessage = ""
|
@Published var errorMessage = ""
|
||||||
@Published var recommendations: HomeRecommendationResponse?
|
@Published var recommendations: HomeRecommendationResponse?
|
||||||
|
@Published var isShowCommunityPostPurchaseDialog = false
|
||||||
|
@Published var purchasingCommunityPostPrice = 0
|
||||||
@Published private(set) var completedFollowKeys = Set<String>()
|
@Published private(set) var completedFollowKeys = Set<String>()
|
||||||
@Published private(set) var followingKeys = Set<String>()
|
@Published private(set) var followingKeys = Set<String>()
|
||||||
|
private var purchasingCommunityPostId = 0
|
||||||
|
|
||||||
func fetchRecommendations() {
|
func fetchRecommendations() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
@@ -104,4 +108,77 @@ final class MainHomeViewModel: ObservableObject {
|
|||||||
func isFollowAllLoading(_ completionKey: String) -> Bool {
|
func isFollowAllLoading(_ completionKey: String) -> Bool {
|
||||||
followingKeys.contains(completionKey)
|
followingKeys.contains(completionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func likeCommunityPost(postId: Int) {
|
||||||
|
guard postId > 0 else { return }
|
||||||
|
|
||||||
|
communityRepository.communityPostLike(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.errorMessage = I18n.Common.commonError
|
||||||
|
self.isShowPopup = true
|
||||||
|
}
|
||||||
|
} receiveValue: { _ in }
|
||||||
|
.store(in: &subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openCommunityPostPurchase(postId: Int, price: Int) {
|
||||||
|
guard postId > 0, price > 0 else { return }
|
||||||
|
|
||||||
|
purchasingCommunityPostId = postId
|
||||||
|
purchasingCommunityPostPrice = price
|
||||||
|
isShowCommunityPostPurchaseDialog = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func purchaseCommunityPost() {
|
||||||
|
let postId = purchasingCommunityPostId
|
||||||
|
guard postId > 0, !isLoading 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.errorMessage = I18n.Common.commonError
|
||||||
|
self.isShowPopup = true
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] response in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
let responseData = response.data
|
||||||
|
|
||||||
|
do {
|
||||||
|
let jsonDecoder = JSONDecoder()
|
||||||
|
let decoded = try jsonDecoder.decode(ApiResponse<GetCommunityPostListResponse>.self, from: responseData)
|
||||||
|
|
||||||
|
if decoded.success {
|
||||||
|
self.purchasingCommunityPostId = 0
|
||||||
|
self.purchasingCommunityPostPrice = 0
|
||||||
|
self.fetchRecommendations()
|
||||||
|
} else {
|
||||||
|
self.errorMessage = decoded.message ?? I18n.Common.commonError
|
||||||
|
self.isShowPopup = true
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.errorMessage = I18n.Common.commonError
|
||||||
|
self.isShowPopup = true
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &subscription)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct HomeRecommendationResponse: Decodable {
|
||||||
|
let lives: [HomeLiveItem]
|
||||||
|
let banners: [RecommendationBannerResponse]
|
||||||
|
let recentlyActiveCreators: [HomeActiveCreatorItem]
|
||||||
|
let recentDebutCreators: [HomeCreatorItem]
|
||||||
|
let firstAudioContents: [HomeFirstAudioContentItem]
|
||||||
|
let aiCharacters: [HomeAiCharacterItem]
|
||||||
|
let genreCreators: [HomeGenreCreatorGroupItem]
|
||||||
|
let cheerCreators: [HomeCreatorItem]
|
||||||
|
let popularCommunityPosts: [HomePopularCommunityPostItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HomeLiveItem: Decodable, Hashable {
|
||||||
|
let roomId: Int
|
||||||
|
let creatorNickname: String
|
||||||
|
let creatorProfileImage: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RecommendationBannerResponse: Decodable, Hashable {
|
||||||
|
let imageUrl: String
|
||||||
|
let eventItem: EventItem?
|
||||||
|
let creatorId: Int?
|
||||||
|
let seriesId: Int?
|
||||||
|
let link: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HomeActiveCreatorItem: Decodable, Hashable {
|
||||||
|
let creatorNickname: String
|
||||||
|
let creatorProfileImage: String
|
||||||
|
let activityType: RecommendedActivityType
|
||||||
|
let activityAt: String
|
||||||
|
let targetId: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HomeCreatorItem: Decodable, Hashable {
|
||||||
|
let creatorId: Int
|
||||||
|
let creatorNickname: String
|
||||||
|
let creatorProfileImage: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HomeFirstAudioContentItem: Decodable, Hashable {
|
||||||
|
let contentId: Int
|
||||||
|
let creatorId: Int
|
||||||
|
let creatorNickname: String
|
||||||
|
let creatorProfileImage: String
|
||||||
|
let title: String
|
||||||
|
let price: Int
|
||||||
|
let coverImage: String?
|
||||||
|
let isPointAvailable: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HomeAiCharacterItem: Decodable, Hashable {
|
||||||
|
let characterId: Int
|
||||||
|
let creatorId: Int
|
||||||
|
let name: String
|
||||||
|
let description: String
|
||||||
|
let profileImage: String?
|
||||||
|
let totalChatCount: Int
|
||||||
|
let originalWorkTitle: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HomeGenreCreatorGroupItem: Decodable, Hashable {
|
||||||
|
let genreName: String
|
||||||
|
let creators: [HomeCreatorItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HomePopularCommunityPostItem: Decodable, Hashable {
|
||||||
|
let postId: Int
|
||||||
|
let creatorId: Int
|
||||||
|
let creatorNickname: String
|
||||||
|
let creatorProfileImage: String?
|
||||||
|
let content: String
|
||||||
|
let imageUrl: String?
|
||||||
|
let audioUrl: String?
|
||||||
|
let price: Int
|
||||||
|
let existOrdered: Bool
|
||||||
|
let likeCount: Int
|
||||||
|
let commentCount: Int
|
||||||
|
let createdAt: String
|
||||||
|
let isLiked: Bool
|
||||||
|
|
||||||
|
func relativeCreatedAtText(now: Date = Date()) -> String {
|
||||||
|
DateParser.relativeTimeText(fromUTC: createdAt, fallback: createdAt, now: now)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Moya
|
import Moya
|
||||||
|
|
||||||
enum MainHomeApi {
|
enum MainHomeRecommendationApi {
|
||||||
case getRecommendations
|
case getRecommendations
|
||||||
case followRecommendedCreators(request: FollowRecommendedCreatorsRequest)
|
case followRecommendedCreators(request: FollowRecommendedCreatorsRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension MainHomeApi: TargetType {
|
extension MainHomeRecommendationApi: TargetType {
|
||||||
var baseURL: URL {
|
var baseURL: URL {
|
||||||
return URL(string: BASE_URL)!
|
return URL(string: BASE_URL)!
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,8 @@ import CombineMoya
|
|||||||
import Combine
|
import Combine
|
||||||
import Moya
|
import Moya
|
||||||
|
|
||||||
final class MainHomeRepository {
|
final class MainHomeRecommendationRepository {
|
||||||
private let api = MoyaProvider<MainHomeApi>()
|
private let api = MoyaProvider<MainHomeRecommendationApi>()
|
||||||
|
|
||||||
func getRecommendations() -> AnyPublisher<Response, MoyaError> {
|
func getRecommendations() -> AnyPublisher<Response, MoyaError> {
|
||||||
return api.requestPublisher(.getRecommendations)
|
return api.requestPublisher(.getRecommendations)
|
||||||
@@ -153,7 +153,18 @@ struct MainView: View {
|
|||||||
private var contentView: some View {
|
private var contentView: some View {
|
||||||
switch viewModel.currentTab {
|
switch viewModel.currentTab {
|
||||||
case .home:
|
case .home:
|
||||||
MainPlaceholderTabView(title: MainTab.home.title)
|
MainHomeView(
|
||||||
|
onTapCanCharge: handleHomeCanChargeTap,
|
||||||
|
onTapSearch: handleHomeSearchTap,
|
||||||
|
onTapNotificationList: handleHomeNotificationListTap,
|
||||||
|
onTapLive: handleRecommendationLiveTap,
|
||||||
|
onTapCreator: handleRecommendationCreatorTap,
|
||||||
|
onTapContent: handleRecommendationContentTap,
|
||||||
|
onTapCharacter: handleRecommendationCharacterTap,
|
||||||
|
onTapCommunity: handleRecommendationCommunityTap,
|
||||||
|
onTapBanner: handleRecommendationBannerTap,
|
||||||
|
onTapFollowAll: handleRecommendationFollowAllTap
|
||||||
|
)
|
||||||
case .content:
|
case .content:
|
||||||
MainPlaceholderTabView(title: MainTab.content.title)
|
MainPlaceholderTabView(title: MainTab.content.title)
|
||||||
case .chat:
|
case .chat:
|
||||||
@@ -416,6 +427,151 @@ struct MainView: View {
|
|||||||
navigationAction()
|
navigationAction()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleHomeCanChargeTap() {
|
||||||
|
appState.setAppStep(step: .canCharge(refresh: {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleHomeSearchTap() {
|
||||||
|
appState.setAppStep(step: .search)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleHomeNotificationListTap() {
|
||||||
|
appState.setAppStep(step: .notificationList)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleRecommendationLiveTap(roomId: Int) {
|
||||||
|
guard roomId > 0 else { return }
|
||||||
|
performRecommendationDetailAction {
|
||||||
|
handleExternalNavigationRequest(
|
||||||
|
value: roomId,
|
||||||
|
navigationAction: {
|
||||||
|
liveViewModel.enterLiveRoom(roomId: roomId)
|
||||||
|
},
|
||||||
|
cancelAction: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleRecommendationCreatorTap(creatorId: Int) {
|
||||||
|
guard creatorId > 0 else { return }
|
||||||
|
performRecommendationDetailAction {
|
||||||
|
appState.setAppStep(step: .creatorDetail(userId: creatorId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleRecommendationContentTap(contentId: Int) {
|
||||||
|
guard contentId > 0 else { return }
|
||||||
|
performRecommendationDetailAction {
|
||||||
|
appState.setAppStep(step: .contentDetail(contentId: contentId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleRecommendationCharacterTap(characterId: Int) {
|
||||||
|
guard characterId > 0 else { return }
|
||||||
|
performRecommendationDetailAction {
|
||||||
|
appState.setAppStep(step: .characterDetail(characterId: characterId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleRecommendationCommunityTap(creatorId: Int) {
|
||||||
|
guard creatorId > 0 else { return }
|
||||||
|
performRecommendationDetailAction {
|
||||||
|
appState.setAppStep(step: .creatorCommunityAll(creatorId: creatorId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleRecommendationFollowAllTap(action: @escaping () -> Void) {
|
||||||
|
performRecommendationDetailAction(action: action)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private func handleRecommendationBannerTap(_ item: RecommendationBannerResponse) {
|
||||||
|
if let eventItem = item.eventItem {
|
||||||
|
performRecommendationDetailAction {
|
||||||
|
appState.setAppStep(step: .eventDetail(event: eventItem))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let creatorId = item.creatorId {
|
||||||
|
handleRecommendationCreatorTap(creatorId: creatorId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let seriesId = item.seriesId {
|
||||||
|
performRecommendationDetailAction {
|
||||||
|
appState.setAppStep(step: .seriesDetail(seriesId: seriesId))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
openRecommendationBannerLink(item.link)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openRecommendationBannerLink(_ link: String?) {
|
||||||
|
guard let link = link?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!link.isEmpty,
|
||||||
|
let url = URL(string: link),
|
||||||
|
UIApplication.shared.canOpenURL(url) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performRecommendationDetailAction(
|
||||||
|
requiresAdultContent: Bool = false,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else {
|
||||||
|
AppState.shared.setAppStep(step: .login)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if requiresAdultContent {
|
||||||
|
let normalizedCountryCode = UserDefaults
|
||||||
|
.string(forKey: .countryCode)
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.uppercased()
|
||||||
|
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
|
||||||
|
|
||||||
|
if isKoreanCountry && auth == false {
|
||||||
|
pendingAction = action
|
||||||
|
isShowAuthConfirmView = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !UserDefaults.isAdultContentVisible() {
|
||||||
|
pendingAction = nil
|
||||||
|
moveToContentSettingsWithGuideToast()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openLiveDetail(roomId: Int) {
|
||||||
|
appState.setAppStep(
|
||||||
|
step: .liveDetail(
|
||||||
|
roomId: roomId,
|
||||||
|
onClickParticipant: {
|
||||||
|
appState.isShowPlayer = false
|
||||||
|
liveViewModel.enterLiveRoom(roomId: roomId)
|
||||||
|
},
|
||||||
|
onClickReservation: {},
|
||||||
|
onClickStart: {},
|
||||||
|
onClickCancel: {}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func moveToContentSettingsWithGuideToast() {
|
||||||
|
AppState.shared.setPendingContentSettingsGuideMessage(I18n.Settings.adultContentEnableGuide)
|
||||||
|
AppState.shared.setAppStep(step: .contentViewSettings)
|
||||||
|
}
|
||||||
|
|
||||||
private func confirmExternalNavigation() {
|
private func confirmExternalNavigation() {
|
||||||
guard pendingExternalNavigationAction != nil else {
|
guard pendingExternalNavigationAction != nil else {
|
||||||
isShowLeaveLiveNavigationDialog = false
|
isShowLeaveLiveNavigationDialog = false
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,48 +1,58 @@
|
|||||||
# PRD: 메인 홈 추천 UI와 API 연동
|
# PRD: 메인 홈 추천 탭 UI와 API 연동
|
||||||
|
|
||||||
## 1. Overview
|
## 1. Overview
|
||||||
메인 홈의 `추천` 탭을 Figma 디자인 기준으로 구성하고, 신규 홈 추천 API(`/api/v2/home/recommendations`)와 모두 팔로우 API(`/api/v2/home/recommendations/creators/follow`)를 연동한다.
|
메인 홈 화면의 `추천` 탭을 Figma 디자인 기준으로 구성하고, 신규 API `GET /api/v2/home/recommendations` 응답을 섹션별 UI에 연동한다.
|
||||||
|
|
||||||
|
기존 홈 API에 필드를 추가하지 않고 홈 추천 전용 API, Repository, ViewModel, 화면 조립 계층을 새로 만든다. `MainHomeView`는 홈 상단 공통 shell과 추천/랭킹/팔로잉 탭 전환만 담당하고, 탭 아래 콘텐츠는 `Recommendation`, `Ranking`, `Following` 하위 폴더의 전용 View로 분리한다. 이미 생성된 V2 공용 widget과 저장소 컴포넌트로 처리 가능한 UI는 재사용하고, 추천 탭에서만 쓰이는 조립 UI는 `SodaLive/Sources/V2/Main/Home/Recommendation/Components/**` 아래에 둔다.
|
||||||
|
|
||||||
## 2. Problem
|
## 2. Problem
|
||||||
- 기존 홈 API에 추천 탭 전용 응답을 추가하지 않고 신규 V2 API로 분리해야 한다.
|
- 추천 탭 전용 데이터가 기존 홈 API와 섞이면 응답/화면 책임이 커지고 회귀 위험이 커진다.
|
||||||
- Figma 기준 추천 탭에는 라이브, 배너, 최근 활동 크리에이터, 데뷔/첫 오디오/AI 캐릭터/장르/응원/커뮤니티/사업자 정보 등 여러 반복 UI가 포함되어 있다.
|
- Figma 추천 탭은 라이브, 배너, 최근 활동, 최근 데뷔, AI 캐릭터, 장르 크리에이터, 응원 크리에이터, 인기 커뮤니티, 사업자 정보 등 여러 독립 섹션으로 구성된다.
|
||||||
- 반복 UI가 많아 단일 화면에 직접 구현하면 유지보수 비용이 커진다.
|
- 섹션별 UI가 많으므로 한 화면 파일에 직접 구현하면 유지보수와 검증이 어려워진다.
|
||||||
- 사업자 정보 텍스트는 외부 라이브러리 없이 3줄 말줄임표와 더보기/접기 토글을 제공해야 한다.
|
- `FeedCommunityView`는 추천 탭 요구에 맞춰 키워드 영역 제거, 이미지 표시, 오디오 재생, 유료/구매 상태 UI가 필요하다.
|
||||||
|
- 사업자 정보는 외부 라이브러리 없이 최대 3줄 말줄임표, 더보기, 접기 동작을 제공해야 한다.
|
||||||
|
|
||||||
## 3. Goals
|
## 3. Goals
|
||||||
- `추천` 탭 진입 시 `/api/v2/home/recommendations`를 호출하고 응답 데이터로 화면을 구성한다.
|
- `추천` 탭 진입 시 `GET /api/v2/home/recommendations`를 호출하고 응답 데이터로 화면을 구성한다.
|
||||||
- 기존 `HomeApi`에 케이스를 추가하지 않고 신규 API/Repository/ViewModel 계층을 만든다.
|
- 기존 API에 추천 endpoint를 추가하지 않고 신규 `MainHomeApi`, Repository, ViewModel 계층을 만든다.
|
||||||
- Figma에서 확인한 기존 widget과 저장소 내 V2 공용 컴포넌트를 가능한 범위에서 재사용한다.
|
- Figma 확인 시 이미 생성된 widget이나 기존 V2 공용 컴포넌트로 처리 가능한 부분은 재사용한다.
|
||||||
- 반복되는 UI는 Custom Widget으로 분리해 재사용 가능하게 한다.
|
- `MainHomeView`를 추천/랭킹/팔로잉 탭 shell로 유지하고, 추천 탭 콘텐츠는 `MainHomeRecommendationView`에서 조립한다.
|
||||||
- 모두 팔로우 API 호출이 정상 완료되고 `success == true`이면 버튼 문구를 `모두 팔로우 완료`로 변경한다.
|
- 랭킹/팔로잉 탭은 `MainHomeRankingView`, `MainHomeFollowingView`로 별도 파일/폴더를 준비해 이후 구현이 MainHome shell에 누적되지 않게 한다.
|
||||||
- `RecommendedActivityType` 서버 enum 값은 앱 enum으로 변환하고 다국어 문구로 표시한다.
|
- 섹션별 UI를 작은 단위의 Custom Widget으로 분리한다.
|
||||||
- 빈 섹션 데이터는 제목과 빈 컨테이너를 노출하지 않아 화면 밀도를 유지한다.
|
- `RecommendedActivityType` 서버 enum 값을 앱 enum으로 변환하고 I18n 문구로 표시한다.
|
||||||
|
- 빈 데이터 섹션은 제목과 컨테이너를 표시하지 않는다.
|
||||||
|
- 사업자 정보 섹션은 화면 width를 채우고, 기본 3줄 말줄임표와 더보기/접기 전환을 지원한다.
|
||||||
|
- `FeedCommunityView`는 추천 탭 요구에 맞게 세로 피드형 이미지/오디오/유료 커뮤니티 포스트 UI를 지원한다.
|
||||||
|
|
||||||
## 4. Non-Goals
|
## 4. Non-Goals
|
||||||
- `추천 필모그래피` 섹션은 만들지 않는다.
|
- `추천 필모그래피` 섹션은 만들지 않는다.
|
||||||
- `또 다른 모습` 섹션은 만들지 않는다.
|
- `또 다른 모습` 섹션은 만들지 않는다.
|
||||||
- 기존 홈 API(`/api/home`) 또는 기존 `HomeApi`에 추천 API를 추가하지 않는다.
|
- 기존 홈 API 또는 기존 `HomeApi`에 `/api/v2/home/recommendations`를 추가하지 않는다.
|
||||||
- 외부 라이브러리를 추가하지 않는다.
|
- 외부 라이브러리를 추가하지 않는다.
|
||||||
- 서버 응답 스펙에 없는 팔로우 개별 상태, 페이지네이션, 정렬/필터 기능은 이번 범위에 포함하지 않는다.
|
- Figma 로컬 asset URL 또는 Figma 웹 URL을 앱 코드에 직접 사용하지 않는다.
|
||||||
- Figma 로컬 asset URL을 앱 코드에 직접 사용하지 않는다.
|
- 서버 응답 스펙에 없는 개별 팔로우 상태, 페이지네이션, 정렬/필터 기능은 이번 범위에 포함하지 않는다.
|
||||||
|
- Figma `node-id=309-19775`의 `구매완료` 버튼은 구현하지 않는다.
|
||||||
|
|
||||||
## 5. Target Users
|
## 5. Target Users
|
||||||
- 앱 홈에서 추천 크리에이터, 라이브, 콘텐츠, 커뮤니티를 빠르게 탐색하는 일반 사용자
|
- 앱 홈에서 추천 라이브, 크리에이터, 콘텐츠, 커뮤니티를 빠르게 탐색하는 사용자
|
||||||
- 추천된 크리에이터 그룹을 한 번에 팔로우하려는 사용자
|
- 최근 활동 타입과 추천 콘텐츠 상태를 현재 앱 언어와 디자인에 맞게 확인하려는 사용자
|
||||||
|
- 사업자 정보를 짧게 확인하고 필요할 때 전체 내용을 펼쳐 보려는 사용자
|
||||||
|
|
||||||
## 6. User Stories
|
## 6. User Stories
|
||||||
- 사용자는 홈 `추천` 탭에서 현재 라이브 중인 크리에이터와 추천 콘텐츠를 한 화면에서 보고 싶다.
|
- 사용자는 홈 `추천` 탭에서 현재 라이브 중인 크리에이터와 추천 콘텐츠를 한 화면에서 보고 싶다.
|
||||||
- 사용자는 관심 있는 장르/응원 크리에이터 그룹을 한 번에 팔로우하고 싶다.
|
- 사용자는 최근 활동한 크리에이터의 활동 타입을 `라이브`, `오디오`, `커뮤니티`처럼 이해 가능한 문구로 보고 싶다.
|
||||||
- 사용자는 사업자 정보를 기본적으로 짧게 보고, 필요할 때 전체 내용을 펼쳐 보고 싶다.
|
- 사용자는 이미지가 포함된 커뮤니티 포스트와 유료/구매 상태를 카드에서 구분하고 싶다.
|
||||||
- 사용자는 최근 활동 타입을 한국어/영어/일본어 등 현재 앱 언어에 맞게 보고 싶다.
|
- 사용자는 사업자 정보를 기본적으로 짧게 보고, 필요할 때 더보기/접기로 전환하고 싶다.
|
||||||
|
- 사용자는 로그인이 필요하거나 민감 콘텐츠 제한이 필요한 상세 화면에 진입할 때 기존 홈과 같은 로그인/본인인증/콘텐츠 보기 설정 안내를 받고 싶다.
|
||||||
|
|
||||||
## 7. Core Features
|
## 7. Core Requirements
|
||||||
|
|
||||||
### 7.1 추천 홈 데이터 조회
|
### 7.1 추천 홈 데이터 조회
|
||||||
|
|
||||||
#### API
|
#### API
|
||||||
- Method: `GET`
|
- Method: `GET`
|
||||||
- Path: `/api/v2/home/recommendations`
|
- Path: `/api/v2/home/recommendations`
|
||||||
|
- 기존 API에 추가하지 않고 신규 API 타입으로 만든다.
|
||||||
- 인증: 기존 인증 헤더 패턴과 동일하게 `Authorization: Bearer {token}` 사용
|
- 인증: 기존 인증 헤더 패턴과 동일하게 `Authorization: Bearer {token}` 사용
|
||||||
- 응답 래퍼: 기존 관례대로 `ApiResponse<HomeRecommendationResponse>` 디코딩
|
- 응답 래퍼: 기존 관례대로 `ApiResponse<HomeRecommendationResponse>` 디코딩
|
||||||
|
|
||||||
@@ -50,42 +60,89 @@
|
|||||||
```kotlin
|
```kotlin
|
||||||
data class HomeRecommendationResponse(
|
data class HomeRecommendationResponse(
|
||||||
val lives: List<HomeLiveItem>,
|
val lives: List<HomeLiveItem>,
|
||||||
val banners: List<HomeBannerItem>,
|
val banners: List<RecommendationBannerResponse>,
|
||||||
val recentlyActiveCreators: List<HomeActiveCreatorItem>,
|
val recentlyActiveCreators: List<HomeActiveCreatorItem>,
|
||||||
val recentDebutCreators: List<HomeCreatorItem>,
|
val recentDebutCreators: List<HomeCreatorItem>,
|
||||||
val firstAudioContents: List<HomeFirstAudioContentItem>,
|
|
||||||
val aiCharacters: List<HomeAiCharacterItem>,
|
val aiCharacters: List<HomeAiCharacterItem>,
|
||||||
val genreCreators: List<HomeGenreCreatorGroupItem>,
|
val genreCreators: List<HomeGenreCreatorGroupItem>,
|
||||||
val cheerCreators: List<HomeCreatorItem>,
|
val cheerCreators: List<HomeCreatorItem>,
|
||||||
val popularCommunityPosts: List<HomePopularCommunityPostItem>
|
val popularCommunityPosts: List<HomePopularCommunityPostItem>
|
||||||
)
|
)
|
||||||
```
|
|
||||||
|
|
||||||
Swift 모델은 위 필드명을 그대로 `Decodable`로 구성한다. 서버 nullable 필드는 Swift optional로 선언한다.
|
data class HomeLiveItem(
|
||||||
|
val roomId: Long,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val creatorProfileImage: String
|
||||||
|
)
|
||||||
|
|
||||||
### 7.2 모두 팔로우 하기
|
data class HomeActiveCreatorItem(
|
||||||
|
val creatorNickname: String,
|
||||||
|
val creatorProfileImage: String,
|
||||||
|
val activityType: String,
|
||||||
|
val activityAt: String,
|
||||||
|
val targetId: Long?
|
||||||
|
)
|
||||||
|
|
||||||
#### API
|
data class HomeCreatorItem(
|
||||||
- Method: `POST`
|
val creatorId: Long,
|
||||||
- Path: `/api/v2/home/recommendations/creators/follow`
|
val creatorNickname: String,
|
||||||
- Request body:
|
val creatorProfileImage: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HomeAiCharacterItem(
|
||||||
|
val characterId: Long,
|
||||||
|
val creatorId: Long,
|
||||||
|
val name: String,
|
||||||
|
val description: String,
|
||||||
|
val profileImage: String?,
|
||||||
|
val totalChatCount: Long,
|
||||||
|
val originalWorkTitle: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HomeGenreCreatorGroupItem(
|
||||||
|
val genreName: String,
|
||||||
|
val creators: List<HomeCreatorItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#### RecommendationBannerResponse
|
||||||
```kotlin
|
```kotlin
|
||||||
data class FollowRecommendedCreatorsRequest(
|
data class RecommendationBannerResponse(
|
||||||
val creatorIds: List<Long>?
|
val imageUrl: String,
|
||||||
|
val eventItem: EventItem?,
|
||||||
|
val creatorId: Long?,
|
||||||
|
val seriesId: Long?,
|
||||||
|
val link: String?
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
- 응답 래퍼: 기존 관례대로 `ApiResponseWithoutData` 디코딩
|
|
||||||
|
|
||||||
#### 동작
|
배너 탭 이동은 `eventItem`, `creatorId`, `seriesId`, `link` 순서로 첫 non-null 값을 사용한다.
|
||||||
- 장르 크리에이터 그룹과 최근 응원이 많은 크리에이터 섹션의 `모두 팔로우하기` 버튼에서 호출한다.
|
- `eventItem` -> 이벤트 상세
|
||||||
- 요청 `creatorIds`는 해당 섹션에 표시된 크리에이터의 `creatorId` 목록을 사용한다.
|
- `creatorId` -> 크리에이터 상세
|
||||||
- API 호출 중 중복 터치를 막는다.
|
- `seriesId` -> 시리즈 상세
|
||||||
- 호출 완료 후 `success == true`이면 해당 섹션 버튼 상태를 완료로 전환한다.
|
- `link` -> 외부 URL 열기
|
||||||
- 완료 상태 버튼 문구는 `모두 팔로우 완료`로 표시한다.
|
|
||||||
- 완료 상태 버튼 아이콘은 `ic_new_following`을 사용한다.
|
|
||||||
- 실패 시 기존 앱 에러 표시 관례에 맞춰 공통 에러 또는 서버 message를 노출하고 버튼 상태는 변경하지 않는다.
|
|
||||||
|
|
||||||
### 7.3 활동 타입 다국어 표시
|
data class HomePopularCommunityPostItem(
|
||||||
|
val postId: Long,
|
||||||
|
val creatorId: Long,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val creatorProfileImage: String?,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val audioUrl: String?,
|
||||||
|
val content: String,
|
||||||
|
val price: Int,
|
||||||
|
val createdAt: String,
|
||||||
|
val likeCount: Long,
|
||||||
|
val commentCount: Long,
|
||||||
|
val isLiked: Boolean,
|
||||||
|
val existOrdered: Boolean
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Swift 모델은 위 필드명을 우선 기준으로 `Decodable`을 구성한다. 서버 nullable 필드는 Swift optional로 선언한다. 현재 코드에 과거 추정 필드명이 이미 있다면 신규 스펙 필드명을 추가하거나 보정하고, 실제 사용 UI는 신규 스펙 필드명을 기준으로 매핑한다.
|
||||||
|
|
||||||
|
### 7.2 활동 타입 다국어 표시
|
||||||
|
|
||||||
서버 enum:
|
서버 enum:
|
||||||
```kotlin
|
```kotlin
|
||||||
@@ -101,200 +158,159 @@ enum class RecommendedActivityType(val code: String) {
|
|||||||
- `LIVE`, `LIVE_REPLAY` -> `I18n.HomeRecommendation.activityLive`
|
- `LIVE`, `LIVE_REPLAY` -> `I18n.HomeRecommendation.activityLive`
|
||||||
- `AUDIO` -> `I18n.HomeRecommendation.activityAudio`
|
- `AUDIO` -> `I18n.HomeRecommendation.activityAudio`
|
||||||
- `COMMUNITY` -> `I18n.HomeRecommendation.activityCommunity`
|
- `COMMUNITY` -> `I18n.HomeRecommendation.activityCommunity`
|
||||||
- 알 수 없는 값은 빈 문자열 또는 서버 코드 직접 표시 대신 해당 아이템의 보조 문구를 숨긴다.
|
- 알 수 없는 값은 서버 코드를 그대로 노출하지 않고 보조 문구를 숨긴다.
|
||||||
|
|
||||||
다국어 기본 문구:
|
다국어 기본 문구:
|
||||||
- ko: `라이브`, `오디오`, `커뮤니티`
|
- ko: `라이브`, `오디오`, `커뮤니티`
|
||||||
- en: `Live`, `Audio`, `Community`
|
- en: `Live`, `Audio`, `Community`
|
||||||
- ja: `ライブ`, `オーディオ`, `コミュニティ`
|
- ja: `ライブ`, `オーディオ`, `コミュニティ`
|
||||||
|
|
||||||
### 7.4 사업자 정보 더보기/접기
|
### 7.3 추천 탭 상세 진입 guard
|
||||||
|
|
||||||
|
기본 방향:
|
||||||
|
- 추천 API 호출과 추천 탭 섹션 노출 자체는 로그인/본인인증/민감 콘텐츠 보기 설정으로 막지 않는다.
|
||||||
|
- guard는 라이브, 크리에이터, 오디오 콘텐츠, AI 캐릭터, 커뮤니티 등 사용자가 상세 화면으로 진입하는 탭 액션 앞에서 적용한다.
|
||||||
|
- 신규 `MainHomeView`에 Bootpay 인증 UI와 인증 상태를 중복 구현하지 않고, V2 `MainView`의 기존 `token`, `auth`, `isShowAuthView`, `isShowAuthConfirmView`, `pendingAction`, `authConfirmDialog`, `authView` 인프라를 재사용한다.
|
||||||
|
|
||||||
|
진입 규칙:
|
||||||
|
- 토큰이 비어 있으면 `AppState.shared.setAppStep(step: .login)`으로 로그인 화면으로 이동한다.
|
||||||
|
- 민감/성인 콘텐츠 진입이 필요한 항목은 한국 사용자이고 `auth == false`이면 원래 이동 액션을 `pendingAction`에 저장하고 본인인증 안내 dialog를 표시한다.
|
||||||
|
- 본인인증 성공 후 `pendingAction`을 실행해 원래 상세 화면으로 이동한다.
|
||||||
|
- 민감 콘텐츠 보기 설정이 꺼져 있으면 `AppState.shared.setPendingContentSettingsGuideMessage(I18n.Settings.adultContentEnableGuide)`로 안내 문구를 예약하고 `AppState.shared.setAppStep(step: .contentViewSettings)`로 이동한다.
|
||||||
|
- 위 조건을 통과한 경우에만 `liveDetail`, `creatorDetail`, `contentDetail`, `characterDetail` 등 실제 상세 이동을 실행한다.
|
||||||
|
- 추천 탭 현재 라이브 아이템은 `roomId`가 항상 존재하는 값으로 취급하고, 아이템 탭 시 상세 화면을 거치지 않고 기존 `LiveViewModel.enterLiveRoom(roomId:)` 흐름을 호출해 방 상세 조회, 입장 전 결제 확인, 비밀방 비밀번호 dialog, `LiveRoomViewV2` 입장을 재사용한다.
|
||||||
|
|
||||||
|
라이브 항목 주의사항:
|
||||||
|
- 기존 홈의 라이브 guard는 `roomId`와 `isAdult`를 함께 받아 성인 여부를 판단한다.
|
||||||
|
- 현재 `HomeLiveItem` 신규 응답 스펙에는 `isAdult`가 없으므로, 라이브 성인 guard를 앱에서 적용하려면 서버 응답에 `isAdult` 또는 동등한 필드가 필요하다.
|
||||||
|
- 해당 필드가 없으면 앱에서 성인 여부를 임의 추정하지 않고, 서버 필터링 전제인지 확인한다.
|
||||||
|
|
||||||
|
### 7.4 FeedCommunityView
|
||||||
|
|
||||||
|
참조 Figma:
|
||||||
|
- 인기 커뮤니티 섹션: `node-id=24-5645`
|
||||||
|
- Text Only Feed: `node-id=567-17875`
|
||||||
|
- Text + Image Feed: `node-id=567-17878`
|
||||||
|
|
||||||
요구사항:
|
요구사항:
|
||||||
|
- 키워드 영역을 제거한다.
|
||||||
|
- 인기 커뮤니티 섹션은 가로 캐러셀이 아니라 세로 피드 목록으로 배치한다.
|
||||||
|
- 커뮤니티 포스트 이미지를 추가한다.
|
||||||
|
- `imageUrl == nil`이면 이미지 영역 없이 본문 중심으로 표시한다.
|
||||||
|
- `imageUrl != nil && price > 0 && existOrdered == false`이면 유료 미구매 상태 UI를 표시한다.
|
||||||
|
- `imageUrl != nil && (price <= 0 || existOrdered == true)`이면 이미지를 일반 노출한다.
|
||||||
|
- `audioUrl`이 있으면 이미지 중앙에 재생/일시정지 버튼을 표시하고 기존 커뮤니티 오디오 재생 흐름을 재사용한다.
|
||||||
|
- 유료 미구매 커뮤니티 포스트는 기존 커뮤니티 구매 API로 구매 가능해야 한다.
|
||||||
|
- `구매완료` 버튼은 표시하지 않는다.
|
||||||
|
- 작성자, 본문, 생성 시간, 좋아요 수, 댓글 수를 표시한다.
|
||||||
|
- 생성 시간은 UTC 값을 디바이스 Timezone 기준 상대 날짜로 표시한다.
|
||||||
|
|
||||||
|
### 7.5 사업자 정보 더보기/접기
|
||||||
|
|
||||||
|
요구사항:
|
||||||
|
- UI 가장 마지막에 사업자 정보 섹션을 배치한다.
|
||||||
|
- width는 화면에 채운다.
|
||||||
- 외부 라이브러리를 사용하지 않는다.
|
- 외부 라이브러리를 사용하지 않는다.
|
||||||
- 기본 상태는 최대 3줄 표시, 말줄임표 적용, `더보기` 액션 제공
|
- 기본 상태는 최대 3줄 표시, 말줄임표 적용, `더보기` 액션 제공
|
||||||
- `더보기` 터치 시 전체 표시로 전환하고 `접기` 액션 제공
|
- `더보기` 터치 시 전체 표시로 전환하고 `접기` 액션 제공
|
||||||
- `접기` 터치 시 다시 3줄 말줄임표 상태로 돌아간다.
|
- `접기` 터치 시 다시 3줄 말줄임표 상태로 돌아간다.
|
||||||
|
- 실제 텍스트가 3줄 이하이면 더보기/접기 버튼은 숨긴다.
|
||||||
|
|
||||||
구현 방향:
|
구현 방향:
|
||||||
- SwiftUI `Text`와 `lineLimit(isExpanded ? nil : 3)`를 사용한다.
|
- SwiftUI `Text`와 `lineLimit(isExpanded ? nil : 3)` 기반으로 구현한다.
|
||||||
- 더보기/접기 버튼은 텍스트 하단 우측 또는 Figma 사업자 정보 섹션 내 자연스러운 위치에 배치한다.
|
- 줄 수 판정은 제한/무제한 높이 비교 또는 Geometry 기반 측정 방식을 사용한다.
|
||||||
- 실제 텍스트가 3줄 이하이면 더보기 버튼은 숨긴다. 줄 수 판정은 Geometry 기반 측정 또는 제한/무제한 높이 비교 방식으로 구현한다.
|
- 사업자 정보 wrapper는 `MainHomeBusinessInfoSection`, 재사용 가능한 텍스트 UI는 `ExpandableTextView`로 분리한다.
|
||||||
|
|
||||||
### 7.5 커뮤니티 포스트 카드
|
|
||||||
|
|
||||||
참조 Figma:
|
|
||||||
- Text Only: `node-id=446-9688`
|
|
||||||
- Text + Img, 유료 + 구매하지 않음: `node-id=446-9690`
|
|
||||||
- Text + Img, 유료/무료 + 구매함: `node-id=446-9691`
|
|
||||||
|
|
||||||
요구사항:
|
|
||||||
- `CommunityPostCard`는 다른 페이지에서도 재사용 가능하도록 `SodaLive/Sources/V2/Component/Card` 아래에 둔다.
|
|
||||||
- 텍스트 전용, 이미지 포함, 유료 이미지 잠금 상태를 지원한다.
|
|
||||||
- `imageUrl == nil`이면 Text Only variant로 표시한다.
|
|
||||||
- `imageUrl != nil && price > 0 && existOrdered == false`이면 이미지 영역에 blur/lock/pay capsule을 표시한다.
|
|
||||||
- `imageUrl != nil && (price <= 0 || existOrdered == true)`이면 이미지를 일반 노출한다.
|
|
||||||
- 유료/무료 + 구매함 Figma 카드의 우측 상단 `구매완료` 캡슐은 구현하지 않는다.
|
|
||||||
- 본문, 작성자, 생성 시간, 좋아요 수, 댓글 수는 표시한다.
|
|
||||||
|
|
||||||
## 8. UX / UI Expectations
|
## 8. UX / UI Expectations
|
||||||
|
|
||||||
### 8.1 Figma 기준 화면 구성
|
### 8.1 Figma 기준
|
||||||
참조 Figma:
|
|
||||||
- 추천 화면: `node-id=24-5514`
|
- 추천 화면: `node-id=24-5514`
|
||||||
- 모두 팔로우 완료 버튼: `node-id=24-9092`
|
- FeedCommunityView 유료 미구매: `node-id=309-19774`
|
||||||
|
- FeedCommunityView 유료 구매함 또는 무료: `node-id=309-19775`
|
||||||
|
|
||||||
Figma 확인 결과 추천 화면은 검정 배경, 상단 홈 타이틀/탭, 하단 메인 탭바 사이에 세로 스크롤 콘텐츠로 구성된다. 주요 섹션은 카드/가로 스크롤/그리드 조합이며, 반복 프로필과 버튼은 재사용 위젯화가 필요하다.
|
Figma 확인 시 이미 생성해 둔 widget 또는 저장소 내 공용 컴포넌트로 구현 가능한 UI는 재사용한다. 단, Figma asset URL을 코드에 직접 넣지 않고 프로젝트 asset 또는 기존 이미지 로딩 패턴을 사용한다.
|
||||||
|
|
||||||
### 8.2 UI 배치 도식
|
### 8.2 화면 구성
|
||||||
아래 도식은 구현 대상만 포함한다. `추천 필모그래피`, `또 다른 모습`은 제외한다.
|
아래 도식은 구현 대상만 포함한다. `추천 필모그래피`, `또 다른 모습`은 제외한다.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
MainHomeView
|
MainHomeView
|
||||||
└─ HomeTitleBar 재사용
|
└─ HomeTitleBar 재사용
|
||||||
└─ 홈 상단 탭(추천/랭킹/팔로잉)
|
└─ 홈 상단 탭(추천/랭킹/팔로잉)
|
||||||
└─ ScrollView
|
└─ 선택된 탭 콘텐츠
|
||||||
├─ 현재 라이브 섹션
|
├─ MainHomeRecommendationView
|
||||||
│ └─ MainHomeLiveSection 신규
|
│ └─ ScrollView
|
||||||
│ └─ MainHomeLiveItem 신규
|
│ ├─ 현재 라이브 섹션
|
||||||
├─ 배너 섹션
|
│ ├─ 배너 섹션
|
||||||
│ └─ BannerCarousel 신규
|
│ ├─ 최근 활동 크리에이터 섹션
|
||||||
├─ 최근 활동 크리에이터 섹션
|
│ ├─ 최근 데뷔한 크리에이터 섹션
|
||||||
│ └─ MainHomeActiveCreatorSection 신규
|
│ ├─ AI 캐릭터 섹션
|
||||||
│ └─ MainHomeActiveCreatorItem 신규
|
│ ├─ 장르의 크리에이터 섹션
|
||||||
├─ 최근 데뷔한 크리에이터 섹션
|
│ ├─ 최근 응원이 많은 크리에이터 섹션
|
||||||
│ ├─ SectionTitle 재사용
|
│ ├─ 인기 커뮤니티 섹션
|
||||||
│ └─ CreatorProfileGrid 재사용
|
│ └─ 사업자 정보 섹션
|
||||||
│ └─ CreatorProfileItem 재사용
|
├─ MainHomeRankingView
|
||||||
├─ 처음 만나는 오디오 섹션
|
└─ MainHomeFollowingView
|
||||||
│ ├─ SectionTitle 재사용
|
└─ MainTabBarView는 기존 MainView 구조에서 유지
|
||||||
│ └─ AudioContentCard 재사용/확장
|
|
||||||
├─ AI 캐릭터 섹션
|
|
||||||
│ ├─ SectionTitle 재사용
|
|
||||||
│ └─ AiCharacterCard 신규
|
|
||||||
├─ 장르의 크리에이터 섹션
|
|
||||||
│ └─ MainHomeCreatorGroupSection 신규
|
|
||||||
│ ├─ SectionTitle 재사용(size 보정 필요 시 신규 variant)
|
|
||||||
│ ├─ MainHomeCreatorGrid 신규
|
|
||||||
│ ├─ CreatorProfileItem 재사용
|
|
||||||
│ └─ FollowAllButton 재사용
|
|
||||||
├─ 최근 응원이 많은 크리에이터 섹션
|
|
||||||
│ └─ MainHomeCreatorGroupSection 재사용
|
|
||||||
├─ 인기 커뮤니티 섹션
|
|
||||||
│ ├─ SectionTitle 재사용
|
|
||||||
│ └─ CommunityPostCard 재사용
|
|
||||||
└─ 사업자 정보 섹션
|
|
||||||
└─ MainHomeBusinessInfoSection 신규
|
|
||||||
└─ ExpandableTextView 재사용
|
|
||||||
└─ MainTabBarView 재사용 가능 여부 확인 후 적용
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 8.3 재사용 컴포넌트 후보
|
### 8.3 재사용 컴포넌트 후보
|
||||||
- `SodaLive/Sources/V2/Component/HomeTitleBar.swift`: 홈 상단 로고/메뉴 타이틀바
|
- `SodaLive/Sources/V2/Component/HomeTitleBar.swift`
|
||||||
- `SodaLive/Sources/V2/Component/SectionTitle.swift`: 섹션 타이틀과 우측 액션 아이콘
|
- `SodaLive/Sources/V2/Component/SectionTitle.swift`
|
||||||
- `SodaLive/Sources/V2/Component/AudioContentCard.swift`: `firstAudioContents` 카드 기본 구조
|
- `SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift`
|
||||||
- `SodaLive/Sources/V2/Main/MainTabBarView.swift`: 하단 탭바가 V2 홈 구조와 맞는 경우 재사용
|
- `SodaLive/Sources/V2/Component/Card/AiCharacterCard.swift`
|
||||||
- `SodaLive/Sources/Chat/Character/Banner/AutoSlideCharacterBannerView.swift`: 배너 carousel 패턴 참고. 홈 배너 타입과 이동 규칙이 달라 직접 재사용 여부는 계획 단계에서 재확인한다.
|
- `SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift`
|
||||||
- `SodaLive/Sources/Explorer/Profile/CreatorCommunity/CreatorCommunityItemView.swift`: 커뮤니티 카드의 작성자/본문/리액션 패턴 참고. 이번 홈 추천 응답 타입과 더보기 요구가 달라 직접 재사용보다는 신규 위젯 생성이 우선이다.
|
- `SodaLive/Sources/V2/Component/Creator/CreatorProfileGrid.swift`
|
||||||
|
- `SodaLive/Sources/V2/Component/Creator/CreatorProfileItem.swift`
|
||||||
|
- `SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift`
|
||||||
|
|
||||||
### 8.4 신규 파일/그룹 후보
|
이미 존재하는 컴포넌트가 Figma 요구를 일부만 충족하면 새 컴포넌트를 중복 생성하지 않고 필요한 범위만 보완한다.
|
||||||
|
|
||||||
`MainView`의 홈 탭에서 표시되는 페이지 조립 계층은 `SodaLive/Sources/V2/Main/Home` 아래에 둔다.
|
### 8.4 컴포넌트 위치 기준
|
||||||
|
- 홈 상단 공통 shell: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift`
|
||||||
```text
|
- 추천 탭 루트: `SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift`
|
||||||
SodaLive/Sources/V2/Main/Home
|
- 추천 탭 API, Repository, ViewModel, 모델: `SodaLive/Sources/V2/Main/Home/Recommendation/**`
|
||||||
├─ MainHomeView.swift
|
- 추천 탭에서만 쓰는 섹션 wrapper: `SodaLive/Sources/V2/Main/Home/Recommendation/Components/**`
|
||||||
├─ MainHomeViewModel.swift
|
- 랭킹 탭 루트: `SodaLive/Sources/V2/Main/Home/Ranking/MainHomeRankingView.swift`
|
||||||
├─ Repository
|
- 팔로잉 탭 루트: `SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift`
|
||||||
│ ├─ MainHomeApi.swift
|
- 여러 페이지에서 재사용 가능한 Card/Banner/Text/Button/Creator widget: `SodaLive/Sources/V2/Component/**`
|
||||||
│ └─ MainHomeRepository.swift
|
- 기존 로직 수정이 아닌 신규 View/ViewModel/Repository는 `SodaLive/Sources/V2/**` 아래에 둔다.
|
||||||
├─ Models
|
|
||||||
│ ├─ MainHomeRecommendationResponse.swift
|
|
||||||
│ ├─ FollowRecommendedCreatorsRequest.swift
|
|
||||||
│ └─ RecommendedActivityType.swift
|
|
||||||
└─ Components
|
|
||||||
├─ MainHomeLiveSection.swift
|
|
||||||
├─ MainHomeLiveItem.swift
|
|
||||||
├─ MainHomeActiveCreatorSection.swift
|
|
||||||
├─ MainHomeActiveCreatorItem.swift
|
|
||||||
├─ MainHomeRecentDebutCreatorSection.swift
|
|
||||||
├─ MainHomeFirstAudioContentSection.swift
|
|
||||||
├─ MainHomeAiCharacterSection.swift
|
|
||||||
├─ MainHomeGenreCreatorSection.swift
|
|
||||||
├─ MainHomeCheerCreatorSection.swift
|
|
||||||
├─ MainHomeCreatorGroupSection.swift
|
|
||||||
├─ MainHomeCreatorGrid.swift
|
|
||||||
├─ MainHomePopularCommunitySection.swift
|
|
||||||
└─ MainHomeBusinessInfoSection.swift
|
|
||||||
```
|
|
||||||
|
|
||||||
`MainHome`에서만 사용하는 섹션 조립 컴포넌트는 `SodaLive/Sources/V2/Main/Home/Components` 아래에 둔다. 여러 페이지에서 재사용 가능성이 있는 UI widget은 `SodaLive/Sources/V2/Component` 아래에서 형태별 폴더에 둔다. 공용 widget은 특정 페이지나 API 이름 접두사를 붙이지 않고, 가능한 한 API 모델에 직접 의존하지 않으며 표시용 프로퍼티 또는 작은 display model을 받아 재사용성을 확보한다.
|
|
||||||
|
|
||||||
```text
|
|
||||||
SodaLive/Sources/V2/Component
|
|
||||||
├─ Banner
|
|
||||||
│ └─ BannerCarousel.swift
|
|
||||||
├─ Card
|
|
||||||
│ ├─ AiCharacterCard.swift
|
|
||||||
│ └─ CommunityPostCard.swift
|
|
||||||
├─ Creator
|
|
||||||
│ ├─ CreatorProfileGrid.swift
|
|
||||||
│ └─ CreatorProfileItem.swift
|
|
||||||
├─ Button
|
|
||||||
│ └─ FollowAllButton.swift
|
|
||||||
└─ Text
|
|
||||||
└─ ExpandableTextView.swift
|
|
||||||
```
|
|
||||||
|
|
||||||
단, 구현 중 특정 widget이 홈 탭에서만 의미가 있고 재사용성이 없다고 판단되면 `SodaLive/Sources/V2/Main/Home/Components` 안에 유지한다. 반대로 이미 존재하는 공용 컴포넌트로 충분한 경우 신규 파일을 만들지 않는다.
|
|
||||||
|
|
||||||
### 8.5 컴포넌트 위치 결정 기준
|
|
||||||
- `BannerCarousel`: 다른 페이지에서도 배너 carousel로 재사용 가능하므로 `SodaLive/Sources/V2/Component/Banner`에 둔다.
|
|
||||||
- 방금 활동한 크리에이터 UI: MainHome에서만 사용하므로 `MainHomeActiveCreatorSection`, `MainHomeActiveCreatorItem`을 `SodaLive/Sources/V2/Main/Home/Components`에 둔다.
|
|
||||||
- 현재 라이브 UI: 별도 재사용 요구가 없으므로 `MainHomeLiveSection`, `MainHomeLiveItem`을 `SodaLive/Sources/V2/Main/Home/Components`에 둔다.
|
|
||||||
- 최근 데뷔한 크리에이터 UI: 다른 페이지에서도 재사용 가능하므로 공용 `CreatorProfileGrid`, `CreatorProfileItem`을 사용하고, 섹션 조립만 `MainHomeRecentDebutCreatorSection`에 둔다.
|
|
||||||
- `AiCharacterCard`: 다른 페이지에서도 캐릭터 카드로 재사용 가능하므로 `SodaLive/Sources/V2/Component/Card`에 둔다.
|
|
||||||
- 장르/응원이 많은 크리에이터: 그리드 그룹 구조는 MainHome 전용이므로 `MainHomeCreatorGroupSection`, `MainHomeCreatorGrid`를 `SodaLive/Sources/V2/Main/Home/Components`에 둔다. 개별 크리에이터 아이템만 `CreatorProfileItem`으로 재사용한다.
|
|
||||||
- 커뮤니티 섹션: 섹션 조립은 `MainHomePopularCommunitySection`에 두고, `CommunityPostCard`는 다른 페이지에서도 재사용 가능하므로 `SodaLive/Sources/V2/Component/Card`에 둔다.
|
|
||||||
- 사업자 정보 섹션: 섹션 wrapper는 `MainHomeBusinessInfoSection`으로 `SodaLive/Sources/V2/Main/Home/Components`에 둔다. 3줄 말줄임표, 더보기, 접기를 담당하는 텍스트 UI는 다른 곳에서도 사용할 수 있는 `ExpandableTextView`로 분리해 `SodaLive/Sources/V2/Component/Text`에 둔다.
|
|
||||||
|
|
||||||
## 9. Technical Constraints
|
## 9. Technical Constraints
|
||||||
- 앱 소스 변경은 기본적으로 `SodaLive/Sources/**`에서 수행한다.
|
- 앱 소스 변경은 `SodaLive/Sources/**`에서 수행한다.
|
||||||
- `MainView` 홈 탭에서 표시되는 페이지 루트, ViewModel, Repository, API, Models와 MainHome 전용 섹션 컴포넌트는 `SodaLive/Sources/V2/Main/Home/**` 아래에 작성한다.
|
- `Pods/**`, `generated/**`, `build/**`는 직접 수정하지 않는다.
|
||||||
- 여러 페이지에서 재사용 가능한 UI widget은 `SodaLive/Sources/V2/Component/**` 아래에 형태별 폴더로 작성한다.
|
|
||||||
- 순수 공용성이 더 큰 컴포넌트는 구현 시점에 `SodaLive/Sources/V2/Component/**`의 더 적합한 하위 그룹으로 이동할 수 있다.
|
|
||||||
- `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다.
|
|
||||||
- 기존 `HomeApi`에 추천 API를 추가하지 않는다.
|
- 기존 `HomeApi`에 추천 API를 추가하지 않는다.
|
||||||
- 외부 라이브러리를 추가하지 않는다.
|
- 외부 라이브러리를 추가하지 않는다.
|
||||||
- 이미지 로딩은 기존 앱에서 사용하는 이미지 컴포넌트/패턴을 따른다.
|
- 이미지 로딩은 기존 앱 이미지 컴포넌트/패턴을 따른다.
|
||||||
- 인증 헤더는 기존 `UserDefaultsKey.token` 기반 패턴을 따른다.
|
- 인증 헤더는 기존 `UserDefaultsKey.token` 기반 패턴을 따른다.
|
||||||
- `creatorId`, `liveRoomId`, `bannerId`, `postId` 등 서버 Long 값은 Swift에서 `Int` 또는 `Int64` 중 기존 라우팅/모델 관례와 맞는 타입을 사용한다. 계획 단계에서 실제 이동 대상 API 타입과 맞춰 확정한다.
|
- 서버 `Long` 값은 Swift에서 기존 라우팅/모델 관례와 맞는 `Int` 또는 `Int64`로 사용한다.
|
||||||
- API 날짜 문자열(`beginDateTime`, `activityAt`, `releaseDate`, `createdAt`)은 기존 날짜 포맷 유틸이 있으면 재사용한다.
|
- 사용자 노출 문구는 가능한 `I18n`에 추가한다.
|
||||||
|
- 상세 진입 guard는 `MainHomeView` 내부에 인증 UI를 중복 구현하지 않고, V2 `MainView`의 기존 인증/민감 콘텐츠 guard 인프라를 재사용한다.
|
||||||
|
- `MainHomeView`에는 추천 탭의 섹션 조립을 직접 누적하지 않고, 탭별 콘텐츠 View를 조합하는 역할만 둔다.
|
||||||
|
- Open Questions에 남긴 항목은 임의 구현하지 않고 확인 후 반영한다.
|
||||||
|
|
||||||
## 10. Success Criteria
|
## 10. Success Criteria
|
||||||
- 추천 탭에서 `/api/v2/home/recommendations`를 호출하고 `success == true` 응답 데이터를 섹션별로 렌더링한다.
|
- 추천 탭에서 `GET /api/v2/home/recommendations`를 호출하고 `success == true` 응답 데이터를 섹션별로 렌더링한다.
|
||||||
- 응답 배열이 비어 있는 섹션은 화면에 표시하지 않는다.
|
- 응답 배열이 비어 있는 섹션은 화면에 표시하지 않는다.
|
||||||
- 제외 섹션인 `추천 필모그래피`, `또 다른 모습`은 코드와 화면에 포함하지 않는다.
|
- `추천 필모그래피`, `또 다른 모습`은 코드와 화면에 포함하지 않는다.
|
||||||
- 모두 팔로우 API 성공 시 해당 버튼이 Figma 완료 디자인에 맞게 `모두 팔로우 완료`와 `ic_new_following` 상태로 변경된다.
|
|
||||||
- 사업자 정보는 기본 3줄 말줄임표, 더보기, 전체 표시, 접기 전환이 동작한다.
|
|
||||||
- `LIVE`, `LIVE_REPLAY`, `AUDIO`, `COMMUNITY` 활동 타입이 I18n 문구로 표시된다.
|
- `LIVE`, `LIVE_REPLAY`, `AUDIO`, `COMMUNITY` 활동 타입이 I18n 문구로 표시된다.
|
||||||
- 반복 UI가 Custom Widget으로 분리되어 같은 프로필/그룹/버튼 구조를 중복 구현하지 않는다.
|
- `FeedCommunityView`에서 키워드 영역이 제거되고, 이미지/유료 미구매/유료 구매함 또는 무료 상태가 조건에 맞게 표시된다.
|
||||||
- 빌드가 성공하고, 가능하면 ViewModel 단위의 응답 디코딩 및 모두 팔로우 성공 상태 테스트가 통과한다.
|
- `FeedCommunityView`에 `구매완료` 버튼이 표시되지 않는다.
|
||||||
|
- 사업자 정보는 마지막 섹션에서 화면 width를 채우고, 기본 3줄 말줄임표, 더보기, 전체 표시, 접기 전환이 동작한다.
|
||||||
|
- 상세 진입 탭 액션은 기존 홈과 같은 로그인, 한국 사용자 본인인증, 민감 콘텐츠 보기 설정 guard를 통과한 뒤에만 실행된다.
|
||||||
|
- `MainHomeView`는 추천/랭킹/팔로잉 탭 shell 역할만 하고, 추천 탭 섹션 조립은 `MainHomeRecommendationView`에 분리되어 있다.
|
||||||
|
- 반복 UI는 기존 widget 또는 공용 컴포넌트를 재사용하고 불필요하게 중복 구현하지 않는다.
|
||||||
|
- 빌드가 성공하고, 가능하면 ViewModel 디코딩/상태 전환 검증을 수행한다.
|
||||||
|
|
||||||
## 11. Metrics
|
## 11. Open Questions
|
||||||
- 추천 API 성공/실패 여부
|
- `RecommendationBannerResponse`는 변경된 백엔드 스펙 기준으로 `eventItem`, `creatorId`, `seriesId`, `link` 순서의 이동 규칙을 사용한다.
|
||||||
- 모두 팔로우 API 호출 성공/실패 여부
|
|
||||||
- 추천 탭 첫 로딩 완료 시간
|
|
||||||
- 모두 팔로우 버튼 터치 후 완료 상태 전환 여부
|
|
||||||
|
|
||||||
## 12. Open Questions
|
|
||||||
- 배너 `type`별 이동 규칙(`eventId`, `creatorId`, `seriesId`, `link`)을 어떤 기존 라우팅과 연결할지 확인이 필요하다.
|
|
||||||
- 각 섹션별 최대 표시 개수와 가로/세로 스크롤 정책이 Figma 기준 그대로인지, 서버 응답 전체를 모두 표시해야 하는지 확인이 필요하다.
|
- 각 섹션별 최대 표시 개수와 가로/세로 스크롤 정책이 Figma 기준 그대로인지, 서버 응답 전체를 모두 표시해야 하는지 확인이 필요하다.
|
||||||
- `creatorIds == null` 요청을 허용해야 하는지, 앱에서는 빈 배열/비어 있는 섹션일 때 버튼을 숨기는 것으로 제한할지 확인이 필요하다.
|
- `HomeLiveItem`에 민감/성인 라이브 여부를 판단할 `isAdult` 또는 동등한 필드가 추가되는지, 아니면 서버에서 추천 라이브를 사전 필터링하는지 확인이 필요하다.
|
||||||
- 모두 팔로우 완료 상태를 앱 세션 동안만 유지할지, 추천 API 재조회 후에도 서버 상태 기반으로 유지할지 확인이 필요하다.
|
- 커뮤니티 `audioUrl`을 추천 탭 카드에서 표시/재생해야 하는지 확인이 필요하다.
|
||||||
- 각 카드 터치 시 상세 이동 대상이 모두 정의되어 있는지 확인이 필요하다.
|
- 각 카드 터치 시 상세 이동 대상이 모두 정의되어 있는지 확인이 필요하다.
|
||||||
|
|
||||||
## 13. Verification Plan
|
## 12. Verification Plan
|
||||||
- PRD 검증: 요구사항, 제외 범위, API URL, 응답 모델, Figma 노드가 문서에 반영되었는지 확인한다.
|
- 문서 검증: API URL, 응답 모델, 제외 섹션, FeedCommunityView, 사업자 정보 요구가 PRD와 계획 문서에 반영되었는지 확인한다.
|
||||||
- 구현 후 빌드 검증: `docs/agent-guides/build-test-verification.md` 기준 명령으로 iOS 빌드 또는 가능한 최소 검증을 실행한다.
|
- 정적 검증: 신규 추천 API가 기존 `HomeApi`에 추가되지 않았고, Figma URL이 앱 코드에 직접 포함되지 않았는지 확인한다.
|
||||||
- 구현 후 기능 검증: 추천 API 성공/실패, 빈 섹션, 모두 팔로우 성공/실패, 사업자 정보 더보기/접기, 활동 타입 I18n 표시를 확인한다.
|
- 빌드 검증: `docs/agent-guides/build-test-verification.md` 기준으로 가능한 iOS 빌드 또는 최소 정적 검증을 실행한다.
|
||||||
|
- 기능 검증: 추천 API 성공/실패, 빈 섹션, 활동 타입 I18n, 상세 진입 guard, 커뮤니티 카드 상태, 사업자 정보 더보기/접기를 확인한다.
|
||||||
|
|||||||
@@ -33,6 +33,9 @@
|
|||||||
- 프로퍼티 선언, 비즈니스 로직, 헬퍼 메서드는 공백 줄로 구획한다.
|
- 프로퍼티 선언, 비즈니스 로직, 헬퍼 메서드는 공백 줄로 구획한다.
|
||||||
- 클로저 체인은 줄바꿈해 가독성을 유지한다.
|
- 클로저 체인은 줄바꿈해 가독성을 유지한다.
|
||||||
|
|
||||||
|
## SwiftUI Preview 규칙
|
||||||
|
- Preview에서 이미지 URL 샘플이 필요하면 `https://placehold.co/500`를 사용한다.
|
||||||
|
|
||||||
## 타입/상태 관리
|
## 타입/상태 관리
|
||||||
- ViewModel은 `final class ...: ObservableObject` 패턴을 우선한다.
|
- ViewModel은 `final class ...: ObservableObject` 패턴을 우선한다.
|
||||||
- View가 소유하는 객체는 `@StateObject`, 외부 주입 객체는 `@ObservedObject`를 사용한다.
|
- View가 소유하는 객체는 `@StateObject`, 외부 주입 객체는 `@ObservedObject`를 사용한다.
|
||||||
|
|||||||
Reference in New Issue
Block a user