diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index f46bdff..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.github/ISSUE_TEMPLATE/issus-template.md b/.github/ISSUE_TEMPLATE/issus-template.md new file mode 100644 index 0000000..570043a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issus-template.md @@ -0,0 +1,19 @@ +--- +name: Issus Template +about: "\b이슈 생성을 위한 템플릿입니다." +title: "[작업 태그] 이슈 제목" +labels: '' +assignees: '' + +--- + +## 배경 +- + + +## 내용 +- + + +## 작업 범위 +- [ ] diff --git a/.gitignore b/.gitignore index 746fe62..86303be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # Created by https://www.toptal.com/developers/gitignore/api/swift,firebase,xcode # Edit at https://www.toptal.com/developers/gitignore?templates=swift,firebase,xcode +### Custom ### +**/.DS_Store + + ### Firebase ### .idea **/node_modules/* diff --git a/AsyncSwift.xcodeproj/project.pbxproj b/AsyncSwift.xcodeproj/project.pbxproj index 684d62e..33a61d9 100644 --- a/AsyncSwift.xcodeproj/project.pbxproj +++ b/AsyncSwift.xcodeproj/project.pbxproj @@ -7,16 +7,22 @@ objects = { /* Begin PBXBuildFile section */ - B25E600C28D2400500E96C78 /* KeyChain.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25E600B28D2400500E96C78 /* KeyChain.swift */; }; B289943328CA69FF002B9F67 /* StampView+Observed.swift in Sources */ = {isa = PBXBuildFile; fileRef = B289943228CA69FF002B9F67 /* StampView+Observed.swift */; }; - B2E1083128C9CD6900C3DD59 /* AppData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E1083028C9CD6900C3DD59 /* AppData.swift */; }; B2FC6F6328D309FF00D2ACBF /* Stamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2FC6F6228D309FF00D2ACBF /* Stamp.swift */; }; + C631EBC3291537E300A54143 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C631EBC2291537E300A54143 /* SafariView.swift */; }; C63A865F28CA70ED0064C417 /* EventDetailView+Observed.swift in Sources */ = {isa = PBXBuildFile; fileRef = C63A865E28CA70ED0064C417 /* EventDetailView+Observed.swift */; }; C63A866328CB3D490064C417 /* View+.swift in Sources */ = {isa = PBXBuildFile; fileRef = C63A866228CB3D490064C417 /* View+.swift */; }; C63A866528CB3F6D0064C417 /* DateFormatter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = C63A866428CB3F6D0064C417 /* DateFormatter+.swift */; }; + C63D444A291BDBD5005D5AE6 /* Tab.swift in Sources */ = {isa = PBXBuildFile; fileRef = C63D4449291BDBD5005D5AE6 /* Tab.swift */; }; + C63D444C291BDCDC005D5AE6 /* KeyChainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C63D444B291BDCDC005D5AE6 /* KeyChainManager.swift */; }; + C63D444E291BDD09005D5AE6 /* MainTabView+Observed.swift in Sources */ = {isa = PBXBuildFile; fileRef = C63D444D291BDD09005D5AE6 /* MainTabView+Observed.swift */; }; + C63D4450291BDD2B005D5AE6 /* String+.swift in Sources */ = {isa = PBXBuildFile; fileRef = C63D444F291BDD2B005D5AE6 /* String+.swift */; }; C66C68D328D1B00A0091F960 /* EventModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C66C68D228D1B00A0091F960 /* EventModel.swift */; }; C66C68D528D1B0130091F960 /* SessionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C66C68D428D1B0130091F960 /* SessionModel.swift */; }; C66DAD5028CF478700195DEB /* SessionView+Observed.swift in Sources */ = {isa = PBXBuildFile; fileRef = C66DAD4F28CF478700195DEB /* SessionView+Observed.swift */; }; + C66E3D95290BA48500097BEA /* ProfileRegisterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C66E3D94290BA48500097BEA /* ProfileRegisterView.swift */; }; + C66E3D97290BA4FC00097BEA /* ProfileRegisterViewObserved.swift in Sources */ = {isa = PBXBuildFile; fileRef = C66E3D96290BA4FC00097BEA /* ProfileRegisterViewObserved.swift */; }; + C66E3D99290BB9C100097BEA /* TextEditor+.swift in Sources */ = {isa = PBXBuildFile; fileRef = C66E3D98290BB9C100097BEA /* TextEditor+.swift */; }; C68DE93628C7685800CA4CC8 /* AsyncSwiftApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C68DE93528C7685800CA4CC8 /* AsyncSwiftApp.swift */; }; C68DE93828C7685800CA4CC8 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C68DE93728C7685800CA4CC8 /* MainTabView.swift */; }; C68DE93A28C7685900CA4CC8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C68DE93928C7685900CA4CC8 /* Assets.xcassets */; }; @@ -27,6 +33,21 @@ C68DE94C28C76F3200CA4CC8 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C68DE94B28C76F3200CA4CC8 /* AppDelegate.swift */; }; C68DE94F28C77DC900CA4CC8 /* EventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C68DE94E28C77DC900CA4CC8 /* EventView.swift */; }; C68DE95128C77DDA00CA4CC8 /* TicketingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C68DE95028C77DDA00CA4CC8 /* TicketingView.swift */; }; + C69C139F2912817D00D9B47F /* ProfileFriendsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C69C139E2912817D00D9B47F /* ProfileFriendsListView.swift */; }; + C69C13A22912868F00D9B47F /* ProfileFriendDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C69C13A12912868F00D9B47F /* ProfileFriendDetailView.swift */; }; + C69C13B32913B08F00D9B47F /* FirebaseDatabase in Frameworks */ = {isa = PBXBuildFile; productRef = C69C13B22913B08F00D9B47F /* FirebaseDatabase */; }; + C69C13B52913B09500D9B47F /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = C69C13B42913B09500D9B47F /* FirebaseFirestore */; }; + C69C13BA2913B0C800D9B47F /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = C69C13B92913B0C800D9B47F /* User.swift */; }; + C69C13BC2913EC2000D9B47F /* ProfileFriendsListViewObserved.swift in Sources */ = {isa = PBXBuildFile; fileRef = C69C13BB2913EC2000D9B47F /* ProfileFriendsListViewObserved.swift */; }; + C69C13BF2913EC9400D9B47F /* FirebaseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C69C13BE2913EC9400D9B47F /* FirebaseManager.swift */; }; + C69C13C1291418A000D9B47F /* ProfileEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C69C13C0291418A000D9B47F /* ProfileEditView.swift */; }; + C69C13C32914192B00D9B47F /* ProfileEditViewObserved.swift in Sources */ = {isa = PBXBuildFile; fileRef = C69C13C22914192B00D9B47F /* ProfileEditViewObserved.swift */; }; + C69C13C6291425F200D9B47F /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = C69C13C5291425F200D9B47F /* CodeScanner */; }; + C69C13C82914A6C300D9B47F /* ProfileFriendDetailViewObserved.swift in Sources */ = {isa = PBXBuildFile; fileRef = C69C13C72914A6C300D9B47F /* ProfileFriendDetailViewObserved.swift */; }; + C6AA71E428FC48EB0091A868 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6AA71E328FC48EB0091A868 /* ProfileView.swift */; }; + C6AA71E628FC49040091A868 /* ProfileViewObserved.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6AA71E528FC49040091A868 /* ProfileViewObserved.swift */; }; + C6AA71E828FC65680091A868 /* Text+.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6AA71E728FC65680091A868 /* Text+.swift */; }; + C6AABAC32901875100779203 /* TextField+.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6AABAC22901875100779203 /* TextField+.swift */; }; C6E744A028CA557100B7B2BD /* Color+.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6E7449F28CA557100B7B2BD /* Color+.swift */; }; C6F7798728C9CB3A0036773B /* StampView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F7798628C9CB3A0036773B /* StampView.swift */; }; C6F7798B28C9CBC60036773B /* EventView+Observed.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F7798A28C9CBC60036773B /* EventView+Observed.swift */; }; @@ -38,16 +59,22 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - B25E600B28D2400500E96C78 /* KeyChain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyChain.swift; sourceTree = ""; }; B289943228CA69FF002B9F67 /* StampView+Observed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StampView+Observed.swift"; sourceTree = ""; }; - B2E1083028C9CD6900C3DD59 /* AppData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppData.swift; sourceTree = ""; }; B2FC6F6228D309FF00D2ACBF /* Stamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stamp.swift; sourceTree = ""; }; + C631EBC2291537E300A54143 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; C63A865E28CA70ED0064C417 /* EventDetailView+Observed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EventDetailView+Observed.swift"; sourceTree = ""; }; C63A866228CB3D490064C417 /* View+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+.swift"; sourceTree = ""; }; C63A866428CB3F6D0064C417 /* DateFormatter+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DateFormatter+.swift"; sourceTree = ""; }; + C63D4449291BDBD5005D5AE6 /* Tab.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Tab.swift; sourceTree = ""; }; + C63D444B291BDCDC005D5AE6 /* KeyChainManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyChainManager.swift; sourceTree = ""; }; + C63D444D291BDD09005D5AE6 /* MainTabView+Observed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "MainTabView+Observed.swift"; sourceTree = ""; }; + C63D444F291BDD2B005D5AE6 /* String+.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+.swift"; sourceTree = ""; }; C66C68D228D1B00A0091F960 /* EventModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventModel.swift; sourceTree = ""; }; C66C68D428D1B0130091F960 /* SessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionModel.swift; sourceTree = ""; }; C66DAD4F28CF478700195DEB /* SessionView+Observed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionView+Observed.swift"; sourceTree = ""; }; + C66E3D94290BA48500097BEA /* ProfileRegisterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRegisterView.swift; sourceTree = ""; }; + C66E3D96290BA4FC00097BEA /* ProfileRegisterViewObserved.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRegisterViewObserved.swift; sourceTree = ""; }; + C66E3D98290BB9C100097BEA /* TextEditor+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TextEditor+.swift"; sourceTree = ""; }; C68DE93228C7685800CA4CC8 /* AsyncSwift.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AsyncSwift.app; sourceTree = BUILT_PRODUCTS_DIR; }; C68DE93528C7685800CA4CC8 /* AsyncSwiftApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncSwiftApp.swift; sourceTree = ""; }; C68DE93728C7685800CA4CC8 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; @@ -59,6 +86,18 @@ C68DE94D28C77CC400CA4CC8 /* AsyncSwift.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = AsyncSwift.entitlements; sourceTree = ""; }; C68DE94E28C77DC900CA4CC8 /* EventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventView.swift; sourceTree = ""; }; C68DE95028C77DDA00CA4CC8 /* TicketingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TicketingView.swift; sourceTree = ""; }; + C69C139E2912817D00D9B47F /* ProfileFriendsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFriendsListView.swift; sourceTree = ""; }; + C69C13A12912868F00D9B47F /* ProfileFriendDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFriendDetailView.swift; sourceTree = ""; }; + C69C13B92913B0C800D9B47F /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; + C69C13BB2913EC2000D9B47F /* ProfileFriendsListViewObserved.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFriendsListViewObserved.swift; sourceTree = ""; }; + C69C13BE2913EC9400D9B47F /* FirebaseManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseManager.swift; sourceTree = ""; }; + C69C13C0291418A000D9B47F /* ProfileEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileEditView.swift; sourceTree = ""; }; + C69C13C22914192B00D9B47F /* ProfileEditViewObserved.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileEditViewObserved.swift; sourceTree = ""; }; + C69C13C72914A6C300D9B47F /* ProfileFriendDetailViewObserved.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFriendDetailViewObserved.swift; sourceTree = ""; }; + C6AA71E328FC48EB0091A868 /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; + C6AA71E528FC49040091A868 /* ProfileViewObserved.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewObserved.swift; sourceTree = ""; }; + C6AA71E728FC65680091A868 /* Text+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Text+.swift"; sourceTree = ""; }; + C6AABAC22901875100779203 /* TextField+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TextField+.swift"; sourceTree = ""; }; C6E7449F28CA557100B7B2BD /* Color+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+.swift"; sourceTree = ""; }; C6F7798628C9CB3A0036773B /* StampView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StampView.swift; sourceTree = ""; }; C6F7798A28C9CBC60036773B /* EventView+Observed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EventView+Observed.swift"; sourceTree = ""; }; @@ -74,8 +113,11 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C69C13B32913B08F00D9B47F /* FirebaseDatabase in Frameworks */, + C69C13B52913B09500D9B47F /* FirebaseFirestore in Frameworks */, C68DE94728C76BC500CA4CC8 /* FirebaseMessaging in Frameworks */, C68DE94528C76BC500CA4CC8 /* FirebaseAnalytics in Frameworks */, + C69C13C6291425F200D9B47F /* CodeScanner in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -87,6 +129,7 @@ children = ( C68DE93428C7685800CA4CC8 /* AsyncSwift */, C68DE93328C7685800CA4CC8 /* Products */, + C69C13B12913B08F00D9B47F /* Frameworks */, ); sourceTree = ""; }; @@ -105,6 +148,7 @@ C68DE94A28C76CE000CA4CC8 /* Info.plist */, C68DE93528C7685800CA4CC8 /* AsyncSwiftApp.swift */, C68DE94B28C76F3200CA4CC8 /* AppDelegate.swift */, + C69C13A32913804E00D9B47F /* Managers */, C6E7449E28CA556800B7B2BD /* Extensions */, C6F7798828C9CB9B0036773B /* Models */, C68DE95228C77F4800CA4CC8 /* Views */, @@ -133,17 +177,63 @@ C6F7798E28C9D1BF0036773B /* SessionView.swift */, C68DE95028C77DDA00CA4CC8 /* TicketingView.swift */, C6F7798628C9CB3A0036773B /* StampView.swift */, + C69C13A02912818500D9B47F /* Profile */, ); path = Views; sourceTree = ""; }; + C69C13A02912818500D9B47F /* Profile */ = { + isa = PBXGroup; + children = ( + C6AA71E328FC48EB0091A868 /* ProfileView.swift */, + C66E3D94290BA48500097BEA /* ProfileRegisterView.swift */, + C69C13C0291418A000D9B47F /* ProfileEditView.swift */, + C69C139E2912817D00D9B47F /* ProfileFriendsListView.swift */, + C69C13A12912868F00D9B47F /* ProfileFriendDetailView.swift */, + ); + path = Profile; + sourceTree = ""; + }; + C69C13A32913804E00D9B47F /* Managers */ = { + isa = PBXGroup; + children = ( + C63D444B291BDCDC005D5AE6 /* KeyChainManager.swift */, + C69C13BE2913EC9400D9B47F /* FirebaseManager.swift */, + ); + path = Managers; + sourceTree = ""; + }; + C69C13B12913B08F00D9B47F /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + C69C13BD2913EC3200D9B47F /* ProfileView */ = { + isa = PBXGroup; + children = ( + C6AA71E528FC49040091A868 /* ProfileViewObserved.swift */, + C69C13C22914192B00D9B47F /* ProfileEditViewObserved.swift */, + C66E3D96290BA4FC00097BEA /* ProfileRegisterViewObserved.swift */, + C69C13BB2913EC2000D9B47F /* ProfileFriendsListViewObserved.swift */, + C69C13C72914A6C300D9B47F /* ProfileFriendDetailViewObserved.swift */, + ); + path = ProfileView; + sourceTree = ""; + }; C6E7449E28CA556800B7B2BD /* Extensions */ = { isa = PBXGroup; children = ( + C63D444F291BDD2B005D5AE6 /* String+.swift */, C6E7449F28CA557100B7B2BD /* Color+.swift */, + C6AA71E728FC65680091A868 /* Text+.swift */, C63A866228CB3D490064C417 /* View+.swift */, C63A866428CB3F6D0064C417 /* DateFormatter+.swift */, + C6AABAC22901875100779203 /* TextField+.swift */, + C66E3D98290BB9C100097BEA /* TextEditor+.swift */, E9E2A4D728CEC5680016AEFF /* WebView.swift */, + C631EBC2291537E300A54143 /* SafariView.swift */, ); path = Extensions; sourceTree = ""; @@ -151,7 +241,9 @@ C6F7798828C9CB9B0036773B /* Models */ = { isa = PBXGroup; children = ( + C69C13B92913B0C800D9B47F /* User.swift */, C66C68D228D1B00A0091F960 /* EventModel.swift */, + C63D4449291BDBD5005D5AE6 /* Tab.swift */, C66C68D428D1B0130091F960 /* SessionModel.swift */, E94F92C628D2505100D9E759 /* Ticketing.swift */, B2FC6F6228D309FF00D2ACBF /* Stamp.swift */, @@ -162,13 +254,13 @@ C6F7798928C9CBA60036773B /* Observed */ = { isa = PBXGroup; children = ( + C63D444D291BDD09005D5AE6 /* MainTabView+Observed.swift */, C6F7798A28C9CBC60036773B /* EventView+Observed.swift */, C63A865E28CA70ED0064C417 /* EventDetailView+Observed.swift */, C66DAD4F28CF478700195DEB /* SessionView+Observed.swift */, - B25E600B28D2400500E96C78 /* KeyChain.swift */, - B2E1083028C9CD6900C3DD59 /* AppData.swift */, B289943228CA69FF002B9F67 /* StampView+Observed.swift */, E9171EFF28D15426002FAF52 /* TicketingView+Observed.swift */, + C69C13BD2913EC3200D9B47F /* ProfileView */, ); path = Observed; sourceTree = ""; @@ -192,6 +284,9 @@ packageProductDependencies = ( C68DE94428C76BC500CA4CC8 /* FirebaseAnalytics */, C68DE94628C76BC500CA4CC8 /* FirebaseMessaging */, + C69C13B22913B08F00D9B47F /* FirebaseDatabase */, + C69C13B42913B09500D9B47F /* FirebaseFirestore */, + C69C13C5291425F200D9B47F /* CodeScanner */, ); productName = AsyncSwift; productReference = C68DE93228C7685800CA4CC8 /* AsyncSwift.app */; @@ -223,6 +318,7 @@ mainGroup = C68DE92928C7685800CA4CC8; packageReferences = ( C68DE94328C76BC500CA4CC8 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, + C69C13C4291425F200D9B47F /* XCRemoteSwiftPackageReference "CodeScanner" */, ); productRefGroup = C68DE93328C7685800CA4CC8 /* Products */; projectDirPath = ""; @@ -251,29 +347,47 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C6AA71E828FC65680091A868 /* Text+.swift in Sources */, C68DE94C28C76F3200CA4CC8 /* AppDelegate.swift in Sources */, E94F92C728D2505100D9E759 /* Ticketing.swift in Sources */, + C69C13C1291418A000D9B47F /* ProfileEditView.swift in Sources */, + C63D4450291BDD2B005D5AE6 /* String+.swift in Sources */, + C63D444C291BDCDC005D5AE6 /* KeyChainManager.swift in Sources */, + C631EBC3291537E300A54143 /* SafariView.swift in Sources */, C66DAD5028CF478700195DEB /* SessionView+Observed.swift in Sources */, + C69C13A22912868F00D9B47F /* ProfileFriendDetailView.swift in Sources */, C6E744A028CA557100B7B2BD /* Color+.swift in Sources */, C68DE95128C77DDA00CA4CC8 /* TicketingView.swift in Sources */, - B25E600C28D2400500E96C78 /* KeyChain.swift in Sources */, C6F7798B28C9CBC60036773B /* EventView+Observed.swift in Sources */, C66C68D528D1B0130091F960 /* SessionModel.swift in Sources */, + C66E3D99290BB9C100097BEA /* TextEditor+.swift in Sources */, + C69C139F2912817D00D9B47F /* ProfileFriendsListView.swift in Sources */, + C69C13BF2913EC9400D9B47F /* FirebaseManager.swift in Sources */, + C69C13BC2913EC2000D9B47F /* ProfileFriendsListViewObserved.swift in Sources */, + C66E3D97290BA4FC00097BEA /* ProfileRegisterViewObserved.swift in Sources */, + C69C13C32914192B00D9B47F /* ProfileEditViewObserved.swift in Sources */, E9171F0028D15426002FAF52 /* TicketingView+Observed.swift in Sources */, C6F7798728C9CB3A0036773B /* StampView.swift in Sources */, + C6AA71E628FC49040091A868 /* ProfileViewObserved.swift in Sources */, + C69C13BA2913B0C800D9B47F /* User.swift in Sources */, B289943328CA69FF002B9F67 /* StampView+Observed.swift in Sources */, C63A865F28CA70ED0064C417 /* EventDetailView+Observed.swift in Sources */, C68DE94F28C77DC900CA4CC8 /* EventView.swift in Sources */, + C63D444E291BDD09005D5AE6 /* MainTabView+Observed.swift in Sources */, C68DE93828C7685800CA4CC8 /* MainTabView.swift in Sources */, C6F7799128C9E5DD0036773B /* EventDetailView.swift in Sources */, + C63D444A291BDBD5005D5AE6 /* Tab.swift in Sources */, C68DE93628C7685800CA4CC8 /* AsyncSwiftApp.swift in Sources */, + C6AA71E428FC48EB0091A868 /* ProfileView.swift in Sources */, + C69C13C82914A6C300D9B47F /* ProfileFriendDetailViewObserved.swift in Sources */, + C6AABAC32901875100779203 /* TextField+.swift in Sources */, E9E2A4D828CEC5680016AEFF /* WebView.swift in Sources */, + C66E3D95290BA48500097BEA /* ProfileRegisterView.swift in Sources */, C63A866328CB3D490064C417 /* View+.swift in Sources */, C63A866528CB3F6D0064C417 /* DateFormatter+.swift in Sources */, C6F7798F28C9D1BF0036773B /* SessionView.swift in Sources */, C66C68D328D1B00A0091F960 /* EventModel.swift in Sources */, B2FC6F6328D309FF00D2ACBF /* Stamp.swift in Sources */, - B2E1083128C9CD6900C3DD59 /* AppData.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -400,15 +514,17 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = AsyncSwift/AsyncSwift.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 2; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"AsyncSwift/Preview Content\""; DEVELOPMENT_TEAM = 76AJ433CP5; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = AsyncSwift/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_NSCalendarsUsageDescription = "캘린더에 일정 추가를 위해서는 권한이 필요해요."; + INFOPLIST_KEY_NSCameraUsageDescription = "QR코드 스캔을 위해 카메라를 사용합니다."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -421,10 +537,13 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.2; + MARKETING_VERSION = 2.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.kim.AsyncSwift; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = AsyncSwiftProvisioningProfile; + PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -437,15 +556,17 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = AsyncSwift/AsyncSwift.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 2; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"AsyncSwift/Preview Content\""; DEVELOPMENT_TEAM = 76AJ433CP5; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = AsyncSwift/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_NSCalendarsUsageDescription = "캘린더에 일정 추가를 위해서는 권한이 필요해요."; + INFOPLIST_KEY_NSCameraUsageDescription = "QR코드 스캔을 위해 카메라를 사용합니다."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -458,10 +579,13 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.2; + MARKETING_VERSION = 2.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.kim.AsyncSwift; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = AsyncSwiftProvisioningProfile; + PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -500,6 +624,14 @@ minimumVersion = 9.0.0; }; }; + C69C13C4291425F200D9B47F /* XCRemoteSwiftPackageReference "CodeScanner" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/twostraws/CodeScanner"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -513,6 +645,21 @@ package = C68DE94328C76BC500CA4CC8 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseMessaging; }; + C69C13B22913B08F00D9B47F /* FirebaseDatabase */ = { + isa = XCSwiftPackageProductDependency; + package = C68DE94328C76BC500CA4CC8 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseDatabase; + }; + C69C13B42913B09500D9B47F /* FirebaseFirestore */ = { + isa = XCSwiftPackageProductDependency; + package = C68DE94328C76BC500CA4CC8 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseFirestore; + }; + C69C13C5291425F200D9B47F /* CodeScanner */ = { + isa = XCSwiftPackageProductDependency; + package = C69C13C4291425F200D9B47F /* XCRemoteSwiftPackageReference "CodeScanner" */; + productName = CodeScanner; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = C68DE92A28C7685800CA4CC8 /* Project object */; diff --git a/AsyncSwift.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AsyncSwift.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1bbce8d..21366cd 100644 --- a/AsyncSwift.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AsyncSwift.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -18,6 +18,15 @@ "version" : "0.9.1" } }, + { + "identity" : "codescanner", + "kind" : "remoteSourceControl", + "location" : "https://github.com/twostraws/CodeScanner", + "state" : { + "revision" : "c7859712034a08bb5a018fbe8751c1f1edc4b248", + "version" : "2.2.1" + } + }, { "identity" : "firebase-ios-sdk", "kind" : "remoteSourceControl", diff --git a/AsyncSwift.xcodeproj/project.xcworkspace/xcuserdata/kiminsub.xcuserdatad/UserInterfaceState.xcuserstate b/AsyncSwift.xcodeproj/project.xcworkspace/xcuserdata/kiminsub.xcuserdatad/UserInterfaceState.xcuserstate index 958c88e..3096789 100644 Binary files a/AsyncSwift.xcodeproj/project.xcworkspace/xcuserdata/kiminsub.xcuserdatad/UserInterfaceState.xcuserstate and b/AsyncSwift.xcodeproj/project.xcworkspace/xcuserdata/kiminsub.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/AsyncSwift/Assets.xcassets/Colors/0A66C2.colorset/Contents.json b/AsyncSwift/Assets.xcassets/Colors/0A66C2.colorset/Contents.json new file mode 100644 index 0000000..5ac2110 --- /dev/null +++ b/AsyncSwift/Assets.xcassets/Colors/0A66C2.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xC2", + "green" : "0x66", + "red" : "0x0A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AsyncSwift/Assets.xcassets/Colors/1920FF.colorset/Contents.json b/AsyncSwift/Assets.xcassets/Colors/1920FF.colorset/Contents.json new file mode 100644 index 0000000..f586130 --- /dev/null +++ b/AsyncSwift/Assets.xcassets/Colors/1920FF.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0x20", + "red" : "0x19" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AsyncSwift/Assets.xcassets/Colors/6C6C70.colorset/Contents.json b/AsyncSwift/Assets.xcassets/Colors/6C6C70.colorset/Contents.json new file mode 100644 index 0000000..75a65a3 --- /dev/null +++ b/AsyncSwift/Assets.xcassets/Colors/6C6C70.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x70", + "green" : "0x6C", + "red" : "0x6C" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AsyncSwift/Assets.xcassets/Colors/AFAFAF.colorset/Contents.json b/AsyncSwift/Assets.xcassets/Colors/AFAFAF.colorset/Contents.json new file mode 100644 index 0000000..e9c332a --- /dev/null +++ b/AsyncSwift/Assets.xcassets/Colors/AFAFAF.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xAF", + "green" : "0xAF", + "red" : "0xAF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AsyncSwift/Assets.xcassets/Colors/D9D9D9.colorset/Contents.json b/AsyncSwift/Assets.xcassets/Colors/D9D9D9.colorset/Contents.json new file mode 100644 index 0000000..78408a9 --- /dev/null +++ b/AsyncSwift/Assets.xcassets/Colors/D9D9D9.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xD9", + "green" : "0xD9", + "red" : "0xD9" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AsyncSwift/Assets.xcassets/Colors/E5E5EA.colorset/Contents.json b/AsyncSwift/Assets.xcassets/Colors/E5E5EA.colorset/Contents.json new file mode 100644 index 0000000..d4a52c1 --- /dev/null +++ b/AsyncSwift/Assets.xcassets/Colors/E5E5EA.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xEA", + "green" : "0xE5", + "red" : "0xE5" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AsyncSwift/Assets.xcassets/Colors/EAEAEA.colorset/Contents.json b/AsyncSwift/Assets.xcassets/Colors/EAEAEA.colorset/Contents.json new file mode 100644 index 0000000..3a50d2b --- /dev/null +++ b/AsyncSwift/Assets.xcassets/Colors/EAEAEA.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xEA", + "green" : "0xEA", + "red" : "0xEA" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AsyncSwift/Assets.xcassets/Colors/buttonBackground.colorset/Contents.json b/AsyncSwift/Assets.xcassets/Colors/buttonBackground.colorset/Contents.json new file mode 100644 index 0000000..a7320b8 --- /dev/null +++ b/AsyncSwift/Assets.xcassets/Colors/buttonBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF3", + "green" : "0xF3", + "red" : "0xF3" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AsyncSwift/Assets.xcassets/Colors/myProfileBackground.colorset/Contents.json b/AsyncSwift/Assets.xcassets/Colors/myProfileBackground.colorset/Contents.json new file mode 100644 index 0000000..6072198 --- /dev/null +++ b/AsyncSwift/Assets.xcassets/Colors/myProfileBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFB", + "green" : "0xFB", + "red" : "0xFB" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AsyncSwift/Assets.xcassets/Colors/placeholderForeground.colorset/Contents.json b/AsyncSwift/Assets.xcassets/Colors/placeholderForeground.colorset/Contents.json new file mode 100644 index 0000000..ea9f58c --- /dev/null +++ b/AsyncSwift/Assets.xcassets/Colors/placeholderForeground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x76", + "green" : "0x76", + "red" : "0x76" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AsyncSwift/Assets.xcassets/Colors/profileGray.colorset/Contents.json b/AsyncSwift/Assets.xcassets/Colors/profileGray.colorset/Contents.json new file mode 100644 index 0000000..11d344a --- /dev/null +++ b/AsyncSwift/Assets.xcassets/Colors/profileGray.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x93", + "green" : "0x8E", + "red" : "0x8E" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AsyncSwift/Assets.xcassets/Colors/unavailableButtonBackground.colorset/Contents.json b/AsyncSwift/Assets.xcassets/Colors/unavailableButtonBackground.colorset/Contents.json new file mode 100644 index 0000000..daca5b9 --- /dev/null +++ b/AsyncSwift/Assets.xcassets/Colors/unavailableButtonBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xD6", + "green" : "0xD6", + "red" : "0xD2" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AsyncSwift/Assets.xcassets/QRplaceholder.imageset/Contents.json b/AsyncSwift/Assets.xcassets/QRplaceholder.imageset/Contents.json new file mode 100644 index 0000000..8f8e550 --- /dev/null +++ b/AsyncSwift/Assets.xcassets/QRplaceholder.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "􀎹.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git "a/AsyncSwift/Assets.xcassets/QRplaceholder.imageset/\364\200\216\271.png" "b/AsyncSwift/Assets.xcassets/QRplaceholder.imageset/\364\200\216\271.png" new file mode 100644 index 0000000..4bb6ccf Binary files /dev/null and "b/AsyncSwift/Assets.xcassets/QRplaceholder.imageset/\364\200\216\271.png" differ diff --git a/AsyncSwift/AsyncSwiftApp.swift b/AsyncSwift/AsyncSwiftApp.swift index feb2e88..0de48d7 100644 --- a/AsyncSwift/AsyncSwiftApp.swift +++ b/AsyncSwift/AsyncSwiftApp.swift @@ -10,19 +10,10 @@ import SwiftUI @main struct AsyncSwiftApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate - @ObservedObject var appData: AppData = AppData() - + var body: some Scene { WindowGroup { MainTabView() - .environmentObject(appData) - .onOpenURL { url in - if appData.checkLink(url: url) { - print("Success Link URL: \(url)") - } else { - print("Fail Link URL: \(url)") - } - } } } } diff --git a/AsyncSwift/Extensions/Color+.swift b/AsyncSwift/Extensions/Color+.swift index d52f3b8..51a3fe4 100644 --- a/AsyncSwift/Extensions/Color+.swift +++ b/AsyncSwift/Extensions/Color+.swift @@ -5,7 +5,6 @@ // Created by Kim Insub on 2022/09/09. // -import Foundation import SwiftUI extension Color { @@ -15,4 +14,15 @@ extension Color { static let emptyTicketViewForeground = Color("emptyTicketViewForeground") static let speakerBackground = Color("speakerBackground") static let skeletonBackground = Color("skeletonBackground") + static let unavailableButtonBackground = Color("unavailableButtonBackground") + static let placeholderForeground = Color("placeholderForeground") + static let profileGray = Color("profileGray") + static let buttonBackground = Color("buttonBackground") + static let profileFontGrayForeground = Color("6C6C70") + static let linkedInBlueBackground = Color("0A66C2") + static let inActiveButtonBackground = Color("E5E5EA") + static let skeletonQR = Color("AFAFAF") + static let skeletonName = Color("D9D9D9") + static let skeletonDescription = Color("EAEAEA") + static let asyncBlue = Color("1920FF") } diff --git a/AsyncSwift/Extensions/SafariView.swift b/AsyncSwift/Extensions/SafariView.swift new file mode 100644 index 0000000..5f87f9b --- /dev/null +++ b/AsyncSwift/Extensions/SafariView.swift @@ -0,0 +1,18 @@ +// +// SafariView.swift +// AsyncSwift +// +// Created by Kim Insub on 2022/11/04. +// + +import SwiftUI +import SafariServices + +struct SafariView: UIViewControllerRepresentable { + let url: URL + func makeUIViewController(context: UIViewControllerRepresentableContext) -> SFSafariViewController { + return SFSafariViewController(url: url) + } + + func updateUIViewController(_ uiViewController: SFSafariViewController, context: UIViewControllerRepresentableContext) { } +} diff --git a/AsyncSwift/Extensions/String+.swift b/AsyncSwift/Extensions/String+.swift new file mode 100644 index 0000000..c32fab5 --- /dev/null +++ b/AsyncSwift/Extensions/String+.swift @@ -0,0 +1,24 @@ +// +// Any+.swift +// AsyncSwift +// +// Created by Inho Choi on 2022/10/07. +// + +import Foundation + +extension String { + func convertToStringArray() -> [String]? { + guard let stringData = self.data(using: .utf8) else { + return nil + } + + var result = [String]() + do { + result = try JSONDecoder().decode([String].self, from: stringData) + } catch { + return nil + } + return result + } +} diff --git a/AsyncSwift/Extensions/Text+.swift b/AsyncSwift/Extensions/Text+.swift new file mode 100644 index 0000000..9109b81 --- /dev/null +++ b/AsyncSwift/Extensions/Text+.swift @@ -0,0 +1,15 @@ +// +// Text+.swift +// AsyncSwift +// +// Created by Kim Insub on 2022/10/17. +// + +import SwiftUI + +extension Text { + var profileInputTitle: some View { + self.font(.headline) + .frame(minWidth: 58, minHeight: 18, alignment: .leading) + } +} diff --git a/AsyncSwift/Extensions/TextEditor+.swift b/AsyncSwift/Extensions/TextEditor+.swift new file mode 100644 index 0000000..7d576d8 --- /dev/null +++ b/AsyncSwift/Extensions/TextEditor+.swift @@ -0,0 +1,16 @@ +// +// TextEditor+.swift +// AsyncSwift +// +// Created by Kim Insub on 2022/10/28. +// + +import SwiftUI + +extension TextEditor { + var profileTextEditor: some View { + self + .font(.subheadline) + .frame(height: 53) + } +} diff --git a/AsyncSwift/Extensions/TextField+.swift b/AsyncSwift/Extensions/TextField+.swift new file mode 100644 index 0000000..03484a4 --- /dev/null +++ b/AsyncSwift/Extensions/TextField+.swift @@ -0,0 +1,16 @@ +// +// TextField+.swift +// AsyncSwift +// +// Created by Kim Insub on 2022/10/20. +// + +import SwiftUI + +extension TextField { + var profileTextField: some View { + self + .font(.subheadline) + .frame(minHeight: 20) + } +} diff --git a/AsyncSwift/Extensions/View+.swift b/AsyncSwift/Extensions/View+.swift index 7413665..bc9c41c 100644 --- a/AsyncSwift/Extensions/View+.swift +++ b/AsyncSwift/Extensions/View+.swift @@ -15,4 +15,20 @@ extension View { .frame(height: 3) .edgesIgnoringSafeArea(.horizontal) } + + func placeholder( + when shouldShow: Bool, + text: String, + isTextField: Bool + ) -> some View { + ZStack(alignment: .leading) { + Text(text) + .font(.subheadline) + .foregroundColor(.placeholderForeground) + .frame(height: 20) + .opacity(shouldShow ? 1 : 0) + .offset(x: isTextField ? 0.0 : 3.0, y: isTextField ? 0.0 : -8.0) + self + } + } } diff --git a/AsyncSwift/Info.plist b/AsyncSwift/Info.plist index ad1822a..2d2ccc1 100644 --- a/AsyncSwift/Info.plist +++ b/AsyncSwift/Info.plist @@ -2,6 +2,11 @@ + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + CFBundleURLTypes @@ -27,7 +32,6 @@ UIBackgroundModes - fetch remote-notification diff --git a/AsyncSwift/Managers/FirebaseManager.swift b/AsyncSwift/Managers/FirebaseManager.swift new file mode 100644 index 0000000..ea31d78 --- /dev/null +++ b/AsyncSwift/Managers/FirebaseManager.swift @@ -0,0 +1,93 @@ +// +// FirebaseManager.swift +// AsyncSwift +// +// Created by Kim Insub on 2022/11/03. +// + +import Firebase +import Foundation + +final class FirebaseManager: ObservableObject { + static let shared = FirebaseManager() + let db = Firestore.firestore() + private init() { } +} + +extension FirebaseManager { + func createUser(user: User) { + let docRef = db.collection("users").document(user.id) + let docData: [String: Any] = [ + "id": user.id, + "name": user.name, + "nickname": user.nickname, + "role": user.role, + "description": user.description, + "linkedInURL": user.linkedInURL, + "profileURL": user.profileURL, + "friends": [] + ] + docRef.setData(docData) { error in + if let error = error { + // TODO: error 일 경우 Alert Message 보내기 + print("Error writing document: \(error)") + } else { + print("Document successfully written") + } + } + } + + func getUserBy(id: String, completion: @escaping (User) -> Void) { + let docRef = db.collection("users").document(id) + docRef.getDocument { (document, error) in + guard error == nil, + let document = document, + document.exists, + let data = document.data(), + let id = data["id"] as? String, // TODO : Optional 에서 변경하기 + let name = data["name"] as? String, + let nickname = data["nickname"] as? String, + let role = data["role"] as? String, + let description = data["description"] as? String, + let linkedInURL = data["linkedInURL"] as? String, + let profileURL = data["profileURL"] as? String, + let friends = data["friends"] as? [String] + else { return } + + let user = User( + id: id, + name: name, + nickname: nickname, + role: role, + description: description, + linkedInURL: linkedInURL, + profileURL: profileURL, + friends: friends + ) + completion(user) + } + } + + func editUser(user: User) { + let docRef = db.collection("users").document(user.id) + let docData: [String: Any] = [ + "id": user.id, + "name": user.name, + "nickname": user.nickname, + "role": user.role, + "description": user.description, + "linkedInURL": user.linkedInURL, + "profileURL": user.profileURL, + "friends": user.friends + ] + + docRef.setData(docData) { error in + if let error = error { + // TODO: error 일 경우 Alert Message 보내기 + print("Error writing document: \(error)") + } else { + print("Document successfully editted") + } + } + } +} diff --git a/AsyncSwift/Observed/KeyChain.swift b/AsyncSwift/Managers/KeyChainManager.swift similarity index 72% rename from AsyncSwift/Observed/KeyChain.swift rename to AsyncSwift/Managers/KeyChainManager.swift index 704dcfc..57a24a2 100644 --- a/AsyncSwift/Observed/KeyChain.swift +++ b/AsyncSwift/Managers/KeyChainManager.swift @@ -5,19 +5,15 @@ // Created by Inho Choi on 2022/09/15. // -import Foundation import UIKit -final class KeyChain { - static let shared = KeyChain() - - private init() { } - - func addItem(key: Any, pwd: Any) -> Bool { +final class KeyChainManager { + let stampKey = "AsyncSwiftStamp" + + @discardableResult func addItem(key: Any, pwd: Any) -> Bool { let addQuery: [CFString: Any] = [kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecValueData: (pwd as AnyObject).data(using: String.Encoding.utf8.rawValue) as Any] - let status = SecItemAdd(addQuery as CFDictionary, nil) switch status { @@ -32,10 +28,12 @@ final class KeyChain { } func getItem(key: Any) -> Any? { - let getQuery: [CFString: Any] = [kSecClass: kSecClassGenericPassword, - kSecAttrAccount: key, - kSecReturnAttributes: true, - kSecReturnData: true] + let getQuery: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: key, + kSecReturnAttributes: true, + kSecReturnData: true + ] var item: CFTypeRef? let result = SecItemCopyMatching(getQuery as CFDictionary, &item) @@ -50,8 +48,10 @@ final class KeyChain { } func updateItem(value: Any, key: Any) -> Bool { - let prevQuery: [CFString: Any] = [kSecClass: kSecClassGenericPassword, - kSecAttrAccount: key] + let prevQuery: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: key + ] let updateQuery: [CFString: Any] = [kSecValueData: (value as AnyObject).data(using: String.Encoding.utf8.rawValue) as Any] let result: Bool = { @@ -63,8 +63,10 @@ final class KeyChain { } func deleteItem(key: String) -> Bool { - let deleteQuery: [CFString: Any] = [kSecClass: kSecClassGenericPassword, - kSecAttrAccount: key] + let deleteQuery: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: key + ] let status = SecItemDelete(deleteQuery as CFDictionary) if status == errSecSuccess { return true diff --git a/AsyncSwift/Models/Stamp.swift b/AsyncSwift/Models/Stamp.swift index d50375b..4e6873c 100644 --- a/AsyncSwift/Models/Stamp.swift +++ b/AsyncSwift/Models/Stamp.swift @@ -5,8 +5,14 @@ // Created by Inho Choi on 2022/09/15. // -import Foundation +import SwiftUI struct Stamp: Decodable { let title: String } + +struct Card { + var originalPosition: CGFloat + var isSelected = false + var image: Image +} diff --git a/AsyncSwift/Models/Tab.swift b/AsyncSwift/Models/Tab.swift new file mode 100644 index 0000000..c45022c --- /dev/null +++ b/AsyncSwift/Models/Tab.swift @@ -0,0 +1,38 @@ +// +// Tab.swift +// AsyncSwift +// +// Created by Inho Choi on 2022/10/08. +// + +import SwiftUI + +enum Tab: String, CaseIterable { + case event = "Event" + case ticketing = "Ticketing" + case stamp = "Stamp" + case profile = "Profile" + + var title: String { + rawValue + } + + var systemImageName: String { + switch self { + case .event: return "calendar" + case .ticketing: return "banknote" + case .stamp: return "checkmark.square" + case .profile: return "person.circle.fill" + } + } + + @ViewBuilder + var view: some View { + switch self { + case .event: EventView() + case .ticketing: TicketingView() + case .stamp: StampView() + case .profile: ProfileView() + } + } +} diff --git a/AsyncSwift/Models/User.swift b/AsyncSwift/Models/User.swift new file mode 100644 index 0000000..756bc1e --- /dev/null +++ b/AsyncSwift/Models/User.swift @@ -0,0 +1,50 @@ +// +// User.swift +// AsyncSwift +// +// Created by Kim Insub on 2022/11/03. +// + +import Foundation + +struct User: Identifiable { + init() { + self.id = "" + self.name = "" + self.nickname = "" + self.role = "" + self.description = "" + self.linkedInURL = "" + self.profileURL = "" + self.friends = [] + } + + init( + id: String, + name: String, + nickname: String, + role: String, + description: String, + linkedInURL: String, + profileURL: String, + friends: [String] + ) { + self.id = id + self.name = name + self.nickname = nickname + self.role = role + self.description = description + self.linkedInURL = linkedInURL + self.profileURL = profileURL + self.friends = friends + } + + var id: String + var name: String + var nickname: String + var role: String + var description: String + var linkedInURL: String + var profileURL: String + var friends: [String] +} diff --git a/AsyncSwift/Observed/AppData.swift b/AsyncSwift/Observed/AppData.swift deleted file mode 100644 index bf1e865..0000000 --- a/AsyncSwift/Observed/AppData.swift +++ /dev/null @@ -1,107 +0,0 @@ -// -// AppData.swift -// AsyncSwift -// -// Created by Inho Choi on 2022/09/08. -// - -import SwiftUI -import UIKit - -final class AppData: ObservableObject { - /// Universal Link로 앱진입시 StampView 전환을 위한 변수 - @Published var currentTab: Tab = .event - - private var currentStamp: Stamp? - var isStampExist: Bool { - if currentStamp == nil { - fetchCurrentStamp() - } - - return KeyChain.shared.getItem(key: currentStamp?.title) != nil - } - - init(){ - fetchCurrentStamp() - } - - func checkLink(url: URL) -> Bool { - // URL Example = https://asyncswift.info?tab=Stamp - // URL Example = https://asyncswift.info?tab=Event - guard URLComponents(url: url, resolvingAgainstBaseURL: true)?.host != nil else { return false } - var queries = [String: String]() - for item in URLComponents(url: url, resolvingAgainstBaseURL: true)?.queryItems ?? [] { - queries[item.name] = item.value - } - - guard let currentStampName = currentStamp?.title else { return false } - - switch queries["tab"] { - case Tab.stamp.rawValue: - KeyChain.shared.addItem(key: currentStampName, pwd: "true") ? print("Adding Stamp History KeyChain is Success") : print("Adding Stamp History is Fail") - currentTab = .stamp - case Tab.event.rawValue: - currentTab = .event - default: - return false - } - - return true - } - - private func fetchCurrentStamp() { - guard - let url = URL(string: "https://raw.githubusercontent.com/Async-Swift/jsonstorage/main/stamp.json") - else { return } - - - let request = URLRequest(url: url) - let dataTask = URLSession.shared.dataTask(with: request) { data, response, _ in - guard - let response = response as? HTTPURLResponse, - response.statusCode == 200, - let data = data - else { return } - - DispatchQueue.main.async { [weak self] in - guard let self = self else {return } - do { - print(data) - let stamp = try JSONDecoder().decode(Stamp.self, from: data) - self.currentStamp = stamp - } catch { - self.currentStamp = nil - } - } - } - dataTask.resume() - } -} - - -enum Tab: String, CaseIterable { - case event = "Event" - case ticketing = "Ticketing" - case stamp = "Stamp" - - var title: String { - rawValue - } - - var systemImageName: String { - switch self { - case .event: return "calendar" - case .ticketing: return "banknote" - case .stamp: return "checkmark.square" - } - } - - @ViewBuilder - var view: some View { - switch self { - case .event: EventView() - case .ticketing: TicketingView() - case .stamp: StampView() - } - } -} diff --git a/AsyncSwift/Observed/EventView+Observed.swift b/AsyncSwift/Observed/EventView+Observed.swift index b595a4d..21f00e9 100644 --- a/AsyncSwift/Observed/EventView+Observed.swift +++ b/AsyncSwift/Observed/EventView+Observed.swift @@ -7,64 +7,74 @@ import SwiftUI -extension EventView { - final class Observed: ObservableObject { +final class EventViewObserved: ObservableObject { - @Published var event = Event() - @Published var eventStatus: EventStatus = .upcoming + @Published var event = Event() + @Published var eventStatus: EventStatus = .upcoming + @Published var isLoading = true + let onLoadingCells = Array(repeating: [0], count: 6) - init() { - fetchJson() + init() { + self.fetchJson { + self.calculateEventStatus() + self.isLoading = false } + } - func fetchJson() { - guard let url = URL(string: "https://async-swift.github.io/jsonstorage/asyncswift.json") else { return } - let request = URLRequest(url: url) - let dataTask = URLSession.shared.dataTask(with: request) { data, response, _ in - guard - let response = response as? HTTPURLResponse, - response.statusCode == 200, - let data = data - else { return } - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - do { - let decodedData = try JSONDecoder().decode(Event.self, from: data) - withAnimation { - self.event = decodedData - } - self.calculateEventStatus() - } catch let error { - print("❌ \(error.localizedDescription)") - } + func fetchJson(completion: @escaping () -> Void) { + guard let url = URL(string: "https://async-swift.github.io/jsonstorage/asyncswift.json") else { return } + let request = URLRequest(url: url) + let dataTask = URLSession.shared.dataTask(with: request) { data, response, _ in + guard + let response = response as? HTTPURLResponse, + response.statusCode == 200, + let data = data + else { return } + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + do { + let decodedData = try JSONDecoder().decode(Event.self, from: data) + self.event = decodedData + completion() + } catch let error { + print("❌ \(error.localizedDescription)") } - } - dataTask.resume() + } } + dataTask.resume() + } - func calculateEventStatus() { - let formatter = DateFormatter.calendarFormatter - guard - let start = formatter.date(from: event.startDate), - let end = formatter.date(from: event.endDate) - else { return } - let currentDate = Date() + func calculateEventStatus() { + let formatter = DateFormatter.calendarFormatter + guard + let start = formatter.date(from: event.startDate), + let end = formatter.date(from: event.endDate) + else { return } + let currentDate = Date() - if currentDate < start { - self.eventStatus = .upcoming - } else if start <= currentDate && currentDate < end { - self.eventStatus = .onProgress - } else if currentDate > end { - self.eventStatus = .done - } + if currentDate < start { + self.eventStatus = .upcoming + } else if start <= currentDate && currentDate < end { + self.eventStatus = .onProgress + } else if currentDate > end { + self.eventStatus = .done } } } -extension EventView { + +extension EventViewObserved { enum EventStatus: String { case upcoming = "예정된 행사" case onProgress = "진행중인 행사" case done = "지나간 행사" + + var statusColor: Color { + switch self { + case .upcoming: return Color.accentColor + case .onProgress: return Color.asyncBlue + case .done: return Color.black + } + } } } diff --git a/AsyncSwift/Observed/MainTabView+Observed.swift b/AsyncSwift/Observed/MainTabView+Observed.swift new file mode 100644 index 0000000..d061259 --- /dev/null +++ b/AsyncSwift/Observed/MainTabView+Observed.swift @@ -0,0 +1,29 @@ +// +// AppData.swift +// AsyncSwift +// +// Created by Inho Choi on 2022/09/08. +// + +import SwiftUI + +final class MainTabViewObserved: ObservableObject { + /// Universal Link로 앱진입시 StampView 전환을 위한 변수 + @Published var currentTab: Tab = .event + private let keyChainManager = KeyChainManager() + + init() { + fixKeyChain() + } + + + // MARK: 버전 1의 실수를 바로 잡습니다. @Toby + /// "seminar002"가 key로 들어가 있던 기존 코드를 삭제하는 함수입니다. + /// - KeyChain에 저장되는 방식을 개선하고자 함수가 구현되었습니다. + func fixKeyChain() { + let isKeyDelete = keyChainManager.deleteItem(key: "seminar002") + if isKeyDelete { + keyChainManager.addItem(key: keyChainManager.stampKey, pwd: ["Seminar002"].description) + } + } +} diff --git a/AsyncSwift/Observed/ProfileView/ProfileEditViewObserved.swift b/AsyncSwift/Observed/ProfileView/ProfileEditViewObserved.swift new file mode 100644 index 0000000..6bbd1d9 --- /dev/null +++ b/AsyncSwift/Observed/ProfileView/ProfileEditViewObserved.swift @@ -0,0 +1,123 @@ +// +// ProfileEditViewObserved.swift +// AsyncSwift +// +// Created by Kim Insub on 2022/11/04. +// + +import UIKit + +@MainActor +final class ProfileEditViewObserved: ObservableObject { + + @Published var isShowingSuccessAlert = false + @Published var isShowingFailureAlert = false + @Published var isShowingInputFailureAlert = false + + @Published var user: User + @Published var description = "" { + didSet { + if description.count >= 80 { + description = oldValue + } + } + } + @Published var linkedInURL = "" { + didSet { + print(self.verifyURL(urlString: profileURL)) + self.isLinkedinURLValidated = self.verifyURL(urlString: linkedInURL) + } + } + @Published var profileURL = "" { + didSet { + print(self.verifyURL(urlString: profileURL)) + self.isProfileURLValidated = self.verifyURL(urlString: profileURL) + } + } + var isLinkedinURLValidated = true + var isProfileURLValidated = true + + init(user: User) { + self.description = user.description + self.linkedInURL = user.linkedInURL + self.profileURL = user.profileURL + self.user = user + } + + func didTapRegisterButton() { + register() + } + + func isButtonAvailable() -> Bool { + !user.name.isEmpty && !user.role.isEmpty + } +} + +private extension ProfileEditViewObserved { + func register() { + guard isButtonAvailable() else { return } + if !linkedInURL.isEmpty && !profileURL.isEmpty { + if isLinkedinURLValidated && isProfileURLValidated { + handleSuccess() + } else { + showFailureAlert() + } + } else if !linkedInURL.isEmpty { + if isLinkedinURLValidated { + handleSuccess() + } else { + showFailureAlert() + } + } else if !profileURL.isEmpty { + if isProfileURLValidated { + handleSuccess() + } else { + showFailureAlert() + } + } else { + handleSuccess() + } + } + + func handleSuccess() { + Task { + await editUser() + showSuccessAlert() + } + } + + func showSuccessAlert() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.isShowingSuccessAlert = true + } + } + + func showFailureAlert() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.isShowingInputFailureAlert = true + } + } + + func editUser() async { + let user = User( + id: user.id, + name: user.name, + nickname: user.nickname, + role: user.role, + description: description, + linkedInURL: linkedInURL, + profileURL: profileURL, + friends: user.friends + ) + FirebaseManager.shared.editUser(user: user) + } + + func verifyURL (urlString: String?) -> Bool { + guard let urlString = urlString, + let url = NSURL(string: urlString) + else { return false } + return UIApplication.shared.canOpenURL(url as URL) + } +} diff --git a/AsyncSwift/Observed/ProfileView/ProfileFriendDetailViewObserved.swift b/AsyncSwift/Observed/ProfileView/ProfileFriendDetailViewObserved.swift new file mode 100644 index 0000000..3e1e97e --- /dev/null +++ b/AsyncSwift/Observed/ProfileView/ProfileFriendDetailViewObserved.swift @@ -0,0 +1,70 @@ +// +// ProfileFriendDetailViewObserved.swift +// AsyncSwift +// +// Created by Kim Insub on 2022/11/04. +// + +import SwiftUI + +enum PreviousView { + case ProfileView + case ListView +} + +@MainActor +final class ProfileFriendDetailViewObserved: ObservableObject { + @Binding var inActive: Bool + @Published var isShowingLinkedInSheet = false + @Published var isShowingProfileSheet = false + @Published var isShowingConfirmAlert = false + let previous: PreviousView + let friend: User + var user: User + var profileURL: URL? { + URL(string: friend.profileURL) + } + var linkedInURL: URL? { + URL(string: friend.linkedInURL) + } + var hasProfileURL: Bool { + get { + !friend.profileURL.isEmpty + } + } + var hasLinkedInURL: Bool { + get { + !friend.linkedInURL.isEmpty + } + } + + init(inActive: Binding, user: User, friend: User, previous: PreviousView) { + self._inActive = inActive + self.friend = friend + self.user = user + self.previous = previous + } + + func didTapDoneButton() { + inActive = false + } + + func didTapDeleteButton() { + isShowingConfirmAlert = true + } + + func didConfirmDelete() { + Task { + await removeFriendFromList() + inActive = false + } + } +} + +private extension ProfileFriendDetailViewObserved { + func removeFriendFromList() async { + let removedList = user.friends.filter { $0 != friend.id } + user.friends = removedList + FirebaseManager.shared.editUser(user: user) + } +} diff --git a/AsyncSwift/Observed/ProfileView/ProfileFriendsListViewObserved.swift b/AsyncSwift/Observed/ProfileView/ProfileFriendsListViewObserved.swift new file mode 100644 index 0000000..62a6ae8 --- /dev/null +++ b/AsyncSwift/Observed/ProfileView/ProfileFriendsListViewObserved.swift @@ -0,0 +1,104 @@ +// +// ProfileFriendsListViewObserved.swift +// AsyncSwift +// +// Created by Kim Insub on 2022/11/03. +// + +import CodeScanner +import Combine +import SwiftUI + +@MainActor +final class ProfileFriendsListViewObserved: ObservableObject { + @Binding var inActive: Bool + @Published var isShowingUserDetail = false { + didSet { + if isShowingUserDetail == false { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.inActive = false + } + } + } + } + @Published var isLoading = true + @Published var isShowingScanner = false + @Published var isShowingScanErrorAlert = false + @Published var friendsList: [User] = [] + @Published var scannedFriend: User = .init() + + var user: User + + init(inActive: Binding, user: User) { + self._inActive = inActive + self.user = user + } + + func onAppear() { + Task { + await getFriendsByID() + isLoading = false + } + } + + func didTapXButton() { + isShowingScanner = false + } + + func handleScan(result: Result) { + switch result { + case .success(let success): + let uuidString = success.string + handleScanSuccess(id: uuidString) + case .failure(_): + isShowingScanner = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + guard let self = self else { return } + self.isShowingScanErrorAlert = true + } + } + } +} + +private extension ProfileFriendsListViewObserved { + func handleScanSuccess(id: String) { + guard UUID(uuidString: id) != nil + else { + isShowingScanner = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + guard let self = self else { return } + self.isShowingScanErrorAlert = true + } + return + } + Task { + user.friends.append(id) + FirebaseManager.shared.editUser(user: self.user) + await getFriendByID(id: id) + isShowingScanner = false + isShowingUserDetail = true + } + } + + func getFriendByID(id: String) async { + FirebaseManager.shared.getUserBy(id: id) { [weak self] user in + guard let self = self else { return } + self.scannedFriend = user + } + } + + func getFriendsByID() async { + friendsList = [] + for friendID in self.user.friends { + FirebaseManager.shared.getUserBy(id: friendID) { [weak self] user in + guard let self = self else { return } + self.friendsList.append(user) + } + } + } + + func isNewFriend(id: String) -> Bool { + return !user.friends.contains(id) + } +} diff --git a/AsyncSwift/Observed/ProfileView/ProfileRegisterViewObserved.swift b/AsyncSwift/Observed/ProfileView/ProfileRegisterViewObserved.swift new file mode 100644 index 0000000..436019f --- /dev/null +++ b/AsyncSwift/Observed/ProfileView/ProfileRegisterViewObserved.swift @@ -0,0 +1,133 @@ +// +// ProfileRegisterViewObserved.swift +// AsyncSwift +// +// Created by Kim Insub on 2022/10/28. +// + +import Combine +import SwiftUI + +@MainActor +final class ProfileRegisterViewObserved: ObservableObject { + @Binding var hasRegisteredProfile: Bool + @Binding var userID: String? + + @Published var isShowingSuccessAlert = false + @Published var isShowingFailureAlert = false + @Published var isShowingInputFailureAlert = false + + @Published var name = "" + @Published var nickname = "" + @Published var role = "" + @Published var description = "" { + didSet { + if description.count >= 80 { + description = oldValue + } + } + } + @Published var linkedInURL = "" { + didSet { + self.isLinkedinURLValidated = self.verifyURL(urlString: linkedInURL) + } + } + @Published var profileURL = "" { + didSet { + self.isProfileURLValidated = self.verifyURL(urlString: profileURL) + } + } + var isLinkedinURLValidated = false + var isProfileURLValidated = false + + init(hasRegisteredProfile: Binding, userID: Binding) { + self._hasRegisteredProfile = hasRegisteredProfile + self._userID = userID + } + + func didTapRegisterButton() { + register() + } + + func isButtonAvailable() -> Bool { + if !name.isEmpty && !role.isEmpty { + return true + } else { + return false + } + } +} + +private extension ProfileRegisterViewObserved { + + func register() { + guard isButtonAvailable() else { return } + if !linkedInURL.isEmpty && !profileURL.isEmpty { + if isLinkedinURLValidated && isProfileURLValidated { + handleSuccess() + } else { + showFailureAlert() + } + } else if !linkedInURL.isEmpty { + if isLinkedinURLValidated { + handleSuccess() + } else { + showFailureAlert() + } + } else if !profileURL.isEmpty { + if isProfileURLValidated { + handleSuccess() + } else { + showFailureAlert() + } + } else { + handleSuccess() + } + } + + func handleSuccess() { + Task { + await createUser() + hasRegisteredProfile = true + showSuccessAlert() + } + } + + func showSuccessAlert() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.isShowingSuccessAlert = true + } + } + + func showFailureAlert() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.isShowingInputFailureAlert = true + } + } + + + func createUser() async { + let userId = UUID().uuidString + let user = User( + id: userId, + name: name, + nickname: nickname, + role: role, + description: description, + linkedInURL: linkedInURL, + profileURL: profileURL, + friends: [] + ) + self.userID = userId + FirebaseManager.shared.createUser(user: user) + } + + func verifyURL (urlString: String?) -> Bool { + guard let urlString = urlString, + let url = NSURL(string: urlString) + else { return false } + return UIApplication.shared.canOpenURL(url as URL) + } +} diff --git a/AsyncSwift/Observed/ProfileView/ProfileViewObserved.swift b/AsyncSwift/Observed/ProfileView/ProfileViewObserved.swift new file mode 100644 index 0000000..c8c0799 --- /dev/null +++ b/AsyncSwift/Observed/ProfileView/ProfileViewObserved.swift @@ -0,0 +1,127 @@ +// +// ProfileView+Observed.swift +// AsyncSwift +// +// Created by Kim Insub on 2022/10/16. +// + +import CodeScanner +import CoreImage.CIFilterBuiltins +import Combine +import UIKit + +@MainActor +final class ProfileViewObserved: ObservableObject { + @Published var hasRegisteredProfile = false + @Published var isLoading = true + @Published var isShowingFriends = false + @Published var isShowingEdit = false + @Published var isShowingScanner = false + @Published var isShowingUserDetail = false + @Published var isShowingFailureAlert = false + @Published var isShowingScanErrorAlert = false + @Published var user: User = .init() + @Published var scannedFriend: User = .init() + private let keyChainManager = KeyChainManager() + + var userID: String? { + didSet { + let _ = keyChainManager.addItem(key: "userID", pwd: userID ?? "") + } + } + + init() { + guard let userid = keyChainManager.getItem(key: "userID") else { return } + self.hasRegisteredProfile = true + self.userID = userid as? String + } + + func onAppear() { + guard hasRegisteredProfile else { return } + Task { + await getUserByID() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + guard let self = self else { return } + self.isLoading = false + } + } + } + + func didTapCloseButton() { + isShowingScanner = false + } + + func getQRCodeImage() -> UIImage { + let context = CIContext() + let filter = CIFilter.qrCodeGenerator() + let data = Data(userID?.utf8 ?? "".utf8) + filter.setValue(data, forKey: "inputMessage") + guard let qrCodeImage = filter.outputImage, + let qrCodeImage = context.createCGImage(qrCodeImage, from: qrCodeImage.extent) + else { return UIImage(systemName: "xmark") ?? UIImage() } + return UIImage(cgImage: qrCodeImage) + } + + func handleScan(result: Result) { + switch result { + case .success(let success): + let uuidString = success.string + handleScanSuccess(id: uuidString) + case .failure(_): + isShowingScanner = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + guard let self = self else { return } + self.isShowingScanErrorAlert = true + } + } + } +} + +private extension ProfileViewObserved { + func handleScanSuccess(id: String) { + guard (UUID(uuidString: id)) != nil + else { + isShowingScanner = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + guard let self = self else { return } + self.isShowingScanErrorAlert = true + } + return + } + guard isNewFriend(id: id) + else { + isShowingScanner = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + guard let self = self else { return } + self.isShowingFailureAlert = true + } + return + } + Task { + user.friends.append(id) + FirebaseManager.shared.editUser(user: self.user) + await getFriendByID(id: id) + isShowingScanner = false + isShowingUserDetail = true + } + } + + func getUserByID() async { + FirebaseManager.shared.getUserBy(id: self.userID ?? "") { [weak self] user in + guard let self = self else { return } + self.user = user + } + } + + func getFriendByID(id: String) async { + FirebaseManager.shared.getUserBy(id: id) { [weak self] user in + guard let self = self else { return } + self.scannedFriend = user + } + } + + func isNewFriend(id: String) -> Bool { + return !user.friends.contains(id) + } +} + diff --git a/AsyncSwift/Observed/StampView+Observed.swift b/AsyncSwift/Observed/StampView+Observed.swift index f6da6b3..1ffc1bd 100644 --- a/AsyncSwift/Observed/StampView+Observed.swift +++ b/AsyncSwift/Observed/StampView+Observed.swift @@ -8,40 +8,137 @@ import SwiftUI extension StampView { - final class Observed: ObservableObject { - @Published var cardAnimatonModel = CardAnimationModel() + @MainActor final class Observed: ObservableObject { + @Published var cards = [String: Card]() + @Published var events = [String]() + @Published var currentIndex = 0 + private let keyChainManager = KeyChainManager() + private let cardInterval: CGFloat = (UIScreen.main.bounds.width - 32) * 56 / 358 + private let cardSize: CGFloat = UIScreen.main.bounds.width - 32 - private let durationAndDelay: CGFloat = 0.3 + init() { + fetchStampsImages() + } + + private func getEvents() -> [String] { + let pwRaw = keyChainManager.getItem(key: keyChainManager.stampKey) as? String + guard var convertedStringArray = pwRaw?.convertToStringArray() else { return [] } + convertedStringArray.insert("Next", at: 0) // MARK: Test 실제에서는 Next storage 둘다 설정해야함 + self.events = convertedStringArray.reversed() + return events + } - func didTabCard () { - cardAnimatonModel.isTapped.toggle() + /// Storage에 저장되어 있는 Stamp Image를 가져오는 함수이다. + /// - + private func fetchStampsImages(){ + let events = getEvents() - if cardAnimatonModel.isTapped { // 카드 회전 연속을 위해서 if문 분리 - withAnimation(.linear(duration: durationAndDelay)) { - cardAnimatonModel.backDegree = 90 + events.enumerated().forEach { [weak self] in + guard let self = self else { return } + let event = $0.element + let index = $0.offset + Task { @MainActor () -> Void in + guard let cardImageURL = URL(string: "https://raw.githubusercontent.com/Async-Swift/jsonstorage/main/Images/Stamp/" + event + "/stamp.png") + else { return } + + let cardImageRequest = URLRequest(url: cardImageURL) + let (cardImageData, cardImageResponse) = try await URLSession.shared.data(for: cardImageRequest) + guard let httpsResponse = cardImageResponse as? HTTPURLResponse, httpsResponse.statusCode == 200 else { return } + + guard let cardUIImage = UIImage(data: cardImageData) else { return } + + self.cards[event] = Card(originalPosition: cardInterval * CGFloat(index), + image: Image(uiImage: cardUIImage)) + // 가장 최근의 EventCard가 선택된 상태로 지정하기 + if index == 0 { + self.cards[event]?.isSelected = true + } else { + self.cards[event]?.isSelected = false + } } - withAnimation(.linear(duration: durationAndDelay).delay(durationAndDelay)) { - cardAnimatonModel.frontDegree = 0 + } + } + + func isAvailableURL(url: URL) async -> Bool { + // URL Example = https://asyncswift.info?tab=Stamp&event=Conference001 + // URL Example = https://asyncswift.info?tab=Event + + if URLComponents(url: url, resolvingAgainstBaseURL: true)?.host == nil { return false } + var queries = [String: String]() + for item in URLComponents(url: url, resolvingAgainstBaseURL: true)?.queryItems ?? [] { + queries[item.name] = item.value + } + + do { + let stamp = try await fetchCurrentStamp() + + if queries["tab"] == Tab.stamp.rawValue { + guard let queryEvent = queries["event"] else { return false } + if stamp.title == queryEvent { + let pwRaw = keyChainManager.getItem(key: keyChainManager.stampKey) as? String + var pw: [String] = pwRaw?.convertToStringArray() ?? [] + pw.append(queryEvent) + + if keyChainManager.addItem(key: keyChainManager.stampKey, pwd: pw.description) { + fetchStampsImages() + } + } } - } else { - withAnimation(.linear(duration: durationAndDelay)) { - cardAnimatonModel.frontDegree = -90 + } catch { + print(error.localizedDescription) + return false + } + return true + } + + private func fetchCurrentStamp() async throws -> Stamp { + guard let url = URL(string: "https://raw.githubusercontent.com/Async-Swift/jsonstorage/main/currentEvent.json") // MARK: URL 주소 확인 테스트용으로 되어 있음 + else { return .init(title: "error") } + + let request = URLRequest(url: url) + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { return .init(title: "error")} + + let stamp = try JSONDecoder().decode(Stamp.self, from: data) + + return stamp + } + + /// 가장 맨 위에 올라온 카드라면 아무것도 작동안하도록, 아니라면 가장 맨위로 올도록 하는 함수입니다. + func didCardTapped(index: Int, scrollReader: ScrollViewProxy) { + if index != currentIndex { + scrollReader.scrollTo(0, anchor: .init(x: 0, y: 94)) + withAnimation(.spring()) { + cards[events[index]]?.isSelected = true + cards[events[currentIndex]]?.isSelected = false + currentIndex = index } - withAnimation(.linear(duration: durationAndDelay).delay(durationAndDelay)) { - cardAnimatonModel.backDegree = 0 + } + } + + /// 카드의 개수에 따라서 카드의 위치를 지정해주는 함수입니다. + func getCardOffsetY(index: Int, size: CGSize) -> CGFloat { + withAnimation(.spring()) { + guard let card = cards[events[index]] else { return .zero} + if card.isSelected { + return .zero + } else if size.height - CGFloat(94) < cardSize + CGFloat(16) + cardInterval * CGFloat(cards.count - 1) { + return cardSize + CGFloat(16) + card.originalPosition + } else { + return size.height - CGFloat(94) - cardInterval * CGFloat(cards.count - index) - CGFloat(8) } } } - } - - struct CardAnimationModel: Identifiable { - fileprivate init() {} - let id = UUID() - var backDegree: Double = 0.0 - var frontDegree: Double = -90.0 - var isTapped = false + /// 스크롤을 원할하게 하기 위해서 Offset 으로 원래 크기 보다 밀려난 만큼 Spacer로 확보해줍니다. + func getSpacerMinLength(size: CGSize) -> CGFloat { + if size.height - CGFloat(94) < cardSize + CGFloat(16) + cardInterval * CGFloat(cards.count - 1) { + return cardSize + CGFloat(16) + cardInterval * CGFloat(cards.count - 1) + cardSize + } else { + return size.height - CGFloat(94) - cardInterval + } + } } } - - diff --git a/AsyncSwift/Views/EventView.swift b/AsyncSwift/Views/EventView.swift index bbaa32f..97af5fc 100644 --- a/AsyncSwift/Views/EventView.swift +++ b/AsyncSwift/Views/EventView.swift @@ -9,19 +9,26 @@ import SwiftUI struct EventView: View { - @ObservedObject var observed: Observed - - init() { - observed = Observed() - } + @StateObject var observed = EventViewObserved() var body: some View { NavigationView { ScrollView { - Header - LazyVStack { - ForEach(observed.event.sessions) { session in - makeSessionCell(for: session) + if observed.isLoading { + VStack(spacing: 0) { + onLoadingHeader + VStack(alignment: .leading, spacing: 0) { + ForEach(observed.onLoadingCells, id: \.self) { _ in + onLoadingCell + } + } + } + } else { + Header + LazyVStack { + ForEach(observed.event.sessions) { session in + makeSessionCell(for: session) + } } } } @@ -32,6 +39,38 @@ struct EventView: View { private extension EventView { + var onLoadingHeader: some View { + HStack { + VStack(alignment: .leading, spacing: 9) { + Rectangle() + .frame(width: 202, height: 30) + .cornerRadius(4) + .foregroundColor(.gray) + .opacity(0.8) + HStack { + Rectangle() + .frame(width: 151, height: 21) + .cornerRadius(13) + .foregroundColor(.gray) + Rectangle() + .frame(width: 70, height: 21) + .cornerRadius(13) + .foregroundColor(.gray) + } + .opacity(0.4) + .padding(.bottom, 2) + Rectangle() + .frame(width: 106, height: 13) + .cornerRadius(4) + .foregroundColor(.gray) + .opacity(0.2) + } + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 32) + } + var Header: some View { VStack(alignment: .leading, spacing: 8) { Text(observed.event.subject) @@ -49,12 +88,12 @@ private extension EventView { Text(observed.eventStatus.rawValue) .font(.caption2) .fontWeight(.bold) - .foregroundColor(Color.accentColor) + .foregroundColor(observed.eventStatus.statusColor) .padding(.vertical, 4) .padding(.horizontal, 8) .overlay( RoundedRectangle(cornerRadius: 20) - .stroke(Color.accentColor, lineWidth: 1) + .stroke(observed.eventStatus.statusColor, lineWidth: 1) ) Spacer() } @@ -70,6 +109,29 @@ private extension EventView { .padding(.vertical, 30) } + @ViewBuilder + var onLoadingCell: some View { + VStack(alignment: .leading, spacing: 0) { + customDivider + VStack(alignment: .leading, spacing: 0) { + Rectangle() + .frame(width: 250, height: 20) + .cornerRadius(4) + .foregroundColor(.gray) + .opacity(0.4) + .padding(.bottom, 4) + Rectangle() + .frame(width: 70, height: 20) + .cornerRadius(4) + .foregroundColor(.gray) + .opacity(0.2) + } + .padding(.horizontal) + .padding(.bottom, 27) + .padding(.top, 31) + } + } + @ViewBuilder func makeSessionCell(for session: Session) -> some View { NavigationLink { diff --git a/AsyncSwift/Views/MainTabView.swift b/AsyncSwift/Views/MainTabView.swift index 0d5e0b4..ff45c39 100644 --- a/AsyncSwift/Views/MainTabView.swift +++ b/AsyncSwift/Views/MainTabView.swift @@ -8,14 +8,20 @@ import SwiftUI struct MainTabView: View { - @EnvironmentObject var appData: AppData + @StateObject var observed = MainTabViewObserved() + + init() { + UITabBar.appearance().backgroundColor = UIColor.white + } + var body: some View { - TabView(selection: $appData.currentTab) { + TabView(selection: $observed.currentTab) { ForEach(Tab.allCases, id: \.self) { tab in tab.view.tabItem { Image(systemName: tab.systemImageName) Text(tab.title) } + .environmentObject(observed) } } } diff --git a/AsyncSwift/Views/Profile/ProfileEditView.swift b/AsyncSwift/Views/Profile/ProfileEditView.swift new file mode 100644 index 0000000..97eeb25 --- /dev/null +++ b/AsyncSwift/Views/Profile/ProfileEditView.swift @@ -0,0 +1,239 @@ +// +// ProfileEditView.swift +// AsyncSwift +// +// Created by Kim Insub on 2022/11/04. +// + +import SwiftUI + +struct ProfileEditView: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject var observed: ProfileEditViewObserved + + init(user: User) { + observed = ProfileEditViewObserved(user: user) + } + + var body: some View { + VStack(spacing: 0) { + Header + ScrollView { + VStack(spacing: 0) { + nameTextField + nicknameTextField + jobTitleTextField + descriptionTextField + linkedInTextField + privateURLTextField + } + } + Spacer() + } + .navigationBarTitle("Edit", displayMode: .large) + .toolbar { + submitButton + } + } +} + +private extension ProfileEditView { + var Header: some View { + VStack(spacing: 0) { + VStack(spacing: 0) { + HStack(spacing: 0) { + Text("프로필을 수정") + .font(.title3) + .fontWeight(.bold) + .frame(minHeight: 24) + .padding(.bottom, 3) + Spacer() + } + HStack(spacing: 0) { + Text("특수문자는 입력할 수 없어요") + .font(.footnote) + .frame(minHeight: 18) + Spacer() + } + } + .padding(.top, 38) + .padding(.bottom, 20) + .padding(.horizontal) + customDivider + } + .alert("프로필 등록 완료", isPresented: $observed.isShowingSuccessAlert, actions: { + Button("확인", role: .cancel) { dismiss() } + }, message: { + Text("개인 프로필이 수정되었습니다.") + }) + .alert("프로필 등록 오류", isPresented: $observed.isShowingFailureAlert, actions: { + Button("다시 시도", role: .cancel) { } + }, message: { + Text("입력되지 않은 내용이 있습니다.\n필수 입력란을 확인해주세요.") + }) + .alert("프로필 입력 오류", isPresented: $observed.isShowingInputFailureAlert, actions: { + Button("다시 시도", role: .cancel) { } + }, message: { + Text("확인되지 않은 주소입니다.\nURL을 확인해주세요.") + }) + } + + + var nameTextField: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + Text("이름") + .profileInputTitle + TextField("", text: $observed.user.name) + .profileTextField + .placeholder( + when: observed.user.name.isEmpty, + text: "Required", + isTextField: true + ) + Spacer() + } + customDivider + .padding(.top, 15) + } + .padding(.leading) + .padding(.top, 23) + } + + var nicknameTextField: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + Text("닉네임") + .profileInputTitle + TextField("", text: $observed.user.nickname) + .profileTextField + .placeholder( + when: observed.user.nickname.isEmpty, + text: "Optional", + isTextField: true + ) + Spacer() + } + customDivider + .padding(.top, 15) + } + .padding(.leading) + .padding(.top, 23) + } + + var jobTitleTextField: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + Text("직군") + .profileInputTitle + TextField("", text: $observed.user.role) + .profileTextField + .placeholder( + when: observed.user.role.isEmpty, + text: "Required", + isTextField: true + ) + Spacer() + } + customDivider + .padding(.top, 15) + } + .padding(.leading) + .padding(.top, 23) + } + + var descriptionTextField: some View { + VStack(spacing: 0) { + HStack(alignment: .top, spacing: 0) { + Text("소개") + .profileInputTitle + if #available(iOS 16.0, *) { + TextEditor(text: $observed.description) + .profileTextEditor + .scrollContentBackground(.hidden) + .placeholder( + when: observed.description.isEmpty, + text: "Optional, 80자 이내", + isTextField: false + ) + .offset(x: -2, y: -8) + } else { + TextEditor(text: $observed.description) + .profileTextEditor + .placeholder( + when: observed.description.isEmpty, + text: "Optional, 80자 이내", + isTextField: false + ) + } + + } + customDivider + .padding(.top, 15) + } + .padding(.leading) + .padding(.top, 23) + .frame(height: 91) + } + + var linkedInTextField: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + Text("링크드인 프로필 URL") + .profileInputTitle + Spacer() + } + .padding(.top, 20) + HStack(spacing: 0) { + TextField("", text: $observed.linkedInURL) + .profileTextField + .placeholder( + when: observed.linkedInURL.isEmpty, + text: "Optional", + isTextField: true + ) + } + .padding(.top, 5) + customDivider + .padding(.top, 15) + } + .padding(.leading) + } + + var privateURLTextField: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + Text("개인 페이지 URL") + .profileInputTitle + Spacer() + } + .padding(.top, 20) + HStack(spacing: 0) { + TextField("", text: $observed.profileURL) + .profileTextField + .placeholder( + when: observed.profileURL.isEmpty, + text: "Optional", + isTextField: true + ) + } + .padding(.top, 5) + } + .padding(.leading) + } + + var submitButton: some View { + Button { + if observed.isButtonAvailable() { + observed.didTapRegisterButton() + } + } label: { + Text("Save") + .foregroundColor( + observed.isButtonAvailable() ? + Color.accentColor : + Color.unavailableButtonBackground + ) + } + } +} diff --git a/AsyncSwift/Views/Profile/ProfileFriendDetailView.swift b/AsyncSwift/Views/Profile/ProfileFriendDetailView.swift new file mode 100644 index 0000000..9a67d0a --- /dev/null +++ b/AsyncSwift/Views/Profile/ProfileFriendDetailView.swift @@ -0,0 +1,145 @@ +// +// ProfileFriendDetailView.swift +// AsyncSwift +// +// Created by Kim Insub on 2022/11/02. +// + +import SwiftUI + +struct ProfileFriendDetailView: View { + @ObservedObject var observed: ProfileFriendDetailViewObserved + + init( + previous: PreviousView, + inActive: Binding, + user: User, + friend: User + ) { + observed = ProfileFriendDetailViewObserved( + inActive: inActive, + user: user, + friend: friend, + previous: previous + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + customDivider + .padding(.top, 10) + userDetail + Spacer() + linkButtons + } + .navigationTitle(observed.friend.name) + .navigationBarItems(trailing: navigationBarTrailingButton) + .fullScreenCover(isPresented: $observed.isShowingProfileSheet, content: { + if let url = observed.profileURL { + SafariView(url: url) + .ignoresSafeArea() + } + }) + .fullScreenCover(isPresented: $observed.isShowingLinkedInSheet, content: { + if let url = observed.linkedInURL { + SafariView(url: url) + .ignoresSafeArea() + } + }) + .alert(isPresented: $observed.isShowingConfirmAlert) { + Alert( + title: Text("삭제"), + message: Text("유저 친구 목록에서 삭제하시겠습니까?"), + primaryButton: .destructive(Text("삭제")) { + observed.didConfirmDelete() + }, + secondaryButton: .cancel(Text("취소")) { + observed.isShowingConfirmAlert = false + } + ) + } + } +} + +private extension ProfileFriendDetailView { + var userDetail: some View { + VStack(alignment: .leading, spacing: 0) { + Text(observed.friend.nickname) + .fontWeight(.semibold) + .font(.system(size: 20)) + Text(observed.friend.role) + .fontWeight(.semibold) + .font(.system(size: 20)) + .foregroundColor(.profileFontGrayForeground) + .padding(.bottom, 24) + Text(observed.friend.description) + } + .padding(.horizontal, 24) + .padding(.top, 28) + } + + var linkButtons: some View { + VStack { + profileButton + linkedInButton + .padding(.bottom, 16) + } + .padding(.horizontal) + } + + var profileButton: some View { + Button { + if observed.hasProfileURL { + observed.isShowingProfileSheet = true + } + } label: { + Text("Profile URL") + .font(.headline) + .foregroundColor(.white) + .frame(minWidth: 0, maxWidth: .infinity, maxHeight: 68) + .background(observed.hasProfileURL ? Color.seminarOrange : Color.inActiveButtonBackground) + .cornerRadius(15) + } + } + + var linkedInButton: some View { + Button { + if observed.hasLinkedInURL { + observed.isShowingLinkedInSheet = true + } + } label: { + Text("LinkedIn") + .font(.headline) + .foregroundColor(.white) + .frame(minWidth: 0, maxWidth: .infinity, maxHeight: 68) + .background(observed.hasLinkedInURL ? Color.linkedInBlueBackground : Color.inActiveButtonBackground) + .cornerRadius(15) + } + } + + @ViewBuilder + var navigationBarTrailingButton: some View { + switch observed.previous { + case .ProfileView: + doneButton + case .ListView: + deleteButton + } + } + + var doneButton: some View { + Button { + observed.didTapDoneButton() + } label: { + Text("Done") + } + } + + var deleteButton: some View { + Button { + observed.didTapDeleteButton() + } label: { + Text("Delete") + } + } +} diff --git a/AsyncSwift/Views/Profile/ProfileFriendsListView.swift b/AsyncSwift/Views/Profile/ProfileFriendsListView.swift new file mode 100644 index 0000000..67149b3 --- /dev/null +++ b/AsyncSwift/Views/Profile/ProfileFriendsListView.swift @@ -0,0 +1,170 @@ +// +// ProfileFriendsListView.swift +// AsyncSwift +// +// Created by Kim Insub on 2022/11/02. +// + +import CodeScanner +import SwiftUI + +struct ProfileFriendsListView: View { + @StateObject var observed: ProfileFriendsListViewObserved + + init(inActive: Binding, user: User) { + _observed = StateObject( + wrappedValue: ProfileFriendsListViewObserved( + inActive: inActive, + user: user + )) + } + + var body: some View { + VStack(spacing: 0) { + customDivider + .padding(.top, 10) + friendList + } + .navigationTitle("Friends") + .fullScreenCover( + isPresented: $observed.isShowingScanner, + content: { scannerView } + ) + .fullScreenCover( + isPresented: $observed.isShowingUserDetail, + content: { scannedFriendDetail } + ) + .onAppear { + observed.onAppear() + } + .alert("QR 등록 오류", isPresented: $observed.isShowingScanErrorAlert, actions: { + Button("취소", role: .cancel) { observed.isShowingScanErrorAlert = false } + }, message: { + Text("등록할 수 없는 QR코드입니다.") + }) + } +} + +private extension ProfileFriendsListView { + @ViewBuilder + var friendList: some View { + switch observed.isLoading { + case true: + loadingList + case false: + switch observed.user.friends.isEmpty { + case true: + emptyList + case false: + list + } + } + } + + var emptyList: some View { + VStack(spacing: 0) { + Spacer() + Text("등록된 프로필이 없습니다.") + .foregroundColor(.profileGray) + .padding(.bottom, 17) + Button { + observed.isShowingScanner = true + } label: { + Text("프로필 스캔하기") + .foregroundColor(.seminarOrange) + .font(.headline) + } + Spacer() + } + } + + var loadingList: some View { + ScrollView { + ForEach(0.. some View { + NavigationLink { + ProfileFriendDetailView( + previous: .ListView, + inActive: $observed.inActive, + user: observed.user, + friend: friend + ) + } label: { + HStack { + Text("\(friend.name) | \(friend.nickname)") + .font(.headline) + Spacer() + Image(systemName: "chevron.forward") + } + .foregroundColor(.black) + .padding(.horizontal, 19) + .padding(.vertical, 23) + .frame(maxWidth: .infinity, maxHeight: 56) + .background(Color.buttonBackground) + .cornerRadius(15) + } + } + + var scannerView: some View { + VStack { + ZStack { + Text("코드스캔") + HStack { + Spacer() + Button { + observed.didTapXButton() + } label: { + Text("Done") + } + .padding() + } + } + .frame(height: 51) + CodeScannerView( + codeTypes: [.qr], + simulatedData: "6A8254C2-1054-4A5B-9F30-602684D329F9", + completion: observed.handleScan + ) + HStack { + Text("QR코드를 스캔해 보세요. 프로필 상세 정보를 확인할 수 있습니다.") + .font(.caption2) + } + .frame(height: 70) + } + } + + var scannedFriendDetail: some View { + NavigationView { + VStack { + ProfileFriendDetailView( + previous: .ProfileView, + inActive: $observed.isShowingUserDetail, + user: observed.user, + friend: observed.scannedFriend + ) + } + } + } +} diff --git a/AsyncSwift/Views/Profile/ProfileRegisterView.swift b/AsyncSwift/Views/Profile/ProfileRegisterView.swift new file mode 100644 index 0000000..75c6f38 --- /dev/null +++ b/AsyncSwift/Views/Profile/ProfileRegisterView.swift @@ -0,0 +1,243 @@ +// +// ProfileRegisterView.swift +// AsyncSwift +// +// Created by Kim Insub on 2022/10/28. +// + +import SwiftUI + +struct ProfileRegisterView: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject var observed: ProfileRegisterViewObserved + + init(hasRegisteredProfile: Binding, userID: Binding) { + observed = ProfileRegisterViewObserved( + hasRegisteredProfile: hasRegisteredProfile, + userID: userID + ) + } + + var body: some View { + VStack(spacing: 0) { + Header + ScrollView { + VStack(spacing: 0) { + nameTextField + nicknameTextField + jobTitleTextField + descriptionTextField + linkedInTextField + privateURLTextField + } + } + Spacer() + } + .navigationBarTitle("Register", displayMode: .large) + .toolbar { + submitButton + } + } +} + +private extension ProfileRegisterView { + var Header: some View { + VStack(spacing: 0) { + VStack(spacing: 0) { + HStack(spacing: 0) { + Text("프로필을 등록해주세요") + .font(.title3) + .fontWeight(.bold) + .frame(minHeight: 24) + .padding(.bottom, 3) + Spacer() + } + HStack(spacing: 0) { + Text("특수문자는 입력할 수 없어요") + .font(.footnote) + .frame(minHeight: 18) + Spacer() + } + } + .padding(.top, 38) + .padding(.bottom, 20) + .padding(.horizontal) + customDivider + } + .alert("프로필 등록 완료", isPresented: $observed.isShowingSuccessAlert, actions: { + Button("확인", role: .cancel) { dismiss() } + }, message: { + Text("개인 프로필이 추가되었습니다.") + }) + .alert("프로필 등록 오류", isPresented: $observed.isShowingFailureAlert, actions: { + Button("다시 시도", role: .cancel) { } + }, message: { + Text("입력되지 않은 내용이 있습니다.\n필수 입력란을 확인해주세요.") + }) + .alert("프로필 입력 오류", isPresented: $observed.isShowingInputFailureAlert, actions: { + Button("다시 시도", role: .cancel) { } + }, message: { + Text("확인되지 않은 주소입니다.\nURL을 확인해주세요.") + }) + } + + + var nameTextField: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + Text("이름") + .profileInputTitle + TextField("", text: $observed.name) + .profileTextField + .placeholder( + when: observed.name.isEmpty, + text: "Required", + isTextField: true + ) + Spacer() + } + customDivider + .padding(.top, 15) + } + .padding(.leading) + .padding(.top, 23) + } + + var nicknameTextField: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + Text("닉네임") + .profileInputTitle + TextField("", text: $observed.nickname) + .profileTextField + .placeholder( + when: observed.nickname.isEmpty, + text: "Optional", + isTextField: true + ) + Spacer() + } + customDivider + .padding(.top, 15) + } + .padding(.leading) + .padding(.top, 23) + } + + var jobTitleTextField: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + Text("직군") + .profileInputTitle + TextField("", text: $observed.role) + .profileTextField + .placeholder( + when: observed.role.isEmpty, + text: "Required", + isTextField: true + ) + Spacer() + } + customDivider + .padding(.top, 15) + } + .padding(.leading) + .padding(.top, 23) + } + + var descriptionTextField: some View { + VStack(spacing: 0) { + HStack(alignment: .top, spacing: 0) { + Text("소개") + .profileInputTitle + if #available(iOS 16.0, *) { + TextEditor(text: $observed.description) + .profileTextEditor + .scrollContentBackground(.hidden) + .placeholder( + when: observed.description.isEmpty, + text: "Optional, 80자 이내", + isTextField: false + ) + .offset(x: -2, y: -8) + } else { + TextEditor(text: $observed.description) + .profileTextEditor + .placeholder( + when: observed.description.isEmpty, + text: "Optional, 80자 이내", + isTextField: false + ) + } + + } + customDivider + .padding(.top, 15) + } + .padding(.leading) + .padding(.top, 23) + .frame(height: 91) + } + + var linkedInTextField: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + Text("링크드인 프로필 URL") + .profileInputTitle + Spacer() + } + .padding(.top, 20) + HStack(spacing: 0) { + TextField("", text: $observed.linkedInURL) + .profileTextField + .placeholder( + when: observed.linkedInURL.isEmpty, + text: "Optional", + isTextField: true + ) + } + .padding(.top, 5) + customDivider + .padding(.top, 15) + } + .padding(.leading) + } + + var privateURLTextField: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + Text("개인 페이지 URL") + .profileInputTitle + Spacer() + } + .padding(.top, 20) + HStack(spacing: 0) { + TextField("", text: $observed.profileURL) + .profileTextField + .placeholder( + when: observed.profileURL.isEmpty, + text: "Optional", + isTextField: true + ) + } + .padding(.top, 5) + } + .padding(.leading) + } + + var submitButton: some View { + Button { + if observed.isButtonAvailable() { + observed.didTapRegisterButton() + } + } label: { + Text("Save") + .foregroundColor( + observed.isButtonAvailable() ? + Color.accentColor : + Color.unavailableButtonBackground + ) + } + } +} + diff --git a/AsyncSwift/Views/Profile/ProfileView.swift b/AsyncSwift/Views/Profile/ProfileView.swift new file mode 100644 index 0000000..d269b5d --- /dev/null +++ b/AsyncSwift/Views/Profile/ProfileView.swift @@ -0,0 +1,295 @@ +// +// ProfileView.swift +// AsyncSwift +// +// Created by Kim Insub on 2022/10/16. +// + +import CodeScanner +import SwiftUI + +// TODO +// 1 : Enter 치면 다음 input focused 되도록 변경 + +struct ProfileView: View { + + @StateObject var observed = ProfileViewObserved() + + var body: some View { + NavigationView { + VStack(spacing: 0) { + header + Spacer() + if observed.hasRegisteredProfile { + friendsListLinkButton + } + editProfileLinkButton + } + .navigationTitle("Profile") + .navigationBarItems(trailing: codeScannerButton) + .fullScreenCover( + isPresented: $observed.isShowingScanner, + content: { scannerView } + ) + .fullScreenCover( + isPresented: $observed.isShowingUserDetail, + content: { scannedFriendDetail } + ) + .onAppear { + observed.onAppear() + } + } + .alert("프로필 등록 오류", isPresented: $observed.isShowingFailureAlert, actions: { + Button("확인", role: .cancel) { observed.isShowingFailureAlert = false } + }, message: { + Text("이미 등록된 프로필입니다.") + }) + .alert("QR 등록 오류", isPresented: $observed.isShowingScanErrorAlert, actions: { + Button("취소", role: .cancel) { observed.isShowingScanErrorAlert = false } + }, message: { + Text("등록할 수 없는 QR코드입니다.") + }) + } +} + +private extension ProfileView { + @ViewBuilder + var header: some View { + if observed.hasRegisteredProfile { + if observed.isLoading { + hasRegisteredHeaderLoadingView + } else { + hasRegisteredHeader + } + } else { + registerHeader + } + } + + var hasRegisteredHeaderLoadingView: some View { + VStack(spacing: 0) { + customDivider + .padding(.top, 10) + .padding(.bottom, 56) + Rectangle() + .frame(width: 130, height: 130) + .foregroundColor(Color.skeletonQR) + .padding(.bottom, 26) + Rectangle() + .frame(width: 165, height: 23) + .foregroundColor(Color.skeletonName) + .cornerRadius(4) + .padding(.bottom, 7) + Rectangle() + .frame(width: 91, height: 16) + .foregroundColor(Color.skeletonName) + .cornerRadius(4) + .padding(.bottom, 25) + VStack(alignment: .leading, spacing: 0) { + Rectangle() + .frame(width: 309, height: 16) + .foregroundColor(Color.skeletonDescription) + .cornerRadius(4) + .padding(.bottom, 3) + Rectangle() + .frame(width: 309, height: 16) + .foregroundColor(Color.skeletonDescription) + .cornerRadius(4) + .padding(.bottom, 3) + Rectangle() + .frame(width: 221, height: 16) + .foregroundColor(Color.skeletonDescription) + .cornerRadius(4) + } + } + } + + var hasRegisteredHeader: some View { + VStack(spacing: 0) { + customDivider + .padding(.top, 10) + .padding(.bottom, 55) + Image(uiImage: observed.getQRCodeImage()) + .interpolation(.none) + .resizable() + .frame(width: 157, height: 157) + .padding(.bottom, 40) + Text("\(observed.user.name) | \(observed.user.nickname)") + .font(.title3) + .fontWeight(.semibold) + .padding(.bottom, 4) + Text("\(observed.user.role)") + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.profileGray) + .padding(.bottom, 18) + Text("\(observed.user.description)") + .font(.footnote) + .padding(.horizontal, 43) + } + } + + var registerHeader: some View { + VStack(spacing: 0) { + Image("QRplaceholder") + .frame(width: 157) + .padding(.bottom, 50) + Text("등록한 프로필이 없습니다.") + .foregroundColor(.profileGray) + .font(.body) + .padding(.bottom, 17) + registerLink + } + .padding(.top, 68) + } + + var registerLink: some View { + NavigationLink { + ProfileRegisterView( + hasRegisteredProfile: $observed.hasRegisteredProfile, + userID: $observed.userID + ) + } label: { + Text("프로필 등록하기") + .font(.headline) + } + } + + @ViewBuilder + var friendsListLinkButton: some View { + if observed.isLoading { + Button { } label: { + linkLabelButtonLabel(text: "Friends") + .opacity(0.2) + } + .padding(.bottom, 16) + } else { + NavigationLink( + destination: ProfileFriendsListView( + inActive: $observed.isShowingFriends, + user: observed.user), + isActive: $observed.isShowingFriends, + label: { + Button { + observed.isShowingFriends = true + } label: { + linkLabelButtonLabel(text: "Friends") + } + } + ) + .padding(.bottom, 16) + } + } + + @ViewBuilder + var editProfileLinkButton: some View { + switch observed.hasRegisteredProfile { + case true: + switch observed.isLoading { + case true: + Button { } label: { + linkLabelButtonLabel(text: "Edit Profile") + .opacity(0.2) + } + .padding(.bottom, 32) + case false: + NavigationLink( + destination: ProfileEditView(user: observed.user), + isActive: $observed.isShowingEdit, + label: { + Button { + observed.isShowingEdit = true + } label: { + linkLabelButtonLabel(text: "Edit Profile") + } + }) + .padding(.bottom, 32) + } + case false: + NavigationLink { + ProfileRegisterView( + hasRegisteredProfile: $observed.hasRegisteredProfile, + userID: $observed.userID + ) + } label: { + linkLabelButtonLabel(text: "Edit Profile") + } + .padding(.bottom, 32) + } + } + + @ViewBuilder + var codeScannerButton: some View { + if observed.hasRegisteredProfile && !observed.isLoading { + Button { + observed.isShowingScanner = true + } label: { + Image(systemName: "qrcode.viewfinder") + } + } else { + Button { + + } label: { + Image(systemName: "qrcode.viewfinder") + .foregroundColor(.gray) + } + } + } + + var scannerView: some View { + VStack { + ZStack { + Text("코드스캔") + HStack { + Spacer() + Button { + observed.didTapCloseButton() + } label: { + Text("Done") + } + .padding() + } + } + .frame(height: 51) + CodeScannerView( + codeTypes: [.qr], + simulatedData: "1AA5CC09-6F7F-4EC4-A2BE-819B93362B7B", + completion: observed.handleScan + ) + HStack { + Text("QR코드를 스캔해 보세요. 프로필 상세 정보를 확인할 수 있습니다.") + .font(.caption2) + } + .frame(height: 70) + } + } + + func linkLabelButtonLabel(text: String) -> some View { + HStack { + Text(text) + .font(.headline) + Spacer() + Image(systemName: "chevron.forward") + } + .foregroundColor(.black) + .padding(.horizontal, 19) + .padding(.vertical, 23) + .frame(maxWidth: .infinity, maxHeight: 68) + .background(Color.buttonBackground) + .cornerRadius(15) + .padding(.horizontal) + } + + var scannedFriendDetail: some View { + NavigationView { + VStack { + ProfileFriendDetailView( + previous: .ProfileView, + inActive: $observed.isShowingUserDetail, + user: observed.user, + friend: observed.scannedFriend + ) + } + } + } +} diff --git a/AsyncSwift/Views/StampView.swift b/AsyncSwift/Views/StampView.swift index 8698985..232c2e8 100644 --- a/AsyncSwift/Views/StampView.swift +++ b/AsyncSwift/Views/StampView.swift @@ -2,71 +2,74 @@ // StampView.swift // AsyncSwift // -// Created by Kim Insub on 2022/09/08. +// Created by Inho Choi on 2022/10/29. // import SwiftUI struct StampView: View { - @EnvironmentObject var appData: AppData - @ObservedObject var observed = Observed() - + @StateObject var observed = Observed() + @EnvironmentObject var envObserved: MainTabViewObserved + var body: some View { - NavigationView { - Group { - if appData.isStampExist { - ZStack { - stampBack - stampFront - } - .onTapGesture { - observed.didTabCard() + GeometryReader { geometry in + if observed.cards.isEmpty { + emptyCardView + .padding(36) + } else { + ScrollView(showsIndicators: false) { + ScrollViewReader { reader in + HStack(alignment: .bottom) { + Text("Stamp") + .font(.system(size: 34)) + .fontWeight(.bold) + .padding(.leading, 16) + .padding(.bottom, 7) + .padding(.top, 48) + Spacer() + } + .frame(height: 94) + ZStack { + ForEach(0.. some View { + observed.cards[observed.events[index]]?.image + .resizable() + .aspectRatio(contentMode: .fit) + .shadow(color: Color.black.opacity(0.1), radius: 10, y: 4) + .offset(y: observed.getCardOffsetY(index: index, size: size)) + .onTapGesture { + observed.didCardTapped(index: index, scrollReader: scrollReader) + } } } diff --git a/AsyncSwift/Views/TicketingView.swift b/AsyncSwift/Views/TicketingView.swift index f5fe175..3ed232e 100644 --- a/AsyncSwift/Views/TicketingView.swift +++ b/AsyncSwift/Views/TicketingView.swift @@ -32,7 +32,8 @@ struct TicketingView: View { .ticketingViewStyle() } } - .padding(30) + .padding(.horizontal) + .padding(.vertical, 30) } .navigationTitle("Ticketing") diff --git a/README.md b/README.md index 75d822d..7019fb2 100644 --- a/README.md +++ b/README.md @@ -1 +1,50 @@ -# AsyncSwift \ No newline at end of file +# AsyncSwift +## AsyncSwift란? +![스크린샷 2022-12-19 오후 9 43 34](https://user-images.githubusercontent.com/55151796/209364676-179c792e-b8f0-4213-aaf0-e9961b5d29ac.png) + +--- +## AsyncSwift 앱 소개 +### 1. 행사 안내 +``` +컨퍼런스가 앞으로 계획되어있다면 앞으로 열릴 컨퍼런스에 대한 발표 소개 및 연사자를 소개해줍니다. +컨퍼런스 계획이 아직 잡혀있지 않다면 가장 최신의 정보를 제공하게 됩니다. +``` +

+ +

+ +### 2. 티켓팅 안내 +``` +컨퍼런스가 계획되어져 있다면 컨퍼런스 포스터와 함께 컨퍼런스 티켓(유/무료) 구매페이지로 안내되어집니다. +컨퍼런스 계획이 잡혀 있지 않다면 아래와 같이 비어있는 화면이 보여지게 됩니다. +``` +

+ +

+ +### 3. 디지털 Stamp +``` +컨퍼런스에서 얻을 수 있는 QR코드(DeepLink)를 찍으면 디지털 Stamp를 얻을 수 있습니다. +디지털 Stamp는 아래와 같이 애니메이션이 작동합니다. +``` +

+ + + + +### 4. Profile공유 +``` +AsyncSwift에서 주최한 행사의 After Party에서 즐기실 수 있는 프로필 교환 및 작성을 위한 기능입니다. +서로의 QR코드 스캔을 통해서 프로필을 교환할 수 있습니다. +``` +

+ +

+ +--- +## 사용 기술 +```Swift +DeepLink +Firebase +KeyChain +``` diff --git a/pull_request_template.md b/pull_request_template.md index 2a45d25..a3801f0 100644 --- a/pull_request_template.md +++ b/pull_request_template.md @@ -1,8 +1,10 @@ # Overview - + # Next TODO # Reference + +Close