diff --git a/SNUTT-2022/SNUTT.xcodeproj/project.pbxproj b/SNUTT-2022/SNUTT.xcodeproj/project.pbxproj index 456a564d..33af153e 100644 --- a/SNUTT-2022/SNUTT.xcodeproj/project.pbxproj +++ b/SNUTT-2022/SNUTT.xcodeproj/project.pbxproj @@ -11,7 +11,6 @@ 731DA001297BC54B0027BA25 /* BookmarkDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731DA000297BC54B0027BA25 /* BookmarkDto.swift */; }; 731DA003297BC5740027BA25 /* BookmarkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731DA002297BC5740027BA25 /* BookmarkRouter.swift */; }; 731DA005297BC8990027BA25 /* BookmarkScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731DA004297BC8990027BA25 /* BookmarkScene.swift */; }; - 73805EF22981143E000F81EA /* TranslucentListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73805EF12981143E000F81EA /* TranslucentListViewModel.swift */; }; B85B244D295BF36F00E6577E /* FindLocalIdView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B85B244C295BF36F00E6577E /* FindLocalIdView.swift */; }; B861EF1929581FC7000DE5BC /* ResetPasswordScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = B861EF1829581FC7000DE5BC /* ResetPasswordScene.swift */; }; B86C4CCF294DCA150019CE51 /* VerificationCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86C4CCE294DCA150019CE51 /* VerificationCodeView.swift */; }; @@ -95,7 +94,6 @@ BE419B9B288B917A00FA9590 /* TimetableUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE419B99288B915800FA9590 /* TimetableUtils.swift */; }; BE4B0EB628735005005FE164 /* MenuSheetViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE4B0EB528735005005FE164 /* MenuSheetViewModel.swift */; }; BE4B0EBE2873BC84005FE164 /* FilterSheetViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE4B0EBD2873BC84005FE164 /* FilterSheetViewModel.swift */; }; - BE4B0EC02873BDAA005FE164 /* SearchSceneViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE4B0EBF2873BDAA005FE164 /* SearchSceneViewModel.swift */; }; BE4CD86128F5A56200BA9BBC /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = BE4CD86028F5A56200BA9BBC /* FirebaseAnalytics */; }; BE4CD86328F5A56200BA9BBC /* FirebaseAnalyticsSwift in Frameworks */ = {isa = PBXBuildFile; productRef = BE4CD86228F5A56200BA9BBC /* FirebaseAnalyticsSwift */; }; BE4CD86528F5A56200BA9BBC /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = BE4CD86428F5A56200BA9BBC /* FirebaseCrashlytics */; }; @@ -155,7 +153,6 @@ BE9413D928C3AFA500171060 /* EmptySearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE9413D828C3AFA500171060 /* EmptySearchResult.swift */; }; BE9413DB28C3B33300171060 /* SearchTips.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE9413DA28C3B33300171060 /* SearchTips.swift */; }; BE9413DD28C3B68F00171060 /* SearchTagsScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE9413DC28C3B68F00171060 /* SearchTagsScrollView.swift */; }; - BE9413DF28C3BDF600171060 /* SearchLectureList.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE9413DE28C3BDF600171060 /* SearchLectureList.swift */; }; BE95D1F728DF2F44009C0C0B /* AuthRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE95D1F628DF2F44009C0C0B /* AuthRepository.swift */; }; BE95D1F928DF32EC009C0C0B /* AuthRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE95D1F828DF32EC009C0C0B /* AuthRepositoryTests.swift */; }; BE982FB8281D0B33005F71E6 /* TimetableBlocksLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE982FB7281D0B33005F71E6 /* TimetableBlocksLayer.swift */; }; @@ -216,7 +213,6 @@ BEEBDFBC286B408E00DB5976 /* LectureBlocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEBDFBB286B408E00DB5976 /* LectureBlocks.swift */; }; BEF9233628E7EE45004AFCB2 /* SignUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEF9233528E7EE45004AFCB2 /* SignUpView.swift */; }; BEF9233828E84653004AFCB2 /* LectureTimeSheetScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEF9233728E84653004AFCB2 /* LectureTimeSheetScene.swift */; }; - BEF9233A28E84B62004AFCB2 /* SearchLectureCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEF9233928E84B62004AFCB2 /* SearchLectureCell.swift */; }; CE076E002A09661400C9430B /* NetworkLogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE076DFF2A09661400C9430B /* NetworkLogEntry.swift */; }; CE076E022A0967E200C9430B /* NetworkLogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE076E012A0967E200C9430B /* NetworkLogStore.swift */; }; CE17DF872A7E9560000432B8 /* ConfigRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE17DF862A7E9560000432B8 /* ConfigRouter.swift */; }; @@ -232,12 +228,16 @@ CE4777F32A6ADCAC00E03253 /* VacancyRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE4777F22A6ADCAC00E03253 /* VacancyRepository.swift */; }; CE4777F52A6ADCE200E03253 /* VacancyRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE4777F42A6ADCE200E03253 /* VacancyRouter.swift */; }; CE4777F72A6AE41C00E03253 /* VacancyScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE4777F62A6AE41C00E03253 /* VacancyScene.swift */; }; + CE5BA5082B2EBF7300F15D10 /* ExpandableLectureCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5BA5072B2EBF7300F15D10 /* ExpandableLectureCell.swift */; }; + CE5BA50A2B2ED4BC00F15D10 /* ExpandableLectureList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5BA5092B2ED4BC00F15D10 /* ExpandableLectureList.swift */; }; CE63A3BC2A21B06900A633FC /* RoutingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE63A3BB2A21B06900A633FC /* RoutingState.swift */; }; CE63A3BE2A21DD3400A633FC /* DeepLinkHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE63A3BD2A21DD3400A633FC /* DeepLinkHandler.swift */; }; CE6CA91029E1E611004E92B1 /* logo.inline.png in Resources */ = {isa = PBXBuildFile; fileRef = CE6CA90F29E1E610004E92B1 /* logo.inline.png */; }; CE6CA91229E24676004E92B1 /* TimetableAccessoryCircularView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6CA91129E24676004E92B1 /* TimetableAccessoryCircularView.swift */; }; CE6CA91329E3CFA6004E92B1 /* String+Localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6D893428EFD68E000607A6 /* String+Localized.swift */; }; CE6CA91429E3CFB4004E92B1 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BE6D893228EFD662000607A6 /* Localizable.strings */; }; + CE72435F2B30235300F9E0D7 /* SearchLectureSceneViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE72435E2B30235300F9E0D7 /* SearchLectureSceneViewModel.swift */; }; + CE7243612B30240D00F9E0D7 /* InteractiveDismissKeyboardModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7243602B30240D00F9E0D7 /* InteractiveDismissKeyboardModifier.swift */; }; CE98204B2A09FBDD001037F5 /* DebugState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE98204A2A09FBDD001037F5 /* DebugState.swift */; }; CE9820502A0A0BB7001037F5 /* NetworkLogListScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE98204F2A0A0BB7001037F5 /* NetworkLogListScene.swift */; }; CE9820522A0A0BE9001037F5 /* NetworkLogEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE9820512A0A0BE9001037F5 /* NetworkLogEntryView.swift */; }; @@ -320,7 +320,6 @@ 731DA000297BC54B0027BA25 /* BookmarkDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkDto.swift; sourceTree = ""; }; 731DA002297BC5740027BA25 /* BookmarkRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkRouter.swift; sourceTree = ""; }; 731DA004297BC8990027BA25 /* BookmarkScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkScene.swift; sourceTree = ""; }; - 73805EF12981143E000F81EA /* TranslucentListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslucentListViewModel.swift; sourceTree = ""; }; B85B244C295BF36F00E6577E /* FindLocalIdView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindLocalIdView.swift; sourceTree = ""; }; B861EF1829581FC7000DE5BC /* ResetPasswordScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetPasswordScene.swift; sourceTree = ""; }; B86C4CCE294DCA150019CE51 /* VerificationCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationCodeView.swift; sourceTree = ""; }; @@ -386,7 +385,6 @@ BE419B99288B915800FA9590 /* TimetableUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimetableUtils.swift; sourceTree = ""; }; BE4B0EB528735005005FE164 /* MenuSheetViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuSheetViewModel.swift; sourceTree = ""; }; BE4B0EBD2873BC84005FE164 /* FilterSheetViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterSheetViewModel.swift; sourceTree = ""; }; - BE4B0EBF2873BDAA005FE164 /* SearchSceneViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSceneViewModel.swift; sourceTree = ""; }; BE682BB22879E24D009EBCB7 /* SNUTT.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SNUTT.app; sourceTree = BUILT_PRODUCTS_DIR; }; BE682BB32879E24D009EBCB7 /* SNUTTTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SNUTTTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; BE682BB42879E24D009EBCB7 /* SNUTTUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SNUTTUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -444,7 +442,6 @@ BE9413D828C3AFA500171060 /* EmptySearchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptySearchResult.swift; sourceTree = ""; }; BE9413DA28C3B33300171060 /* SearchTips.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTips.swift; sourceTree = ""; }; BE9413DC28C3B68F00171060 /* SearchTagsScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTagsScrollView.swift; sourceTree = ""; }; - BE9413DE28C3BDF600171060 /* SearchLectureList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchLectureList.swift; sourceTree = ""; }; BE95D1F628DF2F44009C0C0B /* AuthRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthRepository.swift; sourceTree = ""; }; BE95D1F828DF32EC009C0C0B /* AuthRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthRepositoryTests.swift; sourceTree = ""; }; BE982FB7281D0B33005F71E6 /* TimetableBlocksLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimetableBlocksLayer.swift; sourceTree = ""; }; @@ -503,7 +500,6 @@ BEEBDFBB286B408E00DB5976 /* LectureBlocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LectureBlocks.swift; sourceTree = ""; }; BEF9233528E7EE45004AFCB2 /* SignUpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpView.swift; sourceTree = ""; }; BEF9233728E84653004AFCB2 /* LectureTimeSheetScene.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LectureTimeSheetScene.swift; sourceTree = ""; }; - BEF9233928E84B62004AFCB2 /* SearchLectureCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchLectureCell.swift; sourceTree = ""; }; CE076DFF2A09661400C9430B /* NetworkLogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkLogEntry.swift; sourceTree = ""; }; CE076E012A0967E200C9430B /* NetworkLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkLogStore.swift; sourceTree = ""; }; CE17DF862A7E9560000432B8 /* ConfigRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigRouter.swift; sourceTree = ""; }; @@ -519,10 +515,14 @@ CE4777F42A6ADCE200E03253 /* VacancyRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VacancyRouter.swift; sourceTree = ""; }; CE4777F62A6AE41C00E03253 /* VacancyScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VacancyScene.swift; sourceTree = ""; }; CE4F2C6129BA45420007194E /* TimeRangeSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeRangeSlider.swift; sourceTree = ""; }; + CE5BA5072B2EBF7300F15D10 /* ExpandableLectureCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandableLectureCell.swift; sourceTree = ""; }; + CE5BA5092B2ED4BC00F15D10 /* ExpandableLectureList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandableLectureList.swift; sourceTree = ""; }; CE63A3BB2A21B06900A633FC /* RoutingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutingState.swift; sourceTree = ""; }; CE63A3BD2A21DD3400A633FC /* DeepLinkHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkHandler.swift; sourceTree = ""; }; CE6CA90F29E1E610004E92B1 /* logo.inline.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = logo.inline.png; sourceTree = ""; }; CE6CA91129E24676004E92B1 /* TimetableAccessoryCircularView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimetableAccessoryCircularView.swift; sourceTree = ""; }; + CE72435E2B30235300F9E0D7 /* SearchLectureSceneViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchLectureSceneViewModel.swift; sourceTree = ""; }; + CE7243602B30240D00F9E0D7 /* InteractiveDismissKeyboardModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractiveDismissKeyboardModifier.swift; sourceTree = ""; }; CE98204A2A09FBDD001037F5 /* DebugState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugState.swift; sourceTree = ""; }; CE98204F2A0A0BB7001037F5 /* NetworkLogListScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkLogListScene.swift; sourceTree = ""; }; CE9820512A0A0BE9001037F5 /* NetworkLogEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkLogEntryView.swift; sourceTree = ""; }; @@ -683,6 +683,7 @@ children = ( BE9413B628BE8A2200171060 /* CircleBadgeModifier.swift */, BEBCC29B28EF4C6100732304 /* OnLoadModifier.swift */, + CE7243602B30240D00F9E0D7 /* InteractiveDismissKeyboardModifier.swift */, ); path = Modifiers; sourceTree = ""; @@ -692,10 +693,8 @@ children = ( BE8BB3AE285D77D000070A66 /* SearchBar.swift */, BE9413D828C3AFA500171060 /* EmptySearchResult.swift */, - BEF9233928E84B62004AFCB2 /* SearchLectureCell.swift */, CEA7F1E52A6D096000299BAF /* LectureCellActionButton.swift */, BE9413DA28C3B33300171060 /* SearchTips.swift */, - BE9413DE28C3BDF600171060 /* SearchLectureList.swift */, BE9413DC28C3B68F00171060 /* SearchTagsScrollView.swift */, ); path = Search; @@ -791,6 +790,15 @@ path = NetworkInspector; sourceTree = ""; }; + CE5BA5062B2EBF5600F15D10 /* Lecture */ = { + isa = PBXGroup; + children = ( + CE5BA5072B2EBF7300F15D10 /* ExpandableLectureCell.swift */, + CE5BA5092B2ED4BC00F15D10 /* ExpandableLectureList.swift */, + ); + path = Lecture; + sourceTree = ""; + }; CEDDCA7C2A6AEF0E00474D4E /* Vacancy */ = { isa = PBXGroup; children = ( @@ -1044,6 +1052,7 @@ B8F40EA8289809C60021A2A9 /* LicenseView.swift */, B88D170028AF71E300E2D652 /* UserSupportView.swift */, CE4F2C6129BA45420007194E /* TimeRangeSlider.swift */, + CE5BA5062B2EBF5600F15D10 /* Lecture */, CEDDCA7C2A6AEF0E00474D4E /* Vacancy */, BE28036028E884D300B2B1AB /* WebViews */, BE9413D728C3AF8300171060 /* Search */, @@ -1066,10 +1075,9 @@ DC29159C2865FA2800FE5F9A /* SettingViewModel.swift */, BE4B0EB528735005005FE164 /* MenuSheetViewModel.swift */, BE4B0EBD2873BC84005FE164 /* FilterSheetViewModel.swift */, - BE4B0EBF2873BDAA005FE164 /* SearchSceneViewModel.swift */, + CE72435E2B30235300F9E0D7 /* SearchLectureSceneViewModel.swift */, B8F40EB628980E7A0021A2A9 /* AccountSettingViewModel.swift */, BED04D3428EA966100937E4C /* OnboardViewModel.swift */, - 73805EF12981143E000F81EA /* TranslucentListViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -1354,7 +1362,6 @@ BE060BD928DF4C1E00A2F1B9 /* OnboardScene.swift in Sources */, BE682BFF2888056C009EBCB7 /* Quarter.swift in Sources */, B8F40EA9289809C60021A2A9 /* LicenseView.swift in Sources */, - BEF9233A28E84B62004AFCB2 /* SearchLectureCell.swift in Sources */, BEDF506D27EB740F00CDCC13 /* LectureList.swift in Sources */, BE1B610E289E000B00401361 /* EllipsisSheetButton.swift in Sources */, BEDF507727F42D1100CDCC13 /* STFont.swift in Sources */, @@ -1365,7 +1372,7 @@ BE8BB3AD285D763B00070A66 /* SearchLectureScene.swift in Sources */, BE682C0528881852009EBCB7 /* SearchService.swift in Sources */, BE9C90EB2948EA1800003AA6 /* ColorScheme.swift in Sources */, - BE4B0EC02873BDAA005FE164 /* SearchSceneViewModel.swift in Sources */, + CE5BA50A2B2ED4BC00F15D10 /* ExpandableLectureList.swift in Sources */, CE9820522A0A0BE9001037F5 /* NetworkLogEntryView.swift in Sources */, BEE86519289E287400D3D0E4 /* MenuRenameSheet.swift in Sources */, BEDE34DC2879B40100525014 /* AppEnvironment.swift in Sources */, @@ -1404,7 +1411,6 @@ BE98A06F288AFC1600C2CE95 /* SNUTTWidget.intentdefinition in Sources */, B87DF6F52918AB5D008BB95B /* Date+Ext.swift in Sources */, BEE86517289E274D00D3D0E4 /* MenuEllipsisSheet.swift in Sources */, - BE9413DF28C3BDF600171060 /* SearchLectureList.swift in Sources */, BE77D0A028AEAB920067A9D8 /* CourseBookRepository.swift in Sources */, B88D170128AF71E300E2D652 /* UserSupportView.swift in Sources */, B8EE0C2C2A7DEDCE00CCFFAC /* WIPFriendsView.swift in Sources */, @@ -1412,12 +1418,12 @@ BED04D2F28EA6FA600937E4C /* SettingsMenuItem.swift in Sources */, B8B22D1629311F6200AB88F3 /* EmptyLectureList.swift in Sources */, BE28036228E884F400B2B1AB /* ReviewWebView.swift in Sources */, - 73805EF22981143E000F81EA /* TranslucentListViewModel.swift in Sources */, BEB3B6A528CDE1FD00E56062 /* TimeUtils.swift in Sources */, BE2CB3632959C0CC00FCF0F0 /* ReviewState.swift in Sources */, BEBCC29C28EF4C6100732304 /* OnLoadModifier.swift in Sources */, B8F40EB128980DE00021A2A9 /* TermsOfServiceView.swift in Sources */, CE17DF912A7F43E0000432B8 /* VacancySugangSnuButton.swift in Sources */, + CE7243612B30240D00F9E0D7 /* InteractiveDismissKeyboardModifier.swift in Sources */, BE1D2B3A28014527008F9134 /* Weekday.swift in Sources */, BEDE34CA28754F3100525014 /* Sheet.swift in Sources */, BEB3B6B128D4D4D900E56062 /* View+ResignResponder.swift in Sources */, @@ -1490,6 +1496,7 @@ B86C4CCF294DCA150019CE51 /* VerificationCodeView.swift in Sources */, B89826D128F4971000477A14 /* SyllabusWebView.swift in Sources */, BE9413D928C3AFA500171060 /* EmptySearchResult.swift in Sources */, + CE72435F2B30235300F9E0D7 /* SearchLectureSceneViewModel.swift in Sources */, CEF4200F2A62ADE3005C2B1F /* FriendsViewModel.swift in Sources */, B88D16F328ABC5DD00E2D652 /* String+Ext.swift in Sources */, BEDE34D62879A7B800525014 /* DIContainer.swift in Sources */, @@ -1507,6 +1514,7 @@ CE17DF872A7E9560000432B8 /* ConfigRouter.swift in Sources */, BE682BD128864CA8009EBCB7 /* LectureRouter.swift in Sources */, BEEBDFB8286B38B600DB5976 /* SNUTTView.swift in Sources */, + CE5BA5082B2EBF7300F15D10 /* ExpandableLectureCell.swift in Sources */, CE4777F52A6ADCE200E03253 /* VacancyRouter.swift in Sources */, BEB57C2828BC985900279EFF /* LectureColorPreview.swift in Sources */, BE682BC628845CDB009EBCB7 /* UITextEditor.swift in Sources */, @@ -1695,7 +1703,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.2.0; + MARKETING_VERSION = 3.3.1; PRODUCT_BUNDLE_IDENTIFIER = com.wafflestudio.snutt.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "match Development com.wafflestudio.snutt.dev"; @@ -2008,7 +2016,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.2.0; + MARKETING_VERSION = 3.3.1; PRODUCT_BUNDLE_IDENTIFIER = com.wafflestudio.snutt.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "match Development com.wafflestudio.snutt.dev"; @@ -2052,7 +2060,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.2.0; + MARKETING_VERSION = 3.3.1; PRODUCT_BUNDLE_IDENTIFIER = com.wafflestudio.snutt.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "match Development com.wafflestudio.snutt.dev"; diff --git a/SNUTT-2022/SNUTT/AppState/DeepLinkHandler.swift b/SNUTT-2022/SNUTT/AppState/DeepLinkHandler.swift index 0af0ab4c..36cf0882 100644 --- a/SNUTT-2022/SNUTT/AppState/DeepLinkHandler.swift +++ b/SNUTT-2022/SNUTT/AppState/DeepLinkHandler.swift @@ -30,8 +30,8 @@ struct DeepLinkHandler { extension DeepLinkHandler { private func handleNotification(parameters _: Parameters?) { - appState.system.selectedTab = .settings - appState.routing.settingScene.pushToNotification = true + appState.system.selectedTab = .timetable + appState.routing.timetableScene.pushToNotification = true } private func handleVacancy(parameters _: Parameters?) { diff --git a/SNUTT-2022/SNUTT/AppState/States/RoutingState.swift b/SNUTT-2022/SNUTT/AppState/States/RoutingState.swift index f32eede6..8c1d07ad 100644 --- a/SNUTT-2022/SNUTT/AppState/States/RoutingState.swift +++ b/SNUTT-2022/SNUTT/AppState/States/RoutingState.swift @@ -10,11 +10,17 @@ import Foundation @MainActor class ViewRoutingState { @Published var settingScene = SettingScene.RoutingState() + @Published var timetableScene = TimetableScene.RoutingState() } extension SettingScene { struct RoutingState { - var pushToNotification = false var pushToVacancy = false } } + +extension TimetableScene { + struct RoutingState { + var pushToNotification = false + } +} diff --git a/SNUTT-2022/SNUTT/AppState/States/SearchState.swift b/SNUTT-2022/SNUTT/AppState/States/SearchState.swift index e5231f8f..d859f066 100644 --- a/SNUTT-2022/SNUTT/AppState/States/SearchState.swift +++ b/SNUTT-2022/SNUTT/AppState/States/SearchState.swift @@ -12,6 +12,7 @@ class SearchState { @Published var isFilterOpen = false @Published var searchTagList: SearchTagList? @Published var selectedTagList: [SearchTag] = [] + @Published var displayMode: SearchDisplayMode = .search /// If `nil`, the user had never started searching. /// If empty, the server returned an empty search result. diff --git a/SNUTT-2022/SNUTT/Models/Lecture.swift b/SNUTT-2022/SNUTT/Models/Lecture.swift index b579866f..402d415f 100644 --- a/SNUTT-2022/SNUTT/Models/Lecture.swift +++ b/SNUTT-2022/SNUTT/Models/Lecture.swift @@ -99,7 +99,10 @@ struct Lecture: Identifiable { } func isEquivalent(with lecture: Lecture) -> Bool { - return !isCustom && courseNumber == lecture.courseNumber && lectureNumber == lecture.lectureNumber + if isCustom { + return id == lecture.id + } + return courseNumber == lecture.courseNumber && lectureNumber == lecture.lectureNumber } } diff --git a/SNUTT-2022/SNUTT/Modifiers/InteractiveDismissKeyboardModifier.swift b/SNUTT-2022/SNUTT/Modifiers/InteractiveDismissKeyboardModifier.swift new file mode 100644 index 00000000..ff21d9ff --- /dev/null +++ b/SNUTT-2022/SNUTT/Modifiers/InteractiveDismissKeyboardModifier.swift @@ -0,0 +1,25 @@ +// +// InteractiveDismissKeyboardModifier.swift +// SNUTT +// +// Created by 박신홍 on 2023/12/18. +// + +import SwiftUI + +struct InteractiveDismissesKeyboardModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 16.0, *) { + content + .scrollDismissesKeyboard(.interactively) + } else { + content + } + } +} + +extension View { + func scrollDismissesKeyboardInteractively() -> some View { + modifier(InteractiveDismissesKeyboardModifier()) + } +} diff --git a/SNUTT-2022/SNUTT/Services/SearchService.swift b/SNUTT-2022/SNUTT/Services/SearchService.swift index e988d016..a79de262 100644 --- a/SNUTT-2022/SNUTT/Services/SearchService.swift +++ b/SNUTT-2022/SNUTT/Services/SearchService.swift @@ -20,6 +20,7 @@ protocol SearchServiceProtocol: Sendable { func setSelectedLecture(_ value: Lecture?) func initializeSearchState() func getBookmark() async throws + func setSearchDisplayMode(_ mode: SearchDisplayMode) } struct SearchService: SearchServiceProtocol { @@ -47,6 +48,7 @@ struct SearchService: SearchServiceProtocol { searchState.selectedTagList = [] searchState.searchResult = nil searchState.searchText = "" + searchState.displayMode = .search } func fetchTags(quarter: Quarter) async throws { @@ -108,6 +110,10 @@ struct SearchService: SearchServiceProtocol { searchState.isFilterOpen = value } + func setSearchDisplayMode(_ mode: SearchDisplayMode) { + searchState.displayMode = mode + } + func setSelectedLecture(_ value: Lecture?) { searchState.selectedLecture = value } @@ -117,11 +123,6 @@ struct SearchService: SearchServiceProtocol { } func getBookmark() async throws { - setLoading(true) - defer { - setLoading(false) - } - searchState.pageNum = 0 try await _getBookmark() } @@ -149,4 +150,5 @@ class FakeSearchService: SearchServiceProtocol { func setSelectedLecture(_: Lecture?) {} func initializeSearchState() {} func getBookmark() async throws {} + func setSearchDisplayMode(_: SearchDisplayMode) {} } diff --git a/SNUTT-2022/SNUTT/Services/TimetableService.swift b/SNUTT-2022/SNUTT/Services/TimetableService.swift index bc956d39..d03bda33 100644 --- a/SNUTT-2022/SNUTT/Services/TimetableService.swift +++ b/SNUTT-2022/SNUTT/Services/TimetableService.swift @@ -159,5 +159,4 @@ struct FakeTimetableService: TimetableServiceProtocol { func selectTimetableTheme(theme _: Theme) {} func createTimetable(title _: String, quarter _: Quarter) async throws {} func setTimetableConfig(config _: TimetableConfiguration) {} - func setBookmark(lectures _: [Lecture]) {} } diff --git a/SNUTT-2022/SNUTT/ViewModels/LectureDetailViewModel.swift b/SNUTT-2022/SNUTT/ViewModels/LectureDetailViewModel.swift index 59724818..7162121e 100644 --- a/SNUTT-2022/SNUTT/ViewModels/LectureDetailViewModel.swift +++ b/SNUTT-2022/SNUTT/ViewModels/LectureDetailViewModel.swift @@ -156,7 +156,9 @@ extension LectureDetailScene { } func findLectureInCurrentTimetable(_ lecture: Lecture) -> Lecture? { - guard let lecture = appState.timetable.current?.lectures.first(where: { $0.id == lecture.id }) else { + guard let lecture = appState.timetable.current?.lectures + .first(where: { $0.isEquivalent(with: lecture) }) + else { return nil } return lecture @@ -185,7 +187,8 @@ extension LectureDetailScene { } func isBookmarked(lecture: Lecture) -> Bool { - return (appState.timetable.bookmark?.lectures.first(where: { $0.id == lecture.lectureId ?? lecture.id })) != nil + appState.timetable.bookmark?.lectures + .contains(where: { $0.isEquivalent(with: lecture) }) ?? false } func addVacancyLecture(lecture: Lecture) async { diff --git a/SNUTT-2022/SNUTT/ViewModels/SearchLectureSceneViewModel.swift b/SNUTT-2022/SNUTT/ViewModels/SearchLectureSceneViewModel.swift new file mode 100644 index 00000000..75a9d043 --- /dev/null +++ b/SNUTT-2022/SNUTT/ViewModels/SearchLectureSceneViewModel.swift @@ -0,0 +1,103 @@ +// +// SearchLectureSceneViewModel.swift +// SNUTT +// +// Created by 박신홍 on 2023/12/18. +// + +import Foundation + +class SearchLectureSceneViewModel: BaseViewModel, ObservableObject { + @Published private var _selectedLecture: Lecture? + @Published private var _currentTimetable: Timetable? + @Published private var _timetableConfig: TimetableConfiguration = .init() + @Published private var _searchText: String = "" + @Published private var _isFilterOpen: Bool = false + @Published private var _displayMode: SearchDisplayMode = .search + + @Published var searchResult: [Lecture]? = nil + @Published var selectedTagList: [SearchTag] = [] + @Published var isLoading: Bool = false + + var searchText: String { + get { _searchText } + set { services.searchService.setSearchText(newValue) } + } + + var isFilterOpen: Bool { + get { _isFilterOpen } + set { services.searchService.setIsFilterOpen(newValue) } + } + + var displayMode: SearchDisplayMode { + get { _displayMode } + set { services.searchService.setSearchDisplayMode(newValue) } + } + + override init(container: DIContainer) { + super.init(container: container) + + appState.timetable.$current.assign(to: &$_currentTimetable) + appState.timetable.$configuration.assign(to: &$_timetableConfig) + appState.search.$selectedLecture.assign(to: &$_selectedLecture) + appState.search.$isFilterOpen.assign(to: &$_isFilterOpen) + appState.search.$searchText.assign(to: &$_searchText) + appState.search.$isLoading.assign(to: &$isLoading) + appState.search.$searchResult.assign(to: &$searchResult) + appState.search.$selectedTagList.assign(to: &$selectedTagList) + appState.search.$displayMode.assign(to: &$_displayMode) + } + + var selectedLecture: Lecture? { + get { _selectedLecture } + set { services.searchService.setSelectedLecture(newValue) } + } + + var currentTimetableWithSelection: Timetable? { + _currentTimetable?.withSelectedLecture(_selectedLecture) + } + + var timetableConfigWithAutoFit: TimetableConfiguration { + _timetableConfig.withAutoFitEnabled() + } + + func fetchTags() async { + if appState.search.searchTagList != nil { + return + } + guard let currentTimetable = appState.timetable.current else { return } + do { + try await services.searchService.fetchTags(quarter: currentTimetable.quarter) + } catch { + services.globalUIService.presentErrorAlert(error: error) + } + } + + func deselectTag(_ tag: SearchTag) { + services.searchService.deselectTag(tag) + } + + func fetchInitialSearchResult() async { + do { + try await services.searchService.fetchInitialSearchResult() + } catch { + services.globalUIService.presentErrorAlert(error: error) + } + } + + func getBookmark() async { + do { + try await services.searchService.getBookmark() + } catch { + services.globalUIService.presentErrorAlert(error: error) + } + } + + func fetchMoreSearchResult() async { + do { + try await services.searchService.fetchMoreSearchResult() + } catch { + services.globalUIService.presentErrorAlert(error: error) + } + } +} diff --git a/SNUTT-2022/SNUTT/ViewModels/SearchSceneViewModel.swift b/SNUTT-2022/SNUTT/ViewModels/SearchSceneViewModel.swift deleted file mode 100644 index b600fc90..00000000 --- a/SNUTT-2022/SNUTT/ViewModels/SearchSceneViewModel.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// SearchSceneViewModel.swift -// SNUTT -// -// Created by 박신홍 on 2022/07/05. -// - -import Combine -import SwiftUI - -class SearchSceneViewModel: TransculentListViewModel { - @Published private var _searchText: String = "" - @Published private var _isFilterOpen: Bool = false - @Published var searchResult: [Lecture]? = nil - @Published var selectedTagList: [SearchTag] = [] - - var searchText: String { - get { _searchText } - set { services.searchService.setSearchText(newValue) } - } - - var isFilterOpen: Bool { - get { _isFilterOpen } - set { services.searchService.setIsFilterOpen(newValue) } - } - - override init(container: DIContainer) { - super.init(container: container) - - appState.search.$searchText.assign(to: &$_searchText) - appState.search.$isFilterOpen.assign(to: &$_isFilterOpen) - appState.search.$searchResult.assign(to: &$searchResult) - appState.search.$selectedTagList.assign(to: &$selectedTagList) - } - - func fetchTags() async { - if appState.search.searchTagList != nil { - return - } - guard let currentTimetable = timetableState.current else { return } - do { - try await services.searchService.fetchTags(quarter: currentTimetable.quarter) - } catch { - services.globalUIService.presentErrorAlert(error: error) - } - } - - func initializeSearchState() { - services.searchService.initializeSearchState() - } - - func fetchInitialSearchResult() async { - do { - try await services.searchService.fetchInitialSearchResult() - } catch { - services.globalUIService.presentErrorAlert(error: error) - } - } - - func deselectTag(_ tag: SearchTag) { - services.searchService.deselectTag(tag) - } - - private var searchState: SearchState { - appState.search - } - - private var timetableState: TimetableState { - appState.timetable - } -} diff --git a/SNUTT-2022/SNUTT/ViewModels/SettingViewModel.swift b/SNUTT-2022/SNUTT/ViewModels/SettingViewModel.swift index dbc004a8..0bb0d077 100644 --- a/SNUTT-2022/SNUTT/ViewModels/SettingViewModel.swift +++ b/SNUTT-2022/SNUTT/ViewModels/SettingViewModel.swift @@ -9,10 +9,8 @@ import Foundation import SwiftUI class SettingViewModel: BaseViewModel, ObservableObject { - @Published var currentUser: User? - @Published var preferredColorScheme: ColorScheme? = nil - @Published var notifications: [STNotification] = [] - @Published var unreadCount: Int = 0 + @Published private(set) var currentUser: User? + @Published private(set) var preferredColorScheme: ColorScheme? = nil @Published var _routingState: SettingScene.RoutingState = .init() var routingState: SettingScene.RoutingState { @@ -26,8 +24,6 @@ class SettingViewModel: BaseViewModel, ObservableObject { super.init(container: container) appState.user.$current.assign(to: &$currentUser) appState.system.$preferredColorScheme.assign(to: &$preferredColorScheme) - appState.notification.$notifications.assign(to: &$notifications) - appState.notification.$unreadCount.assign(to: &$unreadCount) appState.routing.$settingScene.assign(to: &$_routingState) } @@ -35,30 +31,6 @@ class SettingViewModel: BaseViewModel, ObservableObject { appState.user.current?.email } - func fetchInitialNotifications(updateLastRead: Bool) async { - do { - try await services.notificationService.fetchInitialNotifications(updateLastRead: updateLastRead) - } catch { - services.globalUIService.presentErrorAlert(error: error) - } - } - - func fetchMoreNotifications() async { - do { - try await services.notificationService.fetchMoreNotifications() - } catch { - services.globalUIService.presentErrorAlert(error: error) - } - } - - func fetchNotificationsCount() async { - do { - try await services.notificationService.fetchUnreadNotificationCount() - } catch { - services.globalUIService.presentErrorAlert(error: error) - } - } - func hasNewBadge(settingName: String) -> Bool { services.globalUIService.hasNewBadge(settingName: settingName) } diff --git a/SNUTT-2022/SNUTT/ViewModels/TimetableViewModel.swift b/SNUTT-2022/SNUTT/ViewModels/TimetableViewModel.swift index 7df193db..403337c4 100644 --- a/SNUTT-2022/SNUTT/ViewModels/TimetableViewModel.swift +++ b/SNUTT-2022/SNUTT/ViewModels/TimetableViewModel.swift @@ -9,10 +9,20 @@ import Combine import Foundation class TimetableViewModel: BaseViewModel, ObservableObject { - @Published var currentTimetable: Timetable? - @Published var configuration: TimetableConfiguration = .init() + @Published private(set) var currentTimetable: Timetable? + @Published private(set) var configuration: TimetableConfiguration = .init() @Published private var metadataList: [TimetableMetadata]? - @Published var isVacancyBannerVisible = false + @Published private(set) var isVacancyBannerVisible = false + + @Published private(set) var unreadCount: Int = 0 + + @Published private var _routingState: TimetableScene.RoutingState = .init() + var routingState: TimetableScene.RoutingState { + get { _routingState } + set { + services.globalUIService.setRoutingState(\.timetableScene, value: newValue) + } + } override init(container: DIContainer) { super.init(container: container) @@ -21,6 +31,9 @@ class TimetableViewModel: BaseViewModel, ObservableObject { appState.timetable.$configuration.assign(to: &$configuration) appState.timetable.$metadataList.assign(to: &$metadataList) appState.vacancy.$isBannerVisible.assign(to: &$isVacancyBannerVisible) + + appState.notification.$unreadCount.assign(to: &$unreadCount) + appState.routing.$timetableScene.assign(to: &$_routingState) } var totalCredit: Int { diff --git a/SNUTT-2022/SNUTT/ViewModels/TranslucentListViewModel.swift b/SNUTT-2022/SNUTT/ViewModels/TranslucentListViewModel.swift deleted file mode 100644 index 24b01f94..00000000 --- a/SNUTT-2022/SNUTT/ViewModels/TranslucentListViewModel.swift +++ /dev/null @@ -1,179 +0,0 @@ -// -// TranslucentListViewModel.swift -// SNUTT -// -// Created by 이채민 on 2023/01/25. -// - -import Combine -import SwiftUI - -class TransculentListViewModel: BaseViewModel, ObservableObject { - @Published private var _currentTimetable: Timetable? - @Published private var _timetableConfig: TimetableConfiguration = .init() - @Published private var _selectedLecture: Lecture? - @Published private var _selectedTab: TabType = .review - - @Published var isLoading: Bool = false - @Published var isLectureOverlapped: Bool = false - @Published var isEmailVerifyAlertPresented = false - @Published var bookmarkedLectures: [Lecture] = [] - @Published var isFirstBookmarkAlertPresented: Bool = false - @Published var vacancyNotificationLectures: [Lecture] = [] - - var errorTitle: String = "" - var errorMessage: String = "" - - var selectedLecture: Lecture? { - get { _selectedLecture } - set { services.searchService.setSelectedLecture(newValue) } - } - - var selectedTab: TabType { - get { _selectedTab } - set { services.globalUIService.setSelectedTab(newValue) } - } - - var currentTimetableWithSelection: Timetable? { - _currentTimetable?.withSelectedLecture(_selectedLecture) - } - - var timetableConfigWithAutoFit: TimetableConfiguration { - _timetableConfig.withAutoFitEnabled() - } - - override init(container: DIContainer) { - super.init(container: container) - - appState.timetable.$current.assign(to: &$_currentTimetable) - appState.timetable.$configuration.assign(to: &$_timetableConfig) - appState.search.$selectedLecture.assign(to: &$_selectedLecture) - appState.system.$selectedTab.assign(to: &$_selectedTab) - appState.search.$isLoading.assign(to: &$isLoading) - appState.vacancy.$lectures.assign(to: &$vacancyNotificationLectures) - appState.timetable.$bookmark.compactMap { - $0?.lectures - }.assign(to: &$bookmarkedLectures) - } - - func fetchMoreSearchResult() async { - do { - try await services.searchService.fetchMoreSearchResult() - } catch { - services.globalUIService.presentErrorAlert(error: error) - } - } - - func getBookmark() async { - do { - try await services.searchService.getBookmark() - } catch { - services.globalUIService.presentErrorAlert(error: error) - } - } - - func bookmarkLecture(lecture: Lecture) async { - isFirstBookmarkAlertPresented = appState.timetable.isFirstBookmark ?? false - do { - try await services.lectureService.bookmarkLecture(lecture: lecture) - } catch { - services.globalUIService.presentErrorAlert(error: error) - } - } - - func undoBookmarkLecture(selected: Lecture) async { - guard let lecture = getBookmarkedLecture(selected) else { return } - do { - try await services.lectureService.undoBookmarkLecture(lecture: lecture) - } catch { - services.globalUIService.presentErrorAlert(error: error) - } - } - - func getBookmarkedLecture(_ lecture: Lecture) -> Lecture? { - timetableState.bookmark?.lectures.first(where: { $0.isEquivalent(with: lecture) }) - } - - func addLecture(lecture: Lecture) async { - do { - try await services.lectureService.addLecture(lecture: lecture) - } catch { - if let error = error.asSTError { - if error.code == .LECTURE_TIME_OVERLAP { - isLectureOverlapped = true - errorTitle = error.title - errorMessage = error.content - } else { - services.globalUIService.presentErrorAlert(error: error) - } - } - } - } - - func deleteLecture(selected: Lecture) async { - guard let lecture = getExistingLecture(selected) else { return } - do { - try await services.lectureService.deleteLecture(lecture: lecture) - services.searchService.setSelectedLecture(nil) - } catch { - services.globalUIService.presentErrorAlert(error: error) - } - } - - func getExistingLecture(_ lecture: Lecture) -> Lecture? { - timetableState.current?.lectures.first(where: { $0.isEquivalent(with: lecture) }) - } - - func fetchReviewId(of lecture: Lecture) async -> String? { - do { - return try await services.lectureService.fetchReviewId(courseNumber: lecture.courseNumber, instructor: lecture.instructor) - } catch let error as STError where error.code == .EMAIL_NOT_VERIFIED { - errorTitle = error.title - errorMessage = error.content - isEmailVerifyAlertPresented = true - } catch { - services.globalUIService.presentErrorAlert(error: error) - } - return nil - } - - func overwriteLecture(lecture: Lecture) async { - do { - try await services.lectureService.addLecture(lecture: lecture, isForced: true) - } catch { - services.globalUIService.presentErrorAlert(error: error) - } - } - - func preloadReviewWebView(reviewId: String) { - services.globalUIService.sendDetailWebViewReloadSignal(url: WebViewType.reviewDetail(id: reviewId).url) - } - - private var searchState: SearchState { - appState.search - } - - private var timetableState: TimetableState { - appState.timetable - } - - func checkIsVacancyNotificationEnabled(lecture: Lecture) -> Bool { - return vacancyNotificationLectures.contains(where: { $0.isEquivalent(with: lecture) }) - } - - func addVacancyLecture(lecture: Lecture) async { - do { - try await services.vacancyService.addLecture(lecture: lecture) - } catch { - services.globalUIService.presentErrorAlert(error: error) - } - } - - func deleteVacancyLecture(lecture: Lecture) async { - do { - try await services.vacancyService.deleteLectures(lectures: [lecture]) - } catch { - services.globalUIService.presentErrorAlert(error: error) - } - } -} diff --git a/SNUTT-2022/SNUTT/Views/Components/Lecture/ExpandableLectureCell.swift b/SNUTT-2022/SNUTT/Views/Components/Lecture/ExpandableLectureCell.swift new file mode 100644 index 00000000..5bafdd93 --- /dev/null +++ b/SNUTT-2022/SNUTT/Views/Components/Lecture/ExpandableLectureCell.swift @@ -0,0 +1,309 @@ +// +// ExpandableLectureCell.swift +// SNUTT +// +// Created by 박신홍 on 2023/12/17. +// + +import Combine +import SwiftUI + +struct ExpandableLectureCell: View { + @ObservedObject var viewModel: ViewModel + + let lecture: Lecture + let isSelected: Bool + let isBookmarked: Bool + let isInCurrentTimetable: Bool + let isVacancyNotificationEnabled: Bool + + @State private var isDetailPagePresented = false + @State private var isReviewWebViewPresented = false + @State private var isRemoveBookmarkAlertPresented = false + @State private var reviewDetailId: String? = nil + + var body: some View { + ZStack { + if isSelected { + STColor.searchListForeground + } + + VStack(spacing: 8) { + LectureHeaderRow(lecture: lecture) + + if lecture.isCustom { + LectureDetailRow(imageName: "tag.white", text: "") + } else { + LectureDetailRow(imageName: "tag.white", text: "\(lecture.department), \(lecture.academicYear)") + } + LectureDetailRow(imageName: "clock.white", text: lecture.preciseTimeString) + + LectureDetailRow(imageName: "map.white", text: lecture.placesString) + + LectureDetailRow(imageName: "ellipsis.white", text: lecture.remark) + + if isSelected { + Spacer().frame(height: 5) + + HStack { + LectureCellActionButton( + icon: .asset(name: "search.detail"), + text: "자세히" + ) { + isDetailPagePresented = true + } + + LectureCellActionButton( + icon: .asset(name: "search.evaluation"), + text: "강의평" + ) { + reviewDetailId = await viewModel.fetchReviewDetailId(lecture: lecture) + if let reviewId = reviewDetailId { + viewModel.reloadReviewWebView(reviewId: reviewId) + isReviewWebViewPresented = true + } + } + + LectureCellActionButton( + icon: .asset(name: isBookmarked ? "search.bookmark.fill" : "search.bookmark"), + text: "관심강좌" + ) { + if isBookmarked { + isRemoveBookmarkAlertPresented = true + } else { + await viewModel.addBookmarkLecture(lecture) + } + } + .alert("강의를 관심강좌에서 제외하시겠습니까?", isPresented: $isRemoveBookmarkAlertPresented) { + Button("취소", role: .cancel, action: {}) + Button("확인", role: .destructive) { + Task { + await viewModel.removeBookmarkLecture(lecture) + } + } + } + + LectureCellActionButton( + icon: .asset(name: isVacancyNotificationEnabled ? "search.vacancy.fill" : "search.vacancy"), + text: "빈자리알림" + ) { + if isVacancyNotificationEnabled { + await viewModel.removeVacancyLecture(lecture) + } else { + await viewModel.addVacancyLecture(lecture) + } + } + + LectureCellActionButton( + icon: .asset(name: isInCurrentTimetable ? "search.remove.fill" : "search.add"), + text: isInCurrentTimetable ? "제거하기" : "추가하기" + ) { + if isInCurrentTimetable { + await viewModel.removeLecture(lecture) + } else { + await viewModel.addLecture(lecture) + } + } + } + /// This `sheet` modifier should be called on `HStack` to prevent animation glitch when `dismiss`ed. + .sheet(isPresented: $isReviewWebViewPresented) { + ReviewScene( + viewModel: .init(container: viewModel.container), + isMainWebView: false, + detailId: reviewDetailId + ) + .id(reviewDetailId) + } + .sheet(isPresented: $isDetailPagePresented) { + NavigationView { + LectureDetailScene( + viewModel: .init(container: viewModel.container), + lecture: lecture, + displayMode: .preview + ) + } + } + } + } + .foregroundColor(.white) + .padding(.vertical, 10) + .padding(.horizontal, 15) + } + .alert(viewModel.errorTitle, isPresented: $viewModel.isEmailVerifyAlertPresented, actions: { + Button("확인") { + viewModel.selectedTab = .review + } + Button("취소", role: .cancel) {} + }, message: { + Text(viewModel.errorMessage) + }) + .alert(viewModel.errorTitle, isPresented: $viewModel.isLectureOverlapped) { + Button { + Task { + await viewModel.forciblyAddLecture(lecture) + } + } label: { + Text("확인") + } + Button("취소", role: .cancel) { + viewModel.isLectureOverlapped = false + } + } message: { + Text(viewModel.errorMessage) + } + .alert("관심강좌", isPresented: $viewModel.isFirstBookmarkAlertPresented) { + Button("확인", role: .cancel, action: { + viewModel.isFirstBookmarkAlertPresented = false + }) + } message: { + Text("시간표 우측 상단에서 선택한 관심강좌 목록을 확인해보세요") + } + } +} + +extension ExpandableLectureCell { + class ViewModel: BaseViewModel, ObservableObject { + @Published var isLectureOverlapped: Bool = false + @Published var isFirstBookmarkAlertPresented: Bool = false + @Published var isEmailVerifyAlertPresented = false + var errorTitle: String = "" + var errorMessage: String = "" + + private var searchState: SearchState { + appState.search + } + + private var timetableState: TimetableState { + appState.timetable + } + + var selectedTab: TabType { + get { appState.system.selectedTab } + set { services.globalUIService.setSelectedTab(newValue) } + } + } +} + +extension ExpandableLectureCell.ViewModel { + func addLecture(_ lecture: Lecture) async { + do { + try await services.lectureService.addLecture(lecture: lecture) + } catch { + if let error = error.asSTError { + if error.code == .LECTURE_TIME_OVERLAP { + isLectureOverlapped = true + errorTitle = error.title + errorMessage = error.content + } else { + services.globalUIService.presentErrorAlert(error: error) + } + } + } + } + + func forciblyAddLecture(_ lecture: Lecture) async { + do { + try await services.lectureService.addLecture(lecture: lecture, isForced: true) + } catch { + services.globalUIService.presentErrorAlert(error: error) + } + } + + func removeLecture(_ lecture: Lecture) async { + guard isInCurrentTimetable(lecture: lecture) + else { return } + do { + try await services.lectureService.deleteLecture(lecture: lecture) + services.searchService.setSelectedLecture(nil) + } catch { + services.globalUIService.presentErrorAlert(error: error) + } + } + + func addVacancyLecture(_ lecture: Lecture) async { + do { + try await services.vacancyService.addLecture(lecture: lecture) + } catch { + services.globalUIService.presentErrorAlert(error: error) + } + } + + func removeVacancyLecture(_ lecture: Lecture) async { + do { + try await services.vacancyService.deleteLectures(lectures: [lecture]) + } catch { + services.globalUIService.presentErrorAlert(error: error) + } + } + + func addBookmarkLecture(_ lecture: Lecture) async { + isFirstBookmarkAlertPresented = appState.timetable.isFirstBookmark ?? false + do { + try await services.lectureService.bookmarkLecture(lecture: lecture) + } catch { + services.globalUIService.presentErrorAlert(error: error) + } + } + + func removeBookmarkLecture(_ lecture: Lecture) async { + guard isBookmarked(lecture: lecture) else { return } + do { + try await services.lectureService.undoBookmarkLecture(lecture: lecture) + } catch { + services.globalUIService.presentErrorAlert(error: error) + } + } + + func fetchReviewDetailId(lecture: Lecture) async -> String? { + do { + return try await services.lectureService.fetchReviewId(courseNumber: lecture.courseNumber, instructor: lecture.instructor) + } catch let error as STError where error.code == .EMAIL_NOT_VERIFIED { + errorTitle = error.title + errorMessage = error.content + isEmailVerifyAlertPresented = true + } catch { + services.globalUIService.presentErrorAlert(error: error) + } + return nil + } + + func reloadReviewWebView(reviewId: String) { + services.globalUIService.sendDetailWebViewReloadSignal( + url: WebViewType.reviewDetail(id: reviewId).url + ) + } + + private func isBookmarked(lecture: Lecture) -> Bool { + timetableState.bookmark?.lectures + .contains(where: { $0.isEquivalent(with: lecture) }) ?? false + } + + private func isInCurrentTimetable(lecture: Lecture) -> Bool { + timetableState.current?.lectures + .contains { $0.isEquivalent(with: lecture) } ?? false + } + + private func isVacancyNotificationEnabled(lecture: Lecture) -> Bool { + appState.vacancy.lectures + .contains { $0.isEquivalent(with: lecture) } + } +} + +extension ObservableObject where Self.ObjectWillChangePublisher == ObservableObjectPublisher { + func objectWillChangeWhen(triggeredBy publishers: Any...) -> AnyCancellable { + let typeErasedPublishers = publishers.compactMap { + let publisher = $0 as? (any Publisher) + return publisher?.eraseToAnyVoidPublisher() + } + return Publishers.MergeMany(typeErasedPublishers) + .sink { [weak self] _ in self?.objectWillChange.send() } + } +} + +extension Publisher { + func eraseToAnyVoidPublisher() -> AnyPublisher { + return map { _ in () } + .replaceError(with: ()) + .eraseToAnyPublisher() + } +} diff --git a/SNUTT-2022/SNUTT/Views/Components/Lecture/ExpandableLectureList.swift b/SNUTT-2022/SNUTT/Views/Components/Lecture/ExpandableLectureList.swift new file mode 100644 index 00000000..e3d67878 --- /dev/null +++ b/SNUTT-2022/SNUTT/Views/Components/Lecture/ExpandableLectureList.swift @@ -0,0 +1,83 @@ +// +// ExpandableLectureList.swift +// SNUTT +// +// Created by 박신홍 on 2023/12/17. +// + +import Combine +import SwiftUI + +struct ExpandableLectureList: View { + @ObservedObject var viewModel: ViewModel + + let lectures: [Lecture] + @Binding var selectedLecture: Lecture? + var fetchMoreLectures: (() async -> Void)? = nil + + var body: some View { + let _ = debugChanges() + ScrollView { + LazyVStack(spacing: 0) { + ForEach(lectures) { lecture in + ExpandableLectureCell( + viewModel: .init(container: viewModel.container), + lecture: lecture, + isSelected: lecture.id == selectedLecture?.id, + isBookmarked: viewModel.isBookmarked(lecture: lecture), + isInCurrentTimetable: viewModel.isInCurrentTimetable(lecture: lecture), + isVacancyNotificationEnabled: viewModel.isVacancyNotificationEnabled(lecture: lecture) + ) + + .task { + if lecture.id == lectures.last?.id { + await fetchMoreLectures?() + } + } + .contentShape(Rectangle()) + .onTapGesture { + if selectedLecture?.id != lecture.id { + selectedLecture = lecture + } + } + } + } + } + .scrollDismissesKeyboardInteractively() + } +} + +extension ExpandableLectureList { + class ViewModel: BaseViewModel, ObservableObject { + private var timetableState: TimetableState { + appState.timetable + } + + private var cancellables: Set = .init() + + override init(container: DIContainer) { + super.init(container: container) + + objectWillChangeWhen(triggeredBy: + timetableState.$bookmark, + timetableState.$current, + appState.vacancy.$lectures) + .store(in: &cancellables) + } + + func isBookmarked(lecture: Lecture) -> Bool { + timetableState.bookmark?.lectures + .contains(where: { $0.isEquivalent(with: lecture) }) ?? false + } + + func isInCurrentTimetable(lecture: Lecture) -> Bool { + timetableState.current?.lectures + .contains { $0.isEquivalent(with: lecture) } ?? false + } + + func isVacancyNotificationEnabled(lecture: Lecture) -> Bool { + appState.vacancy.lectures + .contains { $0.isEquivalent(with: lecture) } + } + } +} diff --git a/SNUTT-2022/SNUTT/Views/Components/NotificationList.swift b/SNUTT-2022/SNUTT/Views/Components/NotificationList.swift index ec431f54..8f2ce98c 100644 --- a/SNUTT-2022/SNUTT/Views/Components/NotificationList.swift +++ b/SNUTT-2022/SNUTT/Views/Components/NotificationList.swift @@ -8,16 +8,15 @@ import SwiftUI struct NotificationList: View { - let notifications: [STNotification] - let initialFetch: (Bool) async -> Void - let fetchMore: () async -> Void + @ObservedObject var viewModel: ViewModel + var body: some View { List { - ForEach(notifications, id: \.hashValue) { notification in + ForEach(viewModel.notifications, id: \.hashValue) { notification in NotificationListCell(notification: notification) .task { - if notification.hashValue == notifications.last?.hashValue { - await fetchMore() + if notification.hashValue == viewModel.notifications.last?.hashValue { + await viewModel.fetchMoreNotifications() } } .listRowBackground(STColor.systemBackground) @@ -29,7 +28,34 @@ struct NotificationList: View { .navigationBarTitleDisplayMode(.inline) .background(STColor.systemBackground) .task { - await initialFetch(true) + await viewModel.fetchInitialNotifications(updateLastRead: true) + } + } +} + +extension NotificationList { + class ViewModel: BaseViewModel, ObservableObject { + @Published private(set) var notifications: [STNotification] = [] + + override init(container: DIContainer) { + super.init(container: container) + appState.notification.$notifications.assign(to: &$notifications) + } + + func fetchInitialNotifications(updateLastRead: Bool) async { + do { + try await services.notificationService.fetchInitialNotifications(updateLastRead: updateLastRead) + } catch { + services.globalUIService.presentErrorAlert(error: error) + } + } + + func fetchMoreNotifications() async { + do { + try await services.notificationService.fetchMoreNotifications() + } catch { + services.globalUIService.presentErrorAlert(error: error) + } } } } @@ -45,9 +71,12 @@ struct NotificationList: View { } static var previews: some View { - NotificationList(notifications: notifications) { _ in - - } fetchMore: {} + var container: DIContainer = { + let container = DIContainer.preview + container.appState.notification.notifications = Self.notifications + return container + }() + NotificationList(viewModel: .init(container: container)) } } #endif diff --git a/SNUTT-2022/SNUTT/Views/Components/Search/SearchBar.swift b/SNUTT-2022/SNUTT/Views/Components/Search/SearchBar.swift index 087423d3..19fae580 100644 --- a/SNUTT-2022/SNUTT/Views/Components/Search/SearchBar.swift +++ b/SNUTT-2022/SNUTT/Views/Components/Search/SearchBar.swift @@ -9,89 +9,102 @@ import SwiftUI import UIKit struct SearchBar: View { + @AppStorage("isNewToBookmark") var isNewToBookmark: Bool = true + @State private var pushToBookmarkScene = false @Binding var text: String @Binding var isFilterOpen: Bool - var shouldShowCancelButton: Bool + @Binding var displayMode: SearchDisplayMode + var action: @MainActor () async -> Void - var cancel: @MainActor () -> Void @State private var isEditing = false @FocusState private var isFocused: Bool - @State private var showCancel: Bool = false - var body: some View { - HStack { - TextField("검색어를 입력하세요", text: $text) { startedEditing in - isEditing = startedEditing - } - .onSubmit { - showCancel = true - Task { - await action() - } - } - .submitLabel(.search) - .focused($isFocused) - .frame(maxHeight: 22) - .padding(7) - .padding(.horizontal, 25) - .onTapGesture { - isFocused = true - } - .background(Color(.systemGray6)) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay( - HStack { - Image(systemName: "magnifyingglass") - .foregroundColor(.gray) - .padding(.leading, 8) + @Environment(\.dependencyContainer) var container: DIContainer? - Spacer() + @State private var feedbackGenerator = UIImpactFeedbackGenerator(style: .light) - if !text.isEmpty { - Button(action: { - isFocused = true - text = "" - }) { - Image(systemName: "multiply.circle.fill") - .foregroundColor(.gray) - } - } + private var searchInputBar: some View { + TextField("검색어를 입력하세요", text: $text) { startedEditing in + isEditing = startedEditing + } + .onSubmit { + Task { + await action() + } + } + .submitLabel(.search) + .focused($isFocused) + .frame(maxHeight: 22) + .padding(7) + .padding(.leading, 25) + .padding(.trailing, 70) + .onTapGesture { + isFocused = true + } + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.gray) + .padding(.leading, 8) - Button { - isFilterOpen = true - } label: { - Image("search.filter") - .padding(.trailing, 8) - } - } - ) + Spacer() - Group { - if showCancel || isEditing { + if !text.isEmpty { Button(action: { - withAnimation(.customSpring) { - isFocused = false - text = "" - showCancel = false - cancel() - } + isFocused = true + text = "" }) { - Text("취소") + Image(systemName: "multiply.circle.fill") + .foregroundColor(.gray) } } + + Button { + isFilterOpen = true + feedbackGenerator.impactOccurred() + } label: { + Image("search.filter") + .padding(.trailing, 8) + } } - .transition(.move(edge: .trailing).combined(with: .opacity)) + ) + } + + var body: some View { + HStack { + switch displayMode { + case .search: + searchInputBar + .frame(maxHeight: .infinity) + .transition(.move(edge: .leading).combined(with: .opacity)) + case .bookmark: + HStack { + Text("관심강좌") + .padding(.horizontal, 5) + .frame(maxHeight: .infinity) + .font(.system(.headline)) + Spacer() + } + .frame(maxWidth: .infinity) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + + NavBarButton(imageName: displayMode == .bookmark ? "nav.bookmark.on" : "nav.bookmark") { + isNewToBookmark = false + displayMode.toggle() + if displayMode == .bookmark { + isFocused = false + } + feedbackGenerator.impactOccurred() + } + .circleBadge(condition: isNewToBookmark) } .padding(.horizontal, 10) - .padding(.bottom, 8) - .padding(.top, 5) .background(STColor.searchBarBackground) .animation(.easeOut(duration: 0.2), value: isEditing) - .animation(.easeOut(duration: 0.2), value: showCancel) - .onChange(of: shouldShowCancelButton) { newValue in - showCancel = newValue - } .onChange(of: isFilterOpen) { newValue in if newValue { isFocused = false @@ -104,7 +117,7 @@ struct SearchBar_Previews: PreviewProvider { static var previews: some View { ZStack { Color.black - SearchBar(text: .constant("Constant String"), isFilterOpen: .constant(false), shouldShowCancelButton: false, action: {}, cancel: {}) + SearchBar(text: .constant("Constant String"), isFilterOpen: .constant(false), displayMode: .constant(.bookmark), action: {}) } } } diff --git a/SNUTT-2022/SNUTT/Views/Components/Search/SearchLectureCell.swift b/SNUTT-2022/SNUTT/Views/Components/Search/SearchLectureCell.swift deleted file mode 100644 index 6efc198a..00000000 --- a/SNUTT-2022/SNUTT/Views/Components/Search/SearchLectureCell.swift +++ /dev/null @@ -1,140 +0,0 @@ -// -// SearchLectureCell.swift -// SNUTT -// -// Created by 박신홍 on 2022/07/22. -// - -import SwiftUI - -struct SearchLectureCell: View { - let lecture: Lecture - let selected: Bool - let bookmarkLecture: (Lecture) async -> Void - let undoBookmarkLecture: (Lecture) async -> Void - let addLecture: (Lecture) async -> Void - let deleteLecture: (Lecture) async -> Void - let fetchReviewId: (Lecture) async -> String? - let preloadReviewWebView: @MainActor (String) -> Void - let addVacancyLecture: (Lecture) async -> Void - let deleteVacancyLecture: (Lecture) async -> Void - let isBookmarked: Bool - let isInTimetable: Bool - let isVacancyNotificationEnabled: Bool - - @State var showingDetailPage = false - @State private var showReviewWebView: Bool = false - @State private var reviewId: String? = nil - @State private var isUndoBookmarkAlertPresented = false - - @Environment(\.dependencyContainer) var container: DIContainer? - - var body: some View { - ZStack { - if selected { - STColor.searchListForeground - } - - VStack(spacing: 8) { - // title - LectureHeaderRow(lecture: lecture) - - // details - if lecture.isCustom { - LectureDetailRow(imageName: "tag.white", text: "") - } else { - LectureDetailRow(imageName: "tag.white", text: "\(lecture.department), \(lecture.academicYear)") - } - LectureDetailRow(imageName: "clock.white", text: lecture.preciseTimeString) - - LectureDetailRow(imageName: "map.white", text: lecture.placesString) - - LectureDetailRow(imageName: "ellipsis.white", text: lecture.remark) - - if selected { - Spacer().frame(height: 5) - - HStack { - LectureCellActionButton( - icon: .asset(name: "search.detail"), - text: "자세히" - ) { - showingDetailPage = true - } - - LectureCellActionButton( - icon: .asset(name: "search.evaluation"), - text: "강의평" - ) { - reviewId = await fetchReviewId(lecture) - if let detailId = reviewId { - preloadReviewWebView(detailId) - showReviewWebView = true - } - } - - LectureCellActionButton( - icon: .asset(name: isBookmarked ? "search.bookmark.fill" : "search.bookmark"), - text: "관심강좌" - ) { - if isBookmarked { - isUndoBookmarkAlertPresented = true - } else { - await bookmarkLecture(lecture) - } - } - .alert("강의를 관심강좌에서 제외하시겠습니까?", isPresented: $isUndoBookmarkAlertPresented) { - Button("취소", role: .cancel, action: {}) - Button("확인", role: .destructive) { - Task { - await undoBookmarkLecture(lecture) - } - } - } - - LectureCellActionButton( - icon: .asset(name: isVacancyNotificationEnabled ? "search.vacancy.fill" : "search.vacancy"), - text: "빈자리알림" - ) { - if isVacancyNotificationEnabled { - await deleteVacancyLecture(lecture) - } else { - await addVacancyLecture(lecture) - } - } - - LectureCellActionButton( - icon: .asset(name: isInTimetable ? "search.remove.fill" : "search.add"), - text: isInTimetable ? "제거하기" : "추가하기" - ) { - if isInTimetable { - await deleteLecture(lecture) - } else { - await addLecture(lecture) - } - } - } - /// This `sheet` modifier should be called on `HStack` to prevent animation glitch when `dismiss`ed. - .sheet(isPresented: $showReviewWebView) { - if let container = container { - ReviewScene(viewModel: .init(container: container), isMainWebView: false, detailId: $reviewId) - .id(reviewId) - } - } - .sheet(isPresented: $showingDetailPage) { - if let container = container { - NavigationView { - LectureDetailScene(viewModel: .init(container: container), lecture: lecture, displayMode: .preview) - } - } - } - } - } - .foregroundColor(.white) - .padding(.vertical, 10) - .padding(.horizontal, 15) - } - - let _ = debugChanges() - } -} diff --git a/SNUTT-2022/SNUTT/Views/Components/Search/SearchLectureList.swift b/SNUTT-2022/SNUTT/Views/Components/Search/SearchLectureList.swift deleted file mode 100644 index bd1672c1..00000000 --- a/SNUTT-2022/SNUTT/Views/Components/Search/SearchLectureList.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// SearchLectureList.swift -// SNUTT -// -// Created by 박신홍 on 2022/09/04. -// - -import SwiftUI - -struct SearchLectureList: View { - let data: [Lecture] - let fetchMore: () async -> Void - let bookmarkedLecture: @MainActor (Lecture) -> Lecture? - let existingLecture: @MainActor (Lecture) -> Lecture? - let bookmarkLecture: (Lecture) async -> Void - let undoBookmarkLecture: (Lecture) async -> Void - let addLecture: (Lecture) async -> Void - let deleteLecture: (Lecture) async -> Void - let fetchReviewId: (Lecture) async -> String? - let overwriteLecture: (Lecture) async -> Void - let preloadReviewWebView: @MainActor (String) -> Void - let checkIsVacancyNotificationEnabled: @MainActor (Lecture) -> Bool - let addVacancyLecture: (Lecture) async -> Void - let deleteVacancyLecture: (Lecture) async -> Void - let errorTitle: String - let errorMessage: String - @Binding var isLectureOverlapped: Bool - @Binding var selected: Lecture? - @Binding var isFirstBookmarkAlertPresented: Bool - - var body: some View { - ScrollView { - LazyVStack(spacing: 0) { - ForEach(data) { lecture in - SearchLectureCell(lecture: lecture, - selected: selected?.id == lecture.id, - bookmarkLecture: bookmarkLecture, - undoBookmarkLecture: undoBookmarkLecture, - addLecture: addLecture, - deleteLecture: deleteLecture, - fetchReviewId: fetchReviewId, - preloadReviewWebView: preloadReviewWebView, - addVacancyLecture: addVacancyLecture, - deleteVacancyLecture: deleteVacancyLecture, - isBookmarked: bookmarkedLecture(lecture) != nil, - isInTimetable: existingLecture(lecture) != nil, - isVacancyNotificationEnabled: checkIsVacancyNotificationEnabled(lecture)) - .task { - if lecture.id == data.last?.id { - await fetchMore() - } - } - .contentShape(Rectangle()) - .onTapGesture { - if selected?.id != lecture.id { - selected = lecture - } - } - .alert(errorTitle, isPresented: $isLectureOverlapped) { - Button { - Task { - await overwriteLecture(selected!) - } - } label: { - Text("확인") - } - - Button("취소", role: .cancel) { - isLectureOverlapped = false - } - } message: { - Text(errorMessage) - } - .alert("관심강좌", isPresented: $isFirstBookmarkAlertPresented) { - Button("확인", role: .cancel, action: { - isFirstBookmarkAlertPresented = false - }) - } message: { - Text("시간표 우측 상단에서 선택한 관심강좌 목록을 확인해보세요") - } - } - } - } - - let _ = debugChanges() - } -} diff --git a/SNUTT-2022/SNUTT/Views/SNUTTView.swift b/SNUTT-2022/SNUTT/Views/SNUTTView.swift index d75d7725..81828c6e 100644 --- a/SNUTT-2022/SNUTT/Views/SNUTTView.swift +++ b/SNUTT-2022/SNUTT/Views/SNUTTView.swift @@ -8,20 +8,23 @@ import Combine import SwiftUI -struct SNUTTView: View { +struct SNUTTView: View, Sendable { @ObservedObject var viewModel: ViewModel - /// Required to synchronize between two navigation bar heights: `TimetableScene` and `SearchLectureScene`. - @MainActor @State private var navigationBarHeight: CGFloat = 0 - private var selected: Binding { Binding { viewModel.selectedTab } set: { [previous = viewModel.selectedTab] current in - if previous == current, current == .review { + let hasDoubleTapped = { (tabType: TabType) -> Bool in + previous == current && current == tabType + } + if hasDoubleTapped(.review) { viewModel.reloadReviewWebView() } + if hasDoubleTapped(.search) { + viewModel.initializeSearchState() + } viewModel.selectedTab = current } } @@ -34,12 +37,9 @@ struct SNUTTView: View { TabView(selection: selected) { TabScene(tabType: .timetable) { TimetableScene(viewModel: .init(container: viewModel.container)) - .background(NavigationBarReader { navbar in - navigationBarHeight = navbar.frame.height - }) } TabScene(tabType: .search) { - SearchLectureScene(viewModel: .init(container: viewModel.container), navigationBarHeight: navigationBarHeight) + SearchLectureScene(viewModel: .init(container: viewModel.container)) } TabScene(tabType: .review) { ReviewScene(viewModel: .init(container: viewModel.container), isMainWebView: true) @@ -75,6 +75,9 @@ struct SNUTTView: View { group.addTask { await viewModel.fetchReactNativeBundleIfNeeded() } + group.addTask { + await viewModel.fetchNotificationsCount() + } }) } @@ -218,6 +221,18 @@ extension SNUTTView { // pass } } + + func fetchNotificationsCount() async { + do { + try await services.notificationService.fetchUnreadNotificationCount() + } catch { + services.globalUIService.presentErrorAlert(error: error) + } + } + + func initializeSearchState() { + services.searchService.initializeSearchState() + } } } @@ -236,27 +251,3 @@ enum TabType: String { } } #endif - -// TODO: move elsewhere if needed -struct NavigationBarReader: UIViewControllerRepresentable { - var callback: (UINavigationBar) -> Void - private let proxyController = ViewController() - - func makeUIViewController(context _: UIViewControllerRepresentableContext) -> UIViewController { - proxyController.callback = callback - return proxyController - } - - func updateUIViewController(_: UIViewController, context _: UIViewControllerRepresentableContext) {} - - private class ViewController: UIViewController { - var callback: (UINavigationBar) -> Void = { _ in } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - if let navBar = navigationController { - callback(navBar.navigationBar) - } - } - } -} diff --git a/SNUTT-2022/SNUTT/Views/Scenes/BookmarkScene.swift b/SNUTT-2022/SNUTT/Views/Scenes/BookmarkScene.swift index 44659d26..fefd367d 100644 --- a/SNUTT-2022/SNUTT/Views/Scenes/BookmarkScene.swift +++ b/SNUTT-2022/SNUTT/Views/Scenes/BookmarkScene.swift @@ -8,70 +8,45 @@ import SwiftUI struct BookmarkScene: View { - @ObservedObject var viewModel: TransculentListViewModel - @State private var reloadBookmarkList: Int = 0 + @ObservedObject var viewModel: ViewModel var body: some View { - GeometryReader { _ in - ZStack { - Group { - VStack { - TimetableZStack(current: viewModel.currentTimetableWithSelection, - config: viewModel.timetableConfigWithAutoFit) - .animation(.customSpring, value: viewModel.selectedLecture?.id) - } - STColor.searchListBackground - } - - if viewModel.bookmarkedLectures.isEmpty { - EmptyBookmarkList() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - SearchLectureList(data: viewModel.bookmarkedLectures, - fetchMore: viewModel.fetchMoreSearchResult, - bookmarkedLecture: viewModel.getBookmarkedLecture, - existingLecture: viewModel.getExistingLecture, - bookmarkLecture: viewModel.bookmarkLecture, - undoBookmarkLecture: viewModel.undoBookmarkLecture, - addLecture: viewModel.addLecture, - deleteLecture: viewModel.deleteLecture, - fetchReviewId: viewModel.fetchReviewId(of:), - overwriteLecture: viewModel.overwriteLecture(lecture:), - preloadReviewWebView: viewModel.preloadReviewWebView(reviewId:), - checkIsVacancyNotificationEnabled: viewModel.checkIsVacancyNotificationEnabled(lecture:), - addVacancyLecture: viewModel.addVacancyLecture(lecture:), - deleteVacancyLecture: viewModel.deleteVacancyLecture(lecture:), - errorTitle: viewModel.errorTitle, - errorMessage: viewModel.errorMessage, - isLectureOverlapped: $viewModel.isLectureOverlapped, - selected: $viewModel.selectedLecture, - isFirstBookmarkAlertPresented: $viewModel.isFirstBookmarkAlertPresented) - .animation(.customSpring, value: viewModel.selectedLecture?.id) - .id(reloadBookmarkList) - } + VStack(spacing: 0) { + if viewModel.bookmarkedLectures.isEmpty { + EmptyBookmarkList() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ExpandableLectureList( + viewModel: .init(container: viewModel.container), + lectures: viewModel.bookmarkedLectures, + selectedLecture: $viewModel.selectedLecture + ) + .animation(.customSpring, value: viewModel.selectedLecture?.id) } } - .alert(viewModel.errorTitle, isPresented: $viewModel.isEmailVerifyAlertPresented, actions: { - Button("확인") { - viewModel.selectedTab = .review - } - Button("취소", role: .cancel) {} - }, message: { - Text(viewModel.errorMessage) - }) .navigationTitle("관심강좌") .navigationBarTitleDisplayMode(.inline) .animation(.customSpring, value: viewModel.bookmarkedLectures.count) - .animation(.customSpring, value: viewModel.isLoading) - .onLoad { - Task { - await viewModel.getBookmark() - } + } +} + +extension BookmarkScene { + class ViewModel: BaseViewModel, ObservableObject { + @Published private var _selectedLecture: Lecture? + @Published var bookmarkedLectures: [Lecture] = [] + + override init(container: DIContainer) { + super.init(container: container) + + appState.search.$selectedLecture.assign(to: &$_selectedLecture) + appState.timetable.$bookmark.compactMap { + $0?.lectures + }.assign(to: &$bookmarkedLectures) } - .onChange(of: viewModel.isLoading) { _ in - withAnimation(.customSpring) { - reloadBookmarkList += 1 - } + + var selectedLecture: Lecture? { + get { _selectedLecture } + set { services.searchService.setSelectedLecture(newValue) } } } } diff --git a/SNUTT-2022/SNUTT/Views/Scenes/LectureDetailScene.swift b/SNUTT-2022/SNUTT/Views/Scenes/LectureDetailScene.swift index 9ea53ee8..3ce81a70 100644 --- a/SNUTT-2022/SNUTT/Views/Scenes/LectureDetailScene.swift +++ b/SNUTT-2022/SNUTT/Views/Scenes/LectureDetailScene.swift @@ -219,7 +219,7 @@ struct LectureDetailScene: View { viewModel.reloadDetailWebView(detailId: reviewId) } .sheet(isPresented: $showReviewWebView) { - ReviewScene(viewModel: .init(container: viewModel.container), isMainWebView: false, detailId: $reviewId) + ReviewScene(viewModel: .init(container: viewModel.container), isMainWebView: false, detailId: reviewId) .id(colorScheme) .id(reviewId) } diff --git a/SNUTT-2022/SNUTT/Views/Scenes/ReviewScene.swift b/SNUTT-2022/SNUTT/Views/Scenes/ReviewScene.swift index fc1d252b..d6139b86 100644 --- a/SNUTT-2022/SNUTT/Views/Scenes/ReviewScene.swift +++ b/SNUTT-2022/SNUTT/Views/Scenes/ReviewScene.swift @@ -10,16 +10,16 @@ import SwiftUI struct ReviewScene: View { @ObservedObject var viewModel: ViewModel - @Binding var detailId: String? + var detailId: String? private var isMainWebView: Bool @Environment(\.colorScheme) var colorScheme @Environment(\.dismiss) var dismiss - init(viewModel: ViewModel, isMainWebView: Bool, detailId: Binding = .constant(nil)) { + init(viewModel: ViewModel, isMainWebView: Bool, detailId: String? = nil) { self.viewModel = viewModel - _detailId = detailId + self.detailId = detailId self.isMainWebView = isMainWebView eventSignal?.send(.colorSchemeChange(to: viewModel.preferredColorScheme)) diff --git a/SNUTT-2022/SNUTT/Views/Scenes/SearchLectureScene.swift b/SNUTT-2022/SNUTT/Views/Scenes/SearchLectureScene.swift index dc4e25be..0f33427a 100644 --- a/SNUTT-2022/SNUTT/Views/Scenes/SearchLectureScene.swift +++ b/SNUTT-2022/SNUTT/Views/Scenes/SearchLectureScene.swift @@ -8,107 +8,113 @@ import SwiftUI struct SearchLectureScene: View { - @ObservedObject var viewModel: SearchSceneViewModel - var navigationBarHeight: CGFloat + @ObservedObject var viewModel: SearchLectureSceneViewModel + + private enum Design { + static let searchBarHeight = 44.0 + } @State private var reloadSearchList: Int = 0 - @State private var reviewId: String = "" var body: some View { - GeometryReader { reader in - ZStack { - // MARK: Background Timetable - - Group { - VStack { - Spacer() - .frame(height: navigationBarHeight) - TimetableZStack(current: viewModel.currentTimetableWithSelection, - config: viewModel.timetableConfigWithAutoFit) - .animation(.customSpring, value: viewModel.selectedLecture?.id) - } - STColor.searchListBackground - } - .ignoresSafeArea(.keyboard) - - VStack(spacing: 0) { - // MARK: SearchBar with padding - - VStack { - Spacer() - SearchBar(text: $viewModel.searchText, - isFilterOpen: $viewModel.isFilterOpen, - shouldShowCancelButton: viewModel.searchResult != nil, - action: viewModel.fetchInitialSearchResult, - cancel: viewModel.initializeSearchState) - } - .frame(height: reader.safeAreaInsets.top + navigationBarHeight) - - // MARK: Selected Filter Tags - - if viewModel.selectedTagList.count > 0 { - SearchTagsScrollView(selectedTagList: viewModel.selectedTagList, deselect: viewModel.deselectTag) - } - - // MARK: Main Content - - if viewModel.isLoading { - ProgressView() - .frame(maxHeight: .infinity, alignment: .center) - } else if viewModel.searchResult == nil { - SearchTips() - } else if viewModel.searchResult?.count == 0 { - EmptySearchResult() - } else { - SearchLectureList(data: viewModel.searchResult!, - fetchMore: viewModel.fetchMoreSearchResult, - bookmarkedLecture: viewModel.getBookmarkedLecture, - existingLecture: viewModel.getExistingLecture, - bookmarkLecture: viewModel.bookmarkLecture, - undoBookmarkLecture: viewModel.undoBookmarkLecture, - addLecture: viewModel.addLecture, - deleteLecture: viewModel.deleteLecture, - fetchReviewId: viewModel.fetchReviewId(of:), - overwriteLecture: viewModel.overwriteLecture(lecture:), - preloadReviewWebView: viewModel.preloadReviewWebView(reviewId:), - checkIsVacancyNotificationEnabled: viewModel.checkIsVacancyNotificationEnabled(lecture:), - addVacancyLecture: viewModel.addVacancyLecture(lecture:), - deleteVacancyLecture: viewModel.deleteVacancyLecture(lecture:), - errorTitle: viewModel.errorTitle, - errorMessage: viewModel.errorMessage, - isLectureOverlapped: $viewModel.isLectureOverlapped, - selected: $viewModel.selectedLecture, - isFirstBookmarkAlertPresented: $viewModel.isFirstBookmarkAlertPresented) - .animation(.customSpring, value: viewModel.selectedLecture?.id) - .id(reloadSearchList) // reload everything when any of the search conditions changes - } + ZStack { + backgroundTimetableView + + VStack(spacing: 0) { + switch viewModel.displayMode { + case .search: + searchContentView + .transition(.move(edge: .leading)) + case .bookmark: + bookmarkContentView + .transition(.move(edge: .trailing)) } - .edgesIgnoringSafeArea(.top) - .ignoresSafeArea(.keyboard) } - .task { - await viewModel.fetchTags() + } + .safeAreaInset(edge: .top, alignment: .center, spacing: 0) { + SearchBar(text: $viewModel.searchText, + isFilterOpen: $viewModel.isFilterOpen, + displayMode: $viewModel.displayMode, + action: viewModel.fetchInitialSearchResult) + .frame(height: Design.searchBarHeight) + } + .task { + await viewModel.fetchTags() + } + .task { + await viewModel.getBookmark() + } + .navigationBarHidden(true) + .animation(.customSpring, value: viewModel.searchResult?.count) + .animation(.customSpring, value: viewModel.isLoading) + .animation(.customSpring, value: viewModel.selectedTagList.count) + .animation(.customSpring, value: viewModel.displayMode) + .onChange(of: viewModel.isLoading) { _ in + withAnimation(.customSpring) { + reloadSearchList += 1 } - .alert(viewModel.errorTitle, isPresented: $viewModel.isEmailVerifyAlertPresented, actions: { - Button("확인") { - viewModel.selectedTab = .review - } - Button("취소", role: .cancel) {} - }, message: { - Text(viewModel.errorMessage) - }) - .navigationBarHidden(true) - .animation(.customSpring, value: viewModel.searchResult?.count) - .animation(.customSpring, value: viewModel.isLoading) - .animation(.customSpring, value: viewModel.selectedTagList.count) - .onChange(of: viewModel.isLoading) { _ in - withAnimation(.customSpring) { - reloadSearchList += 1 + } + + let _ = debugChanges() + } + + private var backgroundTimetableView: some View { + Group { + VStack { + TimetableZStack(current: viewModel.currentTimetableWithSelection, + config: viewModel.timetableConfigWithAutoFit) + .animation(.customSpring, value: viewModel.selectedLecture?.id) + } + STColor.searchListBackground + } + .ignoresSafeArea(.keyboard) + } + + private var searchContentView: some View { + VStack(spacing: 0) { + if viewModel.selectedTagList.count > 0 { + SearchTagsScrollView(selectedTagList: viewModel.selectedTagList, deselect: viewModel.deselectTag) + } + + Group { + if viewModel.isLoading { + ProgressView() + .frame(maxHeight: .infinity, alignment: .center) + } else if viewModel.searchResult == nil { + SearchTips() + } else if viewModel.searchResult?.count == 0 { + EmptySearchResult() + } else if let searchResult = viewModel.searchResult { + ExpandableLectureList( + viewModel: .init(container: viewModel.container), + lectures: searchResult, + selectedLecture: $viewModel.selectedLecture, + fetchMoreLectures: viewModel.fetchMoreSearchResult + ) + .animation(.customSpring, value: viewModel.selectedLecture?.id) + .id(reloadSearchList) // reload everything when any of the search conditions changes } } } + .frame(maxWidth: .infinity) + } - let _ = debugChanges() + private var bookmarkContentView: some View { + BookmarkScene(viewModel: .init(container: viewModel.container)) + } +} + +enum SearchDisplayMode { + case search + case bookmark + + mutating func toggle() { + switch self { + case .search: + self = .bookmark + case .bookmark: + self = .search + } } } @@ -127,7 +133,7 @@ extension EnvironmentValues { struct SearchLectureScene_Previews: PreviewProvider { static var previews: some View { NavigationView { - SearchLectureScene(viewModel: .init(container: .preview), navigationBarHeight: 80) + SearchLectureScene(viewModel: .init(container: .preview)) } } } diff --git a/SNUTT-2022/SNUTT/Views/Scenes/Settings/SettingScene.swift b/SNUTT-2022/SNUTT/Views/Scenes/Settings/SettingScene.swift index 27a4ec6d..c05ae48c 100644 --- a/SNUTT-2022/SNUTT/Views/Scenes/Settings/SettingScene.swift +++ b/SNUTT-2022/SNUTT/Views/Scenes/Settings/SettingScene.swift @@ -9,7 +9,6 @@ import SwiftUI struct SettingScene: View { @ObservedObject var viewModel: SettingViewModel - @State private var pushToNotiScene = false @State private var isLogoutAlertPresented: Bool = false init(viewModel: SettingViewModel) { @@ -99,28 +98,8 @@ struct SettingScene: View { .listStyle(.insetGrouped) .navigationTitle("더보기") .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - NavBarButton(imageName: "nav.alarm.off") { - pushToNotiScene = true - } - .circleBadge(condition: viewModel.unreadCount > 0) - } - } - .background { - NavigationLink(destination: NotificationList(notifications: viewModel.notifications, - initialFetch: viewModel.fetchInitialNotifications, - fetchMore: viewModel.fetchMoreNotifications), isActive: $pushToNotiScene) { EmptyView() } - } - .background { - NavigationLink(destination: NotificationList(notifications: viewModel.notifications, - initialFetch: viewModel.fetchInitialNotifications, - fetchMore: viewModel.fetchMoreNotifications), isActive: $viewModel.routingState.pushToNotification) { EmptyView() } - } .task { await viewModel.fetchUser() - await viewModel.fetchInitialNotifications(updateLastRead: false) - await viewModel.fetchNotificationsCount() } let _ = debugChanges() diff --git a/SNUTT-2022/SNUTT/Views/Scenes/TimetableScene.swift b/SNUTT-2022/SNUTT/Views/Scenes/TimetableScene.swift index f1f47243..b954f9d4 100644 --- a/SNUTT-2022/SNUTT/Views/Scenes/TimetableScene.swift +++ b/SNUTT-2022/SNUTT/Views/Scenes/TimetableScene.swift @@ -9,9 +9,8 @@ import LinkPresentation import SwiftUI struct TimetableScene: View, Sendable { - @AppStorage("isNewToBookmark") var isNewToBookmark: Bool = true + @State private var pushToNotiScene = false @State private var pushToListScene = false - @State private var pushToBookmarkScene = false @State private var isShareSheetOpened = false @State private var screenshot: UIImage = .init() @ObservedObject var viewModel: TimetableViewModel @@ -43,7 +42,6 @@ struct TimetableScene: View, Sendable { .background( Group { NavigationLink(destination: LectureListScene(viewModel: .init(container: viewModel.container)), isActive: $pushToListScene) { EmptyView() } - NavigationLink(destination: BookmarkScene(viewModel: .init(container: viewModel.container)), isActive: $pushToBookmarkScene) { EmptyView() } } ) .navigationBarTitleDisplayMode(.inline) @@ -71,15 +69,14 @@ struct TimetableScene: View, Sendable { } NavBarButton(imageName: "nav.share") { - self.screenshot = timetable.takeScreenshot(size: reader.size, preferredColorScheme: colorScheme) + screenshot = self.timetable.takeScreenshot(size: reader.size, preferredColorScheme: colorScheme) isShareSheetOpened = true } - NavBarButton(imageName: "nav.bookmark") { - pushToBookmarkScene = true - isNewToBookmark = false + NavBarButton(imageName: "nav.alarm.off") { + pushToNotiScene = true } - .circleBadge(condition: isNewToBookmark) + .circleBadge(condition: viewModel.unreadCount > 0) } } } @@ -87,6 +84,14 @@ struct TimetableScene: View, Sendable { ActivityViewController(activityItems: [screenshot, linkMetadata]) } } + .background { + NavigationLink(destination: NotificationList(viewModel: .init(container: viewModel.container)), + isActive: $pushToNotiScene) { EmptyView() } + } + .background { + NavigationLink(destination: NotificationList(viewModel: .init(container: viewModel.container)), + isActive: $viewModel.routingState.pushToNotification) { EmptyView() } + } } } @@ -124,11 +129,3 @@ private final class LinkMetadata: NSObject, UIActivityItemSource { return linkMetadata } } - -// struct MyTimetableScene_Previews: PreviewProvider { -// static var previews: some View { -// NavigationView { -// TimetableScene(viewModel: .init(container: .preview)) -// } -// } -// }