diff --git a/TOASTER-iOS.xcodeproj/project.pbxproj b/TOASTER-iOS.xcodeproj/project.pbxproj index 1eeac121..1770b6b6 100644 --- a/TOASTER-iOS.xcodeproj/project.pbxproj +++ b/TOASTER-iOS.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ 390247AA2B58016C00F9A86A /* PatchOpenLinkRequestDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 390247A92B58016C00F9A86A /* PatchOpenLinkRequestDTO.swift */; }; - 390247AC2B58263C00F9A86A /* MypageAlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 390247AB2B58263C00F9A86A /* MypageAlertView.swift */; }; 39049C8D2B43EEF400C9196E /* ToastStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39049C8C2B43EEF400C9196E /* ToastStatus.swift */; }; 39049C8F2B43F70400C9196E /* ToasterBottomSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39049C8E2B43F70400C9196E /* ToasterBottomSheetViewController.swift */; }; 390925C42B4EF64100487AA3 /* LinkWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 390925C32B4EF64100487AA3 /* LinkWebViewController.swift */; }; @@ -23,6 +22,10 @@ 3913B0B02BCECFC80031A3EB /* UpdateAlertType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3913B0AF2BCECFC80031A3EB /* UpdateAlertType.swift */; }; 391908422B56CFE4006F978A /* PatchEditPriorityCategoryRequestDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 391908412B56CFE4006F978A /* PatchEditPriorityCategoryRequestDTO.swift */; }; 391908442B56D027006F978A /* PatchEditNameCategoryRequestDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 391908432B56D027006F978A /* PatchEditNameCategoryRequestDTO.swift */; }; + 392461802CD0D1FB00C0CBC4 /* TipUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3924617F2CD0D1FB00C0CBC4 /* TipUserDefaults.swift */; }; + 392461812CD0D1FB00C0CBC4 /* TipUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3924617F2CD0D1FB00C0CBC4 /* TipUserDefaults.swift */; }; + 396D7ECB2C855F5F0034A14E /* LinkWebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 396D7ECA2C855F5F0034A14E /* LinkWebViewModel.swift */; }; + 396D7ECD2C880F1F0034A14E /* LinkWebToolBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 396D7ECC2C880F1F0034A14E /* LinkWebToolBarView.swift */; }; 396DCDF42CA19EC600FEF7C8 /* PatchPopupHiddenResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 396DCDF32CA19EC600FEF7C8 /* PatchPopupHiddenResponseDTO.swift */; }; 396DCDF52CA19EC600FEF7C8 /* PatchPopupHiddenResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 396DCDF32CA19EC600FEF7C8 /* PatchPopupHiddenResponseDTO.swift */; }; 396DCDF72CA19EFD00FEF7C8 /* PopupAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 396DCDF62CA19EFD00FEF7C8 /* PopupAPIService.swift */; }; @@ -34,16 +37,29 @@ 396DCE002CA19F5C00FEF7C8 /* GetPopupInfoResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 396DCDFF2CA19F5C00FEF7C8 /* GetPopupInfoResponseDTO.swift */; }; 396DCE012CA19F5C00FEF7C8 /* GetPopupInfoResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 396DCDFF2CA19F5C00FEF7C8 /* GetPopupInfoResponseDTO.swift */; }; 396DCE032CA26C6600FEF7C8 /* PopupInfoModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 396DCE022CA26C6600FEF7C8 /* PopupInfoModel.swift */; }; + 397215542CA8CF07009DF1F9 /* CancelBag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 397215532CA8CF07009DF1F9 /* CancelBag.swift */; }; + 397215592CA8D15F009DF1F9 /* Publisher+UIControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 397215582CA8D15F009DF1F9 /* Publisher+UIControl.swift */; }; + 3972155B2CA8DB1A009DF1F9 /* Publisher+UIGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3972155A2CA8DB1A009DF1F9 /* Publisher+UIGesture.swift */; }; + 3972155D2CA9007B009DF1F9 /* Publisher+UIBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3972155C2CA9007B009DF1F9 /* Publisher+UIBarButtonItem.swift */; }; + 397586EC2CAA312C004FB095 /* Publisher+Driver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 397586EB2CAA312C004FB095 /* Publisher+Driver.swift */; }; 398ACFDC2B5E77FA00D5EE77 /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 398ACFDB2B5E77FA00D5EE77 /* Colors.xcassets */; }; 398BE7F32B456367001595E0 /* ToasterToastMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 398BE7F22B456367001595E0 /* ToasterToastMessageView.swift */; }; 398BE7F62B456AF9001595E0 /* BottomType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 398BE7F52B456AF9001595E0 /* BottomType.swift */; }; 398BE7FA2B467E4B001595E0 /* ClipListCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 398BE7F92B467E4B001595E0 /* ClipListCollectionViewCell.swift */; }; 398BE7FC2B468F80001595E0 /* ClipCollectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 398BE7FB2B468F80001595E0 /* ClipCollectionHeaderView.swift */; }; 398BE7FE2B46C164001595E0 /* AddClipBottomSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 398BE7FD2B46C164001595E0 /* AddClipBottomSheetView.swift */; }; + 39A232DD2CB8F28000ACC803 /* TipPathView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39A232DC2CB8F28000ACC803 /* TipPathView.swift */; }; + 39A232DE2CB8F28000ACC803 /* TipPathView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39A232DC2CB8F28000ACC803 /* TipPathView.swift */; }; + 39A232E02CB8F29A00ACC803 /* ToasterTipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39A232DF2CB8F29A00ACC803 /* ToasterTipView.swift */; }; + 39A232E12CB8F29A00ACC803 /* ToasterTipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39A232DF2CB8F29A00ACC803 /* ToasterTipView.swift */; }; 39A843C52B736039007A4D75 /* ClipViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39A843C42B736039007A4D75 /* ClipViewModel.swift */; }; 39A843CA2B74512B007A4D75 /* DetailClipViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39A843C92B74512B007A4D75 /* DetailClipViewModel.swift */; }; 39A843CE2B745B3A007A4D75 /* DetailClipPropertyType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39A843CD2B745B3A007A4D75 /* DetailClipPropertyType.swift */; }; 39A843D12B746420007A4D75 /* EditClipViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39A843D02B746420007A4D75 /* EditClipViewModel.swift */; }; + 39AE73C72CB3D41E00F89793 /* ToasterLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39AE73C62CB3D41E00F89793 /* ToasterLoadingView.swift */; }; + 39AE73C92CB41DF200F89793 /* UIButton+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39AE73C82CB41DF200F89793 /* UIButton+.swift */; }; + 39AE73CC2CB4EADB00F89793 /* UIButton+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39AE73C82CB41DF200F89793 /* UIButton+.swift */; }; + 39AE73CD2CB4EBC300F89793 /* ToasterLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39AE73C62CB3D41E00F89793 /* ToasterLoadingView.swift */; }; 39B54E732B53C50300538DAE /* SettingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39B54E722B53C50300538DAE /* SettingViewController.swift */; }; 39B54E7A2B53D49900538DAE /* SettingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39B54E792B53D49900538DAE /* SettingTableViewCell.swift */; }; 39BC5B0B2B400602004024E6 /* .swiftlint.yml in Resources */ = {isa = PBXBuildFile; fileRef = 39BC5B0A2B400602004024E6 /* .swiftlint.yml */; }; @@ -56,7 +72,7 @@ 39BE4BB62B4ABA97002B471D /* DetailClipListCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39BE4BB52B4ABA97002B471D /* DetailClipListCollectionViewCell.swift */; }; 39BE4BBE2B4ABB7C002B471D /* DetailClipSegmentedControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39BE4BBD2B4ABB7C002B471D /* DetailClipSegmentedControlView.swift */; }; 39BE4BC02B4ABB9E002B471D /* DetailClipEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39BE4BBF2B4ABB9E002B471D /* DetailClipEmptyView.swift */; }; - 39BE4BC22B4ABBB0002B471D /* DeleteLinkBottomSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39BE4BC12B4ABBB0002B471D /* DeleteLinkBottomSheetView.swift */; }; + 39BE4BC22B4ABBB0002B471D /* LinkOptionBottomSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39BE4BC12B4ABBB0002B471D /* LinkOptionBottomSheetView.swift */; }; 39C3926B2B491F47005B2B0F /* NSObject+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39C3926A2B491F47005B2B0F /* NSObject+.swift */; }; 39E794F92B479C8600F16A38 /* ClipEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39E794F82B479C8600F16A38 /* ClipEmptyView.swift */; }; 39EF95FF2B501BD600F301FC /* EditClipViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39EF95FE2B501BD600F301FC /* EditClipViewController.swift */; }; @@ -142,6 +158,14 @@ 3F7D91812BA1A0D9004A022F /* SUIT-SemiBold.otf in Resources */ = {isa = PBXBuildFile; fileRef = 39BC5B102B410EF2004024E6 /* SUIT-SemiBold.otf */; }; 3F7D91822BA1A0DC004A022F /* SUIT-Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = 39BC5B122B410EF3004024E6 /* SUIT-Regular.otf */; }; 3F7D91832BA1A0DE004A022F /* SUIT-Medium.otf in Resources */ = {isa = PBXBuildFile; fileRef = 39BC5B0F2B410EF2004024E6 /* SUIT-Medium.otf */; }; + 3F82C30F2CA92AAB00492EEE /* ShareViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F82C30E2CA92AAB00492EEE /* ShareViewModel.swift */; }; + 3F82C3212CADA19300492EEE /* Publisher+UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F82C3202CADA19300492EEE /* Publisher+UIButton.swift */; }; + 3F82C3222CADA1EF00492EEE /* Publisher+UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F82C3202CADA19300492EEE /* Publisher+UIButton.swift */; }; + 3F82C3232CADA1F400492EEE /* CancelBag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 397215532CA8CF07009DF1F9 /* CancelBag.swift */; }; + 3F82C3242CADA1F700492EEE /* Publisher+Driver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 397586EB2CAA312C004FB095 /* Publisher+Driver.swift */; }; + 3F82C3252CADA1F900492EEE /* Publisher+UIControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 397215582CA8D15F009DF1F9 /* Publisher+UIControl.swift */; }; + 3F82C3262CADA1FC00492EEE /* Publisher+UIGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3972155A2CA8DB1A009DF1F9 /* Publisher+UIGesture.swift */; }; + 3F82C3272CADA1FE00492EEE /* Publisher+UIBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3972155C2CA9007B009DF1F9 /* Publisher+UIBarButtonItem.swift */; }; 3FA56CD72B85C76B00B9FCFE /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA56CD62B85C76B00B9FCFE /* OnboardingViewController.swift */; }; 3FA8654F2BBD799600A9DB8F /* PostTokenHealthResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA8654E2BBD799600A9DB8F /* PostTokenHealthResponseDTO.swift */; }; 3FACF9B82B4FE306007E5A8F /* KeyChainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FACF9B72B4FE306007E5A8F /* KeyChainService.swift */; }; @@ -152,6 +176,13 @@ 3FE00F0C2BC632A500CC821E /* NetworkResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BE6DA352B5059CE008B06FA /* NetworkResult.swift */; }; 3FE28DC22B879B1400B6AED8 /* OnboardingType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE28DC12B879B1400B6AED8 /* OnboardingType.swift */; }; 3FE828352B54E58E00F10732 /* APIInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE828342B54E58E00F10732 /* APIInterceptor.swift */; }; + 3FEA674B2CB51BBC00675805 /* PatchChangeCategoryRequestDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FEA674A2CB51BBC00675805 /* PatchChangeCategoryRequestDTO.swift */; }; + 3FEA674D2CB51BFC00675805 /* PatchChangeCategoryResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FEA674C2CB51BFC00675805 /* PatchChangeCategoryResponseDTO.swift */; }; + 3FEA674E2CB51E6D00675805 /* PatchChangeCategoryRequestDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FEA674A2CB51BBC00675805 /* PatchChangeCategoryRequestDTO.swift */; }; + 3FEA67502CB6522F00675805 /* ChangeClipBottomSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FEA674F2CB6522F00675805 /* ChangeClipBottomSheetView.swift */; }; + 3FEA67522CB663B100675805 /* ChangeClipViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FEA67512CB663B100675805 /* ChangeClipViewModel.swift */; }; + 3FEA67532CB677A400675805 /* PatchChangeCategoryResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FEA674C2CB51BFC00675805 /* PatchChangeCategoryResponseDTO.swift */; }; + 3FF02B302CAFE6600074332E /* ViewModelType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8334CF9F2CA6E2D200319922 /* ViewModelType.swift */; }; 3FF2BF092BA17492001D7DC1 /* ToasterShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3FF2BEFF2BA17492001D7DC1 /* ToasterShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3FF2BF0E2BA188AE001D7DC1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6B6AE6572B3FF103000E2366 /* Assets.xcassets */; }; 3FF2BF0F2BA188B9001D7DC1 /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 398ACFDB2B5E77FA00D5EE77 /* Colors.xcassets */; }; @@ -181,7 +212,6 @@ 6B6AE6782B3FF46C000E2366 /* Moya in Frameworks */ = {isa = PBXBuildFile; productRef = 6B6AE6772B3FF46C000E2366 /* Moya */; }; 6B6AE67A2B3FF46C000E2366 /* ReactiveMoya in Frameworks */ = {isa = PBXBuildFile; productRef = 6B6AE6792B3FF46C000E2366 /* ReactiveMoya */; }; 6B6AE67C2B3FF46C000E2366 /* RxMoya in Frameworks */ = {isa = PBXBuildFile; productRef = 6B6AE67B2B3FF46C000E2366 /* RxMoya */; }; - 6B6AE68A2B3FF582000E2366 /* MypageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B6AE6892B3FF582000E2366 /* MypageViewController.swift */; }; 6B6AE68D2B3FF58D000E2366 /* RemindViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B6AE68C2B3FF58D000E2366 /* RemindViewController.swift */; }; 6B6AE6902B3FF59C000E2366 /* DetailClipViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B6AE68F2B3FF59C000E2366 /* DetailClipViewController.swift */; }; 6B6AE6932B3FF5A9000E2366 /* ClipViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B6AE6922B3FF5A9000E2366 /* ClipViewController.swift */; }; @@ -281,6 +311,9 @@ 8315CD8C2B54782F0061F377 /* SelectClipViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8315CD8B2B54782F0061F377 /* SelectClipViewController.swift */; }; 8315CD8E2B547EE30061F377 /* SelectClipHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8315CD8D2B547EE30061F377 /* SelectClipHeaderView.swift */; }; 8315CD912B5521F70061F377 /* SelectClipModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8315CD902B5521F70061F377 /* SelectClipModel.swift */; }; + 832F0ED72C9C07EA00E38571 /* AddLinkViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 832F0ED62C9C07EA00E38571 /* AddLinkViewModel.swift */; }; + 8334CFA02CA6E2D200319922 /* ViewModelType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8334CF9F2CA6E2D200319922 /* ViewModelType.swift */; }; + 8334CFA22CA979E700319922 /* SettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8334CFA12CA979E700319922 /* SettingView.swift */; }; 83474A6A2BED06EB009B9C48 /* ToasterTargetType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BE6DA7B2B54571D008B06FA /* ToasterTargetType.swift */; }; 83474A6B2BED06F1009B9C48 /* PatchEditLinkTitleRequestDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8388E98B2BC8FAB200858C5C /* PatchEditLinkTitleRequestDTO.swift */; }; 83474A6C2BED072A009B9C48 /* ToasterAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BE6DA7D2B54572B008B06FA /* ToasterAPIService.swift */; }; @@ -291,6 +324,9 @@ 83CFC3372B564F1100A2EB2B /* WeeklyLinkModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83CFC3362B564F1100A2EB2B /* WeeklyLinkModel.swift */; }; 83CFC3392B568BE700A2EB2B /* RecommendSiteModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83CFC3382B568BE700A2EB2B /* RecommendSiteModel.swift */; }; 83CFC33B2B57324700A2EB2B /* SaveLinkModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83CFC33A2B57324700A2EB2B /* SaveLinkModel.swift */; }; + 83D80DCC2CC1059000DD5410 /* RecentLinkModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83D80DCB2CC1059000DD5410 /* RecentLinkModel.swift */; }; + 83D80DD02CC10D8C00DD5410 /* GetRecentLinkResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83D80DCF2CC10D8C00DD5410 /* GetRecentLinkResponseDTO.swift */; }; + 83D80DD12CC1113E00DD5410 /* GetRecentLinkResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83D80DCF2CC10D8C00DD5410 /* GetRecentLinkResponseDTO.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -319,7 +355,6 @@ /* Begin PBXFileReference section */ 390247A92B58016C00F9A86A /* PatchOpenLinkRequestDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchOpenLinkRequestDTO.swift; sourceTree = ""; }; - 390247AB2B58263C00F9A86A /* MypageAlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MypageAlertView.swift; sourceTree = ""; }; 39049C8C2B43EEF400C9196E /* ToastStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastStatus.swift; sourceTree = ""; }; 39049C8E2B43F70400C9196E /* ToasterBottomSheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToasterBottomSheetViewController.swift; sourceTree = ""; }; 390925C32B4EF64100487AA3 /* LinkWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkWebViewController.swift; sourceTree = ""; }; @@ -334,22 +369,35 @@ 3913B0AF2BCECFC80031A3EB /* UpdateAlertType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateAlertType.swift; sourceTree = ""; }; 391908412B56CFE4006F978A /* PatchEditPriorityCategoryRequestDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchEditPriorityCategoryRequestDTO.swift; sourceTree = ""; }; 391908432B56D027006F978A /* PatchEditNameCategoryRequestDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchEditNameCategoryRequestDTO.swift; sourceTree = ""; }; + 3924617F2CD0D1FB00C0CBC4 /* TipUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipUserDefaults.swift; sourceTree = ""; }; + 396D7EC72C855F180034A14E /* ViewModelType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelType.swift; sourceTree = ""; }; + 396D7ECA2C855F5F0034A14E /* LinkWebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkWebViewModel.swift; sourceTree = ""; }; + 396D7ECC2C880F1F0034A14E /* LinkWebToolBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkWebToolBarView.swift; sourceTree = ""; }; 396DCDF32CA19EC600FEF7C8 /* PatchPopupHiddenResponseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchPopupHiddenResponseDTO.swift; sourceTree = ""; }; 396DCDF62CA19EFD00FEF7C8 /* PopupAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupAPIService.swift; sourceTree = ""; }; 396DCDF92CA19F2000FEF7C8 /* PopupTargetType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupTargetType.swift; sourceTree = ""; }; 396DCDFC2CA19F4500FEF7C8 /* PatchPopupHiddenRequestDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchPopupHiddenRequestDTO.swift; sourceTree = ""; }; 396DCDFF2CA19F5C00FEF7C8 /* GetPopupInfoResponseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetPopupInfoResponseDTO.swift; sourceTree = ""; }; 396DCE022CA26C6600FEF7C8 /* PopupInfoModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupInfoModel.swift; sourceTree = ""; }; + 397215532CA8CF07009DF1F9 /* CancelBag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancelBag.swift; sourceTree = ""; }; + 397215582CA8D15F009DF1F9 /* Publisher+UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+UIControl.swift"; sourceTree = ""; }; + 3972155A2CA8DB1A009DF1F9 /* Publisher+UIGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+UIGesture.swift"; sourceTree = ""; }; + 3972155C2CA9007B009DF1F9 /* Publisher+UIBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+UIBarButtonItem.swift"; sourceTree = ""; }; + 397586EB2CAA312C004FB095 /* Publisher+Driver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+Driver.swift"; sourceTree = ""; }; 398ACFDB2B5E77FA00D5EE77 /* Colors.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Colors.xcassets; sourceTree = ""; }; 398BE7F22B456367001595E0 /* ToasterToastMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToasterToastMessageView.swift; sourceTree = ""; }; 398BE7F52B456AF9001595E0 /* BottomType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomType.swift; sourceTree = ""; }; 398BE7F92B467E4B001595E0 /* ClipListCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipListCollectionViewCell.swift; sourceTree = ""; }; 398BE7FB2B468F80001595E0 /* ClipCollectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipCollectionHeaderView.swift; sourceTree = ""; }; 398BE7FD2B46C164001595E0 /* AddClipBottomSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddClipBottomSheetView.swift; sourceTree = ""; }; + 39A232DC2CB8F28000ACC803 /* TipPathView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipPathView.swift; sourceTree = ""; }; + 39A232DF2CB8F29A00ACC803 /* ToasterTipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToasterTipView.swift; sourceTree = ""; }; 39A843C42B736039007A4D75 /* ClipViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipViewModel.swift; sourceTree = ""; }; 39A843C92B74512B007A4D75 /* DetailClipViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailClipViewModel.swift; sourceTree = ""; }; 39A843CD2B745B3A007A4D75 /* DetailClipPropertyType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailClipPropertyType.swift; sourceTree = ""; }; 39A843D02B746420007A4D75 /* EditClipViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditClipViewModel.swift; sourceTree = ""; }; + 39AE73C62CB3D41E00F89793 /* ToasterLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToasterLoadingView.swift; sourceTree = ""; }; + 39AE73C82CB41DF200F89793 /* UIButton+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIButton+.swift"; sourceTree = ""; }; 39B54E722B53C50300538DAE /* SettingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingViewController.swift; sourceTree = ""; }; 39B54E792B53D49900538DAE /* SettingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingTableViewCell.swift; sourceTree = ""; }; 39BC5B0A2B400602004024E6 /* .swiftlint.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = .swiftlint.yml; sourceTree = ""; }; @@ -362,7 +410,7 @@ 39BE4BB52B4ABA97002B471D /* DetailClipListCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailClipListCollectionViewCell.swift; sourceTree = ""; }; 39BE4BBD2B4ABB7C002B471D /* DetailClipSegmentedControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailClipSegmentedControlView.swift; sourceTree = ""; }; 39BE4BBF2B4ABB9E002B471D /* DetailClipEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailClipEmptyView.swift; sourceTree = ""; }; - 39BE4BC12B4ABBB0002B471D /* DeleteLinkBottomSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteLinkBottomSheetView.swift; sourceTree = ""; }; + 39BE4BC12B4ABBB0002B471D /* LinkOptionBottomSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkOptionBottomSheetView.swift; sourceTree = ""; }; 39C3926A2B491F47005B2B0F /* NSObject+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSObject+.swift"; sourceTree = ""; }; 39E794F82B479C8600F16A38 /* ClipEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipEmptyView.swift; sourceTree = ""; }; 39EF95FE2B501BD600F301FC /* EditClipViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditClipViewController.swift; sourceTree = ""; }; @@ -382,6 +430,8 @@ 3F617CB72B4ECB6000956E69 /* MypageUserModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MypageUserModel.swift; sourceTree = ""; }; 3F6CD49C2B86229A00DEC113 /* CustomPageIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomPageIndicatorView.swift; sourceTree = ""; }; 3F7D91712BA18C93004A022F /* ToasterShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ToasterShareExtension.entitlements; sourceTree = ""; }; + 3F82C30E2CA92AAB00492EEE /* ShareViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewModel.swift; sourceTree = ""; }; + 3F82C3202CADA19300492EEE /* Publisher+UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+UIButton.swift"; sourceTree = ""; }; 3FA56CD62B85C76B00B9FCFE /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = ""; }; 3FA8654E2BBD799600A9DB8F /* PostTokenHealthResponseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostTokenHealthResponseDTO.swift; sourceTree = ""; }; 3FACF9B72B4FE306007E5A8F /* KeyChainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyChainService.swift; sourceTree = ""; }; @@ -391,6 +441,10 @@ 3FE00F0A2BC6328900CC821E /* MoyaPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoyaPlugin.swift; sourceTree = ""; }; 3FE28DC12B879B1400B6AED8 /* OnboardingType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingType.swift; sourceTree = ""; }; 3FE828342B54E58E00F10732 /* APIInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIInterceptor.swift; sourceTree = ""; }; + 3FEA674A2CB51BBC00675805 /* PatchChangeCategoryRequestDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchChangeCategoryRequestDTO.swift; sourceTree = ""; }; + 3FEA674C2CB51BFC00675805 /* PatchChangeCategoryResponseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchChangeCategoryResponseDTO.swift; sourceTree = ""; }; + 3FEA674F2CB6522F00675805 /* ChangeClipBottomSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeClipBottomSheetView.swift; sourceTree = ""; }; + 3FEA67512CB663B100675805 /* ChangeClipViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeClipViewModel.swift; sourceTree = ""; }; 3FF2BEFF2BA17492001D7DC1 /* ToasterShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ToasterShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 3FF2BF062BA17492001D7DC1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 6B0E85D82B564913001BC15F /* RemindTimerAddViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindTimerAddViewModel.swift; sourceTree = ""; }; @@ -402,7 +456,6 @@ 6B6AE6572B3FF103000E2366 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 6B6AE65A2B3FF103000E2366 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 6B6AE65C2B3FF103000E2366 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 6B6AE6892B3FF582000E2366 /* MypageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MypageViewController.swift; sourceTree = ""; }; 6B6AE68C2B3FF58D000E2366 /* RemindViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindViewController.swift; sourceTree = ""; }; 6B6AE68F2B3FF59C000E2366 /* DetailClipViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailClipViewController.swift; sourceTree = ""; }; 6B6AE6922B3FF5A9000E2366 /* ClipViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipViewController.swift; sourceTree = ""; }; @@ -502,12 +555,17 @@ 8315CD8B2B54782F0061F377 /* SelectClipViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectClipViewController.swift; sourceTree = ""; }; 8315CD8D2B547EE30061F377 /* SelectClipHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectClipHeaderView.swift; sourceTree = ""; }; 8315CD902B5521F70061F377 /* SelectClipModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectClipModel.swift; sourceTree = ""; }; + 832F0ED62C9C07EA00E38571 /* AddLinkViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddLinkViewModel.swift; sourceTree = ""; }; + 8334CF9F2CA6E2D200319922 /* ViewModelType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelType.swift; sourceTree = ""; }; + 8334CFA12CA979E700319922 /* SettingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingView.swift; sourceTree = ""; }; 8364220B2BE7BFB2005C4085 /* PatchEditLinkTitleResponseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchEditLinkTitleResponseDTO.swift; sourceTree = ""; }; 8388E98B2BC8FAB200858C5C /* PatchEditLinkTitleRequestDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchEditLinkTitleRequestDTO.swift; sourceTree = ""; }; 8388E98D2BC8FC6700858C5C /* EditLinkBottomSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditLinkBottomSheetView.swift; sourceTree = ""; }; 83CFC3362B564F1100A2EB2B /* WeeklyLinkModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeeklyLinkModel.swift; sourceTree = ""; }; 83CFC3382B568BE700A2EB2B /* RecommendSiteModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendSiteModel.swift; sourceTree = ""; }; 83CFC33A2B57324700A2EB2B /* SaveLinkModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveLinkModel.swift; sourceTree = ""; }; + 83D80DCB2CC1059000DD5410 /* RecentLinkModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentLinkModel.swift; sourceTree = ""; }; + 83D80DCF2CC10D8C00DD5410 /* GetRecentLinkResponseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetRecentLinkResponseDTO.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -555,8 +613,9 @@ isa = PBXGroup; children = ( 3909A0692B6236FE005A4546 /* Model */, - 390925C92B4F047500487AA3 /* View */, 39A843D72B7485DF007A4D75 /* ViewController */, + 390925C92B4F047500487AA3 /* View */, + 396D7EC92C855F4F0034A14E /* ViewModel */, ); path = LinkWeb; sourceTree = ""; @@ -573,6 +632,7 @@ isa = PBXGroup; children = ( 390925CA2B4F049800487AA3 /* LinkWebNavigationView.swift */, + 396D7ECC2C880F1F0034A14E /* LinkWebToolBarView.swift */, ); path = View; sourceTree = ""; @@ -618,6 +678,14 @@ path = UpdateAlert; sourceTree = ""; }; + 396D7EC92C855F4F0034A14E /* ViewModel */ = { + isa = PBXGroup; + children = ( + 396D7ECA2C855F5F0034A14E /* LinkWebViewModel.swift */, + ); + path = ViewModel; + sourceTree = ""; + }; 396DCDE22CA199E800FEF7C8 /* Popup */ = { isa = PBXGroup; children = ( @@ -638,6 +706,49 @@ path = DTO; sourceTree = ""; }; + 397215502CA8CDFF009DF1F9 /* Recovered References */ = { + isa = PBXGroup; + children = ( + 396D7EC72C855F180034A14E /* ViewModelType.swift */, + ); + name = "Recovered References"; + sourceTree = ""; + }; + 397215552CA8D0A1009DF1F9 /* UIKit+ */ = { + isa = PBXGroup; + children = ( + 6B6AE6AB2B3FF6F7000E2366 /* UIColor+.swift */, + 6B6AE6A92B3FF6EA000E2366 /* UIStackView+.swift */, + 6B6AE6A72B3FF6D5000E2366 /* UITextField+.swift */, + 6B6AE6A52B3FF6BD000E2366 /* UIView+.swift */, + 6B6AE6A32B3FF6B0000E2366 /* UIViewController+.swift */, + 830517AF2B4D9A3B009FFB60 /* UILabel+.swift */, + 39AE73C82CB41DF200F89793 /* UIButton+.swift */, + ); + path = "UIKit+"; + sourceTree = ""; + }; + 397215562CA8D0B1009DF1F9 /* Foundation+ */ = { + isa = PBXGroup; + children = ( + 39C3926A2B491F47005B2B0F /* NSObject+.swift */, + ); + path = "Foundation+"; + sourceTree = ""; + }; + 397215572CA8D0CA009DF1F9 /* Combine+ */ = { + isa = PBXGroup; + children = ( + 397215532CA8CF07009DF1F9 /* CancelBag.swift */, + 397586EB2CAA312C004FB095 /* Publisher+Driver.swift */, + 397215582CA8D15F009DF1F9 /* Publisher+UIControl.swift */, + 3972155A2CA8DB1A009DF1F9 /* Publisher+UIGesture.swift */, + 3972155C2CA9007B009DF1F9 /* Publisher+UIBarButtonItem.swift */, + 3F82C3202CADA19300492EEE /* Publisher+UIButton.swift */, + ); + path = "Combine+"; + sourceTree = ""; + }; 398ACFDA2B5E77C500D5EE77 /* Assets */ = { isa = PBXGroup; children = ( @@ -675,6 +786,16 @@ path = View; sourceTree = ""; }; + 39A232DB2CB8F1B900ACC803 /* ToasterTipView */ = { + isa = PBXGroup; + children = ( + 39A232DF2CB8F29A00ACC803 /* ToasterTipView.swift */, + 39A232DC2CB8F28000ACC803 /* TipPathView.swift */, + 3924617F2CD0D1FB00C0CBC4 /* TipUserDefaults.swift */, + ); + path = ToasterTipView; + sourceTree = ""; + }; 39A843C32B73602C007A4D75 /* ViewModel */ = { isa = PBXGroup; children = ( @@ -706,6 +827,7 @@ children = ( 39A843C92B74512B007A4D75 /* DetailClipViewModel.swift */, 39A843CD2B745B3A007A4D75 /* DetailClipPropertyType.swift */, + 3FEA67512CB663B100675805 /* ChangeClipViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -715,8 +837,9 @@ children = ( 39BE4BBD2B4ABB7C002B471D /* DetailClipSegmentedControlView.swift */, 39BE4BBF2B4ABB9E002B471D /* DetailClipEmptyView.swift */, - 39BE4BC12B4ABBB0002B471D /* DeleteLinkBottomSheetView.swift */, + 39BE4BC12B4ABBB0002B471D /* LinkOptionBottomSheetView.swift */, 8388E98D2BC8FC6700858C5C /* EditLinkBottomSheetView.swift */, + 3FEA674F2CB6522F00675805 /* ChangeClipBottomSheetView.swift */, ); path = Component; sourceTree = ""; @@ -745,11 +868,19 @@ path = ViewController; sourceTree = ""; }; + 39AE73C52CB3D3DF00F89793 /* ToasterLoadingView */ = { + isa = PBXGroup; + children = ( + 39AE73C62CB3D41E00F89793 /* ToasterLoadingView.swift */, + ); + path = ToasterLoadingView; + sourceTree = ""; + }; 39B54E712B53C4F100538DAE /* Setting */ = { isa = PBXGroup; children = ( - 39B54E722B53C50300538DAE /* SettingViewController.swift */, - 39B54E792B53D49900538DAE /* SettingTableViewCell.swift */, + 830BD47A2CAFCC8B0050F8D1 /* Model */, + 830BD4792CAFCC1D0050F8D1 /* View */, ); path = Setting; sourceTree = ""; @@ -819,23 +950,6 @@ path = Model; sourceTree = ""; }; - 3F617CB32B4EC2B500956E69 /* View */ = { - isa = PBXGroup; - children = ( - 3F617CB42B4EC2EE00956E69 /* MypageHeaderView.swift */, - 390247AB2B58263C00F9A86A /* MypageAlertView.swift */, - ); - path = View; - sourceTree = ""; - }; - 3F617CB62B4ECB4600956E69 /* Model */ = { - isa = PBXGroup; - children = ( - 3F617CB72B4ECB6000956E69 /* MypageUserModel.swift */, - ); - path = Model; - sourceTree = ""; - }; 3FF2BF002BA17492001D7DC1 /* ToasterShareExtension */ = { isa = PBXGroup; children = ( @@ -843,6 +957,7 @@ 3FF2BF062BA17492001D7DC1 /* Info.plist */, 3F1F26252BAAC231004F75CE /* ShareViewController.swift */, 3FE00F0A2BC6328900CC821E /* MoyaPlugin.swift */, + 3F82C30E2CA92AAB00492EEE /* ShareViewModel.swift */, ); path = ToasterShareExtension; sourceTree = ""; @@ -871,6 +986,7 @@ 3FF2BF002BA17492001D7DC1 /* ToasterShareExtension */, 6B6AE64C2B3FF101000E2366 /* Products */, 390925C52B4EF66C00487AA3 /* Frameworks */, + 397215502CA8CDFF009DF1F9 /* Recovered References */, ); sourceTree = ""; }; @@ -941,13 +1057,9 @@ 6B6AE6812B3FF50A000E2366 /* Extensions */ = { isa = PBXGroup; children = ( - 6B6AE6AB2B3FF6F7000E2366 /* UIColor+.swift */, - 6B6AE6A92B3FF6EA000E2366 /* UIStackView+.swift */, - 6B6AE6A72B3FF6D5000E2366 /* UITextField+.swift */, - 6B6AE6A52B3FF6BD000E2366 /* UIView+.swift */, - 6B6AE6A32B3FF6B0000E2366 /* UIViewController+.swift */, - 39C3926A2B491F47005B2B0F /* NSObject+.swift */, - 830517AF2B4D9A3B009FFB60 /* UILabel+.swift */, + 397215572CA8D0CA009DF1F9 /* Combine+ */, + 397215552CA8D0A1009DF1F9 /* UIKit+ */, + 397215562CA8D0B1009DF1F9 /* Foundation+ */, ); path = Extensions; sourceTree = ""; @@ -964,6 +1076,8 @@ 6B6AE6832B3FF514000E2366 /* Components */ = { isa = PBXGroup; children = ( + 39A232DB2CB8F1B900ACC803 /* ToasterTipView */, + 39AE73C52CB3D3DF00F89793 /* ToasterLoadingView */, 6BC493662B45D78F00544249 /* ToasterNavigationController */, 398BE7F42B456ACF001595E0 /* ToasterBottomSheet */, 398BE7F12B45628F001595E0 /* ToasterToastMessage */, @@ -976,6 +1090,7 @@ isa = PBXGroup; children = ( 3F2FA1762B45C3E000EDBF95 /* AuthenticationAdapterProtocol.swift */, + 8334CF9F2CA6E2D200319922 /* ViewModelType.swift */, ); path = Protocols; sourceTree = ""; @@ -1023,22 +1138,11 @@ 6B6AE68E2B3FF590000E2366 /* DetailClip */, 6B6AE68B2B3FF585000E2366 /* Remind */, 6BE6D9FF2B4F2A91008B06FA /* RemindAdd */, - 6B6AE6882B3FF575000E2366 /* Mypage */, 6B6AE6522B3FF101000E2366 /* ViewController.swift */, ); path = Present; sourceTree = ""; }; - 6B6AE6882B3FF575000E2366 /* Mypage */ = { - isa = PBXGroup; - children = ( - 6B6AE6892B3FF582000E2366 /* MypageViewController.swift */, - 3F617CB62B4ECB4600956E69 /* Model */, - 3F617CB32B4EC2B500956E69 /* View */, - ); - path = Mypage; - sourceTree = ""; - }; 6B6AE68B2B3FF585000E2366 /* Remind */ = { isa = PBXGroup; children = ( @@ -1451,6 +1555,7 @@ 6BE6DA812B545786008B06FA /* PostSaveLinkRequestDTO.swift */, 390247A92B58016C00F9A86A /* PatchOpenLinkRequestDTO.swift */, 8388E98B2BC8FAB200858C5C /* PatchEditLinkTitleRequestDTO.swift */, + 3FEA674A2CB51BBC00675805 /* PatchChangeCategoryRequestDTO.swift */, ); path = Request; sourceTree = ""; @@ -1461,6 +1566,8 @@ 6BE6DA872B546B24008B06FA /* PatchOpenLinkResponseDTO.swift */, 6BE6DA8B2B546CA9008B06FA /* GetWeeksLinkResponseDTO.swift */, 8364220B2BE7BFB2005C4085 /* PatchEditLinkTitleResponseDTO.swift */, + 83D80DCF2CC10D8C00DD5410 /* GetRecentLinkResponseDTO.swift */, + 3FEA674C2CB51BFC00675805 /* PatchChangeCategoryResponseDTO.swift */, ); path = Response; sourceTree = ""; @@ -1570,6 +1677,7 @@ 83CFC3362B564F1100A2EB2B /* WeeklyLinkModel.swift */, 83CFC3382B568BE700A2EB2B /* RecommendSiteModel.swift */, 396DCE022CA26C6600FEF7C8 /* PopupInfoModel.swift */, + 83D80DCB2CC1059000DD5410 /* RecentLinkModel.swift */, ); path = Model; sourceTree = ""; @@ -1579,6 +1687,7 @@ children = ( 8309F5862B8DCE8100A1420A /* Model */, 8309F5852B8DCE7B00A1420A /* View */, + 832F0ED52C9C07C500E38571 /* ViewModel */, ); path = LinkEmbed; sourceTree = ""; @@ -1600,6 +1709,33 @@ path = Model; sourceTree = ""; }; + 830BD4792CAFCC1D0050F8D1 /* View */ = { + isa = PBXGroup; + children = ( + 830BD47B2CAFCC910050F8D1 /* Cell */, + 8334CFA12CA979E700319922 /* SettingView.swift */, + 39B54E722B53C50300538DAE /* SettingViewController.swift */, + 3F617CB42B4EC2EE00956E69 /* MypageHeaderView.swift */, + ); + path = View; + sourceTree = ""; + }; + 830BD47A2CAFCC8B0050F8D1 /* Model */ = { + isa = PBXGroup; + children = ( + 3F617CB72B4ECB6000956E69 /* MypageUserModel.swift */, + ); + path = Model; + sourceTree = ""; + }; + 830BD47B2CAFCC910050F8D1 /* Cell */ = { + isa = PBXGroup; + children = ( + 39B54E792B53D49900538DAE /* SettingTableViewCell.swift */, + ); + path = Cell; + sourceTree = ""; + }; 8315CD882B5478050061F377 /* SelectClip */ = { isa = PBXGroup; children = ( @@ -1635,6 +1771,14 @@ path = ViewModel; sourceTree = ""; }; + 832F0ED52C9C07C500E38571 /* ViewModel */ = { + isa = PBXGroup; + children = ( + 832F0ED62C9C07EA00E38571 /* AddLinkViewModel.swift */, + ); + path = ViewModel; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -1810,15 +1954,19 @@ files = ( 3F3ED28A2BA1A456004E79F0 /* AuthTargetType.swift in Sources */, 396DCDFD2CA19F4500FEF7C8 /* PatchPopupHiddenRequestDTO.swift in Sources */, + 3F82C3222CADA1EF00492EEE /* Publisher+UIButton.swift in Sources */, 3F3ED28B2BA1A456004E79F0 /* PostSocialLoginResponseDTO.swift in Sources */, 3F1F261D2BAA98C8004F75CE /* RemindClipModel.swift in Sources */, 3F3ED28C2BA1A456004E79F0 /* PostRefreshTokenResponseDTO.swift in Sources */, 3F3ED28D2BA1A456004E79F0 /* AuthAPIService.swift in Sources */, + 3F82C30F2CA92AAB00492EEE /* ShareViewModel.swift in Sources */, 3F3ED2892BA1A453004E79F0 /* PostSocialLoginRequestDTO.swift in Sources */, 3F3ED27B2BA1A298004E79F0 /* BaseTargetType.swift in Sources */, 3F3ED28E2BA1A45C004E79F0 /* PatchPushAlarmRequestDTO.swift in Sources */, 3F3ED2782BA1A246004E79F0 /* KeyChainService.swift in Sources */, 3F1F26202BAAB34B004F75CE /* SelectClipModel.swift in Sources */, + 3F82C3272CADA1FE00492EEE /* Publisher+UIBarButtonItem.swift in Sources */, + 3F82C3242CADA1F700492EEE /* Publisher+Driver.swift in Sources */, 3F1F261F2BAAAED7004F75CE /* ToastStatus.swift in Sources */, 3F3ED2792BA1A258004E79F0 /* Config.swift in Sources */, 3F7D917A2BA1A05F004A022F /* UIView+.swift in Sources */, @@ -1827,6 +1975,7 @@ 3FE00F0C2BC632A500CC821E /* NetworkResult.swift in Sources */, 83474A6D2BED0750009B9C48 /* PatchEditLinkTitleResponseDTO.swift in Sources */, 3F3ED2832BA1A3FD004E79F0 /* GetAllCategoryResponseDTO.swift in Sources */, + 39A232DD2CB8F28000ACC803 /* TipPathView.swift in Sources */, 3F1F261C2BAA9887004F75CE /* RemindSelectClipViewModel.swift in Sources */, 3F3ED28F2BA1A45F004E79F0 /* UserAPIService.swift in Sources */, 3F3ED2902BA1A45F004E79F0 /* PatchPushAlarmResponseDTO.swift in Sources */, @@ -1841,6 +1990,7 @@ 3F3ED29E2BA1A478004E79F0 /* TimerAPIService.swift in Sources */, 396DCE002CA19F5C00FEF7C8 /* GetPopupInfoResponseDTO.swift in Sources */, 3F3ED29F2BA1A478004E79F0 /* GetTimerMainpageResponseDTO.swift in Sources */, + 39A232E02CB8F29A00ACC803 /* ToasterTipView.swift in Sources */, 3F3ED2A02BA1A478004E79F0 /* TimerTargetType.swift in Sources */, 3F3ED2AA2BA1AAB7004E79F0 /* FontLiterals.swift in Sources */, 3F3ED2A62BA1A48F004E79F0 /* NoneDataResponseDTO.swift in Sources */, @@ -1848,24 +1998,31 @@ 3F3ED2A12BA1A478004E79F0 /* GetDetailTimerResponseDTO.swift in Sources */, 3F3ED2942BA1A45F004E79F0 /* GetMainPageResponseDTO.swift in Sources */, 3F3ED2B42BA1D5FF004E79F0 /* NSObject+.swift in Sources */, + 3F82C3232CADA1F400492EEE /* CancelBag.swift in Sources */, 3F3ED2862BA1A400004E79F0 /* PatchEditNameCategoryRequestDTO.swift in Sources */, 3F3ED2972BA1A46E004E79F0 /* GetWeeksLinkResponseDTO.swift in Sources */, 3F3ED29B2BA1A475004E79F0 /* PatchEditTimerRequestDTO.swift in Sources */, 3F3ED29C2BA1A475004E79F0 /* PatchEditTimerTitleRequestDTO.swift in Sources */, 3F3ED29D2BA1A475004E79F0 /* PostCreateTimerRequestDTO.swift in Sources */, + 3FEA674E2CB51E6D00675805 /* PatchChangeCategoryRequestDTO.swift in Sources */, 3F1F26262BAAC231004F75CE /* ShareViewController.swift in Sources */, + 3FEA67532CB677A400675805 /* PatchChangeCategoryResponseDTO.swift in Sources */, 3F1F26222BAAB395004F75CE /* ToasterToastMessageView.swift in Sources */, 3F3ED2992BA1A46E004E79F0 /* PatchOpenLinkResponseDTO.swift in Sources */, 3F3ED2872BA1A400004E79F0 /* PostAddCategoryRequestDTO.swift in Sources */, 3F3ED2B52BA1D645004E79F0 /* ClipModel.swift in Sources */, + 3FF02B302CAFE6600074332E /* ViewModelType.swift in Sources */, 83474A6A2BED06EB009B9C48 /* ToasterTargetType.swift in Sources */, 3F3ED2952BA1A46B004E79F0 /* PatchOpenLinkRequestDTO.swift in Sources */, 3F3ED2A92BA1AAB4004E79F0 /* StringLiterals.swift in Sources */, 3F3ED2A22BA1A47E004E79F0 /* GetMainPageSearchResponseDTO.swift in Sources */, 3F3ED2A32BA1A47E004E79F0 /* SearchTargetType.swift in Sources */, 396DCDFB2CA19F2000FEF7C8 /* PopupTargetType.swift in Sources */, + 3F82C3252CADA1F900492EEE /* Publisher+UIControl.swift in Sources */, 3F3ED2A42BA1A47E004E79F0 /* GetRecommendSiteResponseDTO.swift in Sources */, 3F3ED2A52BA1A47E004E79F0 /* SearchAPIService.swift in Sources */, + 39AE73CD2CB4EBC300F89793 /* ToasterLoadingView.swift in Sources */, + 392461812CD0D1FB00C0CBC4 /* TipUserDefaults.swift in Sources */, 3F3ED2962BA1A46B004E79F0 /* PostSaveLinkRequestDTO.swift in Sources */, 3F3ED2882BA1A400004E79F0 /* PatchEditPriorityCategoryRequestDTO.swift in Sources */, 3FE00F082BC5076200CC821E /* PostTokenHealthResponseDTO.swift in Sources */, @@ -1874,11 +2031,14 @@ 3F3ED2852BA1A3FD004E79F0 /* GetDetailCategoryResponseDTO.swift in Sources */, 3F3ED2802BA1A2B0004E79F0 /* APIInterceptor.swift in Sources */, 3F3ED27F2BA1A2A9004E79F0 /* NetworkService.swift in Sources */, + 3F82C3262CADA1FC00492EEE /* Publisher+UIGesture.swift in Sources */, 3F1F26292BAADE01004F75CE /* Config.swift in Sources */, 3F3ED27C2BA1A29F004E79F0 /* BaseAPIService.swift in Sources */, 3F3ED2B32BA1D59D004E79F0 /* ClipListCollectionViewCell.swift in Sources */, 3F3ED2B72BA1D897004E79F0 /* SearchResultModel.swift in Sources */, + 83D80DD12CC1113E00DD5410 /* GetRecentLinkResponseDTO.swift in Sources */, 3F3ED2812BA1A3F5004E79F0 /* ClipAPIService.swift in Sources */, + 39AE73CC2CB4EADB00F89793 /* UIButton+.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1889,6 +2049,8 @@ 8305179D2B4D3701009FFB60 /* MainInfoModel.swift in Sources */, 6BE6DAA42B547579008B06FA /* GetDetailTimerResponseDTO.swift in Sources */, 6BE6D9E22B4E9B58008B06FA /* CompleteTimerCollectionViewCell.swift in Sources */, + 8334CFA02CA6E2D200319922 /* ViewModelType.swift in Sources */, + 83D80DD02CC10D8C00DD5410 /* GetRecentLinkResponseDTO.swift in Sources */, 3F2FA1792B45C46F00EDBF95 /* KakaoAuthenticateAdapter.swift in Sources */, 6BE6DA342B50594B008B06FA /* MoyaPlugin.swift in Sources */, 396DCDFA2CA19F2000FEF7C8 /* PopupTargetType.swift in Sources */, @@ -1906,14 +2068,17 @@ 3F6CD49D2B86229A00DEC113 /* CustomPageIndicatorView.swift in Sources */, 6BE6DAB32B547BEF008B06FA /* GetRecommendSiteResponseDTO.swift in Sources */, 6B6AE6532B3FF101000E2366 /* ViewController.swift in Sources */, + 392461802CD0D1FB00C0CBC4 /* TipUserDefaults.swift in Sources */, 6B6AE6902B3FF59C000E2366 /* DetailClipViewController.swift in Sources */, 6BE6DA202B504433008B06FA /* BaseTargetType.swift in Sources */, 6B6AE6A62B3FF6BD000E2366 /* UIView+.swift in Sources */, 83CFC3372B564F1100A2EB2B /* WeeklyLinkModel.swift in Sources */, 398BE7FC2B468F80001595E0 /* ClipCollectionHeaderView.swift in Sources */, 3F617CB82B4ECB6000956E69 /* MypageUserModel.swift in Sources */, + 8334CFA22CA979E700319922 /* SettingView.swift in Sources */, 6BE6DA612B50B742008B06FA /* ClipAPIService.swift in Sources */, 830517AA2B4D95E9009FFB60 /* HomeFooterCollectionView.swift in Sources */, + 832F0ED72C9C07EA00E38571 /* AddLinkViewModel.swift in Sources */, 39049C8D2B43EEF400C9196E /* ToastStatus.swift in Sources */, 8305178E2B4D1EF8009FFB60 /* WeeklyRecommendCollectionViewCell.swift in Sources */, 830517902B4D1FC7009FFB60 /* HomeHeaderCollectionView.swift in Sources */, @@ -1940,7 +2105,7 @@ 3909A0702B6239F8005A4546 /* ClipPriorityEditModel.swift in Sources */, 6BE6DA962B547037008B06FA /* PatchEditTimerRequestDTO.swift in Sources */, 39B54E732B53C50300538DAE /* SettingViewController.swift in Sources */, - 39BE4BC22B4ABBB0002B471D /* DeleteLinkBottomSheetView.swift in Sources */, + 39BE4BC22B4ABBB0002B471D /* LinkOptionBottomSheetView.swift in Sources */, 6BE6DA292B505433008B06FA /* AuthTargetType.swift in Sources */, 6BC4936C2B48633700544249 /* ToasterNavigationType.swift in Sources */, 6B6AE6992B3FF5C1000E2366 /* SearchViewController.swift in Sources */, @@ -1948,8 +2113,11 @@ 6B0E85DC2B564949001BC15F /* RemindTimerAddModel.swift in Sources */, 6BE6DA512B50B309008B06FA /* PatchPushAlarmRequestDTO.swift in Sources */, 830517962B4D21BB009FFB60 /* CompositioinalFactory.swift in Sources */, + 39A232E12CB8F29A00ACC803 /* ToasterTipView.swift in Sources */, 6BE6D9E82B4EA773008B06FA /* RemindCollectionFooterView.swift in Sources */, 39A843CA2B74512B007A4D75 /* DetailClipViewModel.swift in Sources */, + 3FEA674D2CB51BFC00675805 /* PatchChangeCategoryResponseDTO.swift in Sources */, + 3FEA674B2CB51BBC00675805 /* PatchChangeCategoryRequestDTO.swift in Sources */, 83CFC3392B568BE700A2EB2B /* RecommendSiteModel.swift in Sources */, 391908442B56D027006F978A /* PatchEditNameCategoryRequestDTO.swift in Sources */, 6BE6D9F42B4EF568008B06FA /* RemindTimerEditBottomSheetView.swift in Sources */, @@ -1963,9 +2131,12 @@ 39B54E7A2B53D49900538DAE /* SettingTableViewCell.swift in Sources */, 39A843C52B736039007A4D75 /* ClipViewModel.swift in Sources */, 6BE6DA392B50636B008B06FA /* BaseAPIService.swift in Sources */, + 39AE73C72CB3D41E00F89793 /* ToasterLoadingView.swift in Sources */, + 397215592CA8D15F009DF1F9 /* Publisher+UIControl.swift in Sources */, 6BE6DA572B50B44F008B06FA /* GetMyPageResponseDTO.swift in Sources */, 6BE6DA182B4FF285008B06FA /* TimerRepeatDate.swift in Sources */, 6BE6D9F22B4EEBC3008B06FA /* RemindViewModel.swift in Sources */, + 39AE73C92CB41DF200F89793 /* UIButton+.swift in Sources */, 6B6AE64F2B3FF101000E2366 /* AppDelegate.swift in Sources */, 6BE6DA922B546F07008B06FA /* TimerTargetType.swift in Sources */, 6BE6DA1A2B4FF443008B06FA /* TimerRepeatBottomSheetView.swift in Sources */, @@ -1974,14 +2145,15 @@ 6BC4935E2B4414B400544249 /* ToasterPopupViewController.swift in Sources */, 39BE4BBE2B4ABB7C002B471D /* DetailClipSegmentedControlView.swift in Sources */, 6BE6DA6D2B50C109008B06FA /* GetDetailCategoryResponseDTO.swift in Sources */, + 3972155B2CA8DB1A009DF1F9 /* Publisher+UIGesture.swift in Sources */, 6B6AE6A82B3FF6D5000E2366 /* UITextField+.swift in Sources */, 6BE6DA652B50BA4F008B06FA /* PostAddCategoryRequestDTO.swift in Sources */, 6BE6D9DE2B4E9054008B06FA /* AlarmOffStateButton.swift in Sources */, 390247AA2B58016C00F9A86A /* PatchOpenLinkRequestDTO.swift in Sources */, 8309F5882B8DCEAC00A1420A /* SelectClipViewModel.swift in Sources */, 39049C8F2B43F70400C9196E /* ToasterBottomSheetViewController.swift in Sources */, - 390247AC2B58263C00F9A86A /* MypageAlertView.swift in Sources */, 6BE6DAB12B547BE1008B06FA /* GetMainPageSearchResponseDTO.swift in Sources */, + 3FEA67502CB6522F00675805 /* ChangeClipBottomSheetView.swift in Sources */, 8315CD862B517DBF0061F377 /* AddLinkView.swift in Sources */, 8315CD912B5521F70061F377 /* SelectClipModel.swift in Sources */, 3FA8654F2BBD799600A9DB8F /* PostTokenHealthResponseDTO.swift in Sources */, @@ -1989,6 +2161,7 @@ 398BE7FE2B46C164001595E0 /* AddClipBottomSheetView.swift in Sources */, 6BE6DA012B4F2ACA008B06FA /* RemindSelectClipViewController.swift in Sources */, 6BE6DA592B50B45E008B06FA /* PatchPushAlarmResponseDTO.swift in Sources */, + 397215542CA8CF07009DF1F9 /* CancelBag.swift in Sources */, 39BE4BB62B4ABA97002B471D /* DetailClipListCollectionViewCell.swift in Sources */, 3FE28DC22B879B1400B6AED8 /* OnboardingType.swift in Sources */, 3F617CB52B4EC2EE00956E69 /* MypageHeaderView.swift in Sources */, @@ -2000,15 +2173,20 @@ 3F2FA17D2B4928B700EDBF95 /* LoginUseCase.swift in Sources */, 6BE6DA9A2B54747B008B06FA /* TimerAPIService.swift in Sources */, 6BE6DA882B546B24008B06FA /* PatchOpenLinkResponseDTO.swift in Sources */, + 396D7ECB2C855F5F0034A14E /* LinkWebViewModel.swift in Sources */, 6BE6DA752B50C373008B06FA /* GetCheckCategoryResponseDTO.swift in Sources */, 396DCE012CA19F5C00FEF7C8 /* GetPopupInfoResponseDTO.swift in Sources */, 6BE6D9EF2B4EE98D008B06FA /* RemindModel.swift in Sources */, 396DCDFE2CA19F4500FEF7C8 /* PatchPopupHiddenRequestDTO.swift in Sources */, 6BE6DA6B2B50BFED008B06FA /* NoneDataResponseDTO.swift in Sources */, 391908422B56CFE4006F978A /* PatchEditPriorityCategoryRequestDTO.swift in Sources */, + 396D7ECD2C880F1F0034A14E /* LinkWebToolBarView.swift in Sources */, 6BE6DA732B50C33A008B06FA /* GetAllCategoryResponseDTO.swift in Sources */, 3F2FA1752B45C0AF00EDBF95 /* Config.swift in Sources */, + 83D80DCC2CC1059000DD5410 /* RecentLinkModel.swift in Sources */, + 3F82C3212CADA19300492EEE /* Publisher+UIButton.swift in Sources */, 6B6AE6AC2B3FF6F7000E2366 /* UIColor+.swift in Sources */, + 3FEA67522CB663B100675805 /* ChangeClipViewModel.swift in Sources */, 6BC493682B45D7B100544249 /* ToasterNavigationController.swift in Sources */, 6BE6DA492B50ADC2008B06FA /* NetworkService.swift in Sources */, 6B6AE69C2B3FF5CC000E2366 /* HomeViewController.swift in Sources */, @@ -2043,19 +2221,21 @@ 398BE7FA2B467E4B001595E0 /* ClipListCollectionViewCell.swift in Sources */, 39A843D12B746420007A4D75 /* EditClipViewModel.swift in Sources */, 6BE6DA552B50B43E008B06FA /* GetSettingPageResponseDTO.swift in Sources */, + 3972155D2CA9007B009DF1F9 /* Publisher+UIBarButtonItem.swift in Sources */, 830517B02B4D9A3B009FFB60 /* UILabel+.swift in Sources */, 390925C42B4EF64100487AA3 /* LinkWebViewController.swift in Sources */, 6B6AE6A42B3FF6B0000E2366 /* UIViewController+.swift in Sources */, 39EF96012B501E8A00F301FC /* EditClipNoticeView.swift in Sources */, 8315CD8E2B547EE30061F377 /* SelectClipHeaderView.swift in Sources */, 6B6AE6A22B3FF5F7000E2366 /* TabBarController.swift in Sources */, + 39A232DE2CB8F28000ACC803 /* TipPathView.swift in Sources */, 3F2BFAC92B40370D00DA76B7 /* SocialLoginButtonView.swift in Sources */, - 6B6AE68A2B3FF582000E2366 /* MypageViewController.swift in Sources */, 6BE6DA432B50A999008B06FA /* PostRefreshTokenResponseDTO.swift in Sources */, 830471622B889641005AEEB4 /* HomeViewModel.swift in Sources */, 6BE6DA072B4F2FC5008B06FA /* RemindClipModel.swift in Sources */, 6B6AE69F2B3FF5D7000E2366 /* LoginViewController.swift in Sources */, 6BE6DA982B54709D008B06FA /* PatchEditTimerTitleRequestDTO.swift in Sources */, + 397586EC2CAA312C004FB095 /* Publisher+Driver.swift in Sources */, 6BE6DA362B5059CE008B06FA /* NetworkResult.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2281,7 +2461,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = "TeamLinkMIND.TOASTER-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -2316,7 +2496,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = "TeamLinkMIND.TOASTER-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/TOASTER-iOS/Application/SceneDelegate.swift b/TOASTER-iOS/Application/SceneDelegate.swift index 908dc812..c8179c4b 100644 --- a/TOASTER-iOS/Application/SceneDelegate.swift +++ b/TOASTER-iOS/Application/SceneDelegate.swift @@ -63,25 +63,22 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let appDelegate = UIApplication.shared.delegate as! AppDelegate - if let pasteboardString = UIPasteboard.general.url { + if let pasteboardUrl = UIPasteboard.general.url { if appDelegate.isLogin { guard let rootVC = window?.rootViewController as? ToasterNavigationController else { return } let addLinkViewController = AddLinkViewController() rootVC.pushViewController(addLinkViewController, animated: true) - addLinkViewController.embedURL(url: UIPasteboard.general.string ?? "") + addLinkViewController.embedURL(url: pasteboardUrl.absoluteString) if let presentedVC = rootVC.presentedViewController { presentedVC.dismiss(animated: false) } } } - UIPasteboard.general.string = nil } func sceneDidEnterBackground(_ scene: UIScene) { - // Called as the scene transitions from the foreground to the background. - // Use this method to save data, release shared resources, and store enough scene-specific state information - // to restore the scene back to its current state. + UIPasteboard.general.url = nil } func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { diff --git a/TOASTER-iOS/Global/Components/ToasterBottomSheet/ToasterBottomSheetViewController.swift b/TOASTER-iOS/Global/Components/ToasterBottomSheet/ToasterBottomSheetViewController.swift index ca1a09ec..1e5f1682 100644 --- a/TOASTER-iOS/Global/Components/ToasterBottomSheet/ToasterBottomSheetViewController.swift +++ b/TOASTER-iOS/Global/Components/ToasterBottomSheet/ToasterBottomSheetViewController.swift @@ -44,7 +44,7 @@ final class ToasterBottomSheetViewController: UIViewController { extension ToasterBottomSheetViewController { func setupSheetPresentation(bottomHeight: CGFloat) { if let sheet = self.sheetPresentationController { - sheet.detents = [.custom(resolver: { _ in bottomHeight - 40 })] + sheet.detents = [.custom(resolver: { _ in bottomHeight - (self.view.hasNotch ? 34 : 0)})] sheet.preferredCornerRadius = 20 } } @@ -52,7 +52,7 @@ extension ToasterBottomSheetViewController { func setupSheetHeightChanges(bottomHeight: CGFloat) { if let sheet = self.sheetPresentationController { sheet.animateChanges { - sheet.detents = [.custom(resolver: { _ in bottomHeight - 40 })] + sheet.detents = [.custom(resolver: { _ in bottomHeight - (self.view.hasNotch ? 34 : 0)})] } } } diff --git a/TOASTER-iOS/Global/Components/ToasterLoadingView/ToasterLoadingView.swift b/TOASTER-iOS/Global/Components/ToasterLoadingView/ToasterLoadingView.swift new file mode 100644 index 00000000..ba1a729c --- /dev/null +++ b/TOASTER-iOS/Global/Components/ToasterLoadingView/ToasterLoadingView.swift @@ -0,0 +1,124 @@ +// +// ToasterLoadingView.swift +// TOASTER-iOS +// +// Created by 민 on 10/7/24. +// + +import UIKit + +import SnapKit + +final class ToasterLoadingView: UIView { + + // MARK: - Properties + + /// 현재 로딩 뷰의 애니메이션이 동작하고 있는지를 Bool 값으로 반환 + private(set) var isAnimating: Bool = false + + /// 애니메이션이 중단될 때 로딩 뷰를 사라지게할지/말지를 Bool 값으로 결정 + var hidesWhenStopped: Bool = true + + // MARK: - UI Components + + private let backgroundShapeLayer = CAShapeLayer() + private let loadingShapeLayer = CAShapeLayer() + + // MARK: - Life Cycles + + override init(frame: CGRect) { + super.init(frame: frame) + setupStyle() + setupHierarchy() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + setupLayers() + } +} + +// MARK: - Extensions + +extension ToasterLoadingView { + /// 커스텀 로딩 애니메이션을 시작합니다 + func startAnimation() { + guard !isAnimating else { return } + + let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation") + rotationAnimation.toValue = 2 * CGFloat.pi + rotationAnimation.duration = 1 + rotationAnimation.isRemovedOnCompletion = false + rotationAnimation.repeatCount = .infinity + + layer.add(rotationAnimation, forKey: "rotationAnimation") + + isAnimating = true + if hidesWhenStopped { self.isHidden = false } + } + + /// 커스텀 로딩 애니메이션을 멈춥니다 + func stopAnimation() { + guard isAnimating else { return } + layer.removeAnimation(forKey: "rotationAnimation") + + isAnimating = false + if hidesWhenStopped { self.isHidden = true } + } +} + +// MARK: - Private Extensions + +private extension ToasterLoadingView { + func setupStyle() { + backgroundShapeLayer.do { + $0.strokeColor = UIColor.toasterWhite.cgColor + $0.fillColor = UIColor.clear.cgColor + } + + loadingShapeLayer.do { + $0.strokeColor = UIColor.black850.cgColor + $0.fillColor = UIColor.clear.cgColor + $0.strokeEnd = 0.25 + $0.lineCap = .round + } + } + + func setupLayers() { + let centerPoint = CGPoint(x: frame.width / 2, y: bounds.height / 2) + let radius = bounds.width / 2 + + // 흰색 부분의 동그라미 배경 경로 + let backgroundPath = UIBezierPath( + arcCenter: centerPoint, + radius: radius, + startAngle: 0, + endAngle: 2 * CGFloat.pi, + clockwise: true + ) + backgroundShapeLayer.path = backgroundPath.cgPath + backgroundShapeLayer.lineWidth = radius / 3 + + // 검정색 실제 로딩이 되는 부분의 경로 + let loadingPath = UIBezierPath( + arcCenter: centerPoint, + radius: radius, + startAngle: 0, + endAngle: 2 * CGFloat.pi, + clockwise: true + ) + loadingShapeLayer.path = loadingPath.cgPath + loadingShapeLayer.lineWidth = radius / 3 + } + + func setupHierarchy() { + [backgroundShapeLayer, loadingShapeLayer].forEach { + layer.addSublayer($0) + } + } +} diff --git a/TOASTER-iOS/Global/Components/ToasterTipView/TipPathView.swift b/TOASTER-iOS/Global/Components/ToasterTipView/TipPathView.swift new file mode 100644 index 00000000..437fdeb6 --- /dev/null +++ b/TOASTER-iOS/Global/Components/ToasterTipView/TipPathView.swift @@ -0,0 +1,94 @@ +// +// TipType.swift +// TOASTER-iOS +// +// Created by 민 on 10/11/24. +// + +import UIKit + +import SnapKit + +enum TipType { + case top, bottom, left, right +} + +final class TipPathView: UIView { + + // MARK: - Properties + + private var tipType: TipType + + private let arrowWidth: CGFloat = 10.0 + private let arrowHeight: CGFloat = 9.0 + + // MARK: - UI Components + + private let tipPath = UIBezierPath() + + // MARK: - Life Cycles + + init(tipType: TipType) { + self.tipType = tipType + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func draw(_ rect: CGRect) { + switch tipType { + case .top: drawTopTip(rect) + case .bottom: drawBottomTip(rect) + case .left: drawLeftTip(rect) + case .right: drawRightTip(rect) + } + setupTip() + } +} + +// MARK: - Private Extensions + +private extension TipPathView { + func setupTip() { + tipPath.do { + $0.lineJoinStyle = .round + $0.lineWidth = 2 + tipPath.close() + UIColor.black900.setStroke() + tipPath.stroke() + UIColor.black900.setFill() + tipPath.fill() + } + } + + /// 팁이 상단에 위치했을 때 - 아래를 가리키는 방향 + func drawTopTip(_ rect: CGRect) { + tipPath.move(to: CGPoint(x: rect.midX - arrowWidth/2, y: rect.maxY - arrowHeight)) + tipPath.addLine(to: CGPoint(x: rect.midX, y: rect.maxY)) + tipPath.addLine(to: CGPoint(x: rect.midX + arrowWidth/2, y: rect.maxY - arrowHeight)) + } + + /// 팁이 하단에 위치했을 때 - 위를 가리키는 방향 + func drawBottomTip(_ rect: CGRect) { + tipPath.move(to: CGPoint(x: rect.midX - arrowWidth/2, y: rect.minY + arrowHeight)) + tipPath.addLine(to: CGPoint(x: rect.midX, y: rect.minY)) + tipPath.addLine(to: CGPoint(x: rect.midX + arrowWidth/2, y: rect.minY + arrowHeight)) + } + + /// 팁이 좌측에 위치했을 때 - 오른쪽을 가리키는 방향 + func drawLeftTip(_ rect: CGRect) { + tipPath.move(to: CGPoint(x: rect.maxX - arrowHeight, y: rect.midY - arrowWidth/2)) + tipPath.addLine(to: CGPoint(x: rect.maxX, y: rect.midY)) + tipPath.addLine(to: CGPoint(x: rect.maxX - arrowHeight, y: rect.midY + arrowWidth/2)) + } + + /// 팁이 우측에 위치했을 때 - 왼쪽을 가리키는 방향 + func drawRightTip(_ rect: CGRect) { + tipPath.move(to: CGPoint(x: rect.minX + arrowHeight, y: rect.midY - arrowWidth/2)) + tipPath.addLine(to: CGPoint(x: rect.minX, y: rect.midY)) + tipPath.addLine(to: CGPoint(x: rect.minX + arrowHeight, y: rect.midY + arrowWidth/2)) + } +} diff --git a/TOASTER-iOS/Global/Components/ToasterTipView/TipUserDefaults.swift b/TOASTER-iOS/Global/Components/ToasterTipView/TipUserDefaults.swift new file mode 100644 index 00000000..f641f4cc --- /dev/null +++ b/TOASTER-iOS/Global/Components/ToasterTipView/TipUserDefaults.swift @@ -0,0 +1,14 @@ +// +// TipUserDefaults.swift +// TOASTER-iOS +// +// Created by 민 on 10/29/24. +// + +import Foundation + +enum TipUserDefaults { + static let isShowHomeViewToolTip = "homeViewToolTip" + static let isShowDetailClipViewToolTip = "detailClipViewToolTip" + static let isShowLinkWebViewToolTip = "linkWebViewToolTip" +} diff --git a/TOASTER-iOS/Global/Components/ToasterTipView/ToasterTipView.swift b/TOASTER-iOS/Global/Components/ToasterTipView/ToasterTipView.swift new file mode 100644 index 00000000..25c6fc36 --- /dev/null +++ b/TOASTER-iOS/Global/Components/ToasterTipView/ToasterTipView.swift @@ -0,0 +1,201 @@ +// +// ToasterTipView.swift +// TOASTER-iOS +// +// Created by 민 on 10/11/24. +// + +import UIKit + +import SnapKit + +final class ToasterTipView: UIView { + + // MARK: - Properties + + /// 현재 툴팁이 보여지고 있는지 여부를 Bool 값으로 반환 + private(set) var isShow: Bool = false + + private let title: String + private let tipType: TipType + + // MARK: - UI Components + + private weak var sourceView: UIView? + + private let containerView = UIView() + private let tipLabel = UILabel() + private lazy var tipPathView = TipPathView(tipType: tipType) + + // MARK: - Life Cycles + + init(title: String, type: TipType, sourceItem: AnyObject) { + self.title = title + self.tipType = type + self.sourceView = (sourceItem as? UIView) ?? sourceItem.view + super.init(frame: .zero) + setupStyle() + setupHierarchy() + setupLayout() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Methods + +extension ToasterTipView { + /// 툴팁을 보여줄 때 호출하는 함수 (with 애니메이션) + func showToolTip() { + guard !isShow else { return } + guard let sourceView else { return } + isShow = true + + setupTooltipLayoutBySourceView() + self.alpha = 0 + self.transform = CGAffineTransform(scaleX: 0.2, y: 0.2) + + let finalPosition: CGPoint + switch tipType { + case .top: + finalPosition = CGPoint( + x: sourceView.center.x, + y: sourceView.frame.minY + ) + case .bottom: + finalPosition = CGPoint( + x: sourceView.center.x, + y: sourceView.frame.maxY + ) + case .left: + finalPosition = CGPoint( + x: sourceView.frame.minX - self.frame.width / 2, + y: sourceView.center.y + ) + case .right: + finalPosition = CGPoint( + x: sourceView.frame.maxX + self.frame.width / 2, + y: sourceView.center.y + ) + } + self.center = CGPoint( + x: sourceView.center.x, + y: sourceView.center.y + ) + UIView.animate( + withDuration: 0.3, + delay: 0, + options: [.curveEaseInOut], + animations: { [weak self] in + guard let self else { return } + self.alpha = 1 + self.transform = CGAffineTransform.identity + self.center = finalPosition + }) + } + + /// 툴팁을 사라지게 할 때 호출하는 함수 (with 애니메이션) + func dismissToolTip(completion: (() -> Void)? = nil) { + UIView.animate( + withDuration: 0.3, + delay: 0, + options: [.curveEaseInOut], + animations: { [weak self] in + guard let self else { return } + guard self.isShow else { return } + self.isShow = false + self.alpha = 0 + }, completion: { _ in + self.removeFromSuperview() + completion?() + }) + } + + /// 툴팁을 보여주고, 특정 시간 이후에 자동으로 닫히도록 하는 함수 (with 애니메이션) + func showToolTipAndDismissAfterDelay( + duration: Int, + completion: (() -> Void)? = nil + ) { + showToolTip() + DispatchQueue.main.asyncAfter( + deadline: .now() + .seconds(duration) + ) { [weak self] in + self?.dismissToolTip(completion: completion) + } + } +} + +// MARK: - Private Extensions + +private extension ToasterTipView { + func setupStyle() { + backgroundColor = .clear + + tipPathView.do { + $0.backgroundColor = .clear + } + + containerView.do { + $0.backgroundColor = .black900 + $0.makeRounded(radius: 8) + } + + tipLabel.do { + $0.text = title + $0.numberOfLines = 2 + $0.font = .suitMedium(size: 12) + $0.textColor = .toasterWhite + $0.textAlignment = .center + } + } + + func setupHierarchy() { + addSubviews(tipPathView, containerView) + containerView.addSubviews(tipLabel) + } + + func setupLayout() { + tipPathView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + containerView.snp.makeConstraints { + $0.edges.equalToSuperview().inset(9) + } + + tipLabel.snp.makeConstraints { + $0.top.bottom.equalToSuperview().inset(8) + $0.leading.trailing.equalToSuperview().inset(10) + } + } + + func setupTooltipLayoutBySourceView() { + guard let sourceView else { return } + + switch tipType { + case .top: + self.snp.makeConstraints { + $0.bottom.equalTo(sourceView.snp.top).offset(-8) + $0.centerX.equalTo(sourceView.snp.centerX) + } + case .bottom: + self.snp.makeConstraints { + $0.top.equalTo(sourceView.snp.bottom).offset(8) + $0.centerX.equalTo(sourceView.snp.centerX) + } + case .left: + self.snp.makeConstraints { + $0.right.equalTo(sourceView.snp.left).offset(-8) + $0.centerY.equalTo(sourceView.snp.centerY) + } + case .right: + self.snp.makeConstraints { + $0.left.equalTo(sourceView.snp.right).offset(8) + $0.centerY.equalTo(sourceView.snp.centerY) + } + } + } +} diff --git a/TOASTER-iOS/Global/Consts/StringLiterals.swift b/TOASTER-iOS/Global/Consts/StringLiterals.swift index 6a045f84..9da41670 100644 --- a/TOASTER-iOS/Global/Consts/StringLiterals.swift +++ b/TOASTER-iOS/Global/Consts/StringLiterals.swift @@ -21,7 +21,7 @@ enum StringLiterals { static let home = "HOME" static let clip = "CLIP" static let timer = "TIMER" - static let my = "MY" + static let search = "SEARCH" } enum Button { diff --git a/TOASTER-iOS/Global/Extensions/Combine+/CancelBag.swift b/TOASTER-iOS/Global/Extensions/Combine+/CancelBag.swift new file mode 100644 index 00000000..25565268 --- /dev/null +++ b/TOASTER-iOS/Global/Extensions/Combine+/CancelBag.swift @@ -0,0 +1,23 @@ +// +// CancelBag.swift +// TOASTER-iOS +// +// Created by 민 on 9/29/24. +// + +import Combine + +class CancelBag { + var cancellables = Set() + + deinit { + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + } +} + +extension AnyCancellable { + func store(in cancelBag: CancelBag) { + cancelBag.cancellables.insert(self) + } +} diff --git a/TOASTER-iOS/Global/Extensions/Combine+/Publisher+Driver.swift b/TOASTER-iOS/Global/Extensions/Combine+/Publisher+Driver.swift new file mode 100644 index 00000000..bae8e2fe --- /dev/null +++ b/TOASTER-iOS/Global/Extensions/Combine+/Publisher+Driver.swift @@ -0,0 +1,32 @@ +// +// Publisher+Driver.swift +// TOASTER-iOS +// +// Created by 민 on 9/30/24. +// + +import Combine +import Foundation + +public typealias Driver = AnyPublisher + +public extension Publisher { + func asDriver() -> Driver { + return self.catch { _ in Empty() } + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } + + static func just(_ output: Output) -> Driver { + return Just(output).eraseToAnyPublisher() + } + + static func empty() -> Driver { + return Empty().eraseToAnyPublisher() + } + + func mapVoid() -> AnyPublisher { + return self.map { _ in () } + .eraseToAnyPublisher() + } +} diff --git a/TOASTER-iOS/Global/Extensions/Combine+/Publisher+UIBarButtonItem.swift b/TOASTER-iOS/Global/Extensions/Combine+/Publisher+UIBarButtonItem.swift new file mode 100644 index 00000000..92d6ef2f --- /dev/null +++ b/TOASTER-iOS/Global/Extensions/Combine+/Publisher+UIBarButtonItem.swift @@ -0,0 +1,58 @@ +// +// Publisher+UIBarButtonItem.swift +// TOASTER-iOS +// +// Created by 민 on 9/29/24. +// + +import Combine +import UIKit + +extension UIBarButtonItem { + func publisher() -> UIBarButtonItemPublisher { + UIBarButtonItemPublisher(item: self) + } +} + +final class UIBarButtonItemSubscription: Subscription where S.Input == Item { + private var subscriber: S? + private let item: Item + + init(subscriber: S, item: Item) { + self.subscriber = subscriber + self.item = item + item.target = self + item.action = #selector(eventHandler) + } + + func request(_ demand: Subscribers.Demand) {} + + func cancel() { + subscriber = nil + } + + @objc + private func eventHandler() { + _ = subscriber?.receive(item) + } +} + +public struct UIBarButtonItemPublisher: Publisher { + public typealias Output = Item + public typealias Failure = Never + + private let item: Item + + init(item: Item) { + self.item = item + } + + public func receive(subscriber: S) where S: Subscriber, + S.Failure == Self.Failure, + S.Input == Self.Output { + let subscription = UIBarButtonItemSubscription( + subscriber: subscriber, + item: item) + subscriber.receive(subscription: subscription) + } +} diff --git a/TOASTER-iOS/Global/Extensions/Combine+/Publisher+UIButton.swift b/TOASTER-iOS/Global/Extensions/Combine+/Publisher+UIButton.swift new file mode 100644 index 00000000..f66bfdeb --- /dev/null +++ b/TOASTER-iOS/Global/Extensions/Combine+/Publisher+UIButton.swift @@ -0,0 +1,17 @@ +// +// Publisher+UIButton.swift +// TOASTER-iOS +// +// Created by ParkJunHyuk on 10/3/24. +// + +import Combine +import UIKit + +extension UIButton { + func tapPublisher() -> AnyPublisher { + publisher(for: .touchUpInside) + .map { _ in () } + .eraseToAnyPublisher() + } +} diff --git a/TOASTER-iOS/Global/Extensions/Combine+/Publisher+UIControl.swift b/TOASTER-iOS/Global/Extensions/Combine+/Publisher+UIControl.swift new file mode 100644 index 00000000..3ceaa979 --- /dev/null +++ b/TOASTER-iOS/Global/Extensions/Combine+/Publisher+UIControl.swift @@ -0,0 +1,61 @@ +// +// Publisher+UIControl.swift +// TOASTER-iOS +// +// Created by 민 on 9/29/24. +// + +import Combine +import UIKit + +extension UIControl { + func publisher(for events: UIControl.Event) -> UIControlPublisher { + UIControlPublisher(control: self, events: events) + } +} + +final class UIControlSubscription: Subscription where S.Input == Control { + private var subscriber: S? + private let control: Control + + init(subscriber: S, control: Control, event: UIControl.Event) { + self.subscriber = subscriber + self.control = control + control.addTarget(self, action: #selector(eventHandler), for: event) + } + + func request(_ demand: Subscribers.Demand) {} + + func cancel() { + subscriber = nil + } + + @objc + private func eventHandler() { + _ = subscriber?.receive(control) + } +} + +public struct UIControlPublisher: Publisher { + public typealias Output = Control + public typealias Failure = Never + + private let control: Control + private let controlEvents: UIControl.Event + + init(control: Control, events: UIControl.Event) { + self.control = control + self.controlEvents = events + } + + public func receive(subscriber: S) where S: Subscriber, + S.Failure == UIControlPublisher.Failure, + S.Input == UIControlPublisher.Output { + let subscription = UIControlSubscription( + subscriber: subscriber, + control: control, + event: controlEvents + ) + subscriber.receive(subscription: subscription) + } +} diff --git a/TOASTER-iOS/Global/Extensions/Combine+/Publisher+UIGesture.swift b/TOASTER-iOS/Global/Extensions/Combine+/Publisher+UIGesture.swift new file mode 100644 index 00000000..c25381db --- /dev/null +++ b/TOASTER-iOS/Global/Extensions/Combine+/Publisher+UIGesture.swift @@ -0,0 +1,94 @@ +// +// Publisher+UIGesture.swift +// TOASTER-iOS +// +// Created by 민 on 9/29/24. +// + +import Combine +import UIKit + +extension UIView { + func gesture(_ gestureType: GestureType) -> GesturePublisher { + self.isUserInteractionEnabled = true + return GesturePublisher(view: self, gestureType: gestureType) + } +} + +final class GestureSubscription: Subscription where S.Input == GestureType { + private var subscriber: S? + private let gestureType: GestureType + private var view: UIView + + init(subscriber: S, gestureType: GestureType, view: UIView) { + self.subscriber = subscriber + self.gestureType = gestureType + self.view = view + configureGesture(gestureType) + } + + private func configureGesture(_ gestureType: GestureType) { + let gesture = gestureType.get() + gesture.addTarget(self, action: #selector(eventHandler)) + self.view.addGestureRecognizer(gesture) + } + + func request(_ demand: Subscribers.Demand) {} + + func cancel() { + subscriber = nil + } + + @objc + private func eventHandler() { + _ = subscriber?.receive(self.gestureType) + } +} + +public struct GesturePublisher: Publisher { + public typealias Output = GestureType + public typealias Failure = Never + + private let view: UIView + private let gestureType: GestureType + + init(view: UIView, gestureType: GestureType) { + self.view = view + self.gestureType = gestureType + } + + public func receive(subscriber: S) where S: Subscriber, + S.Failure == GesturePublisher.Failure, + S.Input == GesturePublisher.Output { + let subscription = GestureSubscription( + subscriber: subscriber, + gestureType: self.gestureType, + view: self.view) + subscriber.receive(subscription: subscription) + } +} + +public enum GestureType { + case tap(UITapGestureRecognizer = .init()) + case swipe(UISwipeGestureRecognizer = .init()) + case longPress(UILongPressGestureRecognizer = .init()) + case pan(UIPanGestureRecognizer = .init()) + case pinch(UIPinchGestureRecognizer = .init()) + case edge(UIScreenEdgePanGestureRecognizer = .init()) + + func get() -> UIGestureRecognizer { + switch self { + case let .tap(tapGesture): return tapGesture + case let .swipe(swipeGesture): + return swipeGesture + case let .longPress(longPressGesture): + return longPressGesture + case let .pan(panGesture): + return panGesture + case let .pinch(pinchGesture): + return pinchGesture + case let .edge(edgePanGesture): + return edgePanGesture + } + } +} diff --git a/TOASTER-iOS/Global/Extensions/NSObject+.swift b/TOASTER-iOS/Global/Extensions/Foundation+/NSObject+.swift similarity index 100% rename from TOASTER-iOS/Global/Extensions/NSObject+.swift rename to TOASTER-iOS/Global/Extensions/Foundation+/NSObject+.swift diff --git a/TOASTER-iOS/Global/Extensions/UIKit+/UIButton+.swift b/TOASTER-iOS/Global/Extensions/UIKit+/UIButton+.swift new file mode 100644 index 00000000..30226cf6 --- /dev/null +++ b/TOASTER-iOS/Global/Extensions/UIKit+/UIButton+.swift @@ -0,0 +1,64 @@ +// +// UIButton+.swift +// TOASTER-iOS +// +// Created by 민 on 10/7/24. +// + +import UIKit + +import SnapKit + +extension UIButton { + + /// 버튼 클릭 시 비동기 작업+로딩 애니메이션을 처리하기 위한 메서드입니다. + func loadingButtonTapped( + loadingTitle: String?, + loadingAnimationSize: Int, + task: (@escaping () -> Void) -> Void + ) { + let originalTitle = self.title(for: .normal) + let originalBackgroundColor = self.backgroundColor + + self.setTitle(loadingTitle, for: .normal) + self.isEnabled = false + self.backgroundColor = .gray200 + + let toasterLoadingView = ToasterLoadingView() + toasterLoadingView.alpha = 0 + self.addSubview(toasterLoadingView) + toasterLoadingView.snp.makeConstraints { + $0.size.equalTo(loadingAnimationSize) + $0.centerY.equalToSuperview() + $0.leading.equalTo(self.titleLabel?.snp.trailing ?? self.snp.centerX) + } + + UIView.animate(withDuration: 0.2, animations: { + toasterLoadingView.snp.updateConstraints { + $0.leading.equalTo(self.titleLabel?.snp.trailing ?? self.snp.centerX).offset(10) + } + self.layoutIfNeeded() + }, completion: { _ in + toasterLoadingView.alpha = 1.0 + toasterLoadingView.startAnimation() + }) + + task { + DispatchQueue.main.async { + UIView.animate(withDuration: 0.2, animations: { + toasterLoadingView.snp.updateConstraints { + $0.leading.equalTo(self.titleLabel?.snp.trailing ?? self.snp.centerX) + } + toasterLoadingView.alpha = 0 + self.layoutIfNeeded() + }, completion: { _ in + toasterLoadingView.stopAnimation() + toasterLoadingView.removeFromSuperview() + self.setTitle(originalTitle, for: .normal) + self.isEnabled = true + self.backgroundColor = originalBackgroundColor + }) + } + } + } +} diff --git a/TOASTER-iOS/Global/Extensions/UIColor+.swift b/TOASTER-iOS/Global/Extensions/UIKit+/UIColor+.swift similarity index 100% rename from TOASTER-iOS/Global/Extensions/UIColor+.swift rename to TOASTER-iOS/Global/Extensions/UIKit+/UIColor+.swift diff --git a/TOASTER-iOS/Global/Extensions/UILabel+.swift b/TOASTER-iOS/Global/Extensions/UIKit+/UILabel+.swift similarity index 100% rename from TOASTER-iOS/Global/Extensions/UILabel+.swift rename to TOASTER-iOS/Global/Extensions/UIKit+/UILabel+.swift diff --git a/TOASTER-iOS/Global/Extensions/UIStackView+.swift b/TOASTER-iOS/Global/Extensions/UIKit+/UIStackView+.swift similarity index 100% rename from TOASTER-iOS/Global/Extensions/UIStackView+.swift rename to TOASTER-iOS/Global/Extensions/UIKit+/UIStackView+.swift diff --git a/TOASTER-iOS/Global/Extensions/UITextField+.swift b/TOASTER-iOS/Global/Extensions/UIKit+/UITextField+.swift similarity index 100% rename from TOASTER-iOS/Global/Extensions/UITextField+.swift rename to TOASTER-iOS/Global/Extensions/UIKit+/UITextField+.swift diff --git a/TOASTER-iOS/Global/Extensions/UIView+.swift b/TOASTER-iOS/Global/Extensions/UIKit+/UIView+.swift similarity index 100% rename from TOASTER-iOS/Global/Extensions/UIView+.swift rename to TOASTER-iOS/Global/Extensions/UIKit+/UIView+.swift diff --git a/TOASTER-iOS/Global/Extensions/UIViewController+.swift b/TOASTER-iOS/Global/Extensions/UIKit+/UIViewController+.swift similarity index 100% rename from TOASTER-iOS/Global/Extensions/UIViewController+.swift rename to TOASTER-iOS/Global/Extensions/UIKit+/UIViewController+.swift diff --git a/TOASTER-iOS/Global/Protocols/ViewModelType.swift b/TOASTER-iOS/Global/Protocols/ViewModelType.swift new file mode 100644 index 00000000..a58aaa74 --- /dev/null +++ b/TOASTER-iOS/Global/Protocols/ViewModelType.swift @@ -0,0 +1,16 @@ +// +// ViewModelType.swift +// TOASTER-iOS +// +// Created by Gahyun Kim on 9/27/24. +// + +import Combine +import Foundation + +protocol ViewModelType { + associatedtype Input + associatedtype Output + + func transform(_ input: Input, cancelBag: CancelBag) -> Output +} diff --git a/TOASTER-iOS/Global/Resources/Assets/Assets.xcassets/Icons_24/ic_read.imageset/ic_read.svg b/TOASTER-iOS/Global/Resources/Assets/Assets.xcassets/Icons_24/ic_read.imageset/ic_read.svg index 729b26d3..26a27a41 100644 --- a/TOASTER-iOS/Global/Resources/Assets/Assets.xcassets/Icons_24/ic_read.imageset/ic_read.svg +++ b/TOASTER-iOS/Global/Resources/Assets/Assets.xcassets/Icons_24/ic_read.imageset/ic_read.svg @@ -1,3 +1,3 @@ - + diff --git a/TOASTER-iOS/Global/Resources/Assets/Assets.xcassets/Icons_24/ic_search_24.imageset/Contents.json b/TOASTER-iOS/Global/Resources/Assets/Assets.xcassets/Icons_24/ic_search_24.imageset/Contents.json new file mode 100644 index 00000000..45c37726 --- /dev/null +++ b/TOASTER-iOS/Global/Resources/Assets/Assets.xcassets/Icons_24/ic_search_24.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "ic_search_24.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TOASTER-iOS/Global/Resources/Assets/Assets.xcassets/Icons_24/ic_search_24.imageset/ic_search_24.svg b/TOASTER-iOS/Global/Resources/Assets/Assets.xcassets/Icons_24/ic_search_24.imageset/ic_search_24.svg new file mode 100644 index 00000000..5119069c --- /dev/null +++ b/TOASTER-iOS/Global/Resources/Assets/Assets.xcassets/Icons_24/ic_search_24.imageset/ic_search_24.svg @@ -0,0 +1,3 @@ + + + diff --git a/TOASTER-iOS/Global/Resources/Assets/Assets.xcassets/Icons_24/ic_share.imageset/Contents.json b/TOASTER-iOS/Global/Resources/Assets/Assets.xcassets/Icons_24/ic_share.imageset/Contents.json new file mode 100644 index 00000000..2d17bf8a --- /dev/null +++ b/TOASTER-iOS/Global/Resources/Assets/Assets.xcassets/Icons_24/ic_share.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "ic_share.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TOASTER-iOS/Global/Resources/Assets/Assets.xcassets/Icons_24/ic_share.imageset/ic_share.svg b/TOASTER-iOS/Global/Resources/Assets/Assets.xcassets/Icons_24/ic_share.imageset/ic_share.svg new file mode 100644 index 00000000..6a32d58b --- /dev/null +++ b/TOASTER-iOS/Global/Resources/Assets/Assets.xcassets/Icons_24/ic_share.imageset/ic_share.svg @@ -0,0 +1,3 @@ + + + diff --git a/TOASTER-iOS/Network/Base/NetworkResult.swift b/TOASTER-iOS/Network/Base/NetworkResult.swift index 87bd9b5c..c839dad6 100644 --- a/TOASTER-iOS/Network/Base/NetworkResult.swift +++ b/TOASTER-iOS/Network/Base/NetworkResult.swift @@ -7,7 +7,7 @@ import Foundation -enum NetworkResult { +enum NetworkResult: Error { case success(T?) diff --git a/TOASTER-iOS/Network/Toaster/DTO/Request/PatchChangeCategoryRequestDTO.swift b/TOASTER-iOS/Network/Toaster/DTO/Request/PatchChangeCategoryRequestDTO.swift new file mode 100644 index 00000000..45890569 --- /dev/null +++ b/TOASTER-iOS/Network/Toaster/DTO/Request/PatchChangeCategoryRequestDTO.swift @@ -0,0 +1,13 @@ +// +// PatchChangeCategoryRequestDTO.swift +// TOASTER-iOS +// +// Created by ParkJunHyuk on 10/8/24. +// + +import Foundation + +struct PatchChangeCategoryRequestDTO: Codable { + let toastId: Int + let categoryId: Int +} diff --git a/TOASTER-iOS/Network/Toaster/DTO/Response/GetRecentLinkResponseDTO.swift b/TOASTER-iOS/Network/Toaster/DTO/Response/GetRecentLinkResponseDTO.swift new file mode 100644 index 00000000..df8d2f3a --- /dev/null +++ b/TOASTER-iOS/Network/Toaster/DTO/Response/GetRecentLinkResponseDTO.swift @@ -0,0 +1,23 @@ +// +// GetRecentLinkResponseDTO.swift +// TOASTER-iOS +// +// Created by Gahyun Kim on 10/17/24. +// + +import Foundation + +struct GetRecentLinkResponseDTO: Codable { + let code: Int + let message: String + let data: [GetRecentLinkResponseData] +} + +struct GetRecentLinkResponseData: Codable { + let toastId: Int + let toastTitle: String + let linkUrl: String + let isRead: Bool + let categoryTitle: String? + let thumbnailUrl: String? +} diff --git a/TOASTER-iOS/Network/Toaster/DTO/Response/PatchChangeCategoryResponseDTO.swift b/TOASTER-iOS/Network/Toaster/DTO/Response/PatchChangeCategoryResponseDTO.swift new file mode 100644 index 00000000..9b0fcf2d --- /dev/null +++ b/TOASTER-iOS/Network/Toaster/DTO/Response/PatchChangeCategoryResponseDTO.swift @@ -0,0 +1,18 @@ +// +// PatchChangeCategoryResponseDTO.swift +// TOASTER-iOS +// +// Created by ParkJunHyuk on 10/8/24. +// + +import Foundation + +struct PatchChangeCategoryResponseDTO: Codable { + let code: Int + let message: String + let data: PatchChangeCategoryResponseData +} + +struct PatchChangeCategoryResponseData: Codable { + let categoryId: Int +} diff --git a/TOASTER-iOS/Network/Toaster/ToasterAPIService.swift b/TOASTER-iOS/Network/Toaster/ToasterAPIService.swift index 684165f7..44748028 100644 --- a/TOASTER-iOS/Network/Toaster/ToasterAPIService.swift +++ b/TOASTER-iOS/Network/Toaster/ToasterAPIService.swift @@ -19,6 +19,9 @@ protocol ToasterAPIServiceProtocol { func getWeeksLink(completion: @escaping (NetworkResult) -> Void) func patchEditLinkTitle(requestBody: PatchEditLinkTitleRequestDTO, completion: @escaping (NetworkResult) -> Void) + func getRecentLink(completion: @escaping (NetworkResult) -> Void) + func patchChangeCategory(requestBody: PatchChangeCategoryRequestDTO, + completion: @escaping (NetworkResult) -> Void) } final class ToasterAPIService: BaseAPIService, ToasterAPIServiceProtocol { @@ -108,4 +111,37 @@ final class ToasterAPIService: BaseAPIService, ToasterAPIServiceProtocol { } } } + + func getRecentLink(completion: @escaping (NetworkResult) -> Void) { + provider.request(.getRecentLink) { result in + switch result { + case .success(let response): + let networkResult: NetworkResult = self.fetchNetworkResult(statusCode: response.statusCode, data: response.data) + print(networkResult.stateDescription) + completion(networkResult) + case .failure(let error): + if let response = error.response { + let networkResult: NetworkResult = self.fetchNetworkResult(statusCode: response.statusCode, data: response.data) + completion(networkResult) + } + } + } + } + + func patchChangeCategory(requestBody: PatchChangeCategoryRequestDTO, + completion: @escaping (NetworkResult) -> Void) { + provider.request(.patchChangeCategory(requestBody: requestBody)) { result in + switch result { + case .success(let response): + let networkResult: NetworkResult = self.fetchNetworkResult(statusCode: response.statusCode, data: response.data) + print(networkResult.stateDescription) + completion(networkResult) + case .failure(let error): + if let response = error.response { + let networkResult: NetworkResult = self.fetchNetworkResult(statusCode: response.statusCode, data: response.data) + completion(networkResult) + } + } + } + } } diff --git a/TOASTER-iOS/Network/Toaster/ToasterTargetType.swift b/TOASTER-iOS/Network/Toaster/ToasterTargetType.swift index 8c59b0a1..5d840fa7 100644 --- a/TOASTER-iOS/Network/Toaster/ToasterTargetType.swift +++ b/TOASTER-iOS/Network/Toaster/ToasterTargetType.swift @@ -15,6 +15,8 @@ enum ToasterTargetType { case deleteLink(toastId: Int) case getWeeksLink case patchEditLinkTitle(requestBody: PatchEditLinkTitleRequestDTO) + case getRecentLink + case patchChangeCategory(requestBody: PatchChangeCategoryRequestDTO) } extension ToasterTargetType: BaseTargetType { @@ -35,6 +37,7 @@ extension ToasterTargetType: BaseTargetType { case .postSaveLink(let body): return body case .patchOpenLink(let body): return body case .patchEditLinkTitle(let body): return body + case .patchChangeCategory(let body): return body default: return .none } } @@ -46,6 +49,8 @@ extension ToasterTargetType: BaseTargetType { case .deleteLink: return utilPath.rawValue + "/delete" case .getWeeksLink: return utilPath.rawValue + "/week" case .patchEditLinkTitle: return utilPath.rawValue + "/title" + case .getRecentLink: return utilPath.rawValue + "/recent-saved" + case .patchChangeCategory: return utilPath.rawValue + "/category" } } @@ -56,6 +61,8 @@ extension ToasterTargetType: BaseTargetType { case .deleteLink: return .delete case .getWeeksLink: return .get case .patchEditLinkTitle: return .patch + case .getRecentLink: return .get + case .patchChangeCategory: return .patch } } } diff --git a/TOASTER-iOS/Present/AddLink/LinkEmbed/View/AddLinkView.swift b/TOASTER-iOS/Present/AddLink/LinkEmbed/View/AddLinkView.swift index 0d72bd4e..782b95c2 100644 --- a/TOASTER-iOS/Present/AddLink/LinkEmbed/View/AddLinkView.swift +++ b/TOASTER-iOS/Present/AddLink/LinkEmbed/View/AddLinkView.swift @@ -12,16 +12,15 @@ import Then final class AddLinkView: UIView { - // MARK: - Properties - - private var timer: Timer? + // MARK: - Property + private var keyboardHeight: CGFloat = 100 // MARK: - UI Components private let descriptLabel = UILabel() - var linkEmbedTextField = UITextField() - private let clearButton = UIButton() + private(set) var linkEmbedTextField = UITextField() + let clearButton = UIButton() let nextBottomButton = UIButton() let nextTopButton = UIButton() @@ -36,7 +35,7 @@ final class AddLinkView: UIView { super.init(frame: frame) setLinkEmbedTextField() - setView() + setupView() } @available(*, unavailable) @@ -46,14 +45,13 @@ final class AddLinkView: UIView { // MARK: - Make View - func setView() { + func setupView() { setupStyle() setupHierarchy() setupLayout() } func setLinkEmbedTextField() { - linkEmbedTextField.delegate = self linkEmbedTextField.resignFirstResponder() } @@ -89,6 +87,7 @@ private extension AddLinkView { clearButton.do { $0.setImage(.icCancle24, for: .normal) $0.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside) + $0.isHidden = true } nextBottomButton.do { @@ -112,7 +111,6 @@ private extension AddLinkView { func setupHierarchy() { addSubviews(descriptLabel, linkEmbedTextField, nextBottomButton, clearButton) - clearButton.isHidden = true accessoryView.addSubview(nextTopButton) } @@ -155,7 +153,8 @@ private extension AddLinkView { } } - @objc func cancelButtonTapped() { + @objc + func cancelButtonTapped() { linkEmbedTextField.text = "" linkEmbedTextField.becomeFirstResponder() } @@ -163,110 +162,18 @@ private extension AddLinkView { // MARK: - Extension -extension AddLinkView: UITextFieldDelegate { - - // MARK: - Timer Check - - func textFieldDidBeginEditing(_ textField: UITextField) { - // 텍스트 필드에 입력이 시작될 때 호출되는 메서드 - clearButton.isHidden = false - nextTopButton.backgroundColor = .black850 - linkEmbedTextField.placeholder = nil - - // 여기서 타이머를 시작하고, 0.5초 후에 텍스트를 확인 후 텍스트필드 에러 처리 - if textField.text?.count ?? 0 > 1 { - startTimer() - } - } - - func textField(_ textField: UITextField, - shouldChangeCharactersIn range: NSRange, - replacementString string: String) -> Bool { - - // 입력이 발생할 때마다 호출되는 메서드 - // 여기서 타이머를 재시작 - restartTimer() - return true - } - - func startTimer() { - // 0.5초 후에 checkTextField 메서드 호출 - timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in - // URL 유효 여부 판단 후 처리 - if let urlText = self?.linkEmbedTextField.text { - if (urlText.prefix(8) == "https://") || (urlText.prefix(7) == "http://") { - self?.resetError() - } else { - self?.isValidLinkError() - } - } - } - } - - func restartTimer() { - // 타이머 재시작 - stopTimer() - startTimer() - } - - func stopTimer() { - // 타이머를 정지, 테두리 초기화 - timer?.invalidate() - linkEmbedTextField.layer.borderColor = UIColor.clear.cgColor - } - - // MARK: - Text Field Error - - // 링크를 입력하는 텍스트필드가 비어 있을 경우 error 처리 - func emptyError() { - linkEmbedTextField.layer.borderColor = UIColor.toasterError.cgColor - linkEmbedTextField.layer.borderWidth = 1 - - // Button 비활성화 - nextTopButton.backgroundColor = .gray200 - nextBottomButton.backgroundColor = .gray200 - nextTopButton.isEnabled = false - nextBottomButton.isEnabled = false - - errorLabel.text = "링크를 입력해주세요" - addSubview(errorLabel) - errorLabel.snp.makeConstraints { - $0.top.equalTo(linkEmbedTextField.snp.bottom).offset(6) - $0.leading.equalTo(linkEmbedTextField.snp.leading) - } +extension AddLinkView { + func isValidLinkError(_ message: String) { + errorLabel.text = message errorLabel.isHidden = false - } - - // 링크가 유효하지 않을 경우 error 처리 - func isValidLinkError() { - linkEmbedTextField.layer.borderColor = UIColor.toasterError.cgColor - linkEmbedTextField.layer.borderWidth = 1 - - // Button 비활성화 - nextTopButton.backgroundColor = .gray200 - nextBottomButton.backgroundColor = .gray200 - nextTopButton.isEnabled = false - nextBottomButton.isEnabled = false - - errorLabel.text = "유효하지 않은 형식의 링크입니다" addSubview(errorLabel) errorLabel.snp.makeConstraints { $0.top.equalTo(linkEmbedTextField.snp.bottom).offset(6) $0.leading.equalTo(linkEmbedTextField.snp.leading) } - errorLabel.isHidden = false } - // 링크가 유효할 경우, error reset func resetError() { - linkEmbedTextField.layer.borderColor = UIColor.clear.cgColor - - // Button 활성화 - nextTopButton.backgroundColor = .black850 - nextBottomButton.backgroundColor = .black850 - nextTopButton.isEnabled = true - nextBottomButton.isEnabled = true - errorLabel.isHidden = true } } diff --git a/TOASTER-iOS/Present/AddLink/LinkEmbed/View/AddLinkViewController.swift b/TOASTER-iOS/Present/AddLink/LinkEmbed/View/AddLinkViewController.swift index 74fd5620..fc37e963 100644 --- a/TOASTER-iOS/Present/AddLink/LinkEmbed/View/AddLinkViewController.swift +++ b/TOASTER-iOS/Present/AddLink/LinkEmbed/View/AddLinkViewController.swift @@ -5,6 +5,7 @@ // Created by 김다예 on 12/30/23. // +import Combine import UIKit import SnapKit @@ -30,17 +31,18 @@ final class AddLinkViewController: UIViewController { private weak var delegate: AddLinkViewControllerPopDelegate? private weak var urldelegate: SelectClipViewControllerDelegate? - // MARK: - UI Properties - private var addLinkView = AddLinkView() + private var viewModel = AddLinkViewModel() + private var cancelBag = CancelBag() // MARK: - Life Cycle override func viewDidLoad() { super.viewDidLoad() + bindViewModels() setupStyle() - setAddLinkVew() + setupAddLinkVew() hideKeyboard() } @@ -56,19 +58,6 @@ final class AddLinkViewController: UIViewController { navigationBarHidden(forHidden: false) } - - // MARK: - set up Add Link View - - private func setAddLinkVew() { - view.addSubview(addLinkView) - - addLinkView.snp.makeConstraints { - $0.edges.equalTo(view.safeAreaLayoutGuide) - } - - addLinkView.nextBottomButton.addTarget(self, action: #selector(tappedNextBottomButton), for: .touchUpInside) - addLinkView.nextTopButton.addTarget(self, action: #selector(tappedNextBottomButton), for: .touchUpInside) - } } // MARK: - extension @@ -78,10 +67,17 @@ extension AddLinkViewController { delegate = forDelegate } - // 클립보드 붙여넣기 Alert -> 붙여넣기 허용 클릭 후 자동 링크 임베드를 위한 함수 + /// 클립보드 붙여넣기 Alert -> 붙여넣기 허용 클릭 후 자동 링크 임베드를 위한 함수 func embedURL(url: String) { addLinkView.linkEmbedTextField.becomeFirstResponder() addLinkView.linkEmbedTextField.text = url + viewModel.embedLinkText.send(url) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.addLinkView.linkEmbedTextField.sendActions(for: .editingChanged) + } + + UIPasteboard.general.url = nil } } @@ -92,6 +88,17 @@ private extension AddLinkViewController { view.backgroundColor = .toasterBackground } + func setupAddLinkVew() { + view.addSubview(addLinkView) + + addLinkView.snp.makeConstraints { + $0.edges.equalTo(view.safeAreaLayoutGuide) + } + + addLinkView.nextBottomButton.addTarget(self, action: #selector(tappedNextBottomButton), for: .touchUpInside) + addLinkView.nextTopButton.addTarget(self, action: #selector(tappedNextBottomButton), for: .touchUpInside) + } + func setupNavigationBar() { let type: ToasterNavigationType = ToasterNavigationType(hasBackButton: false, hasRightButton: true, @@ -123,18 +130,57 @@ private extension AddLinkViewController { } @objc func tappedNextBottomButton() { - if (addLinkView.linkEmbedTextField.text?.count ?? 0) < 1 { - addLinkView.emptyError() - } else { - let selectClipViewController = SelectClipViewController() - selectClipViewController.linkURL = addLinkView.linkEmbedTextField.text ?? "" - selectClipViewController.delegate = self - self.navigationController?.pushViewController(selectClipViewController, animated: true) - } + let selectClipViewController = SelectClipViewController() + selectClipViewController.linkURL = addLinkView.linkEmbedTextField.text ?? "" + selectClipViewController.delegate = self + self.navigationController?.pushViewController(selectClipViewController, animated: true) } } +extension AddLinkViewController { + private func bindViewModels() { + let embedLinkText = addLinkView.linkEmbedTextField + .publisher(for: .editingChanged) + .compactMap { [weak self] _ in self?.addLinkView.linkEmbedTextField.text ?? "" } + .eraseToAnyPublisher() + + let clearButtonTapped = addLinkView.clearButton.publisher(for: .touchUpInside) + .mapVoid() + + let input = AddLinkViewModel.Input(embedLinkText: embedLinkText, clearButtonTapped: clearButtonTapped) + let output = viewModel.transform(input, cancelBag: cancelBag) + + output.isClearButtonHidden + .sink { [weak self] isHidden in + self?.addLinkView.clearButton.isHidden = isHidden + } + .store(in: cancelBag) + + output.isNextButtonEnabled + .sink { [weak self] isEnabled in + self?.addLinkView.nextTopButton.isEnabled = isEnabled + self?.addLinkView.nextTopButton.backgroundColor = isEnabled ? .black850 : .gray200 + self?.addLinkView.nextBottomButton.isEnabled = isEnabled + self?.addLinkView.nextBottomButton.backgroundColor = isEnabled ? .black850 : .gray200 + } + .store(in: cancelBag) + + output.linkEffectivenessMessage + .sink { [weak self] message in + if let errorMessage = message { + self?.addLinkView.isValidLinkError(errorMessage) + self?.addLinkView.linkEmbedTextField.layer.borderColor = UIColor.toasterError.cgColor + self?.addLinkView.linkEmbedTextField.layer.borderWidth = 1 + } else { + self?.addLinkView.resetError() + self?.addLinkView.linkEmbedTextField.layer.borderColor = UIColor.clear.cgColor + } + } + .store(in: cancelBag) + } +} + extension AddLinkViewController: SaveLinkButtonDelegate { func saveLinkButtonTapped() { delegate?.changeTabBarIndex() diff --git a/TOASTER-iOS/Present/AddLink/LinkEmbed/ViewModel/AddLinkViewModel.swift b/TOASTER-iOS/Present/AddLink/LinkEmbed/ViewModel/AddLinkViewModel.swift new file mode 100644 index 00000000..8f2d6840 --- /dev/null +++ b/TOASTER-iOS/Present/AddLink/LinkEmbed/ViewModel/AddLinkViewModel.swift @@ -0,0 +1,75 @@ +// +// AddLinkViewModel.swift +// TOASTER-iOS +// +// Created by Gahyun Kim on 9/19/24. +// + +import Combine +import UIKit + +final class AddLinkViewModel: ViewModelType { + + private var cancelBag: CancelBag = CancelBag() + + let embedLinkText = PassthroughSubject() + + struct Input { + let embedLinkText: AnyPublisher + let clearButtonTapped: AnyPublisher + } + + struct Output { + let isClearButtonHidden = PassthroughSubject() + let isNextButtonEnabled = CurrentValueSubject(false) + let textFieldBorderColor = PassthroughSubject() + let linkEffectivenessMessage = PassthroughSubject() + } + + func transform(_ input: Input, cancelBag: CancelBag) -> Output { + let output = Output() + + let inputText = input.embedLinkText + .merge(with: input.clearButtonTapped.map { "" }) + .eraseToAnyPublisher() + + inputText + .map { $0.isEmpty } + .sink { isHidden in + output.isClearButtonHidden.send(isHidden) + } + .store(in: cancelBag) + + let isValid = inputText + .map { self.isValidURL($0) } + .share() + .eraseToAnyPublisher() + + isValid + .combineLatest(inputText.map { !$0.isEmpty }) + .map { $0 && $1 } + .sink { isEnabled in + output.isNextButtonEnabled.send(isEnabled) + } + .store(in: cancelBag) + + input.embedLinkText + .map { $0.isEmpty ? "링크를 입력해주세요" : (self.isValidURL($0) ? nil : "유효하지 않은 형식의 링크입니다. " ) } + .sink { message in + output.linkEffectivenessMessage.send(message) + } + .store(in: cancelBag) + + return output + } +} + +private extension AddLinkViewModel { + func isValidURL(_ urlString: String) -> Bool { + if (urlString.prefix(8) == "https://") || (urlString.prefix(7) == "http://") { + return true + } else { + return false + } + } +} diff --git a/TOASTER-iOS/Present/AddLink/SelectClip/View/SelectClipViewController.swift b/TOASTER-iOS/Present/AddLink/SelectClip/View/SelectClipViewController.swift index a1d1f4bd..8d204f09 100644 --- a/TOASTER-iOS/Present/AddLink/SelectClip/View/SelectClipViewController.swift +++ b/TOASTER-iOS/Present/AddLink/SelectClip/View/SelectClipViewController.swift @@ -186,8 +186,15 @@ private extension SelectClipViewController { } @objc func completeButtonTapped() { - viewModel.postSaveLink(url: linkURL, - category: categoryID) + completeButton.loadingButtonTapped( + loadingTitle: "저장 중...", + loadingAnimationSize: 16, + task: { _ in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + self.viewModel.postSaveLink(url: self.linkURL, category: self.categoryID) + } + } + ) } } diff --git a/TOASTER-iOS/Present/Clip/View/ClipViewController.swift b/TOASTER-iOS/Present/Clip/View/ClipViewController.swift index 9739b117..b57e5dae 100644 --- a/TOASTER-iOS/Present/Clip/View/ClipViewController.swift +++ b/TOASTER-iOS/Present/Clip/View/ClipViewController.swift @@ -197,17 +197,11 @@ extension ClipViewController: UICollectionViewDelegateFlowLayout { // referenceSizeForHeaderInSection: 각 섹션의 헤더 뷰 크기를 CGSize 형태로 return func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { - return CGSize(width: collectionView.frame.width, height: 90) + return CGSize(width: collectionView.frame.width, height: 33) } } extension ClipViewController: ClipCollectionHeaderViewDelegate { - func searchBarButtonTapped() { - let searchVC = SearchViewController() - searchVC.hidesBottomBarWhenPushed = true - navigationController?.pushViewController(searchVC, animated: true) - } - func addClipButtonTapped() { if viewModel.clipList.clips.count >= 15 { showToastMessage(width: 243, status: .warning, message: StringLiterals.ToastMessage.noticeMaxClip) diff --git a/TOASTER-iOS/Present/Clip/View/Component/ClipCollectionHeaderView.swift b/TOASTER-iOS/Present/Clip/View/Component/ClipCollectionHeaderView.swift index 4e854a6d..966af163 100644 --- a/TOASTER-iOS/Present/Clip/View/Component/ClipCollectionHeaderView.swift +++ b/TOASTER-iOS/Present/Clip/View/Component/ClipCollectionHeaderView.swift @@ -12,7 +12,6 @@ import Then protocol ClipCollectionHeaderViewDelegate: AnyObject { func addClipButtonTapped() - func searchBarButtonTapped() } final class ClipCollectionHeaderView: UICollectionReusableView { @@ -23,7 +22,6 @@ final class ClipCollectionHeaderView: UICollectionReusableView { // MARK: - UI Components - private let searchBarButton = UIButton() private let clipCountLabel = UILabel() private let addClipButton = UIButton() @@ -47,7 +45,6 @@ final class ClipCollectionHeaderView: UICollectionReusableView { extension ClipCollectionHeaderView { func isDetailClipView(isHidden: Bool) { - searchBarButton.isHidden = isHidden addClipButton.isHidden = isHidden if isHidden { @@ -69,22 +66,6 @@ private extension ClipCollectionHeaderView { func setupStyle() { backgroundColor = .toasterBackground - searchBarButton.do { - $0.makeRounded(radius: 12) - $0.setImage(.icSearch20, for: .normal) - $0.setTitle(StringLiterals.Placeholder.search, for: .normal) - $0.setTitleColor(.gray400, for: .normal) - $0.titleLabel?.font = .suitRegular(size: 16) - $0.contentHorizontalAlignment = .left - $0.imageView?.contentMode = .scaleAspectFit - $0.semanticContentAttribute = .forceLeftToRight - var configuration = UIButton.Configuration.filled() - configuration.imagePadding = 8 - configuration.baseBackgroundColor = .gray50 - $0.configuration = configuration - $0.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) - } - clipCountLabel.do { $0.textColor = .gray500 $0.font = .suitBold(size: 12) @@ -101,23 +82,18 @@ private extension ClipCollectionHeaderView { } func setupHierarchy() { - addSubviews(searchBarButton, clipCountLabel, addClipButton) + addSubviews(clipCountLabel, addClipButton) } func setupLayout() { - searchBarButton.snp.makeConstraints { - $0.top.equalToSuperview() - $0.leading.trailing.equalToSuperview().inset(20) - $0.height.equalTo(42) - } clipCountLabel.snp.makeConstraints { - $0.top.equalTo(searchBarButton.snp.bottom).offset(20) + $0.top.equalToSuperview().inset(4) $0.leading.equalToSuperview().inset(20) } addClipButton.snp.makeConstraints { - $0.top.equalTo(searchBarButton.snp.bottom).offset(15) + $0.top.equalToSuperview() $0.trailing.equalToSuperview().inset(20) } } @@ -125,8 +101,6 @@ private extension ClipCollectionHeaderView { @objc func buttonTapped(_ sender: UIButton) { switch sender { - case searchBarButton: - clipCollectionHeaderViewDelegate?.searchBarButtonTapped() case addClipButton: clipCollectionHeaderViewDelegate?.addClipButtonTapped() default: diff --git a/TOASTER-iOS/Present/DetailClip/View/Cell/DetailClipListCollectionViewCell.swift b/TOASTER-iOS/Present/DetailClip/View/Cell/DetailClipListCollectionViewCell.swift index fa528dd4..70a33f69 100644 --- a/TOASTER-iOS/Present/DetailClip/View/Cell/DetailClipListCollectionViewCell.swift +++ b/TOASTER-iOS/Present/DetailClip/View/Cell/DetailClipListCollectionViewCell.swift @@ -92,6 +92,29 @@ extension DetailClipListCollectionViewCell { } } + func configureCell(forModel: RecentLinkModel, isClipHidden: Bool) { + modifiedButton.isHidden = true + clipNameLabel.text = forModel.toastTitle + linkTitleLabel.text = forModel.toastTitle + linkLabel.text = forModel.linkUrl + isClipNameLabelHidden = forModel.categoryTitle != nil ? true : false + isReadDimmedView = forModel.isRead + toastId = forModel.toastId + + if forModel.categoryTitle != nil && !isClipHidden { + clipNameLabel.text = forModel.categoryTitle + isClipNameLabelHidden = false + } else { + isClipNameLabelHidden = true + } + + if let imageURL = forModel.thumbnailUrl { + linkImage.kf.setImage(with: URL(string: imageURL)) + } else { + linkImage.image = .imgThumbnail + } + } + func configureCell(forModel: SearchResultDetailClipModel, forText: String) { modifiedButton.isHidden = true linkTitleLabel.text = forModel.title diff --git a/TOASTER-iOS/Present/DetailClip/View/Component/ChangeClipBottomSheetView.swift b/TOASTER-iOS/Present/DetailClip/View/Component/ChangeClipBottomSheetView.swift new file mode 100644 index 00000000..30c8f1ee --- /dev/null +++ b/TOASTER-iOS/Present/DetailClip/View/Component/ChangeClipBottomSheetView.swift @@ -0,0 +1,180 @@ +// +// ChangeClipBottomSheetView.swift +// TOASTER-iOS +// +// Created by ParkJunHyuk on 10/9/24. +// + +import UIKit + +import SnapKit +import Then + +final class ChangeClipBottomSheetView: UIView { + + // MARK: - Properties + + var dataSourceHandler: (() -> [SelectClipModel])? + + weak var delegate: ChangeClipBottomSheetViewDelegate? + + // MARK: - UI Components + + private var clipSelectCollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + private let completeBottomButton = UIButton() + + // MARK: - Life Cycles + + override init(frame: CGRect) { + super.init(frame: frame) + + setupStyle() + setupHierarchy() + setupLayout() + setupRegisterCell() + setupDelegate() + setupAddTarget() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func reloadChangeClipBottom() { + clipSelectCollectionView.reloadData() + } + + /// 버튼 활성화에 따른 UI 변경 + func updateCompleteButtonUI(_ isEnable: Bool) { + completeBottomButton.isUserInteractionEnabled = isEnable ? true : false + completeBottomButton.backgroundColor = isEnable ? .black850 : .gray200 + } +} + +// MARK: - Private Extensions + +private extension ChangeClipBottomSheetView { + func setupStyle() { + backgroundColor = .gray50 + + clipSelectCollectionView.do { + $0.backgroundColor = .gray50 + $0.makeRounded(radius: 12) + $0.isScrollEnabled = true + $0.showsVerticalScrollIndicator = false + } + + completeBottomButton.do { + $0.setTitle(StringLiterals.Button.complete, for: .normal) + $0.setTitleColor(.toasterWhite, for: .normal) + $0.backgroundColor = .gray200 + $0.makeRounded(radius: 12) + } + } + + func setupHierarchy() { + addSubviews(clipSelectCollectionView, completeBottomButton) + } + + func setupLayout() { + clipSelectCollectionView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.horizontalEdges.equalToSuperview().inset(20) + $0.bottom.equalTo(completeBottomButton.snp.top).offset(-20) + } + + completeBottomButton.snp.makeConstraints { + $0.centerX.equalToSuperview() + $0.horizontalEdges.equalToSuperview().inset(20) + $0.bottom.equalToSuperview().inset(34) + $0.height.equalTo(62) + } + } + + func setupRegisterCell() { + clipSelectCollectionView.register(RemindSelectClipCollectionViewCell.self, forCellWithReuseIdentifier: RemindSelectClipCollectionViewCell.className) + } + + func setupDelegate() { + clipSelectCollectionView.delegate = self + clipSelectCollectionView.dataSource = self + } + + func setupAddTarget() { + completeBottomButton.addTarget(self, action: #selector(completeBottomuttonTapped), for: .touchUpInside) + } + + @objc func completeBottomuttonTapped(_ sender: UIButton) { + completeBottomButton.loadingButtonTapped( + loadingTitle: "이동 중...", + loadingAnimationSize: 16, + task: { completion in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + self.delegate?.completButtonTap() + completion() + } + } + ) + } +} + +// MARK: - CollectionView DataSource + +extension ChangeClipBottomSheetView: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return dataSourceHandler?().count ?? 0 + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: RemindSelectClipCollectionViewCell.className, for: indexPath) as? RemindSelectClipCollectionViewCell, let clipData = dataSourceHandler?() else { return UICollectionViewCell() } + + cell.configureChangeClipCell(forModel: clipData[indexPath.item], canSelect: indexPath.row == 0 ? false : true, icon: .icClip24) + + return cell + } +} + +// MARK: - CollectionViewDelegate + +extension ChangeClipBottomSheetView: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { + return indexPath.item != 0 // 첫 번째 아이템(인덱스 0)이 아닌 경우에만 true 반환 + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let clipData = dataSourceHandler?() else { return } + + if indexPath.item != 0 { + delegate?.didSelectClip(selectClipId: clipData[indexPath.row].id) + + if let cell = collectionView.cellForItem(at: .SubSequence(item: 0, section: 0)) { + cell.isSelected = false + } + } + } +} + +// MARK: - UICollectionViewDelegateFlowLayout + +extension ChangeClipBottomSheetView: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + return CGSize(width: convertByWidthRatio(335), height: 54) + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { + return UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { + return 0 + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat { + return 1 + } +} + +protocol ChangeClipBottomSheetViewDelegate: AnyObject { + func didSelectClip(selectClipId: Int) + func completButtonTap() +} diff --git a/TOASTER-iOS/Present/DetailClip/View/Component/EditLinkBottomSheetView.swift b/TOASTER-iOS/Present/DetailClip/View/Component/EditLinkBottomSheetView.swift index 49b4b644..4f74513e 100644 --- a/TOASTER-iOS/Present/DetailClip/View/Component/EditLinkBottomSheetView.swift +++ b/TOASTER-iOS/Present/DetailClip/View/Component/EditLinkBottomSheetView.swift @@ -235,25 +235,10 @@ private extension EditLinkBottomSheetView { // MARK: - UITextField Delegate extension EditLinkBottomSheetView: UITextFieldDelegate { - func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { - let newText = (textField.text as NSString?)?.replacingCharacters(in: range, with: string) ?? string - let currentText = textField.text ?? "" - let maxLength = 16 - - // 길이가 16에서 15로 돌아갈 때 - if currentText.count == maxLength && newText.count == 15 { - editLinkBottomSheetViewDelegate?.minusHeightBottom() - } - return (newText.count <= maxLength) || (newText.count < currentText.count) - } - func textFieldDidChangeSelection(_ textField: UITextField) { let currentText = textField.text ?? "" if currentText.isEmpty { changeTextField(addButton: false, border: false, error: false, clearButton: false) - } else if currentText.count > 15 { - changeTextField(addButton: false, border: true, error: true, clearButton: true) - setupMessage(message: "링크 제목은 최대 15자까지 입력 가능해요") } else { changeTextField(addButton: true, border: false, error: false, clearButton: true) } diff --git a/TOASTER-iOS/Present/DetailClip/View/Component/DeleteLinkBottomSheetView.swift b/TOASTER-iOS/Present/DetailClip/View/Component/LinkOptionBottomSheetView.swift similarity index 58% rename from TOASTER-iOS/Present/DetailClip/View/Component/DeleteLinkBottomSheetView.swift rename to TOASTER-iOS/Present/DetailClip/View/Component/LinkOptionBottomSheetView.swift index db169bfb..e2bd5288 100644 --- a/TOASTER-iOS/Present/DetailClip/View/Component/DeleteLinkBottomSheetView.swift +++ b/TOASTER-iOS/Present/DetailClip/View/Component/LinkOptionBottomSheetView.swift @@ -10,24 +10,39 @@ import UIKit import SnapKit import Then -final class DeleteLinkBottomSheetView: UIView { +enum ClipType { + case allClip + case anthoerClip + + init(categoryId: Int) { + self = categoryId == 0 ? .allClip : .anthoerClip + } +} + +final class LinkOptionBottomSheetView: UIView { // MARK: - Properties + private let currentClipType: ClipType + private var deleteLinkBottomSheetViewButtonAction: (() -> Void)? private var editLinkTitleBottomSheetViewButtonAction: (() -> Void)? private var confirmBottomSheetViewButtonAction: (() -> Void)? + private var changeClipBottomSheetViewButtonAction: (() -> Void)? // MARK: - UI Components private let deleteButton = UIButton() private let editButton = UIButton() + private let changeClipButton = UIButton() private let deleteButtonLabel = UILabel() private let editButtonLabel = UILabel() + private(set) var changeClipButtonLabel = UILabel() // MARK: - Life Cycles - override init(frame: CGRect) { + init(currentClipType: ClipType, frame: CGRect = .zero) { + self.currentClipType = currentClipType super.init(frame: frame) setupStyle() @@ -44,7 +59,7 @@ final class DeleteLinkBottomSheetView: UIView { // MARK: - Extensions -extension DeleteLinkBottomSheetView { +extension LinkOptionBottomSheetView { func setupDeleteLinkBottomSheetButtonAction(_ action: (() -> Void)?) { deleteLinkBottomSheetViewButtonAction = action } @@ -56,11 +71,15 @@ extension DeleteLinkBottomSheetView { func setupConfirmBottomSheetButtonAction(_ action: (() -> Void)?) { confirmBottomSheetViewButtonAction = action } + + func setupChangeClipBottomSheetButtonAction(_ action: (() -> Void)?) { + changeClipBottomSheetViewButtonAction = action + } } // MARK: - Private Extensions -private extension DeleteLinkBottomSheetView { +private extension LinkOptionBottomSheetView { func setupStyle() { backgroundColor = .gray50 @@ -76,8 +95,12 @@ private extension DeleteLinkBottomSheetView { $0.makeRounded(radius: 12) } + changeClipButton.do { + $0.backgroundColor = .toasterWhite + } + editButtonLabel.do { - $0.text = "수정하기" + $0.text = "제목 편집" $0.textColor = .black900 $0.font = .suitMedium(size: 16) } @@ -87,15 +110,35 @@ private extension DeleteLinkBottomSheetView { $0.textColor = .toasterError $0.font = .suitMedium(size: 16) } + + changeClipButtonLabel.do { + $0.text = "클립 이동" + $0.textColor = .black900 + $0.font = .suitMedium(size: 16) + } } func setupHierarchy() { addSubviews(editButton, deleteButton) editButton.addSubview(editButtonLabel) deleteButton.addSubview(deleteButtonLabel) + + if case .anthoerClip = currentClipType { + addSubview(changeClipButton) + changeClipButton.addSubview(changeClipButtonLabel) + } } func setupLayout() { + switch currentClipType { + case .allClip: + setupAllClipLayout() + case .anthoerClip: + setupAnthoerClipLayout() + } + } + + func setupAllClipLayout() { editButton.snp.makeConstraints { $0.leading.trailing.equalToSuperview().inset(20) $0.top.equalToSuperview() @@ -116,9 +159,37 @@ private extension DeleteLinkBottomSheetView { } } + func setupAnthoerClipLayout() { + editButton.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(20) + $0.top.equalToSuperview() + $0.height.equalTo(54) + } + + changeClipButton.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(20) + $0.top.equalTo(editButton.snp.bottom).offset(1) + $0.height.equalTo(54) + } + + deleteButton.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(20) + $0.top.equalTo(changeClipButton.snp.bottom).offset(1) + $0.height.equalTo(54) + } + + [editButtonLabel, changeClipButtonLabel, deleteButtonLabel].forEach { + $0.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().inset(20) + } + } + } + func setupAddTarget() { editButton.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) deleteButton.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + changeClipButton.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) } @objc @@ -128,6 +199,8 @@ private extension DeleteLinkBottomSheetView { editLinkTitleBottomSheetViewButtonAction?() case deleteButton: deleteLinkBottomSheetViewButtonAction?() + case changeClipButton: + changeClipBottomSheetViewButtonAction?() default: break } diff --git a/TOASTER-iOS/Present/DetailClip/View/DetailClipViewController.swift b/TOASTER-iOS/Present/DetailClip/View/DetailClipViewController.swift index 5445fc4f..dd28b278 100644 --- a/TOASTER-iOS/Present/DetailClip/View/DetailClipViewController.swift +++ b/TOASTER-iOS/Present/DetailClip/View/DetailClipViewController.swift @@ -5,9 +5,11 @@ // Created by 김다예 on 12/30/23. // +import Combine import UIKit import SnapKit +import Then final class DetailClipViewController: UIViewController { @@ -16,20 +18,38 @@ final class DetailClipViewController: UIViewController { // MARK: - UI Properties private let viewModel = DetailClipViewModel() + private let changeClipViewModel = ChangeClipViewModel() private let detailClipSegmentedControlView = DetailClipSegmentedControlView() private let detailClipEmptyView = DetailClipEmptyView() private let detailClipListCollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) - private let deleteLinkBottomSheetView = DeleteLinkBottomSheetView() - private lazy var editBottom = ToasterBottomSheetViewController(bottomType: .gray, - bottomTitle: "수정하기", - insertView: deleteLinkBottomSheetView) + private lazy var linkOptionBottomSheetView = LinkOptionBottomSheetView(currentClipType: ClipType(categoryId: viewModel.categoryId)) + private lazy var optionBottom = ToasterBottomSheetViewController(bottomType: .gray, + bottomTitle: "더보기", + insertView: linkOptionBottomSheetView) private let editLinkBottomSheetView = EditLinkBottomSheetView() private lazy var editLinkBottom = ToasterBottomSheetViewController(bottomType: .white, bottomTitle: "링크 제목 편집", insertView: editLinkBottomSheetView) + private let changeClipBottomSheetView = ChangeClipBottomSheetView() + private lazy var changeClipBottom = ToasterBottomSheetViewController(bottomType: .gray, + bottomTitle: "클립을 선택해 주세요", + insertView: changeClipBottomSheetView) + + private lazy var firstToolTip = ToasterTipView( + title: "링크를 다른 클립으로\n이동할 수 있어요!", + type: .right, + sourceItem: linkOptionBottomSheetView.changeClipButtonLabel + ) + + private let changeClipSubject = PassthroughSubject() + private let selectedClipSubject = PassthroughSubject() + private let completeButtonSubject = PassthroughSubject() + + private var cancelBag = CancelBag() + // MARK: - Life Cycle override func viewDidLoad() { @@ -41,6 +61,7 @@ final class DetailClipViewController: UIViewController { setupRegisterCell() setupDelegate() setupViewModel() + bindViewModels() } override func viewWillAppear(_ animated: Bool) { @@ -57,6 +78,7 @@ extension DetailClipViewController { func setupCategory(id: Int, name: String) { viewModel.categoryId = id viewModel.categoryName = name + changeClipViewModel.setupCategory(id) } } @@ -104,6 +126,7 @@ private extension DetailClipViewController { detailClipListCollectionView.dataSource = self detailClipSegmentedControlView.detailClipSegmentedDelegate = self viewModel.delegate = self + changeClipBottomSheetView.delegate = self } func setupViewModel() { @@ -135,6 +158,84 @@ private extension DetailClipViewController { navigationController.setupNavigationBar(forType: type) } } + + func bindViewModels() { + let input = ChangeClipViewModel.Input( + changeButtonTap: changeClipSubject.eraseToAnyPublisher(), + selectedClip: selectedClipSubject.eraseToAnyPublisher(), + completeButtonTap: completeButtonSubject.eraseToAnyPublisher() + ) + + let output = changeClipViewModel.transform(input, cancelBag: cancelBag) + + output.clipData + .receive(on: DispatchQueue.main) + .sink { [weak self] clipData in + guard let self else { return } + + self.dismiss(animated: true) { + // 이동할 클립이 2개 이상일 때 (전체클립 제외) + if let data = clipData { + self.dismiss(animated: true) { + self.changeClipBottom.setupSheetPresentation(bottomHeight: self.changeClipViewModel.collectionViewHeight + 180) + self.present(self.changeClipBottom, animated: true) + } + + self.changeClipBottomSheetView.dataSourceHandler = { data } + self.changeClipBottomSheetView.reloadChangeClipBottom() + + } else { // 현재 클립이 1개 존재할 때 (전체클립 제외) + DispatchQueue.main.asyncAfter(deadline: .now()) { + self.showToastMessage(width: 284, + status: .warning, + message: "이동할 클립을 하나 이상 생성해 주세요") + } + } + } + } + .store(in: cancelBag) + + output.isCompleteButtonEnable + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + self?.changeClipBottomSheetView.updateCompleteButtonUI(result) + } + .store(in: cancelBag) + + output.changeCategoryResult + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + guard let self else { return } + if result == true { + let categoryFilter = DetailCategoryFilter.allCases[viewModel.getViewModelProperty(dataType: .segmentIndex) as? Int ?? 0] + + self.changeClipBottom.dismiss(animated: true) { + if self.viewModel.categoryId == 0 { + self.viewModel.getDetailAllCategoryAPI(filter: categoryFilter) + } else { + self.viewModel.getDetailCategoryAPI(categoryID: self.viewModel.categoryId, filter: categoryFilter) + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.showToastMessage(width: 152, + status: .check, + message: "링크 이동 완료") + } + } + } + .store(in: cancelBag) + } + + func setupToolTip() { + if UserDefaults.standard.value(forKey: TipUserDefaults.isShowDetailClipViewToolTip) == nil { + UserDefaults.standard.set(true, forKey: TipUserDefaults.isShowDetailClipViewToolTip) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.linkOptionBottomSheetView.addSubview(self?.firstToolTip ?? UIView()) + self?.firstToolTip.showToolTipAndDismissAfterDelay(duration: 4) + } + } + } } // MARK: - CollectionView DataSource @@ -158,15 +259,21 @@ extension DetailClipViewController: UICollectionViewDataSource { cell.configureCell(forModel: viewModel.toastList, index: indexPath.item, isClipHidden: true) } - /// "수정하기" 클릭 시 - deleteLinkBottomSheetView.setupEditLinkTitleBottomSheetButtonAction { + // "수정하기" 클릭 시 + linkOptionBottomSheetView.setupEditLinkTitleBottomSheetButtonAction { self.dismiss(animated: true) { self.editLinkBottom.setupSheetPresentation(bottomHeight: 198) self.present(self.editLinkBottom, animated: true) } } - /// "삭제" 클릭 시 - deleteLinkBottomSheetView.setupDeleteLinkBottomSheetButtonAction { + + // "클립이동" 클릭 시 + linkOptionBottomSheetView.setupChangeClipBottomSheetButtonAction { + self.changeClipSubject.send() + } + + // "삭제" 클릭 시 + linkOptionBottomSheetView.setupDeleteLinkBottomSheetButtonAction { self.viewModel.deleteLinkAPI(toastId: self.viewModel.toastId) self.dismiss(animated: true) { [weak self] in self?.showToastMessage(width: 152, status: .check, message: StringLiterals.ToastMessage.completeDeleteLink) @@ -270,8 +377,10 @@ extension DetailClipViewController: DetailClipSegmentedDelegate { extension DetailClipViewController: DetailClipListCollectionViewCellDelegate { func modifiedButtonTapped(toastId: Int) { viewModel.toastId = toastId - editBottom.setupSheetPresentation(bottomHeight: 226) - present(editBottom, animated: true) + changeClipViewModel.setupToastId(toastId) + optionBottom.setupSheetPresentation(bottomHeight: viewModel.categoryId == 0 ? 226 : 280) + present(optionBottom, animated: true) + if viewModel.categoryId != 0 { setupToolTip() } } } @@ -309,3 +418,13 @@ extension DetailClipViewController: PatchClipDelegate { } } } + +extension DetailClipViewController: ChangeClipBottomSheetViewDelegate { + func didSelectClip(selectClipId: Int) { + selectedClipSubject.send(selectClipId) + } + + func completButtonTap() { + completeButtonSubject.send() + } +} diff --git a/TOASTER-iOS/Present/DetailClip/ViewModel/ChangeClipViewModel.swift b/TOASTER-iOS/Present/DetailClip/ViewModel/ChangeClipViewModel.swift new file mode 100644 index 00000000..641d64e9 --- /dev/null +++ b/TOASTER-iOS/Present/DetailClip/ViewModel/ChangeClipViewModel.swift @@ -0,0 +1,176 @@ +// +// ChangeClipViewModel.swift +// TOASTER-iOS +// +// Created by ParkJunHyuk on 10/9/24. +// + +import Combine +import Foundation + +final class ChangeClipViewModel: ViewModelType { + + private(set) var currentToastId: Int = 0 + private(set) var currentCategoryId: Int = 0 + private(set) var botomHeigth: CGFloat = 0 + private(set) var collectionViewHeight: CGFloat = 0 + + struct Input { + let changeButtonTap: AnyPublisher + let selectedClip: AnyPublisher + let completeButtonTap: AnyPublisher + } + + struct Output { + let clipData: AnyPublisher<[SelectClipModel]?, Never> + let isCompleteButtonEnable: AnyPublisher + let changeCategoryResult: AnyPublisher + } + + func transform(_ input: Input, cancelBag: CancelBag) -> Output { + + /// 클립이동 버튼이 눌렸을때 동작 + let clipDataPublisher = input.changeButtonTap + .flatMap { [weak self] _ -> AnyPublisher<[SelectClipModel]?, Never> in + guard let self else { + return Just([]).eraseToAnyPublisher() + } + + return self.getAllCategoryAPI() + .map { [weak self] result -> [SelectClipModel]? in + guard let self = self else { return [] } + + // 2개 이하일 경우 nil 반환 + if result.count < 2 { + return nil + } + + let sortedResult = self.sortCurrentCategoryToTop(result) + self.collectionViewHeight = self.calculateCollectionViewHeight(numberOfItems: sortedResult.count) + return sortedResult + } + .catch { error -> AnyPublisher<[SelectClipModel]?, Never> in + print("Error: \(error)") + return Just([]).eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } .eraseToAnyPublisher() + + /// 이동할 클립을 선택 시 버튼의 UI 를 변경하는 동작 + let isCompleteButtonEnable = Publishers.Merge( + input.changeButtonTap.map { false }, // bottomSheet 열릴 때 false + input.selectedClip.map { _ in true } // 클립 선택 시 true + ).eraseToAnyPublisher() + + /// 완료 버튼이 눌렸을때 동작 + let changeCategoryResult = input.completeButtonTap + .zip(input.selectedClip) { _, selectedClip in + return selectedClip + } + .flatMap { [weak self] selectedClip -> AnyPublisher in + guard let self else { + return Just(false).eraseToAnyPublisher() + } + + return self.patchChagneCategory(categoryId: selectedClip) + .catch { error -> AnyPublisher in + print("Error: \(error)") + return Just(false).eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + + return Output( + clipData: clipDataPublisher, + isCompleteButtonEnable: isCompleteButtonEnable, + changeCategoryResult: changeCategoryResult + ) + } + + func setupCategory(_ id: Int) { + currentCategoryId = id + } + + func setupToastId(_ id: Int) { + currentToastId = id + } +} + +// MARK: - private Extensions + +private extension ChangeClipViewModel { + /// 현재 카테고리를 최상단에 위치하도록 정렬하는 메서드 + func sortCurrentCategoryToTop(_ clipDataList: [SelectClipModel]) -> [SelectClipModel] { + guard let currentCategoryIndex = clipDataList.firstIndex(where: { $0.id == currentCategoryId }) else { + return clipDataList + } + + var tempClipDataList = clipDataList + let currentCategoryData = tempClipDataList.remove(at: currentCategoryIndex) + tempClipDataList.insert(currentCategoryData, at: 0) + + calculateBottomSheetHeight(clipDataList.count) + + return tempClipDataList + } + + func calculateBottomSheetHeight(_ count: Int) { + botomHeigth = CGFloat(count * 54 + 184 + 3) + } + + func calculateCollectionViewHeight(numberOfItems: Int) -> CGFloat { + let cellHeight: CGFloat = 54 + let lineSpacing: CGFloat = 1 + + // 마지막 셀 다음에는 간격이 없으므로 (numberOfItems - 1) + let totalHeight = (cellHeight * CGFloat(numberOfItems)) + (lineSpacing * CGFloat(numberOfItems - 1)) + print("높이:", totalHeight) + return totalHeight + } +} + +// MARK: - API Extensions + +extension ChangeClipViewModel { + func getAllCategoryAPI() -> AnyPublisher<[SelectClipModel], Error> { + return Future<[SelectClipModel], Error> { promise in + NetworkService.shared.clipService.getAllCategory { result in + switch result { + case .success(let response): + let clipDataList = response?.data.categories.map { category in + SelectClipModel( + id: category.categoryId, + title: category.categoryTitle, + clipCount: category.toastNum + ) + } ?? [] + + promise(.success(clipDataList)) + case .unAuthorized, .networkFail, .notFound: + promise(.failure(NetworkResult.unAuthorized)) + default: + break + } + } + }.eraseToAnyPublisher() + } + + func patchChagneCategory(categoryId: Int) -> AnyPublisher { + let requestDTO = PatchChangeCategoryRequestDTO(toastId: currentToastId, categoryId: categoryId) + + return Future { promise in + NetworkService.shared.toastService.patchChangeCategory(requestBody: requestDTO) { result in + switch result { + case .success: + promise(.success(true)) + case .unAuthorized, .networkFail, .notFound, .serverErr: + promise(.failure(NetworkResult.unAuthorized)) + default: + break + } + } + + }.eraseToAnyPublisher() + } +} diff --git a/TOASTER-iOS/Present/Home/Model/RecentLinkModel.swift b/TOASTER-iOS/Present/Home/Model/RecentLinkModel.swift new file mode 100644 index 00000000..4ff02761 --- /dev/null +++ b/TOASTER-iOS/Present/Home/Model/RecentLinkModel.swift @@ -0,0 +1,17 @@ +// +// RecentLinkModel.swift +// TOASTER-iOS +// +// Created by Gahyun Kim on 10/17/24. +// + +import Foundation + +struct RecentLinkModel { + let toastId: Int + let toastTitle: String + let linkUrl: String + let isRead: Bool + let categoryTitle: String? + let thumbnailUrl: String? +} diff --git a/TOASTER-iOS/Present/Home/View/Cell/HomeHeaderCollectionView.swift b/TOASTER-iOS/Present/Home/View/Cell/HomeHeaderCollectionView.swift index 4257bdbd..a9a5a55d 100644 --- a/TOASTER-iOS/Present/Home/View/Cell/HomeHeaderCollectionView.swift +++ b/TOASTER-iOS/Present/Home/View/Cell/HomeHeaderCollectionView.swift @@ -14,6 +14,7 @@ final class HomeHeaderCollectionView: UICollectionReusableView { // MARK: - Properties private let titleLabel = UILabel() + let arrowButton = UIButton() // MARK: - Life Cycle @@ -21,6 +22,7 @@ final class HomeHeaderCollectionView: UICollectionReusableView { super.init(frame: frame) self.backgroundColor = .clear + setView() } @@ -41,33 +43,45 @@ final class HomeHeaderCollectionView: UICollectionReusableView { $0.textColor = .black900 $0.font = .suitMedium(size: 18) } + + arrowButton.do { + $0.setImage(.icArrow20, for: .normal) + $0.isUserInteractionEnabled = true + $0.isHidden = false + } } // MARK: - set up Hierarchy private func setupHierarchy() { - addSubview(titleLabel) + addSubviews(titleLabel, arrowButton) } // MARK: - set up Layout private func setupLayout() { titleLabel.snp.makeConstraints { - $0.leading.trailing.equalToSuperview().inset(10) + $0.leading.equalToSuperview().inset(10) $0.bottom.equalToSuperview().inset(5) } + + arrowButton.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.top).inset(1) + $0.trailing.equalToSuperview().inset(10) + } } } extension HomeHeaderCollectionView { func configureHeader(forTitle: String, num: Int) { if num == 1 { - titleLabel.text = forTitle + "님의 클립" + titleLabel.text = forTitle + "님이 최근 저장한 링크" titleLabel.font = .suitMedium(size: 18) titleLabel.asFont(targetString: forTitle, font: .suitBold(size: 18)) } else { titleLabel.text = forTitle titleLabel.asFont(targetString: forTitle, font: .suitBold(size: 18)) + arrowButton.isHidden = true } } } diff --git a/TOASTER-iOS/Present/Home/View/Cell/MainCollectionViewCell.swift b/TOASTER-iOS/Present/Home/View/Cell/MainCollectionViewCell.swift index 259a9274..75c4f930 100644 --- a/TOASTER-iOS/Present/Home/View/Cell/MainCollectionViewCell.swift +++ b/TOASTER-iOS/Present/Home/View/Cell/MainCollectionViewCell.swift @@ -9,19 +9,12 @@ import UIKit import SnapKit import Then -protocol MainCollectionViewDelegate: AnyObject { - func searchButtonTapped() -} - // MARK: - main section final class MainCollectionViewCell: UICollectionViewCell { - weak var mainCollectionViewDelegate: MainCollectionViewDelegate? - // MARK: - UI Components - private let searchButton = UIButton() private let userLabel = UILabel() private let noticeLabel = UILabel() private let countToastLabel = UILabel() @@ -76,21 +69,6 @@ extension MainCollectionViewCell { private extension MainCollectionViewCell { func setupStyle() { - searchButton.do { - $0.makeRounded(radius: 12) - $0.setImage(.icSearch20, for: .normal) - $0.setTitle(StringLiterals.Placeholder.search, for: .normal) - $0.setTitleColor(.gray400, for: .normal) - $0.titleLabel?.font = .suitRegular(size: 16) - $0.contentHorizontalAlignment = .left - $0.imageView?.contentMode = .scaleAspectFit - $0.semanticContentAttribute = .forceLeftToRight - var configuration = UIButton.Configuration.filled() - configuration.imagePadding = 8 - configuration.baseBackgroundColor = .gray50 - $0.configuration = configuration - $0.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) - } userLabel.do { $0.font = .suitRegular(size: 20) @@ -120,22 +98,15 @@ private extension MainCollectionViewCell { } func setupHierarchy() { - addSubviews(searchButton, - userLabel, + addSubviews(userLabel, noticeLabel, countToastLabel, linkProgressView) } func setupLayout() { - searchButton.snp.makeConstraints { - $0.top.equalToSuperview() - $0.leading.trailing.equalToSuperview().inset(20) - $0.height.equalTo(42) - } - userLabel.snp.makeConstraints { - $0.top.equalTo(searchButton.snp.bottom).offset(18) + $0.top.equalToSuperview() $0.leading.trailing.equalToSuperview().inset(22) } @@ -155,8 +126,4 @@ private extension MainCollectionViewCell { $0.height.equalTo(12) } } - - @objc func buttonTapped() { - mainCollectionViewDelegate?.searchButtonTapped() - } } diff --git a/TOASTER-iOS/Present/Home/View/Cell/UserClipEmptyCollectionViewCell.swift b/TOASTER-iOS/Present/Home/View/Cell/UserClipEmptyCollectionViewCell.swift index d218cdf7..bb7385f4 100644 --- a/TOASTER-iOS/Present/Home/View/Cell/UserClipEmptyCollectionViewCell.swift +++ b/TOASTER-iOS/Present/Home/View/Cell/UserClipEmptyCollectionViewCell.swift @@ -55,7 +55,7 @@ private extension UserClipEmptyCollectionViewCell { addClipLabel.do { $0.font = .suitBold(size: 14) $0.textColor = .gray200 - $0.text = "클립 추가" + $0.text = "첫번째 링크를 저장해보세요" } } @@ -66,13 +66,13 @@ private extension UserClipEmptyCollectionViewCell { func setupLayout() { addClipImage.snp.makeConstraints { $0.centerX.equalToSuperview() - $0.top.equalToSuperview().inset(34) + $0.top.equalToSuperview().inset(17) $0.size.equalTo(42) } addClipLabel.snp.makeConstraints { $0.centerX.equalToSuperview() - $0.top.equalTo(addClipImage.snp.bottom).offset(1) + $0.top.equalTo(addClipImage.snp.bottom).offset(5) } } diff --git a/TOASTER-iOS/Present/Home/View/Component/CompositioinalFactory.swift b/TOASTER-iOS/Present/Home/View/Component/CompositioinalFactory.swift index 1b351fac..41eb8d5f 100644 --- a/TOASTER-iOS/Present/Home/View/Component/CompositioinalFactory.swift +++ b/TOASTER-iOS/Present/Home/View/Component/CompositioinalFactory.swift @@ -17,7 +17,7 @@ enum CompositionalFactory { case 0: section = createMainSection() case 1: - section = createUserClipSection() + section = createUserRecentLinkSection() case 2: section = createWeeklyLinkSection() case 3: @@ -46,7 +46,7 @@ enum CompositionalFactory { // group let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), - heightDimension: .estimated(224)) + heightDimension: .estimated(158)) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) @@ -68,22 +68,22 @@ enum CompositionalFactory { // MARK: - User Clip 에 대한 Layout - static func createUserClipSection() -> NSCollectionLayoutSection { - let itemFractionalWidthFraction = 1.0 / 2.0 - let itemInset: CGFloat = 7 + static func createUserRecentLinkSection() -> NSCollectionLayoutSection { + let itemFractionalWidthFraction = 1.0 / 1.0 + let itemInset: CGFloat = 8 // item let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(itemFractionalWidthFraction), - heightDimension: .absolute(150)) + heightDimension: .absolute(104)) let item = NSCollectionLayoutItem(layoutSize: itemSize) - item.contentInsets = NSDirectionalEdgeInsets(top: itemInset, + item.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: itemInset, bottom: itemInset, trailing: itemInset) // group let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), - heightDimension: .absolute(150)) + heightDimension: .absolute(104)) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) @@ -101,7 +101,7 @@ enum CompositionalFactory { elementKind: UICollectionView.elementKindSectionHeader, alignment: .top), NSCollectionLayoutBoundarySupplementaryItem(layoutSize: .init(widthDimension: .fractionalWidth(1), - heightDimension: .absolute(17)), + heightDimension: .absolute(9)), elementKind: UICollectionView.elementKindSectionFooter, alignment: .bottom)] return section diff --git a/TOASTER-iOS/Present/Home/View/HomeViewController.swift b/TOASTER-iOS/Present/Home/View/HomeViewController.swift index 9e5a8251..d272a83e 100644 --- a/TOASTER-iOS/Present/Home/View/HomeViewController.swift +++ b/TOASTER-iOS/Present/Home/View/HomeViewController.swift @@ -14,6 +14,7 @@ final class HomeViewController: UIViewController { // MARK: - UI Properties private let viewModel = HomeViewModel() + private let clipViewModel = DetailClipViewModel() private let homeView = HomeView() private let addClipBottomSheetView = AddClipBottomSheetView() @@ -21,11 +22,21 @@ final class HomeViewController: UIViewController { bottomTitle: "클립 추가", insertView: addClipBottomSheetView) + private var firstToolTip: ToasterTipView? + private lazy var secondToolTip: ToasterTipView? = { + guard let tabBarItems = tabBarController?.tabBar.items else { return nil } + let firstTabItem = tabBarItems[3] + let sourceItemFrame = firstTabItem.value(forKey: "view") as? UIView ?? UIView() + + return ToasterTipView(title: "검색이 더욱 편리해졌어요", type: .top, sourceItem: sourceItemFrame) + }() + // MARK: - Life Cycle override func viewDidLoad() { super.viewDidLoad() homeView.backgroundColor = .toasterBackground + setupHierarchy() setupLayout() createCollectionView() @@ -41,6 +52,12 @@ final class HomeViewController: UIViewController { viewModel.fetchWeeklyLinkData() viewModel.fetchRecommendSiteData() viewModel.getPopupInfoAPI() + viewModel.fetchRecentLinkData() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + setupToolTip() } } @@ -50,12 +67,13 @@ extension HomeViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { switch indexPath.section { case 1: - let data = viewModel.mainInfoList.mainCategoryListDto + let data = viewModel.recentLink if indexPath.item < data.count { - let nextVC = DetailClipViewController() - nextVC.setupCategory(id: data[indexPath.item].categoryId, - name: data[indexPath.item].categroyTitle) + let nextVC = LinkWebViewController() nextVC.hidesBottomBarWhenPushed = true + nextVC.setupDataBind(linkURL: viewModel.recentLink[indexPath.item].linkUrl, + isRead: viewModel.recentLink[indexPath.item].isRead, + id: viewModel.recentLink[indexPath.item].toastId) self.navigationController?.pushViewController(nextVC, animated: true) } else { addClipCellTapped() @@ -91,8 +109,8 @@ extension HomeViewController: UICollectionViewDataSource { case 0: return 1 case 1: - let count = viewModel.mainInfoList.mainCategoryListDto.count - return min(count + 1, 4) + let count = viewModel.recentLink.count + return count == 0 ? 1 : min(count, 3) case 2: return viewModel.weeklyLinkList.count case 3: @@ -111,11 +129,10 @@ extension HomeViewController: UICollectionViewDataSource { ) as? MainCollectionViewCell else { return UICollectionViewCell() } let model = viewModel.mainInfoList cell.bindData(forModel: model) - cell.mainCollectionViewDelegate = self return cell case 1: - let lastIndex = viewModel.mainInfoList.mainCategoryListDto.count - if indexPath.item == lastIndex && lastIndex < 4 { + let lastIndex = viewModel.recentLink.count + if lastIndex == 0 { guard let cell = collectionView.dequeueReusableCell( withReuseIdentifier: UserClipEmptyCollectionViewCell.className, for: indexPath @@ -123,16 +140,12 @@ extension HomeViewController: UICollectionViewDataSource { return cell } else { guard let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: UserClipCollectionViewCell.className, + withReuseIdentifier: DetailClipListCollectionViewCell.className, for: indexPath - ) as? UserClipCollectionViewCell else { return UICollectionViewCell() } - let model = viewModel.mainInfoList.mainCategoryListDto - if indexPath.item == 0 { - cell.bindData(forModel: model[indexPath.item], - icon: .icAllClip24.withTintColor(.black900)) - } else { - cell.bindData(forModel: model[indexPath.item], - icon: .icClipFull24) + ) as? DetailClipListCollectionViewCell else { return UICollectionViewCell() } + if indexPath.item < lastIndex { + let model = viewModel.recentLink + cell.configureCell(forModel: model[indexPath.item], isClipHidden: false) } return cell } @@ -173,6 +186,14 @@ extension HomeViewController: UICollectionViewDataSource { let nickName = viewModel.mainInfoList.nickname header.configureHeader(forTitle: nickName, num: indexPath.section) + header.arrowButton.addTarget(self, action: #selector(arrowButtonTapped), for: .touchUpInside) + + // 컬뷰 헤더에 붙는 팁뷰 + firstToolTip = ToasterTipView( + title: "마지막으로 저장한 링크를\n확인하러 가보세요!", + type: .left, + sourceItem: header.arrowButton + ) case 2: header.configureHeader(forTitle: "이주의 링크", num: indexPath.section) @@ -230,14 +251,14 @@ private extension HomeViewController { homeCollectionView.do { $0.register(MainCollectionViewCell.self, forCellWithReuseIdentifier: MainCollectionViewCell.className) - $0.register(UserClipCollectionViewCell.self, - forCellWithReuseIdentifier: UserClipCollectionViewCell.className) $0.register(WeeklyLinkCollectionViewCell.self, forCellWithReuseIdentifier: WeeklyLinkCollectionViewCell.className) $0.register(WeeklyRecommendCollectionViewCell.self, forCellWithReuseIdentifier: WeeklyRecommendCollectionViewCell.className) $0.register(UserClipEmptyCollectionViewCell.self, forCellWithReuseIdentifier: UserClipEmptyCollectionViewCell.className) + $0.register(DetailClipListCollectionViewCell.self, + forCellWithReuseIdentifier: DetailClipListCollectionViewCell.className) // header $0.register(HomeHeaderCollectionView.self, @@ -266,6 +287,22 @@ private extension HomeViewController { popupAction: showPopupAction) } + func setupToolTip() { + guard let secondToolTip else { return } + if UserDefaults.standard.value(forKey: TipUserDefaults.isShowHomeViewToolTip) == nil { + UserDefaults.standard.set(true, forKey: TipUserDefaults.isShowHomeViewToolTip) + + DispatchQueue.main.asyncAfter(deadline: .now()+1) { [weak self] in + guard let self else { return } + self.view.addSubview(self.firstToolTip ?? UIView()) + self.firstToolTip?.showToolTipAndDismissAfterDelay(duration: 2) { + self.view.addSubview(self.secondToolTip ?? UIView()) + self.secondToolTip?.showToolTipAndDismissAfterDelay(duration: 3) + } + } + } + } + func reloadCollectionView(isHidden: Bool) { homeView.collectionView.reloadData() } @@ -342,6 +379,13 @@ private extension HomeViewController { settingVC.hidesBottomBarWhenPushed = true navigationController?.pushViewController(settingVC, animated: true) } + + @objc + func arrowButtonTapped() { + let detailClipViewController = DetailClipViewController() + detailClipViewController.setupCategory(id: 0, name: "전체 클립") + navigationController?.pushViewController(detailClipViewController, animated: true) + } } // MARK: - AddClipBottomSheetViewDelegate @@ -368,16 +412,7 @@ extension HomeViewController: AddClipBottomSheetViewDelegate { extension HomeViewController: UserClipCollectionViewCellDelegate { func addClipCellTapped() { - addClipBottom.setupSheetPresentation(bottomHeight: 198) - self.present(addClipBottom, animated: true) - } -} - -// MARK: - MainCollectionViewDelegate - -extension HomeViewController: MainCollectionViewDelegate { - func searchButtonTapped() { - let searchVC = SearchViewController() - self.navigationController?.pushViewController(searchVC, animated: true) + let nextVC = AddLinkViewController() + self.navigationController?.pushViewController(nextVC, animated: true) } } diff --git a/TOASTER-iOS/Present/Home/ViewModel/HomeViewModel.swift b/TOASTER-iOS/Present/Home/ViewModel/HomeViewModel.swift index a2cbdc72..e8642faf 100644 --- a/TOASTER-iOS/Present/Home/ViewModel/HomeViewModel.swift +++ b/TOASTER-iOS/Present/Home/ViewModel/HomeViewModel.swift @@ -32,6 +32,19 @@ final class HomeViewModel { } } + private(set) var recentLink: [RecentLinkModel] = [ + RecentLinkModel(toastId: 0, + toastTitle: "", + linkUrl: "", + isRead: true, + categoryTitle: nil ?? "", + thumbnailUrl: nil ?? "") + ] { + didSet { + dataChangeAction?(!recentLink.isEmpty) + } + } + private(set) var weeklyLinkList: [WeeklyLinkModel] = [ WeeklyLinkModel(toastId: 0, toastTitle: "", @@ -221,4 +234,29 @@ extension HomeViewModel { } } } + + // 최근 링크 -> GET + func fetchRecentLinkData() { + NetworkService.shared.toastService.getRecentLink { result in + switch result { + case .success(let response): + var list: [RecentLinkModel] = [] + if let data = response?.data { + for idx in 0.. Void)? + private var forwardButtonAction: (() -> Void)? + private var shareButtonAction: (() -> Void)? + private var safariButtonAction: (() -> Void)? + + lazy var readLinkButtonTap = readLinkCheckButton.publisher() + + // MARK: - UI Components + + private let toolBar = UIToolbar() + + private let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + private let backButton = UIBarButtonItem() + private let forwardButton = UIBarButtonItem() + private(set) var readLinkCheckButton = UIBarButtonItem() + private let shareLinkButton = UIBarButtonItem() + private let safariButton = UIBarButtonItem() + + // MARK: - Life Cycles + + override init(frame: CGRect) { + super.init(frame: frame) + + setupStyle() + setupHierarchy() + setupLayout() + setupAddTarget() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Extensions + +extension LinkWebToolBarView { + func backButtonTapped(_ action: @escaping () -> Void) { + backButtonAction = action + } + + func forwardButtonTapped(_ action: @escaping () -> Void) { + forwardButtonAction = action + } + + func shareButtonTapped(_ action: @escaping () -> Void) { + shareButtonAction = action + } + + func safariButtonTapped(_ action: @escaping () -> Void) { + safariButtonAction = action + } + + func updateCanGoBack(_ canGoBack: Bool) { + self.canGoBack = canGoBack + } + + func updateCanGoForward(_ canGoForward: Bool) { + self.canGoForward = canGoForward + } + + func updateIsRead(_ isRead: Bool) { + self.isRead = isRead + } +} + +// MARK: - Private Extensions + +private extension LinkWebToolBarView { + func setupStyle() { + toolBar.do { + $0.backgroundColor = .toasterWhite + $0.setBackgroundImage(UIImage(), forToolbarPosition: .bottom, barMetrics: .default) + $0.setShadowImage(UIImage(), forToolbarPosition: .bottom) + } + + backButton.do { + $0.tintColor = .gray700 + $0.image = .icArrow2Back24 + $0.style = .plain + } + + forwardButton.do { + $0.tintColor = .gray700 + $0.image = .icArrow2Forward24 + $0.style = .plain + } + + readLinkCheckButton.do { + $0.image = .icRead + $0.style = .plain + } + + shareLinkButton.do { + $0.tintColor = .gray700 + $0.image = .icShare + $0.style = .plain + } + + safariButton.do { + $0.tintColor = .gray700 + $0.image = .icSafari24 + $0.style = .plain + } + } + + func setupHierarchy() { + addSubview(toolBar) + toolBar.setItems([backButton, flexibleSpace, forwardButton, flexibleSpace, shareLinkButton, flexibleSpace, safariButton], animated: false) + } + + func setupLayout() { + toolBar.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + func setupAddTarget() { + [backButton, forwardButton, readLinkCheckButton, shareLinkButton, safariButton].forEach { + $0.target = self + $0.action = #selector(barButtonTapped) + } + } + + @objc + func barButtonTapped(_ sender: UIBarButtonItem) { + switch sender { + case backButton: + backButtonAction?() + case forwardButton: + forwardButtonAction?() + case shareLinkButton: + shareButtonAction?() + case safariButton: + safariButtonAction?() + default: + break + } + } +} diff --git a/TOASTER-iOS/Present/LinkWeb/ViewController/LinkWebViewController.swift b/TOASTER-iOS/Present/LinkWeb/ViewController/LinkWebViewController.swift index dd5bf094..8cdca88c 100644 --- a/TOASTER-iOS/Present/LinkWeb/ViewController/LinkWebViewController.swift +++ b/TOASTER-iOS/Present/LinkWeb/ViewController/LinkWebViewController.swift @@ -15,25 +15,10 @@ final class LinkWebViewController: UIViewController { // MARK: - Properties - private var canGoBack: Bool = false { - didSet { - backButton.isEnabled = canGoBack - backButton.tintColor = canGoBack ? .gray700 : .gray150 - } - } + private var viewModel = LinkWebViewModel() + private var cancelBag = CancelBag() - private var canGoForward: Bool = false { - didSet { - forwardButton.isEnabled = canGoForward - forwardButton.tintColor = canGoForward ? .gray700 : .gray150 - } - } - - private var isRead: Bool = false { - didSet { - readLinkCheckButton.tintColor = isRead ? .gray700 : .gray150 - } - } + private var progressObservation: NSKeyValueObservation? private var toastId: Int? // MARK: - UI Properties @@ -41,23 +26,32 @@ final class LinkWebViewController: UIViewController { private let navigationView = LinkWebNavigationView() private let progressView = UIProgressView() private let webView = WKWebView() - private let toolBar = UIToolbar() + private let toolBar = LinkWebToolBarView() - private let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) - private lazy var backButton = UIBarButtonItem(image: .icArrow2Back24, style: .plain, target: self, action: #selector(goBackInWeb)) - private lazy var forwardButton = UIBarButtonItem(image: .icArrow2Forward24, style: .plain, target: self, action: #selector(goForwardInWeb)) - private lazy var readLinkCheckButton = UIBarButtonItem(image: .icRead, style: .plain, target: self, action: #selector(checkReadInWeb)) - private lazy var safariButton = UIBarButtonItem(image: .icSafari24, style: .plain, target: self, action: #selector(openInSafari)) + private lazy var firstToolTip = ToasterTipView( + title: "직접 복사할 수 있어요", + type: .bottom, + sourceItem: navigationView.addressLabel + ) + + private lazy var secondToolTip = ToasterTipView( + title: "열람 버튼을 클릭해보세요!", + type: .top, + sourceItem: toolBar.readLinkCheckButton + ) // MARK: - Life Cycle override func viewDidLoad() { super.viewDidLoad() + bindViewModels() setupStyle() setupHierarchy() setupLayout() setupNavigationBarAction() + setupToolBarAction() + setupToolTip() } override func viewWillAppear(_ animated: Bool) { @@ -73,45 +67,53 @@ final class LinkWebViewController: UIViewController { } deinit { - webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress)) + progressObservation?.invalidate() } } // MARK: - Extensions extension LinkWebViewController { - func setupDataBind(linkURL: String, isRead: Bool, id: Int) { + func setupDataBind(linkURL: String, isRead: Bool? = nil, id: Int? = nil) { if let url = URL(string: linkURL) { let request = URLRequest(url: url) webView.load(request) } - self.isRead = isRead + guard let isRead, let id else { return } + toolBar.updateIsRead(isRead) self.toastId = id - toolBar.setItems([backButton, flexibleSpace, forwardButton, flexibleSpace, readLinkCheckButton, flexibleSpace, safariButton], animated: false) - } - - func setupDataBind(linkURL: String) { - if let url = URL(string: linkURL) { - let request = URLRequest(url: url) - webView.load(request) - } - toolBar.setItems([backButton, flexibleSpace, forwardButton, flexibleSpace, safariButton], animated: false) - } - - /// KVO를 사용하여 estimatedProgress가 변경될 때 호출되는 메서드 - override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { - if keyPath == "estimatedProgress" { - if let newProgress = change?[.newKey] as? NSNumber { - let progress = Float(truncating: newProgress) - progressView.progress = progress - } - } } } // MARK: - Private Extensions private extension LinkWebViewController { + func bindViewModels() { + let readLinkButtonTapped = toolBar.readLinkButtonTap + .map { _ in + LinkReadEditModel(toastId: self.toastId ?? 0, isRead: !self.toolBar.isRead) + } + .eraseToAnyPublisher() + + let input = LinkWebViewModel.Input( + readLinkButtonTapped: readLinkButtonTapped + ) + + let output = viewModel.transform(input, cancelBag: cancelBag) + + output.isRead + .sink { [weak self] isRead in + let mesage = isRead ? StringLiterals.ToastMessage.completeReadLink : StringLiterals.ToastMessage.cancelReadLink + self?.showToastMessage(width: 152, status: .check, message: mesage) + self?.toolBar.updateIsRead(isRead) + }.store(in: cancelBag) + + output.navigateToLogin + .sink { [weak self] _ in + self?.changeViewController(viewController: LoginViewController()) + }.store(in: cancelBag) + } + func setupStyle() { view.bringSubviewToFront(progressView) view.backgroundColor = .toasterWhite @@ -123,17 +125,12 @@ private extension LinkWebViewController { webView.do { $0.navigationDelegate = self - $0.addObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress), options: .new, context: nil) - } - - toolBar.do { - $0.backgroundColor = .toasterWhite - $0.setBackgroundImage(UIImage(), forToolbarPosition: .bottom, barMetrics: .default) - $0.setShadowImage(UIImage(), forToolbarPosition: .bottom) - } - - [backButton, forwardButton, safariButton].forEach { - $0.tintColor = .gray700 + progressObservation = $0.observe( + \.estimatedProgress, + options: [.new]) { [weak self] object, _ in + let progress = Float(object.estimatedProgress) + self?.progressView.progress = progress + } } } @@ -175,38 +172,45 @@ private extension LinkWebViewController { } /// 네비게이션바 새로고침 버튼 클릭 액션 클로저 - navigationView.reloadButtonTapped { - self.webView.reload() - } + navigationView.reloadButtonTapped { self.webView.reload() } } - /// 툴바 뒤로가기 버튼 클릭 시 - @objc func goBackInWeb() { - if webView.canGoBack { - webView.goBack() - canGoBack = webView.canGoBack + func setupToolBarAction() { + /// 툴바 뒤로가기 버튼 클릭 액션 클로저 + toolBar.backButtonTapped { + if self.webView.canGoBack { self.webView.goBack() } } - } - - /// 툴바 앞으로가기 버튼 클릭 시 - @objc func goForwardInWeb() { - if webView.canGoForward { - webView.goForward() - canGoForward = webView.canGoForward + + /// 툴바 앞으로가기 버튼 클릭 액션 클로저 + toolBar.forwardButtonTapped { + if self.webView.canGoForward { self.webView.goForward() } } - } - - /// 툴바 링크 확인 완료 버튼 클릭 시 - @objc func checkReadInWeb() { - if let toastId { - patchOpenLinkAPI(requestBody: LinkReadEditModel(toastId: toastId, isRead: !isRead)) + + /// 툴바 공유 버튼 클릭 액션 클로저 + toolBar.shareButtonTapped { + guard let url = self.webView.url else { return } + let activityViewController = UIActivityViewController(activityItems: [url], applicationActivities: nil) + self.present(activityViewController, animated: true, completion: nil) + } + + /// 툴바 사파리 버튼 클릭 액션 클로저 + toolBar.safariButtonTapped { + if let url = self.webView.url { UIApplication.shared.open(url) } } } - /// 툴바 사파리 버튼 클릭 시 - @objc func openInSafari() { - if let url = webView.url { - UIApplication.shared.open(url) + func setupToolTip() { + if UserDefaults.standard.value(forKey: TipUserDefaults.isShowLinkWebViewToolTip) == nil { + UserDefaults.standard.set(true, forKey: TipUserDefaults.isShowLinkWebViewToolTip) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + guard let self else { return } + self.view.addSubview(self.secondToolTip) + self.secondToolTip.showToolTipAndDismissAfterDelay(duration: 2) { + self.view.addSubview(self.firstToolTip) + self.firstToolTip.showToolTipAndDismissAfterDelay(duration: 3) + } + } } } } @@ -219,8 +223,8 @@ extension LinkWebViewController: WKNavigationDelegate { if let url = webView.url?.absoluteString { navigationView.setupLinkAddress(link: url) } - canGoBack = webView.canGoBack - canGoForward = webView.canGoForward + toolBar.updateCanGoBack(webView.canGoBack) + toolBar.updateCanGoForward(webView.canGoForward) } /// 웹 페이지 로딩이 완료되었을 때 호출 @@ -233,25 +237,3 @@ extension LinkWebViewController: WKNavigationDelegate { progressView.isHidden = false } } - -// MARK: - Network - -extension LinkWebViewController { - func patchOpenLinkAPI(requestBody: LinkReadEditModel) { - NetworkService.shared.toastService.patchOpenLink(requestBody: PatchOpenLinkRequestDTO(toastId: requestBody.toastId, - isRead: requestBody.isRead)) { result in - switch result { - case .success: - if !self.isRead { - self.showToastMessage(width: 152, status: .check, message: StringLiterals.ToastMessage.completeReadLink) - } else { - self.showToastMessage(width: 152, status: .check, message: StringLiterals.ToastMessage.cancelReadLink) - } - self.isRead = !self.isRead - case .unAuthorized, .networkFail, .notFound: - self.changeViewController(viewController: LoginViewController()) - default: return - } - } - } -} diff --git a/TOASTER-iOS/Present/LinkWeb/ViewModel/LinkWebViewModel.swift b/TOASTER-iOS/Present/LinkWeb/ViewModel/LinkWebViewModel.swift new file mode 100644 index 00000000..90aeced3 --- /dev/null +++ b/TOASTER-iOS/Present/LinkWeb/ViewModel/LinkWebViewModel.swift @@ -0,0 +1,76 @@ +// +// LinkWebViewModel.swift +// TOASTER-iOS +// +// Created by 민 on 9/2/24. +// + +import Combine +import UIKit + +final class LinkWebViewModel: ViewModelType { + + private var cancelBag = CancelBag() + + // MARK: - Input State + + struct Input { + let readLinkButtonTapped: Driver + } + + // MARK: - Output State + + struct Output { + let isRead = PassthroughSubject() + let navigateToLogin = PassthroughSubject() + } + + // MARK: - Method + + func transform(_ input: Input, cancelBag: CancelBag) -> Output { + let output = Output() + + input.readLinkButtonTapped + .flatMap { [weak self] model in + self?.performLinkReadRequest(model, output) ?? Driver.empty() + } + .sink { isRead in + output.isRead.send(!isRead) + }.store(in: cancelBag) + + return output + } + + func performLinkReadRequest(_ model: LinkReadEditModel, _ output: Output) -> Driver { + return patchOpenLinkAPI(requestBody: model) + .handleEvents(receiveCompletion: { completion in + if case .failure = completion { + output.navigateToLogin.send() + } + }).asDriver() + } +} + +// MARK: - Network + +private extension LinkWebViewModel { + func patchOpenLinkAPI(requestBody: LinkReadEditModel) -> AnyPublisher { + return Future { promise in + NetworkService.shared.toastService.patchOpenLink( + requestBody: PatchOpenLinkRequestDTO( + toastId: requestBody.toastId, + isRead: requestBody.isRead + ) + ) { result in + switch result { + case .success: + promise(.success(!requestBody.isRead)) + case .unAuthorized, .networkFail, .notFound: + promise(.failure(NetworkResult.unAuthorized)) + default: + break + } + } + }.eraseToAnyPublisher() + } +} diff --git a/TOASTER-iOS/Present/Mypage/MypageViewController.swift b/TOASTER-iOS/Present/Mypage/MypageViewController.swift deleted file mode 100644 index 0aadbd09..00000000 --- a/TOASTER-iOS/Present/Mypage/MypageViewController.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// MypageViewController.swift -// TOASTER-iOS -// -// Created by 김다예 on 12/30/23. -// - -import UIKit - -import SnapKit -import Then - -final class MypageViewController: UIViewController { - - // MARK: - UI Properties - - private let mypageHeaderView = MypageHeaderView() - private let mypageAlertView = MypageAlertView() - - // MARK: - Life Cycle - - override func viewDidLoad() { - super.viewDidLoad() - - setupStyle() - setupHierarchy() - setupLayout() - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - setupNavigationBar() - fetchMypageInformation() - } -} - -// MARK: - Private Extensions - -private extension MypageViewController { - func setupStyle() { - view.backgroundColor = .toasterBackground - } - - func setupHierarchy() { - view.addSubviews(mypageHeaderView, mypageAlertView) - } - - func setupLayout() { - mypageHeaderView.snp.makeConstraints { - $0.top.equalTo(view.safeAreaLayoutGuide) - $0.horizontalEdges.equalToSuperview().inset(20) - } - - mypageAlertView.snp.makeConstraints { - $0.top.equalTo(mypageHeaderView.snp.bottom).offset(48) - $0.horizontalEdges.equalToSuperview() - } - } - - func setupNavigationBar() { - let type: ToasterNavigationType = ToasterNavigationType(hasBackButton: false, - hasRightButton: true, - mainTitle: StringOrImageType.string(StringLiterals.Tabbar.my), - rightButton: StringOrImageType.image(.icSettings24), - rightButtonAction: settingButtonTapped) - - if let navigationController = navigationController as? ToasterNavigationController { - navigationController.setupNavigationBar(forType: type) - } - } - - func settingButtonTapped() { - let settingVC = SettingViewController() - settingVC.hidesBottomBarWhenPushed = true - navigationController?.pushViewController(settingVC, animated: true) - } - - func fetchMypageInformation() { - NetworkService.shared.userService.getMyPage { [weak self] result in - switch result { - case .success(let response): - if let responseData = response?.data { - DispatchQueue.main.async { [weak self] in - self?.mypageHeaderView.bindModel(model: MypageUserModel(nickname: responseData.nickname, - profile: responseData.profile, - allReadToast: responseData.allReadToast, - thisWeekendRead: responseData.thisWeekendRead, - thisWeekendSaved: responseData.thisWeekendSaved)) - } - } - case .unAuthorized, .networkFail: - self?.changeViewController(viewController: LoginViewController()) - default: - print("default Fail") - } - } - } -} diff --git a/TOASTER-iOS/Present/Mypage/View/MypageAlertView.swift b/TOASTER-iOS/Present/Mypage/View/MypageAlertView.swift deleted file mode 100644 index 092fd9dc..00000000 --- a/TOASTER-iOS/Present/Mypage/View/MypageAlertView.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// MypageAlertView.swift -// TOASTER-iOS -// -// Created by 민 on 1/18/24. -// - -import UIKit - -import SnapKit -import Then - -final class MypageAlertView: UIView { - - // MARK: - UI Components - - private let seperatorView = UIView() - private let alertImage = UIImageView() - private let alertMessage = UILabel() - - // MARK: - Life Cycles - - override init(frame: CGRect) { - super.init(frame: frame) - - setupStyle() - setupHierarchy() - setupLayout() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -// MARK: - Private Extensions - -private extension MypageAlertView { - func setupStyle() { - seperatorView.do { - $0.backgroundColor = .gray50 - } - - alertImage.do { - $0.image = .imgAlarm - $0.contentMode = .scaleAspectFit - } - - alertMessage.do { - $0.numberOfLines = 2 - $0.text = "아직 마이페이지는 추가 공사 중! \n 업데이트를 기다려주세요:)" - $0.textAlignment = .center - $0.textColor = .gray500 - $0.font = .suitRegular(size: 16) - } - } - - func setupHierarchy() { - addSubviews(seperatorView, alertImage, alertMessage) - } - - func setupLayout() { - seperatorView.snp.makeConstraints { - $0.top.leading.trailing.equalToSuperview() - $0.height.equalTo(4) - } - - alertImage.snp.makeConstraints { - $0.top.equalTo(seperatorView.snp.bottom).offset(convertByHeightRatio(32)) - $0.centerX.equalToSuperview() - $0.size.equalTo(200) - } - - alertMessage.snp.makeConstraints { - $0.top.equalTo(alertImage.snp.bottom).offset(4) - $0.centerX.equalToSuperview() - } - } -} diff --git a/TOASTER-iOS/Present/RemindAdd/ClipAdd/View/Cell/RemindSelectClipCollectionViewCell.swift b/TOASTER-iOS/Present/RemindAdd/ClipAdd/View/Cell/RemindSelectClipCollectionViewCell.swift index ff4112e2..cd19683b 100644 --- a/TOASTER-iOS/Present/RemindAdd/ClipAdd/View/Cell/RemindSelectClipCollectionViewCell.swift +++ b/TOASTER-iOS/Present/RemindAdd/ClipAdd/View/Cell/RemindSelectClipCollectionViewCell.swift @@ -10,21 +10,31 @@ import UIKit import SnapKit import Then +enum ClipCellType { + case remind + case shareExtension + case chagneClip +} + final class RemindSelectClipCollectionViewCell: UICollectionViewCell { // MARK: - Properties + + private var currentCategoryTitle: String? override var isSelected: Bool { didSet { - if isSelected { - setupSelected() - } else { - setupDeselected() + if clipTitleLabel.text != currentCategoryTitle { + if isSelected { + setupSelected() + } else { + setupDeselected() + } } } } - var isShareExtension = false { + var isRounded = true { didSet { updateRoundedStyle() } @@ -67,11 +77,30 @@ extension RemindSelectClipCollectionViewCell { clipImageView.image = isSelected == true ? icon.withTintColor(.toasterPrimary) : icon } - func configureCell(forModel: RemindClipModel, icon: UIImage, isShareExtension: Bool) { + func configureCell(forModel: RemindClipModel, icon: UIImage, isRounded: Bool) { clipTitleLabel.text = forModel.title clipCountLabel.text = "\(forModel.clipCount)개" + clipTitleLabel.textColor = isSelected == true ? .toasterPrimary : .black850 + clipCountLabel.textColor = isSelected == true ? .toasterPrimary : .gray600 clipImageView.image = isSelected == true ? icon.withTintColor(.toasterPrimary) : icon - self.isShareExtension = isShareExtension + self.isRounded = isRounded + } + + /// 이동 할 카테고리 Cell 을 초기화 시키는 메서드 + func configureChangeClipCell(forModel: SelectClipModel, canSelect: Bool, icon: UIImage) { + + if canSelect == false { + currentCategoryTitle = forModel.title + } + + clipTitleLabel.text = forModel.title + clipCountLabel.text = "\(forModel.clipCount)개" + clipImageView.image = icon.withTintColor(canSelect ? .toasterBlack : .gray200) + + clipTitleLabel.textColor = canSelect ? .black850 : .gray200 + clipCountLabel.textColor = canSelect ? .gray600 : .gray200 + + self.isRounded = false } } @@ -139,10 +168,10 @@ private extension RemindSelectClipCollectionViewCell { } func updateRoundedStyle() { - if isShareExtension { - makeRounded(radius: 0) - } else { + if isRounded { makeRounded(radius: 12) + } else { + makeRounded(radius: 0) } } } diff --git a/TOASTER-iOS/Present/Search/View/SearchViewController.swift b/TOASTER-iOS/Present/Search/View/SearchViewController.swift index a43c5169..81400d8b 100644 --- a/TOASTER-iOS/Present/Search/View/SearchViewController.swift +++ b/TOASTER-iOS/Present/Search/View/SearchViewController.swift @@ -29,7 +29,6 @@ final class SearchViewController: UIViewController { private let navigationBar: UIView = UIView() private let searchTextField: UITextField = UITextField() - private let backButton: UIButton = UIButton() private let searchButton: UIButton = UIButton() private let clearButton: UIButton = UIButton() @@ -51,13 +50,7 @@ final class SearchViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - hideNavigationBar() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - showNavigationBar() + setupNavigationBar() } } @@ -74,11 +67,6 @@ private extension SearchViewController { $0.backgroundColor = .toasterBackground } - backButton.do { - $0.setImage(.icArrowLeft24, for: .normal) - $0.addTarget(self, action: #selector(backButtonTapped), for: .touchUpInside) - } - searchButton.do { $0.setImage(.icSearch20, for: .normal) $0.addTarget(self, action: #selector(searchButtonTapped), for: .touchUpInside) @@ -112,7 +100,7 @@ private extension SearchViewController { func setupHierarchy() { view.addSubviews(navigationBar, emptyView, searchResultCollectionView) - navigationBar.addSubviews(backButton, searchTextField) + navigationBar.addSubview(searchTextField) searchTextField.addSubviews(searchButton, clearButton) } @@ -123,12 +111,6 @@ private extension SearchViewController { $0.horizontalEdges.equalToSuperview() } - backButton.snp.makeConstraints { - $0.width.height.equalTo(24) - $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().inset(20) - } - [searchButton, clearButton].forEach { $0.snp.makeConstraints { $0.width.height.equalTo(20) @@ -139,9 +121,8 @@ private extension SearchViewController { searchTextField.snp.makeConstraints { $0.height.equalTo(42) - $0.centerY.equalToSuperview() - $0.leading.equalTo(backButton.snp.trailing).offset(12) - $0.trailing.equalToSuperview().inset(20) + $0.top.equalToSuperview() + $0.horizontalEdges.equalToSuperview().inset(20) } emptyView.snp.makeConstraints { @@ -195,6 +176,18 @@ private extension SearchViewController { self.changeViewController(viewController: LoginViewController()) } + func setupNavigationBar() { + let type: ToasterNavigationType = ToasterNavigationType(hasBackButton: false, + hasRightButton: false, + mainTitle: StringOrImageType.string(StringLiterals.Tabbar.search), + rightButton: StringOrImageType.string(""), + rightButtonAction: {}) + + if let navigationController = navigationController as? ToasterNavigationController { + navigationController.setupNavigationBar(forType: type) + } + } + @objc func searchButtonTapped() { fetchSearchResult() } @@ -204,10 +197,6 @@ private extension SearchViewController { searchTextField.text = nil searchTextField.becomeFirstResponder() } - - @objc func backButtonTapped() { - navigationController?.popViewController(animated: true) - } } // MARK: - UITextFieldDelegate diff --git a/TOASTER-iOS/Present/Mypage/Model/MypageUserModel.swift b/TOASTER-iOS/Present/Setting/Model/MypageUserModel.swift similarity index 100% rename from TOASTER-iOS/Present/Mypage/Model/MypageUserModel.swift rename to TOASTER-iOS/Present/Setting/Model/MypageUserModel.swift diff --git a/TOASTER-iOS/Present/Setting/SettingTableViewCell.swift b/TOASTER-iOS/Present/Setting/View/Cell/SettingTableViewCell.swift similarity index 86% rename from TOASTER-iOS/Present/Setting/SettingTableViewCell.swift rename to TOASTER-iOS/Present/Setting/View/Cell/SettingTableViewCell.swift index 7c290bcd..b0514afe 100644 --- a/TOASTER-iOS/Present/Setting/SettingTableViewCell.swift +++ b/TOASTER-iOS/Present/Setting/View/Cell/SettingTableViewCell.swift @@ -43,18 +43,18 @@ extension SettingTableViewCell { func configureCell(name: String, sectionNumber: Int) { switch sectionNumber { case 0: - settingLabel.do { - $0.text = "\(name)님" - $0.font = .suitMedium(size: 18) - $0.asFont(targetString: name, font: .suitBold(size: 18)) - $0.textColor = .black900 - } - case 1: settingLabel.do { $0.text = name $0.font = .suitMedium(size: 18) $0.textColor = .black900 } + if name == "알림 설정" { + if let isOn = UserDefaults.standard.object(forKey: "isAppAlarmOn") as? Bool { + settingSwitch.isOn = isOn + } else { + settingSwitch.isOn = false + } + } default: settingLabel.do { $0.text = name @@ -68,6 +68,10 @@ extension SettingTableViewCell { settingSwitch.isHidden = false } + func hiddenSwitch() { + settingSwitch.isHidden = true + } + func setSwitchValueChangedHandler(_ handler: @escaping (Bool) -> Void) { switchValueChangedHandler = handler } @@ -111,5 +115,6 @@ private extension SettingTableViewCell { @objc func switchValueChanged(_ sender: UISwitch) { switchValueChangedHandler?(sender.isOn) + UserDefaults.standard.set(sender.isOn, forKey: "isAppAlarmOn") } } diff --git a/TOASTER-iOS/Present/Mypage/View/MypageHeaderView.swift b/TOASTER-iOS/Present/Setting/View/MypageHeaderView.swift similarity index 93% rename from TOASTER-iOS/Present/Mypage/View/MypageHeaderView.swift rename to TOASTER-iOS/Present/Setting/View/MypageHeaderView.swift index 5ccf6ea3..4c966228 100644 --- a/TOASTER-iOS/Present/Mypage/View/MypageHeaderView.swift +++ b/TOASTER-iOS/Present/Setting/View/MypageHeaderView.swift @@ -25,13 +25,15 @@ final class MypageHeaderView: UIView { private let readLinkCountLabel = UILabel() private let readLinkCountUnitLabel = UILabel() - private let weakLinkDataView = UIView() + let weakLinkDataView = UIView() private let weakLinkDivider = UIView() private let openLinkLabel = UILabel() private let saveLinkLabel = UILabel() private let thisWeakOpenLinkCountLabel = UILabel() private let thisWeakSaveLinkCountLabel = UILabel() + let seperatorView = UIView() + // MARK: - Life Cycles override init(frame: CGRect) { @@ -131,10 +133,14 @@ private extension MypageHeaderView { $0.text = "nn" } } + + seperatorView.do { + $0.backgroundColor = .gray50 + } } func setupHierarchy() { - addSubviews(profileImageView, subTitleStackView, weakLinkDataView, readLinkCountLabel, readLinkCountUnitLabel) + addSubviews(profileImageView, subTitleStackView, weakLinkDataView, readLinkCountLabel, readLinkCountUnitLabel, seperatorView) subTitleStackView.addArrangedSubviews(topSubTitleLabel, bottomSubTitleLabel) @@ -195,6 +201,12 @@ private extension MypageHeaderView { $0.top.equalToSuperview().offset(22) $0.trailing.equalToSuperview() } + + seperatorView.snp.makeConstraints { + $0.top.equalTo(weakLinkDataView.snp.bottom).offset(24) + $0.leading.trailing.equalToSuperview() + $0.height.equalTo(4) + } } func changeFontColor(text: String) -> NSAttributedString { diff --git a/TOASTER-iOS/Present/Setting/View/SettingView.swift b/TOASTER-iOS/Present/Setting/View/SettingView.swift new file mode 100644 index 00000000..5b4d059f --- /dev/null +++ b/TOASTER-iOS/Present/Setting/View/SettingView.swift @@ -0,0 +1,106 @@ +// +// SettingView.swift +// TOASTER-iOS +// +// Created by Gahyun Kim on 9/29/24. +// + +import UIKit + +import Kingfisher +import Then +import SnapKit + +final class SettingView: UIView { + + let settingList = ["알림 설정", "1:1 문의", "이용약관", "로그아웃"] + + // MARK: - UI Properties + + let alertWarningView = UIView() + private let warningStackView = UIStackView() + private let warningImage = UIImageView() + private let warningLabel = UILabel() + let settingTableView = UITableView(frame: .zero, style: .grouped) + + // MARK: - Life Cycle + + override init(frame: CGRect) { + super.init(frame: frame) + + setupStyle() + setupHierarchy() + setupLayout() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +private extension SettingView { + func setupStyle() { + self.backgroundColor = .toasterBackground + + alertWarningView.do { + $0.backgroundColor = .gray50 + $0.makeRounded(radius: 12) + } + + warningStackView.do { + $0.spacing = 5 + } + + warningImage.do { + $0.image = .icAlert18Dark + $0.contentMode = .scaleAspectFit + } + + warningLabel.do { + $0.text = "알림 설정을 끄면 타이머 기능을 이용할 수 없어요" + $0.font = .suitBold(size: 12) + $0.textColor = .gray400 + } + + settingTableView.do { + $0.backgroundColor = .toasterBackground + $0.isScrollEnabled = false + $0.separatorStyle = .none + $0.register(SettingTableViewCell.self, forCellReuseIdentifier: SettingTableViewCell.className) + } + } + + func setupHierarchy() { + addSubviews(alertWarningView, settingTableView) + alertWarningView.addSubview(warningStackView) + warningStackView.addArrangedSubviews(warningImage, warningLabel) + } + + func setupLayout() { + alertWarningView.snp.makeConstraints { + $0.top.equalToSuperview().offset(22) + $0.leading.trailing.equalToSuperview().inset(20) + $0.height.equalTo(42) + } + + warningStackView.snp.makeConstraints { + $0.center.equalToSuperview() + } + + warningImage.snp.makeConstraints { + $0.centerY.leading.equalToSuperview() + $0.size.equalTo(18) + } + + warningLabel.snp.makeConstraints { + $0.centerY.trailing.equalToSuperview() + } + + settingTableView.snp.makeConstraints { + $0.top.equalTo(alertWarningView.snp.bottom) + $0.leading.trailing.bottom.equalToSuperview() + } + } +} diff --git a/TOASTER-iOS/Present/Setting/SettingViewController.swift b/TOASTER-iOS/Present/Setting/View/SettingViewController.swift similarity index 76% rename from TOASTER-iOS/Present/Setting/SettingViewController.swift rename to TOASTER-iOS/Present/Setting/View/SettingViewController.swift index 176b5c70..1bc5209c 100644 --- a/TOASTER-iOS/Present/Setting/SettingViewController.swift +++ b/TOASTER-iOS/Present/Setting/View/SettingViewController.swift @@ -14,10 +14,12 @@ final class SettingViewController: UIViewController { // MARK: - Properties - private let settingList = ["알림 설정", "1:1 문의", "이용약관", "로그아웃"] + private let userInfoView = MypageHeaderView() + private let settingView = SettingView() + private var isToggle: Bool? = UserDefaults.standard.object(forKey: "isAppAlarmOn") as? Bool { didSet { - settingTableView.reloadData() + settingView.settingTableView.reloadData() setupWarningView() UserDefaults.standard.set(isToggle, forKey: "isAppAlarmOn") } @@ -25,18 +27,10 @@ final class SettingViewController: UIViewController { private var userName: String = "" { didSet { - settingTableView.reloadData() + settingView.settingTableView.reloadData() } } - - // MARK: - UI Properties - - private let alertWarningView = UIView() - private let warningStackView = UIStackView() - private let warningImage = UIImageView() - private let warningLabel = UILabel() - private let settingTableView = UITableView(frame: .zero, style: .grouped) - + // MARK: - Life Cycle override func viewDidLoad() { @@ -45,13 +39,16 @@ final class SettingViewController: UIViewController { setupStyle() setupHierarchy() setupLayout() + setupDelegate() setupWarningView() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + setupNavigationBar() fetchMysettings() + fetchMypageInformation() } } @@ -59,70 +56,34 @@ final class SettingViewController: UIViewController { private extension SettingViewController { func setupStyle() { - view.backgroundColor = .toasterBackground - - alertWarningView.do { - $0.backgroundColor = .gray50 - $0.makeRounded(radius: 12) - } - - warningStackView.do { - $0.spacing = 5 - } - - warningImage.do { - $0.image = .icAlert18Dark - $0.contentMode = .scaleAspectFit - } - - warningLabel.do { - $0.text = "알림 설정을 끄면 타이머 기능을 이용할 수 없어요" - $0.font = .suitBold(size: 12) - $0.textColor = .gray400 - } - - settingTableView.do { - $0.backgroundColor = .toasterBackground - $0.isScrollEnabled = false - $0.separatorStyle = .none - $0.register(SettingTableViewCell.self, forCellReuseIdentifier: SettingTableViewCell.className) - $0.dataSource = self - $0.delegate = self - } + self.view.backgroundColor = .toasterBackground } - + func setupHierarchy() { - view.addSubviews(alertWarningView, settingTableView) - alertWarningView.addSubview(warningStackView) - warningStackView.addArrangedSubviews(warningImage, warningLabel) + view.addSubviews(userInfoView, settingView) } func setupLayout() { - alertWarningView.snp.makeConstraints { + userInfoView.snp.makeConstraints { + $0.horizontalEdges.equalToSuperview().inset(20) $0.top.equalTo(view.safeAreaLayoutGuide) - $0.leading.trailing.equalToSuperview().inset(20) - $0.height.equalTo(42) } - warningStackView.snp.makeConstraints { - $0.center.equalToSuperview() + userInfoView.seperatorView.snp.makeConstraints { + $0.horizontalEdges.equalTo(view.safeAreaLayoutGuide) } - warningImage.snp.makeConstraints { - $0.centerY.leading.equalToSuperview() - $0.size.equalTo(18) - } - - warningLabel.snp.makeConstraints { - $0.centerY.trailing.equalToSuperview() - } - - settingTableView.snp.makeConstraints { - $0.top.equalTo(alertWarningView.snp.bottom) - $0.leading.trailing.bottom.equalToSuperview() + settingView.snp.makeConstraints { + $0.top.equalTo(userInfoView.seperatorView.snp.bottom) + $0.horizontalEdges.bottom.equalToSuperview() } } + func setupDelegate() { + settingView.settingTableView.dataSource = self + settingView.settingTableView.delegate = self + } + func setupNavigationBar() { let type: ToasterNavigationType = ToasterNavigationType(hasBackButton: true, hasRightButton: false, @@ -138,13 +99,13 @@ private extension SettingViewController { func setupWarningView() { if let isToggle { if isToggle { - settingTableView.snp.remakeConstraints { - $0.top.equalTo(view.safeAreaLayoutGuide) + settingView.settingTableView.snp.remakeConstraints { + $0.top.equalTo(userInfoView.seperatorView.snp.bottom) $0.leading.trailing.bottom.equalToSuperview() } } else { - settingTableView.snp.remakeConstraints { - $0.top.equalTo(alertWarningView.snp.bottom) + settingView.settingTableView.snp.remakeConstraints { + $0.top.equalTo(settingView.alertWarningView.snp.bottom) $0.leading.trailing.bottom.equalToSuperview() } } @@ -209,6 +170,7 @@ private extension SettingViewController { switch result { case .success(let response): self.isToggle = response?.data?.isAllowed + self.setupWarningView() case .notFound, .networkFail: self.changeViewController(viewController: LoginViewController()) default: break @@ -216,6 +178,27 @@ private extension SettingViewController { } } + func fetchMypageInformation() { + NetworkService.shared.userService.getMyPage { [weak self] result in + switch result { + case .success(let response): + if let responseData = response?.data { + DispatchQueue.main.async { [weak self] in + self?.userInfoView.bindModel(model: MypageUserModel(nickname: responseData.nickname, + profile: responseData.profile, + allReadToast: responseData.allReadToast, + thisWeekendRead: responseData.thisWeekendRead, + thisWeekendSaved: responseData.thisWeekendSaved)) + } + } + case .unAuthorized, .networkFail: + self?.changeViewController(viewController: LoginViewController()) + default: + print("default Fail") + } + } + } + func popupDeleteButtonTapped() { deleteAccount() } @@ -252,7 +235,7 @@ extension SettingViewController: UITableViewDelegate { } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if indexPath.section == 1 { + if indexPath.section == 0 { switch indexPath.row { case 1: let urlString = "https://open.kakao.com/o/sfN9Fr4f" @@ -271,7 +254,7 @@ extension SettingViewController: UITableViewDelegate { default: return } - } else if indexPath.section == 2 { + } else if indexPath.section == 1 { self.showPopup(forMainText: "정말로 탈퇴하시겠어요?", forSubText: "회원 탈퇴 시 지금까지\n저장한 모든 링크가 사라져요.", forLeftButtonTitle: "네, 탈퇴할래요", forRightButtonTitle: "더 써볼래요", forLeftButtonHandler: self.popupDeleteButtonTapped, forRightButtonHandler: nil) } } @@ -281,14 +264,12 @@ extension SettingViewController: UITableViewDelegate { extension SettingViewController: UITableViewDataSource { func numberOfSections(in tableView: UITableView) -> Int { - return 3 + return 2 } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch section { case 0: - return 1 - case 1: return 4 default: return 1 @@ -300,9 +281,7 @@ extension SettingViewController: UITableViewDataSource { cell.selectionStyle = .none switch indexPath.section { case 0: - cell.configureCell(name: userName, sectionNumber: indexPath.section) - case 1: - cell.configureCell(name: settingList[indexPath.row], sectionNumber: indexPath.section) + cell.configureCell(name: settingView.settingList[indexPath.row], sectionNumber: indexPath.section) if indexPath.row == 0 { cell.showSwitch() cell.setSwitchValueChangedHandler { isOn in @@ -310,6 +289,7 @@ extension SettingViewController: UITableViewDataSource { } } default: + cell.hiddenSwitch() cell.configureCell(name: "탈퇴하기", sectionNumber: indexPath.section) } return cell diff --git a/TOASTER-iOS/Present/TabBar/TabBarItem.swift b/TOASTER-iOS/Present/TabBar/TabBarItem.swift index 27d9dc7d..2d432326 100644 --- a/TOASTER-iOS/Present/TabBar/TabBarItem.swift +++ b/TOASTER-iOS/Present/TabBar/TabBarItem.swift @@ -9,7 +9,7 @@ import UIKit enum TabBarItem: CaseIterable { - case home, clip, plus, timer, my + case home, clip, plus, search, timer // 선택되지 않은 탭 var normalItem: UIImage? { @@ -20,10 +20,11 @@ enum TabBarItem: CaseIterable { return .icClipFull24.withTintColor(.gray150) case .plus: return .fabPlus + case .search: + return .icSearch24.withTintColor(.gray150) case .timer: return .icTimer24.withTintColor(.gray150) - case .my: - return .icMy24.withTintColor(.gray150) + } } @@ -36,10 +37,10 @@ enum TabBarItem: CaseIterable { return .icClipFull24.withTintColor(.black900) case .plus: return .fabPlus + case .search: + return .icSearch24.withTintColor(.black900) case .timer: return .icTimer24.withTintColor(.black900) - case .my: - return .icMy24.withTintColor(.black900) } } @@ -49,8 +50,8 @@ enum TabBarItem: CaseIterable { case .home: return StringLiterals.Tabbar.home case .clip: return StringLiterals.Tabbar.clip case .plus: return nil + case .search: return StringLiterals.Tabbar.search case .timer: return StringLiterals.Tabbar.timer - case .my: return StringLiterals.Tabbar.my } } @@ -60,8 +61,8 @@ enum TabBarItem: CaseIterable { case .home: return HomeViewController() case .clip: return ClipViewController() case .plus: return ViewController() + case .search: return SearchViewController() case .timer: return RemindViewController() - case .my: return MypageViewController() } } } diff --git a/ToasterShareExtension/Info.plist b/ToasterShareExtension/Info.plist index e713c9c1..cdbc520a 100644 --- a/ToasterShareExtension/Info.plist +++ b/ToasterShareExtension/Info.plist @@ -29,8 +29,10 @@ NSExtensionActivationRule - NSExtensionActivationSupportsURLWithMaxCount + NSExtensionActivationSupportsWebURLWithMaxCount 1 + NSExtensionActivationSupportsText + NSExtensionPointIdentifier diff --git a/ToasterShareExtension/ShareViewController.swift b/ToasterShareExtension/ShareViewController.swift index 4124c169..83c5d20b 100644 --- a/ToasterShareExtension/ShareViewController.swift +++ b/ToasterShareExtension/ShareViewController.swift @@ -7,6 +7,7 @@ import UIKit import Social +import Combine import SnapKit import Then @@ -15,29 +16,27 @@ import Then class ShareViewController: UIViewController { // MARK: - Properties - private var urlString = "" - private let viewModel = RemindSelectClipViewModel() - private var categoryID: Int? - private var selectedClip: RemindClipModel? { - didSet { - nextBottomButton.backgroundColor = .toasterBlack - } - } + private let viewModel = RemindSelectClipViewModel() + private let shareViewModel = ShareViewModel() + private var titleHeight: Int { return isUseShareExtension ? 64 : 0 } - private let appURL = "TOASTER://" private var isUseShareExtension = false + private let selectedClipSubejct = PassthroughSubject() + + private var cancelBag = CancelBag() + // MARK: - UI Components private let titleLabel = UILabel() private let closeButton = UIButton() private let bottomSheetView = UIView() private var clipSelectCollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) - private let nextBottomButton = UIButton() + private let completeBottomButton = UIButton() // MARK: - Life Cycles @@ -49,16 +48,26 @@ class ShareViewController: UIViewController { setupHierarchy() setupLayout() setupDelegate() - setupButton() setupRegisterCell() setupViewModel() fetchCheckTokenHealth() + bindViewModel() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) print("viewDidAppear View height: \(self.view.frame.size.height)") + clipSelectCollectionView.selectItem( + at: IndexPath(row: 0, section: 0), + animated: false, + scrollPosition: .top + ) + collectionView( + clipSelectCollectionView, + didSelectItemAt: IndexPath(row: 0, section: 0) + ) + if isUseShareExtension { // 상단 Title 높이 + 데이터 개수 * cell 높이 + 하단 버튼 + SafeArea let calculateBottomSheetHeight = titleHeight + (viewModel.clipData.count) * 54 + 116 @@ -120,8 +129,8 @@ private extension ShareViewController { $0.showsVerticalScrollIndicator = false } - nextBottomButton.do { - $0.setTitle(StringLiterals.Button.next, for: .normal) + completeBottomButton.do { + $0.setTitle(StringLiterals.Button.complete, for: .normal) $0.setTitleColor(.toasterWhite, for: .normal) $0.backgroundColor = .gray200 $0.makeRounded(radius: 12) @@ -130,7 +139,7 @@ private extension ShareViewController { func setupHierarchy() { view.addSubviews(bottomSheetView) - bottomSheetView.addSubviews(titleLabel, closeButton, clipSelectCollectionView, nextBottomButton) + bottomSheetView.addSubviews(titleLabel, closeButton, clipSelectCollectionView, completeBottomButton) } func setupLayout() { @@ -150,10 +159,10 @@ private extension ShareViewController { clipSelectCollectionView.snp.makeConstraints { $0.top.equalTo(titleLabel.snp.bottom).offset(22) $0.horizontalEdges.equalToSuperview().inset(20) - $0.bottom.equalTo(nextBottomButton.snp.top).inset(-20) + $0.bottom.equalTo(completeBottomButton.snp.top).inset(-20) } - nextBottomButton.snp.makeConstraints { + completeBottomButton.snp.makeConstraints { $0.centerX.equalToSuperview() $0.bottom.equalToSuperview().inset(34) $0.horizontalEdges.equalToSuperview().inset(20) @@ -170,11 +179,6 @@ private extension ShareViewController { clipSelectCollectionView.dataSource = self } - func setupButton() { - closeButton.addTarget(self, action: #selector(hideBottomSheetAction), for: .touchUpInside) - nextBottomButton.addTarget(self, action: #selector(nextButtonTapped), for: .touchUpInside) - } - func setupViewModel() { viewModel.setupDataChangeAction(changeAction: reloadCollectionView) } @@ -182,29 +186,20 @@ private extension ShareViewController { func reloadCollectionView() { clipSelectCollectionView.reloadData() } - - @objc func hideBottomSheetAction(_ sender: UIButton) { - UIView.animate(withDuration: 0.3, animations: { - self.view.transform = CGAffineTransform(translationX: 0, y: self.view.frame.height) - }, completion: { _ in - self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) - }) - } - - @objc func nextButtonTapped(_ sender: UIButton) { - postSaveLink(url: urlString, category: categoryID) - } - + // 웹 사이트 URL 를 받아올 수 있는 메서드 func getUrl() { if let item = extensionContext?.inputItems.first as? NSExtensionItem, - let itemProvider = item.attachments?.first as? NSItemProvider, - itemProvider.hasItemConformingToTypeIdentifier("public.url") { - itemProvider.loadItem(forTypeIdentifier: "public.url", options: nil) { [weak self] (url, error) in - if let shareURL = url as? URL { - self?.urlString = shareURL.absoluteString - } else { - print("Error loading URL: \(error?.localizedDescription ?? "")") + let itemProviders = item.attachments { + itemProviders.forEach { itemProvider in + if itemProvider.hasItemConformingToTypeIdentifier("public.url") { + itemProvider.loadItem(forTypeIdentifier: "public.url") { [weak self] (url, error) in + if let shareURL = url as? URL { + self?.shareViewModel.bindUrl(shareURL.absoluteString) + } else { + print("Error loading URL: \(error?.localizedDescription ?? "")") + } + } } } } @@ -213,7 +208,7 @@ private extension ShareViewController { func fetchCheckTokenHealth() { NetworkService.shared.authService.postTokenHealth(tokenType: .accessToken) { [weak self] result in switch result { - case .success(let response): + case .success: self?.isUseShareExtension = true case .unAuthorized, .networkFail: self?.isUseShareExtension = false @@ -225,22 +220,41 @@ private extension ShareViewController { } } - func postSaveLink(url: String, category: Int?) { - let request = PostSaveLinkRequestDTO(linkUrl: url, - categoryId: category) - NetworkService.shared.toastService.postSaveLink(requestBody: request) { [weak self] result in - switch result { - case .success: - print("저장 성공") - self?.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) - case .networkFail, .unAuthorized, .notFound: - print("저장 실패") - case .badRequest, .serverErr: - print("저장 실패") - default: - return + func bindViewModel() { + let input = ShareViewModel.Input( + selectedClip: selectedClipSubejct.eraseToAnyPublisher(), + completeButtonTap: completeBottomButton.tapPublisher(), + closeButtonTap: closeButton.tapPublisher() + ) + + let output = shareViewModel.transform(input, cancelBag: cancelBag) + + output.isSeleted + .sink { [weak self] result in + if result == true { + self?.completeBottomButton.backgroundColor = .toasterBlack + } } - } + .store(in: cancelBag) + + output.completeButtonAction + .sink { [weak self] result in + if result == true { + self?.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) + } + } + .store(in: cancelBag) + + output.closeButtonAction + .sink { [weak self] _ in + guard let self = self else { return } + UIView.animate(withDuration: 0.3, animations: { + self.view.transform = CGAffineTransform(translationX: 0, y: self.view.frame.height) + }, completion: { _ in + self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) + }) + } + .store(in: cancelBag) } } @@ -270,7 +284,7 @@ private extension ShareViewController { func openMyApp() { self.extensionContext?.completeRequest(returningItems: nil, completionHandler: { _ in - guard let url = URL(string: self.appURL) else { return } + guard let url = URL(string: self.shareViewModel.readAppURL()) else { return } _ = self.openURL(url) }) } @@ -291,9 +305,8 @@ private extension ShareViewController { extension ShareViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - selectedClip = viewModel.clipData[indexPath.item] - let selectRow = viewModel.clipData[indexPath.item].id - categoryID = selectRow == 0 ? nil : selectRow + let selectedClip = viewModel.clipData[indexPath.item] + selectedClipSubejct.send(selectedClip) } } @@ -308,9 +321,9 @@ extension ShareViewController: UICollectionViewDataSource { guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: RemindSelectClipCollectionViewCell.className, for: indexPath) as? RemindSelectClipCollectionViewCell else { return UICollectionViewCell() } if indexPath.item == 0 { - cell.configureCell(forModel: viewModel.clipData[indexPath.item], icon: .icAllClip24, isShareExtension: true) + cell.configureCell(forModel: viewModel.clipData[indexPath.item], icon: .icAllClip24, isRounded: false) } else { - cell.configureCell(forModel: viewModel.clipData[indexPath.item], icon: .icClip24Black, isShareExtension: true) + cell.configureCell(forModel: viewModel.clipData[indexPath.item], icon: .icClip24Black, isRounded: false) } return cell diff --git a/ToasterShareExtension/ShareViewModel.swift b/ToasterShareExtension/ShareViewModel.swift new file mode 100644 index 00000000..79a317fa --- /dev/null +++ b/ToasterShareExtension/ShareViewModel.swift @@ -0,0 +1,92 @@ +// +// ShareViewModel.swift +// ToasterShareExtension +// +// Created by ParkJunHyuk on 9/29/24. +// + +import Foundation +import Combine + +final class ShareViewModel: ViewModelType { + + private let appURL = "TOASTER://" + private var urlString = "" + + struct Input { + let selectedClip: AnyPublisher + let completeButtonTap: AnyPublisher + let closeButtonTap: AnyPublisher + } + + struct Output { + let isSeleted: AnyPublisher + let completeButtonAction: AnyPublisher + let closeButtonAction: AnyPublisher + } + + func transform(_ input: Input, cancelBag: CancelBag) -> Output { + let categoryIDPublisher = input.selectedClip + .map { clip in + clip.id == 0 ? nil : clip.id + } + .eraseToAnyPublisher() + + let isSelectedPublisher = input.selectedClip + .map { _ in true } + .eraseToAnyPublisher() + + let saveLinkResultPublisher = input.completeButtonTap + .combineLatest(categoryIDPublisher) + .map { _, categoryID in categoryID } + .flatMap { [weak self] categoryID -> AnyPublisher in + guard let self else { + return Just(false).eraseToAnyPublisher() + } + + return self.postSaveLink(id: categoryID) + .catch { error -> AnyPublisher in + print("실패: \(error.localizedDescription)") + return Just(false).eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + + return Output( + isSeleted: isSelectedPublisher, + completeButtonAction: saveLinkResultPublisher, + closeButtonAction: input.closeButtonTap + ) + } + + func bindUrl(_ url: String) { + self.urlString = url + } + + func readAppURL() -> String { + return appURL + } +} + +// MARK: - API Methods + +private extension ShareViewModel { + func postSaveLink(id: Int?) -> AnyPublisher { + let request = PostSaveLinkRequestDTO(linkUrl: self.urlString, categoryId: id) + + return Future { promise in + NetworkService.shared.toastService.postSaveLink(requestBody: request) { result in + switch result { + case .success: + print("저장 성공") + promise(.success(true)) + case .networkFail, .unAuthorized, .notFound, .badRequest, .serverErr, .decodeErr, .unProcessable: + print("저장 실패") + promise(.failure(NSError(domain: "PostSaveLinkError", code: 0, userInfo: [NSLocalizedDescriptionKey: "링크 저장에 실패했습니다."]))) + } + } + } + .eraseToAnyPublisher() + } +}