diff --git a/.editorConfig b/.editorConfig new file mode 100644 index 0000000..a331054 --- /dev/null +++ b/.editorConfig @@ -0,0 +1,13 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.swift] +max_line_length = 120 + diff --git a/CodeLounge.app.dSYM.zip b/CodeLounge.app.dSYM.zip index 901aba2..ab2aabf 100644 Binary files a/CodeLounge.app.dSYM.zip and b/CodeLounge.app.dSYM.zip differ diff --git a/CodeLounge.ipa b/CodeLounge.ipa index bb46182..c2ca8a3 100644 Binary files a/CodeLounge.ipa and b/CodeLounge.ipa differ diff --git a/CodeLounge.xcodeproj/project.pbxproj b/CodeLounge.xcodeproj/project.pbxproj index d9394bf..98638d0 100644 --- a/CodeLounge.xcodeproj/project.pbxproj +++ b/CodeLounge.xcodeproj/project.pbxproj @@ -3,122 +3,83 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ - DF1EA5272D466004005CE05E /* SafriWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF1EA5262D465FF4005CE05E /* SafriWebView.swift */; }; - DF1EA5382D4670C5005CE05E /* ProfileSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF1EA5372D4670BD005CE05E /* ProfileSettingView.swift */; }; + DF16EC872F7E5F7D00EE20C5 /* TurboNavigator in Frameworks */ = {isa = PBXBuildFile; productRef = DF16EC862F7E5F7D00EE20C5 /* TurboNavigator */; }; + DF1CB3BB2F87B13900D152AD /* SwiftUI-Kit in Frameworks */ = {isa = PBXBuildFile; productRef = DF1CB3BA2F87B13900D152AD /* SwiftUI-Kit */; }; DF1FD5B92DD4D08B00D57F58 /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = DF1FD5B82DD4D08B00D57F58 /* GoogleMobileAds */; }; - DF1FD5BD2DD4DF3600D57F58 /* BannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF1FD5BC2DD4DF3600D57F58 /* BannerView.swift */; }; + DF4B47392F89875F0053911D /* CodeLoungeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF4B47372F89875F0053911D /* CodeLoungeTests.swift */; }; DF4CD40C2D3A2D0800A9251D /* .gitignore in Resources */ = {isa = PBXBuildFile; fileRef = DF4CD40B2D3A2D0800A9251D /* .gitignore */; }; - DF4CD40E2D3A3AC500A9251D /* CategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF4CD40D2D3A3AC500A9251D /* CategoryView.swift */; }; - DF4FE3262D4A727400C0598D /* CustomTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF4FE3252D4A726D00C0598D /* CustomTextField.swift */; }; - DF6A835E2DCF313D0050DCCB /* MarkdownParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF6A835D2DCF313D0050DCCB /* MarkdownParser.swift */; }; - DF770FDD2D393DDC00E00216 /* Constant.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF770FDC2D393DDC00E00216 /* Constant.swift */; }; - DF770FDF2D393EA400E00216 /* UserService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF770FDE2D393EA400E00216 /* UserService.swift */; }; - DF770FE22D39416900E00216 /* NicknameSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF770FE12D39416900E00216 /* NicknameSettingView.swift */; }; + DF6CBA0F2F7ED24F0096B7DE /* .editorConfig in Resources */ = {isa = PBXBuildFile; fileRef = DF6CBA0E2F7ED24F0096B7DE /* .editorConfig */; }; DFA616E72DB8A1DA0004A063 /* ScaleKit in Frameworks */ = {isa = PBXBuildFile; productRef = DFA616E62DB8A1DA0004A063 /* ScaleKit */; }; - DFBB8AB92D3E79EC00A09CCD /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFBB8AB82D3E79EC00A09CCD /* String+Extension.swift */; }; DFD1082A2DD50E7B003F4DD5 /* Config.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = DFD108292DD50E7B003F4DD5 /* Config.xcconfig */; }; - DFD31FDC2D36175F0076DD16 /* CodeLoungeApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFD31FDB2D36175F0076DD16 /* CodeLoungeApp.swift */; }; - DFD31FDE2D36175F0076DD16 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFD31FDD2D36175F0076DD16 /* ContentView.swift */; }; DFD31FE02D3617600076DD16 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DFD31FDF2D3617600076DD16 /* Assets.xcassets */; }; DFD31FE32D3617600076DD16 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DFD31FE22D3617600076DD16 /* Preview Assets.xcassets */; }; - DFD31FEC2D3617FE0076DD16 /* Color+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFD31FEB2D3617FE0076DD16 /* Color+Extension.swift */; }; - DFD31FEE2D36188B0076DD16 /* DIContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFD31FED2D36188B0076DD16 /* DIContainer.swift */; }; - DFD31FF12D36189F0076DD16 /* Services.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFD31FF02D36189F0076DD16 /* Services.swift */; }; - DFD31FF32D3618A70076DD16 /* ServiceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFD31FF22D3618A70076DD16 /* ServiceError.swift */; }; - DFD31FF52D3618D70076DD16 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFD31FF42D3618D70076DD16 /* AuthenticationService.swift */; }; - DFD31FFC2D361A930076DD16 /* Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFD31FFB2D361A930076DD16 /* Model.swift */; }; - DFD31FFE2D3622350076DD16 /* MorphingSymbolView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFD31FFD2D3622350076DD16 /* MorphingSymbolView.swift */; }; - DFD320042D3629770076DD16 /* IntroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFD320032D3629770076DD16 /* IntroView.swift */; }; - DFE1ACE72D3E0EB2008624D7 /* Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFE1ACE62D3E0EB2008624D7 /* Post.swift */; }; - DFE1ACEC2D3E0F69008624D7 /* DetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFE1ACEB2D3E0F69008624D7 /* DetailView.swift */; }; - DFE1ACEF2D3E0FF2008624D7 /* PostViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFE1ACEE2D3E0FF2008624D7 /* PostViewModel.swift */; }; - DFE1ACF22D3E182E008624D7 /* ButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFE1ACF12D3E182E008624D7 /* ButtonStyle.swift */; }; - DFE1ACF42D3E2058008624D7 /* Navigation+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFE1ACF32D3E2058008624D7 /* Navigation+Extension.swift */; }; - DFEFED2B2D38F78300F18B90 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFEFED2A2D38F78300F18B90 /* LoginView.swift */; }; DFEFED2D2D38FAE300F18B90 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = DFEFED2C2D38FAE300F18B90 /* GoogleService-Info.plist */; }; - DFEFED312D38FBC200F18B90 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFEFED302D38FBC200F18B90 /* MainTabView.swift */; }; - DFEFED342D38FC2D00F18B90 /* CSView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFEFED332D38FC2D00F18B90 /* CSView.swift */; }; - DFEFED362D38FC3600F18B90 /* iOSView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFEFED352D38FC3600F18B90 /* iOSView.swift */; }; - DFEFED382D38FC4400F18B90 /* AosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFEFED372D38FC4400F18B90 /* AosView.swift */; }; - DFEFED3B2D38FC6200F18B90 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFEFED3A2D38FC6200F18B90 /* ProfileView.swift */; }; - DFEFED402D38FF2E00F18B90 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFEFED3F2D38FF2E00F18B90 /* AppDelegate.swift */; }; DFEFED432D38FF8200F18B90 /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = DFEFED422D38FF8200F18B90 /* FirebaseAuth */; }; DFEFED452D38FF8200F18B90 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = DFEFED442D38FF8200F18B90 /* FirebaseCore */; }; DFEFED472D38FF8200F18B90 /* FirebaseDatabase in Frameworks */ = {isa = PBXBuildFile; productRef = DFEFED462D38FF8200F18B90 /* FirebaseDatabase */; }; DFEFED4A2D38FF9400F18B90 /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = DFEFED492D38FF9400F18B90 /* GoogleSignIn */; }; DFEFED4C2D38FF9400F18B90 /* GoogleSignInSwift in Frameworks */ = {isa = PBXBuildFile; productRef = DFEFED4B2D38FF9400F18B90 /* GoogleSignInSwift */; }; - DFEFED4E2D38FFCC00F18B90 /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFEFED4D2D38FFCC00F18B90 /* AuthenticationViewModel.swift */; }; - DFEFED502D38FFD900F18B90 /* AuthenticationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFEFED4F2D38FFD900F18B90 /* AuthenticationView.swift */; }; - DFEFED532D39009400F18B90 /* UserDBRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFEFED522D39009400F18B90 /* UserDBRepository.swift */; }; - DFEFED552D3900A200F18B90 /* UserObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFEFED542D3900A200F18B90 /* UserObject.swift */; }; - DFEFED572D3900A900F18B90 /* DBError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFEFED562D3900A900F18B90 /* DBError.swift */; }; - DFEFED5A2D3900F400F18B90 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFEFED592D3900F400F18B90 /* User.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + DF7A10082F90000800ABCDEF /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DFD31FD02D36175F0076DD16 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DFD31FD72D36175F0076DD16; + remoteInfo = CodeLounge; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ - DF1EA5262D465FF4005CE05E /* SafriWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafriWebView.swift; sourceTree = ""; }; - DF1EA5372D4670BD005CE05E /* ProfileSettingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSettingView.swift; sourceTree = ""; }; - DF1FD5BC2DD4DF3600D57F58 /* BannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerView.swift; sourceTree = ""; }; DF2834C32D3A6B79009AF58A /* CodeLounge.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CodeLounge.entitlements; sourceTree = ""; }; + DF4B47372F89875F0053911D /* CodeLoungeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeLoungeTests.swift; sourceTree = ""; }; DF4CD40B2D3A2D0800A9251D /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitignore; sourceTree = ""; }; - DF4CD40D2D3A3AC500A9251D /* CategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryView.swift; sourceTree = ""; }; - DF4FE3252D4A726D00C0598D /* CustomTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTextField.swift; sourceTree = ""; }; - DF6A835D2DCF313D0050DCCB /* MarkdownParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownParser.swift; sourceTree = ""; }; - DF770FDC2D393DDC00E00216 /* Constant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constant.swift; sourceTree = ""; }; - DF770FDE2D393EA400E00216 /* UserService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserService.swift; sourceTree = ""; }; - DF770FE12D39416900E00216 /* NicknameSettingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NicknameSettingView.swift; sourceTree = ""; }; - DFBB8AB82D3E79EC00A09CCD /* String+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = ""; }; + DF6CBA0E2F7ED24F0096B7DE /* .editorConfig */ = {isa = PBXFileReference; lastKnownFileType = text; path = .editorConfig; sourceTree = ""; }; + DF7A10012F90000100ABCDEF /* CodeLoungeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CodeLoungeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DFD108292DD50E7B003F4DD5 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; DFD31FD82D36175F0076DD16 /* CodeLounge.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CodeLounge.app; sourceTree = BUILT_PRODUCTS_DIR; }; - DFD31FDB2D36175F0076DD16 /* CodeLoungeApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeLoungeApp.swift; sourceTree = ""; }; - DFD31FDD2D36175F0076DD16 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; DFD31FDF2D3617600076DD16 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; DFD31FE22D3617600076DD16 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; - DFD31FEB2D3617FE0076DD16 /* Color+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extension.swift"; sourceTree = ""; }; - DFD31FED2D36188B0076DD16 /* DIContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DIContainer.swift; sourceTree = ""; }; - DFD31FF02D36189F0076DD16 /* Services.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services.swift; sourceTree = ""; }; - DFD31FF22D3618A70076DD16 /* ServiceError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceError.swift; sourceTree = ""; }; - DFD31FF42D3618D70076DD16 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; - DFD31FFB2D361A930076DD16 /* Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Model.swift; sourceTree = ""; }; - DFD31FFD2D3622350076DD16 /* MorphingSymbolView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MorphingSymbolView.swift; sourceTree = ""; }; - DFD320032D3629770076DD16 /* IntroView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroView.swift; sourceTree = ""; }; - DFE1ACE62D3E0EB2008624D7 /* Post.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Post.swift; sourceTree = ""; }; - DFE1ACEB2D3E0F69008624D7 /* DetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailView.swift; sourceTree = ""; }; - DFE1ACEE2D3E0FF2008624D7 /* PostViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostViewModel.swift; sourceTree = ""; }; - DFE1ACF12D3E182E008624D7 /* ButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyle.swift; sourceTree = ""; }; - DFE1ACF32D3E2058008624D7 /* Navigation+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Navigation+Extension.swift"; sourceTree = ""; }; - DFEFED2A2D38F78300F18B90 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; DFEFED2C2D38FAE300F18B90 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; DFEFED2E2D38FB0000F18B90 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - DFEFED302D38FBC200F18B90 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; - DFEFED332D38FC2D00F18B90 /* CSView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSView.swift; sourceTree = ""; }; - DFEFED352D38FC3600F18B90 /* iOSView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSView.swift; sourceTree = ""; }; - DFEFED372D38FC4400F18B90 /* AosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AosView.swift; sourceTree = ""; }; - DFEFED3A2D38FC6200F18B90 /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; - DFEFED3F2D38FF2E00F18B90 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - DFEFED4D2D38FFCC00F18B90 /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = ""; }; - DFEFED4F2D38FFD900F18B90 /* AuthenticationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationView.swift; sourceTree = ""; }; - DFEFED522D39009400F18B90 /* UserDBRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDBRepository.swift; sourceTree = ""; }; - DFEFED542D3900A200F18B90 /* UserObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserObject.swift; sourceTree = ""; }; - DFEFED562D3900A900F18B90 /* DBError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBError.swift; sourceTree = ""; }; - DFEFED592D3900F400F18B90 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; DFFAC7C02D3A46DA008C69F6 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + DF6CBA092F7ECF350096B7DE /* Extensions+ */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "Extensions+"; sourceTree = ""; }; + DF99D8F32F7ED48100334221 /* General */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = General; sourceTree = ""; }; + DF99DADE2F7EDDC000334221 /* View */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = View; sourceTree = ""; }; + DF99DAE62F7EDE1100334221 /* App */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = App; sourceTree = ""; }; + DFF1E4ED2F7F7F5500792F72 /* DesignSystem */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = DesignSystem; sourceTree = ""; }; + DFF1E6D92F7F909E00792F72 /* Repository */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Repository; sourceTree = ""; }; + DFF1E6DF2F7F910600792F72 /* Model */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Model; sourceTree = ""; }; + DFF1E6E42F7F997000792F72 /* Service */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Service; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ + DF7A10032F90000300ABCDEF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DFD31FD52D36175F0076DD16 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( DFEFED4C2D38FF9400F18B90 /* GoogleSignInSwift in Frameworks */, + DF1CB3BB2F87B13900D152AD /* SwiftUI-Kit in Frameworks */, DFEFED472D38FF8200F18B90 /* FirebaseDatabase in Frameworks */, DFA616E72DB8A1DA0004A063 /* ScaleKit in Frameworks */, DFEFED4A2D38FF9400F18B90 /* GoogleSignIn in Frameworks */, DFEFED452D38FF8200F18B90 /* FirebaseCore in Frameworks */, + DF16EC872F7E5F7D00EE20C5 /* TurboNavigator in Frameworks */, DFEFED432D38FF8200F18B90 /* FirebaseAuth in Frameworks */, DF1FD5B92DD4D08B00D57F58 /* GoogleMobileAds in Frameworks */, ); @@ -127,36 +88,12 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - DF1EA5232D465FD0005CE05E /* SafariWeb */ = { - isa = PBXGroup; - children = ( - DF1EA5262D465FF4005CE05E /* SafriWebView.swift */, - ); - path = SafariWeb; - sourceTree = ""; - }; - DF1FD5BB2DD4DF2F00D57F58 /* Banner */ = { - isa = PBXGroup; - children = ( - DF1FD5BC2DD4DF3600D57F58 /* BannerView.swift */, - ); - path = Banner; - sourceTree = ""; - }; - DF4FE3242D4A726900C0598D /* View */ = { - isa = PBXGroup; - children = ( - DF4FE3252D4A726D00C0598D /* CustomTextField.swift */, - ); - path = View; - sourceTree = ""; - }; - DF770FE02D39415D00E00216 /* Nickname */ = { + DF4B47382F89875F0053911D /* CodeLoungeTests */ = { isa = PBXGroup; children = ( - DF770FE12D39416900E00216 /* NicknameSettingView.swift */, + DF4B47372F89875F0053911D /* CodeLoungeTests.swift */, ); - path = Nickname; + path = CodeLoungeTests; sourceTree = ""; }; DFD31FCF2D36175F0076DD16 = { @@ -165,6 +102,8 @@ DFFAC7C02D3A46DA008C69F6 /* README.md */, DF4CD40B2D3A2D0800A9251D /* .gitignore */, DFD108292DD50E7B003F4DD5 /* Config.xcconfig */, + DF6CBA0E2F7ED24F0096B7DE /* .editorConfig */, + DF4B47382F89875F0053911D /* CodeLoungeTests */, DFD31FDA2D36175F0076DD16 /* CodeLounge */, DFD31FD92D36175F0076DD16 /* Products */, ); @@ -174,6 +113,7 @@ isa = PBXGroup; children = ( DFD31FD82D36175F0076DD16 /* CodeLounge.app */, + DF7A10012F90000100ABCDEF /* CodeLoungeTests.xctest */, ); name = Products; sourceTree = ""; @@ -181,22 +121,19 @@ DFD31FDA2D36175F0076DD16 /* CodeLounge */ = { isa = PBXGroup; children = ( - DFEFED3F2D38FF2E00F18B90 /* AppDelegate.swift */, + DFF1E6E42F7F997000792F72 /* Service */, + DFF1E6DF2F7F910600792F72 /* Model */, + DFF1E6D92F7F909E00792F72 /* Repository */, + DFF1E4ED2F7F7F5500792F72 /* DesignSystem */, + DF99DAE62F7EDE1100334221 /* App */, + DF99D8F32F7ED48100334221 /* General */, + DF6CBA092F7ECF350096B7DE /* Extensions+ */, DF2834C32D3A6B79009AF58A /* CodeLounge.entitlements */, - DFD31FDB2D36175F0076DD16 /* CodeLoungeApp.swift */, - DFD31FDD2D36175F0076DD16 /* ContentView.swift */, - DFD31FEA2D3617EE0076DD16 /* Extension */, - DFD31FE92D36178D0076DD16 /* General */, + DF99DADE2F7EDDC000334221 /* View */, DFEFED2C2D38FAE300F18B90 /* GoogleService-Info.plist */, DFEFED2E2D38FB0000F18B90 /* Info.plist */, - DFEFED582D3900E700F18B90 /* Model */, DFD31FE12D3617600076DD16 /* Preview Content */, - DFEFED3E2D38FE5900F18B90 /* Repository */, DFEFED3D2D38FDB800F18B90 /* Resources */, - DFD31FEF2D3618940076DD16 /* Service */, - DFE1ACF02D3E1819008624D7 /* Style */, - DFD31FF62D3619BB0076DD16 /* View */, - DFE1ACED2D3E0FCC008624D7 /* ViewModel */, ); path = CodeLounge; sourceTree = ""; @@ -209,126 +146,6 @@ path = "Preview Content"; sourceTree = ""; }; - DFD31FE92D36178D0076DD16 /* General */ = { - isa = PBXGroup; - children = ( - DF4FE3242D4A726900C0598D /* View */, - DFD31FED2D36188B0076DD16 /* DIContainer.swift */, - DF770FDC2D393DDC00E00216 /* Constant.swift */, - ); - path = General; - sourceTree = ""; - }; - DFD31FEA2D3617EE0076DD16 /* Extension */ = { - isa = PBXGroup; - children = ( - DFD31FEB2D3617FE0076DD16 /* Color+Extension.swift */, - DFE1ACF32D3E2058008624D7 /* Navigation+Extension.swift */, - DFBB8AB82D3E79EC00A09CCD /* String+Extension.swift */, - ); - path = Extension; - sourceTree = ""; - }; - DFD31FEF2D3618940076DD16 /* Service */ = { - isa = PBXGroup; - children = ( - DFD31FF22D3618A70076DD16 /* ServiceError.swift */, - DFD31FF02D36189F0076DD16 /* Services.swift */, - DFD31FF42D3618D70076DD16 /* AuthenticationService.swift */, - DF770FDE2D393EA400E00216 /* UserService.swift */, - ); - path = Service; - sourceTree = ""; - }; - DFD31FF62D3619BB0076DD16 /* View */ = { - isa = PBXGroup; - children = ( - DF1FD5BB2DD4DF2F00D57F58 /* Banner */, - DFE1ACE82D3E0F4A008624D7 /* Detail */, - DF770FE02D39415D00E00216 /* Nickname */, - DFEFED392D38FC5900F18B90 /* Profile */, - DFEFED322D38FC1F00F18B90 /* Cagegory */, - DFD31FF92D3619D00076DD16 /* MainTabView */, - DFD31FF82D3619CA0076DD16 /* Login */, - DFD31FF72D3619C00076DD16 /* Authentication */, - ); - path = View; - sourceTree = ""; - }; - DFD31FF72D3619C00076DD16 /* Authentication */ = { - isa = PBXGroup; - children = ( - DFEFED4F2D38FFD900F18B90 /* AuthenticationView.swift */, - ); - path = Authentication; - sourceTree = ""; - }; - DFD31FF82D3619CA0076DD16 /* Login */ = { - isa = PBXGroup; - children = ( - DFD31FFB2D361A930076DD16 /* Model.swift */, - DFD31FFD2D3622350076DD16 /* MorphingSymbolView.swift */, - DFD320032D3629770076DD16 /* IntroView.swift */, - DFEFED2A2D38F78300F18B90 /* LoginView.swift */, - ); - path = Login; - sourceTree = ""; - }; - DFD31FF92D3619D00076DD16 /* MainTabView */ = { - isa = PBXGroup; - children = ( - DFEFED302D38FBC200F18B90 /* MainTabView.swift */, - ); - path = MainTabView; - sourceTree = ""; - }; - DFE1ACE82D3E0F4A008624D7 /* Detail */ = { - isa = PBXGroup; - children = ( - DFE1ACEB2D3E0F69008624D7 /* DetailView.swift */, - DF6A835D2DCF313D0050DCCB /* MarkdownParser.swift */, - ); - path = Detail; - sourceTree = ""; - }; - DFE1ACED2D3E0FCC008624D7 /* ViewModel */ = { - isa = PBXGroup; - children = ( - DFEFED4D2D38FFCC00F18B90 /* AuthenticationViewModel.swift */, - DFE1ACEE2D3E0FF2008624D7 /* PostViewModel.swift */, - ); - path = ViewModel; - sourceTree = ""; - }; - DFE1ACF02D3E1819008624D7 /* Style */ = { - isa = PBXGroup; - children = ( - DFE1ACF12D3E182E008624D7 /* ButtonStyle.swift */, - ); - path = Style; - sourceTree = ""; - }; - DFEFED322D38FC1F00F18B90 /* Cagegory */ = { - isa = PBXGroup; - children = ( - DFEFED332D38FC2D00F18B90 /* CSView.swift */, - DFEFED372D38FC4400F18B90 /* AosView.swift */, - DFEFED352D38FC3600F18B90 /* iOSView.swift */, - DF4CD40D2D3A3AC500A9251D /* CategoryView.swift */, - ); - path = Cagegory; - sourceTree = ""; - }; - DFEFED392D38FC5900F18B90 /* Profile */ = { - isa = PBXGroup; - children = ( - DF1EA5372D4670BD005CE05E /* ProfileSettingView.swift */, - DF1EA5232D465FD0005CE05E /* SafariWeb */, - DFEFED3A2D38FC6200F18B90 /* ProfileView.swift */, - ); - path = Profile; - sourceTree = ""; - }; DFEFED3D2D38FDB800F18B90 /* Resources */ = { isa = PBXGroup; children = ( @@ -337,36 +154,29 @@ path = Resources; sourceTree = ""; }; - DFEFED3E2D38FE5900F18B90 /* Repository */ = { - isa = PBXGroup; - children = ( - DFEFED512D39007600F18B90 /* DTO */, - DFEFED522D39009400F18B90 /* UserDBRepository.swift */, +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + DF7A10042F90000400ABCDEF /* CodeLoungeTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = DF7A100B2F90000B00ABCDEF /* Build configuration list for PBXNativeTarget "CodeLoungeTests" */; + buildPhases = ( + DF7A10052F90000500ABCDEF /* Sources */, + DF7A10032F90000300ABCDEF /* Frameworks */, + DF7A10062F90000600ABCDEF /* Resources */, ); - path = Repository; - sourceTree = ""; - }; - DFEFED512D39007600F18B90 /* DTO */ = { - isa = PBXGroup; - children = ( - DFEFED542D3900A200F18B90 /* UserObject.swift */, - DFEFED562D3900A900F18B90 /* DBError.swift */, + buildRules = ( ); - path = DTO; - sourceTree = ""; - }; - DFEFED582D3900E700F18B90 /* Model */ = { - isa = PBXGroup; - children = ( - DFEFED592D3900F400F18B90 /* User.swift */, - DFE1ACE62D3E0EB2008624D7 /* Post.swift */, + dependencies = ( + DF7A10092F90000900ABCDEF /* PBXTargetDependency */, ); - path = Model; - sourceTree = ""; + name = CodeLoungeTests; + packageProductDependencies = ( + ); + productName = CodeLoungeTests; + productReference = DF7A10012F90000100ABCDEF /* CodeLoungeTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ DFD31FD72D36175F0076DD16 /* CodeLounge */ = { isa = PBXNativeTarget; buildConfigurationList = DFD31FE62D3617600076DD16 /* Build configuration list for PBXNativeTarget "CodeLounge" */; @@ -379,6 +189,16 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + DF6CBA092F7ECF350096B7DE /* Extensions+ */, + DF99D8F32F7ED48100334221 /* General */, + DF99DADE2F7EDDC000334221 /* View */, + DF99DAE62F7EDE1100334221 /* App */, + DFF1E4ED2F7F7F5500792F72 /* DesignSystem */, + DFF1E6D92F7F909E00792F72 /* Repository */, + DFF1E6DF2F7F910600792F72 /* Model */, + DFF1E6E42F7F997000792F72 /* Service */, + ); name = CodeLounge; packageProductDependencies = ( DFEFED422D38FF8200F18B90 /* FirebaseAuth */, @@ -388,6 +208,8 @@ DFEFED4B2D38FF9400F18B90 /* GoogleSignInSwift */, DFA616E62DB8A1DA0004A063 /* ScaleKit */, DF1FD5B82DD4D08B00D57F58 /* GoogleMobileAds */, + DF16EC862F7E5F7D00EE20C5 /* TurboNavigator */, + DF1CB3BA2F87B13900D152AD /* SwiftUI-Kit */, ); productName = CodeLounge; productReference = DFD31FD82D36175F0076DD16 /* CodeLounge.app */; @@ -403,6 +225,10 @@ LastSwiftUpdateCheck = 1540; LastUpgradeCheck = 1640; TargetAttributes = { + DF7A10042F90000400ABCDEF = { + CreatedOnToolsVersion = 16.4; + TestTargetID = DFD31FD72D36175F0076DD16; + }; DFD31FD72D36175F0076DD16 = { CreatedOnToolsVersion = 15.4; }; @@ -422,23 +248,34 @@ DFEFED482D38FF9400F18B90 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */, DFA616E52DB8A1DA0004A063 /* XCRemoteSwiftPackageReference "ScaleKit" */, DF1FD5B72DD4D08B00D57F58 /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */, + DF16EC852F7E5F7D00EE20C5 /* XCRemoteSwiftPackageReference "TurboNavigator" */, + DF1CB3B92F87B13900D152AD /* XCRemoteSwiftPackageReference "SwiftUI-Kit" */, ); productRefGroup = DFD31FD92D36175F0076DD16 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( DFD31FD72D36175F0076DD16 /* CodeLounge */, + DF7A10042F90000400ABCDEF /* CodeLoungeTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + DF7A10062F90000600ABCDEF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DFD31FD62D36175F0076DD16 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( DF4CD40C2D3A2D0800A9251D /* .gitignore in Resources */, DFD1082A2DD50E7B003F4DD5 /* Config.xcconfig in Resources */, + DF6CBA0F2F7ED24F0096B7DE /* .editorConfig in Resources */, DFD31FE32D3617600076DD16 /* Preview Assets.xcassets in Resources */, DFEFED2D2D38FAE300F18B90 /* GoogleService-Info.plist in Resources */, DFD31FE02D3617600076DD16 /* Assets.xcassets in Resources */, @@ -448,54 +285,86 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + DF7A10052F90000500ABCDEF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DF4B47392F89875F0053911D /* CodeLoungeTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DFD31FD42D36175F0076DD16 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - DFE1ACEF2D3E0FF2008624D7 /* PostViewModel.swift in Sources */, - DFBB8AB92D3E79EC00A09CCD /* String+Extension.swift in Sources */, - DF1FD5BD2DD4DF3600D57F58 /* BannerView.swift in Sources */, - DF4FE3262D4A727400C0598D /* CustomTextField.swift in Sources */, - DFEFED382D38FC4400F18B90 /* AosView.swift in Sources */, - DF770FDF2D393EA400E00216 /* UserService.swift in Sources */, - DFD31FF32D3618A70076DD16 /* ServiceError.swift in Sources */, - DFEFED572D3900A900F18B90 /* DBError.swift in Sources */, - DFEFED342D38FC2D00F18B90 /* CSView.swift in Sources */, - DF770FE22D39416900E00216 /* NicknameSettingView.swift in Sources */, - DFEFED362D38FC3600F18B90 /* iOSView.swift in Sources */, - DF6A835E2DCF313D0050DCCB /* MarkdownParser.swift in Sources */, - DF1EA5382D4670C5005CE05E /* ProfileSettingView.swift in Sources */, - DFD31FF12D36189F0076DD16 /* Services.swift in Sources */, - DFD31FFE2D3622350076DD16 /* MorphingSymbolView.swift in Sources */, - DFEFED4E2D38FFCC00F18B90 /* AuthenticationViewModel.swift in Sources */, - DFD31FEE2D36188B0076DD16 /* DIContainer.swift in Sources */, - DF1EA5272D466004005CE05E /* SafriWebView.swift in Sources */, - DFD31FDE2D36175F0076DD16 /* ContentView.swift in Sources */, - DFE1ACF42D3E2058008624D7 /* Navigation+Extension.swift in Sources */, - DFEFED3B2D38FC6200F18B90 /* ProfileView.swift in Sources */, - DF770FDD2D393DDC00E00216 /* Constant.swift in Sources */, - DFD31FEC2D3617FE0076DD16 /* Color+Extension.swift in Sources */, - DFE1ACEC2D3E0F69008624D7 /* DetailView.swift in Sources */, - DFD31FDC2D36175F0076DD16 /* CodeLoungeApp.swift in Sources */, - DFEFED502D38FFD900F18B90 /* AuthenticationView.swift in Sources */, - DFD320042D3629770076DD16 /* IntroView.swift in Sources */, - DFEFED532D39009400F18B90 /* UserDBRepository.swift in Sources */, - DFE1ACE72D3E0EB2008624D7 /* Post.swift in Sources */, - DFEFED2B2D38F78300F18B90 /* LoginView.swift in Sources */, - DFD31FF52D3618D70076DD16 /* AuthenticationService.swift in Sources */, - DFD31FFC2D361A930076DD16 /* Model.swift in Sources */, - DFEFED402D38FF2E00F18B90 /* AppDelegate.swift in Sources */, - DFEFED312D38FBC200F18B90 /* MainTabView.swift in Sources */, - DFE1ACF22D3E182E008624D7 /* ButtonStyle.swift in Sources */, - DFEFED552D3900A200F18B90 /* UserObject.swift in Sources */, - DFEFED5A2D3900F400F18B90 /* User.swift in Sources */, - DF4CD40E2D3A3AC500A9251D /* CategoryView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + DF7A10092F90000900ABCDEF /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DFD31FD72D36175F0076DD16 /* CodeLounge */; + targetProxy = DF7A10082F90000800ABCDEF /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + DF7A100C2F90000C00ABCDEF /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = DFD108292DD50E7B003F4DD5 /* Config.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 15; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.indextrown.CodeLoungeTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CodeLounge.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CodeLounge"; + }; + name = Debug; + }; + DF7A100D2F90000D00ABCDEF /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = DFD108292DD50E7B003F4DD5 /* Config.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 15; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.indextrown.CodeLoungeTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CodeLounge.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CodeLounge"; + }; + name = Release; + }; DFD31FE42D3617600076DD16 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = DFD108292DD50E7B003F4DD5 /* Config.xcconfig */; @@ -627,7 +496,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_ASSET_PATHS = "\"CodeLounge/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = LGX4B4WC66; @@ -647,7 +516,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.5; + MARKETING_VERSION = 1.0.6; PRODUCT_BUNDLE_IDENTIFIER = com.indextrown.CodeLounge; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -672,7 +541,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_ASSET_PATHS = "\"CodeLounge/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = LGX4B4WC66; @@ -692,7 +561,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.5; + MARKETING_VERSION = 1.0.6; PRODUCT_BUNDLE_IDENTIFIER = com.indextrown.CodeLounge; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -710,6 +579,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + DF7A100B2F90000B00ABCDEF /* Build configuration list for PBXNativeTarget "CodeLoungeTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DF7A100C2F90000C00ABCDEF /* Debug */, + DF7A100D2F90000D00ABCDEF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; DFD31FD32D36175F0076DD16 /* Build configuration list for PBXProject "CodeLounge" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -731,6 +609,22 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + DF16EC852F7E5F7D00EE20C5 /* XCRemoteSwiftPackageReference "TurboNavigator" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/indextrown/TurboNavigator"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.1; + }; + }; + DF1CB3B92F87B13900D152AD /* XCRemoteSwiftPackageReference "SwiftUI-Kit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/TheSwiftLab/SwiftUI-Kit"; + requirement = { + branch = main; + kind = branch; + }; + }; DF1FD5B72DD4D08B00D57F58 /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/googleads/swift-package-manager-google-mobile-ads.git"; @@ -766,6 +660,16 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + DF16EC862F7E5F7D00EE20C5 /* TurboNavigator */ = { + isa = XCSwiftPackageProductDependency; + package = DF16EC852F7E5F7D00EE20C5 /* XCRemoteSwiftPackageReference "TurboNavigator" */; + productName = TurboNavigator; + }; + DF1CB3BA2F87B13900D152AD /* SwiftUI-Kit */ = { + isa = XCSwiftPackageProductDependency; + package = DF1CB3B92F87B13900D152AD /* XCRemoteSwiftPackageReference "SwiftUI-Kit" */; + productName = "SwiftUI-Kit"; + }; DF1FD5B82DD4D08B00D57F58 /* GoogleMobileAds */ = { isa = XCSwiftPackageProductDependency; package = DF1FD5B72DD4D08B00D57F58 /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */; diff --git a/CodeLounge.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeLounge.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6bc89fd..2c66cfd 100644 --- a/CodeLounge.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeLounge.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "82f274f78275a6a9f772723c62a323e26235bd942526ead3ebc419b695d34cfb", + "originHash" : "b57bf8b575073006561d1ee99661b14e0a2489d9583e0cb6415e02afdff7ab37", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -171,6 +171,24 @@ "revision" : "ebc7251dd5b37f627c93698e4374084d98409633", "version" : "1.28.2" } + }, + { + "identity" : "swiftui-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TheSwiftLab/SwiftUI-Kit", + "state" : { + "branch" : "main", + "revision" : "7f3a0c6001f6322ba0a12209106f8d47f27d3acb" + } + }, + { + "identity" : "turbonavigator", + "kind" : "remoteSourceControl", + "location" : "https://github.com/indextrown/TurboNavigator", + "state" : { + "revision" : "da177361504bc244861f41064a6c6564bb061ea7", + "version" : "1.1.1" + } } ], "version" : 3 diff --git a/CodeLounge.xcodeproj/project.xcworkspace/xcuserdata/kimdonghyeon.xcuserdatad/UserInterfaceState.xcuserstate b/CodeLounge.xcodeproj/project.xcworkspace/xcuserdata/kimdonghyeon.xcuserdatad/UserInterfaceState.xcuserstate index 1ebf589..62f8a7b 100644 Binary files a/CodeLounge.xcodeproj/project.xcworkspace/xcuserdata/kimdonghyeon.xcuserdatad/UserInterfaceState.xcuserstate and b/CodeLounge.xcodeproj/project.xcworkspace/xcuserdata/kimdonghyeon.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/CodeLounge.xcodeproj/xcshareddata/xcschemes/CodeLounge.xcscheme b/CodeLounge.xcodeproj/xcshareddata/xcschemes/CodeLounge.xcscheme new file mode 100644 index 0000000..ddeb75d --- /dev/null +++ b/CodeLounge.xcodeproj/xcshareddata/xcschemes/CodeLounge.xcscheme @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CodeLounge.xcodeproj/xcuserdata/kimdonghyeon.xcuserdatad/xcschemes/xcschememanagement.plist b/CodeLounge.xcodeproj/xcuserdata/kimdonghyeon.xcuserdatad/xcschemes/xcschememanagement.plist index c7f6700..d340744 100644 --- a/CodeLounge.xcodeproj/xcuserdata/kimdonghyeon.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/CodeLounge.xcodeproj/xcuserdata/kimdonghyeon.xcuserdatad/xcschemes/xcschememanagement.plist @@ -28,7 +28,15 @@ isShown orderHint - 0 + 1 + + + SuppressBuildableAutocreation + + DFD31FD72D36175F0076DD16 + + primary + diff --git a/CodeLounge/App/AppDelegate.swift b/CodeLounge/App/AppDelegate.swift new file mode 100644 index 0000000..c810a3d --- /dev/null +++ b/CodeLounge/App/AppDelegate.swift @@ -0,0 +1,45 @@ +// +// AppDelegate.swift +// CodeLounge +// +// Created by 김동현 on 1/16/25. +// + +import SwiftUI +import FirebaseAuth +import FirebaseCore +import GoogleSignIn +import GoogleMobileAds + +class AppDelegate: NSObject, UIApplicationDelegate { + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil + ) -> Bool { + + // MARK: - Firebase + FirebaseApp.configure() + setenv("GRPC_VERBOSITY", "ERROR", 1) /// gRPC 관련 환경 변수 설정 (GRPC_TRACE 제거) + unsetenv("GRPC_TRACE") /// GRPC_TRACE 환경 변수 제거 (트레이싱 로그가 비활성화) + + /// Firebase 디버그 로그 활성화 + // FirebaseConfiguration.shared.setLoggerLevel(.debug) + + // MARK: - DI + DIContainer.config() + + // MARK: - Admob + MobileAds.shared.start(completionHandler: nil) + + return true + } + + // MARK: - Google Login + func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + return GIDSignIn.sharedInstance.handle(url) + } +} diff --git a/CodeLounge/App/CodeLoungeApp.swift b/CodeLounge/App/CodeLoungeApp.swift new file mode 100644 index 0000000..895da18 --- /dev/null +++ b/CodeLounge/App/CodeLoungeApp.swift @@ -0,0 +1,131 @@ +// +// CodeLoungeApp.swift +// CodeLounge +// +// Created by 김동현 on 1/14/25. +// + +import SwiftUI +import TurboNavigator + +@main +struct CodeLoungeApp: App { + @UIApplicationDelegateAdaptor var appDelegate: AppDelegate + private let authNavigator: Navigator + private let mainNavigator: Navigator + @StateObject private var rootViewModel = RootViewModel() + + init() { + self.authNavigator = AppRouter.buildAuthNavigator() + self.mainNavigator = AppRouter.buildMainNavigator() + + configureNavigationBar() + configureTabBar() + } + + var body: some Scene { + WindowGroup { + RootView( + rootViewModel: rootViewModel, + authNavigator: authNavigator, + mainNavigator: mainNavigator + ) + .versionUpdateAlert() + } + } +} + +// MARK: - Appearance +private extension CodeLoungeApp { + + func configureNavigationBar() { + let standardAppearance = UINavigationBarAppearance() + standardAppearance.configureWithDefaultBackground() + standardAppearance.shadowColor = .clear + standardAppearance.largeTitleTextAttributes = [ + .foregroundColor: UIColor.white + ] + standardAppearance.titleTextAttributes = [ + .foregroundColor: UIColor.white + ] + + let scrollEdgeAppearance = UINavigationBarAppearance() + scrollEdgeAppearance.configureWithTransparentBackground() + scrollEdgeAppearance.backgroundColor = .clear + scrollEdgeAppearance.shadowColor = .clear + scrollEdgeAppearance.largeTitleTextAttributes = [ + .foregroundColor: UIColor.white + ] + scrollEdgeAppearance.titleTextAttributes = [ + .foregroundColor: UIColor.white + ] + + let backButtonAppearance = UIBarButtonItemAppearance() + backButtonAppearance.normal.titleTextAttributes = [ + .foregroundColor: UIColor.white + ] + backButtonAppearance.highlighted.titleTextAttributes = [ + .foregroundColor: UIColor.white + ] + + standardAppearance.backButtonAppearance = backButtonAppearance + scrollEdgeAppearance.backButtonAppearance = backButtonAppearance + + let navigationBar = UINavigationBar.appearance() + navigationBar.standardAppearance = standardAppearance + navigationBar.scrollEdgeAppearance = scrollEdgeAppearance + navigationBar.compactAppearance = standardAppearance + navigationBar.tintColor = .white + } + + func configureTabBar() { + let appearance = UITabBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = .black + + appearance.stackedLayoutAppearance.selected.iconColor = .white + appearance.stackedLayoutAppearance.selected.titleTextAttributes = [ + .foregroundColor: UIColor.white + ] + + appearance.stackedLayoutAppearance.normal.iconColor = .gray + appearance.stackedLayoutAppearance.normal.titleTextAttributes = [ + .foregroundColor: UIColor.gray + ] + + UITabBar.appearance().standardAppearance = appearance + UITabBar.appearance().scrollEdgeAppearance = appearance + } +} + +/* + @State private var loggedIn: Bool = false +Group { + if loggedIn { + TabNavigationContainer( + navigator: navigator, + items: [ + .init( + tag: 0, + route: .home, + tabBarItem: UITabBarItem(title: "Home", image: nil, tag: 0)), + .init( + tag: 1, + route: .home, + tabBarItem: UITabBarItem(title: "Home", image: nil, tag: 1)), + ], + ) + } else { + NavigationContainer( + navigator: navigator, + initialRoutes: [.intro] + ) + } +} +.ignoresSafeArea(.container, edges: .all) +.versionUpdateAlert() +.task { + try? await Task.sleep(nanoseconds: 3_000_000_000) + loggedIn = true +} +*/ diff --git a/CodeLounge/App/DIContainer.swift b/CodeLounge/App/DIContainer.swift new file mode 100644 index 0000000..68dd536 --- /dev/null +++ b/CodeLounge/App/DIContainer.swift @@ -0,0 +1,74 @@ +// +// DIContainer.swift +// CodeLounge +// +// Created by 김동현 on 4/3/26. +// + +import Foundation + +@propertyWrapper +class Dependency { + let wrappedValue: T + init() { + self.wrappedValue = DIContainer.shared.resolve(T.self) + } +} + + +final class DIContainer { + static let shared = DIContainer() + private init() {} + private var dependencies: [String: Any] = [:] + + /// 타입 자체를 Key로 사용하여 의존성을 등록합니다. + /// - Parameter dependency: 등록할 객체 인스턴스 + /// - Example: `DIContainer.shared.register(NetworkManager())` + func register(_ dependency: T) { + let key = String(describing: T.self) + dependencies[key] = dependency + } + + /// 명시적으로 특정 타입(보통은 프로토콜)을 Key로 지정하여 의존성을 등록합니다. + /// - Parameters: + /// - dependency: 등록할 객체 인스턴스 + /// - type: 이 객체가 매칭될 인터페이스(예: 프로토콜) + /// - Example: + /// ```swift + /// DIContainer.shared.register(NetworkManager(), for: NetworkService.self) + /// ``` + func register(_ dependency: T, for type: T.Type) { + let key = String(describing: type) + dependencies[key] = dependency + } + + /// 등록된 의존성을 꺼냅니다. 존재하지 않으면 앱을 중단시킵니다. + /// - Parameter type: 꺼내고 싶은 타입 + /// - Returns: 등록된 의존성 인스턴스 + func resolve(_ type: T.Type) -> T { + let key = String(describing: type) + guard let dependency = dependencies[key] as? T else { + preconditionFailure("⚠️ \(key)는 register되지 않았습니다. resolve호출 전에 register 해주세요.") + } + return dependency + } +} + +extension DIContainer { + static func config() { + let userRepository = UserDBRepository() + let postRepository = PostRepository() + self.shared.register( + UserService(dbRepository: userRepository), + for: UserServiceProtocol.self + ) + self.shared.register( + PostService(repository: postRepository), + for: PostServiceProtocol.self + ) + self.shared.register( + AuthService(), + for: AuthServiceProtocol.self + ) + } +} diff --git a/CodeLounge/App/Navigator.swift b/CodeLounge/App/Navigator.swift new file mode 100644 index 0000000..b26ce03 --- /dev/null +++ b/CodeLounge/App/Navigator.swift @@ -0,0 +1,156 @@ +// +// Navigator.swift +// CodeLounge +// +// Created by 김동현 on 4/3/26. +// + +import TurboNavigator +import SwiftUI + +struct AppDependencies { + +} + +extension AppDependencies: PreviewDependencies { + @MainActor + static var preview: Self { + AppDependencies() + } +} + +enum AuthRoute: Hashable { + case intro + case login + case register +} + +enum MainRoute: Hashable { + case cs + case ios + case aos + case profile + case profileSettings(RootViewModel) + case postDetail(Post) + + static var tabCases: [MainRoute] { + [.cs, .ios, .aos, .profile] + } + + var title: String { + switch self { + case .cs: return "CS" + case .ios: return "iOS" + case .aos: return "aOS" + case .profile: return "Profile" + case .profileSettings: return "" + case .postDetail: return "" + } + } + + func imageName(isSelected: Bool) -> String { + switch self { + case .cs: + return "desktopcomputer" + case .ios: + return "apple.logo" + case .aos: + return "smartphone" + case .profile: + return isSelected ? "person.fill" : "person" + case .profileSettings: + return "" + case .postDetail: + return "" + } + } +} + +enum AppRouter { + static func buildAuthNavigator() -> Navigator { + let registry = RouteRegistry() + .registering(.intro) { context in + WrappingController( + route: context.route) { + IntroView(navigator: context.navigator) + } + } + .registering(.login) { context in + WrappingController( + route: context.route) { + LoginView(navigator: context.navigator) + } + } + .registering(.register) { context in + WrappingController( + route: context.route) { + RegisterView(navigator: context.navigator) + } + } + return Navigator( + dependencies: AppDependencies(), + registry: registry + ) + } + + static func buildMainNavigator() -> Navigator { + let registry = RouteRegistry() + .registering(.cs) { context in + WrappingController( + route: context.route) { + CSView(navigator: context.navigator) + } + } + .registering(.ios) { context in + WrappingController(route: context.route) { + iOSView(navigator: context.navigator) + } + } + .registering(.aos) { context in + WrappingController(route: context.route) { + AOSView(navigator: context.navigator) + } + } + .registering(.profile) { context in + WrappingController(route: context.route) { + ProfileView(navigator: context.navigator) + } + } + .registering( + extracting: { (route: MainRoute) -> RootViewModel? in + guard case let .profileSettings(rootViewModel) = route else { return nil } + return rootViewModel + }, + build: { context, rootViewModel in + WrappingController( + route: context.route, + title: "", + isTabBarHiddenWhenPushed: true + ) { + ProfileSettingView(rootViewModel: rootViewModel) + } + } + ) + .registering( + extracting: { (route: MainRoute) -> Post? in + guard case let .postDetail(post) = route else { return nil } + return post + }, + build: { context, post in + WrappingController( + route: context.route, + title: "" + ) { + PostDetailView( + navigator: context.navigator, + post: post + ) + } + } + ) + return Navigator( + dependencies: AppDependencies(), + registry: registry + ) + } +} diff --git a/CodeLounge/AppDelegate.swift b/CodeLounge/AppDelegate.swift deleted file mode 100644 index 6b454ae..0000000 --- a/CodeLounge/AppDelegate.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// AppDelegate.swift -// CodeLounge -// -// Created by 김동현 on 1/16/25. -// - -import SwiftUI -import FirebaseAuth -import FirebaseCore -import GoogleSignIn -import GoogleMobileAds - -class AppDelegate: NSObject, UIApplicationDelegate { - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { - FirebaseApp.configure() - - // MARK: - Firebase - // gRPC 관련 환경 변수 설정 (GRPC_TRACE 제거) - setenv("GRPC_VERBOSITY", "ERROR", 1) - // GRPC_TRACE 환경 변수 제거 (트레이싱 로그가 비활성화) - unsetenv("GRPC_TRACE") - - // Firebase 디버그 로그 활성화 - // FirebaseConfiguration.shared.setLoggerLevel(.debug) - - // MARK: - Admob - MobileAds.shared.start(completionHandler: nil) - - return true - } - - - - // MARK: - Google Login - func application(_ app: UIApplication, - open url: URL, - options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { - return GIDSignIn.sharedInstance.handle(url) - } -} diff --git a/CodeLounge/CodeLoungeApp.swift b/CodeLounge/CodeLoungeApp.swift deleted file mode 100644 index 9549df3..0000000 --- a/CodeLounge/CodeLoungeApp.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// CodeLoungeApp.swift -// CodeLounge -// -// Created by 김동현 on 1/14/25. -// - -import SwiftUI -import ScaleKit - -@main -struct CodeLoungeApp: App { - @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate - @StateObject var container: DIContainer = DIContainer.init(services: Services()) - @StateObject private var postViewModel = PostViewModel() - - // MARK: - 최신 버전 확인 - @State private var showUpdateAlert = false - @State private var latestVersion: String? - - @Environment(\.scenePhase) private var scenePhase - @State private var didSetScreenSize = false - - - // MARK: - navigationTitle 색상 흰색으로 지정 - init() { - // Large Navigation Title - UINavigationBar.appearance().largeTitleTextAttributes = [.foregroundColor: UIColor.white] - // Inline Navigation Title - UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: UIColor.white] - } - - var body: some Scene { - WindowGroup { - AuthenticationView(authViewModel: AuthenticationViewModel(container: container)) - .environmentObject(postViewModel) - .onAppear { - postViewModel.fetchAllPosts() - checkForAppUpdates() - } - .alert(isPresented: $showUpdateAlert) { - Alert( - title: Text("앱 업데이트 필요"), - message: Text("새로운 기능과 성능 개선을 위해 최신 버전 (\(latestVersion ?? ""))을 사용해 보세요!"), - dismissButton: .default(Text("업데이트")) { - if let url = URL(string: "https://apps.apple.com/app/id6741165577") { - UIApplication.shared.open(url) - } - } - ) - } - } - .onChange(of: scenePhase) { _, newPhase in - if newPhase == .active && !didSetScreenSize { - if let scene = UIApplication.shared.connectedScenes - .first(where: { $0 is UIWindowScene }) as? UIWindowScene { - // print("✅ scenePhase .active - 화면 크기 적용됨: \(scene.screen.bounds)") - DynamicSize.setScreenSize(scene.screen.bounds) - } else { - // print("⚠️ fallback: UIScreen.main.bounds") - DynamicSize.setScreenSize(UIScreen.main.bounds) - } - didSetScreenSize = true - } - - // 앱 포그라운드 전환시 최신 버전 확인 - checkForAppUpdates() - } - } - - // 최신 버전 확인 로직 - func checkForAppUpdates() { - let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" - - fetchLatestVersionFromAppStore { latest in - if let latest = latest, isUpdateRequired(currentVersion: currentVersion, latestVersion: latest) { - self.latestVersion = latest - self.showUpdateAlert = true - } - } - } - - // iTunes API에서 최신 버전 가져오기 - func fetchLatestVersionFromAppStore(completion: @escaping (String?) -> Void) { - guard let url = URL(string: "https://itunes.apple.com/lookup?bundleId=com.indextrown.CodeLounge") else { return } - URLSession.shared.dataTask(with: url) { data, _, _ in - guard let data = data else { - completion(nil) - return - } - - do { - if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let results = json["results"] as? [[String: Any]], - let latestVersion = results.first?["version"] as? String { - completion(latestVersion) - } else { - completion(nil) - } - } catch { - completion(nil) - } - }.resume() - } - - // 버전 비교 로직 - func isUpdateRequired(currentVersion: String, latestVersion: String) -> Bool { - return currentVersion.compare(latestVersion, options: .numeric) == .orderedAscending - } -} diff --git a/CodeLounge/ContentView.swift b/CodeLounge/ContentView.swift deleted file mode 100644 index 72912fb..0000000 --- a/CodeLounge/ContentView.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// ContentView.swift -// CodeLounge -// -// Created by 김동현 on 1/14/25. -// - -import SwiftUI - -struct ContentView: View { - var body: some View { - IntroView() - } -} - -#Preview { - ContentView() -} diff --git a/CodeLounge/DesignSystem/Button/SocialButtonStyle.swift b/CodeLounge/DesignSystem/Button/SocialButtonStyle.swift new file mode 100644 index 0000000..028b06a --- /dev/null +++ b/CodeLounge/DesignSystem/Button/SocialButtonStyle.swift @@ -0,0 +1,111 @@ +// +// SocialButtonStyle.swift +// CodeLounge +// +// Created by 김동현 on 4/3/26. +// + +import SwiftUI + +struct SocialButtonView: View { + + enum SocialType { + case kakao + case google + case apple + + var title: String { + switch self { + case .kakao: return "카카오로 계속하기" + case .google: return "Google로 계속하기" + case .apple: return "Apple로 계속하기" + } + } + + var imageName: String { + switch self { + case .kakao: return "Logo Kakao" + case .google: return "Logo Google" + case .apple: return "Logo Apple" + } + } + + var foregroundColor: Color { + switch self { + case .kakao: return Color.black.opacity(0.85) + case .google: return .black + case .apple: return .white + } + } + + var backgroundColor: Color { + switch self { + case .kakao: return Color("#FEE500") + case .google: return .white + case .apple: return .black + } + } + + var borderColor: Color { + switch self { + case .kakao: return .clear + case .google: return .black + case .apple: return .white + } + } + + var borderWidth: CGFloat { + switch self { + case .kakao: return 0 + case .google: return 1 + case .apple: return 0.8 + } + } + + var iconColor: Color { + switch self { + case .kakao: return .black + case .google: return .clear + case .apple: return .white + } + } + } + + let type: SocialType + let action: () -> Void + + var body: some View { + Button(action: action) { + + ZStack { + // MARK: - 가운데 텍스트 + Text(type.title) + .font(.system(size: 16, weight: .semibold)) + .frame(maxWidth: .infinity) + + // MARK: - 왼쪽 아이콘 + HStack { + Image(type.imageName) + .resizable() + .renderingMode(type == .google ? .original : .template) + .foregroundColor(type.iconColor) + .frame(width: 30, height: 30) + + Spacer() + } + } + .padding(.horizontal, 45) + .frame(maxWidth: .infinity) + .frame(height: 60) + } + .frame(maxWidth: .infinity, maxHeight: 60.scaled) + .foregroundStyle(type.foregroundColor) + .background(type.backgroundColor) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(type.borderColor, lineWidth: type.borderWidth) + } + .shadow(color: .black.opacity(0.1), radius: 5, x: 0, y: 2) + } +} diff --git a/CodeLounge/General/View/CustomTextField.swift b/CodeLounge/DesignSystem/TextField/CustomTextField.swift similarity index 98% rename from CodeLounge/General/View/CustomTextField.swift rename to CodeLounge/DesignSystem/TextField/CustomTextField.swift index 77eb5d7..aa488f5 100644 --- a/CodeLounge/General/View/CustomTextField.swift +++ b/CodeLounge/DesignSystem/TextField/CustomTextField.swift @@ -2,7 +2,7 @@ // CustomTextField.swift // CodeLounge // -// Created by 김동현 on 1/29/25. +// Created by 김동현 on 4/3/26. // import SwiftUI diff --git a/CodeLounge/Extension/Navigation+Extension.swift b/CodeLounge/Extension/Navigation+Extension.swift deleted file mode 100644 index eeed7d1..0000000 --- a/CodeLounge/Extension/Navigation+Extension.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// Navigation+Extension.swift -// CodeLounge -// -// Created by 김동현 on 1/20/25. -// - -import SwiftUI - -extension UINavigationController: @retroactive UIGestureRecognizerDelegate { - override open func viewDidLoad() { - super.viewDidLoad() - interactivePopGestureRecognizer?.delegate = self - } - - public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - return viewControllers.count > 1 - } - - public func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - return true - } -} diff --git a/CodeLounge/Extension/String+Extension.swift b/CodeLounge/Extension/String+Extension.swift deleted file mode 100644 index dd0f312..0000000 --- a/CodeLounge/Extension/String+Extension.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// String+Extension.swift -// CodeLounge -// -// Created by 김동현 on 1/20/25. -// - -import Foundation - -extension String { - // 문자열의 첫 글자만 대문자로 변환 - func capitalizeFirstLetter() -> String { - guard let first = self.first else { return self } - return first.uppercased() + self.dropFirst() - } -} diff --git a/CodeLounge/Extension/Color+Extension.swift b/CodeLounge/Extensions+/Color+Extension.swift similarity index 100% rename from CodeLounge/Extension/Color+Extension.swift rename to CodeLounge/Extensions+/Color+Extension.swift diff --git a/CodeLounge/Extensions+/UINavigationController+Gesture.swift b/CodeLounge/Extensions+/UINavigationController+Gesture.swift new file mode 100644 index 0000000..0a6c6f5 --- /dev/null +++ b/CodeLounge/Extensions+/UINavigationController+Gesture.swift @@ -0,0 +1,26 @@ +// +// UINavigationController+Gesture.swift +// CodeLounge +// +// Created by Codex on 4/4/26. +// + +import UIKit + +extension UINavigationController: @retroactive UIGestureRecognizerDelegate { + open override func viewDidLoad() { + super.viewDidLoad() + interactivePopGestureRecognizer?.delegate = self + } + + public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + viewControllers.count > 1 + } + + public func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } +} diff --git a/CodeLounge/Extensions+/View+.swift b/CodeLounge/Extensions+/View+.swift new file mode 100644 index 0000000..1aaa1b9 --- /dev/null +++ b/CodeLounge/Extensions+/View+.swift @@ -0,0 +1,101 @@ +// +// View+.swift +// CodeLounge +// +// Created by 김동현 on 4/3/26. +// + +import SwiftUI + +extension View { + func versionUpdateAlert() -> some View { + self.modifier(VersionUpdateModifier()) + } +} + +struct VersionUpdateModifier: ViewModifier { + @Environment(\.scenePhase) private var scenePhase + @State private var showAlert = false + @State private var latestVersion: String? + + func body(content: Content) -> some View { + content + .onChange(of: scenePhase) { _, newPhase in + guard newPhase == .active else { return } + checkForAppUpdates() + } + .alert(isPresented: $showAlert) { + Alert( + title: Text("앱 업데이트 필요"), + message: Text("새로운 기능과 성능 개선을 위해 최신 버전 (\(latestVersion ?? ""))을 사용해 보세요!"), + dismissButton: .default(Text("업데이트")) { + if let url = URL(string: "https://apps.apple.com/app/id6741165577") { + UIApplication.shared.open(url) + } + } + ) + } + } + + // 최신 버전 확인 로직 + func checkForAppUpdates() { + let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + + fetchLatestVersionFromAppStore { latest in + guard let latest = latest else { return } + print("앱버전: \(currentVersion)\n스토어버전: \(latest)") + + if isUpdateRequired( + currentVersion: currentVersion, + lastestVersion: latest + ) { + DispatchQueue.main.async { + self.latestVersion = latest + self.showAlert = true + + } + } + } + } +} + +private extension VersionUpdateModifier { + // 버전 비교 로직 + func isUpdateRequired( + currentVersion: String, + lastestVersion: String + ) -> Bool { + return currentVersion.compare( + lastestVersion, + options: .numeric + ) == .orderedAscending + } + + // iTunes API에서 최신 버전 가져오기 + func fetchLatestVersionFromAppStore( + completion: @escaping (String?) -> Void + ) { + guard let url = URL(string: Constant.URL.appStore) else { + return + } + + URLSession.shared.dataTask(with: url) { data, _, _ in + guard let data = data else { + completion(nil) + return + } + + do { + if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let results = json["results"] as? [[String: Any]], + let latestVersion = results.first?["version"] as? String { + completion(latestVersion) + } else { + completion(nil) + } + } catch { + completion(nil) + } + }.resume() + } +} diff --git a/CodeLounge/General/Constant.swift b/CodeLounge/General/Constant.swift index 54c2453..c4066f4 100644 --- a/CodeLounge/General/Constant.swift +++ b/CodeLounge/General/Constant.swift @@ -2,17 +2,25 @@ // Constant.swift // CodeLounge // -// Created by 김동현 on 1/16/25. +// Created by 김동현 on 4/3/26. // import Foundation enum Constant {} - extension Constant { - struct DBKey { - static let Users = "Users" - } + enum AdUnitID { + static let postDetailBanner = "ca-app-pub-6798240605221343/7424023393" + } + + enum DBKey { + static let Users = "Users" + } + + enum URL { + static let appStore = "https://itunes.apple.com/lookup?bundleId=com.indextrown.CodeLounge" + } } +typealias AdUnitID = Constant.AdUnitID typealias DBKey = Constant.DBKey diff --git a/CodeLounge/General/DIContainer.swift b/CodeLounge/General/DIContainer.swift deleted file mode 100644 index 382896d..0000000 --- a/CodeLounge/General/DIContainer.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// DIContainer.swift -// CodeLounge -// -// Created by 김동현 on 1/14/25. -// - -import Foundation - -final class DIContainer: ObservableObject { - var services: ServiceType - - init(services: ServiceType) { - self.services = services - } -} - diff --git a/CodeLounge/Model/Post.swift b/CodeLounge/Model/Post.swift index 7692ba1..c56cf58 100644 --- a/CodeLounge/Model/Post.swift +++ b/CodeLounge/Model/Post.swift @@ -2,7 +2,7 @@ // Post.swift // CodeLounge // -// Created by 김동현 on 1/20/25. +// Created by 김동현 on 4/3/26. // import Foundation diff --git a/CodeLounge/Model/User.swift b/CodeLounge/Model/User.swift index 9c6a1db..3595027 100644 --- a/CodeLounge/Model/User.swift +++ b/CodeLounge/Model/User.swift @@ -2,92 +2,43 @@ // User.swift // CodeLounge // -// Created by 김동현 on 1/16/25. +// Created by 김동현 on 4/3/26. // import Foundation struct User { - var id: String - var nickname: String - var registerDate: Date? - var birthdayDate: Date? - var gender: Gender? - var loginPlatform: LoginPlatform? + var id: String + var nickname: String + var registerDate: Date? + var birthdayDate: Date? + var gender: Gender? + var loginPlatform: LoginPlatform? } enum Gender: String { - case male = "남자" - case female = "여자" - case other = "비공개" + case male = "남자" + case female = "여자" + case other = "비공개" } enum LoginPlatform: String { - case google = "Google" - case apple = "Apple" + case google = "Google" + case apple = "Apple" } extension User { - func toObject() -> UserObject { - let formatter = ISO8601DateFormatter() // 날짜를 ISO8601 문자열로 변환 - formatter.timeZone = TimeZone(identifier: "Asia/Seoul") // KST (UTC+9) - - return UserObject( - id: id, - nickname: nickname, - registerDate: formatter.string(from: registerDate ?? Date()), - birthdayDate: formatter.string(from: registerDate ?? Date()),//formatter.string(from: birthdayDate!), - gender: (gender ?? .male).rawValue, - loginPlatform: loginPlatform!.rawValue - ) - } + func toDTO() -> UserDTO { + let formatter = ISO8601DateFormatter() + formatter.timeZone = TimeZone(identifier: "Asia/Seoul") + + return UserDTO( + id: id, + nickname: nickname, + registerDate: formatter.string(from: registerDate ?? Date()), + birthdayDate: formatter.string(from: birthdayDate ?? Date()), + gender: (gender ?? .male).rawValue, + loginPlatform: (loginPlatform ?? .google).rawValue + ) + } } - -extension User { - static var stub1: User { - .init( - id: "12345", - nickname: "인덱스", - registerDate: Date(), - birthdayDate: Calendar.current.date(byAdding: .year, value: -25, to: Date()), - gender: .male, - loginPlatform: .google - ) - } - - static var stub2: User { - .init( - id: "67890", - nickname: "관리자", - registerDate: Date(), - birthdayDate: Calendar.current.date(byAdding: .year, value: -20, to: Date()), - gender: .female, - loginPlatform: .apple - ) - } -} - - - - - -/* - - User( - id: "12345", - nickname: "인덱스", - registerDate: Optional(2025-01-16 09:21:32 +0000), - birthdayDate: Optional(2000-01-16 09:21:32 +0000), - gender: Optional(CodeLounge.Gender.male), - loginPlatform: Optional(CodeLounge.LoginPlatform.google) - ) - - UserObject( - id: "12345", - nickname: "인덱스", - registerDate: "2025-01-16T18:21:32+09:00", - birthdayDate: "2000-01-16T18:21:32+09:00", - gender: "남자", - loginPlatform: "Google" - ) - */ diff --git a/CodeLounge/Repository/DBError.swift b/CodeLounge/Repository/DBError.swift new file mode 100644 index 0000000..7e6a898 --- /dev/null +++ b/CodeLounge/Repository/DBError.swift @@ -0,0 +1,20 @@ +// +// DBError.swift +// CodeLounge +// +// Created by 김동현 on 4/3/26. +// + +import Foundation + +enum DBError: Error { + case addUserError(Error) + case updateUserError(Error) + case getUserError(Error) + case loadUsersError(Error) + case loadPostsError(Error) + case emptyValue + case invalidatedType + + case error(Error) +} diff --git a/CodeLounge/Repository/DTO/DBError.swift b/CodeLounge/Repository/DTO/DBError.swift deleted file mode 100644 index f18b87c..0000000 --- a/CodeLounge/Repository/DTO/DBError.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// DBError.swift -// CodeLounge -// -// Created by 김동현 on 1/16/25. -// - -import Foundation - -enum DBError: Error { - // MARK: - UserDBRepository - case addUserError(Error) - case getUserError(Error) - case loadUsersError(Error) - case updateUserError(Error) - case emptyValue - case invalidatedType - case userNotFound - - // MARK: - OtherDBRepository - // ... - case error(Error) - - // MARK: - 에러 상세 설명 - var errorDescription: String { - switch self { - case .addUserError(let error): - return "❌ 에러 [addUserError]: \(error.localizedDescription)" - case .getUserError(let error): - return "❌ 에러 [getUserError]: \(error.localizedDescription)" - case .loadUsersError(let error): - return "❌ 에러 [loadUsersError] \(error.localizedDescription)" - case .updateUserError(let error): - return "❌ 에러 [updateUserError]: \(error.localizedDescription)" - case .emptyValue: - return "❌ 에러 [emptyValue]: 값이 없습니다" - case .invalidatedType: - return "❌ 에러 [invalidatedType]: 유효하지 않은 타입입니다" - case .userNotFound: - return "❌ 에러 [userNotFound]: 유저가 존재하지 않습니다" - case .error: - return "❌ 에러 [error]: error" - - } - } -} diff --git a/CodeLounge/Repository/DTO/PostDTO.swift b/CodeLounge/Repository/DTO/PostDTO.swift new file mode 100644 index 0000000..5820488 --- /dev/null +++ b/CodeLounge/Repository/DTO/PostDTO.swift @@ -0,0 +1,28 @@ +// +// PostDTO.swift +// CodeLounge +// +// Created by 김동현 on 4/3/26. +// + +import Foundation + +struct PostDTO { + let id: String + let title: String + let content: String + let authorID: String + let createdAt: String +} + +extension PostDTO { + func toDomain() -> Post { + return Post( + id: id, + title: title, + content: content, + authorID: authorID, + createdAt: ISO8601DateFormatter().date(from: createdAt) ?? Date() + ) + } +} diff --git a/CodeLounge/Repository/DTO/UserObject.swift b/CodeLounge/Repository/DTO/UserDTO.swift similarity index 70% rename from CodeLounge/Repository/DTO/UserObject.swift rename to CodeLounge/Repository/DTO/UserDTO.swift index 6774204..ae0f86f 100644 --- a/CodeLounge/Repository/DTO/UserObject.swift +++ b/CodeLounge/Repository/DTO/UserDTO.swift @@ -1,22 +1,22 @@ // -// UserObject.swift +// UserDTO.swift // CodeLounge // -// Created by 김동현 on 1/16/25. +// Created by 김동현 on 4/3/26. // import Foundation -struct UserObject: Codable { - var id: String - var nickname: String - var registerDate: String - var birthdayDate: String - var gender: String - var loginPlatform: String +struct UserDTO: Codable { + var id: String + var nickname: String + var registerDate: String + var birthdayDate: String + var gender: String + var loginPlatform: String } -extension UserObject { +extension UserDTO { func toModel() -> User { let formatter = ISO8601DateFormatter() // 날짜를 ISO8601 문자열로 변환 formatter.timeZone = TimeZone(identifier: "Asia/Seoul") // KST (UTC+9) diff --git a/CodeLounge/Repository/PostRepository.swift b/CodeLounge/Repository/PostRepository.swift new file mode 100644 index 0000000..ccfebc6 --- /dev/null +++ b/CodeLounge/Repository/PostRepository.swift @@ -0,0 +1,73 @@ +// +// PostRepository.swift +// CodeLounge +// +// Created by 김동현 on 4/3/26. +// + +import Foundation +import Combine +import FirebaseDatabase + +protocol PostRepositoryProtocol { + func fetchAllPosts() -> AnyPublisher<[String: [PostDTO]], DBError> +} + +final class PostRepository: PostRepositoryProtocol { + private let db: DatabaseReference = Database.database().reference() + + func fetchAllPosts() -> AnyPublisher<[String : [PostDTO]], DBError> { + Future { [weak self] promise in + self?.db + .child("Posts") + .getData { error, snapshot in + if let error { + promise(.failure(.loadUsersError(error))) + } else { + promise(.success(snapshot?.value)) + } + } + }.flatMap { value -> AnyPublisher<[String: [PostDTO]], DBError> in + guard let dic = value as? [String: [String: Any]] else { + return Just([:]) + .setFailureType(to: DBError.self) + .eraseToAnyPublisher() + } + + var result: [String: [PostDTO]] = [:] + + for (category, posts) in dic { + var dtoArray: [PostDTO] = [] + + for (postId, postData) in posts { + guard let postDict = postData as? [String: Any], + let title = postDict["title"] as? String, + let content = postDict["content"] as? String, + let authorID = postDict["author_id"] as? String, + let createdAt = postDict["created_at"] as? String + else { continue } + + dtoArray.append( + PostDTO( + id: postId, + title: title, + content: content, + authorID: authorID, + createdAt: createdAt + ) + ) + } + + result[category] = dtoArray + } + + return Just(result) + .setFailureType(to: DBError.self) + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + + + } + +} diff --git a/CodeLounge/Repository/UserDBRepository.swift b/CodeLounge/Repository/UserDBRepository.swift index 44fdbf3..696f73a 100644 --- a/CodeLounge/Repository/UserDBRepository.swift +++ b/CodeLounge/Repository/UserDBRepository.swift @@ -2,165 +2,184 @@ // UserDBRepository.swift // CodeLounge // -// Created by 김동현 on 1/16/25. +// Created by 김동현 on 4/3/26. // import Foundation import Combine import FirebaseDatabase -protocol UserDBRepositoryType { - func addUser(_ object: UserObject) -> AnyPublisher - func getUser(userId: String) -> AnyPublisher - func loadUsers() -> AnyPublisher<[UserObject], DBError> - func updateUser(_ object: UserObject) -> AnyPublisher - func deleteUser(userId: String) -> AnyPublisher +protocol UserDBRepositoryProtocol { + func addUser(_ dto: UserDTO) -> AnyPublisher + func updateUser(_ dto: UserDTO) -> AnyPublisher + func deleteUser(userId: String) -> AnyPublisher + func getUser(userId: String) -> AnyPublisher + func checkNicknameDuplicate(_ nickname: String) -> AnyPublisher + + func loadUsers() -> AnyPublisher<[UserDTO], DBError> } -final class UserDBRepository: UserDBRepositoryType { - - var db: DatabaseReference = Database.database().reference() - - // MARK: - 사용자 추가 - func addUser(_ object: UserObject) -> AnyPublisher { - Just(object) - .compactMap { try? JSONEncoder().encode($0) } // object > data - .compactMap { try? JSONSerialization.jsonObject(with: $0, options: .fragmentsAllowed) } // data > dict - .flatMap { value in - Future { [weak self] promise in - self?.db.child(DBKey.Users).child(object.id).setValue(value) { error, _ in - if let error { - promise(.failure(error)) - } else { - promise(.success(())) - } - } - } +final class UserDBRepository: UserDBRepositoryProtocol { + + private let db: DatabaseReference = Database.database().reference() + + func addUser(_ dto: UserDTO) -> AnyPublisher { + return Just(dto) + /// dto > data + .compactMap { try? JSONEncoder().encode($0) } + /// data > dict + .compactMap { try? JSONSerialization.jsonObject(with: $0, options: .fragmentsAllowed) } + .flatMap { value in + Future { [weak self] promise in + self?.db + .child(DBKey.Users) + .child(dto.id) + .setValue(value) { error, _ in + if let error { + promise(.failure(error)) + } else { + promise(.success(())) + } } - .mapError { DBError.addUserError($0) } - .eraseToAnyPublisher() - } - - // MARK: - 앱 사용자 불러오기 - func getUser(userId: String) -> AnyPublisher { - Future { [weak self] promise in - self?.db.child(DBKey.Users).child(userId).getData {error, snapshot in - if let error { - promise(.failure(DBError.getUserError(error))) - // DB에 해당 유저정보가 없는걸 체크할때 없으면 nil이 아닌 NSNULL을 갖고있기 떄문에 NSNULL일경우 nil을 아웃풋으로 넘겨줌 - } else if snapshot?.value is NSNull { - promise(.success(nil)) - } else { - promise(.success(snapshot?.value)) - } - } - }.flatMap { value in - if let value { - return Just(value) - .tryMap { try JSONSerialization.data(withJSONObject: $0)} - .decode(type: UserObject.self, decoder: JSONDecoder()) - .mapError { DBError.getUserError($0) } - .eraseToAnyPublisher() - // 값이 없다면 - } else { - return Fail(error: .emptyValue).eraseToAnyPublisher() - } - }.eraseToAnyPublisher() - } - - // MARK: - 앱 사용자 전체 불러오기(본인 제외) - func loadUsers() -> AnyPublisher<[UserObject], DBError> { - print("사용자 목록 불러오기 요청") // 디버깅 출력 추가 - return Future { [weak self] promise in - self?.db.child(DBKey.Users).getData { error, snapshot in - if let error = error { - print("데이터베이스 오류 발생: \(error.localizedDescription)") // 오류 메시지 출력 - promise(.failure(DBError.loadUsersError(error))) - } else if snapshot?.value is NSNull { - print("데이터베이스에 해당 유저 정보가 없습니다.") // 유저 정보 없음 출력 - promise(.success(nil)) - } else { - print("데이터베이스에서 사용자 정보를 성공적으로 불러왔습니다.") // 성공 메시지 출력 - promise(.success(snapshot?.value)) - } + } + } + .mapError { DBError.addUserError($0) } + .eraseToAnyPublisher() + } + + func updateUser(_ dto: UserDTO) -> AnyPublisher { + return Just(dto) + .compactMap { try? JSONEncoder().encode($0) } + .flatMap { value in + Future { [weak self] promise in + // 업데이트할 필드들을 딕셔너리로 설정 + let updates: [String: Any?] = [ + "nickname": dto.nickname, + "birthdayDate": dto.birthdayDate, + "gender": dto.gender, + ].compactMapValues { $0 } // nil 값은 제외 + + self?.db + .child(DBKey.Users) + .child(dto.id) + .updateChildValues(updates as [AnyHashable : Any]) { error, _ in + if let error = error { + promise(.failure(DBError.updateUserError(error))) // DBError로 변환 + } else { + promise(.success(())) + } } } - // 딕셔너리형태(userID: UserObject) -> 배열형태 - .flatMap { value in - if let dic = value as? [String: [String: Any]] { - //print("불러온 사용자 데이터 딕셔너리: \(dic)") // 불러온 데이터 출력 - return Just(dic) - .tryMap { try JSONSerialization.data(withJSONObject: $0) } - .decode(type: [String: UserObject].self, decoder: JSONDecoder()) // 형식 - .map { $0.values.map { $0 as UserObject } } - .mapError { error in - print("JSON 디코딩 오류: \(error.localizedDescription)") - if let decodingError = error as? DecodingError { - switch decodingError { - case .keyNotFound(let key, let context): - print("키 누락: \(key.stringValue), \(context.debugDescription)") - case .typeMismatch(let type, let context): - print("타입 불일치: \(type), \(context.debugDescription)") - case .valueNotFound(let value, let context): - print("값 누락: \(value), \(context.debugDescription)") - case .dataCorrupted(let context): - print("데이터 손상: \(context.debugDescription)") - default: - print("디코딩 실패: \(error.localizedDescription)") - } - } - return DBError.loadUsersError(error) - } + } + .eraseToAnyPublisher() + } + + func deleteUser(userId: String) -> AnyPublisher { + return Future { promise in + self.db + .child(DBKey.Users) + .child(userId) + .removeValue() { error, _ in + if let error = error { + promise(.failure(DBError.error(error))) + } else { + promise(.success(())) + } + } + } + .eraseToAnyPublisher() + } - .eraseToAnyPublisher() - } else if value == nil { - // print("불러온 데이터가 nil입니다.") // nil 데이터 출력 - return Just([]).setFailureType(to: DBError.self).eraseToAnyPublisher() - } else { - // print("유효하지 않은 데이터 타입입니다.") // 유효하지 않은 타입 출력 - return Fail(error: .invalidatedType).eraseToAnyPublisher() - } + func getUser(userId: String) -> AnyPublisher { + return Future { [weak self] promise in + self?.db + .child(DBKey.Users) + .child(userId) + .getData { error, snapshot in + if let error { + promise(.failure(.getUserError(error))) + } else if snapshot?.value is NSNull { + promise(.success(nil)) + } else { + promise(.success(snapshot?.value)) + } } - .eraseToAnyPublisher() } - - // MARK: - 사용자 정보 추가/수정 - func updateUser(_ object: UserObject) -> AnyPublisher { - Just(object) - .compactMap { try? JSONEncoder().encode($0) } - .flatMap { value in - Future { [weak self] promise in - // 업데이트할 필드들을 딕셔너리로 설정 - let updates: [String: Any?] = [ - "nickname": object.nickname, - "birthdayDate": object.birthdayDate, - "gender": object.gender, - ].compactMapValues { $0 } // nil 값은 제외 + .flatMap { value in + if let value { + return Just(value) + /// dic -> data + .tryMap { try JSONSerialization.data(withJSONObject: $0)} + /// data -> dto + .decode(type: UserDTO.self, decoder: JSONDecoder()) + .mapError { DBError.getUserError($0) } + .eraseToAnyPublisher() + // 값이 없다면 + } else { + return Fail(error: .emptyValue).eraseToAnyPublisher() + } + } + .eraseToAnyPublisher() + } - self?.db.child(DBKey.Users).child(object.id).updateChildValues(updates as [AnyHashable : Any]) { error, _ in - if let error = error { - promise(.failure(DBError.updateUserError(error))) // DBError로 변환 - } else { - promise(.success(())) - } - } - } - } - .eraseToAnyPublisher() + func checkNicknameDuplicate(_ nickname: String) -> AnyPublisher { + Future { [weak self] promise in + self?.db + .child(DBKey.Users) + .getData { error, snapshot in + if let error { + promise(.failure(.loadUsersError(error))) + return + } + + guard let users = snapshot?.value as? [String: [String: Any]] else { + promise(.success(false)) + return + } + + let hasDuplicate = users.values.contains { user in + (user["nickname"] as? String) == nickname + } + + promise(.success(hasDuplicate)) + } } - - // MARK: - 회원 탈퇴 - func deleteUser(userId: String) -> AnyPublisher { - Future { promise in - self.db.child(DBKey.Users).child(userId).removeValue { error, _ in - if let error = error { - promise(.failure(.error(error))) - } else { - promise(.success(())) - } + .eraseToAnyPublisher() + } + + func loadUsers() -> AnyPublisher<[UserDTO], DBError> { + return Future { [weak self] promise in + self?.db.child(DBKey.Users).getData { error, snapshot in + if let error = error { + print("데이터베이스 오류 발생: \(error.localizedDescription)") // 오류 메시지 출력 + promise(.failure(DBError.loadUsersError(error))) + } else if snapshot?.value is NSNull { + print("데이터베이스에 해당 유저 정보가 없습니다.") // 유저 정보 없음 출력 + promise(.success(nil)) + } else { + print("데이터베이스에서 사용자 정보를 성공적으로 불러왔습니다.") // 성공 메시지 출력 + promise(.success(snapshot?.value)) } } - .eraseToAnyPublisher() } + .flatMap { value in + if let dic = value as? [String: [String: Any]] { + return Just(dic) + .tryMap { try JSONSerialization.data(withJSONObject: $0) } + .decode(type: [String: UserDTO].self, decoder: JSONDecoder()) // 형식 + .map { $0.values.map { $0 as UserDTO } } + .mapError { DBError.loadUsersError($0) } + .eraseToAnyPublisher() + } else if value == nil { + // print("불러온 데이터가 nil입니다.") // nil 데이터 출력 + return Just([]).setFailureType(to: DBError.self) + .eraseToAnyPublisher() + } else { + // print("유효하지 않은 데이터 타입입니다.") // 유효하지 않은 타입 출력 + return Fail(error: .invalidatedType) + .eraseToAnyPublisher() + } + } + .eraseToAnyPublisher() + } } - diff --git a/CodeLounge/Resources/Assets.xcassets/Login/CodeLounge.imageset/Contents.json b/CodeLounge/Resources/Assets.xcassets/Login/CodeLounge.imageset/Contents.json index b328f7c..eaa2123 100644 --- a/CodeLounge/Resources/Assets.xcassets/Login/CodeLounge.imageset/Contents.json +++ b/CodeLounge/Resources/Assets.xcassets/Login/CodeLounge.imageset/Contents.json @@ -1,11 +1,11 @@ { "images" : [ { - "filename" : "KakaoTalk_Image_2025-01-07-17-34-35_001.png", "idiom" : "universal", "scale" : "1x" }, { + "filename" : "KakaoTalk_Image_2025-01-07-17-34-35_001.png", "idiom" : "universal", "scale" : "2x" }, diff --git a/CodeLounge/Resources/Assets.xcassets/Logo/Contents.json b/CodeLounge/Resources/Assets.xcassets/Logo/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/CodeLounge/Resources/Assets.xcassets/Logo/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CodeLounge/Resources/Assets.xcassets/Logo/Logo Apple.imageset/Contents.json b/CodeLounge/Resources/Assets.xcassets/Logo/Logo Apple.imageset/Contents.json new file mode 100644 index 0000000..7cdf2a0 --- /dev/null +++ b/CodeLounge/Resources/Assets.xcassets/Logo/Logo Apple.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Shape.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CodeLounge/Resources/Assets.xcassets/Logo/Logo Apple.imageset/Shape.png b/CodeLounge/Resources/Assets.xcassets/Logo/Logo Apple.imageset/Shape.png new file mode 100644 index 0000000..b76ac2f Binary files /dev/null and b/CodeLounge/Resources/Assets.xcassets/Logo/Logo Apple.imageset/Shape.png differ diff --git a/CodeLounge/Resources/Assets.xcassets/Google.imageset/Contents.json b/CodeLounge/Resources/Assets.xcassets/Logo/Logo Google.imageset/Contents.json similarity index 100% rename from CodeLounge/Resources/Assets.xcassets/Google.imageset/Contents.json rename to CodeLounge/Resources/Assets.xcassets/Logo/Logo Google.imageset/Contents.json diff --git a/CodeLounge/Resources/Assets.xcassets/Google.imageset/Google.png b/CodeLounge/Resources/Assets.xcassets/Logo/Logo Google.imageset/Google.png similarity index 100% rename from CodeLounge/Resources/Assets.xcassets/Google.imageset/Google.png rename to CodeLounge/Resources/Assets.xcassets/Logo/Logo Google.imageset/Google.png diff --git a/CodeLounge/Resources/Assets.xcassets/Logo/Logo Kakao.imageset/Contents.json b/CodeLounge/Resources/Assets.xcassets/Logo/Logo Kakao.imageset/Contents.json new file mode 100644 index 0000000..a9bea26 --- /dev/null +++ b/CodeLounge/Resources/Assets.xcassets/Logo/Logo Kakao.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Logo kakao.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CodeLounge/Resources/Assets.xcassets/Logo/Logo Kakao.imageset/Logo kakao.png b/CodeLounge/Resources/Assets.xcassets/Logo/Logo Kakao.imageset/Logo kakao.png new file mode 100644 index 0000000..ed8a70c Binary files /dev/null and b/CodeLounge/Resources/Assets.xcassets/Logo/Logo Kakao.imageset/Logo kakao.png differ diff --git a/CodeLounge/Service/AuthService.swift b/CodeLounge/Service/AuthService.swift new file mode 100644 index 0000000..cbaa353 --- /dev/null +++ b/CodeLounge/Service/AuthService.swift @@ -0,0 +1,243 @@ +// +// AuthService.swift +// CodeLounge +// +// Created by 김동현 on 4/3/26. +// + +import Combine +import Foundation +import AuthenticationServices +import FirebaseAuth +import GoogleSignIn +import FirebaseCore +import CryptoKit + +enum ServiceError: Error { + case firebaseInvalidated + + case googleClientIDError + case googleTokenError + + case appleTokenError + + case dbError(DBError) + case error(Error) +} + +protocol AuthServiceProtocol { + func checkAuthenticationState() -> String? + func signInWithGoogle() -> AnyPublisher + func handleSignInWithAppleRequest(_ request: ASAuthorizationAppleIDRequest) -> String + func handleSignInWithAppleCompletion(_ authorization: ASAuthorization, nonce: String) -> AnyPublisher + func logout() -> AnyPublisher +} + +final class AuthService: AuthServiceProtocol { + func checkAuthenticationState() -> String? { + if let user = Auth.auth().currentUser { + return user.uid + } else { + return nil + } + } + + func signInWithGoogle() -> AnyPublisher { + Future { [weak self] promise in + self?.signInWithGoogle() { result in + switch result { + case .success(let user): + promise(.success(user)) + case .failure(let error): + promise(.failure(ServiceError.error(error))) + } + } + }.eraseToAnyPublisher() + } + + func handleSignInWithAppleRequest(_ request: ASAuthorizationAppleIDRequest) -> String { + request.requestedScopes = [.fullName, .email] + + // nonce 는 랜덤스트림을 만드는 sha 암호화 방식 + let nonce = randomNonceString() + request.nonce = sha256(nonce) + return nonce + } + + func handleSignInWithAppleCompletion( + _ authorization: ASAuthorization, + nonce: String + ) -> AnyPublisher { + return Future { [weak self] promise in + self?.handleSignInWithAppleCompletion( + authorization, + nonce: nonce + ) { result in + switch result { + case let .success(user): + promise(.success(user)) + case let .failure(error): + promise(.failure(.error(error))) + } + } + }.eraseToAnyPublisher() + } + + func logout() -> AnyPublisher { + return Future { promise in + do { + try Auth.auth().signOut() + promise(.success(())) + } catch { + promise(.failure(ServiceError.error(error))) + } + } + .eraseToAnyPublisher() + } +} + +private extension AuthService { + // MARK: - 구글 비동기 로그인 + func signInWithGoogle( + completion: @escaping (Result) -> Void + ) { + // Firebase에서 제공하는 clientID를 가져온다. 실패시 clientIDError를 반환한다. + guard let clientID = FirebaseApp.app()?.options.clientID else { + completion(.failure(ServiceError.googleClientIDError)) // 실패시 + return + } + + // GIDConfiguration 객체를 생성하여 Google Sign-In 구성을 초기화한다 + let config = GIDConfiguration(clientID: clientID) + GIDSignIn.sharedInstance.configuration = config + + // Google Sign-in 창을 띄울 rootViewController을 가져온다 + // 현재 실행 중인 UIApplication에서 UIWindow를 탐색하여 추출한다 + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let rootViewController = window.rootViewController else { + return + } + + // 로그인 진행 + // 성공시 result 객체를 반환 + // result.user에서 idToken, accessToken을 가져온다 + GIDSignIn.sharedInstance.signIn(withPresenting: rootViewController) { [weak self] result, error in + if let error { + completion(.failure(error)) + return + } + + guard let user = result?.user, let idToken = user.idToken?.tokenString else { + // 유저정보, 토큰정보가 없다면 + completion(.failure(ServiceError.googleTokenError)) + return + } + + let accessToken = user.accessToken.tokenString + let credential = GoogleAuthProvider.credential(withIDToken: idToken, accessToken: accessToken) + + self?.authenticateUserWithFirebase( + credential: credential, + loginPlatform: .google, + completion: completion + ) + } + } + + // MARK: - 애플 비동기 로그인 + private func handleSignInWithAppleCompletion( + _ authorization: ASAuthorization, + nonce: String, + completion: @escaping (Result) -> Void + ) { + guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential, + let appleIdToken = appleIDCredential.identityToken else { + print("애플 로그인 실패: 유효하지 않은 자격 증명") + completion(.failure(ServiceError.appleTokenError)) + return + } + + guard let idTokenString = String(data: appleIdToken, encoding: .utf8) else { + print("애플 로그인 실패: ID 토큰 변환 실패") + completion(.failure(ServiceError.appleTokenError)) + return + } + + let credential = OAuthProvider.credential(providerID: AuthProviderID.apple, idToken: idTokenString, rawNonce: nonce) + + // Firebase인증에 필요한 AuthCredential를 생성하여 authenticateUserWithFirebase로 전달한다 + authenticateUserWithFirebase( + credential: credential, + loginPlatform: .apple , + completion: completion + ) + } + + // MARK: - 파이어베이스 인증 진행 함수 + private func authenticateUserWithFirebase( + credential: AuthCredential, loginPlatform: LoginPlatform, + completion: @escaping (Result) -> Void + ) { + // Firebase 서버에 인증 요청을 보낸다 + Auth.auth().signIn(with: credential) { result, error in + // 인증 실패시 에러를 completion으로 반환한다. + if let error { + completion(.failure(error)) + return + } + + // 인증 결과가 없는 경우 invalidated 에러를 반환한다 + guard let result else { + completion(.failure(ServiceError.firebaseInvalidated)) + return + } + + // 기존 사용자: 기존 uid를 반환 + // 새 사용자: 새로운 uid를 반환 + let firebaseUser = result.user + + // ISO8601DateFormatter를 사용해 한국 시간대에 맞게 날짜 생성 + let formatter = ISO8601DateFormatter() + formatter.timeZone = TimeZone(identifier: "Asia/Seoul") // KST설정 + let registerDate = formatter.date(from: formatter.string(from: Date())) + + // User객체 생성 + let user = User( + id: firebaseUser.uid, + nickname: "", + registerDate: registerDate, + loginPlatform: loginPlatform) + + completion(.success(user)) + } + } +} + +// MARK: - Nonce 관련 함수 +extension AuthService { + private func randomNonceString(length: Int = 32) -> String { + precondition(length > 0) + var randomBytes = [UInt8](repeating: 0, count: length) + let errorCode = SecRandomCopyBytes(kSecRandomDefault, randomBytes.count, &randomBytes) + if errorCode != errSecSuccess { + fatalError("Unable to generate nonce. SecRandomCopyBytes failed with OSStatus \(errorCode)") + } + + let charset: [Character] = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._") + let nonce = randomBytes.map { byte in + charset[Int(byte) % charset.count] + } + return String(nonce) + } + + private func sha256(_ input: String) -> String { + let inputData = Data(input.utf8) + let hashedData = SHA256.hash(data: inputData) + let hashString = hashedData.compactMap { + String(format: "%02x", $0) + }.joined() + + return hashString + } +} diff --git a/CodeLounge/Service/AuthenticationService.swift b/CodeLounge/Service/AuthenticationService.swift deleted file mode 100644 index 07f61a6..0000000 --- a/CodeLounge/Service/AuthenticationService.swift +++ /dev/null @@ -1,238 +0,0 @@ -// -// AuthenticationService.swift -// CodeLounge -// -// Created by 김동현 on 1/14/25. -// - -import Foundation -import Combine -import FirebaseAuth -import FirebaseCore -import GoogleSignIn -import AuthenticationServices -import CryptoKit - -// 에러타입 -enum AuthenticationError: Error { - case clientIDError // Firebase CliendID 가져오지 못했을 때 발생 - case tokenError // Google로그인 중 토큰을 가져오지 못했을 때 발생 - case invalidated // 인증이 무효되었을 때 발생 -} - -protocol AuthenticationServiceType { - func checkAuthenticationState() -> String? - func signInWithGoogle() -> AnyPublisher - func handleSignInWithAppleRequest(_ request: ASAuthorizationAppleIDRequest) -> String - func handleSignInWithAppleCompletion(_ authorization: ASAuthorization, nonce: String) -> AnyPublisher - - func logout() -> AnyPublisher -} - -final class AuthenticationService: AuthenticationServiceType { - - func checkAuthenticationState() -> String? { - if let user = Auth.auth().currentUser { - return user.uid - } else { - return nil - } - } - - func signInWithGoogle() -> AnyPublisher { - Future { [weak self] promise in - self?.signInWithGoogle() { result in - switch result { - case let .success(user): - promise(.success(user)) - case let.failure(error): - promise(.failure( ServiceError.error(error))) - } - } - } - .eraseToAnyPublisher() - } - - func handleSignInWithAppleRequest(_ request: ASAuthorizationAppleIDRequest) -> String { - request.requestedScopes = [.fullName, .email] - - // nonce 는 랜덤스트림을 만드는 sha 암호화 방식 - let nonce = randomNonceString() - request.nonce = sha256(nonce) - return nonce - } - - func handleSignInWithAppleCompletion(_ authorization: ASAuthorization, nonce: String) -> AnyPublisher { - Future { [weak self] promise in - self?.handleSignInWithAppleCompletion(authorization, nonce: nonce) { result in - switch result { - case let .success(user): - promise(.success(user)) - case let .failure(error): - promise(.failure(.error(error))) - } - } - }.eraseToAnyPublisher() - } - - func logout() -> AnyPublisher { - Future { promise in - do { - try Auth.auth().signOut() - promise(.success(())) - } catch { - promise(.failure(ServiceError.error(error))) - } - } - .eraseToAnyPublisher() - } -} - -final class StubAuthenticationService: AuthenticationServiceType { - - func checkAuthenticationState() -> String? { - return nil - } - func signInWithGoogle() -> AnyPublisher { - Empty().eraseToAnyPublisher() - } - func handleSignInWithAppleRequest(_ request: ASAuthorizationAppleIDRequest) -> String { - return "" - } - func handleSignInWithAppleCompletion(_ authorization: ASAuthorization, nonce: String) -> AnyPublisher { - Empty().eraseToAnyPublisher() - } - func logout() -> AnyPublisher { - Empty().eraseToAnyPublisher() - } -} - -extension AuthenticationService { - // MARK: - 구글 비동기 로그인 - private func signInWithGoogle(completion: @escaping (Result) -> Void) { - // Firebase에서 제공하는 clientID를 가져온다. 실패시 clientIDError를 반환한다. - guard let clientID = FirebaseApp.app()?.options.clientID else { - completion(.failure(AuthenticationError.clientIDError)) // 실패시 - return - } - - // GIDConfiguration 객체를 생성하여 Google Sign-In 구성을 초기화한다 - let config = GIDConfiguration(clientID: clientID) - GIDSignIn.sharedInstance.configuration = config - - // Google Sign-in 창을 띄울 rootViewController을 가져온다 - // 현재 실행 중인 UIApplication에서 UIWindow를 탐색하여 추출한다 - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let rootViewController = window.rootViewController else { - return - } - - // 로그인 진행 - // 성공시 result 객체를 반환 - // result.user에서 idToken, accessToken을 가져온다 - GIDSignIn.sharedInstance.signIn(withPresenting: rootViewController) { [weak self] result, error in - if let error { - completion(.failure(error)) - return - } - - guard let user = result?.user, let idToken = user.idToken?.tokenString else { - // 유저정보, 토큰정보가 없다면 - completion(.failure(AuthenticationError.tokenError)) - return - } - - let accessToken = user.accessToken.tokenString - let credential = GoogleAuthProvider.credential(withIDToken: idToken, accessToken: accessToken) - - self?.authenticateUserWithFirebase(credential: credential, loginPlatform: .google, completion: completion) - } - } - - // MARK: - 애플 비동기 로그인 - private func handleSignInWithAppleCompletion(_ authorization: ASAuthorization, nonce: String, completion: @escaping (Result) -> Void) { - guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential, - let appleIdToken = appleIDCredential.identityToken else { - print("애플 로그인 실패: 유효하지 않은 자격 증명") - completion(.failure(AuthenticationError.tokenError)) - return - } - - guard let idTokenString = String(data: appleIdToken, encoding: .utf8) else { - print("애플 로그인 실패: ID 토큰 변환 실패") - completion(.failure(AuthenticationError.tokenError)) - return - } - - let credential = OAuthProvider.credential(providerID: AuthProviderID.apple, idToken: idTokenString, rawNonce: nonce) - - // Firebase인증에 필요한 AuthCredential를 생성하여 authenticateUserWithFirebase로 전달한다 - authenticateUserWithFirebase(credential: credential, loginPlatform: .apple , completion: completion) - } - - // MARK: - 파이어베이스 인증 진행 함수 - private func authenticateUserWithFirebase(credential: AuthCredential, loginPlatform: LoginPlatform, completion: @escaping (Result) -> Void ) { - // Firebase 서버에 인증 요청을 보낸다 - Auth.auth().signIn(with: credential) { result, error in - // 인증 실패시 에러를 completion으로 반환한다. - if let error { - completion(.failure(error)) - return - } - - // 인증 결과가 없는 경우 invalidated 에러를 반환한다 - guard let result else { - completion(.failure(AuthenticationError.invalidated)) - return - } - - // 기존 사용자: 기존 uid를 반환 - // 새 사용자: 새로운 uid를 반환 - let firebaseUser = result.user - - // ISO8601DateFormatter를 사용해 한국 시간대에 맞게 날짜 생성 - let formatter = ISO8601DateFormatter() - formatter.timeZone = TimeZone(identifier: "Asia/Seoul") // KST설정 - let registerDate = formatter.date(from: formatter.string(from: Date())) - - // User객체 생성 - let user = User( - id: firebaseUser.uid, - nickname: "", - registerDate: registerDate, - loginPlatform: loginPlatform) - - completion(.success(user)) - } - } -} - - -// MARK: - Nonce 관련 함수 -extension AuthenticationService { - private func randomNonceString(length: Int = 32) -> String { - precondition(length > 0) - var randomBytes = [UInt8](repeating: 0, count: length) - let errorCode = SecRandomCopyBytes(kSecRandomDefault, randomBytes.count, &randomBytes) - if errorCode != errSecSuccess { - fatalError("Unable to generate nonce. SecRandomCopyBytes failed with OSStatus \(errorCode)") - } - - let charset: [Character] = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._") - let nonce = randomBytes.map { byte in - charset[Int(byte) % charset.count] - } - return String(nonce) - } - - private func sha256(_ input: String) -> String { - let inputData = Data(input.utf8) - let hashedData = SHA256.hash(data: inputData) - let hashString = hashedData.compactMap { - String(format: "%02x", $0) - }.joined() - - return hashString - } -} diff --git a/CodeLounge/Service/PostService.swift b/CodeLounge/Service/PostService.swift new file mode 100644 index 0000000..3bc5564 --- /dev/null +++ b/CodeLounge/Service/PostService.swift @@ -0,0 +1,46 @@ +// +// PostService.swift +// CodeLounge +// +// Created by 김동현 on 4/3/26. +// + +import Combine + +protocol PostServiceProtocol { + func fetchAllPosts() -> AnyPublisher<[String: [Post]], ServiceError> +} + +final class PostService: PostServiceProtocol { + + private let repository: PostRepositoryProtocol + init(repository: PostRepositoryProtocol) { + self.repository = repository + } + + func fetchAllPosts() -> AnyPublisher<[String: [Post]], ServiceError> { + repository.fetchAllPosts() + + // DTO → Domain 변환 + .map { dtoDict in + dtoDict.mapValues { dtoArray in + dtoArray.map { $0.toDomain() } + } + } + + // 정렬 + .map { dict in + dict.mapValues { posts in + posts.sorted { + if $0.createdAt != $1.createdAt { + return $0.createdAt < $1.createdAt + } else { + return $0.title < $1.title + } + } + } + } + .mapError { ServiceError.error($0) } + .eraseToAnyPublisher() + } +} diff --git a/CodeLounge/Service/ServiceError.swift b/CodeLounge/Service/ServiceError.swift deleted file mode 100644 index aae6ec3..0000000 --- a/CodeLounge/Service/ServiceError.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// ServiceError.swift -// CodeLounge -// -// Created by 김동현 on 1/14/25. -// - -import Foundation - -enum ServiceError: Error { - case dbError(DBError) - case error(Error) -} diff --git a/CodeLounge/Service/Services.swift b/CodeLounge/Service/Services.swift deleted file mode 100644 index a3332f2..0000000 --- a/CodeLounge/Service/Services.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// Services.swift -// CodeLounge -// -// Created by 김동현 on 1/14/25. -// - -import Foundation - -protocol ServiceType { - var authService: AuthenticationServiceType { get set } - var userService: UserServiceType { get set } -} - -final class Services: ServiceType { - var authService: AuthenticationServiceType - var userService: UserServiceType - - init() { - self.authService = AuthenticationService() - self.userService = UserService(dbRepository: UserDBRepository()) - } -} - -final class StubServices: ServiceType { - var authService: AuthenticationServiceType = StubAuthenticationService() - var userService: UserServiceType = UserService(dbRepository: UserDBRepository()) -} diff --git a/CodeLounge/Service/UserService.swift b/CodeLounge/Service/UserService.swift index 3a426d9..07ee5ad 100644 --- a/CodeLounge/Service/UserService.swift +++ b/CodeLounge/Service/UserService.swift @@ -2,99 +2,86 @@ // UserService.swift // CodeLounge // -// Created by 김동현 on 1/16/25. +// Created by 김동현 on 4/3/26. // import Foundation import Combine -protocol UserServiceType { - func addUser(_ user: User) -> AnyPublisher - func getUser(userId: String) -> AnyPublisher - func checkNicknameDuplicate(_ nickname: String) -> AnyPublisher - func updateUserInfo(userId: String, nickname: String, birthday: String, gender: String) -> AnyPublisher - func deleteUser(userId: String) -> AnyPublisher +protocol UserServiceProtocol { + func addUser(_ user: User) -> AnyPublisher + func updateUserInfo( + userId: String, + nickname: String, + birthday: String, + gender: String + ) -> AnyPublisher + func deleteUser(userId: String) -> AnyPublisher + func getUser(userId: String) -> AnyPublisher + func checkNicknameDuplicate(_ nickname: String) -> AnyPublisher } -final class UserService: UserServiceType { - - private var dbRepository: UserDBRepositoryType - - init(dbRepository: UserDBRepositoryType) { - self.dbRepository = dbRepository - } - - func addUser(_ user: User) -> AnyPublisher { - dbRepository.addUser(user.toObject()) - .map { user } - .mapError { .error($0) } - .eraseToAnyPublisher() - } - - func getUser(userId: String) -> AnyPublisher { - dbRepository.getUser(userId: userId) - .map { $0.toModel() } - .mapError { .error($0) } - .eraseToAnyPublisher() - } - - func checkNicknameDuplicate(_ nickname: String) -> AnyPublisher { - dbRepository.loadUsers() - .map { users in - users.contains { $0.nickname == nickname } - } - .mapError { .error($0) } - .eraseToAnyPublisher() - } - - func updateUserInfo(userId: String, nickname: String, birthday: String, gender: String) -> AnyPublisher { - dbRepository.getUser(userId: userId) - .mapError { ServiceError.error($0) } // Map DBError to ServiceError - .flatMap { userObject -> AnyPublisher in - var updatedUserObject = userObject - updatedUserObject.nickname = nickname - updatedUserObject.birthdayDate = birthday - updatedUserObject.gender = gender - - // Update the user and fetch the updated user object - return self.dbRepository.updateUser(updatedUserObject) - .mapError { ServiceError.error($0) } // Map DBError to ServiceError - .flatMap { _ in - self.dbRepository.getUser(userId: userId) - .map { $0.toModel() } - .mapError { ServiceError.error($0) } - } - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - } - - func deleteUser(userId: String) -> AnyPublisher { - dbRepository.deleteUser(userId: userId) - .mapError { ServiceError.error($0) } // DBError를 ServiceError로 매핑 - .eraseToAnyPublisher() - } -} - -final class StubUserService: UserServiceType { - - func addUser(_ user: User) -> AnyPublisher { - Empty().eraseToAnyPublisher() - } - - func getUser(userId: String) -> AnyPublisher { - Just(.stub1).setFailureType(to: ServiceError.self).eraseToAnyPublisher() - } - - func checkNicknameDuplicate(_ nickname: String) -> AnyPublisher { - Empty().eraseToAnyPublisher() - } - - func updateUserInfo(userId: String, nickname: String, birthday: String, gender: String) -> AnyPublisher { - Empty().eraseToAnyPublisher() - } - - func deleteUser(userId: String) -> AnyPublisher { - Empty().eraseToAnyPublisher() - } +final class UserService: UserServiceProtocol { + private var dbRepository: UserDBRepositoryProtocol + + init(dbRepository: UserDBRepositoryProtocol) { + self.dbRepository = dbRepository + } + + func addUser(_ user: User) -> AnyPublisher { + dbRepository.addUser(user.toDTO()) + .map { user } + .mapError { .error($0) } + .eraseToAnyPublisher() + } + + func updateUserInfo( + userId: String, + nickname: String, + birthday: String, + gender: String + ) -> AnyPublisher { + dbRepository.getUser(userId: userId) + .mapError { ServiceError.error($0) } // Map DBError to ServiceError + .flatMap { userObject -> AnyPublisher in + var updatedUserObject = userObject + updatedUserObject.nickname = nickname + updatedUserObject.birthdayDate = birthday + updatedUserObject.gender = gender + + // Update the user and fetch the updated user object + return self.dbRepository.updateUser(updatedUserObject) + .mapError { ServiceError.error($0) } // Map DBError to ServiceError + .flatMap { _ in + self.dbRepository.getUser(userId: userId) + .map { $0.toModel() } + .mapError { ServiceError.error($0) } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + func deleteUser(userId: String) -> AnyPublisher { + dbRepository.deleteUser(userId: userId) + .mapError { ServiceError.error($0) } // DBError를 ServiceError로 매핑 + .eraseToAnyPublisher() + } + + func getUser(userId: String) -> AnyPublisher { + dbRepository.getUser(userId: userId) + .map { $0.toModel() } + .mapError { .error($0) } + .eraseToAnyPublisher() + } + + + /// 닉네임 유무 + /// - Parameter nickname: 닉네임 + /// - Returns: 닉네임이 이미 존재하면 true, 없어서 사용 가능하면 false + func checkNicknameDuplicate(_ nickname: String) -> AnyPublisher { + dbRepository.checkNicknameDuplicate(nickname) + .mapError { .error($0) } + .eraseToAnyPublisher() + } } diff --git a/CodeLounge/Style/ButtonStyle.swift b/CodeLounge/Style/ButtonStyle.swift deleted file mode 100644 index d7fcd93..0000000 --- a/CodeLounge/Style/ButtonStyle.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// ButtonStyle.swift -// CodeLounge -// -// Created by 김동현 on 1/20/25. -// - -import SwiftUI - -// MARK: - 리스트 커스텀 버튼 -struct ListRowButton: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration - .label - // to cover the whole length of the cell - .frame( - maxWidth: .greatestFiniteMagnitude, - alignment: .leading) - // to make all the cell tapable, not just the text - .contentShape(.rect) - .background { - if configuration.isPressed { - Rectangle() - .fill(Color.mainGreen) - // Arbitrary negative padding, adjust accordingly - .padding(-20) - } - } - } -} - -// MARK: - 프로필 뷰 버튼 디자인 -extension View { - func indexButtonStyle(color: Color) -> some View { - self - .padding() - .foregroundColor(color) - .frame(width: 150, height: 100) - .overlay { - RoundedRectangle(cornerRadius: 10) - .stroke(Color.mainGreen, lineWidth: 1) - } - } - -// func boxStyle(color: Color, width: CGFloat,height: CGFloat, radius: CGFloat) -> some View { -// -// self -// .padding() -// .background(color) -// .frame(width: width, height: height) -// .cornerRadius(radius) -// } -} - -/* - Button { - authViewModel.send(action: .logout) - } label: { - Text("로그아웃") - .greenButtonStyle() - } - */ - -struct RectView: View { - var height: CGFloat = 100 - var color: Color = .gray - var radius: CGFloat = 20 - - var body: some View { - Rectangle() - .fill(color) - .frame(height: height) - .cornerRadius(radius) - } -} - -struct StroketView: View { - var width: CGFloat = 100 - var height: CGFloat = 100 - var color: Color = .gray - var radius: CGFloat = 20 - - var body: some View { - Rectangle() - .stroke(Color.white, lineWidth: 10) - .frame(width: width, height: height) - .cornerRadius(radius) - - } -} diff --git a/CodeLounge/View/Authentication/AuthenticationView.swift b/CodeLounge/View/Authentication/AuthenticationView.swift deleted file mode 100644 index 623c3aa..0000000 --- a/CodeLounge/View/Authentication/AuthenticationView.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// AuthenticationView.swift -// CodeLounge -// -// Created by 김동현 on 1/16/25. -// - -import SwiftUI - -struct AuthenticationView: View { - @StateObject var authViewModel: AuthenticationViewModel - - var body: some View { - VStack { - switch authViewModel.authenticationState { - case .unauthenticated: - IntroView() - .environmentObject(authViewModel) - case .authenticated: - MainTabView() - .environmentObject(authViewModel) - case .firstTimeLogin: - NicknameSettingView() - .environmentObject(authViewModel) - } - } - .onAppear { - authViewModel.send(action: .checkAuthenticationState) - } - } -} - -#Preview { - AuthenticationView(authViewModel: AuthenticationViewModel(container: DIContainer(services: StubServices()))) -} diff --git a/CodeLounge/View/Banner/BannerView.swift b/CodeLounge/View/Banner/BannerView.swift deleted file mode 100644 index d4b10f2..0000000 --- a/CodeLounge/View/Banner/BannerView.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// BannerView.swift -// CodeLounge -// -// Created by 김동현 on 5/14/25. -// - -import SwiftUI -import UIKit -import GoogleMobileAds - -// SwiftUI에서 사용할 배너 광고 뷰 -struct BannerAdView: View { - - // 광고 유닛 ID - let adUnitID: String - - var body: some View { - // UIKit의 UIView를 SwiftUI에서 사용할 수 있도록 래핑한 View - BannerAdViewController(adUnitID: adUnitID) - .frame(height: 50) // 배너 높이는 일반적으로 50pt로 고정 - } -} - -// UIViewRepresentable을 통해 UIKit의 GADBannerView를 SwiftUI에서 사용 -struct BannerAdViewController: UIViewRepresentable { - - // 광고 유닛 ID - let adUnitID: String - - // 실제 UIKit 뷰를 생성하는 함수 - func makeUIView(context: Context) -> BannerView { - // GADBannerView 객체 생성 (광고 사이즈 지정) - let banner = BannerView(adSize: AdSizeBanner) - // 광고 ID 설정 - banner.adUnitID = adUnitID - // 루트 뷰 컨트롤러 설정 (광고 클릭 시 사용) - banner.rootViewController = findRootViewController() - // 광고 로드 요청 - banner.load(Request()) - return banner - } - - // SwiftUI 상태 변경 시 업데이트할 내용 (여기서는 필요 없음) - func updateUIView(_ uiView: BannerView, context: Context) {} - - // 현재 앱의 루트 뷰 컨트롤러를 찾는 메서드 - // 광고 클릭 시 새 창을 띄우기 위해 필요함 - private func findRootViewController() -> UIViewController? { - guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let root = scene.windows.first?.rootViewController else { - return nil - } - return root - } -} diff --git a/CodeLounge/View/Cagegory/AosView.swift b/CodeLounge/View/Cagegory/AosView.swift deleted file mode 100644 index 6d48d07..0000000 --- a/CodeLounge/View/Cagegory/AosView.swift +++ /dev/null @@ -1,111 +0,0 @@ -// -// AosView.swift -// CodeLounge -// -// Created by 김동현 on 1/16/25. -// - -import SwiftUI - -struct AosView: View { - @EnvironmentObject private var postViewModel: PostViewModel - @State private var selectedPost: Post? - private let categories: [String] = ["Kotlin", "Jetpack Compose UI"] - - var body: some View { - NavigationStack { - ZStack { - Color.mainBlack - .ignoresSafeArea() // ✅ 배경이 네비게이션 바까지 덮이도록 - - VStack(spacing: 0) { - - // MARK: - 타이틀뷰 & 검색바 - HStack { - - Text("AOS") - .font(.system(size: 35, weight: .bold)) - .padding(.leading, 20) - - Spacer() - - // ✅ UIKit 기반 입력 필드로 변경 - CustomTextField(text: $postViewModel.searchText, placeholder: "검색") - .frame(width: 200, height: 40) - .padding(.horizontal) - .onChange(of: postViewModel.searchText) { _, _ in - postViewModel.filterPosts(for: categories) - } - } - .padding(.top, 30) - .padding(.vertical, 10) - .background(Color.mainBlack) - - // MARK: - 리스트 - List { - ForEach(categories, id: \.self) { category in - if let posts = postViewModel.filteredPostsByCategory[category], !posts.isEmpty { - Section(header: Text(postViewModel.categoryNames[category] ?? category.capitalizeFirstLetter()) - .foregroundColor(Color.mainGreen) - .font(.system(size: 17, weight: .bold)) - .padding(.leading, -10) - .textCase(.none) // ✅ 대소문자 자동 변환 방지 - ) { - ForEach(posts) { post in - Button { - selectedPost = post - } label: { - HStack { - Text(post.title) - .font(.headline) - .foregroundStyle(.white) - - Spacer() - - Image(systemName: "chevron.right") - .font(.system(size: 15)) - .foregroundColor(.gray) - } - } - .buttonStyle(ListRowButton()) - .listRowBackground(Color.subBlack) - .listRowSeparatorTint(Color.gray.opacity(0.4), edges: .bottom) - } - } - } - } - } - .scrollContentBackground(.hidden) // ✅ 리스트 배경 제거 - .background(Color.clear) // ✅ 리스트 배경을 완전히 투명하게 설정 - .scrollIndicators(.hidden) // ✅ 스크롤 인디케이터 숨기기 - .navigationDestination(item: $selectedPost) { post in - DetailView(post: post) - } - } - .simultaneousGesture( - TapGesture().onEnded { - CustomTextField.hideKeyboard() - } - ) -// .onTapGesture { -// CustomTextField.hideKeyboard() // ✅ 외부 터치 시 키보드 닫기 -// } - } - .tint(Color.mainWhite) - } - .onAppear { - postViewModel.searchText = "" // 검색어 초기화 - postViewModel.filterPosts(for: categories) // 현재 탭에 맞는 데이터 필터링 - } - .refreshable { - postViewModel.fetchAllPosts() - postViewModel.searchText = "" // 검색어 초기화 - postViewModel.filterPosts(for: categories) // 현재 탭에 맞는 데이터 필터링 - } - } -} - -#Preview { - AosView() - .environmentObject(PostViewModel()) -} diff --git a/CodeLounge/View/Cagegory/CSView.swift b/CodeLounge/View/Cagegory/CSView.swift deleted file mode 100644 index e7b4f28..0000000 --- a/CodeLounge/View/Cagegory/CSView.swift +++ /dev/null @@ -1,139 +0,0 @@ -// -// CSView.swift -// CodeLounge -// -// Created by 김동현 on 1/16/25. -// - -import SwiftUI - -struct CSView: View { - @EnvironmentObject private var postViewModel: PostViewModel - @State private var selectedPost: Post? - private let categories: [String] = ["OperatingSystems", "Algorithms"] - - var body: some View { - - NavigationStack { - ZStack { - Color.mainBlack - .ignoresSafeArea() // ✅ 배경이 네비게이션 바까지 덮이도록 - - VStack(spacing: 0) { - - // MARK: - 타이틀뷰 & 검색바 - HStack { - - Text("CS") - .font(.system(size: 35, weight: .bold)) - .padding(.leading, 20) - - Spacer() - - // ✅ UIKit 기반 입력 필드로 변경 - CustomTextField(text: $postViewModel.searchText, placeholder: "검색") - .frame(width: 200, height: 40) - .padding(.horizontal) - .onChange(of: postViewModel.searchText) { _, _ in - postViewModel.filterPosts(for: categories) - } - } - .padding(.top, 30) - .padding(.vertical, 10) - .background(Color.mainBlack) - - // MARK: - 리스트 - List { - ForEach(categories, id: \.self) { category in - if let posts = postViewModel.filteredPostsByCategory[category], !posts.isEmpty { - Section(header: Text(postViewModel.categoryNames[category] ?? category.capitalizeFirstLetter()) - .foregroundColor(Color.mainGreen) - .font(.system(size: 17, weight: .bold)) - .padding(.leading, -10) - .textCase(.none) // ✅ 대소문자 자동 변환 방지 - ) { - ForEach(posts) { post in - Button { - selectedPost = post - } label: { - HStack { - Text(post.title) - .font(.headline) - .foregroundStyle(.white) - - Spacer() - - Image(systemName: "chevron.right") - .font(.system(size: 15)) - .foregroundColor(.gray) - } - } - .buttonStyle(ListRowButton()) - .listRowBackground(Color.subBlack) - .listRowSeparatorTint(Color.gray.opacity(0.4), edges: .bottom) - } - } - } - } - } - .scrollContentBackground(.hidden) // ✅ 리스트 배경 제거 - .background(Color.clear) // ✅ 리스트 배경을 완전히 투명하게 설정 - .scrollIndicators(.hidden) // ✅ 스크롤 인디케이터 숨기기 - .navigationDestination(item: $selectedPost) { post in - DetailView(post: post) - } - } - .simultaneousGesture( - TapGesture().onEnded { - CustomTextField.hideKeyboard() - } - ) -// .onTapGesture { -// CustomTextField.hideKeyboard() // ✅ 외부 터치 시 키보드 닫기 -// } - - } - .tint(Color.mainWhite) - } - - .onAppear { - postViewModel.searchText = "" // 검색어 초기화 - postViewModel.filterPosts(for: categories) // 현재 탭에 맞는 데이터 필터링 - - } - .refreshable { - postViewModel.fetchAllPosts() - postViewModel.searchText = "" // 검색어 초기화 - postViewModel.filterPosts(for: categories) // 현재 탭에 맞는 데이터 필터링 - } - } -} - -#Preview { - // CSView() - MainTabView() - .environmentObject(PostViewModel()) -} - -//.modifier(KeyboardAvoidanceModifier()) // ✅ 키보드 올라올 때 배경 깜빡임 제거 -struct KeyboardAvoidanceModifier: ViewModifier { - @State private var keyboardVisible = false - - func body(content: Content) -> some View { - content - .onAppear { - NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { _ in - keyboardVisible = true - } - NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { _ in - keyboardVisible = false - } - } - .background(keyboardVisible ? Color.clear : Color.mainBlack) // ✅ 키보드 올라오면 배경 제거 - .onDisappear { - NotificationCenter.default.removeObserver(self) - } - } -} - - diff --git a/CodeLounge/View/Cagegory/CategoryView.swift b/CodeLounge/View/Cagegory/CategoryView.swift deleted file mode 100644 index 62687ec..0000000 --- a/CodeLounge/View/Cagegory/CategoryView.swift +++ /dev/null @@ -1,143 +0,0 @@ -// -// CategoryView.swift -// CodeLounge -// -// Created by 김동현 on 1/17/25. -// - -import SwiftUI - -struct CategoryView: View { - var body: some View { - ZStack { - Color.mainBlack - .ignoresSafeArea() - - VStack { - TitleView() - TestView() - } - } - } -} - -private struct TitleView: View { - fileprivate var body: some View { - VStack { - Text("Dev Place") - .font(.system(size: 30, weight: .bold)) - .foregroundColor(.mainWhite) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - .padding(.top, 20) - } - } -} - -private struct TestView: View { - @State private var offsetX: CGFloat = 0 // 버튼의 x축 오프셋 - private let animationDuration = 0.8 // 애니메이션 시간 - - fileprivate var body: some View { - ScrollView { - VStack(spacing: 20) { - - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 20) { - Button { - - } label: { - Text("공지사항") - .textRectangle(width: 360, height: 120) - } - Button { - - } label: { - Text("광고") - .textRectangle(width: 360, height: 120) - } - } - .padding(.horizontal, 20) - .offset(x: offsetX) - .onAppear { - Task { - while true { - // 첫 번째 애니메이션 - withAnimation(Animation.easeInOut(duration: animationDuration)) { - offsetX = -380 - } - - // 애니메이션 후 4초 대기 - try? await Task.sleep(nanoseconds: 4_000_000_000) - - // 두 번째 애니메이션 - withAnimation(Animation.easeInOut(duration: animationDuration)) { - offsetX = 0 - } - - // 다시 1초 대기 - try? await Task.sleep(nanoseconds: 4_000_000_000) - } - } - /* - withAnimation(Animation.easeInOut(duration: animationDuration).repeatForever(autoreverses: true)) { - offsetX = -380 - } - */ - } - } - - HStack(spacing: 20) { - Button { - - } label: { - Text("CS") - .textRectangle(width: 170, height: 120) - } - - Button { - - } label: { - Text("iOS") - .textRectangle(width: 170, height: 120) - } - } - HStack(spacing: 20) { - Button { - - } label: { - Text("aOS") - .textRectangle(width: 170, height: 120) - } - - Button { - - } label: { - Text("준비중") - .textRectangle(width: 170, height: 120) - } - } - }.padding() - } - } -} - -#Preview { - CategoryView() -} - -extension View { - func textRectangle(width: CGFloat, height: CGFloat) -> some View { - self - .padding() - .font(.system(size: 25, weight: .bold)) - .foregroundColor(.mainWhite) - .frame(width: width, height: height) - .background(Color.gray.opacity(0.2)) - .cornerRadius(20) -// .overlay { -// RoundedRectangle(cornerRadius: 10) -// .stroke(Color.gray, lineWidth: 1) -// } - } -} diff --git a/CodeLounge/View/Cagegory/iOSView.swift b/CodeLounge/View/Cagegory/iOSView.swift deleted file mode 100644 index cb9e8ac..0000000 --- a/CodeLounge/View/Cagegory/iOSView.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// iOSView.swift -// CodeLounge -// -// Created by 김동현 on 1/16/25. -// - -import SwiftUI - -struct iOSView: View { - @EnvironmentObject private var postViewModel: PostViewModel - @State private var selectedPost: Post? - private let categories: [String] = ["Swift", "SwiftUI"] - - var body: some View { - - NavigationStack { - ZStack { - Color.mainBlack - .ignoresSafeArea() // ✅ 배경이 네비게이션 바까지 덮이도록 - - VStack(spacing: 0) { - - // MARK: - 타이틀뷰 & 검색바 - HStack { - - Text("iOS") - .font(.system(size: 35, weight: .bold)) - .padding(.leading, 20) - - Spacer() - - // ✅ UIKit 기반 입력 필드로 변경 - CustomTextField(text: $postViewModel.searchText, placeholder: "검색") - .frame(width: 200, height: 40) - .padding(.horizontal) - .onChange(of: postViewModel.searchText) { _, _ in - postViewModel.filterPosts(for: categories) - } - } - .padding(.top, 30) - .padding(.vertical, 10) - .background(Color.mainBlack) - - // MARK: - 리스트 - List { - ForEach(categories, id: \.self) { category in - if let posts = postViewModel.filteredPostsByCategory[category], !posts.isEmpty { - Section(header: Text(postViewModel.categoryNames[category] ?? category.capitalizeFirstLetter()) - .foregroundColor(Color.mainGreen) - .font(.system(size: 17, weight: .bold)) - .padding(.leading, -10) - .textCase(.none) // ✅ 대소문자 자동 변환 방지 - ) { - ForEach(posts) { post in - Button { - selectedPost = post - } label: { - HStack { - Text(post.title) - .font(.headline) - .foregroundStyle(.white) - - Spacer() - - Image(systemName: "chevron.right") - .font(.system(size: 15)) - .foregroundColor(.gray) - } - } - .buttonStyle(ListRowButton()) - .listRowBackground(Color.subBlack) - .listRowSeparatorTint(Color.gray.opacity(0.4), edges: .bottom) - } - } - } - } - } - .scrollContentBackground(.hidden) // ✅ 리스트 배경 제거 - .background(Color.clear) // ✅ 리스트 배경을 완전히 투명하게 설정 - .scrollIndicators(.hidden) // ✅ 스크롤 인디케이터 숨기기 - .navigationDestination(item: $selectedPost) { post in - DetailView(post: post) - } - } - .simultaneousGesture( - TapGesture().onEnded { - CustomTextField.hideKeyboard() - } - ) -// .onTapGesture { -// CustomTextField.hideKeyboard() // ✅ 외부 터치 시 키보드 닫기 -// } - } - .tint(Color.mainWhite) - } - .onAppear { - postViewModel.searchText = "" // 검색어 초기화 - postViewModel.filterPosts(for: categories) // 현재 탭에 맞는 데이터 필터링 - } - .refreshable { - postViewModel.fetchAllPosts() - postViewModel.searchText = "" // 검색어 초기화 - postViewModel.filterPosts(for: categories) // 현재 탭에 맞는 데이터 필터링 - } - } -} - -#Preview { - iOSView() - .environmentObject(PostViewModel()) -} diff --git a/CodeLounge/View/Detail/DetailView.swift b/CodeLounge/View/Detail/DetailView.swift deleted file mode 100644 index ff9711e..0000000 --- a/CodeLounge/View/Detail/DetailView.swift +++ /dev/null @@ -1,289 +0,0 @@ -// -// DetailView.swift -// CodeLounge -// -// Created by 김동현 on 1/20/25. -// - -import SwiftUI - -struct DetailView: View { - // 뒤로가기 동작 - @Environment(\.dismiss) private var dismiss - - let post: Post - var body: some View { - VStack(spacing: 0) { - ScrollView(showsIndicators: false) { - VStack { - MarkdownView(markdown: post.content) - .padding() - } - .frame(maxWidth: .infinity, alignment: .topLeading) // 좌측 상단 정렬 - } - // BannerAdView(adUnitID: "ca-app-pub-3940256099942544/2435281174") - BannerAdView(adUnitID: "ca-app-pub-6798240605221343/7424023393") - } - .background(Color.mainBlack.ignoresSafeArea()) - .navigationBarTitle("\(post.title)", displayMode: .inline) - .navigationBarBackButtonHidden(true) // 기본 뒤로가기 버튼 숨김 - //.toolbarBackground(Color.mainBlack, for: .navigationBar) // 네비게이션 바 배경 색상 - .toolbarBackground(.visible, for: .navigationBar) // 배경 색상 강제 적용 - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button { - dismiss() - } label: { - Image(systemName: "chevron.left") - } - .foregroundColor(Color.mainGreen) - } - } - } -} - - -//import SwiftUI -// -//struct DetailView: View { -// // 뒤로가기 동작 -// @Environment(\.dismiss) private var dismiss -// -// let post: Post -// var body: some View { -// ZStack { -// // 배경 -// Color.mainBlack -// .ignoresSafeArea() -// -// ScrollView(showsIndicators: false) { -// VStack { -// MarkdownView(markdown: post.content) -// .padding() -// BannerAdView(adUnitID: "ca-app-pub-3940256099942544/2435281174") -// } -// .frame(maxWidth: .infinity, alignment: .topLeading) // 좌측 상단 정렬 -// } -// } -// .navigationBarTitle("\(post.title)", displayMode: .inline) -// .navigationBarBackButtonHidden(true) // 기본 뒤로가기 버튼 숨김 -// //.toolbarBackground(Color.mainBlack, for: .navigationBar) // 네비게이션 바 배경 색상 -// .toolbarBackground(.visible, for: .navigationBar) // 배경 색상 강제 적용 -// .toolbar { -// ToolbarItem(placement: .navigationBarLeading) { -// Button { -// dismiss() -// } label: { -// Image(systemName: "chevron.left") -// } -// .foregroundColor(Color.mainGreen) -// } -// } -// } -// -// private func formatText(_ content: String) -> AnyView { -// let replacedContent = content.replacingOccurrences(of: "\\n", with: "\n") -// let components = replacedContent.components(separatedBy: "\n```") -// -// var formattedViews: [AnyView] = [] -// -// for (index, component) in components.enumerated() { -// if index % 2 == 1 { // 코드 블록 처리 -// let lines = component.split(separator: "\n", maxSplits: 1) -// let language = lines.first?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "코드" -// let codeContent = lines.count > 1 ? lines[1] : "" -// -// // 코드 블록 스타일 적용 -// formattedViews.append( -// AnyView( -// VStack(alignment: .leading, spacing: 6) { -// Text("\(language.uppercased()) CODE") -// .font(.caption) -// .foregroundColor(.gray) -// Text(codeContent.trimmingCharacters(in: .whitespacesAndNewlines)) -// .font(.system(size: 13.scaled, design: .monospaced)) -// .padding() -// .background(Color.gray.opacity(0.2)) -// .cornerRadius(8) -// } -// .padding(.top, -10) -// ) -// ) -// } else { // 일반 텍스트 처리 -// let subcomponents = component.components(separatedBy: "**") -// var formattedText = Text("") -// for (index, subcomponent) in subcomponents.enumerated() { -// if index % 2 == 1 { // `**`로 감싸진 텍스트 -// formattedText = formattedText -// + Text(subcomponent) -// .font(.system(size: 20.scaled, weight: .bold)) -// .foregroundColor(.mainGreen) -// } else { // 일반 텍스트 -// let underlinedComponents = subcomponent.components(separatedBy: "##") -// for (index, part) in underlinedComponents.enumerated() { -// if index % 2 == 1 { // `##`로 감싸진 텍스트 -// formattedText = formattedText -// + Text(part) -// .underline() -// //.font(.body) -// .font(.system(size: 17.scaled)) -// .foregroundColor(.mainGreen) -// } else { // 일반 텍스트 -// formattedText = formattedText -// + Text(part) -// //.font(.body) -// .font(.system(size: 15.scaled)) -// .foregroundColor(.white) -// } -// } -// } -// } -// formattedViews.append( -// AnyView(formattedText.lineSpacing(8.scaled)) -// ) -// } -// } -// -// return AnyView( -// ScrollView { -// VStack(alignment: .leading, spacing: 12.scaled) { -// ForEach(0.. String { - guard !isAtEnd() else { return "" } - return lines[currentIndex] - } - - /// 다음 줄로 이동 - private func advance() { - currentIndex += 1 - } - - /// 줄의 끝에 도달했는지 확인 - private func isAtEnd() -> Bool { - currentIndex >= lines.count - } -} - -// MARK: - Block 단위 (한 줄) 파싱 (Top-down 방식) -extension MarkdownParser { - - /// 전체 문서를 파싱하여 노드 배열 반환 - /// EBNF: Document ::= Block { Block } - func parseDocument() -> [MarkdownNode] { - var nodes: [MarkdownNode] = [] - - while !isAtEnd() { - if let node = parseBlock() { - nodes.append(node) // 한 줄 해석 결과를 추가 - } else { - advance() - } - } - return nodes - } - - /// 현재 줄이 어떤 블록(heading, list, 문단) 인지 판별 - /// EBNF: Block ::= Heading | ListItem | Paragraph - private func parseBlock() -> MarkdownNode? { - - let line = currentLine() - - if line.trimmingCharacters(in: .whitespaces).isEmpty { - advance() - return .lineBreak // ← 공백 줄은 줄바꿈 처리 - } - - if let heading = parseHeading() { - return heading - } else if let code = parseCodeBlock() { - return code - } else if let list = parseListItem() { - return list - } else if let paragraph = parseParagraph() { - return paragraph - } - return nil - } - - /// Heading 문법: # 또는 ## 등으로 시작하는 줄 - /// EBNF: Heading ::= HeadingMarker Space TextLine - private func parseHeading() -> MarkdownNode? { - let line = currentLine() - - // 1. 줄이 '## ... ##' 형식이면 heading 아님 → paragraph로 넘김 - if line.trimmingCharacters(in: .whitespaces).hasPrefix("##"), - line.trimmingCharacters(in: .whitespaces).hasSuffix("##"), - line.trimmingCharacters(in: .whitespaces).count > 4 { - return nil - } - - // 반드시 '# ' 또는 '## ' 같은 패턴만 허용 - guard let _ = line.range(of: #"^#{1,6} "#, options: .regularExpression) else { - return nil - } - - // # 갯수 계산 - let level = line.prefix(while: { $0 == "#" }).count - let text = line.dropFirst(level + 1) // # + 공백 제거 - advance() - return .heading(level: level, text: String(text)) - } - - - /// 리스트 문법: - 으로 시작하는 줄 - /// EBNF: ListItem ::= '- ' TextLine - /// 입력: - **강조됨** 텍스트 - /* - .listItem(text: [ - .bold([.text("강조됨")]), - .text(" 텍스트") - ]) - */ - private func parseListItem() -> MarkdownNode? { - let line = currentLine() - guard line.hasPrefix("- ") else { return nil} - - let content = line.dropFirst(2) - advance() - return .listItem(text: InlineParser(String(content)).parse()) - } - - /// 일반 문단 파싱 → 내부 인라인은 재귀 하강 - /// EBNF: Paragraph ::= TextLine - /// 입력: 이건 **강조**된 문장입니다 - /* - .paragraph(inlines: [ - .text("이건 "), - .bold([.text("강조")]), - .text("된 문장입니다") - ]) - */ - private func parseParagraph() -> MarkdownNode? { - let line = currentLine() - - // 줄의 앞뒤 공백을 제거하고 내용이 비어있으면 nil 반환 - guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return nil } - - advance() - let inlines = InlineParser(line).parse() - return .paragraph(inlines: inlines) - } - - private func parseCodeBlock() -> MarkdownNode? { - let line = currentLine() - guard line.hasPrefix("```") else { return nil } - - let language = line.dropFirst(3).trimmingCharacters(in: .whitespaces) - advance() - - var codeLines: [String] = [] - - while !isAtEnd(), !currentLine().hasPrefix("```") { - codeLines.append(currentLine()) - advance() - } - - advance() // 닫는 ``` 넘기기 - - return .code(language: language.isEmpty ? "code" : language, content: codeLines.joined(separator: "\n")) - } -} - - -// MARK: - 재귀 하강 인라인 파서 -final class InlineParser { - private let input: String // 전체 줄 문자열 (예: "문장 **굵게**") - private var index: String.Index // 현재 분석 중인 문자 위치 - - init(_ input: String) { - self.input = input - self.index = input.startIndex // 맨 처음 문자부터 시작 - } -} - -extension InlineParser { - /// 전체 문자열을 인라인 토큰 배열로 파싱 - /// 내부적으로 parseUntil(nil) 호출 - /// 입력: 안녕하세요 **강조** 텍스트입니다. - /* - [ - .text("안녕하세요 "), - .bold([.text("강조")]), - .text(" 텍스트입니다.") - ] - */ - func parse() -> [InlineNode] { - return parseUntil(nil) - } - - /// 특정 종료 기호(delimiter)가 나올 때까지 재귀적으로 인라인을 파싱 - /// delimiter: "**"이면 '**'가 닫힐 때까지 내부를 다시 파싱 (중첩 지원) - private func parseUntil(_ delimiter: String?) -> [InlineNode] { - var result: [InlineNode] = [] // 결과로 리턴한 노드 리스트 - var buffer = "" // 일반 텍스트를 모아두는 버퍼 - - // 버퍼에 모인 일반 텍스트를 노드로 바꿔 추가 - func flush() { - if !buffer.isEmpty { - result.append(.text(buffer)) - buffer = "" - } - } - - /// 본격적인 루프 시작 - while !isAtEnd() { - /// 종료 기호가 왔다면 종료 - if let delimiter = delimiter, peek(delimiter) { - advance(delimiter.count) // 닫는 기호 consume - flush() - return result // 현재 계층의 노드 목록 반환 - } - - /// ** 가 등장하면 bold 시작 - if match("**") { - flush() // 기존 텍스트 반영 - let children = parseUntil("**") // '**' 안쪽 재귀 파싱 - result.append(.bold(children)) // Bold 노드 추가 - } else if match("##") { - flush() - let children = parseUntil("##") - result.append(.underline(children)) - } else { - buffer.append(current()) // 일반 문자 누락 - advance(1) - } - } - - flush() - return result - } - - /// 현재 문자 반환 - private func current() -> Character { - input[index] - } - - /// 주어진 문자열 s가 현재 위치에서 시작되는지 확인 - private func peek(_ s: String) -> Bool { - input[index...].hasPrefix(s) - } - - /// 인덱스를 n글자 만큼 앞으로 이동하고 소비한 문자를 반환 - private func advance(_ n: Int) { - index = input.index(index, offsetBy: n) - } - - /// 입력 끝까지 도달했는지 확인 - private func isAtEnd() -> Bool { - index >= input.endIndex - } - - /// 주어진 문자열 s가 현재 위치에 있다면 consume하고 true, 아니면 false - private func match(_ s: String) -> Bool { - if peek(s) { - advance(s.count) - return true - } - return false - } -} - - -// MARK: - 파싱된 MarkdownNode 배열을 SwiftUI View로 변환하는 역할 -struct MarkdownRenderer { - @MainActor - func render(nodes: [MarkdownNode]) -> some View { - VStack(alignment: .leading, spacing: 8) { - // 각 노드를 순회하며 View로 렌더링 - ForEach(Array(nodes.enumerated()), id: \.offset) { _, node in - switch node { - case .heading(let level, let text): - let fontSize = (Int(25.scaled) - level * 2).scaled - Text(text) - .font(.system(size: CGFloat(fontSize), weight: .bold)) - .foregroundStyle(Color.mainGreen) - case .listItem(let inlines): - HStack(alignment: .top) { - Text("•") - renderInline(inlines) - } - case .paragraph(let inlines): - renderInline(inlines) - case .code(language: let language, content: let content): - VStack(alignment: .leading, spacing: 6.scaled) { - Text("\(language.uppercased()) CODE") - .font(.caption) - .foregroundColor(.gray) - Text(content) - .font(.system(size: 13.scaled, design: .monospaced)) - .padding() - .background(Color.gray.opacity(0.2)) - .cornerRadius(8) - } - case .lineBreak: - Spacer(minLength: 8) - } - } - } - } - - func renderInline(_ inlines: [InlineNode], fontSize: CGFloat = 15, color: Color = .primary) -> Text { - let pieces: [Text] = inlines.map { node in - switch node { - case .text(let str): - return Text(str) - .font(.system(size: fontSize)) - .foregroundColor(color) - - case .bold(let children): - return renderInline(children, fontSize: 20, color: .mainGreen) - .bold() - - case .underline(let children): - return renderInline(children, fontSize: fontSize, color: .mainGreen) - .underline() - } - } - - // reduce로 병합 (컴파일러 부담 줄임) - return pieces.reduce(Text(""), +) - } -} - - -// MARK: - 마크다운 텍스트를 입력받아 SwiftUI View로 렌더링 -struct MarkdownView: View { - let markdown: String - - var body: some View { - let nodes = MarkdownParser(markdown: markdown).parseDocument() - ScrollView { - MarkdownRenderer().render(nodes: nodes) - .frame(maxWidth: .infinity, alignment: .topLeading) // 좌측 상단 정렬 - } - } -} - diff --git a/CodeLounge/View/Intro/IntroView.swift b/CodeLounge/View/Intro/IntroView.swift new file mode 100644 index 0000000..3de5af7 --- /dev/null +++ b/CodeLounge/View/Intro/IntroView.swift @@ -0,0 +1,186 @@ +// +// IntroView.swift +// CodeLounge +// +// Created by 김동현 on 1/14/25. +// + +import SwiftUI +import TurboNavigator + +struct IntroView: View { + let navigator: Navigator + + // MARK: - View Properties + @State private var activePage: Page = .page1 + @State private var dragOffset: CGFloat = 0.0 + @State private var navigateToLogin: Bool = false + + var body: some View { + GeometryReader { + let size = $0.size + + VStack { + Spacer(minLength: 0) + + MorphingSymbolView( + symbol: activePage.rawValue, + config: .init( + font: .system(size: 150, weight: .bold), + frame: .init(width: 250, height: 200), + radius: 30, + foregroundColor: .white)) + + + TextContents(size: size) + + Spacer(minLength: 0) + + IndicatorView() + + ContinueButton() + + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .top) { + HeaderView() + } + // MARK: - 제스쳐 관련 + .gesture( + DragGesture() + .onChanged { value in + dragOffset = value.translation.width + } + .onEnded { value in + if value.translation.width < -50 { + // 스와이프 왼쪽 → 다음 페이지로 이동 + activePage = activePage.nextPage + } else if value.translation.width > 50 { + // 스와이프 오른쪽 → 이전 페이지로 이동 + activePage = activePage.previousPage + } + dragOffset = 0 + } + ) + .offset(x: dragOffset) // 드래그 중인 위치 표시 + .animation(.easeInOut, value: dragOffset) // 스와이프 후 애니메이션 + .background( + Rectangle() + .fill(.black.gradient) + .ignoresSafeArea() + ) + .toolbar(.hidden, for: .navigationBar) +// .navigationDestination(isPresented: $navigateToLogin) { +// // LoginView() // Replace `LoginView()` with your actual login view +// } + } + } + + + // MARK: - HeaderView + @ViewBuilder + func HeaderView() -> some View { + HStack { + Button { + activePage = activePage.previousPage + } label: { + Image(systemName: "chevron.left") + .font(.title3) + .fontWeight(.semibold) + .contentShape(.rect) + } + .opacity(activePage != .page1 ? 1 : 0) + + Spacer(minLength: 0) + + Button("skip") { + activePage = .page4 + } + .fontWeight(.semibold) + .opacity(activePage != .page4 ? 1 : 0) + } + .foregroundStyle(.white) + .animation(.snappy(duration: 0.35, extraBounce: 0), value: activePage ) + .padding(15) + } + + @ViewBuilder + func TextContents(size: CGSize) -> some View { + VStack(spacing: 8) { + HStack(alignment: .top, spacing: 0) { + ForEach(Page.allCases, id: \.rawValue) { page in + Text(page.title) + .lineLimit(1) + .font(.title2) + .fontWeight(.semibold) + .kerning(1.1) + .frame(width: size.width) + .foregroundColor(.white) + } + } + .offset(x: -activePage.index * size.width) + .animation(.smooth(duration: 0.7, extraBounce: 0.1), value: activePage) + + HStack(alignment: .top, spacing: 0) { + ForEach(Page.allCases, id: \.rawValue) { page in + Text(page.subTitle) + .font(.callout) + .multilineTextAlignment(.center) + .foregroundColor(.gray) + .frame(width: size.width) + + } + } + .offset(x: -activePage.index * size.width) + .animation(.smooth(duration: 0.9, extraBounce: 0.1), value: activePage) + } + .padding(.top, 15) + .frame(width: size.width, alignment: .leading) + } + + // MARK: - IndicatorView + @ViewBuilder + func IndicatorView() -> some View { + HStack(spacing: 4) { + ForEach(Page.allCases, id: \.rawValue) { page in + Capsule() + .fill(.white.opacity(activePage == page ? 1 : 0.4)) + .frame(width: activePage == page ? 25 : 8, height: 8 ) + } + } + .animation(.smooth(duration: 0.5, extraBounce: 0), value: activePage) + .padding(.bottom, 12) + } + + // MARK: - Continue Button + @ViewBuilder + func ContinueButton() -> some View { + Button { + // activePage = activePage.nextPage + if activePage == .page4 { + // navigateToLogin = true // Trigger navigation to LoginView + navigator.replace(with: [.login]) + } else { + activePage = activePage.nextPage + } + } label: { + Text(activePage == .page4 ? "코드라운지 로그인" : "Continue") + .contentTransition(.identity) + .foregroundStyle(.black) + .padding(.vertical, 15) + .frame(maxWidth: activePage == .page4 ? 220 : 180) + .background(.white, in: .capsule) + } + .padding(.bottom, 15) + .animation(.smooth(duration: 0.5, extraBounce: 0), value: activePage) + /* + .onChange(of: activePage) { _, newValue in + print("Current activePage: \(newValue)") + } + */ + } +} + +//#Preview { +// IntroView() +//} diff --git a/CodeLounge/View/Intro/Model.swift b/CodeLounge/View/Intro/Model.swift new file mode 100644 index 0000000..58878e5 --- /dev/null +++ b/CodeLounge/View/Intro/Model.swift @@ -0,0 +1,93 @@ +// +// Model.swift +// CodeLounge +// +// Created by 김동현 on 1/14/25. +// + +import Foundation + +enum Page: String, CaseIterable { + case page1 = "leaf.circle.fill" + case page2 = "desktopcomputer" + case page3 = "iphone" + case page4 = "text.bubble.fill" + // case page5 = "" + + var title: String { + switch self { + case .page1: return "코드라운지에 오신 것을 환영합니다" + case .page2: return "컴퓨터 공학 기초 (CS)" + case .page3: return "모바일 개발 (iOS & Android)" + case .page4: return "코딩 팁과 모범 사례" + // case .page5: return "" + } + } + + var subTitle: String { + switch self { + case .page1: return "개발자를 위한 지식 공유와 학습의 공간에 오신 것을 환영합니다." + case .page2: return "운영체제, 네트워크, 자료구조 등 CS의 핵심 개념을 정리합니다." + case .page3: return "iOS와 Android 개발에 필요한 필수 지식과 기술을 다룹니다." + case .page4: return "효율적인 개발을 위한 유용한 팁과 트릭을 확인하세요." + // case .page5: return "" + } + } + + var description: String { + switch self { + case .page1: + return """ + 코드라운지는 개발자를 위한 지식 공유와 학습의 허브입니다. + 최신 기술, 심화된 개념, 실무 노하우까지 + 모든 것을 이곳에서 만나보세요. + """ + case .page2: + return """ + 컴퓨터 공학(CS)의 기본부터 심화까지 다룹니다. + 운영체제(OS), 네트워크, 자료구조, 데이터베이스 등 + 개발자로서 알아야 할 모든 기초 지식을 체계적으로 학습할 수 있습니다. + """ + case .page3: + return """ + 모바일 개발의 모든 것을 한 곳에서 배워보세요. + iOS(Swift, UIKit, SwiftUI)와 Android(Kotlin, Jetpack Compose) 기술을 다루며, + 실무에서 바로 사용할 수 있는 내용을 제공합니다. + """ + case .page4: + return """ + 개발 효율성을 높이고 협업을 원활하게 만드는 다양한 팁과 모범 사례를 제공합니다. + 코드 리팩토링, 디버깅, 협업 도구 활용 등 실무 중심의 정보를 확인하세요. + """ + // case .page5: return "" + } + } + + var index: CGFloat { + switch self { + case .page1: return 0 + case .page2: return 1 + case .page3: return 2 + case .page4: return 3 + // case .page5: return 4 + } + } + + // MARK: - 다음 페이지 가져오기 + var nextPage: Page { + let index = Int(self.index) + 1 + if index < Page.allCases.count { + return Page.allCases[index] + } + return self + } + + // MARK: - 이전 페이지 가져오기 + var previousPage: Page { + let index = Int(self.index) - 1 + if index >= 0 { + return Page.allCases[index] + } + return self + } +} diff --git a/CodeLounge/View/Intro/MorphingSymbolView.swift b/CodeLounge/View/Intro/MorphingSymbolView.swift new file mode 100644 index 0000000..2a61e9c --- /dev/null +++ b/CodeLounge/View/Intro/MorphingSymbolView.swift @@ -0,0 +1,116 @@ +// +// MorphingSymbolView.swift +// CodeLounge +// +// Created by 김동현 on 1/14/25. +// + +import SwiftUI + +struct Config { + var font: Font // 글꼴 스타일 + var frame: CGSize // UI 요소 크기 + var radius: CGFloat // UI 요소 둥글기 + var foregroundColor: Color // UI 요소 기본 색상 + var keyFrameDuration: CGFloat = 0.4 // 각 키프레임에서 애니메이션 지속되는 시간(초 단위) + // 심볼 변경시 적용되는 애니메이션 duration: 애니메이션 지속 시간 extraBounce: 추가 튕김 효과 + var symbolAnimation: Animation = .smooth(duration: 0.5, extraBounce: 0) +} + +// MARK: - 심볼이 부드럽게 전환되는 애니메이션 제공 +struct MorphingSymbolView: View { + var symbol: String // 표시할 심볼 + var config: Config // UI & Animation 설정 객체 + @State private var trigger: Bool = false // 애니메이션 트리거 역할 + @State private var displayingSymbol: String = "leaf.circle.fill" // 현재의 화면에 렌더링된 심볼 + @State private var nextSymbol: String = "" // 다음에 렌더링될될 심볼 + + var body: some View { + + // MARK: - Canvas 고급 그래픽 작업을 위한 드로잉 뷰 + + Canvas { ctx, size in + // MARK: - 그래픽 그리기 + // ctx: 렌더링 컨텍스트로, 그래픽 요소를 추가하거나 필터를 적용할 수 있다 + // size: 캔버스의 크기를 나타내며, 뷰의 크기와 동일하다. + // 필터 추가: 알파값과 색상조건을 기반으로 그리기 제어 + // min: 알파 값의 최소 임계값 (여기선 0.4). + // color: 필터를 적용할 색상 + // 효과: 특정 색상만 표시되고 나머지는 제거된다 + ctx.addFilter(.alphaThreshold(min: 0.4, color: config.foregroundColor)) + + // 심볼 렌더링: 제공된 심볼을 렌더링하여 캔버스에 추가 + // ctx.resolveSymbol(id: 0): symbols 블록에서 정의한 심볼(ImageView)을 가져온다 + // ctx.draw(...): 해당 심볼을 캔버스의 특정 위치에 그린다 + // CGPoint(x: size.width / 2, y: size.height / 2)는 캔버스 중앙을 나타낸다 + if let renderedImage = ctx.resolveSymbol(id: 0) { + ctx.draw(renderedImage, at: CGPoint(x: size.width/2, y: size.height/2)) + } + } symbols: { + // MARK: - 심볼 정의(재사용 가능한 렌더링 요소(심볼) 정의 + // tag(0): 심볼에 고유 ID를 부여 + ImageView() + .tag(0) + } + .frame(width: config.frame.width, height: config.frame.height) + .onChange(of: symbol) { oldValue, newValue in + trigger.toggle() + nextSymbol = newValue + } + .task { + guard displayingSymbol == "" else { return } + displayingSymbol = symbol + } + } + + // SwiftUI의 커스텀 뷰를 작성할 때, 조건부 뷰 생성이나 여러 뷰 반환을 가능하게 해주는 속성 + // 여러 개의 뷰를 선언적으로 만들 수 있다 + // ImageView()는 선언적이고 동적인 뷰 생성을 지원한다 + + /* + KeyframeAnimator + - 키프레임 기반 애니메이션을 생성 + - 애니메이션의 각 단계(키프레임)를 정의하고, 애니메이션 값을 뷰에 전달 + + initialValue + - 애니메이션의 시작 값 + - 여기서는 CGFloat.zero로, 블러 반지름이 0에서 시작합니다 + + trigger + - 애니메이션을 재생할 조건 + - trigger 상태가 변경되면 애니메이션이 실행됩니다 + + 애니메이션 동작 + radius + - 현재 애니메이션 단계에서 전달되는 값. 이 값은 키프레임에 따라 변화합니다 + keyframes + - 애니메이션의 각 단계(키프레임)를 정의 + - 첫 번째 키프레임: 블러 반지름(radius)이 config.radius로 증가 + - 두 번째 키프레임: 블러 반지름이 0으로 감소 + + */ + @ViewBuilder + func ImageView() -> some View { + KeyframeAnimator(initialValue: CGFloat.zero, trigger: trigger) { radius in + Image(systemName: displayingSymbol) + .font(config.font) + .blur(radius: radius) + .foregroundStyle(config.foregroundColor) + .frame(width: config.frame.width, height: config.frame.height) + .onChange(of: radius) { oldValue, newValue in + if newValue.rounded() == config.radius { + withAnimation(config.symbolAnimation) { + displayingSymbol = symbol + } + } + } + } keyframes: { _ in + CubicKeyframe(config.radius, duration: config.keyFrameDuration) + CubicKeyframe(0, duration: config.keyFrameDuration) + } + } +} + +#Preview { + MorphingSymbolView(symbol: "gearshape.fill", config: .init(font: .system(size: 100, weight: .bold), frame: CGSize(width: 259, height: 200), radius: 15, foregroundColor: .black)) +} diff --git a/CodeLounge/View/Login/IntroView.swift b/CodeLounge/View/Login/IntroView.swift deleted file mode 100644 index 137c0d6..0000000 --- a/CodeLounge/View/Login/IntroView.swift +++ /dev/null @@ -1,186 +0,0 @@ -// -// IntroView.swift -// CodeLounge -// -// Created by 김동현 on 1/14/25. -// - -import SwiftUI - - -struct IntroView: View { - // MARK: - View Properties - @State private var activePage: Page = .page1 - @State private var dragOffset: CGFloat = 0.0 - @State private var navigateToLogin: Bool = false - - var body: some View { - NavigationStack { - GeometryReader { - let size = $0.size - - VStack { - Spacer(minLength: 0) - - MorphingSymbolView( - symbol: activePage.rawValue, - config: .init( - font: .system(size: 150, weight: .bold), - frame: .init(width: 250, height: 200), - radius: 30, - foregroundColor: .white)) - - - TextContents(size: size) - - Spacer(minLength: 0) - - IndicatorView() - - ContinueButton() - - } - .frame(maxWidth: .infinity) - .overlay(alignment: .top) { - HeaderView() - } - // MARK: - 제스쳐 관련 - .gesture( - DragGesture() - .onChanged { value in - dragOffset = value.translation.width - } - .onEnded { value in - if value.translation.width < -50 { - // 스와이프 왼쪽 → 다음 페이지로 이동 - activePage = activePage.nextPage - } else if value.translation.width > 50 { - // 스와이프 오른쪽 → 이전 페이지로 이동 - activePage = activePage.previousPage - } - dragOffset = 0 - } - ) - .offset(x: dragOffset) // 드래그 중인 위치 표시 - .animation(.easeInOut, value: dragOffset) // 스와이프 후 애니메이션 - } - .background( - Rectangle() - .fill(.black.gradient) - .ignoresSafeArea() - ) - .navigationDestination(isPresented: $navigateToLogin) { - LoginView() // Replace `LoginView()` with your actual login view - } - } - - } - - - // MARK: - HeaderView - @ViewBuilder - func HeaderView() -> some View { - HStack { - Button { - activePage = activePage.previousPage - } label: { - Image(systemName: "chevron.left") - .font(.title3) - .fontWeight(.semibold) - .contentShape(.rect) - } - .opacity(activePage != .page1 ? 1 : 0) - - Spacer(minLength: 0) - - Button("skip") { - activePage = .page4 - } - .fontWeight(.semibold) - .opacity(activePage != .page4 ? 1 : 0) - } - .foregroundStyle(.white) - .animation(.snappy(duration: 0.35, extraBounce: 0), value: activePage ) - .padding(15) - } - - @ViewBuilder - func TextContents(size: CGSize) -> some View { - VStack(spacing: 8) { - HStack(alignment: .top, spacing: 0) { - ForEach(Page.allCases, id: \.rawValue) { page in - Text(page.title) - .lineLimit(1) - .font(.title2) - .fontWeight(.semibold) - .kerning(1.1) - .frame(width: size.width) - .foregroundColor(.white) - } - } - .offset(x: -activePage.index * size.width) - .animation(.smooth(duration: 0.7, extraBounce: 0.1), value: activePage) - - HStack(alignment: .top, spacing: 0) { - ForEach(Page.allCases, id: \.rawValue) { page in - Text(page.subTitle) - .font(.callout) - .multilineTextAlignment(.center) - .foregroundColor(.gray) - .frame(width: size.width) - - } - } - .offset(x: -activePage.index * size.width) - .animation(.smooth(duration: 0.9, extraBounce: 0.1), value: activePage) - } - .padding(.top, 15) - .frame(width: size.width, alignment: .leading) - } - - // MARK: - IndicatorView - @ViewBuilder - func IndicatorView() -> some View { - HStack(spacing: 4) { - ForEach(Page.allCases, id: \.rawValue) { page in - Capsule() - .fill(.white.opacity(activePage == page ? 1 : 0.4)) - .frame(width: activePage == page ? 25 : 8, height: 8 ) - } - } - .animation(.smooth(duration: 0.5, extraBounce: 0), value: activePage) - .padding(.bottom, 12) - } - - // MARK: - Continue Button - @ViewBuilder - func ContinueButton() -> some View { - Button { - // activePage = activePage.nextPage - if activePage == .page4 { - navigateToLogin = true // Trigger navigation to LoginView - } else { - activePage = activePage.nextPage - } - } label: { - Text(activePage == .page4 ? "코드라운지 로그인" : "Continue") - .contentTransition(.identity) - .foregroundStyle(.black) - .padding(.vertical, 15) - .frame(maxWidth: activePage == .page4 ? 220 : 180) - .background(.white, in: .capsule) - } - .padding(.bottom, 15) - .animation(.smooth(duration: 0.5, extraBounce: 0), value: activePage) - /* - .onChange(of: activePage) { _, newValue in - print("Current activePage: \(newValue)") - } - */ - } -} - -#Preview { - IntroView() -} - diff --git a/CodeLounge/View/Login/LoginView.swift b/CodeLounge/View/Login/LoginView.swift index 82b3fe3..217b6f8 100644 --- a/CodeLounge/View/Login/LoginView.swift +++ b/CodeLounge/View/Login/LoginView.swift @@ -2,168 +2,96 @@ // LoginView.swift // CodeLounge // -// Created by 김동현 on 1/16/25. +// Created by 김동현 on 4/3/26. // +import ScaleKit import SwiftUI +import TurboNavigator import AuthenticationServices struct LoginView: View { - @Environment(\.presentationMode) var presentationMode - @EnvironmentObject private var authViewModel: AuthenticationViewModel - - var body: some View { - GeometryReader { - let _ = $0.size - HeaderView() - } - .background(.black.gradient) - .navigationBarBackButtonHidden() - } - - @ViewBuilder - func HeaderView() -> some View { - @Environment(\.dismiss) var dismiss - - VStack { - Spacer() - - Image("CodeLounge") - .resizable() - .aspectRatio(contentMode: .fill) - .frame( - width: UIScreen.main.bounds.width * 0.4, - height: UIScreen.main.bounds.height * 0.4 - ) - - Spacer() - - // MARK: - 애플 로그인 버튼은 직접 커스텀할 수 없기 때문에 실제 버튼 위에 커스텀 버튼을 올려준다. - ZStack { - // MARK: - 실제 Apple 로그인 버튼 - SignInWithAppleButton { result in - authViewModel.send(action: .appleLogin(result)) - } onCompletion: { result in - authViewModel.send(action: .appleLoginCompletion(result)) - } - .frame(maxWidth: .infinity, maxHeight: 60.scaled) - .accessibilityIdentifier("appleLoginButton") // 식별자 추가 - .opacity(0) // 버튼 숨김 대신 투명도 적용 (배경처럼 동작) - - - // MARK: - 애플 커스텀 Ui - Button { - triggerAppleLoginButtonTap() - } label: { - HStack { - Image(systemName: "applelogo") - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 24.scaled, height: 24.scaled) - - - Spacer() - - Text("Apple로 계속하기") - .frame(maxWidth: .infinity, alignment: .center) - - } - .padding(.horizontal, 45.scaled) - }.buttonStyle(SocialButtonStyle(buttonType: "Apple")) - } - - // MARK: - Google 버튼 - Button { - authViewModel.send(action: .googleLogin) - } label: { - HStack { - Image("Google") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 30.scaled, height: 30.scaled) - - Spacer() - - Text("Google로 계속하기") - .frame(maxWidth: .infinity, alignment: .center) - } - .padding(.horizontal, 45.scaled) - }.buttonStyle(SocialButtonStyle(buttonType: "Google")) - - - Spacer() - .frame(height:130.scaled) - } - .padding(.horizontal, 30.scaled) + let navigator: Navigator + @EnvironmentObject private var rootViewModel: RootViewModel + + var body: some View { + ZStack { + Rectangle() + .fill(.black.gradient) + .ignoresSafeArea() + + VStack { + // MARK: - Logo + Spacer() + Image("CodeLounge") + .resizable() + .aspectRatio(contentMode: .fill) + .frame( + width: UIScreen.main.bounds.width * 0.4, + height: UIScreen.main.bounds.height * 0.4 + ) + Spacer() - } - - // MARK: - 커스텀 애플 버튼을 누르면 실제 애플 로그인 버튼을 누르도록 트리거 하는 함수 - // Apple 로그인 버튼을 찾고 동작 트리거 - func triggerAppleLoginButtonTap() { - guard let keyWindow = UIApplication.shared.connectedScenes - .compactMap({ $0 as? UIWindowScene }) - .flatMap({ $0.windows }) - .first(where: { $0.isKeyWindow }), - let appleButton = findAppleSignInButton(in: keyWindow) else { - print("Apple 로그인 버튼을 찾을 수 없습니다.") - return + // apple + ZStack { + SignInWithAppleButton { result in + rootViewModel.send(action: .appleLogin(result)) + } onCompletion: { result in + rootViewModel.send(action: .appleLoginCompletion(result)) + } + .frame(maxWidth: .infinity, maxHeight: 60.scaled) + .accessibilityIdentifier("appleLoginButton") // 식별자 추가 + .opacity(0) // 버튼 숨김 대신 투명도 적용 (배경처럼 동작) + + SocialButtonView(type: .apple) { + triggerAppleLoginButtonTap() + } } - - // 버튼 액션 강제 실행 - appleButton.sendActions(for: .touchUpInside) - } - - func findAppleSignInButton(in view: UIView) -> ASAuthorizationAppleIDButton? { - for subview in view.subviews { - if let appleButton = subview as? ASAuthorizationAppleIDButton { - return appleButton - } - if let found = findAppleSignInButton(in: subview) { - return found - } + + // google + SocialButtonView(type: .google) { + rootViewModel.send(action: .googleLogin) } - return nil - } + Spacer() + .frame(height:130.scaled) + } + .padding(.horizontal, 30.scaled) + } + } } -// 로그인 버튼 스타일 -struct SocialButtonStyle: ButtonStyle { - let buttonType: String - - func makeBody(configuration: Configuration) -> some View { - configuration.label - .font(.system(size: 16.scaled, weight: .semibold)) - .foregroundColor( - buttonType == "Google" ? Color.black : - buttonType == "Kakao" ? Color.black.opacity(0.85) : - Color.white // 애플 버튼 레이블 색상 - ) - .frame(maxWidth: .infinity, maxHeight: 60.scaled) - .background( - RoundedRectangle(cornerRadius: 10) - .fill( - buttonType == "Google" ? Color.white : - buttonType == "Kakao" ? Color("#FEE500") : - Color.black // 애플 버튼 배경색 - ) - ) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke( - buttonType == "Google" ? Color.black : - buttonType == "Kakao" ? Color("#FEE500") : - Color.white, lineWidth: 0.8 // 테두리 색상 - ) - ) - .opacity(configuration.isPressed ? 0.5 : 1) - //.padding(.horizontal, 15) - .shadow(color: .black.opacity(0.1), radius: 5, x: 0, y: 2) - .contentShape(RoundedRectangle(cornerRadius: 10)) - } +private extension LoginView { + // MARK: - 커스텀 애플 버튼을 누르면 실제 애플 로그인 버튼을 누르도록 트리거 하는 함수 + // Apple 로그인 버튼을 찾고 동작 트리거 + func triggerAppleLoginButtonTap() { + guard let keyWindow = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .flatMap({ $0.windows }) + .first(where: { $0.isKeyWindow }), + let appleButton = findAppleSignInButton(in: keyWindow) else { + print("Apple 로그인 버튼을 찾을 수 없습니다.") + return + } + + // 버튼 액션 강제 실행 + appleButton.sendActions(for: .touchUpInside) + } + + func findAppleSignInButton(in view: UIView) -> ASAuthorizationAppleIDButton? { + for subview in view.subviews { + if let appleButton = subview as? ASAuthorizationAppleIDButton { + return appleButton + } + if let found = findAppleSignInButton(in: subview) { + return found + } + } + return nil + } } #Preview { - LoginView() + LoginView(navigator: .preview) + .environmentObject(RootViewModel()) } diff --git a/CodeLounge/View/Login/Model.swift b/CodeLounge/View/Login/Model.swift deleted file mode 100644 index 5041ed7..0000000 --- a/CodeLounge/View/Login/Model.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// Model.swift -// CodeLounge -// -// Created by 김동현 on 1/14/25. -// - -import Foundation - -enum Page: String, CaseIterable { - case page1 = "leaf.circle.fill" - case page2 = "desktopcomputer" - case page3 = "iphone" - case page4 = "text.bubble.fill" - // case page5 = "" - - var title: String { - switch self { - case .page1: return "코드라운지에 오신 것을 환영합니다" - case .page2: return "컴퓨터 공학 기초 (CS)" - case .page3: return "모바일 개발 (iOS & Android)" - case .page4: return "코딩 팁과 모범 사례" - // case .page5: return "" - } - } - - var subTitle: String { - switch self { - case .page1: return "개발자를 위한 지식 공유와 학습의 공간에 오신 것을 환영합니다." - case .page2: return "운영체제, 네트워크, 자료구조 등 CS의 핵심 개념을 정리합니다." - case .page3: return "iOS와 Android 개발에 필요한 필수 지식과 기술을 다룹니다." - case .page4: return "효율적인 개발을 위한 유용한 팁과 트릭을 확인하세요." - // case .page5: return "" - } - } - - var description: String { - switch self { - case .page1: - return """ - 코드라운지는 개발자를 위한 지식 공유와 학습의 허브입니다. - 최신 기술, 심화된 개념, 실무 노하우까지 - 모든 것을 이곳에서 만나보세요. - """ - case .page2: - return """ - 컴퓨터 공학(CS)의 기본부터 심화까지 다룹니다. - 운영체제(OS), 네트워크, 자료구조, 데이터베이스 등 - 개발자로서 알아야 할 모든 기초 지식을 체계적으로 학습할 수 있습니다. - """ - case .page3: - return """ - 모바일 개발의 모든 것을 한 곳에서 배워보세요. - iOS(Swift, UIKit, SwiftUI)와 Android(Kotlin, Jetpack Compose) 기술을 다루며, - 실무에서 바로 사용할 수 있는 내용을 제공합니다. - """ - case .page4: - return """ - 개발 효율성을 높이고 협업을 원활하게 만드는 다양한 팁과 모범 사례를 제공합니다. - 코드 리팩토링, 디버깅, 협업 도구 활용 등 실무 중심의 정보를 확인하세요. - """ - // case .page5: return "" - } - } - - var index: CGFloat { - switch self { - case .page1: return 0 - case .page2: return 1 - case .page3: return 2 - case .page4: return 3 - // case .page5: return 4 - } - } - - // MARK: - 다음 페이지 가져오기 - var nextPage: Page { - let index = Int(self.index) + 1 - if index < Page.allCases.count { - return Page.allCases[index] - } - return self - } - - // MARK: - 이전 페이지 가져오기 - var previousPage: Page { - let index = Int(self.index) - 1 - if index >= 0 { - return Page.allCases[index] - } - return self - } -} diff --git a/CodeLounge/View/Login/MorphingSymbolView.swift b/CodeLounge/View/Login/MorphingSymbolView.swift deleted file mode 100644 index 6116f6a..0000000 --- a/CodeLounge/View/Login/MorphingSymbolView.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// MorphingSymbolView.swift -// CodeLounge -// -// Created by 김동현 on 1/14/25. -// - -import SwiftUI - -struct Config { - var font: Font // 글꼴 스타일 - var frame: CGSize // UI 요소 크기 - var radius: CGFloat // UI 요소 둥글기 - var foregroundColor: Color // UI 요소 기본 색상 - var keyFrameDuration: CGFloat = 0.4 // 각 키프레임에서 애니메이션 지속되는 시간(초 단위) - // 심볼 변경시 적용되는 애니메이션 duration: 애니메이션 지속 시간 extraBounce: 추가 튕김 효과 - var symbolAnimation: Animation = .smooth(duration: 0.5, extraBounce: 0) -} - -// MARK: - 심볼이 부드럽게 전환되는 애니메이션 제공 -struct MorphingSymbolView: View { - var symbol: String // 표시할 심볼 - var config: Config // UI & Animation 설정 객체 - @State private var trigger: Bool = false // 애니메이션 트리거 역할 - @State private var displayingSymbol: String = "leaf.circle.fill" // 현재의 화면에 렌더링된 심볼 - @State private var nextSymbol: String = "" // 다음에 렌더링될될 심볼 - - var body: some View { - - // MARK: - Canvas 고급 그래픽 작업을 위한 드로잉 뷰 - - Canvas { ctx, size in - // MARK: - 그래픽 그리기 - // ctx: 렌더링 컨텍스트로, 그래픽 요소를 추가하거나 필터를 적용할 수 있다 - // size: 캔버스의 크기를 나타내며, 뷰의 크기와 동일하다. - // 필터 추가: 알파값과 색상조건을 기반으로 그리기 제어 - // min: 알파 값의 최소 임계값 (여기선 0.4). - // color: 필터를 적용할 색상 - // 효과: 특정 색상만 표시되고 나머지는 제거된다 - ctx.addFilter(.alphaThreshold(min: 0.4, color: config.foregroundColor)) - - // 심볼 렌더링: 제공된 심볼을 렌더링하여 캔버스에 추가 - // ctx.resolveSymbol(id: 0): symbols 블록에서 정의한 심볼(ImageView)을 가져온다 - // ctx.draw(...): 해당 심볼을 캔버스의 특정 위치에 그린다 - // CGPoint(x: size.width / 2, y: size.height / 2)는 캔버스 중앙을 나타낸다 - if let renderedImage = ctx.resolveSymbol(id: 0) { - ctx.draw(renderedImage, at: CGPoint(x: size.width/2, y: size.height/2)) - } - } symbols: { - // MARK: - 심볼 정의(재사용 가능한 렌더링 요소(심볼) 정의 - // tag(0): 심볼에 고유 ID를 부여 - ImageView() - .tag(0) - } - .frame(width: config.frame.width, height: config.frame.height) - .onChange(of: symbol) { oldValue, newValue in - trigger.toggle() - nextSymbol = newValue - } - .task { - guard displayingSymbol == "" else { return } - displayingSymbol = symbol - } - } - - // SwiftUI의 커스텀 뷰를 작성할 때, 조건부 뷰 생성이나 여러 뷰 반환을 가능하게 해주는 속성 - // 여러 개의 뷰를 선언적으로 만들 수 있다 - // ImageView()는 선언적이고 동적인 뷰 생성을 지원한다 - - /* - KeyframeAnimator - - 키프레임 기반 애니메이션을 생성 - - 애니메이션의 각 단계(키프레임)를 정의하고, 애니메이션 값을 뷰에 전달 - - initialValue - - 애니메이션의 시작 값 - - 여기서는 CGFloat.zero로, 블러 반지름이 0에서 시작합니다 - - trigger - - 애니메이션을 재생할 조건 - - trigger 상태가 변경되면 애니메이션이 실행됩니다 - - 애니메이션 동작 - radius - - 현재 애니메이션 단계에서 전달되는 값. 이 값은 키프레임에 따라 변화합니다 - keyframes - - 애니메이션의 각 단계(키프레임)를 정의 - - 첫 번째 키프레임: 블러 반지름(radius)이 config.radius로 증가 - - 두 번째 키프레임: 블러 반지름이 0으로 감소 - - */ - @ViewBuilder - func ImageView() -> some View { - KeyframeAnimator(initialValue: CGFloat.zero, trigger: trigger) { radius in - Image(systemName: displayingSymbol) - .font(config.font) - .blur(radius: radius) - .foregroundStyle(config.foregroundColor) - .frame(width: config.frame.width, height: config.frame.height) - .onChange(of: radius) { oldValue, newValue in - if newValue.rounded() == config.radius { - withAnimation(config.symbolAnimation) { - displayingSymbol = symbol - } - } - } - } keyframes: { _ in - CubicKeyframe(config.radius, duration: config.keyFrameDuration) - CubicKeyframe(0, duration: config.keyFrameDuration) - } - } -} - -#Preview { - MorphingSymbolView(symbol: "gearshape.fill", config: .init(font: .system(size: 100, weight: .bold), frame: CGSize(width: 259, height: 200), radius: 15, foregroundColor: .black)) -} diff --git a/CodeLounge/View/Login/RegisterView.swift b/CodeLounge/View/Login/RegisterView.swift new file mode 100644 index 0000000..c4594a2 --- /dev/null +++ b/CodeLounge/View/Login/RegisterView.swift @@ -0,0 +1,239 @@ +// +// RegisterView.swift +// CodeLounge +// +// Created by 김동현 on 4/3/26. +// + +import SwiftUI +import TurboNavigator +import Combine + +struct RegisterView: View { + let navigator: Navigator + @EnvironmentObject private var rootViewModel: RootViewModel + + @State private var nickname: String = "" // 닉네임입력 + @State private var slideOffset: CGFloat = UIScreen.main.bounds.width // 화면 너비만큼 오프셋 시작 + @State private var birthdate: Date = Date() // 기본값: 2000년 1월 1일 + @State private var isDatePickerActive: Bool = false // 생일입력 + @State private var selectedGender: Gender = .male // 성별입력 + @State private var isKeyboardVisible = false + + // 닉네임이 유효한지 검사하는 프로퍼티 + private var isNicknameValid: Bool { + !nickname.trimmingCharacters(in: .whitespaces).isEmpty + } + + // 생년월일 날짜 포맷터 + private var dateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy. MM. dd" + return formatter + } + + // 전송용 생년월일 날짜 포맷터 + private var isoDateFormatter: ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.timeZone = TimeZone(identifier: "Asia/Seoul") // KST 설정 + return formatter + } + + var body: some View { + VStack(spacing: 10) { + Text("CodeLounge") + .font(.system(size: 30, weight: .bold)) + .padding(.bottom, 10) + .foregroundColor(Color.mainWhite) + + Text("회원가입에 필요한 정보를 입력해주세요") + .font(.system(size: 22, weight: .bold)) + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundColor(Color.mainWhite) + + Spacer() + .frame(height: 10) + + Text("닉네임") + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundColor(Color.mainWhite) + + TextField("2자 이상 20자 이하로 입력해주세요", text: $nickname) + .padding() + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(lineWidth: 1.5) + .foregroundColor(Color.mainWhite) + ) + + + if let message = rootViewModel.nicknameValidationMessage { + Text(message) + .foregroundColor(.red) + .font(.caption) + } + + Spacer() + .frame(height: 10) + + Text("생년월일") + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundColor(Color.mainWhite) + + Button { + isDatePickerActive.toggle() + } label: { + Text("\(dateFormatter.string(from: birthdate))") + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundColor(Color.mainWhite) + .padding() + .frame(maxWidth: .infinity) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(lineWidth: 1.5) + .foregroundColor(Color.mainWhite) + ) + } + + Spacer() + .frame(height: 10) + + Text("성별") + .frame(maxWidth: .infinity, alignment: .leading) + + HStack { + GenderButton(gender: .male, isSelected: $selectedGender) + GenderButton(gender: .female, isSelected: $selectedGender) + GenderButton(gender: .other, isSelected: $selectedGender) + } + + Spacer() + + Button { + if isNicknameValid { + rootViewModel.send(action: .checkNicknameDuplicate(nickname) { isDuplicate in + if !isDuplicate { + let birthdayString = isoDateFormatter.string(from: birthdate) + let genderString = selectedGender.rawValue + rootViewModel.send(action: .updateUserInfo(nickname, birthdayString, genderString)) + } + }) + } + } label: { + Text("완료") + .padding() + .frame(maxWidth: .infinity) + .foregroundColor(Color.mainBlack) + .background(!nickname.isEmpty ? Color.mainWhite : Color.gray) + .cornerRadius(20) + } + .disabled(nickname.isEmpty) + .padding(.bottom, isKeyboardVisible ? 16 : 0) + + + } + .padding(.horizontal, 25) + .offset(x: slideOffset) // x축 오프셋 적용 + .onAppear { + slideOffset = 0 // 오프셋을 0으로 만들어 화면 중앙으로 이동 + rootViewModel.nicknameValidationMessage = nil + } + .onChange(of: nickname) { _, _ in + rootViewModel.nicknameValidationMessage = nil + } + .sheet(isPresented: $isDatePickerActive) { + BirthdayPickerView(birthdate: $birthdate) + .presentationDetents([.fraction(0.5)]) + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in + withAnimation(.easeOut(duration: 0.2)) { + isKeyboardVisible = true + } + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in + withAnimation(.easeOut(duration: 0.2)) { + isKeyboardVisible = false + } + } + .background(.black.gradient) + } +} + +#Preview { + RegisterView(navigator: .preview) + .environmentObject(RootViewModel()) +} + +// MARK: - 생년월일 선택 +private struct BirthdayPickerView: View { + @Binding var birthdate: Date + @Environment(\.dismiss) private var dismiss // sheet 닫기 + + fileprivate var body: some View { + VStack { + Text("생년월일 선택") + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 50) + .font(.system(size: 20, weight: .bold)) + + DatePicker( + "생년얼일", + selection: $birthdate, + in: ...Date(), // 현재 날짜까지 선택 가능 + displayedComponents: .date + ) + .datePickerStyle(.wheel) + .labelsHidden() + .padding() + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(.black, lineWidth: 2) + ) + .environment(\.locale, Locale(identifier: "ko_KR")) + + Spacer() + .frame(height: 30) + + Button { + dismiss() + } label: { + Text("완료") + .padding() + .frame(maxWidth: .infinity) + .foregroundColor(Color.mainBlack) + .background(Color.mainWhite) + .cornerRadius(20) + } + .padding(.horizontal, 40) + + } + } +} + +// MARK: - 성별 버튼 +private struct GenderButton: View { + + // 성별 + var gender: Gender + + // 선택유무 + @Binding var isSelected: Gender + + fileprivate var body: some View { + Button { + isSelected = gender + } label: { + Text(gender.rawValue) + .foregroundColor(isSelected == gender ? Color.mainBlack : Color.mainWhite) + .padding() + .frame(maxWidth: .infinity) + .background(isSelected == gender ? Color.mainWhite : Color.mainBlack) // 선택 여부에 따라 배경색 변경 + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(lineWidth: 1.5) + .foregroundColor(Color.mainWhite) + ) + } +} diff --git a/CodeLounge/View/MainTab/AOSView.swift b/CodeLounge/View/MainTab/AOSView.swift new file mode 100644 index 0000000..4b87b84 --- /dev/null +++ b/CodeLounge/View/MainTab/AOSView.swift @@ -0,0 +1,23 @@ +// +// AOSView.swift +// CodeLounge +// +// Created by 김동현 on 4/3/26. +// + +import SwiftUI +import TurboNavigator + +struct AOSView: View { + let navigator: Navigator + + private let categories = ["Kotlin", "Jetpack Compose UI"] + + var body: some View { + BoardView(title: "AOS", categories: categories, navigator: navigator) + } +} + +#Preview { + AOSView(navigator: .preview) +} diff --git a/CodeLounge/View/MainTab/BannerAdView.swift b/CodeLounge/View/MainTab/BannerAdView.swift new file mode 100644 index 0000000..ff5f2f1 --- /dev/null +++ b/CodeLounge/View/MainTab/BannerAdView.swift @@ -0,0 +1,44 @@ +// +// BannerAdView.swift +// CodeLounge +// +// Created by 김동현 on 5/14/25. +// + +import SwiftUI +import UIKit +import GoogleMobileAds + +struct BannerAdView: View { + let adUnitID: String + + var body: some View { + BannerAdRepresentable(adUnitID: adUnitID) + .frame(height: 50) + } +} + +struct BannerAdRepresentable: UIViewRepresentable { + let adUnitID: String + + func makeUIView(context: Context) -> BannerView { + let bannerView = BannerView(adSize: AdSizeBanner) + bannerView.adUnitID = adUnitID + bannerView.rootViewController = findRootViewController() + bannerView.load(Request()) + return bannerView + } + + func updateUIView(_ uiView: BannerView, context: Context) {} + + private func findRootViewController() -> UIViewController? { + guard + let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = scene.windows.first?.rootViewController + else { + return nil + } + + return rootViewController + } +} diff --git a/CodeLounge/View/MainTab/BoardView.swift b/CodeLounge/View/MainTab/BoardView.swift new file mode 100644 index 0000000..94277c3 --- /dev/null +++ b/CodeLounge/View/MainTab/BoardView.swift @@ -0,0 +1,160 @@ +// +// BoardView.swift +// CodeLounge +// +// Created by 김동현 on 4/4/26. +// + +import SwiftUI +import TurboNavigator + +struct BoardView: View { + let title: String + let categories: [String] + let navigator: Navigator + + @EnvironmentObject private var postViewModel: PostViewModel + + private var filteredPostsByCategory: [String: [Post]] { + postViewModel.filteredPosts(for: categories) + } + + var body: some View { + ZStack { + Color.mainBlack.ignoresSafeArea() + + VStack(spacing: 0) { + BoardHeaderView(title: title, searchText: $postViewModel.searchText) + + BoardPostListView( + categories: categories, + postsByCategory: filteredPostsByCategory, + categoryNames: postViewModel.categoryNames + ) { post in + navigator.push(.postDetail(post)) + } + .simultaneousGesture( + TapGesture().onEnded { + CustomTextField.hideKeyboard() + } + ) + } + } + .tint(Color.mainWhite) + .onAppear { + postViewModel.searchText = "" + } + .refreshable { + postViewModel.fetchAllPosts() + postViewModel.searchText = "" + } + } +} + +struct BoardHeaderView: View { + let title: String + @Binding var searchText: String + + var body: some View { + HStack { + Text(title) + .font(.system(size: 35, weight: .bold)) + .padding(.leading, 20) + + Spacer() + + CustomTextField(text: $searchText, placeholder: "검색") + .frame(width: 200, height: 40) + .padding(.horizontal) + } + .padding(.top, 30) + .padding(.vertical, 10) + .background(Color.mainBlack) + } +} + +struct BoardPostListView: View { + let categories: [String] + let postsByCategory: [String: [Post]] + let categoryNames: [String: String] + let onSelect: (Post) -> Void + + var body: some View { + List { + ForEach(categories, id: \.self) { category in + if let posts = postsByCategory[category], !posts.isEmpty { + PostSectionView( + posts: posts, + categoryName: categoryNames[category] ?? category, + onSelect: onSelect + ) + } + } + } + .scrollContentBackground(.hidden) + .background(Color.clear) + .scrollIndicators(.hidden) + } +} + +struct PostSectionView: View { + let posts: [Post] + let categoryName: String + let onSelect: (Post) -> Void + + var body: some View { + Section( + header: Text(categoryName) + .foregroundColor(Color.mainGreen) + .font(.system(size: 17, weight: .bold)) + .padding(.leading, -10) + .textCase(.none) + ) { + ForEach(posts) { post in + PostRowView(post: post) { + onSelect(post) + } + } + } + } +} + +struct PostRowView: View { + let post: Post + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack { + Text(post.title) + .font(.headline) + .foregroundStyle(.white) + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 15)) + .foregroundColor(.gray) + } + } + .buttonStyle(ListRowButton()) + .listRowBackground(Color.subBlack) + .listRowSeparatorTint(Color.gray.opacity(0.4), edges: .bottom) + } +} + +struct ListRowButton: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration + .label + .frame(maxWidth: .greatestFiniteMagnitude, alignment: .leading) + .contentShape(.rect) + .background { + if configuration.isPressed { + Rectangle() + .fill(Color.mainGreen) + .padding(-20) + } + } + } +} diff --git a/CodeLounge/View/MainTab/CSView.swift b/CodeLounge/View/MainTab/CSView.swift new file mode 100644 index 0000000..ecc0a47 --- /dev/null +++ b/CodeLounge/View/MainTab/CSView.swift @@ -0,0 +1,23 @@ +// +// CSView.swift +// CodeLounge +// +// Created by 김동현 on 4/3/26. +// + +import SwiftUI +import TurboNavigator + +struct CSView: View { + let navigator: Navigator + + private let categories = ["OperatingSystems", "Algorithms"] + + var body: some View { + BoardView(title: "CS", categories: categories, navigator: navigator) + } +} + +#Preview { + CSView(navigator: .preview) +} diff --git a/CodeLounge/View/MainTab/MarkdownParser.swift b/CodeLounge/View/MainTab/MarkdownParser.swift new file mode 100644 index 0000000..b6c698e --- /dev/null +++ b/CodeLounge/View/MainTab/MarkdownParser.swift @@ -0,0 +1,360 @@ +// +// MarkdownParser.swift +// CodeLounge +// +// Created by 김동현 on 5/10/25. +// + +import Foundation +import SwiftUI + +enum MarkdownNode: Equatable { + case heading(level: Int, text: String) + case listItem(text: [InlineNode]) + case paragraph(inlines: [InlineNode]) + case code(language: String, content: String) + case lineBreak +} + +enum InlineNode: Equatable { + case text(String) + case bold([InlineNode]) + case underline([InlineNode]) +} + +final class MarkdownParser { + private let lines: [String] + private var currentIndex = 0 + + init(markdown: String) { + let normalized = markdown.replacingOccurrences(of: "\\n", with: "\n") + self.lines = normalized.components(separatedBy: .newlines) + } + + func parseDocument() -> [MarkdownNode] { + var nodes: [MarkdownNode] = [] + + while !isAtEnd() { + if let node = parseBlock() { + nodes.append(node) + } else { + advance() + } + } + + return nodes + } + + private func parseBlock() -> MarkdownNode? { + let line = currentLine() + + if line.trimmingCharacters(in: .whitespaces).isEmpty { + advance() + return .lineBreak + } + + if let heading = parseHeading() { + return heading + } + + if let code = parseCodeBlock() { + return code + } + + if let list = parseListItem() { + return list + } + + return parseParagraph() + } + + private func parseHeading() -> MarkdownNode? { + let trimmed = currentLine().trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("##"), trimmed.hasSuffix("##"), trimmed.count > 4 { + return nil + } + + guard let _ = currentLine().range(of: #"^#{1,6} "#, options: .regularExpression) else { + return nil + } + + let level = currentLine().prefix(while: { $0 == "#" }).count + let text = String(currentLine().dropFirst(level + 1)) + advance() + return .heading(level: level, text: text) + } + + private func parseListItem() -> MarkdownNode? { + let line = currentLine() + guard line.hasPrefix("- ") else { return nil } + + let content = String(line.dropFirst(2)) + advance() + return .listItem(text: InlineParser(content).parse()) + } + + private func parseParagraph() -> MarkdownNode? { + let line = currentLine() + guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return nil } + + advance() + return .paragraph(inlines: InlineParser(line).parse()) + } + + private func parseCodeBlock() -> MarkdownNode? { + let line = currentLine() + guard line.hasPrefix("```") else { return nil } + + let language = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces) + advance() + + var codeLines: [String] = [] + while !isAtEnd(), !currentLine().hasPrefix("```") { + codeLines.append(currentLine()) + advance() + } + + if !isAtEnd() { + advance() + } + + return .code( + language: language.isEmpty ? "code" : language, + content: codeLines.joined(separator: "\n") + ) + } + + private func currentLine() -> String { + guard !isAtEnd() else { return "" } + return lines[currentIndex] + } + + private func advance() { + currentIndex += 1 + } + + private func isAtEnd() -> Bool { + currentIndex >= lines.count + } +} + +final class InlineParser { + private let input: String + private var index: String.Index + + init(_ input: String) { + self.input = input + self.index = input.startIndex + } + + func parse() -> [InlineNode] { + parseUntil(nil) + } + + private func parseUntil(_ delimiter: String?) -> [InlineNode] { + var result: [InlineNode] = [] + var buffer = "" + + func flush() { + if !buffer.isEmpty { + result.append(.text(buffer)) + buffer = "" + } + } + + while !isAtEnd() { + if let delimiter, peek(delimiter) { + advance(delimiter.count) + flush() + return result + } + + if match("**") { + flush() + result.append(.bold(parseUntil("**"))) + } else if match("##") { + flush() + result.append(.underline(parseUntil("##"))) + } else { + buffer.append(current()) + advance(1) + } + } + + flush() + return result + } + + private func current() -> Character { + input[index] + } + + private func peek(_ string: String) -> Bool { + input[index...].hasPrefix(string) + } + + private func advance(_ count: Int) { + index = input.index(index, offsetBy: count) + } + + private func isAtEnd() -> Bool { + index >= input.endIndex + } + + private func match(_ string: String) -> Bool { + guard peek(string) else { return false } + advance(string.count) + return true + } +} + +struct MarkdownRenderer: View { + let nodes: [MarkdownNode] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + ForEach(Array(nodes.enumerated()), id: \.offset) { _, node in + MarkdownNodeView(node: node) + } + } + } +} + +private struct MarkdownNodeView: View { + let node: MarkdownNode + + var body: some View { + switch node { + case .heading(let level, let text): + Text(text) + .font(.system(size: headingSize(for: level), weight: .bold)) + .foregroundStyle(Color.mainGreen) + .padding(.top, 4) + + case .listItem(let inlines): + HStack(alignment: .top, spacing: 8) { + Text("•") + .foregroundStyle(Color.mainWhite) + MarkdownInlineText(inlines: inlines) + } + + case .paragraph(let inlines): + MarkdownInlineText(inlines: inlines) + + case .code(let language, let content): + VStack(alignment: .leading, spacing: 6) { + Text("\(language.uppercased()) CODE") + .font(.caption) + .foregroundStyle(.gray) + + ScrollView(.horizontal, showsIndicators: false) { + Text(content) + .font(.system(size: 13, design: .monospaced)) + .foregroundStyle(Color.mainWhite) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + } + .background(Color.subBlack) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .padding(.vertical, 2) + + case .lineBreak: + Color.clear + .frame(height: 8) + } + } + + private func headingSize(for level: Int) -> CGFloat { + max(18, 26 - CGFloat(level - 1) * 2) + } +} + +private struct MarkdownInlineText: View { + let inlines: [InlineNode] + var fontSize: CGFloat = 15 + var color: Color = .mainWhite + + var body: some View { + renderInline(inlines) + .font(.system(size: fontSize)) + .lineSpacing(8) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func renderInline(_ nodes: [InlineNode], fontSize: CGFloat? = nil, color: Color? = nil) -> Text { + let resolvedFontSize = fontSize ?? self.fontSize + let resolvedColor = color ?? self.color + + return nodes.reduce(Text("")) { partial, node in + partial + text(for: node, fontSize: resolvedFontSize, color: resolvedColor) + } + } + + private func text(for node: InlineNode, fontSize: CGFloat, color: Color) -> Text { + switch node { + case .text(let string): + return Text(string) + .font(.system(size: fontSize)) + .foregroundColor(color) + + case .bold(let children): + return renderInline(children, fontSize: 20, color: .mainGreen).bold() + + case .underline(let children): + return renderInline(children, fontSize: fontSize, color: .mainGreen).underline() + } + } +} + +struct MarkdownView: View { + private let nodes: [MarkdownNode] + + init(markdown: String, hiddenTitle: String? = nil) { + let parsedNodes = MarkdownParser(markdown: markdown).parseDocument() + self.nodes = MarkdownView.removeLeadingDuplicateTitle(from: parsedNodes, hiddenTitle: hiddenTitle) + } + + var body: some View { + MarkdownRenderer(nodes: nodes) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + + private static func removeLeadingDuplicateTitle( + from nodes: [MarkdownNode], + hiddenTitle: String? + ) -> [MarkdownNode] { + guard let hiddenTitle = hiddenTitle?.trimmingCharacters(in: .whitespacesAndNewlines), + !hiddenTitle.isEmpty else { + return nodes + } + + guard let firstMeaningfulIndex = nodes.firstIndex(where: { node in + switch node { + case .lineBreak: + return false + default: + return true + } + }) else { + return nodes + } + + guard case .heading(_, let text) = nodes[firstMeaningfulIndex], + text.trimmingCharacters(in: .whitespacesAndNewlines) + .localizedCaseInsensitiveCompare(hiddenTitle) == .orderedSame else { + return nodes + } + + var filteredNodes = nodes + filteredNodes.remove(at: firstMeaningfulIndex) + + if firstMeaningfulIndex < filteredNodes.count, + case .lineBreak = filteredNodes[firstMeaningfulIndex] { + filteredNodes.remove(at: firstMeaningfulIndex) + } + + return filteredNodes + } +} diff --git a/CodeLounge/View/MainTab/PostDetailView.swift b/CodeLounge/View/MainTab/PostDetailView.swift new file mode 100644 index 0000000..5691a6f --- /dev/null +++ b/CodeLounge/View/MainTab/PostDetailView.swift @@ -0,0 +1,59 @@ +// +// PostDetailView.swift +// CodeLounge +// +// Created by 김동현 on 4/4/26. +// + +import SwiftUI +import TurboNavigator + +struct PostDetailView: View { + let navigator: Navigator + let post: Post + + var body: some View { + VStack(spacing: 0) { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 20) { + MarkdownView(markdown: post.content) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(24) + } + + BannerAdView(adUnitID: AdUnitID.postDetailBanner) + } + .background(Color.mainBlack.ignoresSafeArea()) + .navigationBarTitleDisplayMode(.inline) + // .navigationTitle(post.title) + .tint(Color.mainWhite) + } +} + +#Preview { + PostDetailView( + navigator: .preview, + post: Post( + id: "preview", + title: "샘플 게시글", + content: """ + ## Q. Swift의 struct와 class의 주요 차이점은 무엇인가요? + + **1. 값 타입 vs 참조 타입** + struct는 값을 복사하지만, class는 참조를 전달합니다. + + - **상속** + class는 상속이 가능하지만, struct는 불가능합니다. + + ```swift + struct Person { + var name: String + } + ``` + """, + authorID: "author", + createdAt: Date() + ) + ) +} diff --git a/CodeLounge/View/MainTab/PostViewModel.swift b/CodeLounge/View/MainTab/PostViewModel.swift new file mode 100644 index 0000000..85d72c5 --- /dev/null +++ b/CodeLounge/View/MainTab/PostViewModel.swift @@ -0,0 +1,69 @@ +// +// PostViewModel.swift +// CodeLounge +// +// Created by 김동현 on 4/3/26. +// + +import Combine +import Foundation +import FirebaseDatabase + +final class PostViewModel: ObservableObject { + @Published var postsByCategory: [String: [Post]] = [:] /// 전체 카테고리별 posts 저장 + @Published var searchText: String = "" /// 검색어 + @Published private var debouncedSearchText: String = "" + + @Dependency private var postService: PostServiceProtocol + private var cancellables = Set() + + let categoryNames: [String: String] = [ + "OperatingSystems": "운영체제", + "Algorithms": "알고리즘", + "Swift": "Swift", + "UIKit": "UIKit", + "SwiftUI": "SwiftUI", + "Kotlin": "Kotlin", + "JetpackCompose": "Jetpack Compose" + ] + + init() { + $searchText + .debounce(for: .milliseconds(250), scheduler: DispatchQueue.main) + .removeDuplicates() + .assign(to: &$debouncedSearchText) + } + + // MARK: - 전체 Posts 가져오기 + func fetchAllPosts() { + postService.fetchAllPosts() + .receive(on: DispatchQueue.main) + .sink { completion in + if case .failure(let error) = completion { + print("Error: \(error)") + } + } receiveValue: { [weak self] posts in + self?.postsByCategory = posts + // print("결과: \(self?.postsByCategory ?? [:])") + }.store(in: &cancellables) + } + + // MARK: - 특정 카테고리와 검색어를 기준으로 필터링 + func filteredPosts(for categories: [String]) -> [String: [Post]] { + let lowercasedSearchText = debouncedSearchText.lowercased() + let categoryFilteredPosts = postsByCategory.filter { categories.contains($0.key) } + + if debouncedSearchText.isEmpty { + return categoryFilteredPosts + } + + return categoryFilteredPosts + .mapValues { posts in + posts.filter { + $0.title.lowercased().contains(lowercasedSearchText) || + $0.content.lowercased().contains(lowercasedSearchText) + } + } + .filter { !$0.value.isEmpty } + } +} diff --git a/CodeLounge/View/MainTab/Profile/ProfileSettingView.swift b/CodeLounge/View/MainTab/Profile/ProfileSettingView.swift new file mode 100644 index 0000000..fb12538 --- /dev/null +++ b/CodeLounge/View/MainTab/Profile/ProfileSettingView.swift @@ -0,0 +1,276 @@ +// +// ProfileSettingView.swift +// CodeLounge +// +// Created by 김동현 on 4/9/26. +// + +import SwiftUI + +struct ProfileSettingView: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject var rootViewModel: RootViewModel + + @State private var nickname: String = "" + @State private var birthdate: Date = Date() + @State private var isDatePickerActive = false + @State private var selectedGender: Gender = .male + + private var isNicknameValid: Bool { + !nickname.trimmingCharacters(in: .whitespaces).isEmpty + } + + private var dateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy. MM. dd" + return formatter + } + + private var isoDateFormatter: ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.timeZone = TimeZone(identifier: "Asia/Seoul") + return formatter + } + + var body: some View { + VStack(spacing: 12) { + Text("프로필 수정") + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(Color.mainWhite) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 8) + + profileFieldTitle("닉네임") + + TextField("2자 이상 20자 이하로 입력해주세요", text: $nickname) + .padding() + .foregroundStyle(Color.mainWhite) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.mainWhite, lineWidth: 1.5) + ) + + if let nicknameMessage = rootViewModel.nicknameValidationMessage { + Text(nicknameMessage) + .font(.caption) + .foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .leading) + } + + profileFieldTitle("생년월일") + + Button { + isDatePickerActive = true + } label: { + Text(dateFormatter.string(from: birthdate)) + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundStyle(Color.mainWhite) + .padding() + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.mainWhite, lineWidth: 1.5) + ) + } + + profileFieldTitle("성별") + + HStack { + ProfileGenderButton(gender: .male, isSelected: $selectedGender) + ProfileGenderButton(gender: .female, isSelected: $selectedGender) + ProfileGenderButton(gender: .other, isSelected: $selectedGender) + } + + Spacer() + } + .padding(.horizontal, 24) + .padding(.top, 24) + .safeAreaInset(edge: .bottom) { + Button { + submit() + } label: { + Text("완료") + .padding() + .frame(maxWidth: .infinity) + .foregroundStyle(Color.mainBlack) + .background(isNicknameValid ? Color.mainWhite : Color.gray) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + .disabled(!isNicknameValid) + .padding(.horizontal, 24) + .padding(.top, 12) + .padding(.bottom, 20) + .background(Color.mainBlack) + } + .background(Color.mainBlack.ignoresSafeArea()) + .sheet(isPresented: $isDatePickerActive) { + ProfileBirthdayPickerView(birthdate: $birthdate) + .presentationDetents([.fraction(0.5)]) + } + // 커스텀 네비게이션바 사용 + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Color.mainGreen) + .frame(width: 44, height: 44, alignment: .center) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .toolbar(.hidden, for: .tabBar) + .onAppear { + setTabBarHidden(true) + rootViewModel.nicknameValidationMessage = nil + nickname = rootViewModel.user?.nickname ?? "" + birthdate = rootViewModel.user?.birthdayDate ?? Date() + selectedGender = rootViewModel.user?.gender ?? .male + } + .onDisappear { + setTabBarHidden(false) + } + .onChange(of: nickname) { _, _ in + rootViewModel.nicknameValidationMessage = nil + } + } + + @ViewBuilder + private func profileFieldTitle(_ title: String) -> some View { + Text(title) + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundStyle(Color.mainWhite) + } + + private func submit() { + guard isNicknameValid else { return } + + rootViewModel.send(action: .checkNicknameDuplicate(nickname) { isDuplicate in + let currentNickname = rootViewModel.user?.nickname ?? "" + + if isDuplicate, nickname != currentNickname { + return + } + + rootViewModel.send( + action: .updateUserInfo( + nickname, + isoDateFormatter.string(from: birthdate), + selectedGender.rawValue + ) + ) + dismiss() + }) + } + + private func setTabBarHidden(_ isHidden: Bool) { + DispatchQueue.main.async { + let windowScenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + let windows = windowScenes.flatMap(\.windows) + + for window in windows { + if let rootViewController = window.rootViewController { + updateTabBarVisibility(in: rootViewController, isHidden: isHidden) + } + updateTabBarVisibilityInViewHierarchy(in: window, isHidden: isHidden) + } + } + } + + private func updateTabBarVisibility(in viewController: UIViewController, isHidden: Bool) { + if let tabBarController = viewController as? UITabBarController { + tabBarController.tabBar.isHidden = isHidden + } + + for child in viewController.children { + updateTabBarVisibility(in: child, isHidden: isHidden) + } + + if let presentedViewController = viewController.presentedViewController { + updateTabBarVisibility(in: presentedViewController, isHidden: isHidden) + } + } + + private func updateTabBarVisibilityInViewHierarchy(in view: UIView, isHidden: Bool) { + if let tabBar = view as? UITabBar { + tabBar.isHidden = isHidden + tabBar.alpha = isHidden ? 0 : 1 + tabBar.isUserInteractionEnabled = !isHidden + } + + for subview in view.subviews { + updateTabBarVisibilityInViewHierarchy(in: subview, isHidden: isHidden) + } + } +} + +private struct ProfileBirthdayPickerView: View { + @Binding var birthdate: Date + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack { + Text("생년월일 선택") + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 40) + .font(.system(size: 20, weight: .bold)) + + DatePicker( + "생년월일", + selection: $birthdate, + in: ...Date(), + displayedComponents: .date + ) + .datePickerStyle(.wheel) + .labelsHidden() + .padding() + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Color.mainWhite, lineWidth: 1.5) + ) + .environment(\.locale, Locale(identifier: "ko_KR")) + + Spacer() + .frame(height: 30) + + Button { + dismiss() + } label: { + Text("완료") + .padding() + .frame(maxWidth: .infinity) + .foregroundStyle(Color.mainBlack) + .background(Color.mainWhite) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + .padding(.horizontal, 40) + } + .padding(.top, 20) + .background(Color.mainBlack.ignoresSafeArea()) + } +} + +private struct ProfileGenderButton: View { + let gender: Gender + @Binding var isSelected: Gender + + var body: some View { + Button { + isSelected = gender + } label: { + Text(gender.rawValue) + .foregroundStyle(isSelected == gender ? Color.mainBlack : Color.mainWhite) + .padding() + .frame(maxWidth: .infinity) + .background(isSelected == gender ? Color.mainWhite : Color.mainBlack) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.mainWhite, lineWidth: 1.5) + ) + } +} diff --git a/CodeLounge/View/MainTab/Profile/ProfileView.swift b/CodeLounge/View/MainTab/Profile/ProfileView.swift new file mode 100644 index 0000000..310142f --- /dev/null +++ b/CodeLounge/View/MainTab/Profile/ProfileView.swift @@ -0,0 +1,288 @@ +// +// ProfileView.swift +// CodeLounge +// +// Created by 김동현 on 4/3/26. +// + +import SwiftUI +import SwiftUI_Kit +import SafariServices +import TurboNavigator + +struct ProfileView: View { + let navigator: Navigator + @EnvironmentObject private var rootViewModel: RootViewModel + + @State private var showDeleteUserAlert = false + @State private var showContactView = false + @State private var showAnnounceView = false + @State private var showPrivacyPolicy = false + + private let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 20) { + ProfileTitleView() + .padding(.top, 20) + + ProfileSummaryCard { + navigator.push(.profileSettings(rootViewModel)) + } + + ProfileMenuCard( + rows: [ + .init(title: "공지사항") { showAnnounceView = true }, + .init(title: "문의하기") { showContactView = true } + ] + ) + + ProfileInfoCard( + version: version, + onPrivacyPolicyTap: { showPrivacyPolicy = true } + ) + + ProfileDangerCard(title: "계정탈퇴") { + showDeleteUserAlert = true + } + + Button { + rootViewModel.send(action: .logout) + } label: { + Text("로그아웃") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.red) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 40) + } + .background(Color.mainBlack.ignoresSafeArea()) + .alert("계정 삭제", isPresented: $showDeleteUserAlert) { + Button("삭제", role: .destructive) { + rootViewModel.send(action: .deleteUser) + } + Button("취소", role: .cancel) {} + } message: { + Text("정말로 계정을 삭제하시겠습니까?") + } + .fullScreenCover(isPresented: $showContactView) { + ProfileSafariView(url: profileURL(for: "KAKAO_URL")) + } + .fullScreenCover(isPresented: $showAnnounceView) { + ProfileSafariView(url: profileURL(for: "NOTION_URL")) + } + .fullScreenCover(isPresented: $showPrivacyPolicy) { + ProfileSafariView(url: profileURL(for: "NOTION_POLICY_URL")) + } + } + + private func profileURL(for key: String) -> URL? { + guard let rawValue = Bundle.main.infoDictionary?[key] as? String else { + return nil + } + + if let url = URL(string: rawValue), url.scheme != nil { + return url + } + + return URL(string: "https://" + rawValue) + } +} + +#Preview { + PreviewProfileView() +} + +private struct PreviewProfileView: View { + @StateObject private var rootViewModel: RootViewModel + + init() { + DIContainer.config() + _rootViewModel = StateObject(wrappedValue: RootViewModel()) + } + + var body: some View { + ProfileView(navigator: .preview) + .environmentObject(rootViewModel) + } +} + +private struct ProfileTitleView: View { + var body: some View { + HStack { + Text("Profile") + .font(.system(size: 30, weight: .bold)) + .foregroundStyle(Color.mainWhite) + + Spacer() + } + } +} + +private struct ProfileSummaryCard: View { + @EnvironmentObject private var rootViewModel: RootViewModel + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text(rootViewModel.user?.nickname ?? "닉네임") + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(Color.mainWhite) + + Text(dayCountText) + .font(.system(size: 15)) + .foregroundStyle(Color.mainGray) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.mainWhite) + } + .padding(20) + .frame(maxWidth: .infinity, minHeight: 100) + .background(Color.subBlack) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Color.mainWhite.opacity(0.4), lineWidth: 0.5) + ) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + .buttonStyle(.plain) + } + + private var dayCountText: String { + guard let registerDate = rootViewModel.user?.registerDate else { + return "CodeLounge에 오신 걸 환영해요" + } + + return "CodeLounge \(calculateDaySince(registerDate))일 째" + } + + private func calculateDaySince(_ registerDate: Date) -> Int { + let currentDate = Date() + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Seoul") ?? .current + + let startOfRegisterDate = calendar.startOfDay(for: registerDate) + let startOfCurrentDate = calendar.startOfDay(for: currentDate) + + return calendar.dateComponents([.day], from: startOfRegisterDate, to: startOfCurrentDate).day ?? 0 + } +} + +private struct ProfileMenuCard: View { + struct Row { + let title: String + let action: () -> Void + } + + let rows: [Row] + + var body: some View { + VStack(spacing: 0) { + ForEach(Array(rows.enumerated()), id: \.offset) { index, row in + Button(action: row.action) { + HStack { + Text(row.title) + .foregroundStyle(Color.mainWhite) + Spacer() + } + .padding(.horizontal, 20) + .frame(height: 50) + } + .buttonStyle(.plain) + + if index < rows.count - 1 { + Rectangle() + .fill(Color.mainGray) + .frame(height: 1) + .padding(.horizontal, 20) + } + } + } + .background(Color.subBlack) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } +} + +private struct ProfileInfoCard: View { + let version: String + let onPrivacyPolicyTap: () -> Void + + var body: some View { + VStack(spacing: 0) { + Button(action: onPrivacyPolicyTap) { + HStack { + Text("개인정보처리방침") + .foregroundStyle(Color.mainWhite) + Spacer() + } + .padding(.horizontal, 20) + .frame(height: 50) + } + .buttonStyle(.plain) + + Rectangle() + .fill(Color.mainGray) + .frame(height: 1) + .padding(.horizontal, 20) + + HStack { + Text("버전정보") + .foregroundStyle(Color.mainWhite) + Spacer() + Text(version) + .foregroundStyle(Color.mainGray) + } + .padding(.horizontal, 20) + .frame(height: 50) + } + .background(Color.subBlack) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } +} + +private struct ProfileDangerCard: View { + let title: String + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + Text(title) + .foregroundStyle(Color.mainWhite) + Spacer() + } + .padding(.horizontal, 20) + .frame(height: 50) + .background(Color.subBlack) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + .buttonStyle(.plain) + } +} + +private struct ProfileSafariView: UIViewControllerRepresentable { + let url: URL? + + func makeUIViewController(context: Context) -> UIViewController { + guard let url else { + let controller = UIViewController() + controller.view.backgroundColor = .black + return controller + } + + return SFSafariViewController(url: url) + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + diff --git a/CodeLounge/View/MainTab/iOSView.swift b/CodeLounge/View/MainTab/iOSView.swift new file mode 100644 index 0000000..1eec37c --- /dev/null +++ b/CodeLounge/View/MainTab/iOSView.swift @@ -0,0 +1,23 @@ +// +// iOSView.swift +// CodeLounge +// +// Created by 김동현 on 4/3/26. +// + +import SwiftUI +import TurboNavigator + +struct iOSView: View { + let navigator: Navigator + + private let categories = ["Swift", "UIKit", "SwiftUI"] + + var body: some View { + BoardView(title: "iOS", categories: categories, navigator: navigator) + } +} + +#Preview { + iOSView(navigator: .preview) +} diff --git a/CodeLounge/View/MainTabView/MainTabView.swift b/CodeLounge/View/MainTabView/MainTabView.swift deleted file mode 100644 index be0cda1..0000000 --- a/CodeLounge/View/MainTabView/MainTabView.swift +++ /dev/null @@ -1,172 +0,0 @@ -// -// MainTabView.swift -// CodeLounge -// -// Created by 김동현 on 1/16/25. -// - -import SwiftUI - -enum MainTabType: CaseIterable { - case csView - case iosView - case aosView - case profileView - - var title: String { - switch self { - case .csView: - return "CS" - case .iosView: - return "iOS" - case .aosView: - return "aOS" - case .profileView: - return "profile" - } - } - - func imageName(isSelected: Bool) -> String { - switch self { - case .csView: - return isSelected ? "desktopcomputer" : "desktopcomputer" - case .iosView: - return isSelected ? "apple.logo" : "apple.logo" - case .aosView: - return isSelected ? "smartphone" : "smartphone" - case .profileView: - return isSelected ? "person.fill" : "person" - } - } -} - -struct MainTabView_save: View { - @State private var selectedTab: MainTabType = .csView - - var body: some View { - VStack(spacing: 0) { - VStack { - switch selectedTab { - case .csView: - CSView() - case .iosView: - iOSView() - case .aosView: - AosView() - case .profileView: - ProfileView() - } - } - - VStack(spacing: 0) { - Divider() - .background(Color.gray.opacity(0.3)) - .padding(.bottom, 15) - - HStack { - ForEach(MainTabType.allCases, id: \.self) { tab in - Spacer() - .frame(width: 10) - - VStack(spacing: 4) { - Image(systemName: tab.imageName(isSelected: selectedTab == tab)) - .font(.system(size: 24)) - .foregroundColor(selectedTab == tab ? .white : .gray) - Text(tab.title) - .font(.caption2) - .lineLimit(1) // 한 줄로 고정해 잘리지 않도록 설정 - .foregroundColor(selectedTab == tab ? .white : .gray) - } - .padding(.horizontal, 20) // 좌우 터치 영역 추가 - .frame(maxWidth: .infinity) - .onTapGesture { - // 진동 발생 - let generator = UIImpactFeedbackGenerator(style: .medium) - generator.impactOccurred() - selectedTab = tab - } - Spacer() - .frame(width: 10) - } - .frame(height: 50) // 고정 높이 - } - } - .background(.black) - } - .ignoresSafeArea(.keyboard, edges: .bottom) - } -} - -struct MainTabView: View { - - @State private var selectedTab: MainTabType = .csView - - var body: some View { - ZStack { - Color.black - - VStack(spacing: 0) { - switch selectedTab { - case .csView: - CSView() - case .iosView: - iOSView() - case .aosView: - AosView() - case .profileView: - ProfileView() - } - } - .padding(.bottom, 100) - - VStack(spacing: 0) { - Spacer() - - Rectangle() - .fill(Color.gray.opacity(0.3)) - .frame(height: 1) - - HStack { - ForEach(MainTabType.allCases, id: \.self) { tab in - - Spacer() - .frame(width: 10) - - VStack { - Spacer() - .frame(height: 5) - Image(systemName: tab.imageName(isSelected: selectedTab == tab)) - .font(.system(size: 24)) - .foregroundColor(selectedTab == tab ? .white : .gray) - Text(tab.title) - .font(.caption2) - .foregroundColor(selectedTab == tab ? .white : .gray) - Spacer() - .frame(height: 20) - } - .padding(.horizontal, 20) // 좌우 터치 영역 추가 - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) // 터치 가능한 영역을 명시적으로 지정 - .onTapGesture { - // 진동 발생 - let generator = UIImpactFeedbackGenerator(style: .medium) - generator.impactOccurred() - selectedTab = tab - } - Spacer() - .frame(width: 10) - } - .frame(height: 100) // 고정 높이 - } - .background(.black) - } - } - .ignoresSafeArea(edges: .all) - } -} -#Preview { - MainTabView() - .environmentObject(PostViewModel()) // 필요한 객체 주입 - .environmentObject(AuthenticationViewModel(container: DIContainer(services: Services()))) -} - diff --git a/CodeLounge/View/Nickname/NicknameSettingView.swift b/CodeLounge/View/Nickname/NicknameSettingView.swift deleted file mode 100644 index 2cbee97..0000000 --- a/CodeLounge/View/Nickname/NicknameSettingView.swift +++ /dev/null @@ -1,236 +0,0 @@ -// -// NicknameSettingView.swift -// CodeLounge -// -// Created by 김동현 on 1/16/25. -// - -import SwiftUI - -struct NicknameSettingView: View { - @EnvironmentObject var authViewModel: AuthenticationViewModel - @State private var nickname: String = "" // 닉네임입력 - @State private var nicknameMessage: String? = nil // 닉네임 오류 메시지 - @State private var slideOffset: CGFloat = UIScreen.main.bounds.width // 화면 너비만큼 오프셋 시작 - @State private var birthdate: Date = Date() // 기본값: 2000년 1월 1일 - @State private var isDatePickerActive: Bool = false // 생일입력 - @State private var selectedGender: Gender = .male // 성별입력 - - - // 닉네임이 유효한지 검사하는 프로퍼티 - private var isNicknameValid: Bool { - !nickname.trimmingCharacters(in: .whitespaces).isEmpty - } - - // 생년월일 날짜 포맷터 - private var dateFormatter: DateFormatter { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy. MM. dd" - return formatter - } - - // 전송용 생년월일 날짜 포맷터 - private var isoDateFormatter: ISO8601DateFormatter { - let formatter = ISO8601DateFormatter() - formatter.timeZone = TimeZone(identifier: "Asia/Seoul") // KST 설정 - return formatter - } - - var body: some View { - VStack(spacing: 10) { - Text("CodeLounge") - .font(.system(size: 30, weight: .bold)) - .padding(.bottom, 10) - .foregroundColor(Color.mainWhite) - - Text("회원가입에 필요한 정보를 입력해주세요") - .font(.system(size: 22, weight: .bold)) - .frame(maxWidth: .infinity, alignment: .leading) - .foregroundColor(Color.mainWhite) - - Spacer() - .frame(height: 10) - - Text("닉네임") - .frame(maxWidth: .infinity, alignment: .leading) - .foregroundColor(Color.mainWhite) - - TextField("2자 이상 20자 이하로 입력해주세요", text: $nickname) - .padding() - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(lineWidth: 1.5) - .foregroundColor(Color.mainWhite) - ) - - - if let message = nicknameMessage { - Text(message) - .foregroundColor(.red) - .font(.caption) - } - - Spacer() - .frame(height: 10) - - Text("생년월일") - .frame(maxWidth: .infinity, alignment: .leading) - .foregroundColor(Color.mainWhite) - - Button { - isDatePickerActive.toggle() - } label: { - Text("\(dateFormatter.string(from: birthdate))") - .frame(maxWidth: .infinity, alignment: .leading) - .foregroundColor(Color.mainWhite) - .padding() - .frame(maxWidth: .infinity) - //.background(Color.white) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(lineWidth: 1.5) - .foregroundColor(Color.mainWhite) - ) - } - - Spacer() - .frame(height: 10) - - Text("성별") - .frame(maxWidth: .infinity, alignment: .leading) - - HStack { - GenderButton(gender: .male, isSelected: $selectedGender) - GenderButton(gender: .female, isSelected: $selectedGender) - GenderButton(gender: .other, isSelected: $selectedGender) - } - - Spacer() - - Button { - - if isNicknameValid { - authViewModel.send(action: .checkNicknameDuplicate(nickname) { isDuplicate in - - if isDuplicate { - nicknameMessage = "닉네임이 중복되었습니다" - } else { - // 업데이트 성공 - nicknameMessage = nil - //authViewModel.send(action: .updateUserNickname(nickname)) - - let birthdayString = isoDateFormatter.string(from: birthdate) - print("디버깅: \(birthdayString)") - let genderString = selectedGender.rawValue - authViewModel.send(action: .updateUserInfo(nickname, birthdayString, genderString)) - } - }) - } - - - } label: { - Text("완료") - .padding() - .frame(maxWidth: .infinity) - .foregroundColor(Color.mainBlack) - .background(!nickname.isEmpty ? Color.mainWhite : Color.gray) - .cornerRadius(20) - } - .disabled(nickname.isEmpty) - - - } - .padding(.horizontal, 25) - .offset(x: slideOffset) // x축 오프셋 적용 - .onAppear { -// withAnimation(.easeOut(duration: 0.4)) { // 0.3초 동안 easeOut 애니메이션 - slideOffset = 0 // 오프셋을 0으로 만들어 화면 중앙으로 이동 -// } - } - .sheet(isPresented: $isDatePickerActive) { - BirthdayPickerView(birthdate: $birthdate) - .presentationDetents([.fraction(0.5)]) - } - .background(.black.gradient) - } - -} - -#Preview { - NicknameSettingView() -} - - -// MARK: - 생년월일 선택 -private struct BirthdayPickerView: View { - @Binding var birthdate: Date - @Environment(\.dismiss) private var dismiss // sheet 닫기 - - fileprivate var body: some View { - VStack { - Text("생년월일 선택") - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 50) - .font(.system(size: 20, weight: .bold)) - - DatePicker( - "생년얼일", - selection: $birthdate, - in: ...Date(), // 현재 날짜까지 선택 가능 - displayedComponents: .date - ) - .datePickerStyle(.wheel) - .labelsHidden() - .padding() - .overlay( - RoundedRectangle(cornerRadius: 20) - .stroke(.black, lineWidth: 2) - ) - .environment(\.locale, Locale(identifier: "ko_KR")) - - Spacer() - .frame(height: 30) - - Button { - dismiss() - } label: { - Text("완료") - .padding() - .frame(maxWidth: .infinity) - .foregroundColor(Color.mainBlack) - .background(Color.mainWhite) - .cornerRadius(20) - } - .padding(.horizontal, 40) - - } - } -} - -// MARK: - 성별 버튼 -private struct GenderButton: View { - - // 성별 - var gender: Gender - - // 선택유무 - @Binding var isSelected: Gender - - fileprivate var body: some View { - Button { - isSelected = gender - } label: { - Text(gender.rawValue) - .foregroundColor(isSelected == gender ? Color.mainBlack : Color.mainWhite) - .padding() - .frame(maxWidth: .infinity) - .background(isSelected == gender ? Color.mainWhite : Color.mainBlack) // 선택 여부에 따라 배경색 변경 - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(lineWidth: 1.5) - .foregroundColor(Color.mainWhite) - ) - } -} diff --git a/CodeLounge/View/Profile/ProfileSettingView.swift b/CodeLounge/View/Profile/ProfileSettingView.swift deleted file mode 100644 index 2ed0bb0..0000000 --- a/CodeLounge/View/Profile/ProfileSettingView.swift +++ /dev/null @@ -1,235 +0,0 @@ -// -// ProfileSettingView.swift -// CodeLounge -// -// Created by 김동현 on 1/26/25. -// - -import SwiftUI - -struct ProfileSettingView: View { - @Environment(\.dismiss) private var dismiss - @EnvironmentObject var authViewModel: AuthenticationViewModel - @State private var nickname: String = "" // 닉네임입력 - @State private var nicknameMessage: String? = nil // 닉네임 오류 메시지 - @State private var birthdate: Date = Date() // 기본값: 2000년 1월 1일 - @State private var isDatePickerActive: Bool = false // 생일입력 - @State private var selectedGender: Gender = .male // 성별입력 - - - // 닉네임이 유효한지 검사하는 프로퍼티 - private var isNicknameValid: Bool { - !nickname.trimmingCharacters(in: .whitespaces).isEmpty - } - - // 생년월일 날짜 포맷터 - private var dateFormatter: DateFormatter { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy. MM. dd" - return formatter - } - - // 전송용 생년월일 날짜 포맷터 - private var isoDateFormatter: ISO8601DateFormatter { - let formatter = ISO8601DateFormatter() - formatter.timeZone = TimeZone(identifier: "Asia/Seoul") // KST 설정 - return formatter - } - - var body: some View { - VStack(spacing: 10) { - Text("프로필 수정") - .font(.system(size: 30, weight: .bold)) - .padding(.bottom, 10) - .foregroundColor(Color.mainWhite) - - Spacer() - .frame(height: 10) - - Text("닉네임") - .frame(maxWidth: .infinity, alignment: .leading) - .foregroundColor(Color.mainWhite) - - TextField("2자 이상 20자 이하로 입력해주세요", text: $nickname) - .padding() - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(lineWidth: 1.5) - .foregroundColor(Color.mainWhite) - ) - - - if let message = nicknameMessage { - Text(message) - .foregroundColor(.red) - .font(.caption) - } - - Spacer() - .frame(height: 10) - - Text("생년월일") - .frame(maxWidth: .infinity, alignment: .leading) - .foregroundColor(Color.mainWhite) - - Button { - isDatePickerActive.toggle() - } label: { - Text("\(dateFormatter.string(from: birthdate))") - .frame(maxWidth: .infinity, alignment: .leading) - .foregroundColor(Color.mainWhite) - .padding() - .frame(maxWidth: .infinity) - //.background(Color.white) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(lineWidth: 1.5) - .foregroundColor(Color.mainWhite) - ) - } - - Spacer() - .frame(height: 10) - - Text("성별") - .frame(maxWidth: .infinity, alignment: .leading) - - HStack { - GenderButton(gender: .male, isSelected: $selectedGender) - GenderButton(gender: .female, isSelected: $selectedGender) - GenderButton(gender: .other, isSelected: $selectedGender) - } - - Spacer() - - Button { - - if isNicknameValid { - authViewModel.send(action: .checkNicknameDuplicate(nickname) { isDuplicate in - - if isDuplicate { - nicknameMessage = "닉네임이 중복되었습니다" - } else { - // 업데이트 성공 - nicknameMessage = nil - let birthdayString = isoDateFormatter.string(from: birthdate) - let genderString = selectedGender.rawValue - authViewModel.send(action: .updateUserInfo(nickname, birthdayString, genderString)) - dismiss() - } - }) - } - - - } label: { - Text("완료") - .padding() - .frame(maxWidth: .infinity) - .foregroundColor(Color.mainBlack) - .background(!nickname.isEmpty ? Color.mainWhite : Color.gray) - .cornerRadius(20) - } - .disabled(nickname.isEmpty) - .padding(.bottom, 50) - - - } - .padding(.horizontal, 25) - .sheet(isPresented: $isDatePickerActive) { - BirthdayPickerView(birthdate: $birthdate) - .presentationDetents([.fraction(0.5)]) - } - .background(Color.mainBlack) - .navigationBarBackButtonHidden() - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button { - dismiss() - } label: { - Image(systemName: "chevron.left") - } - .foregroundColor(Color.mainGreen) - } - } - } - -} - -#Preview { - ProfileSettingView() -} - - -// MARK: - 생년월일 선택 -private struct BirthdayPickerView: View { - @Binding var birthdate: Date - @Environment(\.dismiss) private var dismiss // sheet 닫기 - - fileprivate var body: some View { - VStack { - Text("생년월일 선택") - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 50) - .font(.system(size: 20, weight: .bold)) - - DatePicker( - "생년얼일", - selection: $birthdate, - in: ...Date(), // 현재 날짜까지 선택 가능 - displayedComponents: .date - ) - .datePickerStyle(.wheel) - .labelsHidden() - .padding() - .overlay( - RoundedRectangle(cornerRadius: 20) - .stroke(Color.mainWhite, lineWidth: 1.5) - ) - .environment(\.locale, Locale(identifier: "ko_KR")) - - Spacer() - .frame(height: 30) - - Button { - dismiss() - } label: { - Text("완료") - .padding() - .frame(maxWidth: .infinity) - .foregroundColor(Color.mainBlack) - .background(Color.mainWhite) - .cornerRadius(20) - } - .padding(.horizontal, 40) - - } - } -} - -// MARK: - 성별 버튼 -private struct GenderButton: View { - - // 성별 - var gender: Gender - - // 선택유무 - @Binding var isSelected: Gender - - fileprivate var body: some View { - Button { - isSelected = gender - } label: { - Text(gender.rawValue) - .foregroundColor(isSelected == gender ? Color.mainBlack : Color.mainWhite) - .padding() - .frame(maxWidth: .infinity) - .background(isSelected == gender ? Color.mainWhite : Color.mainBlack) // 선택 여부에 따라 배경색 변경 - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(lineWidth: 1.5) - .foregroundColor(Color.mainWhite) - ) - } -} diff --git a/CodeLounge/View/Profile/ProfileView.swift b/CodeLounge/View/Profile/ProfileView.swift deleted file mode 100644 index 0686045..0000000 --- a/CodeLounge/View/Profile/ProfileView.swift +++ /dev/null @@ -1,260 +0,0 @@ -// -// ProfileView.swift -// CodeLounge -// -// Created by 김동현 on 1/16/25. -// - -import SwiftUI - -struct ProfileView: View { - @EnvironmentObject private var authViewModel: AuthenticationViewModel - @State private var showDeleteUserAlarm: Bool = false - @State private var showContactView: Bool = false - @State private var showAnnounceView: Bool = false - @State private var showPrivacyPolicy: Bool = false - - private let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" - - var body: some View { - - NavigationStack { - ScrollView(showsIndicators: false) { - VStack(spacing: 20) { - - // MARK: - 타이틀 - TitleView() - .padding(.top, 20) - - // MARK: - 닉네임 - NicknameView() - - // MARK: - 공지사항, 문의하기 - RectView(height: 100, color: .subBlack, radius: 20) - .overlay { - VStack(alignment: .leading) { - /* - NavigationLink { - // NoticeView() - } label: { - HStack { - Text("공지사항") - .foregroundStyle(Color.mainWhite) - .padding(.leading, 20) - .padding(.bottom, 5) - Spacer() - } - } - */ - Button { - showAnnounceView.toggle() - } label: { - HStack { - Text("공지사항") - .foregroundStyle(Color.mainWhite) - .padding(.leading, 20) - .padding(.bottom, 5) - Spacer() - } - } - - Rectangle() - .fill(Color.mainGray) - .frame(height: 1) - .padding(.horizontal, 20) - - Button { - showContactView.toggle() - } label: { - HStack { - Text("문의하기") - .foregroundStyle(Color.mainWhite) - .padding(.leading, 20) - .padding(.top, 5) - Spacer() - } - } - - } - } - - // MARK: - 개인정보처리방침, 버전정보 - RectView(height: 100, color: .subBlack, radius: 20) - .overlay { - VStack(alignment: .leading) { - Button { - showPrivacyPolicy.toggle() - } label: { - HStack { - Text("개인정보처리방침") - .foregroundStyle(Color.mainWhite) - .padding(.leading, 20) - .padding(.bottom, 5) - Spacer() - } - } - - Rectangle() - .fill(Color.mainGray) - .frame(height: 1) - .padding(.horizontal, 20) - - HStack { - Text("버전정보") - .padding(.leading, 20) - .padding(.top, 5) - Spacer() - Text(version) - .padding(.trailing, 20) - .padding(.top, 5) - } - } - } - - // MARK: - 계정탈퇴 - RectView(height: 50, color: .subBlack, radius: 20) - .overlay { - Button { - showDeleteUserAlarm.toggle() - } label: { - HStack { - Text("계정탈퇴") - .foregroundColor(.white) - Spacer() - }.padding(.leading) - } - } - - // MARK: - 로그아웃 - Button { - authViewModel.send(action: .logout) - } label: { - Text("로그아웃") - .foregroundColor(.red) - } - } - .padding() - .alert(isPresented: $showDeleteUserAlarm) { - Alert( - title: Text("계정 삭제"), - message: Text("정말로 계정을 삭제하시겠습니가?"), - primaryButton: .destructive(Text("삭제")) { - authViewModel.send(action: .deleteUser) - }, - secondaryButton: .cancel() - ) - } - .fullScreenCover(isPresented: $showContactView) { - let urlString = Bundle.main.infoDictionary?["KAKAO_URL"] as? String ?? "" - - SafriWebView(url: URL(string: "https://" + urlString)!) - .ignoresSafeArea() - } - .fullScreenCover(isPresented: $showAnnounceView) { - let urlString = Bundle.main.infoDictionary?["NOTION_URL"] as? String ?? "" - - SafriWebView(url: URL(string: "https://" + urlString)!) - .ignoresSafeArea() - } - .fullScreenCover(isPresented: $showPrivacyPolicy) { - let urlString = Bundle.main.infoDictionary?["NOTION_POLICY_URL"] as? String ?? "" - - SafriWebView(url: URL(string: "https://" + urlString)!) - .ignoresSafeArea() - } - - } - .background(Color.mainBlack) - } - } -} - -// MARK: - 타이틀 뷰 -private struct TitleView: View { - fileprivate var body: some View { - HStack { - Text("Profile") - .font(.system(size: 30, weight: .bold)) - .padding(.leading, 10) - Spacer() - } - } -} - -// MARK: - 닉네임 뷰 -private struct NicknameView: View { - @EnvironmentObject private var authViewModel: AuthenticationViewModel - fileprivate var body: some View { - NavigationLink { - ProfileSettingView() - // NicknameSettingView() - } label: { - HStack { - VStack(alignment: .leading) { - Text(authViewModel.user?.nickname ?? "닉네임") - .font(.system(size: 25, weight: .bold)) - - if let registerDate = authViewModel.user?.registerDate { - Text("CodeLounge \(calculateDaySince(registerDate))일 째") - } - } - .padding(.leading, 20) - .foregroundColor(.white) - Spacer() - - Image(systemName: "chevron.right") - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 10, height: 10) - .foregroundColor(Color.mainWhite) - .padding(.trailing, 20) - } - .frame(maxWidth: .infinity) - .frame(height: 100) - .overlay { - RoundedRectangle(cornerRadius: 20) - .stroke(.white, lineWidth: 0.5) - } - } - } - - // MARK: - 날짜 비교 함수 - func calculateDaySince_legacy(_ registerDate: Date) -> Int { - let currentDate = Date() - let calendar = Calendar(identifier: .gregorian) - var calendarInKorea = calendar - calendarInKorea.timeZone = TimeZone(identifier: "Asia/Seoul")! // 한국 시간대 설정 - - let days = calendarInKorea.dateComponents([.day], from: registerDate, to: currentDate).day ?? 0 - return days - } - - // MARK: - 날짜 비교 함수 - func calculateDaySince(_ registerDate: Date) -> Int { - let currentDate = Date() - let calendar = Calendar(identifier: .gregorian) - var calendarInKorea = calendar - calendarInKorea.timeZone = TimeZone(identifier: "Asia/Seoul")! // 한국 시간대 설정 - - // 날짜 단위로 비교하여 차이를 계산 - let startOfRegisterDate = calendarInKorea.startOfDay(for: registerDate) - let startOfCurrentDate = calendarInKorea.startOfDay(for: currentDate) - - let days = calendarInKorea.dateComponents([.day], from: startOfRegisterDate, to: startOfCurrentDate).day ?? 0 - return days - } -} - -// MARK: - 공지사항 뷰 -private struct NoticeView: View { - fileprivate var body: some View { - VStack { - Text("공지사항") - } - } -} - -#Preview { - ProfileView() - .environmentObject(AuthenticationViewModel(container: DIContainer(services: Services()))) -} diff --git a/CodeLounge/View/Profile/SafariWeb/SafriWebView.swift b/CodeLounge/View/Profile/SafariWeb/SafriWebView.swift deleted file mode 100644 index a156525..0000000 --- a/CodeLounge/View/Profile/SafariWeb/SafriWebView.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// SafriWebView.swift -// CodeLounge -// -// Created by 김동현 on 1/26/25. -// - -import SwiftUI -import SafariServices - -struct SafriWebView: UIViewControllerRepresentable { - let url: URL - - func makeUIViewController(context: Context) -> SFSafariViewController { - return SFSafariViewController(url: url) - } - - func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) { - - } -} diff --git a/CodeLounge/View/Root/RootView.swift b/CodeLounge/View/Root/RootView.swift new file mode 100644 index 0000000..b384d86 --- /dev/null +++ b/CodeLounge/View/Root/RootView.swift @@ -0,0 +1,127 @@ +// +// RootView.swift +// CodeLounge +// +// Created by 김동현 on 4/3/26. +// + +import SwiftUI +import TurboNavigator +import UIKit + +struct RootView: View { + @StateObject var rootViewModel: RootViewModel + @StateObject private var postViewModel = PostViewModel() + let authNavigator: Navigator + let mainNavigator: Navigator + + init( + rootViewModel: RootViewModel, + authNavigator: Navigator, + mainNavigator: Navigator + ) { + _rootViewModel = StateObject(wrappedValue: rootViewModel) + self.authNavigator = authNavigator + self.mainNavigator = mainNavigator + } + + var body: some View { + Group { + switch rootViewModel.authenticationState { + case .unauthenticated: + NavigationContainer( + navigator: authNavigator, + initialRoutes: [.intro] + ) + + case .authenticated: + TabNavigationContainer( + navigator: mainNavigator, + items: MainRoute.tabCases.enumerated().map { (index, tab) -> TabNavigationItem in + + TabNavigationItem( + tag: index, + route: tab, + tabBarItem: { + let item = UITabBarItem( + title: tab.title, + image: paddedTabBarImage( + systemName: tab.imageName(isSelected: false), + topPadding: 6 + ), + selectedImage: paddedTabBarImage( + systemName: tab.imageName(isSelected: true), + topPadding: 6 + ) + ) + item.tag = index + return item + }(), + hapticStyle: .medium + ) + }, + disablesSystemTabTransitionAnimation: true + ) + .ignoresSafeArea(.keyboard, edges: .bottom) + .onAppear { + postViewModel.fetchAllPosts() + } + + case .firstTimeLogin: + NavigationContainer( + navigator: authNavigator, + initialRoutes: [.register] + ) + } + } + .ignoresSafeArea(.container, edges: .all) + .environmentObject(rootViewModel) + .environmentObject(postViewModel) + .onAppear { + rootViewModel.send(action: .autoLogin) + } + } +} + +private func paddedTabBarImage(systemName: String, topPadding: CGFloat) -> UIImage? { + let configuration = UIImage.SymbolConfiguration(pointSize: 20, weight: .regular) + guard let image = UIImage(systemName: systemName, withConfiguration: configuration) else { return nil } + + let newSize = CGSize( + width: image.size.width, + height: image.size.height + topPadding + ) + let renderer = UIGraphicsImageRenderer(size: newSize) + + return renderer.image { _ in + image.draw(at: CGPoint(x: 0, y: topPadding)) + }.withRenderingMode(.alwaysTemplate) +} + +// +// .init( +// tag: 0, +// route: .cs, +// tabBarItem: { +// let item = UITabBarItem( +// title: "Home", +// image: nil, +// selectedImage: nil +// ) +// item.tag = 0 +// return item +// }() +// ), +// .init( +// tag: 1, +// route: .cs, +// tabBarItem: { +// let item = UITabBarItem( +// title: "Home", +// image: nil, +// selectedImage: nil +// ) +// item.tag = 0 +// return item +// }() +// ) diff --git a/CodeLounge/View/Root/RootViewModel.swift b/CodeLounge/View/Root/RootViewModel.swift new file mode 100644 index 0000000..950e4b2 --- /dev/null +++ b/CodeLounge/View/Root/RootViewModel.swift @@ -0,0 +1,232 @@ +// +// RootViewModel.swift +// CodeLounge +// +// Created by 김동현 on 4/3/26. +// + +import Foundation +import AuthenticationServices +import Combine + +enum AuthenticationState { + case unauthenticated + case authenticated + case firstTimeLogin +} + +final class RootViewModel: ObservableObject { + enum Action { + case autoLogin + case appleLogin(ASAuthorizationRequest) + case appleLoginCompletion(Result) + case googleLogin + case checkNickname(User) + case checkNicknameDuplicate(String, (Bool) -> Void) + case updateUserInfo(String, String, String) + case deleteUser + case logout + } + @Dependency private var authService: AuthServiceProtocol + @Dependency private var userService: UserServiceProtocol + @Published var authenticationState: AuthenticationState = .unauthenticated + @Published var nicknameValidationMessage: String? + private var currentNonce: String? + private var subscriptions = Set() + var userId: String? + var user: User? + + func send(action: Action) { + switch action { + + case .autoLogin: + if let userId = authService.checkAuthenticationState() { + self.authenticationState = .authenticated + self.userService.getUser(userId: userId) + .sink { [weak self] completion in + if case .failure = completion { + self?.authenticationState = .unauthenticated + } + } receiveValue: { [weak self] user in + self?.send(action: .checkNickname(user)) + }.store(in: &subscriptions) + } + + case .appleLogin(let request): + let nonce = authService.handleSignInWithAppleRequest( + request as! ASAuthorizationAppleIDRequest + ) + currentNonce = nonce + + + case .appleLoginCompletion(let result): + if case let .success(authorization) = result { + guard let nonce = self.currentNonce else { + print("Error: Missing nonce") + return + } + + authService.handleSignInWithAppleCompletion( + authorization, + nonce: nonce + ).flatMap { user in + self.userService.getUser(userId: user.id) + .catch { _ in + self.userService.addUser(user) + } + }.sink { [weak self] completion in + if case .failure(let error) = completion { + print("애플 로그인 실패: \(error.localizedDescription)") + self?.authenticationState = .unauthenticated + } + } receiveValue: { [weak self] user in + self?.send(action: .checkNickname(user)) + }.store(in: &subscriptions) + } + + + case .googleLogin: + authService.signInWithGoogle() + .flatMap { user in + /// 사용자가 존재하는지 getUser로 확인 후, 없으면 최초 로그인이므로 addUser호출 로직 + self.userService.getUser(userId: user.id) + .catch { error -> AnyPublisher in + return self.userService.addUser(user) + } + } + .sink { completion in + switch completion { + case .finished: + print("✅ 유저가 성공적으로 추가/로그인 되었습니다!") + case .failure(let error): + print("❌ Service 기본 에러: \(error.localizedDescription)") + } + } receiveValue: { [weak self] user in + self?.send(action: .checkNickname(user)) + }.store(in: &subscriptions) + + + + case .checkNickname(let user): + self.userId = user.id + self.nicknameValidationMessage = nil + if user.nickname.trimmingCharacters(in: .whitespaces).isEmpty { + self.authenticationState = .firstTimeLogin + } else { + self.authenticationState = .authenticated + self.user = user + } + + + case .checkNicknameDuplicate(let nickname, let completion): + let trimmedNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmedNickname.isEmpty else { + nicknameValidationMessage = "닉네임을 입력해주세요" + completion(true) + return + } + + userService.checkNicknameDuplicate(trimmedNickname) + .sink { [weak self] result in + switch result { + case .failure(let error): + print("닉네임 확인 실패: \(error)") + DispatchQueue.main.async { + self?.nicknameValidationMessage = "닉네임 확인에 실패했습니다" + completion(true) + } + case .finished: + break + } + } receiveValue: { [weak self] isDuplicate in + DispatchQueue.main.async { + self?.nicknameValidationMessage = isDuplicate ? "닉네임이 중복되었습니다" : nil + completion(isDuplicate) + } + }.store(in: &subscriptions) + + + case .updateUserInfo(let nickname, let birthday, let gender): + guard let userId = userId else { return } + let trimmedNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines) + let currentNickname = user?.nickname.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + guard !trimmedNickname.isEmpty else { + nicknameValidationMessage = "닉네임을 입력해주세요" + return + } + + userService.checkNicknameDuplicate(trimmedNickname) + .sink { [weak self] completion in + if case .failure(let error) = completion { + self?.nicknameValidationMessage = "닉네임 확인에 실패했습니다" + print("닉네임 중복 확인 실패: \(error)") + } + } receiveValue: { [weak self] isDuplicate in + guard let self else { return } + + if isDuplicate, trimmedNickname != currentNickname { + self.nicknameValidationMessage = "닉네임이 중복되었습니다" + return + } + + self.nicknameValidationMessage = nil + self.userService.updateUserInfo( + userId: userId, + nickname: trimmedNickname, + birthday: birthday, + gender: gender + ) + .sink { [weak self] completion in + switch completion { + case .finished: + self?.authenticationState = .authenticated + case .failure(let error): + print("닉네임 업데이트 실패: \(error)") + } + } receiveValue: { [weak self] user in + self?.user = user + }.store(in: &self.subscriptions) + }.store(in: &subscriptions) + + case .deleteUser: + guard let userId = userId ?? user?.id else { return } + userService.deleteUser(userId: userId) + .flatMap { [authService] _ in + authService.logout() + } + .sink { completion in + if case .failure(let error) = completion { + print("계정 삭제 실패: \(error)") + } + } receiveValue: { [weak self] _ in + self?.authenticationState = .unauthenticated + self?.user = nil + self?.userId = nil + }.store(in: &subscriptions) + + + case .logout: + nicknameValidationMessage = nil + authService.logout() + .sink { _ in + + } receiveValue: { [weak self] _ in + self?.authenticationState = .unauthenticated + self?.user = nil + self?.userId = nil + }.store(in: &subscriptions) + } + } +} + +extension RootViewModel: Hashable { + static func == (lhs: RootViewModel, rhs: RootViewModel) -> Bool { + lhs === rhs + } + + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} diff --git a/CodeLounge/ViewModel/AuthenticationViewModel.swift b/CodeLounge/ViewModel/AuthenticationViewModel.swift deleted file mode 100644 index 51f5892..0000000 --- a/CodeLounge/ViewModel/AuthenticationViewModel.swift +++ /dev/null @@ -1,230 +0,0 @@ -// -// AuthenticationViewModel.swift -// CodeLounge -// -// Created by 김동현 on 1/16/25. -// - -import Foundation -import Combine -import AuthenticationServices -import FirebaseAuth - -enum AuthenticationState { - case unauthenticated - case authenticated - case firstTimeLogin -} - -final class AuthenticationViewModel: ObservableObject { - - enum Action { - case checkAuthenticationState - case googleLogin - case appleLogin(ASAuthorizationRequest) - case appleLoginCompletion(Result) - case checkNickname(User) - case checkNicknameDuplicate(String, (Bool) -> Void) - case updateUserInfo(String, String, String) - case logout - case deleteUser - } - - @Published var authenticationState: AuthenticationState = .unauthenticated - @Published var isLoading: Bool = false - - var userId: String? - var user: User? - private var container: DIContainer - private var subscriptions = Set() - private var currentNonce: String? - - init(container: DIContainer) { - self.container = container - } - - func send(action: Action) { - switch action { - case .checkAuthenticationState: - if let userId = container.services.authService.checkAuthenticationState() { - self.userId = userId - self.authenticationState = .authenticated - // MARK: - Firebase에서 사용자 정보를 가져와 닉네임 확인 - container.services.userService.getUser(userId: userId) - .sink { [weak self] completion in - if case .failure = completion { - self?.authenticationState = .unauthenticated - } - } receiveValue: { [weak self] user in - // 닉네임 유무 확인 - self?.send(action: .checkNickname(user)) - } - .store(in: &subscriptions) - } - - case .googleLogin: - isLoading = true - container.services.authService.signInWithGoogle() - .flatMap { user in - // MARK: - 사용자가 존재하는지 getUser로 확인 후, 없으면 최초 로그인이므로 addUser호출 로직 - self.container.services.userService.getUser(userId: user.id) - .catch { error -> AnyPublisher in - return self.container.services.userService.addUser(user) - } - } - .sink { [weak self] completion in - switch completion { - case .finished: - print("✅ 유저가 성공적으로 추가/로그인 되었습니다!") - - case .failure(let error): - if case .dbError(let dbError) = error { - // ❌ error가 .dbError 케이스일 경우 실행 - ServiceError{DBError} - print(dbError.errorDescription) - } else { - // ❌ 다른 ServiceError 처리 - print("❌ Service 기본 에러: \(error.localizedDescription)") - } - self?.isLoading = false - } - } receiveValue: { [weak self] user in - self?.isLoading = false - self?.userId = user.id - - - // MARK: - 닉네임 유무를 확인하는 구간 - self?.send(action: .checkNickname(user)) - }.store(in: &subscriptions) - - case let .appleLogin(request): - let nonce = container.services.authService.handleSignInWithAppleRequest(request as! ASAuthorizationAppleIDRequest) - currentNonce = nonce - - case .checkNickname(let user): - if user.nickname.trimmingCharacters(in: .whitespaces).isEmpty { - self.authenticationState = .firstTimeLogin - } else { - self.authenticationState = .authenticated - self.user = user - } - - case let .appleLoginCompletion(result): - if case let .success(authorization) = result { - guard let nonce = self.currentNonce else { - print("Error: Missing nonce") - return - } - - container.services.authService.handleSignInWithAppleCompletion(authorization, nonce: nonce) - .flatMap { user in - self.container.services.userService.getUser(userId: user.id) - .catch { error -> AnyPublisher in - return self.container.services.userService.addUser(user) - } - } - .sink { [weak self] completion in - if case let .failure(error) = completion { - self?.isLoading = false - // 구체적인 에러 정보 출력 - print("애플 로그인 실패: \(error.localizedDescription)") - - - } - } receiveValue: { [weak self] user in - self?.isLoading = false - self?.userId = user.id - - - // MARK: - 닉네임 유무를 확인하는 구간 - self?.send(action: .checkNickname(user)) - - }.store(in: &subscriptions) - } - - case .checkNicknameDuplicate(let nickname, let completion): - container.services.userService.checkNicknameDuplicate(nickname) - .sink { result in - switch result { - case .failure(let error): - DispatchQueue.main.async { - print("닉네임 중복 확인 오류: \(error.localizedDescription)") - completion(false) // 오류 발생 시 false 반환 - } - case .finished: - break - } - } receiveValue: { isDuplicate in - DispatchQueue.main.async { - if isDuplicate { - completion(true) // 중복된 경우 클로저에 true 전달 - } else { - completion(false) // 중복되지 않은 경우 클로저에 false 전달 - } - } - } - .store(in: &subscriptions) - - case .updateUserInfo(let nickname, let birthday, let gender): - guard let userId = userId else { return } // 사용자 ID가 없으면 리턴 - - // container.services.userService를 통해 닉네임 업데이트 호출 - container.services.userService.updateUserInfo(userId: userId, nickname: nickname, birthday: birthday, gender: gender) - .sink(receiveCompletion: { completion in - switch completion { - case .finished: - self.authenticationState = .authenticated // 닉네임 설정 후 인증 상태 변경 - case .failure(let error): - print("닉네임 업데이트 실패: \(error)") // 오류 처리 - } - }, receiveValue: { [weak self] user in - self?.user = user - }) - .store(in: &subscriptions) - - case .logout: - container.services.authService.logout() - .sink { completion in - - } receiveValue: { [weak self] _ in - self?.authenticationState = .unauthenticated - self?.userId = nil - }.store(in: &subscriptions) - - case .deleteUser: - guard let userId = self.userId else { return } - - // 1단계: Realtime Database에서 유저 데이터를 삭제 - container.services.userService.deleteUser(userId: userId) - .tryMap { _ -> FirebaseAuth.User in - // 2단계: Firebase Auth 계정 삭제 - guard let currentUser = Auth.auth().currentUser else { - throw ServiceError.dbError(.userNotFound) - } - return currentUser - } - .flatMap { currentUser -> AnyPublisher in - return Future { promise in - currentUser.delete { error in - if let error = error { - promise(.failure(error)) - } else { - promise(.success(())) - } - } - }.eraseToAnyPublisher() - } - .sink { completion in - if case .failure(let error) = completion { - print("계정 삭제 실패: \(error)") - } - } receiveValue: { [weak self] _ in - // 계정과 데이터가 성공적으로 삭제된 경우 - DispatchQueue.main.async { - self?.authenticationState = .unauthenticated - self?.userId = nil - } - } - .store(in: &subscriptions) - } - } -} diff --git a/CodeLounge/ViewModel/PostViewModel.swift b/CodeLounge/ViewModel/PostViewModel.swift deleted file mode 100644 index 1f70b66..0000000 --- a/CodeLounge/ViewModel/PostViewModel.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// PostViewModel.swift -// CodeLounge -// -// Created by 김동현 on 1/20/25. -// - -import Foundation -import FirebaseDatabase - -final class PostViewModel: ObservableObject { - @Published var postsByCategory: [String: [Post]] = [:] // 전체 카테고리별 posts 저장 - @Published var filteredPostsByCategory: [String: [Post]] = [:] // 검색 결과 - @Published var searchText: String = "" // 검색어 - - private var databaseRef: DatabaseReference = Database.database().reference() - - // 카테고리 키와 한글 이름 매핑 - let categoryNames: [String: String] = [ - "OperatingSystems": "운영체제", - "Algorithms": "알고리즘", -// "Swift": "Swift", -// "SwiftUI": "SwiftUI" - ] - - // MARK: - 전체 Posts 가져오기 - func fetchAllPosts() { - databaseRef.child("Posts").observeSingleEvent(of: .value) { snapshot in - var categoryPosts: [String: [Post]] = [:] - - guard let value = snapshot.value as? [String: [String: Any]] else { - print("Posts 데이터를 읽는 데 실패했습니다.") - return - } - - for (category, posts) in value { - var postsArray: [Post] = [] - - for (postId, postData) in posts { - if let postDict = postData as? [String: Any], - let title = postDict["title"] as? String, - let content = postDict["content"] as? String, - let authorID = postDict["author_id"] as? String, - let createdAtString = postDict["created_at"] as? String, - let createdAt = ISO8601DateFormatter().date(from: createdAtString) { - - let post = Post( - id: postId, - title: title, - content: content, - authorID: authorID, - createdAt: createdAt - ) - postsArray.append(post) - } - } - - postsArray.sort { - if $0.createdAt != $1.createdAt { - return $0.createdAt < $1.createdAt - } else { - return $0.title < $1.title - } - } - categoryPosts[category] = postsArray - } - - DispatchQueue.main.async { - self.postsByCategory = categoryPosts - self.filteredPostsByCategory = categoryPosts // 초기화 - } - } withCancel: { error in - print("Error fetching data: \(error.localizedDescription)") - } - } - - // MARK: - 특정 카테고리와 검색어를 기준으로 필터링 - func filterPosts(for categories: [String]) { - let lowercasedSearchText = searchText.lowercased() // 검색어를 소문자로 변환 - - if searchText.isEmpty { - filteredPostsByCategory = postsByCategory.filter { categories.contains($0.key) } - } else { - filteredPostsByCategory = postsByCategory.filter { categories.contains($0.key) } - .mapValues { posts in - posts.filter { - $0.title.lowercased().contains(lowercasedSearchText) || // 제목에서 검색 - $0.content.lowercased().contains(lowercasedSearchText) // 내용에서 검색 - } - } - .filter { !$0.value.isEmpty } - } - } -} - diff --git a/CodeLoungeTests/CodeLoungeTests.swift b/CodeLoungeTests/CodeLoungeTests.swift new file mode 100644 index 0000000..03c043c --- /dev/null +++ b/CodeLoungeTests/CodeLoungeTests.swift @@ -0,0 +1,180 @@ +import Foundation +import Testing +@testable import CodeLounge + +struct CodeLoungeTests { + @Test("User.toDTO가 명시된 값을 올바르게 변환한다") + func userToDTOMapsExplicitValues() { + let registerDate = ISO8601DateFormatter().date(from: "2026-04-10T00:00:00Z")! + let birthdayDate = ISO8601DateFormatter().date(from: "2000-01-29T00:00:00Z")! + let user = User( + id: "user-1", + nickname: "김동현", + registerDate: registerDate, + birthdayDate: birthdayDate, + gender: .male, + loginPlatform: .apple + ) + + let dto = user.toDTO() + + #expect(dto.id == "user-1") + #expect(dto.nickname == "김동현") + #expect(dto.gender == Gender.male.rawValue) + #expect(dto.loginPlatform == LoginPlatform.apple.rawValue) + #expect(dto.registerDate == seoulISO8601Formatter.string(from: registerDate)) + #expect(dto.birthdayDate == seoulISO8601Formatter.string(from: birthdayDate)) + } + + @Test("User.toDTO가 옵셔널 값이 없을 때 기본값으로 대체한다") + func userToDTOFallsBackToDefaults() { + let user = User( + id: "user-default", + nickname: "fallback", + registerDate: nil, + birthdayDate: nil, + gender: nil, + loginPlatform: nil + ) + + let dto = user.toDTO() + + #expect(dto.id == "user-default") + #expect(dto.nickname == "fallback") + #expect(dto.gender == Gender.male.rawValue) + #expect(dto.loginPlatform == LoginPlatform.google.rawValue) + #expect(ISO8601DateFormatter().date(from: dto.registerDate) != nil) + #expect(ISO8601DateFormatter().date(from: dto.birthdayDate) != nil) + } + + @Test("UserDTO.toModel이 enum과 날짜를 올바르게 변환한다") + func userDTOToModelDecodesValues() { + let dto = UserDTO( + id: "user-2", + nickname: "tester", + registerDate: "2026-04-10T00:00:00Z", + birthdayDate: "2001-02-03T00:00:00Z", + gender: Gender.female.rawValue, + loginPlatform: LoginPlatform.google.rawValue + ) + + let model = dto.toModel() + + #expect(model.id == "user-2") + #expect(model.nickname == "tester") + #expect(model.gender == .female) + #expect(model.loginPlatform == .google) + #expect(model.registerDate != nil) + #expect(model.birthdayDate != nil) + } + + @Test("UserDTO.toModel이 잘못된 enum과 날짜 값에 대해 기본 처리한다") + func userDTOToModelFallsBackForInvalidValues() { + let dto = UserDTO( + id: "broken-user", + nickname: "tester", + registerDate: "not-a-date", + birthdayDate: "also-not-a-date", + gender: "invalid-gender", + loginPlatform: "invalid-platform" + ) + + let before = Date() + let model = dto.toModel() + let after = Date() + + #expect(model.id == "broken-user") + #expect(model.nickname == "tester") + #expect(model.gender == nil) + #expect(model.loginPlatform == nil) + #expect(model.registerDate != nil) + #expect(model.birthdayDate != nil) + #expect(model.registerDate! >= before.addingTimeInterval(-1)) + #expect(model.registerDate! <= after.addingTimeInterval(1)) + #expect(model.birthdayDate! >= before.addingTimeInterval(-1)) + #expect(model.birthdayDate! <= after.addingTimeInterval(1)) + } + + @Test("PostDTO.toDomain이 필드를 올바르게 변환한다") + func postDTOToDomainMapsFields() { + let dto = PostDTO( + id: "post-1", + title: "Swift", + content: "content", + authorID: "author-1", + createdAt: "2026-04-10T12:00:00Z" + ) + + let post = dto.toDomain() + + #expect(post.id == "post-1") + #expect(post.title == "Swift") + #expect(post.content == "content") + #expect(post.authorID == "author-1") + #expect(post.createdAt == ISO8601DateFormatter().date(from: "2026-04-10T12:00:00Z")) + } + + @Test("PostDTO.toDomain이 잘못된 시간값에 대해 현재 날짜로 대체한다") + func postDTOToDomainFallsBackForInvalidTimestamp() { + let dto = PostDTO( + id: "post-invalid-date", + title: "Swift", + content: "content", + authorID: "author-1", + createdAt: "invalid" + ) + + let before = Date() + let post = dto.toDomain() + let after = Date() + + #expect(post.id == "post-invalid-date") + #expect(post.createdAt >= before.addingTimeInterval(-1)) + #expect(post.createdAt <= after.addingTimeInterval(1)) + } + + @Test("MarkdownParser가 제목 목록 문단 코드블록을 파싱한다") + func markdownParserParsesStructuredBlocks() { + let markdown = """ + # Title + + - **Bold** + 일반 문단 + ```swift + let value = 1 + ``` + """ + + let nodes = MarkdownParser(markdown: markdown).parseDocument() + + #expect(nodes.count == 5) + #expect(nodes[0] == .heading(level: 1, text: "Title")) + #expect(nodes[1] == .lineBreak) + #expect(nodes[2] == .listItem(text: [.bold([.text("Bold")])])) + #expect(nodes[3] == .paragraph(inlines: [.text("일반 문단")])) + #expect(nodes[4] == .code(language: "swift", content: "let value = 1")) + } + + @Test("InlineParser가 중첩된 볼드와 밑줄 마크업을 파싱한다") + func inlineParserParsesNestedMarkup() { + let nodes = InlineParser("start **bold ##under## end** done").parse() + + #expect( + nodes == [ + .text("start "), + .bold([ + .text("bold "), + .underline([.text("under")]), + .text(" end") + ]), + .text(" done") + ] + ) + } +} + +private let seoulISO8601Formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.timeZone = TimeZone(identifier: "Asia/Seoul") + return formatter +}() diff --git a/fastlane/report.xml b/fastlane/report.xml index 88dea54..1263f0e 100644 --- a/fastlane/report.xml +++ b/fastlane/report.xml @@ -5,27 +5,17 @@ - + - + - - - - - - - - - - - +